teksilo_widgets/rich_text/hit_test.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Hit-test helpers and context-target classification.
5//!
6//! Pointer positions arrive in widget-local screen space. text-typeset's
7//! `Typesetter::hit_test` already compensates for the current scroll
8//! offset and zoom internally, so the widget forwards the pointer
9//! coordinate unchanged. The `ContextTarget` enum is exposed publicly
10//! so applications can build external context menus without reaching
11//! into the widget's private state.
12
13use teksilo_canvas::Point;
14use teksilo_text::text_document::{MoveMode, TextDocument};
15use teksilo_text::{HitRegion, HitTestResult, RichTextEngine};
16
17/// What was under a click, for context-menu classification.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ContextTarget {
20 /// Ordinary text, not inside a selection.
21 Plain,
22 /// A click inside the current selection (for "Copy/Cut" menus).
23 InSelection,
24 /// A hyperlink.
25 Link { href: String },
26 /// An inline image.
27 Image { name: String },
28 /// A table cell. Row and column are zero-based.
29 TableCell {
30 table_id: usize,
31 row: usize,
32 col: usize,
33 },
34}
35
36/// Convenience wrapper over [`RichTextEngine::hit_test`]. The
37/// engine's hit-test accepts widget-local pointer coordinates
38/// directly — scroll offset and zoom are applied internally.
39pub fn hit_test_at(
40 engine: &RichTextEngine,
41 screen: Point,
42 _scroll_x: f32,
43 _scroll_y: f32,
44) -> Option<HitTestResult> {
45 engine.hit_test(screen.x, screen.y)
46}
47
48/// Classify what the user pointed at. Used by both the widget's own
49/// single-click dispatch and the application-facing
50/// `context_target_at()` helper. The `selection` argument is the current
51/// `(anchor, position)` of the widget's cursor in character offsets; if
52/// the hit position falls within that range we report `InSelection` so
53/// the context menu can show "Copy/Cut" without first collapsing the
54/// selection. `document` is used to resolve the table cell row/column
55/// for `TableCell` results — text-typeset's `HitTestResult::table_id`
56/// gives us the table, but only the document model knows the cell's
57/// row/column coordinates.
58pub fn classify(
59 hit: &HitTestResult,
60 selection: Option<(usize, usize)>,
61 document: &TextDocument,
62) -> ContextTarget {
63 // In-selection check first: a click inside the current selection
64 // reports `InSelection` regardless of whether the hit landed on a
65 // link, image, or plain text — the app's context menu typically
66 // wants "Cut/Copy/Paste" in that case rather than link-specific
67 // actions.
68 if let Some((anchor, caret)) = selection {
69 let (lo, hi) = (anchor.min(caret), anchor.max(caret));
70 if lo != hi && hit.position >= lo && hit.position <= hi {
71 return ContextTarget::InSelection;
72 }
73 }
74
75 match &hit.region {
76 HitRegion::Link { href } => ContextTarget::Link { href: href.clone() },
77 HitRegion::Image { name } => ContextTarget::Image { name: name.clone() },
78 _ => {
79 if let Some(table_id) = hit.table_id {
80 // Probe the document model for the cell's row/column.
81 // A fresh cursor at `hit.position` asks text-document
82 // to resolve the containing cell via
83 // `current_table_cell()`, which already understands
84 // row/column semantics.
85 let probe = document.cursor();
86 probe.set_position(hit.position, MoveMode::MoveAnchor);
87 if let Some(cell_ref) = probe.current_table_cell() {
88 return ContextTarget::TableCell {
89 table_id,
90 row: cell_ref.row,
91 col: cell_ref.column,
92 };
93 }
94 return ContextTarget::TableCell {
95 table_id,
96 row: 0,
97 col: 0,
98 };
99 }
100 ContextTarget::Plain
101 }
102 }
103}