Refactor euph message content highlighting

This commit is contained in:
Joscha 2025-02-23 20:22:04 +01:00
parent 17185ea536
commit 8040b82ff1
3 changed files with 210 additions and 201 deletions

View file

@ -1,7 +1,9 @@
pub use highlight::*;
pub use room::*; pub use room::*;
pub use small_message::*; pub use small_message::*;
pub use util::*; pub use util::*;
mod highlight;
mod room; mod room;
mod small_message; mod small_message;
mod util; mod util;

203
cove/src/euph/highlight.rs Normal file
View file

@ -0,0 +1,203 @@
use std::ops::Range;
use crossterm::style::Stylize;
use toss::{Style, Styled};
use crate::euph::util;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpanType {
Mention,
Room,
Emoji,
}
fn nick_char(ch: char) -> bool {
// Closely following the heim mention regex:
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15
// `>` has been experimentally confirmed to delimit mentions as well.
match ch {
',' | '.' | '!' | '?' | ';' | '&' | '<' | '>' | '\'' | '"' => false,
_ => !ch.is_whitespace(),
}
}
fn room_char(ch: char) -> bool {
// Basically just \w, see also
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66
ch.is_ascii_alphanumeric() || ch == '_'
}
struct SpanFinder<'a> {
content: &'a str,
span: Option<(SpanType, usize)>,
room_or_mention_possible: bool,
result: Vec<(SpanType, Range<usize>)>,
}
impl<'a> SpanFinder<'a> {
fn is_valid_span(&self, span: SpanType, range: Range<usize>) -> bool {
let text = &self.content[range.start..range.end];
match span {
SpanType::Mention => range.len() > 1 && text.starts_with('@'),
SpanType::Room => range.len() > 1 && text.starts_with('&'),
SpanType::Emoji => {
if range.len() <= 2 {
return false;
}
let Some(name) = Some(text)
.and_then(|it| it.strip_prefix(':'))
.and_then(|it| it.strip_suffix(':'))
else {
return false;
};
util::EMOJI.get(name).is_some()
}
}
}
fn close_span(&mut self, end: usize) {
let Some((span, start)) = self.span else {
return;
};
if self.is_valid_span(span, start..end) {
self.result.push((span, start..end));
}
self.span = None;
}
fn open_span(&mut self, span: SpanType, start: usize) {
self.close_span(start);
self.span = Some((span, start))
}
fn step(&mut self, idx: usize, char: char) {
match (char, self.span) {
('@', _) if self.room_or_mention_possible => self.open_span(SpanType::Mention, idx),
('&', _) if self.room_or_mention_possible => self.open_span(SpanType::Room, idx),
(':', None) => self.open_span(SpanType::Emoji, idx),
(':', Some((SpanType::Emoji, _))) => self.close_span(idx + 1),
(c, Some((SpanType::Mention, _))) if !nick_char(c) => self.close_span(idx),
(c, Some((SpanType::Room, _))) if !room_char(c) => self.close_span(idx),
_ => {}
}
// More permissive than the heim web client
self.room_or_mention_possible = !char.is_alphanumeric();
}
fn find(content: &'a str) -> Vec<(SpanType, Range<usize>)> {
let mut this = Self {
content,
span: None,
room_or_mention_possible: true,
result: vec![],
};
for (idx, char) in content.char_indices() {
this.step(idx, char);
}
this.close_span(content.len());
this.result
}
}
pub fn find_spans(content: &str) -> Vec<(SpanType, Range<usize>)> {
SpanFinder::find(content)
}
/// Highlight spans in a string.
///
/// The list of spans must be non-overlapping and in ascending order.
///
/// If `exact` is specified, colon-delimited emoji are not replaced with their
/// unicode counterparts.
pub fn apply_spans(
content: &str,
spans: &[(SpanType, Range<usize>)],
base: Style,
exact: bool,
) -> Styled {
let mut result = Styled::default();
let mut i = 0;
for (span, range) in spans {
assert!(i <= range.start);
assert!(range.end <= content.len());
if i < range.start {
result = result.then_plain(&content[i..range.start]);
}
let text = &content[range.start..range.end];
result = match span {
SpanType::Mention if exact => result.and_then(util::style_nick_exact(text, base)),
SpanType::Mention => result.and_then(util::style_nick(text, base)),
SpanType::Room => result.then(text, base.blue().bold()),
SpanType::Emoji if exact => result.then(text, base.magenta()),
SpanType::Emoji => {
let name = text.strip_prefix(':').unwrap_or(text);
let name = name.strip_suffix(':').unwrap_or(name);
if let Some(Some(replacement)) = util::EMOJI.get(name) {
result.then_plain(replacement)
} else {
result.then(name, base.magenta())
}
}
};
i = range.end;
}
if i < content.len() {
result = result.then_plain(&content[i..]);
}
result
}
/// Highlight an euphoria message's content.
///
/// If `exact` is specified, colon-delimited emoji are not replaced with their
/// unicode counterparts.
pub fn highlight(content: &str, base: Style, exact: bool) -> Styled {
apply_spans(content, &find_spans(content), base, exact)
}
#[cfg(test)]
mod tests {
use crate::euph::SpanType;
use super::find_spans;
#[test]
fn mentions() {
assert_eq!(find_spans("@foo"), vec![(SpanType::Mention, 0..4)]);
assert_eq!(find_spans("a @foo b"), vec![(SpanType::Mention, 2..6)]);
assert_eq!(find_spans("@@foo@"), vec![(SpanType::Mention, 1..6)]);
assert_eq!(find_spans("a @b@c d"), vec![(SpanType::Mention, 2..6)]);
assert_eq!(
find_spans("a @b @c d"),
vec![(SpanType::Mention, 2..4), (SpanType::Mention, 5..7)]
);
}
#[test]
fn rooms() {
assert_eq!(find_spans("&foo"), vec![(SpanType::Room, 0..4)]);
assert_eq!(find_spans("a &foo b"), vec![(SpanType::Room, 2..6)]);
assert_eq!(find_spans("&&foo&"), vec![(SpanType::Room, 1..5)]);
assert_eq!(find_spans("a &b&c d"), vec![(SpanType::Room, 2..4)]);
assert_eq!(
find_spans("a &b &c d"),
vec![(SpanType::Room, 2..4), (SpanType::Room, 5..7)]
);
}
}

