Refactor euph message content highlighting
This commit is contained in:
parent
17185ea536
commit
8040b82ff1
3 changed files with 210 additions and 201 deletions
|
|
@ -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
203
cove/src/euph/highlight.rs
Normal 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)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue