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}