iced_devtools/
lib.rs

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