rosace_widgets/tree/selection.rs
1//! Theme-driven text-selection styling (D105 ext pattern, D124 follow-up).
2//!
3//! Registered ONCE on the theme — never per-widget:
4//!
5//! ```rust,ignore
6//! let theme = dark_theme().with_ext(SelectionStyle::glass());
7//! ```
8//!
9//! `TextInput`/`TextArea` resolve it from `ctx.theme.ext::<SelectionStyle>()`
10//! at paint time; no style registered means [`SelectionStyle::flat`] — the
11//! exact pre-existing look, so apps that never touch this see zero change.
12//!
13//! Two built-in kinds:
14//! - [`SelectionKind::Flat`] — Material-style: flat tint band behind the
15//! glyphs + round drag grips below each endpoint.
16//! - [`SelectionKind::Glass`] — Liquid-Glass-style: softer tint, iOS-style
17//! lollipop handles (vertical bar through the line, grip at the bottom
18//! anchor the engine's handle-drag already targets), and a
19//! backdrop-sampling MAGNIFIER pill over the selected text — the Phase
20//! 28 Step 7 magnifier, finally landed. Single-line fields only; a
21//! multi-line lens (TextArea) is deferred with the same GPU-path notes
22//! as every backdrop material.
23
24use rosace_render::Color;
25
26/// Which selection look to render.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum SelectionKind {
29 /// Flat tint + round grips (the Material/default look).
30 Flat,
31 /// Glass: soft tint, lollipop handles, magnifier lens over the
32 /// selection (GPU/backdrop path; degrades to the tint alone elsewhere).
33 Glass,
34}
35
36/// Theme-extension value carrying the selection look — see the module doc.
37#[derive(Clone, Debug, PartialEq)]
38pub struct SelectionStyle {
39 pub kind: SelectionKind,
40 /// Tint painted behind the selected glyphs.
41 pub highlight: Color,
42 /// Handle grip/bar color.
43 pub handle: Color,
44 /// Glass only: lens magnification (1.0 = no zoom).
45 pub zoom: f32,
46}
47
48impl SelectionStyle {
49 /// The Material/default look — exactly the colors the widgets used
50 /// before selection became themeable.
51 pub fn flat() -> Self {
52 Self {
53 kind: SelectionKind::Flat,
54 highlight: Color::rgba(110, 75, 210, 90),
55 handle: Color::rgb(180, 160, 255),
56 zoom: 1.0,
57 }
58 }
59
60 /// The Liquid-Glass look: cooler, softer tint; near-white glass
61 /// handles; a magnifier lens over the selected run. Zoom is a subtle
62 /// 1.15x "lift" — a stronger lens crops the ends of the selection out
63 /// of the pill (physically correct, but wrong as selection UX; found
64 /// live at 1.35x).
65 pub fn glass() -> Self {
66 Self {
67 kind: SelectionKind::Glass,
68 // A STRONG dark band, on purpose: the lens magnifies whatever
69 // sits behind the glyphs, and the backdrop under a glass
70 // surface can be arbitrarily bright (found live — a bright
71 // aurora blob drifting behind the input washed the magnified
72 // text out with the earlier subtle tints). A near-opaque dark
73 // band guarantees light-theme glyph contrast inside the lens
74 // no matter what the scene does, and reads clearly as the
75 // selection on paths where the lens can't render.
76 highlight: Color::rgba(28, 36, 110, 120),
77 handle: Color::rgba(235, 240, 255, 230),
78 zoom: 1.25,
79 }
80 }
81
82 /// Pack the lens uniforms (`radius`, `zoom` + two pad scalars) in the
83 /// WGSL layout `builtin::SELECTION_LENS` declares — four tightly
84 /// packed f32s, one 16-byte uniform row.
85 pub fn lens_uniforms(radius: f32, zoom: f32) -> Vec<u8> {
86 let mut out = Vec::with_capacity(16);
87 for v in [radius, zoom, 0.0f32, 0.0f32] {
88 out.extend_from_slice(&v.to_le_bytes());
89 }
90 out
91 }
92
93 /// The glass lens geometry for a single-line selection spanning
94 /// `x0..x1` on a line at `y_top` with height `line_h` — the pill rect,
95 /// the end-bar x positions, and the grip anchor y. Used by BOTH
96 /// `TextInput`'s painting AND the engine's handle-drag grab, so the
97 /// visible grips and the draggable anchors can never drift apart
98 /// (found live: grips pinned at the unzoomed endpoints floated far
99 /// inside the pill on wide selections, disconnected from the bars).
100 pub fn glass_lens(&self, x0: f32, x1: f32, y_top: f32, line_h: f32) -> GlassLens {
101 let cx = (x0 + x1) * 0.5;
102 let cy = y_top + line_h * 0.5;
103 let w = (x1 - x0) * self.zoom + 8.0;
104 let h = line_h * self.zoom + 6.0;
105 let rect = (cx - w / 2.0, cy - h / 2.0, w, h);
106 GlassLens {
107 rect,
108 bar_x: (rect.0 + 3.5, rect.0 + w - 3.5),
109 grip_y: cy + h / 2.0,
110 }
111 }
112}
113
114/// See [`SelectionStyle::glass_lens`]. Plain data, `(x, y, w, h)` rects.
115#[derive(Clone, Copy, Debug, PartialEq)]
116pub struct GlassLens {
117 pub rect: (f32, f32, f32, f32),
118 /// Left and right end-bar center x — the visual cursors, at the pill's
119 /// outer edges, always bounding every magnified glyph.
120 pub bar_x: (f32, f32),
121 /// Grip circle center y — hanging at the pill's bottom edge, directly
122 /// under each bar (the lollipop). The engine grabs here too.
123 pub grip_y: f32,
124}
125
126impl Default for SelectionStyle {
127 fn default() -> Self {
128 Self::flat()
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn default_is_the_flat_pre_themeable_look() {
138 let s = SelectionStyle::default();
139 assert_eq!(s.kind, SelectionKind::Flat);
140 assert_eq!(s.highlight, Color::rgba(110, 75, 210, 90));
141 assert_eq!(s.handle, Color::rgb(180, 160, 255));
142 }
143
144 #[test]
145 fn glass_kind_carries_a_real_zoom() {
146 let s = SelectionStyle::glass();
147 assert_eq!(s.kind, SelectionKind::Glass);
148 assert!(s.zoom > 1.0, "a lens that doesn't magnify isn't a lens");
149 }
150
151 #[test]
152 fn glass_lens_bounds_the_magnified_selection_with_connected_lollipops() {
153 let st = SelectionStyle::glass();
154 let g = st.glass_lens(100.0, 200.0, 50.0, 20.0);
155 // Pill spans the MAGNIFIED selection (plus padding), centered.
156 assert!(g.rect.2 > (200.0 - 100.0) * st.zoom, "pill must fit sel*zoom");
157 assert!((g.rect.0 + g.rect.2 / 2.0 - 150.0).abs() < 0.01, "centered on selection");
158 // Bars sit INSIDE the pill, grips hang at its bottom edge.
159 assert!(g.bar_x.0 > g.rect.0 && g.bar_x.1 < g.rect.0 + g.rect.2);
160 assert!((g.grip_y - (g.rect.1 + g.rect.3)).abs() < 0.01, "grip at pill bottom");
161 }
162
163 #[test]
164 fn lens_uniforms_pack_radius_then_zoom_in_sixteen_bytes() {
165 let b = SelectionStyle::lens_uniforms(12.0, 1.35);
166 assert_eq!(b.len(), 16);
167 assert_eq!(&b[0..4], &12.0f32.to_le_bytes());
168 assert_eq!(&b[4..8], &1.35f32.to_le_bytes());
169 assert_eq!(&b[8..16], &[0u8; 8]);
170 }
171}