diff --git a/src/app/mod.rs b/src/app/mod.rs index f79500f..b40cfe8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,11 +12,12 @@ use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding}; use crate::presence::PresenceMode; use crate::theme::{AppTheme, Palette}; use crate::widget::context_input::{context_input, locked_value}; +use crate::widget::selectable_text::selectable_rich_text; use iced::widget::{ container, column, row, text, button, scrollable, slider, checkbox, pick_list, radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area, - rich_text, span, responsive, + span, responsive, }; use iced::widget::text_input; use iced::widget::canvas::{Action, Frame, Geometry, Path, Program}; @@ -4670,8 +4671,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { } }) .collect(); - let body = rich_text(spans) + let body = selectable_rich_text(spans) .on_link_click(AppMessage::OpenUrl) + .selection_color(color_blue) .width(iced::Length::Fill); // Small avatar keyed on the sender's id (falls back to name); the // " (You)" suffix on our own echoes is stripped for clean initials. diff --git a/src/widget/mod.rs b/src/widget/mod.rs index a1ead38..4cf7c97 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -1 +1,2 @@ pub mod context_input; +pub mod selectable_text; diff --git a/src/widget/selectable_text.rs b/src/widget/selectable_text.rs new file mode 100644 index 0000000..c015bed --- /dev/null +++ b/src/widget/selectable_text.rs @@ -0,0 +1,890 @@ +use iced::advanced::clipboard::{self, Clipboard}; +use iced::advanced::layout; +use iced::advanced::mouse; +use iced::advanced::renderer; +use iced::advanced::text::{self as advanced_text, Paragraph, Span}; +use iced::advanced::widget::tree::{self, Tree}; +use iced::advanced::widget::Widget; +use iced::advanced::{Layout, Shell}; +use iced::widget::text::{ + self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn, + Wrapping, +}; +use iced::{ + alignment, Background, Border, Color, Element, Event, Length, Pixels, Point, + Rectangle, Size, Vector, keyboard, +}; + +const DRAG_THRESHOLD: f32 = 3.0; +const HIT_SEARCH_STEPS: usize = 24; + +// Offsets here are paragraph-global BYTE offsets. `Paragraph::hit_test` returns +// `Hit::CharOffset(cursor.index)`, and cosmic-text's `cursor.index` is a byte +// offset WITHIN its buffer line — it discards the line number. That equals the +// global byte offset only when the text is a single logical line. Chat bodies +// satisfy this because `app::sanitize_chat` turns every control char (incl. `\n` +// and `\r`) into a space and collapses whitespace, so a stored message can never +// contain a newline. If that sanitizer ever starts preserving newlines, this +// widget's per-line offsets would stop being global and selection/copy across +// lines would break — revisit then. + +pub fn selected_substring( + text: &str, + anchor: usize, + cursor: usize, +) -> Option { + let (start, end) = normalized_byte_range(text, anchor, cursor); + + (start != end).then(|| text[start..end].to_owned()) +} + +pub fn select_all(text: &str) -> (usize, usize) { + (0, text.len()) +} + +fn normalized_byte_range( + text: &str, + anchor: usize, + cursor: usize, +) -> (usize, usize) { + let start = clamp_to_char_boundary(text, anchor.min(cursor)); + let end = clamp_to_char_boundary(text, anchor.max(cursor)); + + (start.min(end), start.max(end)) +} + +fn clamp_to_char_boundary(text: &str, offset: usize) -> usize { + let mut offset = offset.min(text.len()); + + while offset > 0 && !text.is_char_boundary(offset) { + offset -= 1; + } + + offset +} + +pub fn selectable_rich_text<'a, Link, Message, Theme, Renderer>( + spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a, +) -> SelectableRichText<'a, Link, Message, Theme, Renderer> +where + Link: Clone + 'static, + Theme: Catalog + 'a, + Renderer: advanced_text::Renderer, + Renderer::Font: 'a, +{ + SelectableRichText::with_spans(spans) +} + +pub struct SelectableRichText< + 'a, + Link, + Message, + Theme = iced::Theme, + Renderer = iced::Renderer, +> where + Link: Clone + 'static, + Theme: Catalog, + Renderer: advanced_text::Renderer, +{ + spans: Box]> + 'a>, + size: Option, + line_height: LineHeight, + width: Length, + height: Length, + font: Option, + align_x: Alignment, + align_y: alignment::Vertical, + wrapping: Wrapping, + class: Theme::Class<'a>, + hovered_link: Option, + on_link_click: Option Message + 'a>>, + selection_color: Color, +} + +impl<'a, Link, Message, Theme, Renderer> + SelectableRichText<'a, Link, Message, Theme, Renderer> +where + Link: Clone + 'static, + Theme: Catalog, + Renderer: advanced_text::Renderer, + Renderer::Font: 'a, +{ + pub fn new() -> Self { + Self { + spans: Box::new([]), + size: None, + line_height: LineHeight::default(), + width: Length::Shrink, + height: Length::Shrink, + font: None, + align_x: Alignment::Default, + align_y: alignment::Vertical::Top, + wrapping: Wrapping::default(), + class: Theme::default(), + hovered_link: None, + on_link_click: None, + selection_color: Color::from_rgba(0.35, 0.55, 0.95, 0.35), + } + } + + pub fn with_spans( + spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a, + ) -> Self { + Self { + spans: Box::new(spans), + ..Self::new() + } + } + + pub fn size(mut self, size: impl Into) -> Self { + self.size = Some(size.into()); + self + } + + pub fn line_height(mut self, line_height: impl Into) -> Self { + self.line_height = line_height.into(); + self + } + + pub fn font(mut self, font: impl Into) -> Self { + self.font = Some(font.into()); + self + } + + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + pub fn align_x(mut self, alignment: impl Into) -> Self { + self.align_x = alignment.into(); + self + } + + pub fn align_y( + mut self, + alignment: impl Into, + ) -> Self { + self.align_y = alignment.into(); + self + } + + pub fn wrapping(mut self, wrapping: Wrapping) -> Self { + self.wrapping = wrapping; + self + } + + pub fn on_link_click( + mut self, + on_link_click: impl Fn(Link) -> Message + 'a, + ) -> Self { + self.on_link_click = Some(Box::new(on_link_click)); + self + } + + pub fn selection_color(mut self, color: Color) -> Self { + self.selection_color = Color { + a: color.a.min(0.35), + ..color + }; + self + } + + pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self + where + Theme::Class<'a>: From>, + { + self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); + self + } + + pub fn color(self, color: impl Into) -> Self + where + Theme::Class<'a>: From>, + { + self.color_maybe(Some(color)) + } + + pub fn color_maybe(self, color: Option>) -> Self + where + Theme::Class<'a>: From>, + { + let color = color.map(Into::into); + + self.style(move |_theme| Style { color }) + } + + pub fn class(mut self, class: impl Into>) -> Self { + self.class = class.into(); + self + } +} + +impl<'a, Link, Message, Theme, Renderer> Default + for SelectableRichText<'a, Link, Message, Theme, Renderer> +where + Link: Clone + 'static, + Theme: Catalog, + Renderer: advanced_text::Renderer, + Renderer::Font: 'a, +{ + fn default() -> Self { + Self::new() + } +} + +struct SelectableTextState { + spans: Vec>, + span_pressed: Option, + paragraph: P, + selection: Option<(usize, usize)>, + dragging: bool, + active: bool, + press_position: Option, +} + +impl SelectableTextState { + fn selection_range(&self, text: &str) -> Option<(usize, usize)> { + let (anchor, cursor) = self.selection?; + let (start, end) = normalized_byte_range(text, anchor, cursor); + + (start != end).then_some((start, end)) + } +} + +impl Widget + for SelectableRichText<'_, Link, Message, Theme, Renderer> +where + Link: Clone + 'static, + Theme: Catalog, + Renderer: advanced_text::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::>() + } + + fn state(&self) -> tree::State { + tree::State::new(SelectableTextState:: { + spans: Vec::new(), + span_pressed: None, + paragraph: Renderer::Paragraph::default(), + selection: None, + dragging: false, + active: false, + press_position: None, + }) + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout_text( + tree.state + .downcast_mut::>(), + renderer, + limits, + TextLayout { + width: self.width, + height: self.height, + spans: self.spans.as_ref().as_ref(), + line_height: self.line_height, + size: self.size, + font: self.font, + align_x: self.align_x, + align_y: self.align_y, + wrapping: self.wrapping, + }, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + defaults: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + if !layout.bounds().intersects(viewport) { + return; + } + + let state = tree + .state + .downcast_ref::>(); + let spans = self.spans.as_ref().as_ref(); + let flat_text = flatten_spans(spans); + let style = theme.style(&self.class); + let translation = layout.position() - Point::ORIGIN; + + if let Some((start, end)) = state.selection_range(&flat_text) { + let mut rects = selection_rects(&state.paragraph, spans.len(), start, end); + if rects.is_empty() { + rects = visual_lines(&state.paragraph, spans.len()); + } + + for bounds in rects { + renderer.fill_quad( + renderer::Quad { + bounds: bounds + translation, + border: Border { + radius: 2.0.into(), + ..Border::default() + }, + ..renderer::Quad::default() + }, + Background::Color(self.selection_color), + ); + } + } + + for (index, span) in spans.iter().enumerate() { + let is_hovered_link = self.on_link_click.is_some() + && Some(index) == self.hovered_link; + + if span.highlight.is_some() + || span.underline + || span.strikethrough + || is_hovered_link + { + let regions = state.paragraph.span_bounds(index); + + if let Some(highlight) = span.highlight { + for bounds in ®ions { + let bounds = Rectangle::new( + bounds.position() + - Vector::new( + span.padding.left, + span.padding.top, + ), + bounds.size() + + Size::new(span.padding.x(), span.padding.y()), + ); + + renderer.fill_quad( + renderer::Quad { + bounds: bounds + translation, + border: highlight.border, + ..Default::default() + }, + highlight.background, + ); + } + } + + if span.underline || span.strikethrough || is_hovered_link { + let size = span + .size + .or(self.size) + .unwrap_or(renderer.default_size()); + + let line_height = span + .line_height + .unwrap_or(self.line_height) + .to_absolute(size); + + let color = span + .color + .or(style.color) + .unwrap_or(defaults.text_color); + + let baseline = translation + + Vector::new( + 0.0, + size.0 + (line_height.0 - size.0) / 2.0, + ); + + if span.underline || is_hovered_link { + for bounds in ®ions { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + bounds.position() + baseline + - Vector::new(0.0, size.0 * 0.08), + Size::new(bounds.width, 1.0), + ), + ..Default::default() + }, + color, + ); + } + } + + if span.strikethrough { + for bounds in ®ions { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + bounds.position() + baseline + - Vector::new(0.0, size.0 / 2.0), + Size::new(bounds.width, 1.0), + ), + ..Default::default() + }, + color, + ); + } + } + } + } + } + + widget_text::draw( + renderer, + defaults, + layout.bounds(), + &state.paragraph, + style, + viewport, + ); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + let local_position = cursor.position_in(bounds); + let state = tree + .state + .downcast_mut::>(); + let spans = self.spans.as_ref().as_ref(); + let flat_text = flatten_spans(spans); + + let was_hovered = self.hovered_link.is_some(); + self.hovered_link = local_position.and_then(|position| { + state.paragraph.hit_span(position).and_then(|span| { + if spans.get(span)?.link.is_some() { + Some(span) + } else { + None + } + }) + }); + + if was_hovered != self.hovered_link.is_some() { + shell.request_redraw(); + } + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if let Some(position) = local_position { + state.active = true; + state.dragging = true; + state.press_position = Some(position); + state.span_pressed = self.hovered_link; + state.selection = state + .paragraph + .hit_test(position) + .map(|hit| { + let offset = hit.cursor().min(flat_text.len()); + (offset, offset) + }); + shell.capture_event(); + shell.request_redraw(); + } else if state.active || state.selection.is_some() { + state.active = false; + state.dragging = false; + state.press_position = None; + state.span_pressed = None; + state.selection = None; + shell.request_redraw(); + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if state.dragging + && let Some(position) = clamped_position(cursor, bounds) + && let Some(hit) = state.paragraph.hit_test(position) + && let Some((anchor, _)) = state.selection + { + state.selection = + Some((anchor, hit.cursor().min(flat_text.len()))); + shell.request_redraw(); + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + if state.dragging { + let release_position = clamped_position(cursor, bounds) + .or(local_position) + .or(state.press_position); + let dragged = state + .press_position + .zip(release_position) + .is_some_and(|(start, end)| point_distance(start, end) > DRAG_THRESHOLD); + + if let Some(position) = release_position + && let Some(hit) = state.paragraph.hit_test(position) + && let Some((anchor, _)) = state.selection + { + state.selection = + Some((anchor, hit.cursor().min(flat_text.len()))); + } + + if !dragged { + if let (Some(on_link_clicked), Some(span)) = + (&self.on_link_click, state.span_pressed) + && Some(span) == self.hovered_link + && let Some(link) = + spans.get(span).and_then(|span| span.link.clone()) + { + shell.publish(on_link_clicked(link)); + } + state.selection = None; + } else if state.selection_range(&flat_text).is_none() { + state.selection = None; + } + + state.dragging = false; + state.span_pressed = None; + state.press_position = None; + shell.capture_event(); + shell.request_redraw(); + } + } + Event::Keyboard(keyboard::Event::KeyPressed { + key, + physical_key, + modifiers, + .. + }) if state.active && modifiers.command() => { + match key.to_latin(*physical_key) { + Some('c') | Some('C') => { + if let Some((anchor, cursor)) = state.selection + && let Some(selected) = + selected_substring(&flat_text, anchor, cursor) + { + clipboard.write(clipboard::Kind::Standard, selected); + shell.capture_event(); + } + } + Some('a') | Some('A') => { + state.selection = Some(select_all(&flat_text)); + shell.capture_event(); + shell.request_redraw(); + } + _ => {} + } + } + _ => {} + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let state = tree + .state + .downcast_ref::>(); + + if state.dragging { + mouse::Interaction::Text + } else if self.hovered_link.is_some() { + mouse::Interaction::Pointer + } else if cursor.is_over(layout.bounds()) { + mouse::Interaction::Text + } else { + mouse::Interaction::None + } + } +} + +struct TextLayout<'a, 'span, Link, Font> { + width: Length, + height: Length, + spans: &'a [Span<'span, Link, Font>], + line_height: LineHeight, + size: Option, + font: Option, + align_x: Alignment, + align_y: alignment::Vertical, + wrapping: Wrapping, +} + +fn layout_text( + state: &mut SelectableTextState, + renderer: &Renderer, + limits: &layout::Limits, + config: TextLayout<'_, '_, Link, Renderer::Font>, +) -> layout::Node +where + Link: Clone, + Renderer: advanced_text::Renderer, +{ + layout::sized(limits, config.width, config.height, |limits| { + let bounds = limits.max(); + let size = config.size.unwrap_or_else(|| renderer.default_size()); + let font = config.font.unwrap_or_else(|| renderer.default_font()); + + let text_with_spans = || advanced_text::Text { + content: config.spans, + bounds, + size, + line_height: config.line_height, + font, + align_x: config.align_x, + align_y: config.align_y, + shaping: Shaping::Advanced, + wrapping: config.wrapping, + }; + + if state.spans != config.spans { + state.paragraph = + Renderer::Paragraph::with_spans(text_with_spans()); + state.spans = config + .spans + .iter() + .cloned() + .map(Span::to_static) + .collect(); + } else { + match state.paragraph.compare(advanced_text::Text { + content: (), + bounds, + size, + line_height: config.line_height, + font, + align_x: config.align_x, + align_y: config.align_y, + shaping: Shaping::Advanced, + wrapping: config.wrapping, + }) { + advanced_text::Difference::None => {} + advanced_text::Difference::Bounds => { + state.paragraph.resize(bounds); + } + advanced_text::Difference::Shape => { + state.paragraph = + Renderer::Paragraph::with_spans(text_with_spans()); + } + } + } + + state.paragraph.min_bounds() + }) +} + +fn flatten_spans(spans: &[Span<'_, Link, Font>]) -> String { + spans + .iter() + .map(|span| span.text.as_ref()) + .collect::() +} + +fn selection_rects( + paragraph: &P, + span_count: usize, + start: usize, + end: usize, +) -> Vec { + visual_lines(paragraph, span_count) + .into_iter() + .filter_map(|line| selection_rect_for_line(paragraph, line, start, end)) + .collect() +} + +fn selection_rect_for_line( + paragraph: &P, + line: Rectangle, + start: usize, + end: usize, +) -> Option { + let y = line.center_y(); + let paragraph_width = paragraph.bounds().width.max(line.x + line.width + 1.0); + let left_probe = line.x.max(0.0); + let right_probe = (line.x + line.width + 1.0).min(paragraph_width.max(1.0)); + let line_start = paragraph + .hit_test(Point::new(left_probe, y)) + .map(advanced_text::Hit::cursor)?; + let line_end = paragraph + .hit_test(Point::new(right_probe, y)) + .map(advanced_text::Hit::cursor) + .unwrap_or(line_start); + let (line_start, line_end) = if line_start <= line_end { + (line_start, line_end) + } else { + (line_end, line_start) + }; + let overlap_start = start.max(line_start); + let overlap_end = end.min(line_end); + + if overlap_start >= overlap_end { + return None; + } + + let x_start = if overlap_start <= line_start { + line.x + } else { + x_for_offset(paragraph, y, overlap_start, line.x, line.x + line.width) + }; + let x_end = if overlap_end >= line_end { + line.x + line.width + } else { + x_for_offset(paragraph, y, overlap_end, line.x, line.x + line.width) + }; + let left = x_start.min(x_end); + let right = x_start.max(x_end); + + (right > left).then(|| { + Rectangle::new( + Point::new(left, line.y), + Size::new(right - left, line.height), + ) + }) +} + +fn x_for_offset( + paragraph: &P, + y: f32, + offset: usize, + left: f32, + right: f32, +) -> f32 { + let mut low = left; + let mut high = right.max(left); + + for _ in 0..HIT_SEARCH_STEPS { + let mid = (low + high) / 2.0; + let hit = paragraph + .hit_test(Point::new(mid, y)) + .map(advanced_text::Hit::cursor); + + match hit { + Some(hit) if hit < offset => low = mid, + Some(_) => high = mid, + None => break, + } + } + + high +} + +fn visual_lines( + paragraph: &P, + span_count: usize, +) -> Vec { + let mut lines: Vec = Vec::new(); + + for span in 0..span_count { + for bounds in paragraph.span_bounds(span) { + if bounds.width <= 0.0 || bounds.height <= 0.0 { + continue; + } + + if let Some(line) = lines + .iter_mut() + .find(|line| (line.center_y() - bounds.center_y()).abs() < 1.0) + { + *line = union(*line, bounds); + } else { + lines.push(bounds); + } + } + } + + lines.sort_by(|a, b| a.y.total_cmp(&b.y)); + lines +} + +fn union(a: Rectangle, b: Rectangle) -> Rectangle { + let left = a.x.min(b.x); + let top = a.y.min(b.y); + let right = (a.x + a.width).max(b.x + b.width); + let bottom = (a.y + a.height).max(b.y + b.height); + + Rectangle::new( + Point::new(left, top), + Size::new(right - left, bottom - top), + ) +} + +fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option { + cursor.position_from(bounds.position()).map(|position| { + Point::new( + position.x.clamp(0.0, bounds.width.max(1.0) - 1.0), + position.y.clamp(0.0, bounds.height.max(1.0) - 1.0), + ) + }) +} + +fn point_distance(a: Point, b: Point) -> f32 { + let dx = a.x - b.x; + let dy = a.y - b.y; + + (dx * dx + dy * dy).sqrt() +} + +impl<'a, Link, Message, Theme, Renderer> + From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Link: Clone + 'a, + Theme: Catalog + 'a, + Renderer: advanced_text::Renderer + 'a, +{ + fn from( + text: SelectableRichText<'a, Link, Message, Theme, Renderer>, + ) -> Element<'a, Message, Theme, Renderer> { + Element::new(text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selected_substring_returns_middle_and_none_for_collapsed() { + assert_eq!(selected_substring("abcdef", 2, 5), Some("cde".to_owned())); + assert_eq!(selected_substring("abcdef", 3, 3), None); + } + + #[test] + fn selected_substring_handles_reversed_range() { + assert_eq!(selected_substring("abcdef", 5, 2), Some("cde".to_owned())); + } + + #[test] + fn select_all_uses_byte_length() { + assert_eq!(select_all(""), (0, 0)); + assert_eq!(select_all("aĆ©šŸ‘z"), (0, "aĆ©šŸ‘z".len())); + } + + #[test] + fn unicode_selection_uses_byte_offsets_without_panicking() { + let text = "aĆ©šŸ‘z"; + + assert_eq!(selected_substring(text, 1, 7), Some("Ć©šŸ‘".to_owned())); + assert_eq!(selected_substring(text, 3, 7), Some("šŸ‘".to_owned())); + assert_eq!(selected_substring(text, 2, 7), Some("Ć©šŸ‘".to_owned())); + } +}