Skip to main content

teksilo_widgets/
text_scale_control.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`TextScaleControl`] — the settings control that grows all text in the app.
5//!
6//! Drop this into a preferences/settings window to let low-vision users scale
7//! every piece of text uniformly (the framework multiplies the active theme's
8//! typography by the chosen factor — see
9//! [`WidgetTree::set_user_text_scale`](teksilo_core::widget_tree::WidgetTree::set_user_text_scale)).
10//! It is a thin specialization of [`SpinBox`] that displays a percent
11//! (80 %–200 %, step 10 %) and, on each edit, both **persists** the value and
12//! **applies it app-wide** — so the developer only has to place the widget.
13//!
14//! Bind it to the persisted factor signal, typically the settings-backed
15//! `teksilo_settings::TEXT_SCALE_KEY`:
16//!
17//! ```ignore
18//! use teksilo::prelude::*;
19//! use teksilo::widgets::TextScaleControl;
20//!
21//! // inside build():
22//! let scale = ctx.settings().signal_for(&teksilo_settings::TEXT_SCALE_KEY);
23//! ctx.add(TextScaleControl::new(scale).label(tr!(text_size())));
24//! ```
25//!
26//! Writing the bound signal triggers the `SettingsStore`'s debounced auto-save
27//! (persistence), and the widget's `on_value_changed` calls
28//! [`EventContext::set_text_scale`](teksilo_core::widget::EventContext::set_text_scale)
29//! (immediate app-wide application). At startup `teksilo-app` reads the saved
30//! key and seeds every window, so the chosen size is restored automatically.
31
32use teksilo_canvas::{Rect, SizeProposal};
33use teksilo_core::build_context::BuildContext;
34use teksilo_core::signal::Signal;
35use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
36use teksilo_core::widget_id::WidgetId;
37use teksilo_i18n::LocalizedString;
38
39use crate::primitives::{HStack, TextWidget};
40use crate::spin_box::SpinBox;
41
42/// Lowest user-selectable scale, as a percent. The control is grow-oriented but
43/// allows a slight shrink for users who prefer a denser UI.
44const MIN_PERCENT: i32 = 80;
45/// Highest user-selectable scale, as a percent (2× the base size).
46const MAX_PERCENT: i32 = 200;
47/// Single-step increment, as a percent.
48const STEP_PERCENT: i32 = 10;
49/// Page-step increment (PageUp/PageDown), as a percent.
50const PAGE_PERCENT: i32 = 50;
51
52/// Convert a scale factor (`1.0` = 100 %) to a rounded integer percent.
53fn factor_to_percent(factor: f32) -> i32 {
54    (factor * 100.0).round() as i32
55}
56
57/// A specialized [`SpinBox`] for the global user text-scale setting.
58///
59/// See the [module docs](self) for the persistence + app-wide application
60/// contract. Construct with [`TextScaleControl::new`], optionally attach a
61/// visible [`label`](TextScaleControl::label), and place it in a settings view.
62#[derive(Debug)]
63pub struct TextScaleControl {
64    /// The bound scale factor (`1.0` = 100 %). Usually the settings-backed
65    /// signal so edits persist; writes also flow out via `set_text_scale`.
66    factor_signal: Signal<f32>,
67    /// Internal percent view bridged to `factor_signal`, driving the inner
68    /// `SpinBox<i32>`.
69    percent_signal: Signal<i32>,
70    /// Optional visible label rendered to the leading side of the spinbox.
71    label: Option<LocalizedString>,
72    root_child_id: Option<WidgetId>,
73    /// Optional plain tooltip text shown after a hover delay.
74    /// Mutually exclusive with `rich_tooltip_source` and
75    /// `composite_tooltip_content` — every tooltip setter clears the other two.
76    tooltip_text: Option<LocalizedString>,
77    /// Optional rich tooltip source (registry key or inline content).
78    /// Mutually exclusive with `tooltip_text` and `composite_tooltip_content`
79    /// — every tooltip setter clears the other two so last-call wins.
80    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
81    /// Optional composite tooltip body. Hosts an arbitrary widget inside the
82    /// tooltip overlay. Mutually exclusive with `tooltip_text` and
83    /// `rich_tooltip_source` per the last-call-wins contract.
84    composite_tooltip_content: Option<Box<dyn Widget>>,
85}
86
87impl TextScaleControl {
88    /// Construct bound to `factor_signal` (a scale factor where `1.0` = 100 %).
89    ///
90    /// Pass `ctx.settings().signal_for(&teksilo_settings::TEXT_SCALE_KEY)` to get
91    /// automatic persistence; any `Signal<f32>` works for ad-hoc / preview use.
92    pub fn new(factor_signal: Signal<f32>) -> Self {
93        let percent = factor_to_percent(factor_signal.get());
94        Self {
95            factor_signal,
96            percent_signal: Signal::new(percent),
97            label: None,
98            root_child_id: None,
99            tooltip_text: None,
100            rich_tooltip_source: None,
101            composite_tooltip_content: None,
102        }
103    }
104
105    /// Attach a visible label placed to the leading side of the spinbox
106    /// (e.g. `tr!(text_size())`). Also used as the control's accessible name.
107    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
108        self.label = Some(label.into());
109        self
110    }
111
112    /// Attach a plain tooltip that appears after a hover delay.
113    ///
114    /// Clears any previously set rich or composite tooltip (last-call wins).
115    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
116        self.tooltip_text = Some(text.into());
117        self.rich_tooltip_source = None;
118        self.composite_tooltip_content = None;
119        self
120    }
121
122    /// Attach a rich tooltip resolved from the app-wide tooltip registry.
123    ///
124    /// `key` is looked up in the
125    /// [`TooltipRegistry`](crate::tooltip::TooltipRegistry) at build time.
126    /// Clears any previously set plain or composite tooltip (last-call wins).
127    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
128        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
129        self.tooltip_text = None;
130        self.composite_tooltip_content = None;
131        self
132    }
133
134    /// Attach a rich tooltip driven by inline
135    /// [`TooltipContent`](crate::tooltip::TooltipContent).
136    ///
137    /// Clears any previously set plain or composite tooltip (last-call wins).
138    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
139        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
140        self.tooltip_text = None;
141        self.composite_tooltip_content = None;
142        self
143    }
144
145    /// Attach a composite tooltip that hosts an arbitrary widget body.
146    ///
147    /// The `content` widget is rendered inside the tooltip overlay after the
148    /// heavy hover delay. Clears any previously set plain or rich tooltip
149    /// (last-call wins).
150    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
151        self.composite_tooltip_content = Some(Box::new(content));
152        self.tooltip_text = None;
153        self.rich_tooltip_source = None;
154        self
155    }
156}
157
158impl Widget for TextScaleControl {
159    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
160        // Reflect external factor changes (settings load, another window's edit
161        // fanned in) into the percent view. Guarded so the round-trip from an
162        // in-widget edit (percent → factor → here) does not re-enter.
163        ctx.effect(&self.factor_signal, {
164            let percent = self.percent_signal.clone();
165            move |factor| {
166                let pct = factor_to_percent(*factor);
167                if percent.get() != pct {
168                    percent.set(pct);
169                }
170            }
171        });
172
173        let mut spin = SpinBox::new(self.percent_signal.clone(), MIN_PERCENT, MAX_PERCENT)
174            .single_step(STEP_PERCENT)
175            .page_step(PAGE_PERCENT)
176            // Plain unit string — `suffix` is not localized; acceptable for a
177            // settings unit. The percent value itself is what the user reads.
178            .suffix(" %");
179        // With a visible label the control is named by pointing at it (below);
180        // a name set here would win over the relation in the consumer and
181        // announce a copy that no longer tracks the label. Without one there
182        // is nothing to point at, so the fallback string stands.
183        if self.label.is_none() {
184            spin = spin.label(LocalizedString::literal("Text scale"));
185        }
186        let spin = spin.on_value_changed({
187            let factor = self.factor_signal.clone();
188            move |pct, ectx| {
189                let f = pct as f32 / 100.0;
190                // Persist (settings-backed signals auto-save on set)…
191                factor.set(f);
192                // …and apply app-wide immediately (every window re-scales).
193                ectx.set_text_scale(f);
194            }
195        });
196        let spin_id = ctx.add(spin);
197
198        let root = if let Some(label) = &self.label {
199            let label_id = ctx.add(TextWidget::new(label.clone()));
200            // Name the control from the label it already paints instead of
201            // from a copy: the label then stays reachable and reviewable in
202            // its own right, and one string cannot drift from the other. The
203            // enclosing group takes its name from the same place.
204            ctx.access_labelled_by(spin_id, label_id);
205            let self_id = ctx.self_id();
206            ctx.access_labelled_by(self_id, label_id);
207            ctx.add(HStack::new().spacing(8.0).child(label_id).child(spin_id))
208        } else {
209            spin_id
210        };
211
212        self.root_child_id = Some(root);
213
214        // Attach whichever tooltip variant was set, anchored on this widget's
215        // own root (not forwarded to the inner SpinBox).
216        if let Some(content) = self.composite_tooltip_content.take() {
217            let delay = ctx.theme().motion.tooltip_delay_heavy;
218            crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
219        } else if let Some(source) = self.rich_tooltip_source.clone() {
220            let delay = ctx.theme().motion.tooltip_delay;
221            crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
222        } else if let Some(text) = self.tooltip_text.clone() {
223            let delay = ctx.theme().motion.tooltip_delay;
224            crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
225        }
226
227        vec![root]
228    }
229
230    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
231        self.root_child_id
232            .and_then(|id| ctx.child_size(id, proposal))
233            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
234            .into()
235    }
236
237    fn place_children(
238        &self,
239        bounds: Rect,
240        _proposal: SizeProposal,
241        children: &mut [WidgetPlacement],
242        _ctx: &LayoutContext,
243    ) {
244        for child in children.iter_mut() {
245            child.origin = bounds.origin();
246            child.size = bounds.size();
247        }
248    }
249
250    fn accessibility(&self, builder: &mut teksilo_core::accessibility::AccessNodeBuilder) {
251        // A group wrapping the inner SpinButton, named — like the spin box
252        // itself — by pointing at the label it already paints. Copying the
253        // string here instead made a reader hear it twice on the way in,
254        // once for the group and once for the label under it.
255        builder.set_role(teksilo_core::accesskit::Role::Group);
256    }
257
258    fn children(&self) -> Vec<WidgetId> {
259        self.root_child_id.into_iter().collect()
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use teksilo_core::widget_tree::WidgetTree;
267    use teksilo_i18n::lit;
268
269    #[test]
270    fn factor_percent_roundtrip() {
271        assert_eq!(factor_to_percent(1.0), 100);
272        assert_eq!(factor_to_percent(1.5), 150);
273        assert_eq!(factor_to_percent(0.8), 80);
274        // Round to nearest, no truncation surprises.
275        assert_eq!(factor_to_percent(1.234), 123);
276    }
277
278    #[test]
279    fn percent_signal_seeded_from_factor() {
280        let control = TextScaleControl::new(Signal::new(1.3));
281        assert_eq!(control.percent_signal.get(), 130);
282    }
283
284    #[test]
285    fn builds_and_lays_out() {
286        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
287        let factor = Signal::new(1.0_f32);
288        let id =
289            tree.add(TextScaleControl::new(factor).label(LocalizedString::literal("Text size")));
290        tree.layout(SizeProposal::exact(400.0, 60.0));
291        let b = tree.bounds(id);
292        assert!(b.width > 0.0 && b.height > 0.0);
293    }
294
295    #[test]
296    fn external_factor_change_reflects_into_percent() {
297        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
298        let factor = Signal::new(1.0_f32);
299        let control = TextScaleControl::new(factor.clone());
300        let percent = control.percent_signal.clone();
301        tree.add(control);
302        tree.layout(SizeProposal::exact(400.0, 60.0));
303        // Simulate a settings load / cross-window fan-in.
304        factor.set(1.6);
305        assert_eq!(percent.get(), 160);
306    }
307
308    #[test]
309    fn tooltip_appears_on_hover() {
310        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
311        let id = tree.add(TextScaleControl::new(Signal::new(1.0_f32)).tooltip(lit!("Tip")));
312        tree.layout(SizeProposal::exact(300.0, 200.0));
313        tree.pointer_move(tree.bounds(id).center());
314        tree.advance_time(std::time::Duration::from_secs(1));
315        assert_eq!(
316            tree.active_overlays().len(),
317            1,
318            "tooltip should appear on hover"
319        );
320        assert!(tree.find_by_label("Tip").is_some());
321    }
322}