返回 CodeWhale
hover_hit.rs
根目录 / crates / tui / src / tui / hover_hit.rs
1 //! Shared hover-hit abstraction for OSC-8 terminal links.
2
3 // Public API surface; hover_layer + mouse_ui consume these primitives.
4
5 use ratatui::{
6 layout::Rect,
7 style::{Color, Modifier, Style},
8 };
9
10 use crate::tui::ocean;
11
12 /// Kind of interactive surface under the pointer.
13 ///
14 /// Slice G central registry: every clickable primitive family has a kind so
15 /// per-screen renderers register one rect and the shared
16 /// [`crate::tui::hover_layer`] paints the feedback. Selection (keyboard)
17 /// styling stays in [`crate::tui::menu_style`]; these kinds only drive the
18 /// pointer layer.
19 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
20 pub enum HoverTargetKind {
21 Link,
22 /// A compact row that omitted part of its full source label.
23 TruncatedText,
24 }
25
26 /// Result of a hover hit-test.
27 #[derive(Debug, Clone, PartialEq, Eq)]
28 pub struct HoverHit {
29 pub kind: HoverTargetKind,
30 pub area: Rect,
31 /// Optional label for tooltips / copy affordances.
32 pub label: String,
33 /// Whether a hover-only `copy` chip should be shown.
34 pub copyable: bool,
35 }
36
37 /// Underline + glow for OSC-8 / file links under the pointer.
38 #[must_use]
39 pub fn link_hover_style(fg: Color, reduced_motion: bool, elapsed_ms: u128) -> Style {
40 let scale = if reduced_motion {
41 1.15
42 } else {
43 let phase = (elapsed_ms % 1_200) as f32 / 1_200.0;
44 1.10 + (phase * std::f32::consts::TAU).sin().abs() * 0.12
45 };
46 Style::default()
47 .fg(ocean::scale_color(fg, scale))
48 .add_modifier(Modifier::UNDERLINED | Modifier::BOLD)
49 }
50
51 /// Hover-only `copy` chip text (display width fixed).
52 #[must_use]
53 pub fn copy_affordance() -> &'static str {
54 "⧉ copy"
55 }
56
57 /// Whether `column,row` hits `area`.
58 #[must_use]
59 pub fn point_in_rect(column: u16, row: u16, area: Option<Rect>) -> bool {
60 let Some(area) = area else {
61 return false;
62 };
63 column >= area.x
64 && column < area.x.saturating_add(area.width)
65 && row >= area.y
66 && row < area.y.saturating_add(area.height)
67 }
68
69 /// Hit-test a list of rectangular targets; returns the topmost match.
70 #[must_use]
71 pub fn hit_test(column: u16, row: u16, targets: &[HoverHit]) -> Option<&HoverHit> {
72 targets
73 .iter()
74 .rev()
75 .find(|t| point_in_rect(column, row, Some(t.area)))
76 }
77
78 #[cfg(test)]
79 mod tests {
80 use super::*;
81
82 #[test]
83 fn hit_test_returns_topmost() {
84 let targets = vec![
85 HoverHit {
86 kind: HoverTargetKind::Link,
87 area: Rect::new(0, 0, 10, 1),
88 label: "a".into(),
89 copyable: false,
90 },
91 HoverHit {
92 kind: HoverTargetKind::Link,
93 area: Rect::new(2, 0, 4, 1),
94 label: "b".into(),
95 copyable: true,
96 },
97 ];
98 let hit = hit_test(3, 0, &targets).expect("hit");
99 assert_eq!(hit.kind, HoverTargetKind::Link);
100 }
101
102 #[test]
103 fn copy_affordance_is_stable() {
104 assert_eq!(copy_affordance(), "⧉ copy");
105 }
106
107 #[test]
108 fn every_kind_hit_tests_through_the_shared_registry() {
109 // Each registered kind must resolve through the same topmost-wins
110 // hit-test so per-screen registration is one call.
111 for kind in [HoverTargetKind::Link, HoverTargetKind::TruncatedText] {
112 let targets = vec![HoverHit {
113 kind,
114 area: Rect::new(4, 1, 12, 1),
115 label: "control".into(),
116 copyable: false,
117 }];
118 let hit = hit_test(6, 1, &targets).expect("hit");
119 assert_eq!(hit.kind, kind);
120 }
121 let targets = vec![
122 HoverHit {
123 kind: HoverTargetKind::TruncatedText,
124 area: Rect::new(0, 0, 20, 1),
125 label: "row".into(),
126 copyable: false,
127 },
128 HoverHit {
129 kind: HoverTargetKind::Link,
130 area: Rect::new(2, 0, 6, 1),
131 label: "button".into(),
132 copyable: false,
133 },
134 ];
135 assert_eq!(
136 hit_test(3, 0, &targets).expect("hit").kind,
137 HoverTargetKind::Link,
138 "topmost (last registered) control wins"
139 );
140 }
141 }
142
142 lines RUST