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}