teksilo_widgets/
text_scale_control.rs1use 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
42const MIN_PERCENT: i32 = 80;
45const MAX_PERCENT: i32 = 200;
47const STEP_PERCENT: i32 = 10;
49const PAGE_PERCENT: i32 = 50;
51
52fn factor_to_percent(factor: f32) -> i32 {
54 (factor * 100.0).round() as i32
55}
56
57#[derive(Debug)]
63pub struct TextScaleControl {
64 factor_signal: Signal<f32>,
67 percent_signal: Signal<i32>,
70 label: Option<LocalizedString>,
72 root_child_id: Option<WidgetId>,
73 tooltip_text: Option<LocalizedString>,
77 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
81 composite_tooltip_content: Option<Box<dyn Widget>>,
85}
86
87impl TextScaleControl {
88 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 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
108 self.label = Some(label.into());
109 self
110 }
111
112 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 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 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 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 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 .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 factor.set(f);
191 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 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 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 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 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}