use std::cell::RefCell;
use std::rc::Rc;
use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::Signal;
use teksilo_core::styles::RecipeColor;
use teksilo_core::widget::{
CursorIcon, EventContext, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_i18n::{LocalizedString, lit};
use teksilo_tokens::{BorderRole, FontWeight, SurfaceRole, TextRole, TextStyle, TextStyleRole};
use super::StepperOrientation;
use super::step::StepStatus;
use crate::primitives::{TextWidget, VStack};
pub(crate) const DEFAULT_CIRCLE_SIZE: f32 = 28.0;
const MARKER_LABEL_GAP: f32 = 10.0;
type ActivateFn = Rc<dyn Fn(&mut EventContext)>;
pub(crate) struct StepIndicator {
index: usize,
total: usize,
title: LocalizedString,
supporting_text: Option<LocalizedString>,
status: Signal<StepStatus>,
current: Signal<usize>,
orientation: StepperOrientation,
clickable: bool,
circle_size: f32,
on_activate: Option<ActivateFn>,
panel_ids: Rc<RefCell<Vec<WidgetId>>>,
label_block_id: Option<WidgetId>,
}
impl StepIndicator {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
index: usize,
total: usize,
title: LocalizedString,
supporting_text: Option<LocalizedString>,
status: Signal<StepStatus>,
current: Signal<usize>,
orientation: StepperOrientation,
clickable: bool,
circle_size: f32,
on_activate: Option<ActivateFn>,
panel_ids: Rc<RefCell<Vec<WidgetId>>>,
) -> Self {
Self {
index,
total,
title,
supporting_text,
status,
current,
orientation,
clickable,
circle_size,
on_activate,
panel_ids,
label_block_id: None,
}
}
fn is_active(&self) -> bool {
self.current.get() == self.index
}
fn marker_rect(&self, bounds: Rect) -> Rect {
let d = self.circle_size;
let cy = bounds.y + (bounds.height - d).max(0.0) / 2.0;
Rect::new(bounds.x, cy, d, d)
}
}
impl std::fmt::Debug for StepIndicator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StepIndicator")
.field("index", &self.index)
.field("total", &self.total)
.finish()
}
}
impl Widget for StepIndicator {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let locale = ctx.locale_signal();
let title = self.title.clone();
let title_sig = locale.map(move |_| title.resolve_now());
let mut block = VStack::new().spacing(2.0).child(
TextWidget::new(lit!(""))
.text(title_sig)
.style(TextStyleRole::Body)
.color(if self.is_active() {
TextRole::Primary
} else {
TextRole::Secondary
})
.single_line(),
);
if let Some(ref supporting) = self.supporting_text {
let supporting = supporting.clone();
let locale2 = ctx.locale_signal();
let supporting_sig = locale2.map(move |_| supporting.resolve_now());
block = block.child(
TextWidget::new(lit!(""))
.text(supporting_sig)
.style(TextStyleRole::Small)
.color(TextRole::Secondary)
.single_line(),
);
}
let block_id = ctx.add(block);
self.label_block_id = Some(block_id);
let self_id = ctx.self_id();
let registry = ctx.binding_registry();
for level in [
teksilo_core::binding::BindingLevel::RepaintOnly,
teksilo_core::binding::BindingLevel::AccessibilityOnly,
] {
self.status.bind_to(self_id, registry, level);
self.current.bind_to(self_id, registry, level);
}
if self.clickable {
if let Some(activate) = self.on_activate.clone() {
let activate2 = activate.clone();
let handlers = HandlerSet::new()
.focusable(true)
.cursor(CursorIcon::Pointer)
.on_tap(move |_pos, ctx| activate(ctx))
.on_access_action(move |action, ctx| {
if action == teksilo_core::accesskit::Action::Click {
activate2(ctx);
teksilo_core::event::EventResponse::Handled
} else {
teksilo_core::event::EventResponse::Ignored
}
});
ctx.apply_self_handlers(handlers);
}
}
vec![block_id]
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
let d = self.circle_size;
let label = self
.label_block_id
.and_then(|id| ctx.child_size(id, proposal))
.map(|r| Size::new(r.width, r.height))
.unwrap_or(Size::new(0.0, 0.0));
let w = d + MARKER_LABEL_GAP + label.width;
let h = d.max(label.height);
proposal.resolve(w, h).into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
let d = self.circle_size;
for child in children.iter_mut() {
let x = bounds.x + d + MARKER_LABEL_GAP;
let w = (bounds.width - d - MARKER_LABEL_GAP).max(0.0);
child.origin = Point::new(x, bounds.y);
child.size = Size::new(w, bounds.height);
}
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
let theme = ctx.theme;
let status = self.status.get();
let marker = self.marker_rect(bounds);
let center = Point::new(
marker.x + marker.width / 2.0,
marker.y + marker.height / 2.0,
);
let radius = marker.width / 2.0;
let (fill, stroke, fg, glyph): (SurfaceRole, Option<BorderRole>, TextRole, Glyph) =
match status {
StepStatus::Active => {
(SurfaceRole::Accent, None, TextRole::OnAccent, Glyph::Number)
}
StepStatus::Complete => {
(SurfaceRole::Accent, None, TextRole::OnAccent, Glyph::Check)
}
StepStatus::Error => (
SurfaceRole::Content,
Some(BorderRole::Error),
TextRole::Error,
Glyph::Bang,
),
StepStatus::Disabled => (
SurfaceRole::Content,
Some(BorderRole::Divider),
TextRole::Disabled,
Glyph::Number,
),
StepStatus::Skipped => (
SurfaceRole::Content,
Some(BorderRole::Divider),
TextRole::Secondary,
Glyph::Dash,
),
StepStatus::Upcoming | StepStatus::Optional => (
SurfaceRole::Content,
Some(BorderRole::Divider),
TextRole::Secondary,
Glyph::Number,
),
};
canvas.fill_circle(center, radius, RecipeColor::from(fill).resolve(theme));
if let Some(border) = stroke {
canvas.stroke_circle(
center,
radius - 1.0,
RecipeColor::from(border).resolve(theme),
2.0,
);
}
let text = match glyph {
Glyph::Number => (self.index + 1).to_string(),
Glyph::Check => "✓".to_string(),
Glyph::Bang => "!".to_string(),
Glyph::Dash => "–".to_string(),
};
let Some(backend) = canvas.text_backend().cloned() else {
return;
};
let style = TextStyle {
family: theme.typography.body_bold.family.clone(),
size: self.circle_size * 0.46,
weight: FontWeight::SEMI_BOLD,
line_height: 1.0,
letter_spacing: 0.0,
};
let layout = backend.borrow_mut().layout_single_line(&text, &style, None);
let pos = Rect::new(
center.x - layout.width / 2.0,
center.y - layout.height / 2.0,
layout.width,
layout.height,
);
canvas.draw_text(&text, pos, &style, RecipeColor::from(fg).resolve(theme));
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
let active = self.is_active();
if self.clickable {
builder.set_role(teksilo_core::accesskit::Role::Tab);
builder.set_selected(active);
builder.add_action(teksilo_core::accesskit::Action::Focus);
builder.add_action(teksilo_core::accesskit::Action::Click);
if let Some(&panel_id) = self.panel_ids.borrow().get(self.index) {
builder
.push_controlled(teksilo_core::accessibility::widget_id_to_node_id(panel_id));
}
} else {
builder.set_role(teksilo_core::accesskit::Role::ListItem);
}
builder.set_name(self.title.resolve_now());
if active {
builder.set_aria_current(teksilo_core::accesskit::AriaCurrent::Step);
}
builder.inner_mut().set_position_in_set(self.index + 1);
builder.inner_mut().set_size_of_set(self.total);
let _ = self.orientation; }
fn children(&self) -> Vec<WidgetId> {
self.label_block_id.into_iter().collect()
}
}
enum Glyph {
Number,
Check,
Bang,
Dash,
}