1use frus_core::{
14 Color, Point, Rect, ResolvedTextStyle, Role, Scene, SemanticsProperties, TextStyle,
15};
16use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
17
18use crate::flex::Flex;
19use crate::icons::Icons;
20use crate::interaction::Status;
21use crate::theme::Theme;
22use crate::widget::Widget;
23
24const MARKER_D: f32 = 28.0;
26const R: f32 = MARKER_D / 2.0;
28const LABEL_GAP: f32 = 8.0;
30const NUM_SIZE: f32 = 12.0;
33const HEIGHT: f32 = 56.0;
35
36fn label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
47 over.or(theme.and_then(|t| t.widgets.steps.label_text_style))
48 .unwrap_or_else(|| crate::theme::type_scale(theme).body_large)
49 .resolved()
50}
51
52fn index_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
59 match over.or(theme.and_then(|t| t.widgets.steps.index_text_style)) {
60 Some(style) => style.resolved(),
61 None => ResolvedTextStyle::exact(NUM_SIZE),
62 }
63}
64
65fn bar_height(label: &ResolvedTextStyle) -> f32 {
68 HEIGHT.max(MARKER_D + LABEL_GAP + label.line_height() + 4.0)
69}
70
71pub struct Steps<Msg = crate::callback::Callback> {
79 labels: Vec<String>,
80 current: usize,
81 color: Option<Color>,
83 completed: Vec<bool>,
86 label_text_style: Option<TextStyle>,
87 index_text_style: Option<TextStyle>,
88 children: Vec<Box<dyn Widget<Msg>>>,
91}
92
93impl<Msg: Clone + 'static> Steps<Msg> {
94 pub fn new(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
96 Self {
97 labels: labels.into_iter().map(Into::into).collect(),
98 current: 0,
99 color: None,
100 completed: Vec::new(),
101 label_text_style: None,
102 index_text_style: None,
103 children: Vec::new(),
104 }
105 }
106
107 #[must_use]
109 pub fn label_text_style(mut self, style: TextStyle) -> Self {
110 self.label_text_style = Some(style);
111 self
112 }
113
114 #[must_use]
121 pub fn index_text_style(mut self, style: TextStyle) -> Self {
122 self.index_text_style = Some(style);
123 self
124 }
125
126 pub fn completed(mut self, flags: impl IntoIterator<Item = bool>) -> Self {
130 self.completed = flags.into_iter().collect();
131 self
132 }
133
134 pub fn current(mut self, index: usize) -> Self {
137 self.current = index.min(self.labels.len().saturating_sub(1));
138 self
139 }
140
141 pub fn color(mut self, color: Color) -> Self {
143 self.color = Some(color);
144 self
145 }
146
147 pub fn on_tap(mut self, on_tap: impl Fn(usize) -> Msg) -> Self {
152 let mut row: Flex<Msg> = Flex::row().justify(Justify::SpaceBetween);
153 for (i, label) in self.labels.iter().enumerate() {
154 row = row.child(Hotspot {
155 label: label.clone(),
156 message: on_tap(i),
157 });
158 }
159 self.children = vec![Box::new(row)];
160 self
161 }
162}
163
164impl<Msg> Steps<Msg> {
165 fn sizing(&self, theme: Option<&Theme>) -> Style {
166 let label = label_style(self.label_text_style, theme);
167 Style {
168 height: Dimension::Length(bar_height(&label)),
169 flex_direction: FlexDirection::Column,
171 align: Align::Stretch,
172 ..Default::default()
173 }
174 }
175
176 fn is_done(&self, i: usize) -> bool {
178 if self.completed.is_empty() {
179 i < self.current
180 } else {
181 self.completed.get(i).copied().unwrap_or(false)
182 }
183 }
184
185 fn center_x(&self, bounds: Rect, i: usize) -> f32 {
187 let n = self.labels.len();
188 if n <= 1 {
189 bounds.x + bounds.width * 0.5
190 } else {
191 bounds.x + R + i as f32 * (bounds.width - 2.0 * R) / (n as f32 - 1.0)
192 }
193 }
194}
195
196impl<Msg: Clone> Widget<Msg> for Steps<Msg> {
197 fn style(&self) -> Style {
201 self.sizing(None)
202 }
203
204 fn style_themed(&self, theme: &Theme) -> Style {
205 self.sizing(Some(theme))
206 }
207
208 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
210 crate::widget::FillAxes::WIDTH
211 }
212
213 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
214 &self.children
215 }
216
217 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
218 let n = self.labels.len();
219 if n == 0 {
220 return;
221 }
222 let o = status.opacity;
223 let accent = self.color.unwrap_or(theme.primary);
224 let cy = bounds.y + R;
225
226 for i in 0..n.saturating_sub(1) {
228 let x0 = self.center_x(bounds, i) + R;
229 let x1 = self.center_x(bounds, i + 1) - R;
230 let col = if self.is_done(i) {
231 accent
232 } else {
233 theme.border
234 };
235 let rect = Rect::new(x0, cy - 1.0, (x1 - x0).max(0.0), 2.0);
236 scene.draw_rect(rect, col.fade(o), 0.0, 0.0, Color::TRANSPARENT);
237 }
238
239 for i in 0..n {
241 let cx = self.center_x(bounds, i);
242 let rect = Rect::new(cx - R, cy - R, MARKER_D, MARKER_D);
243 let current = i == self.current;
244 let completed = !current && self.is_done(i);
247
248 let (fill, bw, bc) = if completed || current {
249 (accent, 0.0, Color::TRANSPARENT)
250 } else {
251 (theme.surface, 1.5, theme.border)
252 };
253 scene.draw_rect(rect, fill.fade(o), R, bw, bc.fade(o));
254
255 if completed {
256 let path = Icons::CHECK.placed(16.0, cx - 8.0, cy - 8.0, theme.direction);
258 scene.fill_path(&path, theme.on_primary.fade(o));
259 } else {
260 let num = (i + 1).to_string();
261 let num_style = index_style(self.index_text_style, Some(theme));
262 let m = frus_text::measure_resolved(&num, &num_style);
263 let color = if current {
264 theme.on_primary
265 } else {
266 theme.on_surface
267 };
268 let p = Point::new(cx - m.width * 0.5, cy - m.height * 0.5);
269 scene.text(p, num, &num_style, color.fade(o));
270 }
271
272 let label = &self.labels[i];
274 let label_s = label_style(self.label_text_style, Some(theme));
275 let lm = frus_text::measure_resolved(label, &label_s);
276 let alpha = if current { o } else { 0.6 * o };
277 let lx = (cx - lm.width * 0.5)
282 .clamp(bounds.x, (bounds.x + bounds.width - lm.width).max(bounds.x));
283 let p = Point::new(lx, bounds.y + MARKER_D + LABEL_GAP);
284 scene.text(p, label.clone(), &label_s, theme.on_surface.fade(alpha));
285 }
286 }
287
288 fn on_click(&self) -> Option<Msg> {
289 None
290 }
291}
292
293struct Hotspot<Msg> {
297 label: String,
298 message: Msg,
299}
300
301impl<Msg: Clone> Widget<Msg> for Hotspot<Msg> {
302 fn style(&self) -> Style {
303 Style {
304 width: Dimension::Length(MARKER_D),
305 height: Dimension::Length(MARKER_D),
306 ..Default::default()
307 }
308 }
309
310 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
311 &[]
312 }
313
314 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
315
316 fn on_click(&self) -> Option<Msg> {
317 Some(self.message.clone())
318 }
319
320 fn focusable(&self) -> bool {
321 true
322 }
323
324 fn semantics(&self) -> Option<SemanticsProperties> {
325 Some(
326 SemanticsProperties::new(Role::Button)
327 .label(self.label.clone())
328 .clickable(),
329 )
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use frus_core::Primitive;
337
338 fn paint_steps(steps: &Steps<()>) -> Vec<Primitive> {
339 let mut scene = Scene::new();
340 Widget::<()>::paint(
341 steps,
342 Rect::new(0.0, 0.0, 400.0, HEIGHT),
343 Status::default(),
344 &Theme::default(),
345 &mut scene,
346 );
347 scene.primitives().to_vec()
348 }
349
350 #[test]
351 fn current_is_clamped_to_last() {
352 let steps = Steps::<()>::new(["A", "B", "C"]).current(9);
353 assert_eq!(steps.current, 2);
354 assert_eq!(Steps::<()>::new(Vec::<String>::new()).current(3).current, 0);
355 }
356
357 #[test]
358 fn markers_reflect_progress() {
359 let prims = paint_steps(&Steps::<()>::new(["A", "B", "C", "D"]).current(2));
361 let has_text = |t: &str| {
362 prims
363 .iter()
364 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
365 };
366 assert!(
368 has_text("3") && has_text("4"),
369 "the numbers of the current and upcoming steps"
370 );
371 assert!(
372 !has_text("1") && !has_text("2"),
373 "the done steps show a tick"
374 );
375 let checks = prims
377 .iter()
378 .filter(|p| matches!(p, Primitive::Path { fill: Some(_), .. }))
379 .count();
380 assert_eq!(checks, 2, "two ticks for the two done steps");
381 assert!(has_text("A") && has_text("B") && has_text("C") && has_text("D"));
383 }
384
385 #[test]
386 fn completed_mask_overrides_position() {
387 let default = Steps::<()>::new(["A", "B", "C"]).current(2);
389 assert!(default.is_done(0) && default.is_done(1));
390 assert!(
391 !default.is_done(2),
392 "the current step is not done by default"
393 );
394 let masked = Steps::<()>::new(["A", "B", "C"])
396 .current(1)
397 .completed([false, false, true]);
398 assert!(
399 !masked.is_done(0),
400 "step 0 invalid → not done despite i < current"
401 );
402 assert!(
403 masked.is_done(2),
404 "step 2 valid → done even though i > current"
405 );
406 let short = Steps::<()>::new(["A", "B", "C"]).completed([true]);
408 assert!(short.is_done(0) && !short.is_done(1) && !short.is_done(2));
409 }
410
411 #[test]
412 fn on_tap_overlays_clickable_hotspots() {
413 #[derive(Clone, Debug, PartialEq)]
414 enum Msg {
415 Go(usize),
416 }
417 let plain = Steps::<Msg>::new(["A", "B", "C"]).current(1);
419 assert!(Widget::<Msg>::children(&plain).is_empty());
420 let tappable = Steps::new(["A", "B", "C"]).current(1).on_tap(Msg::Go);
422 let row = Widget::<Msg>::children(&tappable);
423 assert_eq!(row.len(), 1, "a single row of hotspots");
424 let spots = row[0].children();
425 assert_eq!(spots.len(), 3, "one hotspot per step");
426 assert_eq!(spots[0].on_click(), Some(Msg::Go(0)));
427 assert_eq!(spots[2].on_click(), Some(Msg::Go(2)));
428 assert!(spots[0].focusable(), "a hotspot is focusable");
429 }
430}