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