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 at_name = self
174            .label
175            .clone()
176            .unwrap_or_else(|| LocalizedString::literal("Text scale"));
177
178        let spin = SpinBox::new(self.percent_signal.clone(), MIN_PERCENT, MAX_PERCENT)
179            .single_step(STEP_PERCENT)
180            .page_step(PAGE_PERCENT)
181            // Plain unit string — `suffix` is not localized; acceptable for a
182            // settings unit. The percent value itself is what the user reads.
183            .suffix(" %")
184            .label(at_name)
185            .on_value_changed({
186                let factor = self.factor_signal.clone();
187                move |pct, ectx| {
188                    let f = pct as f32 / 100.0;
189                    // Persist (settings-backed signals auto-save on set)…
190                    factor.set(f);
191                    // …and apply app-wide immediately (every window re-scales).
192                    ectx.set_text_scale(f);
193                }
194            });
195        let spin_id = ctx.add(spin);
196
197        let root = if let Some(label) = &self.label {
198            let label_id = ctx.add(TextWidget::new(label.clone()));
199            ctx.add(
200                HStack::new()
201                    .spacing(8.0)
202                    .add_child(label_id)
203                    .add_child(spin_id),
204            )
205        } else {
206            spin_id
207        };
208
209        self.root_child_id = Some(root);
210
211        // Attach whichever tooltip variant was set, anchored on this widget's
212        // own root (not forwarded to the inner SpinBox).
213        if let Some(content) = self.composite_tooltip_content.take() {
214            let delay = ctx.theme().motion.tooltip_delay_heavy;
215            crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
216        } else if let Some(source) = self.rich_tooltip_source.clone() {
217            let delay = ctx.theme().motion.tooltip_delay;
218            crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
219        } else if let Some(text) = self.tooltip_text.clone() {
220            let delay = ctx.theme().motion.tooltip_delay;
221            crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
222        }
223
224        vec![root]
225    }
226
227    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
228        self.root_child_id
229            .and_then(|id| ctx.child_size(id, proposal))
230            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
231            .into()
232    }
233
234    fn place_children(
235        &self,
236        bounds: Rect,
237        _proposal: SizeProposal,
238        children: &mut [WidgetPlacement],
239        _ctx: &LayoutContext,
240    ) {
241        for child in children.iter_mut() {
242            child.origin = bounds.origin();
243            child.size = bounds.size();
244        }
245    }
246
247    fn accessibility(&self, builder: &mut teksilo_core::accessibility::AccessNodeBuilder) {
248        // A labelled group wrapping the inner SpinButton.
249        builder.set_role(teksilo_core::accesskit::Role::Group);
250        if let Some(label) = &self.label {
251            builder.set_name(label.resolve_now());
252        }
253    }
254
255    fn children(&self) -> Vec<WidgetId> {
256        self.root_child_id.into_iter().collect()
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use teksilo_core::widget_tree::WidgetTree;
264    use teksilo_i18n::lit;
265
266    #[test]
267    fn factor_percent_roundtrip() {
268        assert_eq!(factor_to_percent(1.0), 100);
269        assert_eq!(factor_to_percent(1.5), 150);
270        assert_eq!(factor_to_percent(0.8), 80);
271        // Round to nearest, no truncation surprises.
272        assert_eq!(factor_to_percent(1.234), 123);
273    }
274
275    #[test]
276    fn percent_signal_seeded_from_factor() {
277        let control = TextScaleControl::new(Signal::new(1.3));
278        assert_eq!(control.percent_signal.get(), 130);
279    }
280
281    #[test]
282    fn builds_and_lays_out() {
283        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
284        let factor = Signal::new(1.0_f32);
285        let id =
286            tree.add(TextScaleControl::new(factor).label(LocalizedString::literal("Text size")));
287        tree.layout(SizeProposal::exact(400.0, 60.0));
288        let b = tree.bounds(id);
289        assert!(b.width > 0.0 && b.height > 0.0);
290    }
291
292    #[test]
293    fn external_factor_change_reflects_into_percent() {
294        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
295        let factor = Signal::new(1.0_f32);
296        let control = TextScaleControl::new(factor.clone());
297        let percent = control.percent_signal.clone();
298        tree.add(control);
299        tree.layout(SizeProposal::exact(400.0, 60.0));
300        // Simulate a settings load / cross-window fan-in.
301        factor.set(1.6);
302        assert_eq!(percent.get(), 160);
303    }
304
305    #[test]
306    fn tooltip_appears_on_hover() {
307        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
308        let id = tree.add(TextScaleControl::new(Signal::new(1.0_f32)).tooltip(lit!("Tip")));
309        tree.layout(SizeProposal::exact(300.0, 200.0));
310        tree.pointer_move(tree.bounds(id).center());
311        tree.advance_time(std::time::Duration::from_secs(1));
312        assert_eq!(
313            tree.active_overlays().len(),
314            1,
315            "tooltip should appear on hover"
316        );
317        assert!(tree.find_by_label("Tip").is_some());
318    }
319}