// 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.
///|
/// Semantic execution status for individual workflow steps.
pub(all) enum StepStatus {
Wait
Process
Finish
Error
} derive(Eq, Debug)
///|
/// Descriptor for an individual step item in a Steps navigation bar.
pub struct StepItem {
title : String
description : String
status : StepStatus?
} derive(Eq, Debug)
///|
/// Creates a new step descriptor with title and optional description.
pub fn StepItem::new(title : String, description? : String = "") -> StepItem {
{ title, description, status: None, }
}
///|
/// Creates a step descriptor with an explicit semantic status.
pub fn StepItem::with_status(
title : String,
status : StepStatus,
description? : String = "",
) -> StepItem {
{ title, description, status: Some(status), }
}
///|
/// Standalone workflow steps navigation bar widget supporting fluent configuration.
pub struct Steps {
id_salt : String
current : Int
items : Array[StepItem]
clickable : Bool
width : Double?
} derive(Eq, Debug)
///|
/// Creates a new Steps widget with current index and item list.
pub fn Steps::new(
id_salt : String,
current : Int,
items : Array[StepItem],
) -> Steps {
{ id_salt, current, items, clickable: true, width: None, }
}
///|
/// Configures whether clicking step nodes switches active index.
pub fn Steps::clickable(self : Steps, clickable : Bool) -> Steps {
{ ..self, clickable, }
}
///|
/// Sets explicit width for the steps layout.
pub fn Steps::width(self : Steps, width : Double) -> Steps {
{ ..self, width: Some(width), }
}
///|
/// Reference-bound Steps widget for uniform `ui.add(...)` dispatch.
pub struct StepsRef {
steps : Steps
target : Ref[Int]
}
///|
/// Creates a Steps widget bound directly to a `Ref[Int]`.
pub fn Steps::from_ref(
id_salt : String,
target : Ref[Int],
items : Array[StepItem],
) -> StepsRef {
{ steps: Steps::new(id_salt, target.val, items), target, }
}
///|
pub fn StepsRef::clickable(self : StepsRef, clickable : Bool) -> StepsRef {
{ ..self, steps: self.steps.clickable(clickable), }
}
///|
pub fn StepsRef::width(self : StepsRef, width : Double) -> StepsRef {
{ ..self, steps: self.steps.width(width), }
}
///|
pub impl @core.Widget for StepsRef with fn ui(self, ctx : UIContext) -> Response {
let (new_idx, resp) = { ..self.steps, current: self.target.val, }.show(ctx)
self.target.val = new_idx
resp
}
///|
/// Renders the Steps navigation bar, draws connector rails, step nodes, titles,
/// and returns the selected step index and interaction response.
pub fn Steps::show(self : Steps, ctx : UIContext) -> (Int, Response) {
let scale = ctx.style.scale
let circle_r = 13.0 * scale
let font_title = ctx.style.font_normal * scale
let font_desc = ctx.style.font_small * scale
let n = self.items.length()
let has_desc = self.items
.iter()
.any(fn(item) { item.description.length() > 0 })
let h = if has_desc { 56.0 * scale } else { 38.0 * scale }
let default_w = ctx.available_width()
let avail_w = if default_w > 100.0 * scale {
default_w
} else {
320.0 * scale
}
let w = match self.width {
Some(w_val) => w_val * scale
None => avail_w
}
ctx.push_id(self.id_salt)
let (id, rect, resp) = ctx.allocate_space(@math.Vec2::new(w, h))
ctx.pop_id()
if self.clickable {
ctx.register_focusable(id)
}
let is_focused = self.clickable && (resp.has_focus() || ctx.has_focus(id))
let mut new_current = self.current
if new_current < 0 {
new_current = 0
}
if n > 0 && new_current >= n {
new_current = n - 1
}
// 1. Keyboard Navigation when focused
if is_focused && n > 1 {
if ctx.input.key_pressed(Key::ArrowLeft) ||
ctx.input.key_pressed(Key::ArrowDown) {
if new_current > 0 {
new_current = new_current - 1
}
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) {
if new_current < n - 1 {
new_current = new_current + 1
}
ctx.input.consume_key(Key::ArrowRight)
ctx.input.consume_key(Key::ArrowUp)
}
if ctx.input.key_pressed(Key::Home) {
new_current = 0
ctx.input.consume_key(Key::Home)
}
if ctx.input.key_pressed(Key::End) {
new_current = n - 1
ctx.input.consume_key(Key::End)
}
}
// 2. Mouse Click & Hover Detection
if n > 0 {
let step_w = rect.w / n.to_double()
for i = 0; i < n; i = i + 1 {
let item_rect = @math.Rect::new(
rect.x + i.to_double() * step_w,
rect.y,
step_w,
h,
)
if self.clickable && ctx.is_hovered(item_rect) {
ctx.set_cursor_icon("pointer")
if resp.clicked {
new_current = i
}
}
}
}
// 3. Focus Ring
let theme = ctx.theme
if is_focused {
let radius = ctx.style.radius_md * scale
ctx
.painter()
.add_rect_stroke(
rect.expand(2.0 * scale),
theme.border_focus,
1.5 * scale,
radius + 2.0 * scale,
)
}
// 4. Drawing Nodes & Rails
if n > 0 {
let step_w = rect.w / n.to_double()
let circle_y = rect.y + circle_r + 2.0 * scale
// Draw connector rails between steps
for i = 0; i < n - 1; i = i + 1 {
let c1_x = rect.x + (i.to_double() + 0.5) * step_w
let c2_x = rect.x + (i.to_double() + 1.5) * step_w
let rail_start_x = c1_x + circle_r + 6.0 * scale
let rail_end_x = c2_x - circle_r - 6.0 * scale
if rail_end_x > rail_start_x {
let rail_color = if i < new_current {
theme.accent_primary
} else {
theme.border_muted
}
ctx
.painter()
.add_line(
@math.Vec2::new(rail_start_x, circle_y),
@math.Vec2::new(rail_end_x, circle_y),
rail_color,
2.0 * scale,
)
}
}
// Draw step nodes (circles and labels)
for i = 0; i < n; i = i + 1 {
let center_x = rect.x + (i.to_double() + 0.5) * step_w
let center = @math.Vec2::new(center_x, circle_y)
let item = self.items[i]
let status = match item.status {
Some(st) => st
None =>
if i < new_current {
StepStatus::Finish
} else if i == new_current {
StepStatus::Process
} else {
StepStatus::Wait
}
}
// Draw node circle
match status {
Finish => {
ctx.painter().add_circle(center, circle_r, theme.accent_primary)
// Hand-drawn vector checkmark (✓), mathematically centered within the circle
let stroke_w = 2.0 * scale
let p1 = @math.Vec2::new(
center_x - circle_r * 0.38,
circle_y - circle_r * 0.02,
)
let p2 = @math.Vec2::new(
center_x - circle_r * 0.10,
circle_y + circle_r * 0.32,
)
let p3 = @math.Vec2::new(
center_x + circle_r * 0.38,
circle_y - circle_r * 0.30,
)
ctx.painter().add_line(p1, p2, theme.text_inverse, stroke_w)
ctx.painter().add_line(p2, p3, theme.text_inverse, stroke_w)
}
Process => {
// Subtle halo for active process node
ctx
.painter()
.add_circle_stroke(
center,
circle_r + 3.0 * scale,
theme.accent_soft,
2.0 * scale,
)
ctx.painter().add_circle(center, circle_r, theme.accent_primary)
let num_str = (i + 1).to_string()
let num_sz = ctx.measure_text(num_str, font_title)
let num_pos = @math.Vec2::new(
center_x - num_sz.x * 0.5,
circle_y - num_sz.y * 0.5,
)
ctx
.painter()
.add_text(num_pos, num_str, font_title, theme.text_inverse)
}
Wait => {
ctx.painter().add_circle(center, circle_r, theme.bg_surface)
ctx
.painter()
.add_circle_stroke(
center,
circle_r,
theme.border_default,
1.0 * scale,
)
let num_str = (i + 1).to_string()
let num_sz = ctx.measure_text(num_str, font_title)
let num_pos = @math.Vec2::new(
center_x - num_sz.x * 0.5,
circle_y - num_sz.y * 0.5,
)
ctx.painter().add_text(num_pos, num_str, font_title, theme.text_muted)
}
Error => {
ctx.painter().add_circle(center, circle_r, theme.danger)
// Hand-drawn vector cross (✕), symmetrically centered within the circle
let stroke_w = 2.0 * scale
let d = circle_r * 0.34
let q1 = @math.Vec2::new(center_x - d, circle_y - d)
let q2 = @math.Vec2::new(center_x + d, circle_y + d)
let q3 = @math.Vec2::new(center_x - d, circle_y + d)
let q4 = @math.Vec2::new(center_x + d, circle_y - d)
ctx.painter().add_line(q1, q2, theme.text_inverse, stroke_w)
ctx.painter().add_line(q3, q4, theme.text_inverse, stroke_w)
}
}
// Draw Title below circle
let title_color = match status {
Process => theme.text_primary
Finish => theme.text_primary
Error => theme.danger
Wait => theme.text_muted
}
let title_y = circle_y + circle_r + 4.0 * scale
let title_sz = ctx.measure_text(item.title, font_title)
let title_x = center_x - title_sz.x * 0.5
ctx
.painter()
.add_text(
@math.Vec2::new(title_x, title_y),
item.title,
font_title,
title_color,
)
// Draw Description if present
if item.description.length() > 0 {
let desc_y = title_y + font_title + 2.0 * scale
let desc_sz = ctx.measure_text(item.description, font_desc)
let desc_x = center_x - desc_sz.x * 0.5
ctx
.painter()
.add_text(
@math.Vec2::new(desc_x, desc_y),
item.description,
font_desc,
theme.text_secondary,
)
}
}
}
(new_current, resp)
}
///|
/// Convenience functional helper to render a Steps navigation bar.
pub fn steps(
ctx : UIContext,
id_salt : String,
current : Int,
items : Array[StepItem],
clickable? : Bool = true,
width? : Double,
) -> (Int, Response) {
let mut s = Steps::new(id_salt, current, items).clickable(clickable)
if width is Some(w) {
s = s.width(w)
}
s.show(ctx)
}