1use egui::{
23 Color32, CornerRadius, Painter, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2,
24 Widget, WidgetInfo, WidgetType,
25};
26
27use crate::theme::{with_alpha, Theme};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum StepsStyle {
32 Cells,
34 Numbered,
37 Labeled,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum StepState {
44 Done,
45 Active,
46 Error,
47 Pending,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum Orient {
52 Horizontal,
53 Vertical,
54}
55
56#[derive(Debug, Clone)]
94#[must_use = "Add with `ui.add(...)`."]
95pub struct Steps {
96 total: usize,
97 current: usize,
98 errored: bool,
99 style: StepsStyle,
100 orientation: Orient,
101 labels: Vec<String>,
102 active_sublabel: Option<String>,
103 height: Option<f32>,
104 desired_width: Option<f32>,
105}
106
107impl Steps {
108 pub fn new(total: usize) -> Self {
111 Self {
112 total: total.max(1),
113 current: 0,
114 errored: false,
115 style: StepsStyle::Cells,
116 orientation: Orient::Horizontal,
117 labels: Vec::new(),
118 active_sublabel: None,
119 height: None,
120 desired_width: None,
121 }
122 }
123
124 pub fn labeled(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
132 let labels: Vec<String> = labels.into_iter().map(Into::into).collect();
133 Self {
134 total: labels.len().max(1),
135 current: 0,
136 errored: false,
137 style: StepsStyle::Labeled,
138 orientation: Orient::Horizontal,
139 labels,
140 active_sublabel: None,
141 height: None,
142 desired_width: None,
143 }
144 }
145
146 #[inline]
149 pub fn vertical(mut self) -> Self {
150 self.orientation = Orient::Vertical;
151 self
152 }
153
154 #[inline]
158 pub fn horizontal(mut self) -> Self {
159 self.orientation = Orient::Horizontal;
160 self
161 }
162
163 #[inline]
165 pub fn current(mut self, current: usize) -> Self {
166 self.current = current.min(self.total);
167 self
168 }
169
170 #[inline]
173 pub fn errored(mut self, errored: bool) -> Self {
174 self.errored = errored;
175 self
176 }
177
178 #[inline]
180 pub fn style(mut self, style: StepsStyle) -> Self {
181 self.style = style;
182 self
183 }
184
185 #[inline]
190 pub fn active_sublabel(mut self, text: impl Into<String>) -> Self {
191 self.active_sublabel = Some(text.into());
192 self
193 }
194
195 #[inline]
198 pub fn height(mut self, height: f32) -> Self {
199 self.height = Some(height);
200 self
201 }
202
203 #[inline]
205 pub fn desired_width(mut self, width: f32) -> Self {
206 self.desired_width = Some(width);
207 self
208 }
209
210 fn step_state(&self, i: usize) -> StepState {
211 if i < self.current {
212 StepState::Done
213 } else if i == self.current && self.current < self.total {
214 if self.errored {
215 StepState::Error
216 } else {
217 StepState::Active
218 }
219 } else {
220 StepState::Pending
221 }
222 }
223}
224
225impl Widget for Steps {
226 fn ui(self, ui: &mut Ui) -> Response {
227 match self.style {
228 StepsStyle::Cells => paint_cells(ui, &self),
229 StepsStyle::Numbered => paint_numbered(ui, &self),
230 StepsStyle::Labeled => paint_labeled(ui, &self),
231 }
232 }
233}
234
235fn paint_cells(ui: &mut Ui, s: &Steps) -> Response {
236 let theme = Theme::current(ui.ctx());
237 let p = &theme.palette;
238
239 let height = s.height.unwrap_or(6.0);
240 let gap = 4.0;
241 let width = s
242 .desired_width
243 .unwrap_or_else(|| ui.available_width())
244 .max(height * 2.0);
245
246 let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
247
248 if ui.is_rect_visible(rect) {
249 let painter = ui.painter();
250 let n = s.total as f32;
251 let total_gap = gap * (n - 1.0).max(0.0);
252 let cell_w = ((width - total_gap) / n).max(1.0);
253 let radius = CornerRadius::same((height * 0.65).round().clamp(2.0, 8.0) as u8);
254 let pending_fill = p.depth_tint(p.card, 0.08);
255
256 for i in 0..s.total {
257 let x = rect.min.x + (cell_w + gap) * i as f32;
258 let cell_rect =
259 Rect::from_min_size(Pos2::new(x, rect.min.y), Vec2::new(cell_w, height));
260 let fill = match s.step_state(i) {
261 StepState::Done => p.success,
262 StepState::Active => p.sky,
263 StepState::Error => p.danger,
264 StepState::Pending => pending_fill,
265 };
266 painter.rect(cell_rect, radius, fill, Stroke::NONE, StrokeKind::Inside);
267 }
268 }
269
270 response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "progress"));
271 response
272}
273
274fn paint_numbered(ui: &mut Ui, s: &Steps) -> Response {
275 let theme = Theme::current(ui.ctx());
276 let p = &theme.palette;
277 let t = &theme.typography;
278
279 let dot_d = s.height.unwrap_or(22.0);
280 let dot_r = dot_d * 0.5;
281 let conn_h = (dot_d * 0.09).max(1.5);
282 let conn_inset = 4.0;
283 let width = s
284 .desired_width
285 .unwrap_or_else(|| ui.available_width())
286 .max(dot_d * s.total as f32);
287
288 let has_labels = !s.labels.is_empty();
289 let has_sublabel = has_labels && s.active_sublabel.is_some() && s.current < s.total;
290 let label_gap = 8.0;
291 let sublabel_gap = 2.0;
292 let label_block = if has_labels { t.body + label_gap } else { 0.0 };
293 let sublabel_block = if has_sublabel {
294 t.small + sublabel_gap
295 } else {
296 0.0
297 };
298 let total_h = dot_d + label_block + sublabel_block;
299
300 let (rect, response) = ui.allocate_exact_size(Vec2::new(width, total_h), Sense::hover());
301
302 if !ui.is_rect_visible(rect) {
303 response
304 .widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "progress"));
305 return response;
306 }
307
308 let painter = ui.painter();
309 let center_y = rect.min.y + dot_r;
310
311 let dot_center_x = |i: usize| -> f32 {
312 if s.total == 1 {
313 rect.center().x
314 } else {
315 let f = i as f32 / (s.total - 1) as f32;
316 rect.min.x + dot_r + f * (width - dot_d)
317 }
318 };
319
320 let pending_fill = p.depth_tint(p.card, 0.08);
321
322 for i in 0..s.total.saturating_sub(1) {
323 let x_start = dot_center_x(i) + dot_r + conn_inset;
324 let x_end = dot_center_x(i + 1) - dot_r - conn_inset;
325 if x_end <= x_start {
326 continue;
327 }
328 let conn_rect = Rect::from_min_max(
329 Pos2::new(x_start, center_y - conn_h * 0.5),
330 Pos2::new(x_end, center_y + conn_h * 0.5),
331 );
332 let color = match s.step_state(i) {
333 StepState::Done => p.success,
334 _ => pending_fill,
335 };
336 painter.rect_filled(conn_rect, CornerRadius::ZERO, color);
337 }
338
339 for i in 0..s.total {
340 let center = Pos2::new(dot_center_x(i), center_y);
341 let state = s.step_state(i);
342 let (fill, text_color) = match state {
343 StepState::Done => (p.success, Color32::WHITE),
344 StepState::Active => (p.sky, Color32::WHITE),
345 StepState::Error => (p.danger, Color32::WHITE),
346 StepState::Pending => (pending_fill, p.text_muted),
347 };
348
349 if matches!(state, StepState::Active) {
350 painter.circle_filled(center, dot_r + 3.0, with_alpha(p.sky, 64));
351 }
352
353 painter.circle_filled(center, dot_r, fill);
354
355 if matches!(state, StepState::Done) {
356 paint_check(painter, center, dot_r * 0.45, text_color);
357 } else {
358 let label = (i + 1).to_string();
359 let font_size = (dot_d * 0.55).max(10.0).min(t.body);
360 let galley =
361 crate::theme::placeholder_galley(ui, &label, font_size, true, f32::INFINITY);
362 let pos = Pos2::new(
363 center.x - galley.size().x * 0.5,
364 center.y - galley.size().y * 0.5,
365 );
366 painter.galley(pos, galley, text_color);
367 }
368
369 if has_labels {
370 if let Some(label_text) = s.labels.get(i) {
371 let is_active = matches!(state, StepState::Active | StepState::Error);
372 let label_color = match state {
373 StepState::Done => p.text_muted,
374 StepState::Active => p.text,
375 StepState::Error => p.danger,
376 StepState::Pending => p.text_muted,
377 };
378 let label_galley = crate::theme::placeholder_galley(
379 ui,
380 label_text,
381 t.body,
382 is_active,
383 f32::INFINITY,
384 );
385 let label_y = rect.min.y + dot_d + label_gap;
386 let pos = Pos2::new(center.x - label_galley.size().x * 0.5, label_y);
387 painter.galley(pos, label_galley, label_color);
388
389 if has_sublabel && i == s.current {
390 if let Some(sub) = s.active_sublabel.as_deref() {
391 let sub_galley = crate::theme::placeholder_galley(
392 ui,
393 sub,
394 t.small,
395 false,
396 f32::INFINITY,
397 );
398 let sub_y = label_y + t.body + sublabel_gap;
399 let sub_pos = Pos2::new(center.x - sub_galley.size().x * 0.5, sub_y);
400 painter.galley(sub_pos, sub_galley, p.text_faint);
401 }
402 }
403 }
404 }
405 }
406
407 response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "progress"));
408 response
409}
410
411fn paint_labeled(ui: &mut Ui, s: &Steps) -> Response {
412 let theme = Theme::current(ui.ctx());
413 let p = &theme.palette;
414 let t = &theme.typography;
415
416 let pill_h = s.height.unwrap_or(32.0);
417 let horizontal = matches!(s.orientation, Orient::Horizontal);
418 let gap = if horizontal { 22.0 } else { 20.0 };
420 let icon_gap = 6.0;
421 let pending_fill = p.depth_tint(p.card, 0.08);
422 let n = s.total;
423 let width = s.desired_width.unwrap_or_else(|| ui.available_width());
424
425 let (alloc_size, cell_w) = if horizontal {
426 let total_gap = gap * n.saturating_sub(1) as f32;
427 let cell_w = ((width - total_gap) / n as f32).max(1.0);
428 (Vec2::new(width, pill_h), cell_w)
429 } else {
430 let total_h = pill_h * n as f32 + gap * n.saturating_sub(1) as f32;
431 (Vec2::new(width, total_h), width)
432 };
433
434 let (rect, response) = ui.allocate_exact_size(alloc_size, Sense::hover());
435
436 if ui.is_rect_visible(rect) {
437 let painter = ui.painter();
438 let radius = CornerRadius::same((pill_h * 0.22).round().clamp(4.0, 12.0) as u8);
439 let chevron_color = p.text_faint;
440
441 for i in 0..n {
442 let cell_rect = if horizontal {
443 let x = rect.min.x + (cell_w + gap) * i as f32;
444 Rect::from_min_size(Pos2::new(x, rect.min.y), Vec2::new(cell_w, pill_h))
445 } else {
446 let y = rect.min.y + (pill_h + gap) * i as f32;
447 Rect::from_min_size(Pos2::new(rect.min.x, y), Vec2::new(cell_w, pill_h))
448 };
449
450 if i + 1 < n {
451 let (chev_center, direction) = if horizontal {
452 (
453 Pos2::new(cell_rect.max.x + gap * 0.5, cell_rect.center().y),
454 ChevronDir::Right,
455 )
456 } else {
457 (
458 Pos2::new(cell_rect.center().x, cell_rect.max.y + gap * 0.5),
459 ChevronDir::Down,
460 )
461 };
462 paint_chevron(painter, chev_center, direction, chevron_color);
463 }
464
465 let state = s.step_state(i);
466 let (fill, text_color) = match state {
467 StepState::Done => (p.success, Color32::WHITE),
468 StepState::Active => (p.sky, Color32::WHITE),
469 StepState::Error => (p.danger, Color32::WHITE),
470 StepState::Pending => (pending_fill, p.text_muted),
471 };
472 painter.rect(cell_rect, radius, fill, Stroke::NONE, StrokeKind::Inside);
473
474 let label = s.labels.get(i).map(String::as_str).unwrap_or("");
475 let galley = if label.is_empty() {
476 None
477 } else {
478 Some(crate::theme::placeholder_galley(
479 ui,
480 label,
481 t.body,
482 true,
483 f32::INFINITY,
484 ))
485 };
486 let galley_w = galley.as_ref().map_or(0.0, |g| g.size().x);
487 let galley_h = galley.as_ref().map_or(0.0, |g| g.size().y);
488 let check_scale = pill_h * 0.2;
489
490 if horizontal {
491 let has_check = matches!(state, StepState::Done);
493 let check_block = if has_check {
494 check_scale * 2.0 + icon_gap
495 } else {
496 0.0
497 };
498 let group_w = check_block + galley_w;
499 let start_x = cell_rect.center().x - group_w * 0.5;
500 let clip = painter.clip_rect().intersect(cell_rect.shrink(2.0));
501 let clipped = painter.with_clip_rect(clip);
502
503 let mut cursor_x = start_x;
504 if has_check {
505 let check_center = Pos2::new(start_x + check_scale, cell_rect.center().y);
506 paint_check(&clipped, check_center, check_scale, text_color);
507 cursor_x = check_center.x + check_scale + icon_gap;
508 }
509 if let Some(g) = galley {
510 let pos = Pos2::new(cursor_x, cell_rect.center().y - galley_h * 0.5);
511 clipped.galley(pos, g, text_color);
512 }
513 } else {
514 let pad_x = 12.0;
516 let mut text_x = cell_rect.min.x + pad_x;
517 if matches!(state, StepState::Done) {
518 let check_center = Pos2::new(text_x + check_scale, cell_rect.center().y);
519 paint_check(painter, check_center, check_scale, text_color);
520 text_x = check_center.x + check_scale + icon_gap;
521 }
522 if let Some(g) = galley {
523 let pos = Pos2::new(text_x, cell_rect.center().y - galley_h * 0.5);
524 painter.galley(pos, g, text_color);
525 }
526 }
527 }
528 }
529
530 response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "progress"));
531 response
532}
533
534fn paint_check(painter: &Painter, center: Pos2, scale: f32, color: Color32) {
535 let stroke = Stroke::new((scale * 0.45).max(1.5), color);
536 let a = Pos2::new(center.x - scale, center.y);
537 let b = Pos2::new(center.x - scale * 0.375, center.y + scale * 0.625);
538 let c = Pos2::new(center.x + scale, center.y - scale * 0.75);
539 painter.line_segment([a, b], stroke);
540 painter.line_segment([b, c], stroke);
541}
542
543#[derive(Clone, Copy)]
544enum ChevronDir {
545 Right,
546 Down,
547}
548
549fn paint_chevron(painter: &Painter, center: Pos2, dir: ChevronDir, color: Color32) {
550 let stroke = Stroke::new(1.6, color);
551 let d = 3.0;
553 let w = 10.4;
554 let (a, apex, b) = match dir {
555 ChevronDir::Right => (
556 Pos2::new(center.x - d, center.y - w),
557 Pos2::new(center.x + d, center.y),
558 Pos2::new(center.x - d, center.y + w),
559 ),
560 ChevronDir::Down => (
561 Pos2::new(center.x - w, center.y - d),
562 Pos2::new(center.x, center.y + d),
563 Pos2::new(center.x + w, center.y - d),
564 ),
565 };
566 painter.line_segment([a, apex], stroke);
567 painter.line_segment([apex, b], stroke);
568}