// Copyright 2026 Ling71671
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Formats a floating-point number into a string with specified decimal precision.
fn format_stepper_value(val : Double, precision : Int) -> String {
if precision <= 0 {
let rounded = if val >= 0.0 {
(val + 0.5).to_int()
} else {
(val - 0.5).to_int()
}
rounded.to_string()
} else if precision == 1 {
let factor = 10.0
let rounded = if val >= 0.0 {
(val * factor + 0.5).to_int()
} else {
(val * factor - 0.5).to_int()
}
let whole = rounded / 10
let frac = (rounded % 10).abs()
let sign = if val < 0.0 && whole == 0 { "-" } else { "" }
"\{sign}\{whole}.\{frac}"
} else {
let mut factor = 1.0
for i = 0; i < precision; i = i + 1 {
factor = factor * 10.0
}
let factor_int = factor.to_int()
let rounded = if val >= 0.0 {
(val * factor + 0.5).to_int()
} else {
(val * factor - 0.5).to_int()
}
let whole = rounded / factor_int
let frac = (rounded % factor_int).abs()
let mut frac_str = frac.to_string()
while frac_str.length() < precision {
frac_str = "0" + frac_str
}
let sign = if val < 0.0 && whole == 0 { "-" } else { "" }
"\{sign}\{whole}.\{frac_str}"
}
}
///|
/// Standalone numeric stepper widget supporting fluent configuration.
pub struct Stepper {
id_salt : String
value : Double
step : Double
min : Double?
max : Double?
precision : Int
prefix : String
suffix : String
width : Double?
} derive(Eq, Debug)
///|
/// Creates a new Stepper widget with the given identifier and initial value.
pub fn Stepper::new(id_salt : String, value : Double) -> Stepper {
{
id_salt,
value,
step: 1.0,
min: None,
max: None,
precision: 0,
prefix: "",
suffix: "",
width: None,
}
}
///|
/// Sets the step increment/decrement amount per step action.
pub fn Stepper::step(self : Stepper, step : Double) -> Stepper {
{ ..self, step: if step > 0.0 { step } else { 1.0 }, }
}
///|
/// Sets the lower bound minimum value.
pub fn Stepper::min(self : Stepper, min : Double) -> Stepper {
{ ..self, min: Some(min), }
}
///|
/// Sets the upper bound maximum value.
pub fn Stepper::max(self : Stepper, max : Double) -> Stepper {
{ ..self, max: Some(max), }
}
///|
/// Sets both minimum and maximum boundaries.
pub fn Stepper::range(self : Stepper, min : Double, max : Double) -> Stepper {
{ ..self, min: Some(min), max: Some(max), }
}
///|
/// Sets decimal precision for number formatting.
pub fn Stepper::precision(self : Stepper, precision : Int) -> Stepper {
{ ..self, precision: if precision < 0 { 0 } else { precision }, }
}
///|
/// Sets a display prefix string (e.g. "Width: ").
pub fn Stepper::prefix(self : Stepper, prefix : String) -> Stepper {
{ ..self, prefix, }
}
///|
/// Sets a display suffix string (e.g. " px", " dB").
pub fn Stepper::suffix(self : Stepper, suffix : String) -> Stepper {
{ ..self, suffix, }
}
///|
/// Overrides the default width of the stepper component.
pub fn Stepper::width(self : Stepper, width : Double) -> Stepper {
{ ..self, width: Some(width), }
}
///|
/// Reference-bound stepper widget for uniform `ui.add(...)` dispatch.
pub struct StepperRef {
stepper : Stepper
target : Ref[Double]
}
///|
/// Creates a Stepper bound directly to a `Ref[Double]`.
pub fn Stepper::from_ref(id_salt : String, target : Ref[Double]) -> StepperRef {
{ stepper: Stepper::new(id_salt, target.val), target, }
}
///|
pub fn StepperRef::step(self : StepperRef, step : Double) -> StepperRef {
{ ..self, stepper: self.stepper.step(step), }
}
///|
pub fn StepperRef::min(self : StepperRef, min : Double) -> StepperRef {
{ ..self, stepper: self.stepper.min(min), }
}
///|
pub fn StepperRef::max(self : StepperRef, max : Double) -> StepperRef {
{ ..self, stepper: self.stepper.max(max), }
}
///|
pub fn StepperRef::range(
self : StepperRef,
min : Double,
max : Double,
) -> StepperRef {
{ ..self, stepper: self.stepper.range(min, max), }
}
///|
pub fn StepperRef::precision(self : StepperRef, precision : Int) -> StepperRef {
{ ..self, stepper: self.stepper.precision(precision), }
}
///|
pub fn StepperRef::prefix(self : StepperRef, prefix : String) -> StepperRef {
{ ..self, stepper: self.stepper.prefix(prefix), }
}
///|
pub fn StepperRef::suffix(self : StepperRef, suffix : String) -> StepperRef {
{ ..self, stepper: self.stepper.suffix(suffix), }
}
///|
pub fn StepperRef::width(self : StepperRef, width : Double) -> StepperRef {
{ ..self, stepper: self.stepper.width(width), }
}
///|
pub impl @core.Widget for StepperRef with fn ui(self, ctx : UIContext) -> Response {
let (new_val, resp) = { ..self.stepper, value: self.target.val, }.show(ctx)
self.target.val = new_val
resp
}
///|
/// Renders the stepper widget, handles mouse clicks, wheel, keyboard inputs,
/// and returns the updated numeric value and interaction response.
pub fn Stepper::show(self : Stepper, ctx : UIContext) -> (Double, Response) {
let scale = ctx.style.scale
let h = (ctx.style.control_h_sm - ctx.style.radius_xs) * scale
let radius = ctx.style.radius_sm * scale
let font_sz = ctx.style.font_small * scale
let default_w = 112.0 * scale
let w = match self.width {
Some(w_val) => w_val * scale
None => default_w
}
let btn_w = h
ctx.push_id(self.id_salt)
let (id, rect, resp) = ctx.allocate_space(@math.Vec2::new(w, h))
ctx.pop_id()
ctx.register_focusable(id)
let is_focused = resp.has_focus() || ctx.has_focus(id)
let btn_minus_rect = @math.Rect::new(rect.x, rect.y, btn_w, h)
let btn_plus_rect = @math.Rect::new(rect.x + rect.w - btn_w, rect.y, btn_w, h)
let val_rect = @math.Rect::new(
rect.x + btn_w,
rect.y,
rect.w - 2.0 * btn_w,
h,
)
let minus_hovered = ctx.is_hovered(btn_minus_rect)
let plus_hovered = ctx.is_hovered(btn_plus_rect)
let val_hovered = ctx.is_hovered(val_rect)
// Cursor contract
if resp.hovered {
if minus_hovered || plus_hovered {
ctx.set_cursor_icon("pointer")
} else if val_hovered {
ctx.set_cursor_icon("ew-resize")
} else {
ctx.set_cursor_icon("pointer")
}
}
// Modifiers: Shift = 10x, Ctrl/Meta = 0.1x
let multiplier = if ctx.input.modifiers.shift {
10.0
} else if ctx.input.modifiers.ctrl || ctx.input.modifiers.meta {
0.1
} else {
1.0
}
let effective_step = self.step * multiplier
let mut new_val = if self.value.is_nan() {
match self.min {
Some(min_v) => min_v
None => 0.0
}
} else {
self.value
}
// 1. Mouse Click on Buttons
if resp.clicked {
if minus_hovered {
new_val = new_val - effective_step
} else if plus_hovered {
new_val = new_val + effective_step
}
}
// 2. Mouse Wheel when hovered over stepper
if resp.hovered && ctx.input.scroll_delta.y != 0.0 {
if ctx.input.scroll_delta.y > 0.0 {
new_val = new_val - effective_step
} else {
new_val = new_val + effective_step
}
}
// 3. Keyboard navigation when focused
if is_focused {
if ctx.input.key_pressed(Key::ArrowLeft) ||
ctx.input.key_pressed(Key::ArrowDown) {
new_val = new_val - effective_step
ctx.input.consume_key(Key::ArrowLeft)
ctx.input.consume_key(Key::ArrowDown)
}
if ctx.input.key_pressed(Key::ArrowRight) ||
ctx.input.key_pressed(Key::ArrowUp) {
new_val = new_val + effective_step
ctx.input.consume_key(Key::ArrowRight)
ctx.input.consume_key(Key::ArrowUp)
}
if ctx.input.key_pressed(Key::Home) {
match self.min {
Some(min_v) => new_val = min_v
None => ()
}
ctx.input.consume_key(Key::Home)
}
if ctx.input.key_pressed(Key::End) {
match self.max {
Some(max_v) => new_val = max_v
None => ()
}
ctx.input.consume_key(Key::End)
}
let page_step = effective_step * 10.0
if ctx.input.key_pressed(Key::PageUp) {
new_val = new_val + page_step
ctx.input.consume_key(Key::PageUp)
}
if ctx.input.key_pressed(Key::PageDown) {
new_val = new_val - page_step
ctx.input.consume_key(Key::PageDown)
}
}
// 4. Bounds Clamping
match self.min {
Some(min_v) => if new_val < min_v { new_val = min_v }
None => ()
}
match self.max {
Some(max_v) => if new_val > max_v { new_val = max_v }
None => ()
}
// 5. Drawing (Painter)
// Background for the entire stepper
let theme = ctx.theme
let base_bg = if resp.hovered { theme.bg_window } else { theme.bg_surface }
ctx.painter().add_rect(rect, base_bg, radius)
// Minus button background
let minus_bg = if minus_hovered && ctx.input.mouse_down {
theme.bg_active
} else if minus_hovered {
theme.bg_subtle
} else {
theme.bg_surface
}
ctx.painter().add_rect(btn_minus_rect, minus_bg, radius)
// Plus button background
let plus_bg = if plus_hovered && ctx.input.mouse_down {
theme.bg_active
} else if plus_hovered {
theme.bg_subtle
} else {
theme.bg_surface
}
ctx.painter().add_rect(btn_plus_rect, plus_bg, radius)
// Vertical divider lines
let sep_color = theme.border_muted
let line_w = 1.0 * scale
ctx
.painter()
.add_line(
@math.Vec2::new(rect.x + btn_w, rect.y + 2.0 * scale),
@math.Vec2::new(rect.x + btn_w, rect.y + h - 2.0 * scale),
sep_color,
line_w,
)
ctx
.painter()
.add_line(
@math.Vec2::new(rect.x + rect.w - btn_w, rect.y + 2.0 * scale),
@math.Vec2::new(rect.x + rect.w - btn_w, rect.y + h - 2.0 * scale),
sep_color,
line_w,
)
// Outer border stroke
let border_color = if is_focused {
theme.border_focus
} else if resp.hovered {
theme.accent_primary
} else {
theme.border_default
}
ctx.painter().add_rect_stroke(rect, border_color, 1.0 * scale, radius)
// Focus Ring
if is_focused {
ctx
.painter()
.add_rect_stroke(
rect.expand(2.0 * scale),
theme.border_focus,
1.5 * scale,
radius + 2.0 * scale,
)
}
// Draw Minus icon (-)
let icon_color = if minus_hovered {
theme.accent_primary
} else {
theme.text_secondary
}
let minus_center = @math.Vec2::new(
btn_minus_rect.x + btn_w * 0.5,
btn_minus_rect.y + h * 0.5,
)
let icon_half_w = 4.0 * scale
ctx
.painter()
.add_line(
@math.Vec2::new(minus_center.x - icon_half_w, minus_center.y),
@math.Vec2::new(minus_center.x + icon_half_w, minus_center.y),
icon_color,
1.5 * scale,
)
// Draw Plus icon (+)
let plus_icon_color = if plus_hovered {
theme.accent_primary
} else {
theme.text_secondary
}
let plus_center = @math.Vec2::new(
btn_plus_rect.x + btn_w * 0.5,
btn_plus_rect.y + h * 0.5,
)
ctx
.painter()
.add_line(
@math.Vec2::new(plus_center.x - icon_half_w, plus_center.y),
@math.Vec2::new(plus_center.x + icon_half_w, plus_center.y),
plus_icon_color,
1.5 * scale,
)
ctx
.painter()
.add_line(
@math.Vec2::new(plus_center.x, plus_center.y - icon_half_w),
@math.Vec2::new(plus_center.x, plus_center.y + icon_half_w),
plus_icon_color,
1.5 * scale,
)
// Format and draw Value Text
let formatted_val = format_stepper_value(new_val, self.precision)
let display_text = self.prefix + formatted_val + self.suffix
let text_sz = ctx.measure_text(display_text, font_sz)
let text_pos = @math.Vec2::new(
val_rect.x + (val_rect.w - text_sz.x) * 0.5,
val_rect.y + (val_rect.h - font_sz) * 0.5,
)
let text_color = if is_focused {
theme.accent_primary
} else {
theme.text_primary
}
ctx.painter().add_text(text_pos, display_text, font_sz, text_color)
(new_val, resp)
}
///|
/// Convenience functional helper to render a stepper widget.
pub fn stepper(
ctx : UIContext,
id_salt : String,
value : Double,
step? : Double = 1.0,
min? : Double,
max? : Double,
precision? : Int = 0,
prefix? : String = "",
suffix? : String = "",
width? : Double,
) -> (Double, Response) {
let mut s = Stepper::new(id_salt, value)
.step(step)
.precision(precision)
.prefix(prefix)
.suffix(suffix)
if min is Some(m) {
s = s.min(m)
}
if max is Some(m) {
s = s.max(m)
}
if width is Some(w) {
s = s.width(w)
}
s.show(ctx)
}