Skip to main content

iced_tester/
lib.rs

1//! Record, edit, and run end-to-end tests for your iced applications.
2pub use iced_test as test;
3pub use iced_test::core;
4pub use iced_test::program;
5pub use iced_test::runtime;
6pub use iced_test::runtime::futures;
7pub use iced_widget as widget;
8
9mod icon;
10mod recorder;
11
12use recorder::recorder;
13
14use crate::core::Alignment::Center;
15use crate::core::Length::Fill;
16use crate::core::alignment::Horizontal::Right;
17use crate::core::border;
18use crate::core::mouse;
19use crate::core::theme;
20use crate::core::window;
21use crate::core::{Color, Element, Font, Settings, Size, Theme};
22use crate::futures::futures::channel::mpsc;
23use crate::program::Program;
24use crate::runtime::task::{self, Task};
25use crate::test::emulator;
26use crate::test::ice;
27use crate::test::instruction;
28use crate::test::{Emulator, Ice, Instruction};
29use crate::widget::{
30    button, center, column, combo_box, container, pick_list, row, rule, scrollable, slider, space,
31    stack, text, text_editor, themer,
32};
33
34use std::ops::RangeInclusive;
35
36/// Attaches a [`Tester`] to the given [`Program`].
37pub fn attach<P: Program + 'static>(program: P) -> Attach<P> {
38    Attach { program }
39}
40
41/// A [`Program`] with a [`Tester`] attached to it.
42#[derive(Debug)]
43pub struct Attach<P> {
44    /// The original [`Program`] attached to the [`Tester`].
45    pub program: P,
46}
47
48impl<P> Program for Attach<P>
49where
50    P: Program + 'static,
51{
52    type State = Tester<P>;
53    type Message = Message<P>;
54    type Theme = Theme;
55    type Renderer = P::Renderer;
56    type Executor = P::Executor;
57
58    fn name() -> &'static str {
59        P::name()
60    }
61
62    fn settings(&self) -> Settings {
63        let mut settings = self.program.settings();
64        settings.fonts.push(icon::FONT.into());
65        settings
66    }
67
68    fn window(&self) -> Option<window::Settings> {
69        Some(
70            self.program
71                .window()
72                .map(|window| window::Settings {
73                    size: window.size + Size::new(300.0, 80.0),
74                    ..window
75                })
76                .unwrap_or_default(),
77        )
78    }
79
80    fn boot(&self) -> (Self::State, Task<Self::Message>) {
81        (Tester::new(&self.program), Task::none())
82    }
83
84    fn update(&self, state: &mut Self::State, message: Self::Message) -> Task<Self::Message> {
85        state.tick(&self.program, message.0).map(Message)
86    }
87
88    fn view<'a>(
89        &self,
90        state: &'a Self::State,
91        window: window::Id,
92    ) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
93        state.view(&self.program, window).map(Message)
94    }
95
96    fn theme(&self, state: &Self::State, window: window::Id) -> Option<Theme> {
97        state
98            .theme(&self.program, window)
99            .as_ref()
100            .and_then(theme::Base::seed)
101            .map(|seed| Theme::custom("Tester", seed))
102    }
103}
104
105/// A tester decorates a [`Program`] definition and attaches a test recorder on top.
106///
107/// It can be used to both record and play [`Ice`] tests.
108pub struct Tester<P: Program> {
109    viewport: Size,
110    mode: emulator::Mode,
111    presets: combo_box::State<String>,
112    preset: Option<String>,
113    instructions: Vec<Instruction>,
114    state: State<P>,
115    edit: Option<text_editor::Content<P::Renderer>>,
116}
117
118enum State<P: Program> {
119    Empty,
120    Idle {
121        state: P::State,
122    },
123    Recording {
124        emulator: Emulator<P>,
125    },
126    Asserting {
127        state: P::State,
128        window: window::Id,
129        last_interaction: Option<instruction::Interaction>,
130    },
131    Playing {
132        emulator: Emulator<P>,
133        current: usize,
134        outcome: Outcome,
135    },
136}
137
138enum Outcome {
139    Running,
140    Failed,
141    Success,
142}
143
144/// The message of a [`Tester`].
145pub struct Message<P: Program>(Tick<P>);
146
147#[derive(Debug, Clone)]
148enum Event {
149    ViewportChanged(Size),
150    ModeSelected(emulator::Mode),
151    PresetSelected(String),
152    Record,
153    Stop,
154    Play,
155    Import,
156    Export,
157    Imported(Result<Ice, ice::ParseError>),
158    Edit,
159    Edited(text_editor::Action),
160    Confirm,
161}
162
163enum Tick<P: Program> {
164    Tester(Event),
165    Program(P::Message),
166    Emulator(emulator::Event<P>),
167    Record(instruction::Interaction),
168    Assert(instruction::Interaction),
169}
170
171impl<P: Program + 'static> Tester<P> {
172    fn new(program: &P) -> Self {
173        let (state, _) = program.boot();
174        let window = program.window().unwrap_or_default();
175
176        Self {
177            mode: emulator::Mode::default(),
178            viewport: window.size,
179            presets: combo_box::State::new(
180                program
181                    .presets()
182                    .iter()
183                    .map(program::Preset::name)
184                    .map(str::to_owned)
185                    .collect(),
186            ),
187            preset: None,
188            instructions: Vec::new(),
189            state: State::Idle { state },
190            edit: None,
191        }
192    }
193
194    fn is_busy(&self) -> bool {
195        matches!(
196            self.state,
197            State::Recording { .. }
198                | State::Playing {
199                    outcome: Outcome::Running,
200                    ..
201                }
202        )
203    }
204
205    fn update(&mut self, program: &P, event: Event) -> Task<Tick<P>> {
206        match event {
207            Event::ViewportChanged(viewport) => {
208                self.viewport = viewport;
209
210                Task::none()
211            }
212            Event::ModeSelected(mode) => {
213                self.mode = mode;
214
215                Task::none()
216            }
217            Event::PresetSelected(preset) => {
218                self.preset = Some(preset);
219
220                let (state, _) = self
221                    .preset(program)
222                    .map(program::Preset::boot)
223                    .unwrap_or_else(|| program.boot());
224
225                self.state = State::Idle { state };
226
227                Task::none()
228            }
229            Event::Record => {
230                self.edit = None;
231                self.instructions.clear();
232
233                let (sender, receiver) = mpsc::channel(1);
234
235                let emulator = Emulator::with_preset(
236                    sender,
237                    program,
238                    self.mode,
239                    self.viewport,
240                    self.preset(program),
241                );
242
243                self.state = State::Recording { emulator };
244
245                Task::run(receiver, Tick::Emulator)
246            }
247            Event::Stop => {
248                let State::Recording { emulator } =
249                    std::mem::replace(&mut self.state, State::Empty)
250                else {
251                    return Task::none();
252                };
253
254                while let Some(Instruction::Interact(instruction::Interaction::Mouse(
255                    instruction::Mouse::Move(_),
256                ))) = self.instructions.last()
257                {
258                    let _ = self.instructions.pop();
259                }
260
261                let (state, window) = emulator.into_state();
262
263                self.state = State::Asserting {
264                    state,
265                    window,
266                    last_interaction: None,
267                };
268
269                Task::none()
270            }
271            Event::Play => {
272                self.confirm();
273
274                let (sender, receiver) = mpsc::channel(1);
275
276                let emulator = Emulator::with_preset(
277                    sender,
278                    program,
279                    self.mode,
280                    self.viewport,
281                    self.preset(program),
282                );
283
284                self.state = State::Playing {
285                    emulator,
286                    current: 0,
287                    outcome: Outcome::Running,
288                };
289
290                Task::run(receiver, Tick::Emulator)
291            }
292            Event::Import => {
293                use std::fs;
294
295                let import = rfd::AsyncFileDialog::new()
296                    .add_filter("ice", &["ice"])
297                    .pick_file();
298
299                Task::future(import)
300                    .and_then(|file| {
301                        task::blocking(move |mut sender| {
302                            let _ = sender.try_send(Ice::parse(
303                                &fs::read_to_string(file.path()).unwrap_or_default(),
304                            ));
305                        })
306                    })
307                    .map(Event::Imported)
308                    .map(Tick::Tester)
309            }
310            Event::Export => {
311                use std::fs;
312                use std::thread;
313
314                self.confirm();
315
316                let ice = Ice {
317                    viewport: self.viewport,
318                    mode: self.mode,
319                    preset: self.preset.clone(),
320                    instructions: self.instructions.clone(),
321                };
322
323                let export = rfd::AsyncFileDialog::new()
324                    .add_filter("ice", &["ice"])
325                    .save_file();
326
327                Task::future(async move {
328                    let Some(file) = export.await else {
329                        return;
330                    };
331
332                    let _ = thread::spawn(move || fs::write(file.path(), ice.to_string()));
333                })
334                .discard()
335            }
336            Event::Imported(Ok(ice)) => {
337                self.viewport = ice.viewport;
338                self.mode = ice.mode;
339                self.preset = ice.preset;
340                self.instructions = ice.instructions;
341                self.edit = None;
342
343                let (state, _) = self
344                    .preset(program)
345                    .map(program::Preset::boot)
346                    .unwrap_or_else(|| program.boot());
347
348                self.state = State::Idle { state };
349
350                Task::none()
351            }
352            Event::Edit => {
353                if self.is_busy() {
354                    return Task::none();
355                }
356
357                self.edit = Some(text_editor::Content::with_text(
358                    &self
359                        .instructions
360                        .iter()
361                        .map(Instruction::to_string)
362                        .collect::<Vec<_>>()
363                        .join("\n"),
364                ));
365
366                Task::none()
367            }
368            Event::Edited(action) => {
369                if let Some(edit) = &mut self.edit {
370                    edit.perform(action);
371                }
372
373                Task::none()
374            }
375            Event::Confirm => {
376                self.confirm();
377
378                Task::none()
379            }
380            Event::Imported(Err(error)) => {
381                log::error!("{error}");
382
383                Task::none()
384            }
385        }
386    }
387
388    fn confirm(&mut self) {
389        let Some(edit) = &mut self.edit else {
390            return;
391        };
392
393        self.instructions = edit
394            .lines()
395            .filter(|line| !line.text.trim().is_empty())
396            .filter_map(|line| Instruction::parse(&line.text).ok())
397            .collect();
398
399        self.edit = None;
400    }
401
402    fn theme(&self, program: &P, window: window::Id) -> Option<P::Theme> {
403        match &self.state {
404            State::Empty => None,
405            State::Idle { state } => program.theme(state, window),
406            State::Recording { emulator } | State::Playing { emulator, .. } => {
407                emulator.theme(program)
408            }
409            State::Asserting { state, window, .. } => program.theme(state, *window),
410        }
411    }
412
413    fn preset<'a>(&self, program: &'a P) -> Option<&'a program::Preset<P::State, P::Message>> {
414        self.preset.as_ref().and_then(|preset| {
415            program
416                .presets()
417                .iter()
418                .find(|candidate| candidate.name() == preset)
419        })
420    }
421
422    fn tick(&mut self, program: &P, tick: Tick<P>) -> Task<Tick<P>> {
423        match tick {
424            Tick::Tester(message) => self.update(program, message),
425            Tick::Program(message) => {
426                let State::Recording { emulator } = &mut self.state else {
427                    return Task::none();
428                };
429
430                emulator.update(program, message);
431
432                Task::none()
433            }
434            Tick::Emulator(event) => {
435                match &mut self.state {
436                    State::Recording { emulator } => {
437                        if let emulator::Event::Action(action) = event {
438                            emulator.perform(program, action);
439                        }
440                    }
441                    State::Playing {
442                        emulator,
443                        current,
444                        outcome,
445                    } => match event {
446                        emulator::Event::Action(action) => {
447                            emulator.perform(program, action);
448                        }
449                        emulator::Event::Failed(_instruction) => {
450                            *outcome = Outcome::Failed;
451                        }
452                        emulator::Event::Ready => {
453                            *current += 1;
454
455                            if let Some(instruction) = self.instructions.get(*current - 1) {
456                                emulator.run(program, instruction);
457                            }
458
459                            if *current >= self.instructions.len() {
460                                *outcome = Outcome::Success;
461                            }
462                        }
463                    },
464                    State::Empty | State::Idle { .. } | State::Asserting { .. } => {}
465                }
466
467                Task::none()
468            }
469            Tick::Record(interaction) => {
470                let mut interaction = Some(interaction);
471
472                while let Some(new_interaction) = interaction.take() {
473                    if let Some(Instruction::Interact(last_interaction)) = self.instructions.pop() {
474                        let (merged_interaction, new_interaction) =
475                            last_interaction.merge(new_interaction);
476
477                        if let Some(new_interaction) = new_interaction {
478                            self.instructions
479                                .push(Instruction::Interact(merged_interaction));
480
481                            self.instructions
482                                .push(Instruction::Interact(new_interaction));
483                        } else {
484                            interaction = Some(merged_interaction);
485                        }
486                    } else {
487                        self.instructions
488                            .push(Instruction::Interact(new_interaction));
489                    }
490                }
491
492                Task::none()
493            }
494            Tick::Assert(interaction) => {
495                let State::Asserting {
496                    last_interaction, ..
497                } = &mut self.state
498                else {
499                    return Task::none();
500                };
501
502                *last_interaction = if let Some(last_interaction) = last_interaction.take() {
503                    let (merged, new) = last_interaction.merge(interaction);
504
505                    Some(new.unwrap_or(merged))
506                } else {
507                    Some(interaction)
508                };
509
510                let Some(interaction) = last_interaction.take() else {
511                    return Task::none();
512                };
513
514                let instruction::Interaction::Mouse(instruction::Mouse::Click {
515                    button: mouse::Button::Left,
516                    target: Some(instruction::Target::Text(text)),
517                }) = interaction
518                else {
519                    *last_interaction = Some(interaction);
520                    return Task::none();
521                };
522
523                self.instructions
524                    .push(Instruction::Expect(instruction::Expectation::Text(text)));
525
526                Task::none()
527            }
528        }
529    }
530
531    fn view<'a>(
532        &'a self,
533        program: &P,
534        window: window::Id,
535    ) -> Element<'a, Tick<P>, Theme, P::Renderer> {
536        let status = {
537            let (icon, label) = match &self.state {
538                State::Empty | State::Idle { .. } => (text(""), "Idle"),
539                State::Recording { .. } => (icon::record(), "Recording"),
540                State::Asserting { .. } => (icon::lightbulb(), "Asserting"),
541                State::Playing { outcome, .. } => match outcome {
542                    Outcome::Running => (icon::play(), "Playing"),
543                    Outcome::Failed => (icon::cancel(), "Failed"),
544                    Outcome::Success => (icon::check(), "Success"),
545                },
546            };
547
548            container(row![icon.size(14), label].align_y(Center).spacing(8)).style(
549                |theme: &Theme| {
550                    let palette = theme.palette();
551
552                    container::Style {
553                        text_color: Some(match &self.state {
554                            State::Empty | State::Idle { .. } => palette.background.strongest.color,
555                            State::Recording { .. } => palette.danger.base.color,
556                            State::Asserting { .. } => palette.warning.base.color,
557                            State::Playing { outcome, .. } => match outcome {
558                                Outcome::Running => palette.primary.base.color,
559                                Outcome::Failed => palette.danger.base.color,
560                                Outcome::Success => palette.success.strong.color,
561                            },
562                        }),
563                        ..container::Style::default()
564                    }
565                },
566            )
567        };
568
569        let view = match &self.state {
570            State::Empty => Element::from(space()),
571            State::Idle { state } => program.view(state, window).map(Tick::Program),
572            State::Recording { emulator } => recorder(emulator.view(program).map(Tick::Program))
573                .on_record(Tick::Record)
574                .into(),
575            State::Asserting { state, window, .. } => {
576                recorder(program.view(state, *window).map(Tick::Program))
577                    .on_record(Tick::Assert)
578                    .into()
579            }
580            State::Playing { emulator, .. } => emulator.view(program).map(Tick::Program),
581        };
582
583        let viewport = container(
584            scrollable(
585                container(themer(self.theme(program, window), view))
586                    .width(self.viewport.width)
587                    .height(self.viewport.height),
588            )
589            .direction(scrollable::Direction::Both {
590                vertical: scrollable::Scrollbar::default(),
591                horizontal: scrollable::Scrollbar::default(),
592            }),
593        )
594        .style(|theme: &Theme| {
595            let palette = theme.palette();
596
597            container::Style {
598                border: border::width(2.0).color(match &self.state {
599                    State::Empty | State::Idle { .. } => palette.background.strongest.color,
600                    State::Recording { .. } => palette.danger.base.color,
601                    State::Asserting { .. } => palette.warning.weak.color,
602                    State::Playing { outcome, .. } => match outcome {
603                        Outcome::Running => palette.primary.base.color,
604                        Outcome::Failed => palette.danger.strong.color,
605                        Outcome::Success => palette.success.strong.color,
606                    },
607                }),
608                ..container::Style::default()
609            }
610        })
611        .padding(10);
612
613        row![
614            center(column![status, viewport].spacing(10).align_x(Right)).padding(10),
615            rule::vertical(1).style(rule::weak),
616            container(self.controls().map(Tick::Tester))
617                .width(250)
618                .padding(10)
619                .style(|theme| container::Style::default()
620                    .background(theme.palette().background.weakest.color)),
621        ]
622        .into()
623    }
624
625    fn controls(&self) -> Element<'_, Event, Theme, P::Renderer> {
626        let viewport = column![
627            labeled_slider(
628                "Width",
629                100.0..=2000.0,
630                self.viewport.width,
631                |width| Event::ViewportChanged(Size {
632                    width,
633                    ..self.viewport
634                }),
635                |width| format!("{width:.0}"),
636            ),
637            labeled_slider(
638                "Height",
639                100.0..=2000.0,
640                self.viewport.height,
641                |height| Event::ViewportChanged(Size {
642                    height,
643                    ..self.viewport
644                }),
645                |height| format!("{height:.0}"),
646            ),
647        ]
648        .spacing(10);
649
650        let preset = combo_box(
651            &self.presets,
652            "Default",
653            self.preset.as_ref(),
654            Event::PresetSelected,
655        )
656        .size(14)
657        .width(Fill);
658
659        let mode = pick_list(
660            Some(self.mode),
661            emulator::Mode::ALL,
662            emulator::Mode::to_string,
663        )
664        .on_select(Event::ModeSelected)
665        .text_size(14)
666        .width(Fill);
667
668        let player = {
669            let instructions = if let Some(edit) = &self.edit {
670                text_editor(edit)
671                    .size(12)
672                    .height(Fill)
673                    .font(Font::MONOSPACE)
674                    .on_action(Event::Edited)
675                    .into()
676            } else if self.instructions.is_empty() {
677                Element::from(center(
678                    text("No instructions recorded yet!")
679                        .size(14)
680                        .font(Font::MONOSPACE)
681                        .width(Fill)
682                        .center(),
683                ))
684            } else {
685                scrollable(
686                    column(
687                        self.instructions
688                            .iter()
689                            .enumerate()
690                            .map(|(i, instruction)| {
691                                text(instruction.to_string())
692                                    .wrapping(text::Wrapping::None) // TODO: Ellipsize?
693                                    .size(10)
694                                    .font(Font::MONOSPACE)
695                                    .style(move |theme: &Theme| text::Style {
696                                        color: match &self.state {
697                                            State::Playing {
698                                                current, outcome, ..
699                                            } => {
700                                                if *current == i + 1 {
701                                                    Some(match outcome {
702                                                        Outcome::Running => {
703                                                            theme.palette().primary.base.color
704                                                        }
705                                                        Outcome::Failed => {
706                                                            theme.palette().danger.strong.color
707                                                        }
708                                                        Outcome::Success => {
709                                                            theme.palette().success.strong.color
710                                                        }
711                                                    })
712                                                } else if *current > i + 1 {
713                                                    Some(theme.palette().success.strong.color)
714                                                } else {
715                                                    None
716                                                }
717                                            }
718                                            _ => None,
719                                        },
720                                    })
721                                    .into()
722                            }),
723                    )
724                    .spacing(5),
725                )
726                .width(Fill)
727                .height(Fill)
728                .spacing(5)
729                .into()
730            };
731
732            let control = |icon: text::Text<'static, _, _>| {
733                button(icon.size(14).width(Fill).height(Fill).center())
734            };
735
736            let play = control(icon::play()).on_press_maybe(
737                (!matches!(self.state, State::Recording { .. }) && !self.instructions.is_empty())
738                    .then_some(Event::Play),
739            );
740
741            let record = if let State::Recording { .. } = &self.state {
742                control(icon::stop())
743                    .on_press(Event::Stop)
744                    .style(button::success)
745            } else {
746                control(icon::record())
747                    .on_press_maybe((!self.is_busy()).then_some(Event::Record))
748                    .style(button::danger)
749            };
750
751            let import = control(icon::folder())
752                .on_press_maybe((!self.is_busy()).then_some(Event::Import))
753                .style(button::secondary);
754
755            let export = control(icon::floppy())
756                .on_press_maybe(
757                    (!matches!(self.state, State::Recording { .. })
758                        && !self.instructions.is_empty())
759                    .then_some(Event::Export),
760                )
761                .style(button::success);
762
763            let controls = row![import, export, play, record].height(30).spacing(10);
764
765            column![instructions, controls].spacing(10).align_x(Center)
766        };
767
768        let edit = if self.is_busy() {
769            Element::from(space::horizontal())
770        } else if self.edit.is_none() {
771            button(icon::pencil().size(14))
772                .padding(0)
773                .on_press(Event::Edit)
774                .style(button::text)
775                .into()
776        } else {
777            button(icon::check().size(14))
778                .padding(0)
779                .on_press(Event::Confirm)
780                .style(button::text)
781                .into()
782        };
783
784        column![
785            labeled("Viewport", viewport),
786            labeled("Mode", mode),
787            labeled("Preset", preset),
788            labeled_with("Instructions", edit, player)
789        ]
790        .spacing(10)
791        .into()
792    }
793}
794
795fn labeled<'a, Message, Renderer>(
796    fragment: impl text::IntoFragment<'a>,
797    content: impl Into<Element<'a, Message, Theme, Renderer>>,
798) -> Element<'a, Message, Theme, Renderer>
799where
800    Message: 'a,
801    Renderer: program::Renderer + 'a,
802{
803    column![
804        text(fragment).size(14).font(Font::MONOSPACE),
805        content.into()
806    ]
807    .spacing(5)
808    .into()
809}
810
811fn labeled_with<'a, Message, Renderer>(
812    fragment: impl text::IntoFragment<'a>,
813    control: impl Into<Element<'a, Message, Theme, Renderer>>,
814    content: impl Into<Element<'a, Message, Theme, Renderer>>,
815) -> Element<'a, Message, Theme, Renderer>
816where
817    Message: 'a,
818    Renderer: program::Renderer + 'a,
819{
820    column![
821        row![
822            text(fragment).size(14).font(Font::MONOSPACE),
823            space::horizontal(),
824            control.into()
825        ]
826        .spacing(5)
827        .align_y(Center),
828        content.into()
829    ]
830    .spacing(5)
831    .into()
832}
833
834fn labeled_slider<'a, Message, Renderer>(
835    label: impl text::IntoFragment<'a>,
836    range: RangeInclusive<f32>,
837    current: f32,
838    on_change: impl Fn(f32) -> Message + 'a,
839    to_string: impl Fn(&f32) -> String,
840) -> Element<'a, Message, Theme, Renderer>
841where
842    Message: Clone + 'a,
843    Renderer: core::text::Renderer + 'a,
844{
845    stack![
846        container(
847            slider(range, current, on_change)
848                .step(10.0)
849                .width(Fill)
850                .height(24)
851                .style(|theme: &core::Theme, status| {
852                    let palette = theme.palette();
853
854                    slider::Style {
855                        rail: slider::Rail {
856                            backgrounds: (
857                                match status {
858                                    slider::Status::Active | slider::Status::Dragged => {
859                                        palette.background.strongest.color
860                                    }
861                                    slider::Status::Hovered | slider::Status::Focused => {
862                                        palette.background.stronger.color
863                                    }
864                                }
865                                .into(),
866                                Color::TRANSPARENT.into(),
867                            ),
868                            width: 24.0,
869                            border: border::rounded(2),
870                        },
871                        handle: slider::Handle {
872                            shape: slider::HandleShape::Circle { radius: 0.0 },
873                            background: Color::TRANSPARENT.into(),
874                            border_width: 0.0,
875                            border_color: Color::TRANSPARENT,
876                            shadow: core::Shadow::default(),
877                        },
878                    }
879                })
880        )
881        .style(|theme| container::Style::default()
882            .background(theme.palette().background.weak.color)
883            .border(border::rounded(2))),
884        row![
885            text(label).size(14).style(|theme: &core::Theme| {
886                text::Style {
887                    color: Some(theme.palette().background.weak.text),
888                }
889            }),
890            space::horizontal(),
891            text(to_string(&current)).size(14)
892        ]
893        .padding([0, 10])
894        .height(Fill)
895        .align_y(Center),
896    ]
897    .into()
898}