flare: add patched flare-signal with five local feature patches
- patches/flare/000{1..5}-*.patch: typing indicators, formatted
messages, edited messages, multi-select with delete-for-me, and
in-channel message search. Mirror the matching commits in
~/projects/forks/flare and apply cleanly on top of upstream 0.20.4
(which is what nixpkgs ships).
- home/profiles/gui.nix: include a flare-signal override that appends
the patches via overrideAttrs. None of them touch Cargo.lock so the
cargoDeps hash stays valid; signal-desktop stays alongside it.
This commit is contained in:
@@ -0,0 +1,622 @@
|
||||
From 45b21cee00bfc5545aea6fbc9a4f991cfd781cff Mon Sep 17 00:00:00 2001
|
||||
From: Simon Gardling <titaniumtown@proton.me>
|
||||
Date: Wed, 29 Apr 2026 19:13:52 -0400
|
||||
Subject: [PATCH 2/6] feat(messages): Implement formatted messages
|
||||
|
||||
- Display Signal BodyRange styles (bold, italic, strikethrough,
|
||||
spoiler, monospace) on incoming messages by translating them into
|
||||
pango attributes alongside the existing mention rendering, making
|
||||
the offset accounting work for mention substitutions and
|
||||
surrogate-pair text alike.
|
||||
- Parse a markdown-style formatting syntax on outbound messages and
|
||||
send the resulting BodyRanges with the cleaned body text. The
|
||||
parser lives in its own module with unit tests covering the
|
||||
supported markers, nesting, unmatched markers, and non-BMP UTF-16
|
||||
offsets.
|
||||
- Update the message-input tooltip to surface the supported markers.
|
||||
---
|
||||
CHANGELOG.md | 2 +
|
||||
data/resources/ui/channel_messages.blp | 2 +-
|
||||
src/backend/message/formatting.rs | 287 +++++++++++++++++++++++++
|
||||
src/backend/message/mod.rs | 2 +
|
||||
src/backend/message/text_message.rs | 200 +++++++++++++----
|
||||
5 files changed, 447 insertions(+), 46 deletions(-)
|
||||
create mode 100644 src/backend/message/formatting.rs
|
||||
|
||||
diff --git a/CHANGELOG.md b/CHANGELOG.md
|
||||
index 2bde927..50cd5f5 100644
|
||||
--- a/CHANGELOG.md
|
||||
+++ b/CHANGELOG.md
|
||||
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Send typing indicators while composing a message and display them above the message input.
|
||||
- Settings to enable or disable sending and showing typing indicators.
|
||||
+- Render formatted message styles (bold, italic, strikethrough, spoiler, monospace) on incoming messages.
|
||||
+- Send formatted messages with markdown-style markers (`**bold**`, `*italic*`, `~~strike~~`, `||spoiler||`, `` `monospace` ``).
|
||||
|
||||
## [0.20.4] - 2026-04-22
|
||||
|
||||
diff --git a/data/resources/ui/channel_messages.blp b/data/resources/ui/channel_messages.blp
|
||||
index 7f438e4..6c3948f 100644
|
||||
--- a/data/resources/ui/channel_messages.blp
|
||||
+++ b/data/resources/ui/channel_messages.blp
|
||||
@@ -301,7 +301,7 @@ template $FlChannelMessages: Box {
|
||||
activate => $send_message() swapped;
|
||||
paste-file => $paste_file() swapped;
|
||||
paste-texture => $paste_texture() swapped;
|
||||
- tooltip-text: C_("tooltip", "Message input");
|
||||
+ tooltip-text: C_("tooltip", "Message input. Use **bold**, *italic*, ~~strike~~, ||spoiler|| or `monospace` to format text.");
|
||||
}
|
||||
|
||||
Button button_send {
|
||||
diff --git a/src/backend/message/formatting.rs b/src/backend/message/formatting.rs
|
||||
new file mode 100644
|
||||
index 0000000..5a1d596
|
||||
--- /dev/null
|
||||
+++ b/src/backend/message/formatting.rs
|
||||
@@ -0,0 +1,287 @@
|
||||
+//! Lightweight markdown-style formatting parser for outgoing messages.
|
||||
+//!
|
||||
+//! Supported syntax (mirroring the way Signal Desktop and iOS render
|
||||
+//! formatted messages):
|
||||
+//!
|
||||
+//! - `**text**` for bold
|
||||
+//! - `*text*` or `_text_` for italic
|
||||
+//! - `~~text~~` for strikethrough
|
||||
+//! - `||text||` for spoiler
|
||||
+//! - `` `text` `` for monospace
|
||||
+//!
|
||||
+//! Parsing is forgiving: any marker without a matching counterpart is left
|
||||
+//! verbatim in the resulting text. Markers may nest as long as the inner
|
||||
+//! marker is a different kind from the outer one.
|
||||
+//!
|
||||
+//! The function returns the cleaned message body plus the corresponding
|
||||
+//! `BodyRange`s with offsets in UTF-16 code units, as required by the
|
||||
+//! Signal protocol.
|
||||
+
|
||||
+use std::collections::HashMap;
|
||||
+
|
||||
+use libsignal_service::proto::BodyRange;
|
||||
+use libsignal_service::proto::body_range::{AssociatedValue, Style as BodyRangeStyle};
|
||||
+
|
||||
+#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)]
|
||||
+enum Marker {
|
||||
+ Bold,
|
||||
+ Italic,
|
||||
+ Strikethrough,
|
||||
+ Spoiler,
|
||||
+ Monospace,
|
||||
+}
|
||||
+
|
||||
+impl Marker {
|
||||
+ fn style(self) -> BodyRangeStyle {
|
||||
+ match self {
|
||||
+ Marker::Bold => BodyRangeStyle::Bold,
|
||||
+ Marker::Italic => BodyRangeStyle::Italic,
|
||||
+ Marker::Strikethrough => BodyRangeStyle::Strikethrough,
|
||||
+ Marker::Spoiler => BodyRangeStyle::Spoiler,
|
||||
+ Marker::Monospace => BodyRangeStyle::Monospace,
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// Try to consume a marker starting at `chars[i]` and return its kind plus
|
||||
+/// the number of characters that make up the marker token.
|
||||
+fn detect_marker(chars: &[char], i: usize) -> Option<(Marker, usize)> {
|
||||
+ let cur = *chars.get(i)?;
|
||||
+ let next = chars.get(i + 1).copied();
|
||||
+ match (cur, next) {
|
||||
+ ('*', Some('*')) => Some((Marker::Bold, 2)),
|
||||
+ ('~', Some('~')) => Some((Marker::Strikethrough, 2)),
|
||||
+ ('|', Some('|')) => Some((Marker::Spoiler, 2)),
|
||||
+ ('*', _) | ('_', _) => Some((Marker::Italic, 1)),
|
||||
+ ('`', _) => Some((Marker::Monospace, 1)),
|
||||
+ _ => None,
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[derive(Debug, Clone, Copy)]
|
||||
+struct MatchedSpan {
|
||||
+ marker: Marker,
|
||||
+ open_pos: usize,
|
||||
+ close_pos: usize,
|
||||
+ marker_len: usize,
|
||||
+}
|
||||
+
|
||||
+/// Walk the character stream left-to-right and pair markers of the same
|
||||
+/// kind. The first occurrence opens a span, the next occurrence of the same
|
||||
+/// kind closes it; markers without a partner are simply ignored.
|
||||
+fn detect_matched_markers(chars: &[char]) -> Vec<MatchedSpan> {
|
||||
+ let mut open: HashMap<Marker, (usize, usize)> = HashMap::new();
|
||||
+ let mut spans: Vec<MatchedSpan> = Vec::new();
|
||||
+ let mut i = 0;
|
||||
+ while i < chars.len() {
|
||||
+ if let Some((marker, len)) = detect_marker(chars, i) {
|
||||
+ if let Some((open_pos, marker_len)) = open.remove(&marker) {
|
||||
+ spans.push(MatchedSpan {
|
||||
+ marker,
|
||||
+ open_pos,
|
||||
+ close_pos: i,
|
||||
+ marker_len,
|
||||
+ });
|
||||
+ } else {
|
||||
+ open.insert(marker, (i, len));
|
||||
+ }
|
||||
+ i += len;
|
||||
+ } else {
|
||||
+ i += 1;
|
||||
+ }
|
||||
+ }
|
||||
+ spans
|
||||
+}
|
||||
+
|
||||
+/// Parse markdown-style formatting markers in `input` and produce the cleaned
|
||||
+/// text plus the corresponding Signal [BodyRange]s with UTF-16 offsets.
|
||||
+///
|
||||
+/// Empty matched spans (e.g. `**` followed immediately by `**`) are dropped.
|
||||
+pub fn parse_formatting(input: &str) -> (String, Vec<BodyRange>) {
|
||||
+ let chars: Vec<char> = input.chars().collect();
|
||||
+ let spans = detect_matched_markers(&chars);
|
||||
+
|
||||
+ if spans.is_empty() {
|
||||
+ return (input.to_owned(), Vec::new());
|
||||
+ }
|
||||
+
|
||||
+ // Mark which character positions are part of a matched marker token and
|
||||
+ // therefore must be removed from the cleaned output.
|
||||
+ let mut skip = vec![false; chars.len()];
|
||||
+ for sp in &spans {
|
||||
+ for k in sp.open_pos..(sp.open_pos + sp.marker_len).min(chars.len()) {
|
||||
+ skip[k] = true;
|
||||
+ }
|
||||
+ for k in sp.close_pos..(sp.close_pos + sp.marker_len).min(chars.len()) {
|
||||
+ skip[k] = true;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Build the cleaned output and a per-input-char map into the output's
|
||||
+ // UTF-16 code-unit offset.
|
||||
+ let mut output = String::with_capacity(input.len());
|
||||
+ let mut input_to_output_utf16 = vec![0u32; chars.len() + 1];
|
||||
+ let mut utf16_count: u32 = 0;
|
||||
+ for (i, c) in chars.iter().enumerate() {
|
||||
+ input_to_output_utf16[i] = utf16_count;
|
||||
+ if !skip[i] {
|
||||
+ output.push(*c);
|
||||
+ utf16_count += c.len_utf16() as u32;
|
||||
+ }
|
||||
+ }
|
||||
+ input_to_output_utf16[chars.len()] = utf16_count;
|
||||
+
|
||||
+ let mut ranges: Vec<BodyRange> = Vec::with_capacity(spans.len());
|
||||
+ for sp in spans {
|
||||
+ let start = input_to_output_utf16[sp.open_pos + sp.marker_len];
|
||||
+ let end = input_to_output_utf16[sp.close_pos];
|
||||
+ if end <= start {
|
||||
+ continue;
|
||||
+ }
|
||||
+ ranges.push(BodyRange {
|
||||
+ start: Some(start),
|
||||
+ length: Some(end - start),
|
||||
+ associated_value: Some(AssociatedValue::Style(sp.marker.style() as i32)),
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
+ // Sort by start so the final ranges are stable for tests and for
|
||||
+ // downstream consumers that expect ordered ranges.
|
||||
+ ranges.sort_by_key(|r| r.start);
|
||||
+
|
||||
+ (output, ranges)
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ fn ranges_summary(ranges: &[BodyRange]) -> Vec<(u32, u32, BodyRangeStyle)> {
|
||||
+ ranges
|
||||
+ .iter()
|
||||
+ .map(|r| {
|
||||
+ let style = match r.associated_value {
|
||||
+ Some(AssociatedValue::Style(s)) => {
|
||||
+ BodyRangeStyle::try_from(s).unwrap_or(BodyRangeStyle::None)
|
||||
+ }
|
||||
+ _ => BodyRangeStyle::None,
|
||||
+ };
|
||||
+ (r.start.unwrap_or(0), r.length.unwrap_or(0), style)
|
||||
+ })
|
||||
+ .collect()
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn no_markers() {
|
||||
+ let (text, ranges) = parse_formatting("hello world");
|
||||
+ assert_eq!(text, "hello world");
|
||||
+ assert!(ranges.is_empty());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn bold() {
|
||||
+ let (text, ranges) = parse_formatting("**bold**");
|
||||
+ assert_eq!(text, "bold");
|
||||
+ assert_eq!(ranges_summary(&ranges), vec![(0, 4, BodyRangeStyle::Bold)]);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn italic_asterisk() {
|
||||
+ let (text, ranges) = parse_formatting("*italic*");
|
||||
+ assert_eq!(text, "italic");
|
||||
+ assert_eq!(
|
||||
+ ranges_summary(&ranges),
|
||||
+ vec![(0, 6, BodyRangeStyle::Italic)]
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn italic_underscore() {
|
||||
+ let (text, ranges) = parse_formatting("_italic_");
|
||||
+ assert_eq!(text, "italic");
|
||||
+ assert_eq!(
|
||||
+ ranges_summary(&ranges),
|
||||
+ vec![(0, 6, BodyRangeStyle::Italic)]
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn strikethrough() {
|
||||
+ let (text, ranges) = parse_formatting("~~strike~~");
|
||||
+ assert_eq!(text, "strike");
|
||||
+ assert_eq!(
|
||||
+ ranges_summary(&ranges),
|
||||
+ vec![(0, 6, BodyRangeStyle::Strikethrough)]
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn spoiler() {
|
||||
+ let (text, ranges) = parse_formatting("||hidden||");
|
||||
+ assert_eq!(text, "hidden");
|
||||
+ assert_eq!(
|
||||
+ ranges_summary(&ranges),
|
||||
+ vec![(0, 6, BodyRangeStyle::Spoiler)]
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn monospace() {
|
||||
+ let (text, ranges) = parse_formatting("`code`");
|
||||
+ assert_eq!(text, "code");
|
||||
+ assert_eq!(
|
||||
+ ranges_summary(&ranges),
|
||||
+ vec![(0, 4, BodyRangeStyle::Monospace)]
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn bold_and_italic_nested() {
|
||||
+ let (text, ranges) = parse_formatting("**bold *italic***");
|
||||
+ assert_eq!(text, "bold italic");
|
||||
+ let summary = ranges_summary(&ranges);
|
||||
+ assert!(summary.contains(&(0, 11, BodyRangeStyle::Bold)));
|
||||
+ assert!(summary.contains(&(5, 6, BodyRangeStyle::Italic)));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn unmatched_open_left_literal() {
|
||||
+ let (text, ranges) = parse_formatting("**only one start");
|
||||
+ assert_eq!(text, "**only one start");
|
||||
+ assert!(ranges.is_empty());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn surrounding_text_preserved() {
|
||||
+ let (text, ranges) = parse_formatting("hello **world**!");
|
||||
+ assert_eq!(text, "hello world!");
|
||||
+ assert_eq!(ranges_summary(&ranges), vec![(6, 5, BodyRangeStyle::Bold)]);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn multiple_pairs() {
|
||||
+ let (text, ranges) = parse_formatting("**a**b**c**");
|
||||
+ assert_eq!(text, "abc");
|
||||
+ let summary = ranges_summary(&ranges);
|
||||
+ assert_eq!(summary.len(), 2);
|
||||
+ assert_eq!(summary[0], (0, 1, BodyRangeStyle::Bold));
|
||||
+ assert_eq!(summary[1], (2, 1, BodyRangeStyle::Bold));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn empty_pair_dropped() {
|
||||
+ let (text, ranges) = parse_formatting("****");
|
||||
+ assert_eq!(text, "");
|
||||
+ assert!(ranges.is_empty());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn utf16_offsets_for_non_bmp() {
|
||||
+ // Character "𝟚" (U+1D7DA) is a non-BMP codepoint occupying two
|
||||
+ // UTF-16 code units, so a Bold range over a string containing it
|
||||
+ // must reflect that in its `length`.
|
||||
+ let (text, ranges) = parse_formatting("**𝟚**");
|
||||
+ assert_eq!(text, "𝟚");
|
||||
+ assert_eq!(ranges_summary(&ranges), vec![(0, 2, BodyRangeStyle::Bold)]);
|
||||
+ }
|
||||
+}
|
||||
diff --git a/src/backend/message/mod.rs b/src/backend/message/mod.rs
|
||||
index 74952ac..4e0f584 100644
|
||||
--- a/src/backend/message/mod.rs
|
||||
+++ b/src/backend/message/mod.rs
|
||||
@@ -1,12 +1,14 @@
|
||||
mod call_message;
|
||||
mod deletion_message;
|
||||
mod display_message;
|
||||
+mod formatting;
|
||||
mod reaction_message;
|
||||
mod text_message;
|
||||
|
||||
pub use call_message::{CallMessage, CallMessageType};
|
||||
pub use deletion_message::DeletionMessage;
|
||||
pub use display_message::{DisplayMessage, DisplayMessageExt};
|
||||
+pub use formatting::parse_formatting;
|
||||
pub use reaction_message::ReactionMessage;
|
||||
pub use text_message::TextMessage;
|
||||
|
||||
diff --git a/src/backend/message/text_message.rs b/src/backend/message/text_message.rs
|
||||
index a9adb04..c06bcfa 100644
|
||||
--- a/src/backend/message/text_message.rs
|
||||
+++ b/src/backend/message/text_message.rs
|
||||
@@ -2,9 +2,9 @@ use crate::prelude::*;
|
||||
|
||||
use libsignal_service::content::Reaction;
|
||||
use libsignal_service::proto::DataMessage;
|
||||
-use libsignal_service::proto::body_range::AssociatedValue;
|
||||
+use libsignal_service::proto::body_range::{AssociatedValue, Style as BodyRangeStyle};
|
||||
use libsignal_service::proto::data_message::Delete;
|
||||
-use pango::{AttrColor, AttrList};
|
||||
+use pango::{AttrColor, AttrInt, AttrList, AttrString, Style as PangoStyle, Weight};
|
||||
|
||||
use crate::backend::timeline::{TimelineItem, TimelineItemExt};
|
||||
use crate::backend::{Attachment, Channel, Contact};
|
||||
@@ -19,6 +19,48 @@ gtk::glib::wrapper! {
|
||||
const MENTION_CHAR: char = '@';
|
||||
const MENTION_COLOR: (u16, u16, u16) = (0, 0, u16::MAX);
|
||||
|
||||
+/// Convert a Signal [BodyRangeStyle] into the pango attributes that render
|
||||
+/// the same visual style. Spoilers are approximated as a black-on-black
|
||||
+/// span as pango has no native spoiler primitive.
|
||||
+fn style_to_pango_attrs(
|
||||
+ style: BodyRangeStyle,
|
||||
+ start_byte: u32,
|
||||
+ end_byte: u32,
|
||||
+) -> Vec<pango::Attribute> {
|
||||
+ fn span<A: Into<pango::Attribute>>(attr: A, start: u32, end: u32) -> pango::Attribute {
|
||||
+ let mut attr: pango::Attribute = attr.into();
|
||||
+ attr.set_start_index(start);
|
||||
+ attr.set_end_index(end);
|
||||
+ attr
|
||||
+ }
|
||||
+
|
||||
+ match style {
|
||||
+ BodyRangeStyle::Bold => vec![span(
|
||||
+ AttrInt::new_weight(Weight::Bold),
|
||||
+ start_byte,
|
||||
+ end_byte,
|
||||
+ )],
|
||||
+ BodyRangeStyle::Italic => vec![span(
|
||||
+ AttrInt::new_style(PangoStyle::Italic),
|
||||
+ start_byte,
|
||||
+ end_byte,
|
||||
+ )],
|
||||
+ BodyRangeStyle::Strikethrough => {
|
||||
+ vec![span(AttrInt::new_strikethrough(true), start_byte, end_byte)]
|
||||
+ }
|
||||
+ BodyRangeStyle::Monospace => vec![span(
|
||||
+ AttrString::new_family("monospace"),
|
||||
+ start_byte,
|
||||
+ end_byte,
|
||||
+ )],
|
||||
+ BodyRangeStyle::Spoiler => vec![
|
||||
+ span(AttrColor::new_foreground(0, 0, 0), start_byte, end_byte),
|
||||
+ span(AttrColor::new_background(0, 0, 0), start_byte, end_byte),
|
||||
+ ],
|
||||
+ BodyRangeStyle::None => Vec::new(),
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
impl TextMessage {
|
||||
pub fn from_text_channel_sender<S: AsRef<str>>(
|
||||
text: S,
|
||||
@@ -65,14 +107,16 @@ impl TextMessage {
|
||||
.build();
|
||||
|
||||
let text_owned = text.as_ref().to_owned();
|
||||
- let body = if text_owned.is_empty() {
|
||||
- None
|
||||
+ let (body, body_ranges) = if text_owned.is_empty() {
|
||||
+ (None, Vec::new())
|
||||
} else {
|
||||
- Some(text_owned)
|
||||
+ let (cleaned, ranges) = super::parse_formatting(&text_owned);
|
||||
+ (Some(cleaned), ranges)
|
||||
};
|
||||
|
||||
let message = DataMessage {
|
||||
body,
|
||||
+ body_ranges,
|
||||
timestamp: Some(timestamp),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -245,10 +289,17 @@ impl TextMessage {
|
||||
self.notify_body();
|
||||
}
|
||||
|
||||
- /// Formats the message body based on its ranges, e.g. to insert mention names.
|
||||
+ /// Format the message body based on its body ranges.
|
||||
+ ///
|
||||
+ /// This both substitutes mentions with the resolved participant name and
|
||||
+ /// applies styling (bold, italic, monospace, strikethrough, spoiler) as
|
||||
+ /// pango attributes on the resulting text.
|
||||
///
|
||||
- /// Returns the resulting strings and an [AttrList] that can be used in labels to highlight areas.
|
||||
- /// Be carefull when editing this function and note that Signal uses UTF-16 byte offsets, while Rust uses UTF-8 byte offsets.
|
||||
+ /// Note that Signal uses UTF-16 byte offsets, while Rust strings use
|
||||
+ /// UTF-8. The implementation maintains an explicit per-utf16-index
|
||||
+ /// mapping into the resulting UTF-8 string so that styles applied to a
|
||||
+ /// range that survives a mention substitution still land on the right
|
||||
+ /// bytes.
|
||||
async fn format_body(&self) -> (Option<String>, AttrList) {
|
||||
let Some(body) = self.internal_data().and_then(|m| m.body) else {
|
||||
return (None, AttrList::new());
|
||||
@@ -264,53 +315,112 @@ impl TextMessage {
|
||||
|
||||
let channel = self.channel();
|
||||
|
||||
- // Sort by growing start index
|
||||
+ // Sort by growing start index so mention substitutions happen left-to-right.
|
||||
ranges.sort_unstable_by_key(|r| r.start());
|
||||
|
||||
- let attrs = AttrList::new();
|
||||
-
|
||||
- // Signal (Java) uses UTF-16 body and therefore also UTF-16 offsets, while Flare (Rust) uses UTF-8. Need to convert.
|
||||
- let body_utf16: Vec<u16> = body.encode_utf16().collect();
|
||||
-
|
||||
- let mut result_utf8 = String::new();
|
||||
- let mut index_utf16 = 0;
|
||||
- let mut index_utf8 = 0;
|
||||
- for r in ranges {
|
||||
- let start = r.start() as usize;
|
||||
- let end = start + r.length() as usize;
|
||||
- let uuid = match r.associated_value {
|
||||
+ // Resolve mention names asynchronously up front so the rest of the
|
||||
+ // formatting can be a synchronous walk.
|
||||
+ let mut mentions: Vec<(usize, usize, String)> = Vec::new();
|
||||
+ for r in &ranges {
|
||||
+ let uuid = match &r.associated_value {
|
||||
Some(AssociatedValue::MentionAci(u)) => u.parse().ok(),
|
||||
Some(AssociatedValue::MentionAciBinary(u)) => {
|
||||
- u.try_into().ok().map(Uuid::from_bytes)
|
||||
+ u.clone().try_into().ok().map(Uuid::from_bytes)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
- let Some(uuid) = uuid else {
|
||||
+ if let Some(uuid) = uuid {
|
||||
+ let start = r.start() as usize;
|
||||
+ let end = (r.start() + r.length()) as usize;
|
||||
+ let name = format!(
|
||||
+ "{}{}",
|
||||
+ MENTION_CHAR,
|
||||
+ channel.participant_by_uuid(uuid).await.title()
|
||||
+ );
|
||||
+ mentions.push((start, end, name));
|
||||
+ }
|
||||
+ }
|
||||
+ // Mentions cannot overlap each other; ensure the iterator order is stable.
|
||||
+ mentions.sort_unstable_by_key(|(s, _, _)| *s);
|
||||
+
|
||||
+ let body_utf16: Vec<u16> = body.encode_utf16().collect();
|
||||
+ let attrs = AttrList::new();
|
||||
+
|
||||
+ // Build the result string while constructing a per-utf16-index map
|
||||
+ // into the resulting UTF-8 byte offsets.
|
||||
+ let mut byte_at: Vec<usize> = Vec::with_capacity(body_utf16.len() + 1);
|
||||
+ let mut result_utf8 = String::new();
|
||||
+ let mut mention_iter = mentions.into_iter().peekable();
|
||||
+
|
||||
+ let mut i = 0;
|
||||
+ while i < body_utf16.len() {
|
||||
+ // Inject mention substitutions at their start position.
|
||||
+ if mention_iter
|
||||
+ .peek()
|
||||
+ .is_some_and(|(m_start, _, _)| *m_start == i)
|
||||
+ {
|
||||
+ let (m_start, m_end, name) = mention_iter.next().expect("peeked entry to exist");
|
||||
+ let mention_byte_start = result_utf8.len();
|
||||
+ // Mark every UTF-16 index inside the mention span as the start
|
||||
+ // of the substituted text. Indices >= m_end will be filled by
|
||||
+ // subsequent iterations.
|
||||
+ for _ in m_start..m_end {
|
||||
+ byte_at.push(mention_byte_start);
|
||||
+ }
|
||||
+ result_utf8.push_str(&name);
|
||||
+
|
||||
+ let mut highlight =
|
||||
+ AttrColor::new_foreground(MENTION_COLOR.0, MENTION_COLOR.1, MENTION_COLOR.2);
|
||||
+ highlight.set_start_index(mention_byte_start as u32);
|
||||
+ highlight.set_end_index(result_utf8.len() as u32);
|
||||
+ attrs.insert(highlight);
|
||||
+
|
||||
+ i = m_end.min(body_utf16.len());
|
||||
continue;
|
||||
- };
|
||||
- let name = format!(
|
||||
- "{}{}",
|
||||
- MENTION_CHAR,
|
||||
- channel.participant_by_uuid(uuid).await.title()
|
||||
- );
|
||||
- let to_add_body = String::from_utf16_lossy(&body_utf16[index_utf16..start]);
|
||||
- result_utf8.push_str(&to_add_body);
|
||||
- result_utf8.push_str(&name);
|
||||
- index_utf16 = end;
|
||||
-
|
||||
- let index_start_highlight = index_utf8 + to_add_body.len();
|
||||
- index_utf8 += to_add_body.len() + name.len();
|
||||
- let index_end_highlight = index_utf8;
|
||||
-
|
||||
- let (red, green, blue) = MENTION_COLOR;
|
||||
- let mut highlight = AttrColor::new_foreground(red, green, blue);
|
||||
- highlight.set_start_index(index_start_highlight as u32);
|
||||
- highlight.set_end_index(index_end_highlight as u32);
|
||||
- attrs.insert(highlight);
|
||||
+ }
|
||||
+
|
||||
+ byte_at.push(result_utf8.len());
|
||||
+ let unit = body_utf16[i];
|
||||
+ if (0xD800..=0xDBFF).contains(&unit) && i + 1 < body_utf16.len() {
|
||||
+ // High surrogate: consume the pair as one codepoint.
|
||||
+ let pair = [unit, body_utf16[i + 1]];
|
||||
+ let decoded = char::decode_utf16(pair.iter().copied())
|
||||
+ .next()
|
||||
+ .and_then(|r| r.ok())
|
||||
+ .unwrap_or('\u{FFFD}');
|
||||
+ result_utf8.push(decoded);
|
||||
+ byte_at.push(result_utf8.len());
|
||||
+ i += 2;
|
||||
+ } else {
|
||||
+ let decoded = char::decode_utf16([unit].iter().copied())
|
||||
+ .next()
|
||||
+ .and_then(|r| r.ok())
|
||||
+ .unwrap_or('\u{FFFD}');
|
||||
+ result_utf8.push(decoded);
|
||||
+ i += 1;
|
||||
+ }
|
||||
}
|
||||
+ byte_at.push(result_utf8.len());
|
||||
|
||||
- if index_utf16 < body_utf16.len() {
|
||||
- result_utf8.push_str(&String::from_utf16_lossy(&body_utf16[index_utf16..]))
|
||||
+ // Apply style ranges using the byte-offset map.
|
||||
+ for r in ranges {
|
||||
+ let Some(AssociatedValue::Style(s)) = r.associated_value else {
|
||||
+ continue;
|
||||
+ };
|
||||
+ let style = match BodyRangeStyle::try_from(s) {
|
||||
+ Ok(BodyRangeStyle::None) | Err(_) => continue,
|
||||
+ Ok(other) => other,
|
||||
+ };
|
||||
+ let start_utf16 = (r.start() as usize).min(byte_at.len() - 1);
|
||||
+ let end_utf16 = ((r.start() + r.length()) as usize).min(byte_at.len() - 1);
|
||||
+ if start_utf16 >= end_utf16 {
|
||||
+ continue;
|
||||
+ }
|
||||
+ let start_byte = byte_at[start_utf16] as u32;
|
||||
+ let end_byte = byte_at[end_utf16] as u32;
|
||||
+ for attr in style_to_pango_attrs(style, start_byte, end_byte) {
|
||||
+ attrs.insert(attr);
|
||||
+ }
|
||||
}
|
||||
|
||||
(Some(result_utf8), attrs)
|
||||
--
|
||||
2.53.0
|
||||
|
||||
Reference in New Issue
Block a user