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: ¶graph::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}