1pub mod alpha_strip;
35pub mod hsv_canvas;
36pub mod hue_strip;
37pub mod state;
38pub mod swatch;
39pub mod swatch_grid;
40
41#[cfg(test)]
42mod tests;
43
44use std::cell::RefCell;
45use std::rc::Rc;
46use teksilo_i18n::lit;
47use teksilo_i18n::localized;
48
49use teksilo_canvas::{Rect, SizeProposal};
50use teksilo_core::accessibility::AccessNodeBuilder;
51use teksilo_core::accesskit::{Action, Live, Role};
52use teksilo_core::build_context::BuildContext;
53use teksilo_core::signal::{Prop, Signal};
54use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
55use teksilo_core::widget_id::WidgetId;
56use teksilo_i18n::{LocalizedString, resolve_message_widget};
57use teksilo_tokens::{Color, Orientation};
58
59use self::alpha_strip::AlphaStrip;
60use self::hsv_canvas::HsvCanvas;
61use self::hue_strip::HueStrip;
62use self::state::ColorComponents;
63use self::swatch_grid::SwatchGrid;
64use crate::button::{Button, ButtonVariant};
65use crate::hex_color_input::HexColorInput;
66use crate::primitives::{HStack, Spacer, TextWidget};
67use crate::spin_box::SpinBox;
68
69pub use self::swatch::ColorSwatch;
70
71pub const DEFAULT_SWATCHES: [Color; 12] = [
74 Color::new(0.91, 0.30, 0.24, 1.0), Color::new(0.95, 0.60, 0.20, 1.0), Color::new(0.96, 0.83, 0.27, 1.0), Color::new(0.42, 0.70, 0.35, 1.0), Color::new(0.20, 0.66, 0.61, 1.0), Color::new(0.21, 0.52, 0.89, 1.0), Color::new(0.36, 0.36, 0.83, 1.0), Color::new(0.66, 0.40, 0.85, 1.0), Color::new(0.92, 0.45, 0.68, 1.0), Color::new(0.55, 0.36, 0.20, 1.0), Color::new(0.06, 0.06, 0.06, 1.0), Color::new(0.96, 0.96, 0.96, 1.0), ];
87
88pub use teksilo_core::styles::ColorPickerLayout;
89
90#[derive(Clone)]
96enum ColorBinding {
97 Required(Signal<Color>),
98 Nullable {
99 source: Signal<Option<Color>>,
100 proxy: Signal<Color>,
101 },
102}
103
104impl ColorBinding {
105 fn value(&self) -> Signal<Color> {
106 match self {
107 Self::Required(s) => s.clone(),
108 Self::Nullable { proxy, .. } => proxy.clone(),
109 }
110 }
111}
112
113pub struct ColorPicker {
130 binding: ColorBinding,
131 alpha_enabled: bool,
132 show_hsv_canvas: bool,
133 show_hue_strip: bool,
134 show_alpha_strip: Option<bool>,
135 show_rgb_spinners: bool,
136 show_hsv_spinners: bool,
137 show_hex_input: bool,
138 show_preview: bool,
139 show_swatches: bool,
140 show_footer: bool,
141 on_done: Option<Rc<dyn Fn(&mut EventContext)>>,
142 on_cancel: Option<Rc<dyn Fn(&mut EventContext)>>,
143 swatches: Prop<Vec<Color>>,
144 swatch_columns: usize,
145 layout: ColorPickerLayout,
146 label: Option<LocalizedString>,
147 enabled: Prop<bool>,
150 last_announced_hex: Rc<RefCell<Option<String>>>,
157 style_override: Option<teksilo_core::styles::SharedColorPickerStyle>,
161 root_child_id: Option<WidgetId>,
162 tooltip_text: Option<LocalizedString>,
166 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
168 composite_tooltip_content: Option<Box<dyn Widget>>,
170}
171
172impl ColorPicker {
173 pub fn new(value: Signal<Color>) -> Self {
175 Self::from_binding(ColorBinding::Required(value))
176 }
177
178 pub fn nullable(value: Signal<Option<Color>>) -> Self {
184 let proxy = Signal::new(value.get().unwrap_or(Color::TRANSPARENT));
185 Self::from_binding(ColorBinding::Nullable {
186 source: value,
187 proxy,
188 })
189 }
190
191 fn from_binding(binding: ColorBinding) -> Self {
192 Self {
193 binding,
194 alpha_enabled: false,
195 show_hsv_canvas: true,
196 show_hue_strip: true,
197 show_alpha_strip: None, show_rgb_spinners: true,
199 show_hsv_spinners: false,
200 show_hex_input: true,
201 show_preview: true,
202 show_swatches: true,
203 show_footer: false,
204 on_done: None,
205 on_cancel: None,
206 swatches: Prop::Static(DEFAULT_SWATCHES.to_vec()),
207 swatch_columns: 6,
208 layout: ColorPickerLayout::Standard,
209 label: None,
210 enabled: Prop::Static(true),
211 last_announced_hex: Rc::new(RefCell::new(None)),
212 style_override: None,
213 root_child_id: None,
214 tooltip_text: None,
215 rich_tooltip_source: None,
216 composite_tooltip_content: None,
217 }
218 }
219
220 pub fn style(mut self, style: impl teksilo_core::styles::ColorPickerStyle) -> Self {
223 self.style_override = Some(Rc::new(style));
224 self
225 }
226
227 pub fn alpha_enabled(mut self, e: bool) -> Self {
229 self.alpha_enabled = e;
230 self
231 }
232
233 pub fn show_hsv_canvas(mut self, s: bool) -> Self {
237 self.show_hsv_canvas = s;
238 self
239 }
240
241 pub fn show_hue_strip(mut self, s: bool) -> Self {
243 self.show_hue_strip = s;
244 self
245 }
246
247 pub fn show_alpha_strip(mut self, s: bool) -> Self {
251 self.show_alpha_strip = Some(s);
252 self
253 }
254
255 pub fn show_rgb_spinners(mut self, s: bool) -> Self {
257 self.show_rgb_spinners = s;
258 self
259 }
260
261 pub fn show_hsv_spinners(mut self, s: bool) -> Self {
263 self.show_hsv_spinners = s;
264 self
265 }
266
267 pub fn show_hex_input(mut self, s: bool) -> Self {
269 self.show_hex_input = s;
270 self
271 }
272
273 pub fn show_preview(mut self, s: bool) -> Self {
275 self.show_preview = s;
276 self
277 }
278
279 pub fn show_swatches(mut self, s: bool) -> Self {
281 self.show_swatches = s;
282 self
283 }
284
285 pub fn show_footer(mut self, s: bool) -> Self {
293 self.show_footer = s;
294 self
295 }
296
297 pub fn on_done(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
303 self.on_done = Some(Rc::new(f));
304 self
305 }
306
307 pub fn on_cancel(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
315 self.on_cancel = Some(Rc::new(f));
316 self
317 }
318
319 pub fn swatches(mut self, s: impl Into<Prop<Vec<Color>>>) -> Self {
323 self.swatches = s.into();
324 self
325 }
326
327 pub fn swatch_columns(mut self, n: usize) -> Self {
330 self.swatch_columns = n.max(1);
331 self
332 }
333
334 pub fn layout(mut self, l: ColorPickerLayout) -> Self {
336 self.layout = l;
337 self
338 }
339
340 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
343 self.label = Some(label.into());
344 self
345 }
346
347 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
350 self.enabled = enabled.into();
351 self
352 }
353
354 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
359 self.tooltip_text = Some(text.into());
360 self.rich_tooltip_source = None;
361 self.composite_tooltip_content = None;
362 self
363 }
364
365 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
369 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
370 self.tooltip_text = None;
371 self.composite_tooltip_content = None;
372 self
373 }
374
375 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
379 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
380 self.tooltip_text = None;
381 self.composite_tooltip_content = None;
382 self
383 }
384
385 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
389 self.composite_tooltip_content = Some(Box::new(content));
390 self.tooltip_text = None;
391 self.rich_tooltip_source = None;
392 self
393 }
394
395 pub fn current(&self) -> Color {
399 self.binding.value().get()
400 }
401}
402
403impl std::fmt::Debug for ColorPicker {
404 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 f.debug_struct("ColorPicker")
406 .field("alpha_enabled", &self.alpha_enabled)
407 .field("layout", &self.layout)
408 .field("enabled", &self.enabled.get())
409 .finish_non_exhaustive()
410 }
411}
412
413impl Widget for ColorPicker {
414 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
415 if let ColorBinding::Nullable { source, proxy } = &self.binding {
417 {
419 let proxy = proxy.clone();
420 ctx.effect(source, move |new| {
421 let resolved = new.unwrap_or(Color::TRANSPARENT);
422 if proxy.get() != resolved {
423 proxy.set(resolved);
424 }
425 });
426 }
427 {
429 let source = source.clone();
430 ctx.effect(proxy, move |c| {
431 if source.get() != Some(*c) {
432 source.set(Some(*c));
433 }
434 });
435 }
436 }
437
438 let value = self.binding.value();
439 let components = Rc::new(ColorComponents::new(ctx, value.clone()));
440
441 {
449 let dragging = components.dragging.clone();
450 let last_announced = self.last_announced_hex.clone();
451 let alpha = self.alpha_enabled;
452 ctx.effect(&value, move |c| {
453 if dragging.get() {
454 return;
455 }
456 let hex = c.to_hex_upper(alpha);
457 let needs_update = last_announced.borrow().as_deref() != Some(hex.as_str());
458 if needs_update {
459 *last_announced.borrow_mut() = Some(hex);
460 }
461 });
462 }
463
464 let self_id = ctx.self_id();
465 ctx.enabled_when(self_id, self.enabled.clone());
472
473 let alpha_enabled = self.alpha_enabled;
475 let show_alpha_strip = self.show_alpha_strip.unwrap_or(alpha_enabled);
476 let layout = self.layout;
477 let enabled = self.enabled.get();
478 use crate::styles::recipe_color_picker_style as cp;
479
480 let mut top_row = HStack::new().spacing(cp::GAP);
484 if self.show_hsv_canvas {
485 let canvas = HsvCanvas::new(
486 components.hue.clone(),
487 components.saturation.clone(),
488 components.value_hsv.clone(),
489 components.set_hsv.clone(),
490 components.dragging.clone(),
491 )
492 .enabled(enabled);
493 use teksilo_core::widget_builder::WidgetBuilder;
496 top_row = top_row.child(canvas.access_exclude_subtree());
497 }
498 if self.show_hue_strip {
499 let hue = HueStrip::new(
500 components.hue.clone(),
501 components.set_hue.clone(),
502 components.dragging.clone(),
503 )
504 .orientation(Orientation::Vertical)
505 .enabled(enabled)
506 .label(resolve_message_widget("color-picker-hue-label", &[]));
507 top_row = top_row.child(hue);
508 }
509 if alpha_enabled && show_alpha_strip {
510 let alpha = AlphaStrip::new(
511 value.clone(),
512 components.alpha.clone(),
513 components.set_alpha.clone(),
514 components.dragging.clone(),
515 )
516 .orientation(Orientation::Vertical)
517 .enabled(enabled)
518 .label(resolve_message_widget("color-picker-alpha-label", &[]));
519 top_row = top_row.child(alpha);
520 }
521 let top_row_id = ctx.add(top_row);
522
523 let preview_row_id: Option<WidgetId> =
526 if layout != ColorPickerLayout::Compact && (self.show_preview || self.show_hex_input) {
527 let mut row = HStack::new().spacing(cp::GAP);
528 if self.show_preview {
529 row = row.child(
530 ColorSwatch::new(value.clone())
531 .size(cp::PREVIEW_HEIGHT)
532 .corner_radius(cp::PREVIEW_CORNER_RADIUS)
533 .label(localized(move || {
534 resolve_message_widget("color-picker-current-color-label", &[])
535 })),
536 );
537 }
538 if self.show_hex_input {
539 let hex = HexColorInput::new(value.clone())
540 .alpha_enabled(alpha_enabled)
541 .label(localized(move || {
542 resolve_message_widget("color-picker-hex-label", &[])
543 }))
544 .width(cp::HEX_FIELD_WIDTH);
545 row = row.child(hex);
546 }
547 Some(ctx.add(row))
548 } else {
549 None
550 };
551
552 let rgb_row_id: Option<WidgetId> =
560 if layout != ColorPickerLayout::Compact && self.show_rgb_spinners {
561 let r_spin = make_byte_spinner_from_value(
562 ctx,
563 value.clone(),
564 |c| c.r(),
565 components.set_red.clone(),
566 enabled,
567 cp::SPINNER_FIELD_WIDTH,
568 );
569 let g_spin = make_byte_spinner_from_value(
570 ctx,
571 value.clone(),
572 |c| c.g(),
573 components.set_green.clone(),
574 enabled,
575 cp::SPINNER_FIELD_WIDTH,
576 );
577 let b_spin = make_byte_spinner_from_value(
578 ctx,
579 value.clone(),
580 |c| c.b(),
581 components.set_blue.clone(),
582 enabled,
583 cp::SPINNER_FIELD_WIDTH,
584 );
585 let mut row = HStack::new()
586 .spacing(cp::GAP)
587 .child(spinner_cell("color-picker-red-short", r_spin))
588 .child(spinner_cell("color-picker-green-short", g_spin))
589 .child(spinner_cell("color-picker-blue-short", b_spin));
590 if alpha_enabled {
591 let a_spin = make_byte_spinner_from_value(
592 ctx,
593 value.clone(),
594 |c| c.a(),
595 components.set_alpha.clone(),
596 enabled,
597 cp::SPINNER_FIELD_WIDTH,
598 );
599 row = row.child(spinner_cell("color-picker-alpha-short", a_spin));
600 }
601 Some(ctx.add(row))
602 } else {
603 None
604 };
605
606 let hsv_row_id: Option<WidgetId> =
608 if layout != ColorPickerLayout::Compact && self.show_hsv_spinners {
609 let h_spin = make_hue_spinner_from_value(
610 ctx,
611 value.clone(),
612 components.set_hue.clone(),
613 enabled,
614 cp::SPINNER_FIELD_WIDTH,
615 );
616 let s_spin = make_percent_spinner_from_value(
617 ctx,
618 value.clone(),
619 |c| c.to_hsv().1,
620 components.set_saturation.clone(),
621 enabled,
622 cp::SPINNER_FIELD_WIDTH,
623 );
624 let v_spin = make_percent_spinner_from_value(
625 ctx,
626 value.clone(),
627 |c| c.to_hsv().2,
628 components.set_value_hsv.clone(),
629 enabled,
630 cp::SPINNER_FIELD_WIDTH,
631 );
632 Some(
633 ctx.add(
634 HStack::new()
635 .spacing(cp::GAP)
636 .child(spinner_cell("color-picker-hue-short", h_spin))
637 .child(spinner_cell("color-picker-saturation-short", s_spin))
638 .child(spinner_cell("color-picker-value-short", v_spin)),
639 ),
640 )
641 } else {
642 None
643 };
644
645 let compact_hex_id: Option<WidgetId> =
647 if layout == ColorPickerLayout::Compact && self.show_hex_input {
648 Some(
649 ctx.add(
650 HexColorInput::new(value.clone())
651 .alpha_enabled(alpha_enabled)
652 .label(localized(move || {
653 resolve_message_widget("color-picker-hex-label", &[])
654 }))
655 .width(cp::HEX_FIELD_WIDTH),
656 ),
657 )
658 } else {
659 None
660 };
661
662 let swatches_is_bound = matches!(self.swatches, Prop::Bound(_));
670 let swatches_id: Option<WidgetId> = if layout != ColorPickerLayout::Compact
671 && (swatches_is_bound || (self.show_swatches && !self.swatches.get().is_empty()))
672 {
673 let swatches_signal = self.swatches.as_signal();
674 let on_select: Rc<dyn Fn(Color, &mut EventContext)> = {
675 let value = value.clone();
676 Rc::new(move |c, _ctx_evt| {
677 value.set(c);
678 })
679 };
680 Some(ctx.add(SwatchGrid::new(
681 swatches_signal,
682 value.clone(),
683 self.swatch_columns,
684 on_select,
685 )))
686 } else {
687 None
688 };
689
690 let footer_id: Option<WidgetId> = if self.show_footer {
695 let mut row = HStack::new().spacing(cp::GAP).child(Spacer::new());
696 if let Some(cb) = self.on_cancel.clone() {
697 let cancel_btn = Button::new(localized(move || {
698 resolve_message_widget("color-picker-cancel-label", &[])
699 }))
700 .variant(ButtonVariant::Plain)
701 .enabled(enabled)
702 .on_activate_fn(move |ctx_evt| cb(ctx_evt));
703 row = row.child(cancel_btn);
704 }
705 if let Some(cb) = self.on_done.clone() {
706 let done_btn = Button::new(localized(move || {
707 resolve_message_widget("color-picker-done-label", &[])
708 }))
709 .variant(ButtonVariant::Filled)
710 .enabled(enabled)
711 .on_activate_fn(move |ctx_evt| cb(ctx_evt));
712 row = row.child(done_btn);
713 }
714 Some(ctx.add(row))
715 } else {
716 None
717 };
718
719 let style = resolve_color_picker_style(&self.style_override, ctx);
721 let cfg = teksilo_core::styles::ColorPickerStyleConfig {
722 layout,
723 top_row: top_row_id,
724 preview_row: preview_row_id,
725 rgb_row: rgb_row_id,
726 hsv_row: hsv_row_id,
727 swatches: swatches_id,
728 footer: footer_id,
729 compact_hex: compact_hex_id,
730 };
731 let root_id = style.make_body(&cfg, ctx);
732 self.root_child_id = Some(root_id);
733
734 if let Some(content) = self.composite_tooltip_content.take() {
736 let delay = ctx.theme().motion.tooltip_delay_heavy;
737 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
738 } else if let Some(source) = self.rich_tooltip_source.clone() {
739 let delay = ctx.theme().motion.tooltip_delay;
740 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
741 } else if let Some(text) = self.tooltip_text.clone() {
742 let delay = ctx.theme().motion.tooltip_delay;
743 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
744 }
745
746 let self_id = ctx.self_id();
749 let registry = ctx.binding_registry();
750 value.bind_to(
751 self_id,
752 registry,
753 teksilo_core::binding::BindingLevel::AccessibilityOnly,
754 );
755
756 vec![root_id]
757 }
758
759 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
760 match self.root_child_id {
761 Some(id) => ctx
762 .child_layout_response(id, proposal)
763 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
764 None => proposal.resolve(0.0, 0.0).into(),
765 }
766 }
767
768 fn place_children(
769 &self,
770 bounds: Rect,
771 _proposal: SizeProposal,
772 children: &mut [WidgetPlacement],
773 _ctx: &LayoutContext,
774 ) {
775 for child in children.iter_mut() {
776 child.origin = bounds.origin();
777 child.size = bounds.size();
778 }
779 }
780
781 fn children(&self) -> Vec<WidgetId> {
782 self.root_child_id.into_iter().collect()
783 }
784
785 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
786 builder.set_role(Role::Group);
787 let name = self
788 .label
789 .as_ref()
790 .map(|ls| ls.resolve_now())
791 .unwrap_or_else(|| resolve_message_widget("color-picker-name", &[]));
792 builder.set_name(name);
793 builder.set_live(Live::Polite);
794 let hex = self
795 .last_announced_hex
796 .borrow()
797 .clone()
798 .unwrap_or_else(|| self.binding.value().get().to_hex_upper(self.alpha_enabled));
799 builder.set_value(resolve_message_widget(
800 "color-picker-changed-announcement",
801 &[("hex", hex.into())],
802 ));
803 builder.add_action(Action::Focus);
805 }
806}
807
808fn resolve_color_picker_style(
809 override_: &Option<teksilo_core::styles::SharedColorPickerStyle>,
810 ctx: &BuildContext,
811) -> teksilo_core::styles::SharedColorPickerStyle {
812 if let Some(s) = override_.clone() {
813 return s;
814 }
815 ctx.theme_signal()
816 .get()
817 .style_slots
818 .color_picker
819 .clone()
820 .unwrap_or_else(|| {
821 Rc::new(crate::styles::recipe_color_picker_style::RecipeColorPickerStyle::default())
822 as teksilo_core::styles::SharedColorPickerStyle
823 })
824}
825
826fn make_byte_spinner_from_value(
832 ctx: &mut BuildContext,
833 value: Signal<Color>,
834 accessor: fn(Color) -> f32,
835 setter: Rc<dyn Fn(f32)>,
836 enabled: bool,
837 width: f32,
838) -> SpinBox<u8> {
839 let initial = (accessor(value.get()) * 255.0).round().clamp(0.0, 255.0) as u8;
840 let bridge = ctx.signal(initial);
841 {
843 let bridge = bridge.clone();
844 ctx.effect(&value, move |c| {
845 let new_u = (accessor(*c) * 255.0).round().clamp(0.0, 255.0) as u8;
846 if bridge.get() != new_u {
847 bridge.set(new_u);
848 }
849 });
850 }
851 {
856 let setter = setter.clone();
857 let value = value.clone();
858 ctx.effect(&bridge, move |new_u| {
859 let current_u = (accessor(value.get()) * 255.0).round().clamp(0.0, 255.0) as u8;
860 if *new_u == current_u {
861 return;
862 }
863 (setter)((*new_u) as f32 / 255.0);
864 });
865 }
866 SpinBox::new(bridge, 0u8, 255u8)
867 .single_step(1u8)
868 .page_step(16u8)
869 .enabled(enabled)
870 .width(width)
871}
872
873fn make_hue_spinner_from_value(
875 ctx: &mut BuildContext,
876 value: Signal<Color>,
877 setter: Rc<dyn Fn(f32)>,
878 enabled: bool,
879 width: f32,
880) -> SpinBox<u32> {
881 let initial = value.get().to_hsv().0.round().clamp(0.0, 359.0) as u32;
882 let bridge = ctx.signal(initial);
883 {
884 let bridge = bridge.clone();
885 ctx.effect(&value, move |c| {
886 let new_u = c.to_hsv().0.round().clamp(0.0, 359.0) as u32;
887 if bridge.get() != new_u {
888 bridge.set(new_u);
889 }
890 });
891 }
892 {
893 let setter = setter.clone();
894 let value = value.clone();
895 ctx.effect(&bridge, move |new_u| {
896 let current_u = value.get().to_hsv().0.round().clamp(0.0, 359.0) as u32;
897 if *new_u == current_u {
898 return;
899 }
900 (setter)(*new_u as f32);
901 });
902 }
903 SpinBox::new(bridge, 0u32, 359u32)
904 .single_step(1u32)
905 .page_step(15u32)
906 .enabled(enabled)
907 .width(width)
908}
909
910fn make_percent_spinner_from_value(
912 ctx: &mut BuildContext,
913 value: Signal<Color>,
914 accessor: fn(Color) -> f32,
915 setter: Rc<dyn Fn(f32)>,
916 enabled: bool,
917 width: f32,
918) -> SpinBox<u8> {
919 let initial = (accessor(value.get()) * 100.0).round().clamp(0.0, 100.0) as u8;
920 let bridge = ctx.signal(initial);
921 {
922 let bridge = bridge.clone();
923 ctx.effect(&value, move |c| {
924 let new_u = (accessor(*c) * 100.0).round().clamp(0.0, 100.0) as u8;
925 if bridge.get() != new_u {
926 bridge.set(new_u);
927 }
928 });
929 }
930 {
931 let setter = setter.clone();
932 let value = value.clone();
933 ctx.effect(&bridge, move |new_u| {
934 let current_u = (accessor(value.get()) * 100.0).round().clamp(0.0, 100.0) as u8;
935 if *new_u == current_u {
936 return;
937 }
938 (setter)((*new_u) as f32 / 100.0);
939 });
940 }
941 SpinBox::new(bridge, 0u8, 100u8)
942 .single_step(1u8)
943 .page_step(10u8)
944 .suffix(" %")
945 .enabled(enabled)
946 .width(width)
947}
948
949fn spinner_cell(label_key: &str, spinner: impl Widget + 'static) -> HStack {
951 let label = resolve_message_widget(label_key, &[]);
952 HStack::new()
953 .spacing(4.0)
954 .child(TextWidget::new(lit!(label)))
955 .child(spinner)
956}