Skip to main content

teksilo_widgets/styles/
recipe_text_selection_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `TextSelectionStyle` impl — the shipped look of the touch selection
5//! handles and the magnifier.
6//!
7//! An accent disc on an accent stem, ringed in the surface colour so it stays
8//! visible over text of its own hue; a lens on the editor's own background with
9//! a hairline border. Apps that want another treatment write their own
10//! `impl TextSelectionStyle` and install it theme-wide
11//! (`theme.style_slots.text_selection = Some(Rc::new(MyHandles))`).
12//!
13//! The dimensions are **not** this module's to invent: they are
14//! `teksilo_core::text_touch`'s constants, because the controller hit-tests a
15//! handle from the same numbers the style paints it with, and two copies would
16//! drift. Everything this file adds is which token each part is coloured from.
17
18use teksilo_core::styles::density::dp;
19use teksilo_core::styles::{
20    RecipeColor, TextMagnifierRecipe, TextSelectionHandleRecipe, TextSelectionStyle, Theme,
21};
22use teksilo_core::text_touch::{HANDLE_DIAMETER, HANDLE_HIT_SIZE, HANDLE_STEM_WIDTH, magnifier};
23use teksilo_tokens::{BorderRole, InputTokens, SurfaceRole, TargetRole};
24
25/// Width of the ring drawn around a handle's disc, in dp.
26pub const HANDLE_OUTLINE_WIDTH: f32 = 1.0;
27
28/// Width of the magnifier's frame, in dp.
29pub const MAGNIFIER_BORDER_WIDTH: f32 = 1.0;
30
31/// Corner radius of the magnifier's frame, in dp — **zero**, so the shipped
32/// lens is a rectangle.
33///
34/// Not a stylistic preference. The clip behind the frame is rectangular and
35/// cannot be anything else (see [`teksilo_core::text_touch::magnifier`]), so a
36/// frame with radius `r` leaves `r × (√2 − 1)` dp of magnified content standing
37/// outside each of its corners — 4 dp at a 10 dp radius, which a 1 dp frame
38/// does not begin to cover. At zero the frame is exactly the clip and nothing
39/// escapes. A style that wants a rounded lens may raise this and accept the
40/// corners; it is the one number in this recipe with a correctness consequence.
41pub const MAGNIFIER_CORNER_RADIUS: f32 = 0.0;
42
43/// Dimension recipe for [`RecipeTextSelectionStyle`].
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct TextSelectionRecipe {
46    pub handle_diameter: f32,
47    pub handle_hit_size: f32,
48    pub handle_stem_width: f32,
49    pub handle_outline_width: f32,
50    pub magnifier_radius: f32,
51    pub magnifier_half_height: f32,
52    pub magnifier_scale: f32,
53    pub magnifier_rise: f32,
54    pub magnifier_corner_radius: f32,
55    pub magnifier_border_width: f32,
56}
57
58impl TextSelectionRecipe {
59    /// The shipped dimensions resolved against a density's [`InputTokens`].
60    ///
61    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
62    /// ladder — so the constants above are the Compact column by construction.
63    ///
64    /// Only the handle's **hit** extent is a
65    /// [`Target`](teksilo_tokens::TargetRole::Target). The disc, the stem and
66    /// the whole lens are decoration: they are already sized for a fingertip,
67    /// and nothing meets them but the eye.
68    pub fn for_tokens(tokens: &InputTokens) -> Self {
69        Self {
70            handle_diameter: dp(HANDLE_DIAMETER, TargetRole::Decoration, tokens),
71            handle_hit_size: dp(HANDLE_HIT_SIZE, TargetRole::Target, tokens),
72            handle_stem_width: HANDLE_STEM_WIDTH,
73            handle_outline_width: HANDLE_OUTLINE_WIDTH,
74            magnifier_radius: magnifier::MAGNIFIER_RADIUS,
75            magnifier_half_height: magnifier::MAGNIFIER_HALF_HEIGHT,
76            magnifier_scale: magnifier::MAGNIFIER_SCALE,
77            magnifier_rise: magnifier::MAGNIFIER_RISE,
78            magnifier_corner_radius: MAGNIFIER_CORNER_RADIUS,
79            magnifier_border_width: MAGNIFIER_BORDER_WIDTH,
80        }
81    }
82}
83
84impl Default for TextSelectionRecipe {
85    fn default() -> Self {
86        Self::for_tokens(&InputTokens::default())
87    }
88}
89
90/// Default `TextSelectionStyle` shipped with Teksilo. Colours come from the
91/// active theme's roles, so a theme swap repaints for free.
92#[derive(Debug, Default, Clone, Copy)]
93pub struct RecipeTextSelectionStyle {
94    pub recipe: TextSelectionRecipe,
95}
96
97impl RecipeTextSelectionStyle {
98    pub fn new(recipe: TextSelectionRecipe) -> Self {
99        Self { recipe }
100    }
101
102    /// This style with every dimension resolved against a density's
103    /// [`InputTokens`] — `RecipeTextSelectionStyle::for_tokens(&ctx.theme().input)`
104    /// at the host's own build site.
105    pub fn for_tokens(tokens: &InputTokens) -> Self {
106        Self {
107            recipe: TextSelectionRecipe::for_tokens(tokens),
108        }
109    }
110}
111
112impl TextSelectionStyle for RecipeTextSelectionStyle {
113    fn handle(&self, _theme: &Theme) -> TextSelectionHandleRecipe {
114        TextSelectionHandleRecipe {
115            diameter: self.recipe.handle_diameter,
116            hit_size: self.recipe.handle_hit_size,
117            stem_width: self.recipe.handle_stem_width,
118            fill: RecipeColor::Surface(SurfaceRole::Accent),
119            // The ring is the surface behind the text, not a border colour: its
120            // job is to separate the disc from the glyphs it sits on, which the
121            // page's own background does better than any line would.
122            outline: RecipeColor::Surface(SurfaceRole::EditorBg),
123            outline_width: self.recipe.handle_outline_width,
124        }
125    }
126
127    fn magnifier(&self, _theme: &Theme) -> TextMagnifierRecipe {
128        TextMagnifierRecipe {
129            radius: self.recipe.magnifier_radius,
130            half_height: self.recipe.magnifier_half_height,
131            scale: self.recipe.magnifier_scale,
132            rise: self.recipe.magnifier_rise,
133            corner_radius: self.recipe.magnifier_corner_radius,
134            background: RecipeColor::Surface(SurfaceRole::EditorBg),
135            border: RecipeColor::Border(BorderRole::Default),
136            border_width: self.recipe.magnifier_border_width,
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use teksilo_tokens::TargetDensity;
145
146    /// The shipped constants are the Compact column, by construction rather
147    /// than by a second table someone has to keep in step.
148    #[test]
149    fn the_default_recipe_is_the_compact_projection() {
150        assert_eq!(
151            TextSelectionRecipe::default(),
152            TextSelectionRecipe::for_tokens(&InputTokens::for_density(TargetDensity::Compact))
153        );
154        assert_eq!(
155            TextSelectionRecipe::default().handle_diameter,
156            HANDLE_DIAMETER
157        );
158        assert_eq!(
159            TextSelectionRecipe::default().handle_hit_size,
160            HANDLE_HIT_SIZE
161        );
162    }
163
164    /// The controller hit-tests a handle from the recipe the style paints it
165    /// with, so the two must be the same numbers at every density.
166    #[test]
167    fn the_controllers_metrics_come_from_this_recipe() {
168        for density in [
169            TargetDensity::Compact,
170            TargetDensity::Comfortable,
171            TargetDensity::Touch,
172        ] {
173            let tokens = InputTokens::for_density(density);
174            let style = RecipeTextSelectionStyle::for_tokens(&tokens);
175            let recipe = style.handle(&teksilo_core::presets::intui::light());
176            let metrics = teksilo_core::text_touch::HandleMetrics::from(&recipe);
177            assert_eq!(
178                metrics,
179                teksilo_core::text_touch::HandleMetrics::for_tokens(&tokens),
180                "{density:?}"
181            );
182        }
183    }
184
185    /// The shipped lens is rectangular because its clip is. A non-zero radius
186    /// would leave `r × (√2 − 1)` dp of magnified content outside each corner
187    /// of the frame, with nothing in the pipeline able to remove it.
188    #[test]
189    fn the_shipped_lens_frame_matches_the_rectangular_clip() {
190        assert_eq!(TextSelectionRecipe::default().magnifier_corner_radius, 0.0);
191    }
192
193    /// A handle's target may never fall below the WCAG 2.2 minimum, at any
194    /// density.
195    #[test]
196    fn a_handles_target_clears_the_conformance_floor_at_every_density() {
197        for density in [
198            TargetDensity::Compact,
199            TargetDensity::Comfortable,
200            TargetDensity::Touch,
201        ] {
202            let tokens = InputTokens::for_density(density);
203            let recipe = TextSelectionRecipe::for_tokens(&tokens);
204            assert!(
205                recipe.handle_hit_size >= tokens.min_target_conformance,
206                "{density:?}: {} dp target",
207                recipe.handle_hit_size
208            );
209        }
210    }
211}