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