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 small_message::*;
|
||||
pub use util::*;
|
||||
|
||||
mod highlight;
|
||||
mod room;
|
||||
mod small_message;
|
||||
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 euphoxide::api::{MessageId, Snowflake, Time};
|
||||
use jiff::Timestamp;
|
||||
|
|
@ -7,200 +5,6 @@ use toss::{Style, Styled};
|
|||
|
||||
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)]
|
||||
pub struct SmallMessage {
|
||||
pub id: MessageId,
|
||||
|
|
@ -221,22 +25,22 @@ fn style_me() -> Style {
|
|||
|
||||
fn styled_nick(nick: &str) -> Styled {
|
||||
Styled::new_plain("[")
|
||||
.and_then(util::style_nick(nick, Style::new()))
|
||||
.and_then(super::style_nick(nick, Style::new()))
|
||||
.then_plain("]")
|
||||
}
|
||||
|
||||
fn styled_nick_me(nick: &str) -> Styled {
|
||||
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 {
|
||||
highlight_content(content.trim(), Style::new(), false)
|
||||
super::highlight(content.trim(), Style::new(), false)
|
||||
}
|
||||
|
||||
fn styled_content_me(content: &str) -> Styled {
|
||||
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 {
|
||||
|
|
@ -245,7 +49,7 @@ fn styled_editor_content(content: &str) -> Styled {
|
|||
} else {
|
||||
Style::new()
|
||||
};
|
||||
highlight_content(content, style, true)
|
||||
super::highlight(content, style, true)
|
||||
}
|
||||
|
||||
impl Msg for SmallMessage {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue