Skip to main content

fission_core/input/
selectable_text.rs

1use super::{ControllerContext, InputController};
2use crate::event::{InputEvent, KeyCode, KeyEvent, PointerEvent, MOD_CTRL, MOD_SUPER};
3use crate::ui::widgets::context_menu::{text_context_menu_button_id, TextContextMenuAction};
4use fission_ir::{op::LayoutOp, Op, Semantics, WidgetId};
5use fission_layout::{LayoutNodeGeometry, LayoutPoint};
6
7pub struct SelectableTextController;
8
9impl InputController for SelectableTextController {
10    fn handle_event(&mut self, ctx: &mut ControllerContext, event: &InputEvent) -> bool {
11        match event {
12            InputEvent::Keyboard(KeyEvent::Down {
13                key_code,
14                modifiers,
15            }) => self.handle_key(ctx, key_code.clone(), *modifiers),
16            InputEvent::Pointer(PointerEvent::Down {
17                point,
18                button,
19                modifiers,
20                ..
21            }) => {
22                let hit =
23                    crate::hit_test::hit_test_with_scroll(ctx.ir, ctx.layout, ctx.scroll, *point);
24                if let (Some(owner), Some(hit_node_id)) = (ctx.context_menu.owner, hit) {
25                    if let Some(action) = Self::toolbar_action_hit(ctx.ir, owner, hit_node_id) {
26                        return self.execute_action(ctx, owner, action);
27                    }
28                }
29
30                if !matches!(button, crate::event::PointerButton::Primary) {
31                    return false;
32                }
33
34                let Some(hit_node_id) = hit else {
35                    return false;
36                };
37                let Some((text_id, semantics)) = Self::selectable_text_at_hit(ctx, hit_node_id)
38                else {
39                    return false;
40                };
41                let caret = Self::caret_for_text(ctx, text_id, &semantics, *point);
42                let state = ctx.selectable_text.get_mut_or_default(text_id);
43                if !Self::has_shift(*modifiers) {
44                    state.anchor = caret;
45                }
46                state.caret = caret;
47                state.selecting = true;
48                ctx.interaction.set_focused(Some(text_id));
49                ctx.context_menu.close();
50                true
51            }
52            InputEvent::Pointer(PointerEvent::Move { point, .. }) => {
53                let Some(text_id) = Self::active_selection_owner(ctx) else {
54                    return false;
55                };
56                let Some(semantics) = Self::selectable_text_semantics(ctx, text_id) else {
57                    return false;
58                };
59                let caret = Self::caret_for_text(ctx, text_id, &semantics, *point);
60                ctx.selectable_text.get_mut_or_default(text_id).caret = caret;
61                true
62            }
63            InputEvent::Pointer(PointerEvent::Up { point, button, .. }) => {
64                let Some(text_id) = Self::active_selection_owner(ctx) else {
65                    return false;
66                };
67                let show_menu = ctx
68                    .selectable_text
69                    .get(text_id)
70                    .and_then(|state| state.selection_range())
71                    .is_some()
72                    && matches!(button, crate::event::PointerButton::Secondary);
73                ctx.selectable_text.get_mut_or_default(text_id).selecting = false;
74                if show_menu {
75                    ctx.context_menu.open(text_id, *point);
76                }
77                true
78            }
79            _ => false,
80        }
81    }
82}
83
84impl SelectableTextController {
85    fn handle_key(
86        &mut self,
87        ctx: &mut ControllerContext,
88        key_code: KeyCode,
89        modifiers: u8,
90    ) -> bool {
91        let Some(owner) = ctx.interaction.focused else {
92            return false;
93        };
94        let Some(semantics) = Self::selectable_text_semantics(ctx, owner) else {
95            return false;
96        };
97        if !Self::has_primary_shortcut(modifiers) {
98            return false;
99        }
100        match key_code {
101            KeyCode::Char('c') | KeyCode::Char('C') => {
102                if let Some(selected) = Self::selected_text(ctx, owner, &semantics) {
103                    if let Some(clipboard) = ctx.clipboard {
104                        clipboard.set_text(&selected);
105                    }
106                }
107                true
108            }
109            KeyCode::Char('a') | KeyCode::Char('A') => {
110                let len = semantics.value.as_deref().unwrap_or("").len();
111                let state = ctx.selectable_text.get_mut_or_default(owner);
112                state.anchor = 0;
113                state.caret = len;
114                true
115            }
116            _ => false,
117        }
118    }
119
120    fn selectable_text_at_hit(
121        ctx: &ControllerContext,
122        hit_node_id: WidgetId,
123    ) -> Option<(WidgetId, Semantics)> {
124        let mut current = Some(hit_node_id);
125        while let Some(node_id) = current {
126            let node = ctx.ir.nodes.get(&node_id)?;
127            if let Op::Semantics(semantics) = &node.op {
128                if semantics.selectable_text && !semantics.disabled {
129                    return Some((node_id, semantics.clone()));
130                }
131            }
132            current = node.parent;
133        }
134        None
135    }
136
137    fn selectable_text_semantics(ctx: &ControllerContext, owner: WidgetId) -> Option<Semantics> {
138        let node = ctx.ir.nodes.get(&owner)?;
139        match &node.op {
140            Op::Semantics(semantics) if semantics.selectable_text => Some(semantics.clone()),
141            _ => None,
142        }
143    }
144
145    fn toolbar_action_hit(
146        ir: &fission_ir::CoreIR,
147        owner: WidgetId,
148        hit_node_id: WidgetId,
149    ) -> Option<TextContextMenuAction> {
150        for action in [
151            TextContextMenuAction::Copy,
152            TextContextMenuAction::Cut,
153            TextContextMenuAction::Paste,
154            TextContextMenuAction::SelectAll,
155        ] {
156            if Self::node_or_ancestor_matches(
157                ir,
158                hit_node_id,
159                text_context_menu_button_id(owner, action),
160            ) {
161                return Some(action);
162            }
163        }
164        None
165    }
166
167    fn execute_action(
168        &mut self,
169        ctx: &mut ControllerContext,
170        owner: WidgetId,
171        action: TextContextMenuAction,
172    ) -> bool {
173        let Some(semantics) = Self::selectable_text_semantics(ctx, owner) else {
174            return false;
175        };
176        match action {
177            TextContextMenuAction::Copy => {
178                if let Some(selected) = Self::selected_text(ctx, owner, &semantics) {
179                    if let Some(clipboard) = ctx.clipboard {
180                        clipboard.set_text(&selected);
181                    }
182                }
183                ctx.context_menu.close();
184                true
185            }
186            TextContextMenuAction::SelectAll => {
187                let len = semantics.value.as_deref().unwrap_or("").len();
188                let state = ctx.selectable_text.get_mut_or_default(owner);
189                state.anchor = 0;
190                state.caret = len;
191                state.selecting = false;
192                ctx.context_menu.close();
193                true
194            }
195            TextContextMenuAction::Cut | TextContextMenuAction::Paste => true,
196        }
197    }
198
199    fn selected_text(
200        ctx: &ControllerContext,
201        owner: WidgetId,
202        semantics: &Semantics,
203    ) -> Option<String> {
204        let value = semantics.value.as_deref().unwrap_or("");
205        let state = ctx.selectable_text.get(owner)?;
206        let (anchor, caret) = state.selection_range()?;
207        let start = Self::clamp_to_char_boundary(value, anchor.min(caret));
208        let end = Self::clamp_to_char_boundary(value, anchor.max(caret));
209        value.get(start..end).map(ToString::to_string)
210    }
211
212    fn caret_for_text(
213        ctx: &ControllerContext,
214        owner: WidgetId,
215        semantics: &Semantics,
216        point: LayoutPoint,
217    ) -> usize {
218        let value = semantics.value.as_deref().unwrap_or("");
219        let Some((layout_id, geom)) = Self::layout_geometry(ctx, owner) else {
220            return 0;
221        };
222        let local = Self::local_point(ctx, layout_id, geom, point);
223        let Some(measurer) = ctx.measurer else {
224            return 0;
225        };
226        let width = (geom.rect.size.width > 0.0).then_some(geom.rect.size.width);
227        let caret = if let Some(runs) = Self::rich_runs(ctx.ir, owner) {
228            measurer.hit_test_rich(&runs, width, local.x, local.y)
229        } else {
230            let font_size = Self::font_size(ctx.ir, owner).unwrap_or(14.0);
231            measurer.hit_test(value, font_size, width, local.x, local.y)
232        };
233        Self::clamp_to_char_boundary(value, caret.min(value.len()))
234    }
235
236    fn layout_geometry<'a>(
237        ctx: &'a ControllerContext,
238        owner: WidgetId,
239    ) -> Option<(WidgetId, &'a LayoutNodeGeometry)> {
240        fn walk<'a>(
241            ctx: &'a ControllerContext,
242            node_id: WidgetId,
243        ) -> Option<(WidgetId, &'a LayoutNodeGeometry)> {
244            if let Some(geom) = ctx.layout.get_node_geometry(node_id) {
245                return Some((node_id, geom));
246            }
247            for child in &ctx.ir.nodes.get(&node_id)?.children {
248                if let Some(found) = walk(ctx, *child) {
249                    return Some(found);
250                }
251            }
252            None
253        }
254        walk(ctx, owner)
255    }
256
257    fn local_point(
258        ctx: &ControllerContext,
259        node_id: WidgetId,
260        geom: &LayoutNodeGeometry,
261        point: LayoutPoint,
262    ) -> LayoutPoint {
263        let mut scroll_x = 0.0;
264        let mut scroll_y = 0.0;
265        let mut walk = ctx.ir.nodes.get(&node_id).and_then(|node| node.parent);
266        while let Some(parent_id) = walk {
267            let Some(parent) = ctx.ir.nodes.get(&parent_id) else {
268                break;
269            };
270            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &parent.op {
271                let offset = ctx.scroll.get_offset(parent_id);
272                match direction {
273                    fission_ir::FlexDirection::Row => scroll_x += offset,
274                    fission_ir::FlexDirection::Column => scroll_y += offset,
275                }
276            }
277            walk = parent.parent;
278        }
279        LayoutPoint::new(
280            point.x - geom.rect.origin.x + scroll_x,
281            point.y - geom.rect.origin.y + scroll_y,
282        )
283    }
284
285    fn rich_runs(ir: &fission_ir::CoreIR, owner: WidgetId) -> Option<Vec<fission_ir::op::TextRun>> {
286        fn walk(
287            ir: &fission_ir::CoreIR,
288            node_id: WidgetId,
289        ) -> Option<Vec<fission_ir::op::TextRun>> {
290            let node = ir.nodes.get(&node_id)?;
291            match &node.op {
292                Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) if !runs.is_empty() => {
293                    Some(runs.clone())
294                }
295                _ => node.children.iter().find_map(|child| walk(ir, *child)),
296            }
297        }
298        walk(ir, owner)
299    }
300
301    fn font_size(ir: &fission_ir::CoreIR, owner: WidgetId) -> Option<f32> {
302        fn walk(ir: &fission_ir::CoreIR, node_id: WidgetId) -> Option<f32> {
303            let node = ir.nodes.get(&node_id)?;
304            match &node.op {
305                Op::Paint(fission_ir::PaintOp::DrawText { size, .. }) => Some(*size),
306                Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) => {
307                    runs.first().map(|run| run.style.font_size)
308                }
309                _ => node.children.iter().find_map(|child| walk(ir, *child)),
310            }
311        }
312        walk(ir, owner)
313    }
314
315    fn active_selection_owner(ctx: &ControllerContext) -> Option<WidgetId> {
316        ctx.selectable_text
317            .states
318            .iter()
319            .find_map(|(id, state)| state.selecting.then_some(*id))
320    }
321
322    fn node_or_ancestor_matches(
323        ir: &fission_ir::CoreIR,
324        node_id: WidgetId,
325        expected: WidgetId,
326    ) -> bool {
327        let mut current = Some(node_id);
328        while let Some(id) = current {
329            if id == expected {
330                return true;
331            }
332            current = ir.nodes.get(&id).and_then(|node| node.parent);
333        }
334        false
335    }
336
337    fn clamp_to_char_boundary(value: &str, mut index: usize) -> usize {
338        index = index.min(value.len());
339        while index > 0 && !value.is_char_boundary(index) {
340            index -= 1;
341        }
342        index
343    }
344
345    fn has_shift(modifiers: u8) -> bool {
346        (modifiers & crate::event::MOD_SHIFT) != 0
347    }
348
349    fn has_primary_shortcut(modifiers: u8) -> bool {
350        if cfg!(target_os = "macos") || cfg!(target_os = "ios") {
351            (modifiers & MOD_SUPER) != 0
352        } else {
353            (modifiers & MOD_CTRL) != 0
354        }
355    }
356}