mod step_button;
#[cfg(test)]
mod tests;
mod value;
use std::rc::Rc;
pub use self::value::SpinValue;
use teksilo_canvas::{Path, Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::event::{EventResponse, Key, ScrollDelta, WidgetEvent};
use teksilo_core::signal::{Prop, Signal};
use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_text::SharedTypesetter;
use teksilo_tokens::{CornerRadius, TextStyle};
use crate::primitives::icon_widget::IconWidget;
use crate::primitives::text_input_field::TextInputField;
use crate::primitives::{MinSize, Padding};
use self::step_button::StepButton;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WrapMode {
#[default]
Clamp,
Wrap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StepType {
#[default]
Fixed,
Adaptive,
}
pub use teksilo_core::styles::ButtonLayout;
use teksilo_i18n::LocalizedString;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WheelMode {
#[default]
Focused,
Hover,
Disabled,
}
#[derive(Debug, Clone)]
pub enum WidthPolicy {
Pixels(f32),
Chars(u32),
Fill,
}
type TextFromValue<T> = Rc<dyn Fn(T) -> LocalizedString>;
type ValueFromText<T> = Rc<dyn Fn(&str) -> Option<T>>;
type OnValueChangedFn<T> = Rc<dyn Fn(T, &mut EventContext)>;
const MIN_WIDTH_WITH_BUTTONS: f32 = 72.0;
const MIN_WIDTH_NO_BUTTONS: f32 = 48.0;
const DEFAULT_PREFERRED_WIDTH: f32 = 120.0;
pub struct SpinBox<T: SpinValue> {
value: Signal<T>,
min: T,
max: T,
single_step: T,
page_step: Option<T>,
decimals: u8,
suffix: String,
localized: bool,
use_grouping: bool,
special_value_text: Option<LocalizedString>,
wrap_mode: WrapMode,
step_type: StepType,
button_layout: ButtonLayout,
wheel_mode: WheelMode,
width_policy: WidthPolicy,
label: Option<LocalizedString>,
placeholder: LocalizedString,
enabled: Prop<bool>,
read_only: bool,
text_from_value: Option<TextFromValue<T>>,
value_from_text: Option<ValueFromText<T>>,
on_value_changed: Option<OnValueChangedFn<T>>,
text_signal: Signal<String>,
focused: Signal<bool>,
can_step_up: Signal<bool>,
can_step_down: Signal<bool>,
pixel_cap: Option<f32>,
min_width: f32,
style_override: Option<teksilo_core::styles::SharedSpinBoxStyle>,
root_child_id: Option<WidgetId>,
field_id: Option<WidgetId>,
tooltip_text: Option<LocalizedString>,
rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
composite_tooltip_content: Option<Box<dyn Widget>>,
}
impl<T: SpinValue> std::fmt::Debug for SpinBox<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SpinBox")
.field("min", &self.min)
.field("max", &self.max)
.field("single_step", &self.single_step)
.field("decimals", &self.decimals)
.field("localized", &self.localized)
.field("use_grouping", &self.use_grouping)
.field("wrap_mode", &self.wrap_mode)
.finish_non_exhaustive()
}
}
impl<T: SpinValue> SpinBox<T> {
pub fn new(value: Signal<T>, min: T, max: T) -> Self {
let default_step = T::from_f64_saturating(1.0);
Self {
value,
min,
max,
single_step: default_step,
page_step: None,
decimals: if T::is_integer() { 0 } else { 2 },
suffix: String::new(),
localized: true,
use_grouping: false,
special_value_text: None,
wrap_mode: WrapMode::Clamp,
step_type: StepType::Fixed,
button_layout: ButtonLayout::Stacked,
wheel_mode: WheelMode::Focused,
width_policy: WidthPolicy::Pixels(DEFAULT_PREFERRED_WIDTH),
label: None,
placeholder: LocalizedString::literal(String::new()),
enabled: Prop::Static(true),
read_only: false,
text_from_value: None,
value_from_text: None,
on_value_changed: None,
text_signal: Signal::new(String::new()),
focused: Signal::new(false),
can_step_up: Signal::new(true),
can_step_down: Signal::new(true),
pixel_cap: None,
min_width: MIN_WIDTH_WITH_BUTTONS,
style_override: None,
root_child_id: None,
field_id: None,
tooltip_text: None,
rich_tooltip_source: None,
composite_tooltip_content: None,
}
}
pub fn style(mut self, style: impl teksilo_core::styles::SpinBoxStyle) -> Self {
self.style_override = Some(Rc::new(style));
self
}
pub fn single_step(mut self, step: T) -> Self {
self.single_step = step;
self
}
pub fn page_step(mut self, step: T) -> Self {
self.page_step = Some(step);
self
}
pub fn decimals(mut self, decimals: u8) -> Self {
self.decimals = decimals;
self
}
pub fn localized(mut self, on: bool) -> Self {
self.localized = on;
self
}
pub fn use_grouping(mut self, on: bool) -> Self {
self.use_grouping = on;
self
}
pub fn suffix(mut self, text: impl Into<String>) -> Self {
self.suffix = text.into();
self
}
pub fn special_value_text(mut self, text: impl Into<LocalizedString>) -> Self {
self.special_value_text = Some(text.into());
self
}
pub fn wrap_mode(mut self, mode: WrapMode) -> Self {
self.wrap_mode = mode;
self
}
pub fn step_type(mut self, step_type: StepType) -> Self {
self.step_type = step_type;
self
}
pub fn button_layout(mut self, layout: ButtonLayout) -> Self {
self.button_layout = layout;
self
}
pub fn show_buttons(mut self, show: bool) -> Self {
self.button_layout = if show {
ButtonLayout::Stacked
} else {
ButtonLayout::Hidden
};
self
}
pub fn wheel_mode(mut self, mode: WheelMode) -> Self {
self.wheel_mode = mode;
self
}
pub fn width(mut self, width: f32) -> Self {
self.width_policy = WidthPolicy::Pixels(width.max(0.0));
self
}
pub fn width_chars(mut self, chars: u32) -> Self {
self.width_policy = WidthPolicy::Chars(chars);
self
}
pub fn fill_width(mut self) -> Self {
self.width_policy = WidthPolicy::Fill;
self
}
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
let ls: LocalizedString = label.into();
self.label = Some(ls);
self
}
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
let ls: LocalizedString = text.into();
self.placeholder = ls;
self
}
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
self.enabled = enabled.into();
self
}
pub fn read_only(mut self, read_only: bool) -> Self {
self.read_only = read_only;
self
}
pub fn text_from_value(mut self, f: impl Fn(T) -> LocalizedString + 'static) -> Self {
self.text_from_value = Some(Rc::new(f));
self
}
pub fn value_from_text(mut self, f: impl Fn(&str) -> Option<T> + 'static) -> Self {
self.value_from_text = Some(Rc::new(f));
self
}
pub fn on_value_changed(mut self, f: impl Fn(T, &mut EventContext) + 'static) -> Self {
self.on_value_changed = Some(Rc::new(f));
self
}
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
self.tooltip_text = Some(text.into());
self.rich_tooltip_source = None;
self.composite_tooltip_content = None;
self
}
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
self.tooltip_text = None;
self.composite_tooltip_content = None;
self
}
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
self.tooltip_text = None;
self.composite_tooltip_content = None;
self
}
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
self.composite_tooltip_content = Some(Box::new(content));
self.tooltip_text = None;
self.rich_tooltip_source = None;
self
}
pub(crate) fn composite_tooltip_boxed(mut self, content: Box<dyn Widget>) -> Self {
self.composite_tooltip_content = Some(content);
self.tooltip_text = None;
self.rich_tooltip_source = None;
self
}
pub fn value(&self) -> Signal<T> {
self.value.clone()
}
}
impl<T: SpinValue> Widget for SpinBox<T> {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
debug_assert!(self.min <= self.max, "SpinBox min must be <= max");
let theme = ctx.theme_signal().get();
use crate::styles::recipe_text_input_style as field_dims;
let field_border_width = field_dims::TEXT_FIELD_BORDER_WIDTH;
let focus_ring_width = theme.shape.focus_ring_width;
let min = self.min;
let max = self.max;
let decimals = self.decimals;
let suffix_str = self.suffix.clone();
let special_text = self.special_value_text.clone();
let text_from_value = self.text_from_value.clone();
let value_from_text = self.value_from_text.clone();
let wrap_mode = self.wrap_mode;
let step_type = self.step_type;
let single_step = self.single_step;
let page_step = self
.page_step
.unwrap_or_else(|| single_step.saturating_mul_u32(10));
let on_value_changed = self.on_value_changed.clone();
let self_id = ctx.self_id();
ctx.enabled_when(self_id, self.enabled.clone());
let enabled = self.enabled.get();
let read_only = self.read_only;
let wheel_mode = self.wheel_mode;
let presentation = NumberPresentation::resolve(self.localized, self.use_grouping);
{
let initial = format_for_display(
self.value.get(),
decimals,
special_text.as_ref(),
text_from_value.as_deref(),
min,
false,
&presentation,
);
self.text_signal.set(initial);
}
{
let text_signal = self.text_signal.clone();
let text_from_value = text_from_value.clone();
let special_text = special_text.clone();
let focused = self.focused.clone();
let can_up = self.can_step_up.clone();
let can_down = self.can_step_down.clone();
let min_cap = min;
let max_cap = max;
let presentation = presentation.clone();
ctx.effect(&self.value, move |new_value| {
let is_focused = focused.get();
if wrap_mode == WrapMode::Wrap {
can_up.set(true);
can_down.set(true);
} else {
can_up.set(*new_value < max_cap);
can_down.set(*new_value > min_cap);
}
if !is_focused {
let formatted = format_for_display(
*new_value,
decimals,
special_text.as_ref(),
text_from_value.as_deref(),
min_cap,
false,
&presentation,
);
if text_signal.get() != formatted {
text_signal.set(formatted);
}
}
});
}
{
let text_signal = self.text_signal.clone();
let text_from_value = text_from_value.clone();
let special_text = special_text.clone();
let value_signal = self.value.clone();
let focused = self.focused.clone();
let locale_signal = ctx.locale_signal();
let localized = self.localized;
let grouping = self.use_grouping;
ctx.effect(&locale_signal, move |_| {
let presentation = NumberPresentation::resolve(localized, grouping);
let formatted = format_for_display(
value_signal.get(),
decimals,
special_text.as_ref(),
text_from_value.as_deref(),
min,
focused.get(),
&presentation,
);
if text_signal.get() != formatted {
text_signal.set(formatted);
}
});
}
let commit: Rc<dyn Fn(&mut EventContext)> = {
let value_signal = self.value.clone();
let text_signal = self.text_signal.clone();
let value_from_text = value_from_text.clone();
let text_from_value = text_from_value.clone();
let special_text = special_text.clone();
let on_value_changed = on_value_changed.clone();
let commit_presentation = presentation.clone();
Rc::new(move |ctx: &mut EventContext| {
let raw = text_signal.get();
let parsed: Option<T> = match value_from_text.as_deref() {
Some(f) => f(raw.trim()),
None => commit_presentation.parse::<T>(&raw),
};
let old = value_signal.get();
let new_value = match parsed {
Some(v) => v.clamp_value(min, max),
None => old, };
let formatted = format_for_display(
new_value,
decimals,
special_text.as_ref(),
text_from_value.as_deref(),
min,
false,
&commit_presentation,
);
if text_signal.get() != formatted {
text_signal.set(formatted);
}
if approx_ne(new_value, old) {
value_signal.set(new_value);
if let Some(cb) = on_value_changed.as_ref() {
cb(new_value, ctx);
}
}
})
};
fn apply_step<T: SpinValue>(
dir: i32,
page: bool,
step_type: StepType,
wrap_mode: WrapMode,
single_step: T,
page_step: T,
min: T,
max: T,
current: T,
) -> T {
let base_step = if page { page_step } else { single_step };
let effective = resolve_effective_step(step_type, current, base_step);
let stepped = if dir > 0 {
current.saturating_add(effective)
} else {
current.saturating_sub(effective)
};
if stepped < min || stepped > max {
match wrap_mode {
WrapMode::Clamp => stepped.clamp_value(min, max),
WrapMode::Wrap => {
if stepped > max {
min
} else {
max
}
}
}
} else {
stepped
}
}
let step_silent: Rc<dyn Fn(i32, bool) -> Option<T>> = {
let value_signal = self.value.clone();
let text_signal = self.text_signal.clone();
let text_from_value = text_from_value.clone();
let special_text = special_text.clone();
let presentation = presentation.clone();
Rc::new(move |dir: i32, page: bool| {
if read_only {
return None;
}
let current = value_signal.get();
let new_value = apply_step(
dir,
page,
step_type,
wrap_mode,
single_step,
page_step,
min,
max,
current,
);
if approx_eq(new_value, current) {
return None;
}
value_signal.set(new_value);
let formatted = format_for_display(
new_value,
decimals,
special_text.as_ref(),
text_from_value.as_deref(),
min,
false,
&presentation,
);
if text_signal.get() != formatted {
text_signal.set(formatted);
}
Some(new_value)
})
};
let step: Rc<dyn Fn(i32, bool, &mut EventContext)> = {
let step_silent = step_silent.clone();
let on_value_changed = on_value_changed.clone();
Rc::new(move |dir: i32, page: bool, ctx: &mut EventContext| {
if let Some(new_value) = step_silent(dir, page) {
if let Some(cb) = on_value_changed.as_ref() {
cb(new_value, ctx);
}
ctx.request_frame();
}
})
};
let inner_height =
(field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
let text_area_height =
(inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
let mut field = TextInputField::new(self.text_signal.clone())
.enabled(enabled)
.read_only(read_only)
.placeholder(self.placeholder.clone())
.text_height(text_area_height)
.char_filter({
let presentation = presentation.clone();
move |c| presentation.accepts_char::<T>(c)
});
if !suffix_str.is_empty() {
if self.special_value_text.is_some() {
let suffix_live = ctx.signal(suffix_str.clone());
let resolve = {
let suffix_str = suffix_str.clone();
let min_cap = min;
move |v: T, focused: bool| -> String {
let at_min = approx_eq(v, min_cap);
if at_min && !focused {
String::new()
} else {
suffix_str.clone()
}
}
};
{
let current_focused = self.focused.get();
suffix_live.set(resolve(self.value.get(), current_focused));
}
{
let suffix_live = suffix_live.clone();
let focused = self.focused.clone();
let resolve = resolve.clone();
ctx.effect(&self.value, move |v| {
let is_focused = focused.get();
let next = resolve(*v, is_focused);
if suffix_live.get() != next {
suffix_live.set(next);
}
});
}
{
let suffix_live = suffix_live.clone();
let value_signal = self.value.clone();
let resolve = resolve.clone();
ctx.effect(&self.focused, move |is_focused| {
let next = resolve(value_signal.get(), *is_focused);
if suffix_live.get() != next {
suffix_live.set(next);
}
});
}
field = field.suffix(suffix_live);
} else {
field = field.suffix(suffix_str.clone());
}
}
{
let commit = commit.clone();
field = field.on_submit_fn(move |ctx| commit(ctx));
}
{
let commit = commit.clone();
field = field.on_blur_fn(move |ctx| commit(ctx));
}
{
let focused_for_text = self.focused.clone();
let text_signal = self.text_signal.clone();
let value_signal = self.value.clone();
let text_from_value = text_from_value.clone();
let min_cap = min;
ctx.effect(&focused_for_text, move |is_focused| {
if *is_focused {
let plain = format_for_display(
value_signal.get(),
decimals,
None,
text_from_value.as_deref(),
min_cap,
true,
&presentation,
);
if text_signal.get() != plain {
text_signal.set(plain);
}
}
});
}
let field_id = ctx.add(field);
self.field_id = Some(field_id);
let padded_field_id = ctx.add(
Padding::new(
field_dims::TEXT_FIELD_PADDING_VERTICAL,
0.0,
field_dims::TEXT_FIELD_PADDING_VERTICAL,
0.0,
)
.child_id(field_id),
);
let (step_up_id, step_down_id) = if self.button_layout != ButtonLayout::Hidden {
let (u, d) = build_step_buttons(
ctx,
&step,
&step_silent,
self.can_step_up.clone(),
self.can_step_down.clone(),
enabled && !read_only,
field_dims::TEXT_FIELD_HEIGHT,
field_dims::TEXT_FIELD_CORNER_RADIUS,
);
(Some(u), Some(d))
} else {
(None, None)
};
let style =
crate::styles::recipe_spin_box_style::resolve_spin_box_style(&self.style_override, ctx);
let is_disabled = ctx.effective_enabled_signal(self_id).map(|on| !*on);
let cfg = teksilo_core::styles::SpinBoxStyleConfig {
field: padded_field_id,
step_up: step_up_id,
step_down: step_down_id,
layout: self.button_layout,
is_focused: self.focused.clone(),
is_disabled,
};
let zstack_id = style.make_body(&cfg, ctx);
let _ = focus_ring_width;
let _ = field_border_width;
let min_width = match self.button_layout {
ButtonLayout::Stacked => MIN_WIDTH_WITH_BUTTONS,
ButtonLayout::Hidden => MIN_WIDTH_NO_BUTTONS,
};
let pixel_cap: Option<f32> = match self.width_policy {
WidthPolicy::Fill => None,
WidthPolicy::Pixels(px) => Some(px.max(min_width)),
WidthPolicy::Chars(chars) => {
let style = &theme.typography.body;
let sample: String = "0".repeat(chars as usize);
let digits_w = measure_width_px(ctx, &sample, style);
let suffix_w = if suffix_str.is_empty() {
0.0
} else {
measure_width_px(ctx, &suffix_str, style)
};
let button_chrome = match self.button_layout {
ButtonLayout::Stacked => 18.0 + 4.0 + 1.0,
ButtonLayout::Hidden => 0.0,
};
let chrome = field_dims::TEXT_FIELD_PADDING_HORIZONTAL * 2.0 + button_chrome + 2.0;
Some((digits_w + suffix_w + chrome).max(min_width))
}
};
let sized_id =
ctx.add(MinSize::new(min_width, field_dims::TEXT_FIELD_HEIGHT).child_id(zstack_id));
self.pixel_cap = pixel_cap;
self.min_width = min_width;
let root_id = sized_id;
self.root_child_id = Some(root_id);
let step_for_key = step.clone();
let step_for_wheel = step.clone();
let value_for_a11y = self.value.clone();
let field_id_for_access = field_id;
let handlers = HandlerSet::new()
.focus_within(self.focused.clone())
.on_key_preview(move |event, ctx| {
if !enabled || read_only {
return EventResponse::Ignored;
}
let WidgetEvent::KeyDown { key, .. } = event else {
return EventResponse::Ignored;
};
match key {
Key::ArrowUp => {
(step_for_key)(1, false, ctx);
EventResponse::Handled
}
Key::ArrowDown => {
(step_for_key)(-1, false, ctx);
EventResponse::Handled
}
Key::PageUp => {
(step_for_key)(1, true, ctx);
EventResponse::Handled
}
Key::PageDown => {
(step_for_key)(-1, true, ctx);
EventResponse::Handled
}
_ => EventResponse::Ignored,
}
})
.on_scroll({
let focused = self.focused.clone();
move |event, ctx| {
if !enabled || read_only || wheel_mode == WheelMode::Disabled {
return EventResponse::Ignored;
}
if wheel_mode == WheelMode::Focused && !focused.get() {
return EventResponse::Ignored;
}
let WidgetEvent::Scroll { delta, .. } = event else {
return EventResponse::Ignored;
};
let y = match delta {
ScrollDelta::Lines { y, .. } => *y,
ScrollDelta::Pixels { y, .. } => *y,
};
if y == 0.0 {
return EventResponse::Ignored;
}
let dir = if y > 0.0 { -1 } else { 1 };
(step_for_wheel)(dir, false, ctx);
EventResponse::Handled
}
})
.on_access_action(move |action, ctx| {
use teksilo_core::accesskit::Action;
match action {
Action::Increment => {
(step.clone())(1, false, ctx);
EventResponse::Handled
}
Action::Decrement => {
(step.clone())(-1, false, ctx);
EventResponse::Handled
}
Action::Focus => {
ctx.request_focus(field_id_for_access);
EventResponse::Handled
}
_ => EventResponse::Ignored,
}
});
let self_id = ctx.self_id();
value_for_a11y.bind_to(
self_id,
ctx.binding_registry(),
teksilo_core::binding::BindingLevel::AccessibilityOnly,
);
ctx.apply_self_handlers(handlers);
if let Some(content) = self.composite_tooltip_content.take() {
let delay = ctx.theme().motion.tooltip_delay_heavy;
crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
} else if let Some(source) = self.rich_tooltip_source.clone() {
let delay = ctx.theme().motion.tooltip_delay;
crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
} else if let Some(text) = self.tooltip_text.clone() {
let delay = ctx.theme().motion.tooltip_delay;
crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
}
vec![root_id]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let scale = ctx.text_scale;
let pixel_cap = self.pixel_cap.map(|c| c * scale);
let min_width = self.min_width * scale;
let effective_proposal = SizeProposal {
width: match (proposal.width, pixel_cap) {
(Some(w), Some(cap)) => Some(w.min(cap).max(min_width)),
(None, Some(cap)) => Some(cap.max(min_width)),
(w, None) => w,
},
height: proposal.height,
};
let child_size = self
.root_child_id
.and_then(|id| ctx.child_size(id, effective_proposal))
.unwrap_or_else(|| effective_proposal.resolve(0.0, 0.0));
let w = match effective_proposal.width {
Some(pw) => pw.max(child_size.width),
None => child_size.width,
};
Size::new(w, child_size.height).into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
if let Some(p) = children.first_mut() {
p.origin = Point::new(bounds.x, bounds.y);
p.size = bounds.size();
}
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
use teksilo_core::accesskit::{Action, Role};
builder.set_role(Role::SpinButton);
if let Some(ref label) = self.label {
builder.set_name(label.resolve_now());
}
builder.set_numeric_value(self.value.get().to_f64());
builder.set_min_numeric_value(self.min.to_f64());
builder.set_max_numeric_value(self.max.to_f64());
builder.set_numeric_value_step(self.single_step.to_f64());
if let Some(page) = self.page_step {
builder.set_numeric_value_jump(page.to_f64());
} else {
builder.set_numeric_value_jump(self.single_step.saturating_mul_u32(10).to_f64());
}
let value = self.value.get();
let using_special = self.special_value_text.is_some() && approx_eq(value, self.min);
let display = format_for_display(
value,
self.decimals,
self.special_value_text.as_ref(),
self.text_from_value.as_deref(),
self.min,
false,
&NumberPresentation::resolve(self.localized, self.use_grouping),
);
let full = if !self.suffix.is_empty() && !using_special {
format!("{}{}", display, self.suffix)
} else {
display
};
builder.set_value(full);
if self.read_only {
builder.set_read_only();
}
builder.add_action(Action::Increment);
builder.add_action(Action::Decrement);
builder.add_action(Action::SetValue);
builder.add_action(Action::Focus);
}
}
fn build_step_buttons<T: SpinValue>(
ctx: &mut BuildContext,
step: &Rc<dyn Fn(i32, bool, &mut EventContext)>,
step_silent: &Rc<dyn Fn(i32, bool) -> Option<T>>,
can_up: Signal<bool>,
can_down: Signal<bool>,
enabled: bool,
frame_height: f32,
corner_radius: f32,
) -> (WidgetId, WidgetId) {
let button_height = ((frame_height - 2.0) * 0.5).max(8.0);
let button_width = 18.0;
let up_icon = chevron_up_icon(8.0);
let down_icon = chevron_down_icon(8.0);
let up_enabled = if enabled { can_up } else { Signal::new(false) };
let down_enabled = if enabled {
can_down
} else {
Signal::new(false)
};
let step_for_up_tap = step.clone();
let silent_for_up_auto = step_silent.clone();
let up_button = StepButton::new(up_icon, up_enabled, move |ctx| {
(step_for_up_tap)(1, false, ctx);
})
.on_auto_repeat(move || {
(silent_for_up_auto)(1, false);
})
.size(button_width, button_height)
.corner_radius(CornerRadius {
top_left: 0.0,
top_right: corner_radius,
bottom_left: 0.0,
bottom_right: 0.0,
});
let step_for_down_tap = step.clone();
let silent_for_down_auto = step_silent.clone();
let down_button = StepButton::new(down_icon, down_enabled, move |ctx| {
(step_for_down_tap)(-1, false, ctx);
})
.on_auto_repeat(move || {
(silent_for_down_auto)(-1, false);
})
.size(button_width, button_height)
.corner_radius(CornerRadius {
top_left: 0.0,
top_right: 0.0,
bottom_left: 0.0,
bottom_right: corner_radius,
});
(ctx.add(up_button), ctx.add(down_button))
}
fn chevron_up_icon(size: f32) -> IconWidget {
let mut path = Path::new();
let s = size;
path.move_to(Point::new(s * 0.25, s * 0.65));
path.line_to(Point::new(s * 0.5, s * 0.35));
path.line_to(Point::new(s * 0.75, s * 0.65));
IconWidget::from_path(path, size)
}
fn chevron_down_icon(size: f32) -> IconWidget {
let mut path = Path::new();
let s = size;
path.move_to(Point::new(s * 0.25, s * 0.35));
path.line_to(Point::new(s * 0.5, s * 0.65));
path.line_to(Point::new(s * 0.75, s * 0.35));
IconWidget::from_path(path, size)
}
#[derive(Clone)]
pub(crate) struct NumberPresentation {
symbols: Option<Rc<teksilo_i18n::NumberSymbols>>,
grouping: bool,
}
impl NumberPresentation {
pub(crate) fn resolve(localized: bool, grouping: bool) -> Self {
Self {
symbols: localized.then(teksilo_i18n::NumberSymbols::current),
grouping,
}
}
fn render(&self, plain: String) -> String {
match &self.symbols {
Some(sym) => sym.localize(&plain, self.grouping),
None => plain,
}
}
fn read(&self, raw: &str) -> Option<String> {
match &self.symbols {
Some(sym) => sym.delocalize(raw),
None => Some(raw.trim().to_string()),
}
}
fn parse<T: SpinValue>(&self, raw: &str) -> Option<T> {
T::parse(&self.read(raw)?)
}
fn accepts_char<T: SpinValue>(&self, c: char) -> bool {
if T::is_valid_input_char(c) {
return true;
}
let Some(sym) = &self.symbols else {
return false;
};
if sym.has_non_ascii_digits() && sym.delocalize(&c.to_string()).is_some() {
return true;
}
[
Some(sym.decimal_separator()),
Some(sym.minus_sign()),
Some(sym.plus_sign()),
self.grouping.then(|| sym.group_separator()),
]
.into_iter()
.flatten()
.any(|sep| sep.chars().any(|sc| sc == c))
}
}
fn format_for_display<T: SpinValue>(
value: T,
decimals: u8,
special: Option<&LocalizedString>,
custom: Option<&dyn Fn(T) -> LocalizedString>,
min: T,
force_plain: bool,
presentation: &NumberPresentation,
) -> String {
if !force_plain
&& let Some(special_text) = special
&& approx_eq(value, min)
{
return special_text.resolve_now();
}
match custom {
Some(f) => f(value).resolve_now(),
None => presentation.render(value.format(decimals)),
}
}
fn resolve_effective_step<T: SpinValue>(step_type: StepType, current: T, base_step: T) -> T {
if step_type == StepType::Fixed {
return base_step;
}
let abs = current.to_f64().abs();
if abs < 10.0 {
return base_step;
}
let pow = abs.log10().floor();
let magnitude = 10f64.powf(pow);
let adaptive = T::from_f64_saturating(magnitude);
let adaptive_f = adaptive.to_f64();
if adaptive_f.abs() < 1e-12 {
base_step
} else {
adaptive
}
}
fn approx_eq<T: SpinValue>(a: T, b: T) -> bool {
if T::is_integer() {
a.to_f64() == b.to_f64()
} else {
let af = a.to_f64();
let bf = b.to_f64();
let scale = af.abs().max(bf.abs()).max(1.0);
(af - bf).abs() <= scale * 1e-9
}
}
fn approx_ne<T: SpinValue>(a: T, b: T) -> bool {
!approx_eq(a, b)
}
fn measure_width_px(ctx: &mut BuildContext, text: &str, style: &TextStyle) -> f32 {
if text.is_empty() {
return 0.0;
}
if let Some(ts) = ctx.app_state::<SharedTypesetter>() {
let backend = ts.as_text_backend();
let layout = backend.borrow_mut().layout_single_line(text, style, None);
return layout.width;
}
text.chars().count() as f32 * style.size * 0.55
}