quvyta-framework 0.1.35

A Rust framework for building terminal applications
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! The application trait.

use super::clipboard::ClipboardEvent;
use super::command::Command;
use super::frame_limit::FrameLimit;
use super::termination::Termination;
use crate::geometry::Size;
use crate::graphics::Graphics;
use crate::storage::Preferences;
use crate::widget::View;

/// An application built with quvyta-framework: data, a function that draws it and a function that
/// changes it.
///
/// An application implements this trait and runs in a [`Runtime`](super::Runtime), or in a
/// [`Harness`](super::Harness) for tests.
///
/// ```
/// use qframe::prelude::*;
///
/// struct Counter {
///     value: i32,
/// }
///
/// #[derive(Clone)]
/// enum Msg {
///     Increment,
/// }
///
/// impl App for Counter {
///     type Msg = Msg;
///
///     fn update(&mut self, msg: Msg) -> Command<Msg> {
///         match msg {
///             Msg::Increment => self.value += 1,
///         }
///         Command::none()
///     }
///
///     fn view(&self, ui: &mut View<'_, Msg>) {
///         ui.column(|ui| {
///             ui.add(Text::new(format!("Value: {}", self.value)));
///             ui.add(Button::new("Increment").on_press(Msg::Increment));
///         });
///     }
/// }
///
/// let mut app = Harness::new(Counter { value: 0 }, 30, 4);
/// app.press("tab").press("enter");
/// assert!(app.screen().contains("Value: 1"));
/// ```
///
/// # Lifecycle
///
/// Besides `update` and `view`, seven optional hooks follow the application through its life.
/// Each has a default, so an application implements only the ones it needs:
///
/// 1. [`App::resized`] hears the size of the screen: first when the application starts, then
///    whenever it changes.
/// 2. [`App::cell_pixels`] hears the size of one cell in pixels, right after that first size and
///    whenever it changes, which a change of font size does with the columns and rows left as
///    they were.
/// 3. [`App::graphics`] hears the way the terminal draws pictures, right after that first cell
///    size and whenever it changes, so a picture is decoded at the size it will be shown.
/// 4. [`App::preferences`] hears the ecosystem's shared preferences of an application started
///    with [`Runtime::member`](super::Runtime::member): right after the graphics, and whenever
///    another application changes them while this one runs.
/// 5. [`App::init`] runs once, right after the first size, cell and graphics, before the
///    first frame is built.
/// 6. [`App::before_quit`] is asked whenever the runtime is about to quit on the user's behalf.
/// 7. [`App::terminating`] hears that the system is ending the application: a `SIGTERM` or a
///    `SIGHUP`, when the SSH connection or the terminal went away. It is the one chance to save.
///
/// The hooks that only report something ([`App::resized`], [`App::cell_pixels`],
/// [`App::graphics`], [`App::preferences`], [`App::before_quit`], [`App::terminating`], like
/// [`App::action`] and [`App::clipboard`]) read the state and answer with a message, which then
/// goes through `update` like every other; the one that starts work ([`App::init`]) returns a
/// [`Command`] like `update` does. The [`Harness`](super::Harness) runs every hook exactly
/// where the terminal runtime does, so a test sees what a user sees.
///
/// ```
/// use qframe::prelude::*;
///
/// #[derive(Default)]
/// struct Editor {
///     size: Size,
///     unsaved: bool,
///     asking: bool,
/// }
///
/// #[derive(Clone)]
/// enum Msg {
///     Resized(Size),
///     AskBeforeQuit,
///     Quit,
/// }
///
/// impl App for Editor {
///     type Msg = Msg;
///
///     fn init(&mut self) -> Command<Msg> {
///         // The first key already reaches the list.
///         Command::focus("files")
///     }
///
///     fn resized(&self, size: Size) -> Option<Msg> {
///         Some(Msg::Resized(size))
///     }
///
///     fn before_quit(&self) -> Option<Msg> {
///         self.unsaved.then_some(Msg::AskBeforeQuit)
///     }
///
///     fn update(&mut self, msg: Msg) -> Command<Msg> {
///         match msg {
///             Msg::Resized(size) => self.size = size,
///             Msg::AskBeforeQuit => self.asking = true,
///             // Decided: this quit does not ask again.
///             Msg::Quit => return Command::quit(),
///         }
///         Command::none()
///     }
///
///     fn view(&self, ui: &mut View<'_, Msg>) {
///         ui.add(List::new(["notes.md", "todo.md"].map(ListItem::new))).id("files");
///     }
/// }
///
/// let mut app = Harness::new(Editor { unsaved: true, ..Editor::default() }, 40, 6);
/// assert!(app.is_focused("files"));
/// assert_eq!(app.app().size, Size::new(40, 6));
/// app.resize(30, 4);
/// assert_eq!(app.app().size, Size::new(30, 4));
/// app.press("ctrl+q");
/// assert!(app.app().asking && !app.quit_requested());
/// app.send(Msg::Quit);
/// assert!(app.quit_requested());
/// ```
pub trait App: 'static {
    /// Everything that can happen in the application.
    type Msg: Send + 'static;

    /// Applies a message and returns work for the runtime to do.
    fn update(&mut self, msg: Self::Msg) -> Command<Self::Msg>;

    /// Describes the screen. Runs after every change; must not do I/O.
    fn view(&self, ui: &mut View<'_, Self::Msg>);

    /// Turns an `[app]` keymap action into a message, e.g. `"save"` into `Msg::Save`.
    fn action(&self, _name: &str) -> Option<Self::Msg> {
        None
    }

    /// Runs once when the application starts and returns its first work: the focus the first key
    /// should reach, a tick to start, a dialog to open, a file to read.
    ///
    /// It runs at the start of the first frame, after the first [`App::resized`] message and
    /// before the view of that frame is built, so the first frame already shows what it
    /// changed. A [`Command::focus`] it returns names a widget that is not on screen yet; focus
    /// reaches it as soon as that first frame is painted, before the runtime reads any input,
    /// and the frame is drawn again at once with the widget focused. The first key the user
    /// presses therefore reaches the focused widget.
    ///
    /// The runtime calls it once per run, the [`Harness`](super::Harness) once when it is
    /// created. The default does nothing.
    fn init(&mut self) -> Command<Self::Msg> {
        Command::none()
    }

    /// Hears the size of the screen, in columns and rows: when the application starts, before
    /// [`App::init`], and afterwards whenever the terminal is resized. The message it returns
    /// goes through [`App::update`], which is where work that needs the size starts, such as
    /// [`Process::pty`](super::Process::pty) with the width and height the output will have.
    ///
    /// It is the size [`View::size`] reports: the terminal size of the frame about to be drawn.
    /// The message is applied before that frame's view is built, so `update` and `view` never
    /// disagree about it. A resize that ends at the size already reported is not reported
    /// again. [`Harness::new`](super::Harness::new) reports the size it is given, and
    /// [`Harness::resize`](super::Harness::resize) the new one.
    ///
    /// The default ignores the size.
    fn resized(&self, _size: Size) -> Option<Self::Msg> {
        None
    }

    /// Hears the size of one cell in pixels, [`Env::cell_pixels`](crate::env::Env::cell_pixels)
    /// as width and height: when the application starts, right after [`App::resized`] and before
    /// [`App::init`], and afterwards whenever it changes. The message it returns goes through
    /// [`App::update`], which is where work that needs the pixels starts: a picture decoded again
    /// at the size the terminal now shows, a browser's viewport measured afresh.
    ///
    /// [`App::resized`] hears columns and rows, and a change of font size leaves those as they
    /// were — the window grows with the cells, by a keystroke or in a tiling window manager — so
    /// nothing else tells the application that its cells are bigger. Here it is a change like any
    /// other, heard before the frame whose view first sees the new size is built. A cell and a
    /// screen size that change in the same frame are told in that order, and a value already
    /// reported is not reported again: `None` is a value too, so a terminal that stops reporting
    /// one is heard as well. [`Harness::set_cell_pixels`](super::Harness::set_cell_pixels) reports
    /// a change the way the runtime does.
    ///
    /// ```
    /// use qframe::prelude::*;
    ///
    /// #[derive(Default)]
    /// struct Wallpaper {
    ///     cell: Option<(u16, u16)>,
    /// }
    ///
    /// #[derive(Clone)]
    /// enum Msg {
    ///     Cell(Option<(u16, u16)>),
    /// }
    ///
    /// impl App for Wallpaper {
    ///     type Msg = Msg;
    ///
    ///     fn cell_pixels(&self, cell: Option<(u16, u16)>) -> Option<Msg> {
    ///         Some(Msg::Cell(cell))
    ///     }
    ///
    ///     fn update(&mut self, msg: Msg) -> Command<Msg> {
    ///         match msg {
    ///             Msg::Cell(cell) => self.cell = cell,
    ///         }
    ///         Command::none()
    ///     }
    ///
    ///     fn view(&self, ui: &mut View<'_, Msg>) {
    ///         ui.add(Text::new(match self.cell {
    ///             Some((width, height)) => format!("{width} × {height}"),
    ///             None => "-".to_owned(),
    ///         }));
    ///     }
    /// }
    ///
    /// // A font size change leaves the columns and rows as they were, so nothing else reports it.
    /// let mut app = Harness::new(Wallpaper::default(), 40, 6);
    /// app.set_cell_pixels(Some((12, 24)));
    /// assert_eq!(app.app().cell, Some((12, 24)));
    /// assert!(app.screen().contains("12 × 24"), "{}", app.screen());
    /// ```
    ///
    /// The default ignores the cell size.
    fn cell_pixels(&self, _cell: Option<(u16, u16)>) -> Option<Self::Msg> {
        None
    }

    /// Hears the way this terminal draws pictures, [`Env::graphics`](crate::env::Env::graphics):
    /// when the application starts, after [`App::cell_pixels`] and before [`App::init`], and
    /// afterwards whenever it changes, such as when the glyph mode is switched to ASCII or back
    /// while the application runs, or when the terminal's kitty answer arrives late over a slow
    /// link. The message it returns goes through [`App::update`], which is where a picture is
    /// decoded at the size the terminal shows: about ten by twenty pixels a cell for
    /// [`Graphics::Kitty`], one pixel wide and two tall for half blocks, and not at all where
    /// [`Graphics::can_draw`] is false.
    ///
    /// Like the size, the message is applied before the frame whose view first sees the new
    /// value is built, and a value already reported is not reported again.
    /// [`Harness::set_graphics`](super::Harness::set_graphics),
    /// [`Harness::set_depth`](super::Harness::set_depth) and
    /// [`Harness::set_glyph_mode`](super::Harness::set_glyph_mode) report a change the way the
    /// runtime does.
    ///
    /// The default ignores it.
    fn graphics(&self, _graphics: Graphics) -> Option<Self::Msg> {
        None
    }

    /// Hears the ecosystem's shared preferences, for an application started as a member of an
    /// ecosystem with [`Runtime::member`](super::Runtime::member): once when it starts, after
    /// [`App::graphics`] and before [`App::init`], with what it starts with; and afterwards
    /// whenever the ecosystem's shared file or the application's own file changes what they
    /// resolve to, such as when another application of the ecosystem switches the theme for all
    /// of them.
    ///
    /// The runtime has already switched the screen by then: language, theme, icons and reduced
    /// motion as the ecosystem resolves them, and the pillar when the application's own file
    /// changed it. Nothing has to be applied here. An application with a settings screen
    /// refreshes it here, so an open screen shows the new values: an
    /// [`Appearance`](crate::widgets::Appearance) section takes them with
    /// [`Appearance::refresh`](crate::widgets::Appearance::refresh).
    ///
    /// A file written again with what it already said is not reported, so an application that
    /// saves its own change hears at most what it saved, once, and never loops. An application
    /// started without [`Runtime::member`](super::Runtime::member) is never told.
    /// [`Harness::member_in`](super::Harness::member_in) and
    /// [`Harness::member_in_with_env`](super::Harness::member_in_with_env) start a test the same
    /// way, and [`Harness::poll_preferences`](super::Harness::poll_preferences) reads the files
    /// again as the runtime does when they change.
    ///
    /// ```
    /// use qframe::prelude::*;
    /// use qframe::storage::{Ecosystem, Preferences, Scope, Shared};
    ///
    /// #[derive(Default)]
    /// struct Notes {
    ///     theme: String,
    /// }
    ///
    /// enum Msg {
    ///     Preferences(Preferences),
    /// }
    ///
    /// impl App for Notes {
    ///     type Msg = Msg;
    ///
    ///     fn preferences(&self, preferences: &Preferences) -> Option<Msg> {
    ///         Some(Msg::Preferences(preferences.clone()))
    ///     }
    ///
    ///     fn update(&mut self, msg: Msg) -> Command<Msg> {
    ///         match msg {
    ///             Msg::Preferences(preferences) => self.theme = preferences.theme().value.clone(),
    ///         }
    ///         Command::none()
    ///     }
    ///
    ///     fn view(&self, ui: &mut View<'_, Msg>) {
    ///         ui.add(Text::new(self.theme.clone()));
    ///     }
    /// }
    ///
    /// # let folder = std::env::temp_dir().join(format!("quvyta-app-preferences-doc-{}", std::process::id()));
    /// # std::fs::remove_dir_all(&folder).ok();
    /// let ecosystem = Ecosystem::QUVYTA;
    /// let mut app = Harness::member_in(Notes::default(), ecosystem, &folder, "notes", 30, 3);
    /// assert_eq!(app.app().theme, "monochrome");
    /// // Another application switches every follower to amber.
    /// ecosystem.set_in(&folder, "desk", Shared::Theme, "amber", Scope::Ecosystem).expect("saved");
    /// app.poll_preferences();
    /// assert_eq!(app.app().theme, "amber");
    /// assert_eq!(app.env().theme().id(), "amber");
    /// # std::fs::remove_dir_all(&folder).ok();
    /// ```
    fn preferences(&self, _preferences: &Preferences) -> Option<Self::Msg> {
        None
    }

    /// Asked whenever the runtime is about to quit on the user's behalf: the global `quit`
    /// action of the keymap, however it was reached (its key, the command palette, a widget
    /// that runs the action). `None` lets the runtime quit. A message keeps the application running and is
    /// delivered through [`App::update`] instead, e.g. to ask "finish and quit, keep running or
    /// cancel" first.
    ///
    /// Once the application has decided, it quits with [`Command::quit`], which is its own
    /// decision and is never asked about. While an answer is pending the user may ask to quit
    /// again, and the hook is asked again; it sees its own state and can, say, keep the
    /// question it already shows.
    ///
    /// The default lets every quit through.
    fn before_quit(&self) -> Option<Self::Msg> {
        None
    }

    /// Hears that the system is ending the application, and why: see [`Termination`] for each
    /// cause and the signal behind it. `None` quits at once. A message keeps the application
    /// running and is delivered through [`App::update`] instead, which is where it saves and
    /// then returns [`Command::quit`].
    ///
    /// The run ends in bounded time whatever the answer: after [`Termination::grace`] the runtime
    /// quits without the application, and a second `SIGTERM` or `SIGINT` quits at once. After a
    /// [`Termination::Hangup`] the terminal is usually gone, so nothing is drawn any more and a
    /// dialog would wait for nobody; save without asking. Work of [`Command::perform`] and
    /// tasks still run and deliver their messages until the run ends.
    ///
    /// The runtime tells the application once per cause: a hangup that repeats is not told
    /// again, a hangup during a pending terminate is. [`Harness::terminate`](super::Harness::terminate)
    /// simulates each cause in tests.
    ///
    /// The default answers a [`Termination::Terminate`] like a quit the user asked for, with
    /// [`App::before_quit`], and quits at once on a [`Termination::Hangup`]. So an application
    /// that implements neither hook quits cleanly on every signal, and one that asks before
    /// quitting asks on a `SIGTERM` too.
    ///
    /// ```
    /// use qframe::prelude::*;
    /// use qframe::runtime::Termination;
    ///
    /// #[derive(Default)]
    /// struct Timer {
    ///     running: bool,
    ///     saved: bool,
    /// }
    ///
    /// #[derive(Clone)]
    /// enum Msg {
    ///     SaveAndQuit,
    /// }
    ///
    /// impl App for Timer {
    ///     type Msg = Msg;
    ///
    ///     fn terminating(&self, _cause: Termination) -> Option<Msg> {
    ///         // Whether a person or the system ends it, a running timer is saved first.
    ///         self.running.then_some(Msg::SaveAndQuit)
    ///     }
    ///
    ///     fn update(&mut self, msg: Msg) -> Command<Msg> {
    ///         match msg {
    ///             Msg::SaveAndQuit => {
    ///                 self.saved = true;
    ///                 Command::quit()
    ///             }
    ///         }
    ///     }
    ///
    ///     fn view(&self, ui: &mut View<'_, Msg>) {
    ///         ui.add(Text::new("25:00"));
    ///     }
    /// }
    ///
    /// let mut app = Harness::new(Timer { running: true, ..Timer::default() }, 20, 3);
    /// app.terminate(Termination::Hangup);
    /// assert!(app.app().saved && app.quit_requested());
    /// ```
    fn terminating(&self, cause: Termination) -> Option<Self::Msg> {
        match cause {
            Termination::Terminate => self.before_quit(),
            Termination::Hangup => None,
        }
    }

    /// How many frames a second the runtime draws at most; see [`FrameLimit`].
    ///
    /// Asked before every frame, so an application may answer from its own state, such as a
    /// setting the user changed. The default draws 60 frames a second locally and 20 over a
    /// remote connection. The frames the application's own work causes are merged, and so are
    /// the pointer's motions and the wheel; a frame that answers a key, a paste, a press or a
    /// release is never held back.
    ///
    /// ```
    /// # use qframe::prelude::*;
    /// # use qframe::runtime::FrameLimit;
    /// # struct Desktop;
    /// # impl App for Desktop {
    /// #     type Msg = ();
    /// #     fn update(&mut self, (): ()) -> Command<()> {
    /// #         Command::none()
    /// #     }
    /// // A desktop of terminal windows spends a slow link on ten frames a second.
    /// fn frame_limit(&self) -> FrameLimit {
    ///     FrameLimit::per_second(60).remote(10)
    /// }
    /// #     fn view(&self, ui: &mut View<'_, ()>) {
    /// #         ui.add(Text::new("windows"));
    /// #     }
    /// # }
    /// ```
    fn frame_limit(&self) -> FrameLimit {
        FrameLimit::default()
    }

    /// Hears about the clipboard: text a widget or a mouse selection copied, and pasted text
    /// that no focused widget took. Copies the application asked for with
    /// [`Command::copy`] are not reported, so answering a copy with a copy cannot loop.
    fn clipboard(&self, _event: &ClipboardEvent) -> Option<Self::Msg> {
        None
    }
}