iced_devtools/
lib.rs

1#![allow(missing_docs)]
2use iced_debug as debug;
3use iced_program as program;
4use iced_program::runtime;
5use iced_program::runtime::futures;
6use iced_widget as widget;
7use iced_widget::core;
8
9mod comet;
10mod time_machine;
11
12use crate::core::border;
13use crate::core::keyboard;
14use crate::core::theme::{self, Theme};
15use crate::core::time::seconds;
16use crate::core::window;
17use crate::core::{Alignment::Center, Color, Element, Font, Length::Fill, Settings};
18use crate::futures::Subscription;
19use crate::program::Program;
20use crate::program::message;
21use crate::runtime::task::{self, Task};
22use crate::time_machine::TimeMachine;
23use crate::widget::{
24    bottom_right, button, center, column, container, opaque, row, scrollable, space, stack, text,
25    themer,
26};
27
28use std::fmt;
29use std::thread;
30
31pub fn attach<P: Program + 'static>(program: P) -> Attach<P> {
32    Attach { program }
33}
34
35/// A [`Program`] with some devtools attached to it.
36#[derive(Debug)]
37pub struct Attach<P> {
38    /// The original [`Program`] managed by these devtools.
39    pub program: P,
40}
41
42impl<P> Program for Attach<P>
43where
44    P: Program + 'static,
45    P::Message: std::fmt::Debug + message::MaybeClone,
46{
47    type State = DevTools<P>;
48    type Message = Event<P>;
49    type Theme = P::Theme;
50    type Renderer = P::Renderer;
51    type Executor = P::Executor;
52
53    fn name() -> &'static str {
54        P::name()
55    }
56
57    fn settings(&self) -> Settings {
58        self.program.settings()
59    }
60
61    fn window(&self) -> Option<window::Settings> {
62        self.program.window()
63    }
64
65    fn boot(&self) -> (Self::State, Task<Self::Message>) {
66        let (state, boot) = self.program.boot();
67        let (devtools, task) = DevTools::new(state);
68
69        (
70            devtools,
71            Task::batch([boot.map(Event::Program), task.map(Event::Message)]),
72        )
73    }
74
75    fn update(&self, state: &mut Self::State, message: Self::Message) -> Task<Self::Message> {
76        state.update(&self.program, message)
77    }
78
79    fn view<'a>(
80        &self,
81        state: &'a Self::State,
82        window: window::Id,
83    ) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
84        state.view(&self.program, window)
85    }
86
87    fn title(&self, state: &Self::State, window: window::Id) -> String {
88        state.title(&self.program, window)
89    }
90
91    fn subscription(&self, state: &Self::State) -> Subscription<Self::Message> {
92        state.subscription(&self.program)
93    }
94
95    fn theme(&self, state: &Self::State, window: window::Id) -> Option<Self::Theme> {
96        state.theme(&self.program, window)
97    }
98
99    fn style(&self, state: &Self::State, theme: &Self::Theme) -> theme::Style {
100        state.style(&self.program, theme)
101    }
102
103    fn scale_factor(&self, state: &Self::State, window: window::Id) -> f32 {
104        state.scale_factor(&self.program, window)
105    }
106}
107
108/// The state of the devtools.
109pub struct DevTools<P>
110where
111    P: Program,
112{
113    state: P::State,
114    show_notification: bool,
115    time_machine: TimeMachine<P>,
116    mode: Mode,
117}
118
119#[derive(Debug, Clone)]
120pub enum Message {
121    HideNotification,
122    ToggleComet,
123    CometLaunched(comet::launch::Result),
124    InstallComet,
125    Installing(comet::install::Result),
126    CancelSetup,
127}
128
129enum Mode {
130    Hidden,
131    Setup(Setup),
132}
133
134enum Setup {
135    Idle { goal: Goal },
136    Running { logs: Vec<String> },
137}
138
139enum Goal {
140    Installation,
141    Update { revision: Option<String> },
142}
143
144impl<P> DevTools<P>
145where
146    P: Program + 'static,
147    P::Message: std::fmt::Debug + message::MaybeClone,
148{
149    pub fn new(state: P::State) -> (Self, Task<Message>) {
150        (
151            Self {
152                state,
153                mode: Mode::Hidden,
154                show_notification: true,
155                time_machine: TimeMachine::new(),
156            },
157            Task::batch([task::blocking(|mut sender| {
158                thread::sleep(seconds(2));
159                let _ = sender.try_send(());
160            })
161            .map(|_| Message::HideNotification)]),
162        )
163    }
164
165    pub fn title(&self, program: &P, window: window::Id) -> String {
166        program.title(&self.state, window)
167    }
168
169    pub fn update(&mut self, program: &P, event: Event<P>) -> Task<Event<P>> {
170        match event {
171            Event::Message(message) => match message {
172                Message::HideNotification => {
173                    self.show_notification = false;
174
175                    Task::none()
176                }
177                Message::ToggleComet => {
178                    if let Mode::Setup(setup) = &self.mode {
179                        if matches!(setup, Setup::Idle { .. }) {
180                            self.mode = Mode::Hidden;
181                        }
182
183                        Task::none()
184                    } else if debug::quit() {
185                        Task::none()
186                    } else {
187                        comet::launch()
188                            .map(Message::CometLaunched)
189                            .map(Event::Message)
190                    }
191                }
192                Message::CometLaunched(Ok(())) => Task::none(),
193                Message::CometLaunched(Err(error)) => {
194                    match error {
195                        comet::launch::Error::NotFound => {
196                            self.mode = Mode::Setup(Setup::Idle {
197                                goal: Goal::Installation,
198                            });
199                        }
200                        comet::launch::Error::Outdated { revision } => {
201                            self.mode = Mode::Setup(Setup::Idle {
202                                goal: Goal::Update { revision },
203                            });
204                        }
205                        comet::launch::Error::IoFailed(error) => {
206                            log::error!("comet failed to run: {error}");
207                        }
208                    }
209
210                    Task::none()
211                }
212                Message::InstallComet => {
213                    self.mode = Mode::Setup(Setup::Running { logs: Vec::new() });
214
215                    comet::install()
216                        .map(Message::Installing)
217                        .map(Event::Message)
218                }
219                Message::Installing(Ok(installation)) => {
220                    let Mode::Setup(Setup::Running { logs }) = &mut self.mode else {
221                        return Task::none();
222                    };
223
224                    match installation {
225                        comet::install::Event::Logged(log) => {
226                            logs.push(log);
227                            Task::none()
228                        }
229                        comet::install::Event::Finished => {
230                            self.mode = Mode::Hidden;
231                            comet::launch().discard()
232                        }
233                    }
234                }
235                Message::Installing(Err(error)) => {
236                    let Mode::Setup(Setup::Running { logs }) = &mut self.mode else {
237                        return Task::none();
238                    };
239
240                    match error {
241                        comet::install::Error::ProcessFailed(status) => {
242                            logs.push(format!("process failed with {status}"));
243                        }
244                        comet::install::Error::IoFailed(error) => {
245                            logs.push(error.to_string());
246                        }
247                    }
248
249                    Task::none()
250                }
251                Message::CancelSetup => {
252                    self.mode = Mode::Hidden;
253
254                    Task::none()
255                }
256            },
257            Event::Program(message) => {
258                self.time_machine.push(&message);
259
260                if self.time_machine.is_rewinding() {
261                    debug::enable();
262                }
263
264                let span = debug::update(&message);
265                let task = program.update(&mut self.state, message);
266                debug::tasks_spawned(task.units());
267                span.finish();
268
269                if self.time_machine.is_rewinding() {
270                    debug::disable();
271                }
272
273                task.map(Event::Program)
274            }
275            Event::Command(command) => {
276                match command {
277                    debug::Command::RewindTo { message } => {
278                        self.time_machine.rewind(program, message);
279                    }
280                    debug::Command::GoLive => {
281                        self.time_machine.go_to_present();
282                    }
283                }
284
285                Task::none()
286            }
287            Event::Discard => Task::none(),
288        }
289    }
290
291    pub fn view(
292        &self,
293        program: &P,
294        window: window::Id,
295    ) -> Element<'_, Event<P>, P::Theme, P::Renderer> {
296        let state = self.state();
297
298        let view = {
299            let view = program.view(state, window);
300
301            if self.time_machine.is_rewinding() {
302                view.map(|_| Event::Discard)
303            } else {
304                view.map(Event::Program)
305            }
306        };
307
308        let theme = || {
309            program
310                .theme(state, window)
311                .as_ref()
312                .and_then(theme::Base::palette)
313                .map(|palette| Theme::custom("iced devtools", palette))
314        };
315
316        let setup = if let Mode::Setup(setup) = &self.mode {
317            let stage: Element<'_, _, Theme, P::Renderer> = match setup {
318                Setup::Idle { goal } => self::setup(goal),
319                Setup::Running { logs } => installation(logs),
320            };
321
322            let setup = center(
323                container(stage)
324                    .padding(20)
325                    .max_width(500)
326                    .style(container::bordered_box),
327            )
328            .padding(10)
329            .style(|_theme| container::Style::default().background(Color::BLACK.scale_alpha(0.8)));
330
331            Some(themer(theme(), opaque(setup).map(Event::Message)))
332        } else {
333            None
334        };
335
336        let notification = self
337            .show_notification
338            .then(|| text("Press F12 to open debug metrics"))
339            .or_else(|| {
340                debug::is_stale()
341                    .then(|| text("Types have changed. Restart to re-enable hotpatching."))
342            })
343            .map(|notification| {
344                themer(
345                    theme(),
346                    bottom_right(opaque(
347                        container(notification).padding(10).style(container::dark),
348                    )),
349                )
350            });
351
352        stack![view, setup, notification]
353            .width(Fill)
354            .height(Fill)
355            .into()
356    }
357
358    pub fn subscription(&self, program: &P) -> Subscription<Event<P>> {
359        let subscription = program.subscription(&self.state).map(Event::Program);
360        debug::subscriptions_tracked(subscription.units());
361
362        let hotkeys = futures::keyboard::listen()
363            .filter_map(|event| match event {
364                keyboard::Event::KeyPressed {
365                    modified_key: keyboard::Key::Named(keyboard::key::Named::F12),
366                    ..
367                } => Some(Message::ToggleComet),
368                _ => None,
369            })
370            .map(Event::Message);
371
372        let commands = debug::commands().map(Event::Command);
373
374        Subscription::batch([subscription, hotkeys, commands])
375    }
376
377    pub fn theme(&self, program: &P, window: window::Id) -> Option<P::Theme> {
378        program.theme(self.state(), window)
379    }
380
381    pub fn style(&self, program: &P, theme: &P::Theme) -> theme::Style {
382        program.style(self.state(), theme)
383    }
384
385    pub fn scale_factor(&self, program: &P, window: window::Id) -> f32 {
386        program.scale_factor(self.state(), window)
387    }
388
389    pub fn state(&self) -> &P::State {
390        self.time_machine.state().unwrap_or(&self.state)
391    }
392}
393
394pub enum Event<P>
395where
396    P: Program,
397{
398    Message(Message),
399    Program(P::Message),
400    Command(debug::Command),
401    Discard,
402}
403
404impl<P> fmt::Debug for Event<P>
405where
406    P: Program,
407    P::Message: std::fmt::Debug,
408{
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        match self {
411            Self::Message(message) => message.fmt(f),
412            Self::Program(message) => message.fmt(f),
413            Self::Command(command) => command.fmt(f),
414            Self::Discard => f.write_str("Discard"),
415        }
416    }
417}
418
419fn setup<Renderer>(goal: &Goal) -> Element<'_, Message, Theme, Renderer>
420where
421    Renderer: program::Renderer + 'static,
422{
423    let controls = row![
424        button(text("Cancel").center().width(Fill))
425            .width(100)
426            .on_press(Message::CancelSetup)
427            .style(button::danger),
428        space::horizontal(),
429        button(
430            text(match goal {
431                Goal::Installation => "Install",
432                Goal::Update { .. } => "Update",
433            })
434            .center()
435            .width(Fill)
436        )
437        .width(100)
438        .on_press(Message::InstallComet)
439        .style(button::success),
440    ];
441
442    let command = container(
443        text!(
444            "cargo install --locked \\
445    --git https://github.com/iced-rs/comet.git \\
446    --rev {}",
447            comet::COMPATIBLE_REVISION
448        )
449        .size(14)
450        .font(Font::MONOSPACE),
451    )
452    .width(Fill)
453    .padding(5)
454    .style(container::dark);
455
456    Element::from(match goal {
457        Goal::Installation => column![
458            text("comet is not installed!").size(20),
459            "In order to display performance \
460                metrics, the  comet debugger must \
461                be installed in your system.",
462            "The comet debugger is an official \
463                companion tool that helps you debug \
464                your iced applications.",
465            column![
466                "Do you wish to install it with the \
467                    following command?",
468                command
469            ]
470            .spacing(10),
471            controls,
472        ]
473        .spacing(20),
474        Goal::Update { revision } => {
475            let comparison = column![
476                row![
477                    "Installed revision:",
478                    space::horizontal(),
479                    inline_code(revision.as_deref().unwrap_or("Unknown"))
480                ]
481                .align_y(Center),
482                row![
483                    "Compatible revision:",
484                    space::horizontal(),
485                    inline_code(comet::COMPATIBLE_REVISION),
486                ]
487                .align_y(Center)
488            ]
489            .spacing(5);
490
491            column![
492                text("comet is out of date!").size(20),
493                comparison,
494                column![
495                    "Do you wish to update it with the following \
496                        command?",
497                    command
498                ]
499                .spacing(10),
500                controls,
501            ]
502            .spacing(20)
503        }
504    })
505}
506
507fn installation<'a, Renderer>(logs: &'a [String]) -> Element<'a, Message, Theme, Renderer>
508where
509    Renderer: program::Renderer + 'a,
510{
511    column![
512        text("Installing comet...").size(20),
513        container(
514            scrollable(
515                column(
516                    logs.iter()
517                        .map(|log| { text(log).size(12).font(Font::MONOSPACE).into() })
518                )
519                .spacing(3),
520            )
521            .spacing(10)
522            .width(Fill)
523            .height(300)
524            .anchor_bottom(),
525        )
526        .padding(10)
527        .style(container::dark)
528    ]
529    .spacing(20)
530    .into()
531}
532
533fn inline_code<'a, Renderer>(
534    code: impl text::IntoFragment<'a>,
535) -> Element<'a, Message, Theme, Renderer>
536where
537    Renderer: program::Renderer + 'a,
538{
539    container(text(code).size(12).font(Font::MONOSPACE))
540        .style(|_theme| {
541            container::Style::default()
542                .background(Color::BLACK)
543                .border(border::rounded(2))
544        })
545        .padding([2, 4])
546        .into()
547}