Skip to main content

modde_ui/views/
selectable_text.rs

1use iced::advanced::clipboard::{self, Clipboard};
2use iced::advanced::layout::{self, Layout};
3use iced::advanced::mouse;
4use iced::advanced::renderer;
5use iced::advanced::text::paragraph;
6use iced::advanced::widget::text as text_widget;
7use iced::advanced::widget::{self, Tree};
8use iced::advanced::{Renderer as _, text::Paragraph as _};
9use iced::advanced::{Shell, Widget, text as advanced_text};
10use iced::event::Event;
11use iced::keyboard::{self, Key};
12use iced::{
13    Background, Border, Color, Element, Font, Length, Pixels, Point, Rectangle, Shadow, Size, Theme,
14};
15use unicode_segmentation::UnicodeSegmentation;
16
17use crate::app::Message;
18
19type Renderer = iced::Renderer;
20
21#[derive(Debug, Clone)]
22pub struct SelectableText {
23    content: String,
24    format: text_widget::Format<Font>,
25    color: Option<Color>,
26}
27
28#[derive(Debug, Default)]
29struct State {
30    paragraph: paragraph::Plain<<Renderer as advanced_text::Renderer>::Paragraph>,
31    focus: bool,
32    drag_anchor: Option<usize>,
33    selection: Option<(usize, usize)>,
34}
35
36pub fn text(content: impl ToString) -> SelectableText {
37    SelectableText {
38        content: content.to_string(),
39        format: text_widget::Format::default(),
40        color: None,
41    }
42}
43
44impl SelectableText {
45    #[must_use]
46    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
47        self.format.size = Some(size.into());
48        self
49    }
50
51    #[must_use]
52    pub fn width(mut self, width: impl Into<Length>) -> Self {
53        self.format.width = width.into();
54        self
55    }
56
57    #[must_use]
58    pub fn height(mut self, height: impl Into<Length>) -> Self {
59        self.format.height = height.into();
60        self
61    }
62
63    #[must_use]
64    pub fn color(mut self, color: impl Into<Color>) -> Self {
65        self.color = Some(color.into());
66        self
67    }
68}
69
70impl Widget<Message, Theme, Renderer> for SelectableText {
71    fn tag(&self) -> widget::tree::Tag {
72        widget::tree::Tag::of::<State>()
73    }
74
75    fn state(&self) -> widget::tree::State {
76        widget::tree::State::new(State::default())
77    }
78
79    fn size(&self) -> Size<Length> {
80        Size {
81            width: self.format.width,
82            height: self.format.height,
83        }
84    }
85
86    fn layout(
87        &mut self,
88        tree: &mut Tree,
89        renderer: &Renderer,
90        limits: &layout::Limits,
91    ) -> layout::Node {
92        let state = tree.state.downcast_mut::<State>();
93        text_widget::layout(
94            &mut state.paragraph,
95            renderer,
96            limits,
97            &self.content,
98            self.format,
99        )
100    }
101
102    fn update(
103        &mut self,
104        tree: &mut Tree,
105        event: &Event,
106        layout: Layout<'_>,
107        cursor: mouse::Cursor,
108        _renderer: &Renderer,
109        clipboard: &mut dyn Clipboard,
110        shell: &mut Shell<'_, Message>,
111        _viewport: &Rectangle,
112    ) {
113        let state = tree.state.downcast_mut::<State>();
114        let bounds = layout.bounds();
115
116        match event {
117            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
118                if let Some(position) = cursor.position_over(bounds) {
119                    state.focus = true;
120                    let index = hit_index(&state.paragraph, bounds, position).unwrap_or(0);
121                    state.drag_anchor = Some(index);
122                    state.selection = None;
123                } else {
124                    state.focus = false;
125                    state.drag_anchor = None;
126                    state.selection = None;
127                }
128            }
129            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
130                if let (Some(anchor), Some(position)) =
131                    (state.drag_anchor, cursor.position_over(bounds))
132                    && let Some(index) = hit_index(&state.paragraph, bounds, position)
133                    && anchor != index
134                {
135                    state.selection = Some((anchor, index));
136                    shell.capture_event();
137                    shell.request_redraw();
138                }
139            }
140            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
141                let had_selection = state.selection.is_some();
142                state.drag_anchor = None;
143
144                if had_selection {
145                    shell.capture_event();
146                }
147            }
148            Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
149                if state.focus
150                    && modifiers.command()
151                    && matches!(key, Key::Character(c) if c.eq_ignore_ascii_case("c"))
152                    && let Some((start, end)) = normalized_selection(state.selection)
153                {
154                    clipboard.write(
155                        clipboard::Kind::Standard,
156                        selected_text(&self.content, start, end),
157                    );
158                    shell.capture_event();
159                }
160            }
161            _ => {}
162        }
163    }
164
165    fn draw(
166        &self,
167        tree: &Tree,
168        renderer: &mut Renderer,
169        theme: &Theme,
170        defaults: &renderer::Style,
171        layout: Layout<'_>,
172        _cursor: mouse::Cursor,
173        viewport: &Rectangle,
174    ) {
175        let state = tree.state.downcast_ref::<State>();
176        let bounds = layout.bounds();
177
178        if let Some((start, end)) = normalized_selection(state.selection) {
179            draw_selection(renderer, theme, bounds, state, &self.content, start, end);
180        }
181
182        text_widget::draw(
183            renderer,
184            defaults,
185            bounds,
186            state.paragraph.raw(),
187            text_widget::Style { color: self.color },
188            viewport,
189        );
190    }
191
192    fn operate(
193        &mut self,
194        _tree: &mut Tree,
195        layout: Layout<'_>,
196        _renderer: &Renderer,
197        operation: &mut dyn widget::Operation,
198    ) {
199        operation.text(None, layout.bounds(), &self.content);
200    }
201
202    fn mouse_interaction(
203        &self,
204        _tree: &Tree,
205        layout: Layout<'_>,
206        cursor: mouse::Cursor,
207        _viewport: &Rectangle,
208        _renderer: &Renderer,
209    ) -> mouse::Interaction {
210        if cursor.is_over(layout.bounds()) {
211            mouse::Interaction::Text
212        } else {
213            mouse::Interaction::default()
214        }
215    }
216}
217
218impl From<SelectableText> for Element<'_, Message> {
219    fn from(text: SelectableText) -> Self {
220        Element::new(text)
221    }
222}
223
224fn hit_index(
225    paragraph: &paragraph::Plain<<Renderer as advanced_text::Renderer>::Paragraph>,
226    bounds: Rectangle,
227    position: Point,
228) -> Option<usize> {
229    let anchor = bounds.anchor(
230        paragraph.min_bounds(),
231        paragraph.align_x(),
232        paragraph.align_y(),
233    );
234    paragraph
235        .raw()
236        .hit_test(Point::new(position.x - anchor.x, position.y - anchor.y))
237        .map(advanced_text::Hit::cursor)
238}
239
240fn normalized_selection(selection: Option<(usize, usize)>) -> Option<(usize, usize)> {
241    selection
242        .map(|(start, end)| (start.min(end), start.max(end)))
243        .filter(|(start, end)| start != end)
244}
245
246fn selected_text(content: &str, start: usize, end: usize) -> String {
247    UnicodeSegmentation::graphemes(content, true)
248        .skip(start)
249        .take(end.saturating_sub(start))
250        .collect()
251}
252
253fn draw_selection(
254    renderer: &mut Renderer,
255    theme: &Theme,
256    bounds: Rectangle,
257    state: &State,
258    content: &str,
259    start: usize,
260    end: usize,
261) {
262    let paragraph = state.paragraph.raw();
263    let anchor = bounds.anchor(
264        state.paragraph.min_bounds(),
265        state.paragraph.align_x(),
266        state.paragraph.align_y(),
267    );
268    let line_height = paragraph.line_height().to_absolute(paragraph.size()).0;
269    let palette = theme.extended_palette();
270
271    let mut line_start = 0;
272    for (line_index, line) in content.split_inclusive('\n').enumerate() {
273        let has_newline = line.ends_with('\n');
274        let line_content = line.trim_end_matches('\n');
275        let line_len = UnicodeSegmentation::graphemes(line_content, true).count();
276        let line_end = line_start + line_len;
277
278        let selection_start = start.max(line_start);
279        let selection_end = end.min(line_end);
280
281        if selection_start < selection_end {
282            let local_start = selection_start - line_start;
283            let local_end = selection_end - line_start;
284
285            if let (Some(start_pos), Some(end_pos)) = (
286                paragraph.grapheme_position(line_index, local_start),
287                paragraph.grapheme_position(line_index, local_end),
288            ) {
289                renderer.fill_quad(
290                    renderer::Quad {
291                        bounds: Rectangle {
292                            x: anchor.x + start_pos.x,
293                            y: anchor.y + start_pos.y,
294                            width: (end_pos.x - start_pos.x).max(1.0),
295                            height: line_height,
296                        },
297                        border: Border::default(),
298                        shadow: Shadow::default(),
299                        snap: true,
300                    },
301                    Background::Color(palette.primary.weak.color),
302                );
303            }
304        }
305
306        line_start = line_end + usize::from(has_newline);
307    }
308}