1pub mod alpha_strip;
54pub mod hsv_canvas;
55pub mod hue_strip;
56pub mod state;
57pub mod swatch;
58pub mod swatch_grid;
59
60#[cfg(test)]
61mod tests;
62
63use std::cell::RefCell;
64use std::rc::Rc;
65use teksilo_i18n::lit;
66use teksilo_i18n::localized;
67
68use teksilo_canvas::{Rect, SizeProposal};
69use teksilo_core::accessibility::AccessNodeBuilder;
70use teksilo_core::accesskit::{Action, Live, Role};
71use teksilo_core::build_context::BuildContext;
72use teksilo_core::signal::{Prop, Signal};
73use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
74use teksilo_core::widget_id::WidgetId;
75use teksilo_i18n::{LocalizedString, resolve_message_widget};
76use teksilo_tokens::{Color, Orientation};
77
78use self::alpha_strip::AlphaStrip;
79use self::hsv_canvas::HsvCanvas;
80use self::hue_strip::HueStrip;
81use self::state::ColorComponents;
82use self::swatch_grid::SwatchGrid;
83use crate::button::{Button, ButtonVariant};
84use crate::hex_color_input::HexColorInput;
85use crate::primitives::{HStack, Spacer, TextWidget};
86use crate::spin_box::SpinBox;
87
88pub use self::swatch::ColorSwatch;
89
90pub const DEFAULT_SWATCHES: [Color; 12] = [
93 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), ];
106
107pub use teksilo_core::styles::ColorPickerLayout;
108
109#[derive(Clone)]
115enum ColorBinding {
116 Required(Signal<Color>),
117 Nullable {
118 source: Signal<Option<Color>>,
119 proxy: Signal<Color>,
120 },
121}
122
123impl ColorBinding {
124 fn value(&self) -> Signal<Color> {
125 match self {
126 Self::Required(s) => s.clone(),
127 Self::Nullable { proxy, .. } => proxy.clone(),
128 }
129 }
130}
131
132pub struct ColorPicker {
149 binding: ColorBinding,
150 alpha_enabled: bool,
151 show_hsv_canvas: bool,
152 show_hue_strip: bool,
153 show_alpha_strip: Option<bool>,
154 show_rgb_spinners: bool,
155 show_hsv_spinners: bool,
156 show_hex_input: bool,
157 show_preview: bool,
158 show_swatches: bool,
159 show_footer: bool,
160 on_done: Option<Rc<dyn Fn(&mut EventContext)>>,
161 on_cancel: Option<Rc<dyn Fn(&mut EventContext)>>,
162 swatches: Prop<Vec<Color>>,
163 swatch_columns: usize,
164 layout: ColorPickerLayout,
165 label: Option<LocalizedString>,
166 enabled: Prop<bool>,
169 last_announced_hex: Rc<RefCell<Option<String>>>,
176 style_override: Option<teksilo_core::styles::SharedColorPickerStyle>,
180 root_child_id: Option<WidgetId>,
181 tooltip_text: Option<LocalizedString>,
185 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
187 composite_tooltip_content: Option<Box<dyn Widget>>,
189}
190
191impl ColorPicker {
192 pub fn new(value: Signal<Color>) -> Self {
194 Self::from_binding(ColorBinding::Required(value))
195 }
196
197 pub fn nullable(value: Signal<Option<Color>>) -> Self {
203 let proxy = Signal::new(value.get().unwrap_or(Color::TRANSPARENT));
204 Self::from_binding(ColorBinding::Nullable {
205 source: value,
206 proxy,
207 })
208 }
209
210 fn from_binding(binding: ColorBinding) -> Self {
211 Self {
212 binding,
213 alpha_enabled: false,
214 show_hsv_canvas: true,
215 show_hue_strip: true,
216 show_alpha_strip: None, show_rgb_spinners: true,
218 show_hsv_spinners: true,
223 show_hex_input: true,
224 show_preview: true,
225 show_swatches: true,
226 show_footer: false,
227 on_done: None,
228 on_cancel: None,
229 swatches: Prop::Static(DEFAULT_SWATCHES.to_vec()),
230 swatch_columns: 6,
231 layout: ColorPickerLayout::Standard,
232 label: None,
233 enabled: Prop::Static(true),
234 last_announced_hex: Rc::new(RefCell::new(None)),
235 style_override: None,
236 root_child_id: None,
237 tooltip_text: None,
238 rich_tooltip_source: None,
239 composite_tooltip_content: None,
240 }
241 }
242
243 pub fn style(mut self, style: impl teksilo_core::styles::ColorPickerStyle) -> Self {
246 self.style_override = Some(Rc::new(style));
247 self
248 }
249
250 pub fn alpha_enabled(mut self, e: bool) -> Self {
252 self.alpha_enabled = e;
253 self
254 }
255
256 pub fn show_hsv_canvas(mut self, s: bool) -> Self {
260 self.show_hsv_canvas = s;
261 self
262 }
263
264 pub fn show_hue_strip(mut self, s: bool) -> Self {
266 self.show_hue_strip = s;
267 self
268 }
269
270 pub fn show_alpha_strip(mut self, s: bool) -> Self {
274 self.show_alpha_strip = Some(s);
275 self
276 }
277
278 pub fn show_rgb_spinners(mut self, s: bool) -> Self {
280 self.show_rgb_spinners = s;
281 self
282 }
283
284 pub fn show_hsv_spinners(mut self, s: bool) -> Self {
286 self.show_hsv_spinners = s;
287 self
288 }
289
290 pub fn show_hex_input(mut self, s: bool) -> Self {
292 self.show_hex_input = s;
293 self
294 }
295
296 pub fn show_preview(mut self, s: bool) -> Self {
298 self.show_preview = s;
299 self
300 }
301
302 pub fn show_swatches(mut self, s: bool) -> Self {
304 self.show_swatches = s;
305 self
306 }
307
308 pub fn show_footer(mut self, s: bool) -> Self {
316 self.show_footer = s;
317 self
318 }
319
320 pub fn on_done(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
326 self.on_done = Some(Rc::new(f));
327 self
328 }
329
330 pub fn on_cancel(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
338 self.on_cancel = Some(Rc::new(f));
339 self
340 }
341
342 pub fn swatches(mut self, s: impl Into<Prop<Vec<Color>>>) -> Self {
346 self.swatches = s.into();
347 self
348 }
349
350 pub fn swatch_columns(mut self, n: usize) -> Self {
353 self.swatch_columns = n.max(1);
354 self
355 }
356
357 pub fn layout(mut self, l: ColorPickerLayout) -> Self {
359 self.layout = l;
360 self
361 }
362
363 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
366 self.label = Some(label.into());
367 self
368 }
369
370 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
373 self.enabled = enabled.into();
374 self
375 }
376
377 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
382 self.tooltip_text = Some(text.into());
383 self.rich_tooltip_source = None;
384 self.composite_tooltip_content = None;
385 self
386 }
387
388 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
392 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
393 self.tooltip_text = None;
394 self.composite_tooltip_content = None;
395 self
396 }
397
398 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
402 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
403 self.tooltip_text = None;
404 self.composite_tooltip_content = None;
405 self
406 }
407
408 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
412 self.composite_tooltip_content = Some(Box::new(content));
413 self.tooltip_text = None;
414 self.rich_tooltip_source = None;
415 self
416 }
417
418 pub fn current(&self) -> Color {
422 self.binding.value().get()
423 }
424}
425
426impl std::fmt::Debug for ColorPicker {
427 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428 f.debug_struct("ColorPicker")
429 .field("alpha_enabled", &self.alpha_enabled)
430 .field("layout", &self.layout)
431 .field("enabled", &self.enabled.get())
432 .finish_non_exhaustive()
433 }
434}
435
436impl Widget for ColorPicker {
437 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
438 if let ColorBinding::Nullable { source, proxy } = &self.binding {
440 {
442 let proxy = proxy.clone();
443 ctx.effect(source, move |new| {
444 let resolved = new.unwrap_or(Color::TRANSPARENT);
445 if proxy.get() != resolved {
446 proxy.set(resolved);
447 }
448 });
449 }
450 {
452 let source = source.clone();
453 ctx.effect(proxy, move |c| {
454 if source.get() != Some(*c) {
455 source.set(Some(*c));
456 }
457 });
458 }
459 }
460
461 let value = self.binding.value();
462 let components = Rc::new(ColorComponents::new(ctx, value.clone()));
463
464 {
472 let dragging = components.dragging.clone();
473 let last_announced = self.last_announced_hex.clone();
474 let alpha = self.alpha_enabled;
475 ctx.effect(&value, move |c| {
476 if dragging.get() {
477 return;
478 }
479 let hex = c.to_hex_upper(alpha);
480 let needs_update = last_announced.borrow().as_deref() != Some(hex.as_str());
481 if needs_update {
482 *last_announced.borrow_mut() = Some(hex);
483 }
484 });
485 }
486
487 let self_id = ctx.self_id();
488 ctx.enabled_when(self_id, self.enabled.clone());
495
496 let alpha_enabled = self.alpha_enabled;
498 let show_alpha_strip = self.show_alpha_strip.unwrap_or(alpha_enabled);
499 let layout = self.layout;
500 let enabled = self.enabled.get();
501 use crate::styles::recipe_color_picker_style as cp;
502
503 let mut top_row = HStack::new().spacing(cp::GAP);
507 if self.show_hsv_canvas {
508 let canvas = HsvCanvas::new(
509 components.hue.clone(),
510 components.saturation.clone(),
511 components.value_hsv.clone(),
512 components.set_hsv.clone(),
513 components.dragging.clone(),
514 )
515 .enabled(enabled);
516 use teksilo_core::widget_builder::WidgetBuilder;
522 top_row = top_row.child(
523 canvas.access_subtree(teksilo_core::widget_builder::AccessSubtreeMode::Exclude),
524 );
525 }
526 if self.show_hue_strip {
527 let hue = HueStrip::new(
528 components.hue.clone(),
529 components.set_hue.clone(),
530 components.dragging.clone(),
531 )
532 .orientation(Orientation::Vertical)
533 .enabled(enabled)
534 .label(resolve_message_widget("color-picker-hue-label", &[]));
535 top_row = top_row.child(hue);
536 }
537 if alpha_enabled && show_alpha_strip {
538 let alpha = AlphaStrip::new(
539 value.clone(),
540 components.alpha.clone(),
541 components.set_alpha.clone(),
542 components.dragging.clone(),
543 )
544 .orientation(Orientation::Vertical)
545 .enabled(enabled)
546 .label(resolve_message_widget("color-picker-alpha-label", &[]));
547 top_row = top_row.child(alpha);
548 }
549 let top_row_id = ctx.add(top_row);
550
551 let preview_row_id: Option<WidgetId> =
554 if layout != ColorPickerLayout::Compact && (self.show_preview || self.show_hex_input) {
555 let mut row = HStack::new().spacing(cp::GAP);
556 if self.show_preview {
557 row = row.child(
558 ColorSwatch::new(value.clone())
559 .size(cp::PREVIEW_HEIGHT)
560 .corner_radius(cp::PREVIEW_CORNER_RADIUS)
561 .label(localized(move || {
562 resolve_message_widget("color-picker-current-color-label", &[])
563 })),
564 );
565 }
566 if self.show_hex_input {
567 let hex = HexColorInput::new(value.clone())
568 .alpha_enabled(alpha_enabled)
569 .label(localized(move || {
570 resolve_message_widget("color-picker-hex-label", &[])
571 }))
572 .width(cp::HEX_FIELD_WIDTH);
573 row = row.child(hex);
574 }
575 Some(ctx.add(row))
576 } else {
577 None
578 };
579
580 let rgb_row_id: Option<WidgetId> =
588 if layout != ColorPickerLayout::Compact && self.show_rgb_spinners {
589 let r_spin = make_byte_spinner_from_value(
590 ctx,
591 value.clone(),
592 |c| c.r(),
593 components.set_red.clone(),
594 enabled,
595 cp::SPINNER_FIELD_WIDTH,
596 );
597 let g_spin = make_byte_spinner_from_value(
598 ctx,
599 value.clone(),
600 |c| c.g(),
601 components.set_green.clone(),
602 enabled,
603 cp::SPINNER_FIELD_WIDTH,
604 );
605 let b_spin = make_byte_spinner_from_value(
606 ctx,
607 value.clone(),
608 |c| c.b(),
609 components.set_blue.clone(),
610 enabled,
611 cp::SPINNER_FIELD_WIDTH,
612 );
613 let mut row = HStack::new()
614 .spacing(cp::GAP)
615 .child(spinner_cell("color-picker-red-short", r_spin))
616 .child(spinner_cell("color-picker-green-short", g_spin))
617 .child(spinner_cell("color-picker-blue-short", b_spin));
618 if alpha_enabled {
619 let a_spin = make_byte_spinner_from_value(
620 ctx,
621 value.clone(),
622 |c| c.a(),
623 components.set_alpha.clone(),
624 enabled,
625 cp::SPINNER_FIELD_WIDTH,
626 );
627 row = row.child(spinner_cell("color-picker-alpha-short", a_spin));
628 }
629 Some(ctx.add(row))
630 } else {
631 None
632 };
633
634 let hsv_row_id: Option<WidgetId> =
636 if layout != ColorPickerLayout::Compact && self.show_hsv_spinners {
637 let h_spin = make_hue_spinner_from_value(
638 ctx,
639 value.clone(),
640 components.set_hue.clone(),
641 enabled,
642 cp::SPINNER_FIELD_WIDTH,
643 );
644 let s_spin = make_percent_spinner_from_value(
645 ctx,
646 value.clone(),
647 |c| c.to_hsv().1,
648 components.set_saturation.clone(),
649 enabled,
650 cp::SPINNER_FIELD_WIDTH,
651 );
652 let v_spin = make_percent_spinner_from_value(
653 ctx,
654 value.clone(),
655 |c| c.to_hsv().2,
656 components.set_value_hsv.clone(),
657 enabled,
658 cp::SPINNER_FIELD_WIDTH,
659 );
660 Some(
661 ctx.add(
662 HStack::new()
663 .spacing(cp::GAP)
664 .child(spinner_cell("color-picker-hue-short", h_spin))
665 .child(spinner_cell("color-picker-saturation-short", s_spin))
666 .child(spinner_cell("color-picker-value-short", v_spin)),
667 ),
668 )
669 } else {
670 None
671 };
672
673 let compact_hex_id: Option<WidgetId> =
675 if layout == ColorPickerLayout::Compact && self.show_hex_input {
676 Some(
677 ctx.add(
678 HexColorInput::new(value.clone())
679 .alpha_enabled(alpha_enabled)
680 .label(localized(move || {
681 resolve_message_widget("color-picker-hex-label", &[])
682 }))
683 .width(cp::HEX_FIELD_WIDTH),
684 ),
685 )
686 } else {
687 None
688 };
689
690 let swatches_is_bound = matches!(self.swatches, Prop::Bound(_));
698 let swatches_id: Option<WidgetId> = if layout != ColorPickerLayout::Compact
699 && (swatches_is_bound || (self.show_swatches && !self.swatches.get().is_empty()))
700 {
701 let swatches_signal = self.swatches.as_signal();
702 let on_select: Rc<dyn Fn(Color, &mut EventContext)> = {
703 let value = value.clone();
704 Rc::new(move |c, _ctx_evt| {
705 value.set(c);
706 })
707 };
708 Some(ctx.add(SwatchGrid::new(
709 swatches_signal,
710 value.clone(),
711 self.swatch_columns,
712 on_select,
713 )))
714 } else {
715 None
716 };
717
718 let footer_id: Option<WidgetId> = if self.show_footer {
723 let mut row = HStack::new().spacing(cp::GAP).child(Spacer::new());
724 if let Some(cb) = self.on_cancel.clone() {
725 let cancel_btn = Button::new(localized(move || {
726 resolve_message_widget("color-picker-cancel-label", &[])
727 }))
728 .variant(ButtonVariant::Plain)
729 .enabled(enabled)
730 .on_activate_fn(move |ctx_evt| cb(ctx_evt));
731 row = row.child(cancel_btn);
732 }
733 if let Some(cb) = self.on_done.clone() {
734 let done_btn = Button::new(localized(move || {
735 resolve_message_widget("color-picker-done-label", &[])
736 }))
737 .variant(ButtonVariant::Filled)
738 .enabled(enabled)
739 .on_activate_fn(move |ctx_evt| cb(ctx_evt));
740 row = row.child(done_btn);
741 }
742 Some(ctx.add(row))
743 } else {
744 None
745 };
746
747 let style = resolve_color_picker_style(&self.style_override, ctx);
749 let cfg = teksilo_core::styles::ColorPickerStyleConfig {
750 layout,
751 top_row: top_row_id,
752 preview_row: preview_row_id,
753 rgb_row: rgb_row_id,
754 hsv_row: hsv_row_id,
755 swatches: swatches_id,
756 footer: footer_id,
757 compact_hex: compact_hex_id,
758 };
759 let root_id = style.make_body(&cfg, ctx);
760 self.root_child_id = Some(root_id);
761
762 if let Some(content) = self.composite_tooltip_content.take() {
764 let delay = ctx.theme().motion.tooltip_delay_heavy;
765 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
766 } else if let Some(source) = self.rich_tooltip_source.clone() {
767 let delay = ctx.theme().motion.tooltip_delay;
768 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
769 } else if let Some(text) = self.tooltip_text.clone() {
770 let delay = ctx.theme().motion.tooltip_delay;
771 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
772 }
773
774 let self_id = ctx.self_id();
777 let registry = ctx.binding_registry();
778 value.bind_to(
779 self_id,
780 registry,
781 teksilo_core::binding::BindingLevel::AccessibilityOnly,
782 );
783
784 vec![root_id]
785 }
786
787 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
788 match self.root_child_id {
789 Some(id) => ctx
790 .child_layout_response(id, proposal)
791 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
792 None => proposal.resolve(0.0, 0.0).into(),
793 }
794 }
795
796 fn place_children(
797 &self,
798 bounds: Rect,
799 _proposal: SizeProposal,
800 children: &mut [WidgetPlacement],
801 _ctx: &LayoutContext,
802 ) {
803 for child in children.iter_mut() {
804 child.origin = bounds.origin();
805 child.size = bounds.size();
806 }
807 }
808
809 fn children(&self) -> Vec<WidgetId> {
810 self.root_child_id.into_iter().collect()
811 }
812
813 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
814 builder.set_role(Role::Group);
815 let name = self
816 .label
817 .as_ref()
818 .map(|ls| ls.resolve_now())
819 .unwrap_or_else(|| resolve_message_widget("color-picker-name", &[]));
820 builder.set_name(name);
821 builder.set_live(Live::Polite);
822 let hex = self
823 .last_announced_hex
824 .borrow()
825 .clone()
826 .unwrap_or_else(|| self.binding.value().get().to_hex_upper(self.alpha_enabled));
827 builder.set_value(resolve_message_widget(
828 "color-picker-changed-announcement",
829 &[("hex", hex.into())],
830 ));
831 builder.add_action(Action::Focus);
833 }
834}
835
836fn resolve_color_picker_style(
837 override_: &Option<teksilo_core::styles::SharedColorPickerStyle>,
838 ctx: &BuildContext,
839) -> teksilo_core::styles::SharedColorPickerStyle {
840 if let Some(s) = override_.clone() {
841 return s;
842 }
843 ctx.theme_signal()
844 .get()
845 .style_slots
846 .color_picker
847 .clone()
848 .unwrap_or_else(|| {
849 Rc::new(
850 crate::styles::recipe_color_picker_style::RecipeColorPickerStyle::for_tokens(
851 &ctx.theme().input,
852 ),
853 ) as teksilo_core::styles::SharedColorPickerStyle
854 })
855}
856
857fn make_byte_spinner_from_value(
863 ctx: &mut BuildContext,
864 value: Signal<Color>,
865 accessor: fn(Color) -> f32,
866 setter: Rc<dyn Fn(f32)>,
867 enabled: bool,
868 width: f32,
869) -> SpinBox<u8> {
870 let initial = (accessor(value.get()) * 255.0).round().clamp(0.0, 255.0) as u8;
871 let bridge = ctx.signal(initial);
872 {
874 let bridge = bridge.clone();
875 ctx.effect(&value, move |c| {
876 let new_u = (accessor(*c) * 255.0).round().clamp(0.0, 255.0) as u8;
877 if bridge.get() != new_u {
878 bridge.set(new_u);
879 }
880 });
881 }
882 {
887 let setter = setter.clone();
888 let value = value.clone();
889 ctx.effect(&bridge, move |new_u| {
890 let current_u = (accessor(value.get()) * 255.0).round().clamp(0.0, 255.0) as u8;
891 if *new_u == current_u {
892 return;
893 }
894 (setter)((*new_u) as f32 / 255.0);
895 });
896 }
897 SpinBox::new(bridge, 0u8, 255u8)
898 .single_step(1u8)
899 .page_step(16u8)
900 .enabled(enabled)
901 .width(width)
902}
903
904fn make_hue_spinner_from_value(
906 ctx: &mut BuildContext,
907 value: Signal<Color>,
908 setter: Rc<dyn Fn(f32)>,
909 enabled: bool,
910 width: f32,
911) -> SpinBox<u32> {
912 let initial = value.get().to_hsv().0.round().clamp(0.0, 359.0) as u32;
913 let bridge = ctx.signal(initial);
914 {
915 let bridge = bridge.clone();
916 ctx.effect(&value, move |c| {
917 let new_u = c.to_hsv().0.round().clamp(0.0, 359.0) as u32;
918 if bridge.get() != new_u {
919 bridge.set(new_u);
920 }
921 });
922 }
923 {
924 let setter = setter.clone();
925 let value = value.clone();
926 ctx.effect(&bridge, move |new_u| {
927 let current_u = value.get().to_hsv().0.round().clamp(0.0, 359.0) as u32;
928 if *new_u == current_u {
929 return;
930 }
931 (setter)(*new_u as f32);
932 });
933 }
934 SpinBox::new(bridge, 0u32, 359u32)
935 .single_step(1u32)
936 .page_step(15u32)
937 .enabled(enabled)
938 .width(width)
939}
940
941fn make_percent_spinner_from_value(
943 ctx: &mut BuildContext,
944 value: Signal<Color>,
945 accessor: fn(Color) -> f32,
946 setter: Rc<dyn Fn(f32)>,
947 enabled: bool,
948 width: f32,
949) -> SpinBox<u8> {
950 let initial = (accessor(value.get()) * 100.0).round().clamp(0.0, 100.0) as u8;
951 let bridge = ctx.signal(initial);
952 {
953 let bridge = bridge.clone();
954 ctx.effect(&value, move |c| {
955 let new_u = (accessor(*c) * 100.0).round().clamp(0.0, 100.0) as u8;
956 if bridge.get() != new_u {
957 bridge.set(new_u);
958 }
959 });
960 }
961 {
962 let setter = setter.clone();
963 let value = value.clone();
964 ctx.effect(&bridge, move |new_u| {
965 let current_u = (accessor(value.get()) * 100.0).round().clamp(0.0, 100.0) as u8;
966 if *new_u == current_u {
967 return;
968 }
969 (setter)((*new_u) as f32 / 100.0);
970 });
971 }
972 SpinBox::new(bridge, 0u8, 100u8)
973 .single_step(1u8)
974 .page_step(10u8)
975 .suffix(" %")
976 .enabled(enabled)
977 .width(width)
978}
979
980fn spinner_cell(label_key: &str, spinner: impl Widget + 'static) -> HStack {
982 let label = resolve_message_widget(label_key, &[]);
983 HStack::new()
984 .spacing(4.0)
985 .child(TextWidget::new(lit!(label)))
986 .child(spinner)
987}