Skip to main content

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}