View file

@ -1,5 +1,3 @@
use std::mem;
use crossterm::style::Stylize; use crossterm::style::Stylize;
use euphoxide::api::{MessageId, Snowflake, Time}; use euphoxide::api::{MessageId, Snowflake, Time};
use jiff::Timestamp; use jiff::Timestamp;
@ -7,200 +5,6 @@ use toss::{Style, Styled};
use crate::{store::Msg, ui::ChatMsg}; use crate::{store::Msg, ui::ChatMsg};
use super::util;
fn nick_char(ch: char) -> bool {
// Closely following the heim mention regex:
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15
// `>` has been experimentally confirmed to delimit mentions as well.
match ch {
',' | '.' | '!' | '?' | ';' | '&' | '<' | '>' | '\'' | '"' => false,
_ => !ch.is_whitespace(),
}
}
fn room_char(ch: char) -> bool {
// Basically just \w, see also
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66
ch.is_ascii_alphanumeric() || ch == '_'
}
enum Span {
Nothing,
Mention,
Room,
Emoji,
}
struct Highlighter<'a> {
content: &'a str,
base_style: Style,
exact: bool,
span: Span,
span_start: usize,
room_or_mention_possible: bool,
result: Styled,
}
impl<'a> Highlighter<'a> {
/// Does *not* guarantee `self.span_start == idx` after running!
fn close_mention(&mut self, idx: usize) {
let span_length = idx.saturating_sub(self.span_start);
if span_length <= 1 {
// We can repurpose the current span
self.span = Span::Nothing;
return;
}
let text = &self.content[self.span_start..idx]; // Includes @
self.result = mem::take(&mut self.result).and_then(if self.exact {
util::style_nick_exact(text, self.base_style)
} else {
util::style_nick(text, self.base_style)
});
self.span = Span::Nothing;
self.span_start = idx;
}
/// Does *not* guarantee `self.span_start == idx` after running!
fn close_room(&mut self, idx: usize) {
let span_length = idx.saturating_sub(self.span_start);
if span_length <= 1 {
// We can repurpose the current span
self.span = Span::Nothing;
return;
}
self.result = mem::take(&mut self.result).then(
&self.content[self.span_start..idx],
self.base_style.blue().bold(),
);
self.span = Span::Nothing;
self.span_start = idx;
}
// Warning: `idx` is the index of the closing colon.
fn close_emoji(&mut self, idx: usize) {
let name = &self.content[self.span_start + 1..idx];
if let Some(replace) = util::EMOJI.get(name) {
match replace {
Some(replace) if !self.exact => {
self.result = mem::take(&mut self.result).then(replace, self.base_style);
}
_ => {
let text = &self.content[self.span_start..=idx];
let style = self.base_style.magenta();
self.result = mem::take(&mut self.result).then(text, style);
}
}
self.span = Span::Nothing;
self.span_start = idx + 1;
} else {
self.close_plain(idx);
self.span = Span::Emoji;
}
}
/// Guarantees `self.span_start == idx` after running.
fn close_plain(&mut self, idx: usize) {
if self.span_start == idx {
// Span has length 0
return;
}
self.result =
mem::take(&mut self.result).then(&self.content[self.span_start..idx], self.base_style);
self.span = Span::Nothing;
self.span_start = idx;
}
fn close_span_before_current_char(&mut self, idx: usize, char: char) {
match self.span {
Span::Mention if !nick_char(char) => self.close_mention(idx),
Span::Room if !room_char(char) => self.close_room(idx),
Span::Emoji if char == '&' || char == '@' => {
self.span = Span::Nothing;
}
_ => {}
}
}
fn update_span_with_current_char(&mut self, idx: usize, char: char) {
match self.span {
Span::Nothing if char == '@' && self.room_or_mention_possible => {
self.close_plain(idx);
self.span = Span::Mention;
}
Span::Nothing if char == '&' && self.room_or_mention_possible => {
self.close_plain(idx);
self.span = Span::Room;
}
Span::Nothing if char == ':' => {
self.close_plain(idx);
self.span = Span::Emoji;
}
Span::Emoji if char == ':' => self.close_emoji(idx),
_ => {}
}
}
fn close_final_span(&mut self) {
let idx = self.content.len();
if self.span_start >= idx {
return; // Span has no contents
}
match self.span {
Span::Mention => self.close_mention(idx),
Span::Room => self.close_room(idx),
_ => {}
}
self.close_plain(idx);
}
fn step(&mut self, idx: usize, char: char) {
if self.span_start < idx {
self.close_span_before_current_char(idx, char);
}
self.update_span_with_current_char(idx, char);
// More permissive than the heim web client
self.room_or_mention_possible = !char.is_alphanumeric();
}
fn highlight(content: &'a str, base_style: Style, exact: bool) -> Styled {
let mut this = Self {
content: if exact { content } else { content.trim() },
base_style,
exact,
span: Span::Nothing,
span_start: 0,
room_or_mention_possible: true,
result: Styled::default(),
};
for (idx, char) in (if exact { content } else { content.trim() }).char_indices() {
this.step(idx, char);
}
this.close_final_span();
this.result
}
}
fn highlight_content(content: &str, base_style: Style, exact: bool) -> Styled {
Highlighter::highlight(content, base_style, exact)
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SmallMessage { pub struct SmallMessage {
pub id: MessageId, pub id: MessageId,
@ -221,22 +25,22 @@ fn style_me() -> Style {
fn styled_nick(nick: &str) -> Styled { fn styled_nick(nick: &str) -> Styled {
Styled::new_plain("[") Styled::new_plain("[")
.and_then(util::style_nick(nick, Style::new())) .and_then(super::style_nick(nick, Style::new()))
.then_plain("]") .then_plain("]")
} }
fn styled_nick_me(nick: &str) -> Styled { fn styled_nick_me(nick: &str) -> Styled {
let style = style_me(); let style = style_me();
Styled::new("*", style).and_then(util::style_nick(nick, style)) Styled::new("*", style).and_then(super::style_nick(nick, style))
} }
fn styled_content(content: &str) -> Styled { fn styled_content(content: &str) -> Styled {
highlight_content(content.trim(), Style::new(), false) super::highlight(content.trim(), Style::new(), false)
} }
fn styled_content_me(content: &str) -> Styled { fn styled_content_me(content: &str) -> Styled {
let style = style_me(); let style = style_me();
highlight_content(content.trim(), style, false).then("*", style) super::highlight(content.trim(), style, false).then("*", style)
} }
fn styled_editor_content(content: &str) -> Styled { fn styled_editor_content(content: &str) -> Styled {
@ -245,7 +49,7 @@ fn styled_editor_content(content: &str) -> Styled {
} else { } else {
Style::new() Style::new()
}; };
highlight_content(content, style, true) super::highlight(content, style, true)
} }
impl Msg for SmallMessage { impl Msg for SmallMessage {