Skip to main content

slipcase_open/present/
mod.rs

1//! What the tool says to the person using it, and how it asks.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 9. A notification carrying actions is the baseline and exists on all
7//! three platforms; the command line is the floor beneath it. This is the trait
8//! both sit behind, so the engine can narrate and ask without knowing which of
9//! the two is listening, and so the tray — an enhancement on the two platforms
10//! that have one — joins later without the engine noticing.
11//!
12//! **An answer arrives long after the question, or never at all.** A
13//! notification with buttons sits in the message list until somebody comes back
14//! to it, so [`Channel::ask`] hands the question over and returns, and answers
15//! are collected on the loop's own turn by [`Channel::answers`]. Waiting on one
16//! would starve the watchers, which are the reason the process is resident
17//! (concept 8).
18//!
19//! **This is the boundary concept 8 draws rather than a breach of it.** The
20//! engine holds the session model and calls the trait; the implementations sit
21//! beside it and are chosen by `main`. Nothing in `flow`, `writeback` or
22//! `recover` knows a notification exists.
23
24use std::fmt;
25
26use crate::i18n::t;
27
28pub mod terminal;
29
30#[cfg(target_os = "linux")]
31pub mod freedesktop;
32
33#[cfg(windows)]
34pub mod toast;
35
36#[cfg(windows)]
37pub mod tray;
38
39/// One session, as the standing list shows it.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Listed {
42    /// What `sessions` prints and `close` takes.
43    pub id: String,
44    /// The content file and what has become of it, without the id in front.
45    /// The command line puts the id back; a menu has no room for one.
46    pub label: String,
47    /// The content file's name alone, for a menu item that is an action rather
48    /// than a line of a report: *Close report.pdf* says what pressing it does,
49    /// where the whole label repeats what the tooltip already counted.
50    pub content_name: String,
51    /// Live: open, or closed and waiting for the application to finish. What
52    /// the standing list is reassuring somebody about.
53    pub live: bool,
54    /// A decision only a person can make — a session left behind with an edit
55    /// in it. Concept 6.3's three choices are what it needs.
56    pub needs_a_person: bool,
57    /// Saves written back so far, where the session is one that counts them.
58    ///
59    /// The one number that changes while somebody works, which is the whole
60    /// reason a standing list is worth more than a notification: a toast says
61    /// what happened once, and this says what is true now.
62    pub write_backs: Option<u64>,
63}
64
65/// What the icon is saying, which for most people is the whole of this tool's
66/// interface.
67///
68/// **One question, answered continuously: is my work safe.** Somebody who never
69/// opens the menu and never reads a notification should still be able to glance
70/// at the clock and know the answer, and a person who learns to ignore it
71/// entirely is the success case rather than a failure.
72///
73/// Ordered, because the icon can only be one colour and what it shows is the
74/// worst thing currently true.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
76pub enum Mood {
77    /// Watching, and everything that was saved has landed.
78    #[default]
79    Settled,
80    /// A save is on its way back into its container. Seen for a moment and
81    /// gone.
82    ///
83    /// **The least important of these and the one most worth having.** Pressing
84    /// Save and seeing nothing happen anywhere is what makes a person doubt the
85    /// tool is running at all, and it is the first thing that was noticed
86    /// missing. It sits below the warnings because a flicker must never hide
87    /// one.
88    Working,
89    /// Worth a look, with nothing at risk: a decision waiting, or a container
90    /// that did not open because the desktop had nothing to open it with.
91    Look,
92    /// Work that is not in its container — a write-back that failed, or a
93    /// container that moved out from under a session. The one thing this tool
94    /// promises is that a save reaches the container, and this is that promise
95    /// outstanding.
96    AtRisk,
97    /// Danger, and nothing else may use it or it stops meaning anything. Today
98    /// that is one thing: a content file that is a program wearing a
99    /// document's name.
100    Danger,
101}
102
103/// Something the icon has taken on a colour for, in words.
104///
105/// The icon says *something is wrong*; the menu says *what*, in one line, about
106/// a file the person recognises. Held rather than worked out from the sessions,
107/// because most of these are moments — a container that would not open leaves
108/// no session to re-read the trouble from.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Trouble {
111    /// What it is called when it is put down.
112    pub id: String,
113    /// How much colour it is worth.
114    pub mood: Mood,
115    /// One line, naming the file.
116    pub summary: String,
117}
118
119/// What somebody chose from the standing list.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum Chosen {
122    /// Put down a trouble that has been read.
123    ///
124    /// **The one thing the standing list asks of anybody**, and it is there
125    /// because a list of things that cannot be acted on is furniture. It is
126    /// also the only way an icon that has gone red gets to go back to blue,
127    /// which is what makes the colour a statement rather than a decoration.
128    Dismiss(String),
129    /// Leave, keeping every session recoverable, which is what interrupting
130    /// the command line already does.
131    Quit,
132}
133
134/// The standing list of what is open.
135///
136/// Concept 12 gives this a tray icon on Windows, a menu bar item on macOS and
137/// the command line on Linux — which is why doing nothing is a complete
138/// implementation and [`Nowhere`] is one. Concept 9 says the tray joins later
139/// without the engine noticing, and not noticing is what this trait is for: the
140/// loop hands it lines it was going to format anyway and asks what came back.
141pub trait Standing {
142    /// What is open, what is wrong, and the colour those add up to, whenever
143    /// any of the three changes.
144    fn show(&self, sessions: &[Listed], troubles: &[Trouble], mood: Mood);
145
146    /// What has been chosen since this was last asked. Does not block.
147    fn taken(&self) -> Vec<Chosen>;
148
149    /// Whether this surface is itself a reason for the instance to stay.
150    ///
151    /// **A standing list stands until it is dismissed, and that supersedes
152    /// concept 8's exit rule wherever there is one.** The rule ends an instance
153    /// when no session, no lingering session and no unanswered question remain,
154    /// which was right while the process had no face: there was nothing for it
155    /// to be, so there was no reason for it to be. An icon changes that. It is
156    /// where a warning lives, and a warning that appears in a process already
157    /// on its way out has nowhere to go but a notification somebody may never
158    /// see — which is the whole reason the colours exist.
159    ///
160    /// The cost, said plainly: open one container and a background process
161    /// stays until it is asked to leave. That is the bargain every sync client
162    /// makes, and the icon is what turns it from a surprise into a bargain.
163    ///
164    /// Where a terminal started this there is no icon, by [`crate::present`]'s
165    /// own rule that the command line is the floor — so that invocation keeps
166    /// concept 8 exactly as written, and `open` at a prompt still returns.
167    fn holding(&self) -> bool {
168        false
169    }
170}
171
172/// No standing list, which is every platform without one and every test.
173pub struct Nowhere;
174
175impl Standing for Nowhere {
176    fn show(&self, _sessions: &[Listed], _troubles: &[Trouble], _mood: Mood) {}
177    fn taken(&self) -> Vec<Chosen> {
178        Vec::new()
179    }
180}
181
182/// How much of the person's attention something is worth.
183///
184/// **The axis is whether the person asked for it, not how loud it is.** That is
185/// what [`crate::policy::Notify`] thresholds on, and it is the distinction a
186/// setting called *how much the tool says without being asked* has to be able
187/// to make. A confirmation of something somebody just clicked is not chatter
188/// however routine it looks, and silencing it would mean pressing a button and
189/// getting nothing back.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum Weight {
192    /// Happens on its own, with nobody waiting on it. A write-back is this.
193    /// The only weight a threshold may drop.
194    Routine,
195    /// The answer to something the person just did — a button pressed, a verb
196    /// run — or a standing fact about the machine they should know once, like
197    /// concept 10's *settings here are administered*.
198    Ordinary,
199    /// Worth being interrupted for. Concept 5.1's content check earns this and
200    /// says why: it fires close to never, and when it fires it means the
201    /// content file is an executable wearing a document's name.
202    Interrupt,
203}
204
205/// Something said that needs no answer.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct Report {
208    /// One line, and the only line a notification is certain to show.
209    pub summary: String,
210    /// What follows it, where there is room.
211    pub detail: Vec<String>,
212    pub weight: Weight,
213}
214
215impl Report {
216    /// Something that happened on its own. Droppable.
217    #[must_use]
218    pub fn routine(summary: impl Into<String>) -> Self {
219        Self {
220            weight: Weight::Routine,
221            ..Self::ordinary(summary)
222        }
223    }
224
225    /// The answer to something somebody did, or a fact worth knowing once.
226    #[must_use]
227    pub fn ordinary(summary: impl Into<String>) -> Self {
228        Self {
229            summary: summary.into(),
230            detail: Vec::new(),
231            weight: Weight::Ordinary,
232        }
233    }
234
235    /// A report worth interrupting for.
236    #[must_use]
237    pub fn interrupt(summary: impl Into<String>) -> Self {
238        Self {
239            weight: Weight::Interrupt,
240            ..Self::ordinary(summary)
241        }
242    }
243
244    /// Another line of detail.
245    #[must_use]
246    pub fn and(mut self, line: impl Into<String>) -> Self {
247        self.detail.push(line.into());
248        self
249    }
250}
251
252/// What can be done about a session, as a button.
253///
254/// The set is concept 6.3's: write-back, discard, and reveal-the-folder. It is
255/// deliberately small, because every member has to be expressible as a button
256/// on all three platforms and as a verb on the command line.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum Choice {
259    /// Put the content file back into its container.
260    WriteBack,
261    /// Throw the content file away and remove the session.
262    Discard,
263    /// Show the content directory, and decide later.
264    Reveal,
265}
266
267impl Choice {
268    /// The name this travels under, which a notification hands back rather than
269    /// the label. Stable, because a running notification service may hold a
270    /// question across an upgrade of this binary.
271    #[must_use]
272    /// **Never translated.** This is what the notification service is handed
273    /// and what it echoes back when a button is pressed, and [`Self::from_key`]
274    /// matches on it: a German key would be a button whose press this build
275    /// cannot recognise. [`Self::label`] beside it is the half a person reads.
276    pub fn key(self) -> &'static str {
277        match self {
278            Self::WriteBack => "write-back",
279            Self::Discard => "discard",
280            Self::Reveal => "reveal",
281        }
282    }
283
284    /// What the button says.
285    #[must_use]
286    pub fn label(self) -> &'static str {
287        match self {
288            Self::WriteBack => t("Write it back"),
289            Self::Discard => t("Discard it"),
290            Self::Reveal => t("Show me"),
291        }
292    }
293
294    /// The choice a notification service handed back, where it is one this
295    /// build knows.
296    #[must_use]
297    pub fn from_key(key: &str) -> Option<Self> {
298        [Self::WriteBack, Self::Discard, Self::Reveal]
299            .into_iter()
300            .find(|c| c.key() == key)
301    }
302}
303
304impl fmt::Display for Choice {
305    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306        f.write_str(self.key())
307    }
308}
309
310/// Something asked, which nothing acts on until it is answered.
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub struct Question {
313    /// The session the answer applies to, spelled as `sessions` names it. An
314    /// answer carries this back rather than a handle, because the question may
315    /// outlive the process that asked it.
316    pub about: String,
317    pub summary: String,
318    pub detail: Vec<String>,
319    /// In the order they should be offered. The first is the default where a
320    /// platform distinguishes one.
321    pub choices: Vec<Choice>,
322}
323
324/// What somebody chose.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct Answer {
327    pub about: String,
328    pub choice: Choice,
329}
330
331/// How the tool speaks, and how it is answered.
332///
333/// Object-safe on purpose: `main` picks one implementation at startup from what
334/// the machine turns out to have, and the engine holds it as a reference in
335/// [`crate::outside::Outside`].
336pub trait Channel {
337    /// Say something that needs no answer.
338    fn report(&self, report: &Report);
339
340    /// Ask something, and return. The answer, if one comes, arrives through
341    /// [`answers`](Self::answers).
342    fn ask(&self, question: &Question);
343
344    /// Take back a question that no longer applies, named by its `about`.
345    ///
346    /// The case is a session that resolved itself while its question was still
347    /// sitting in somebody's message list — a lingering editor's last save
348    /// landing (concept 8), or the same question answered at the command line.
349    /// A button that would act on a session that has gone is answerable and
350    /// pointless, and the answer would have to be a refusal.
351    fn withdraw(&self, about: &str);
352
353    /// The answers that have arrived since this was last asked.
354    ///
355    /// Does not block. The resident loop calls this each turn between pumping
356    /// the watchers.
357    fn answers(&self) -> Vec<Answer>;
358
359    /// Say something in a way that cannot be missed, because something was
360    /// refused and the person is owed an explanation for it.
361    ///
362    /// **Not a question, and it does not block the caller.** By the time this
363    /// is called the refusal has already happened — nothing is waiting on an
364    /// answer, and there is nothing to decide. What it buys over
365    /// [`report`](Self::report) is that a notification can be missed and this
366    /// one must not be: a double-click that produced no document and no
367    /// message is the tool looking broken at the exact moment it worked.
368    ///
369    /// The default is [`report`](Self::report), which is right wherever the
370    /// channel has nothing louder. Concept 12 gives Windows a native message
371    /// box and that is where this becomes a dialog.
372    fn insist(&self, report: &Report) {
373        self.report(report);
374    }
375
376    /// Wait for anything this channel put on the screen that belongs to this
377    /// process, before the process ends.
378    ///
379    /// **The other half of [`insist`](Self::insist), and it exists because a
380    /// refusal was silently lost.** Measured against the installed package on
381    /// 2026-09-06: a container whose content file is a program, double-clicked with
382    /// no instance already running, produced nothing at all — no box, no window,
383    /// the process gone inside 400ms. With an instance running it produced the
384    /// box every time. The difference is entirely who was left alive: the
385    /// Windows box is a thread, because the resident loop must not stop pumping
386    /// watchers for as long as somebody leaves a dialog up, and an invocation
387    /// that refused and held nothing returns immediately — taking the thread
388    /// down with it before it had drawn.
389    ///
390    /// So the rule is not *show it* but *do not leave while it is up*, and that
391    /// is what this is. A channel whose output outlives the process — a toast,
392    /// a freedesktop notification, a line already written to a terminal — has
393    /// nothing to wait for, which is why the default does nothing.
394    ///
395    /// Called at the points where the process is about to end, not after each
396    /// [`insist`](Self::insist): the resident loop insists mid-flight and must
397    /// carry on, and the whole reason the box is on a thread is so that it can.
398    fn stay_until_seen(&self) {}
399}
400
401#[cfg(test)]
402pub mod testing {
403    //! A channel that remembers rather than shows, and can be answered by hand.
404
405    use super::{Answer, Channel, Choice, Question, Report};
406    use std::sync::Mutex;
407
408    /// A channel that discards everything, for the tests of code that does not
409    /// speak. `flow` is all of it: concept 8 keeps the narration out of the
410    /// engine, so a flow test that had to build a recorder would be asserting
411    /// the absence of something by carrying it around.
412    pub struct Silent;
413
414    impl Channel for Silent {
415        fn report(&self, _report: &Report) {}
416        fn ask(&self, _question: &Question) {}
417        fn withdraw(&self, _about: &str) {}
418        fn answers(&self) -> Vec<Answer> {
419            Vec::new()
420        }
421    }
422
423    /// Records what it was told and what it was asked, and hands back whatever
424    /// answers a test has put in it.
425    #[derive(Default)]
426    pub struct Recording {
427        reports: Mutex<Vec<Report>>,
428        questions: Mutex<Vec<Question>>,
429        withdrawn: Mutex<Vec<String>>,
430        pending: Mutex<Vec<Answer>>,
431        insisted: Mutex<Vec<Report>>,
432    }
433
434    impl Recording {
435        /// Everything insisted on, in order. Not included in
436        /// [`reports`](Self::reports).
437        ///
438        /// # Panics
439        ///
440        /// As [`reports`](Self::reports).
441        #[must_use]
442        pub fn insisted(&self) -> Vec<Report> {
443            self.insisted.lock().unwrap().clone()
444        }
445
446        /// Everything reported, in order.
447        ///
448        /// # Panics
449        ///
450        /// If a previous caller panicked while holding the lock, which in a
451        /// test means the test that did so has already failed.
452        #[must_use]
453        pub fn reports(&self) -> Vec<Report> {
454            self.reports.lock().unwrap().clone()
455        }
456
457        /// Every summary and detail line reported, joined, for the tests that
458        /// only care that something was said.
459        ///
460        /// # Panics
461        ///
462        /// As [`reports`](Self::reports).
463        #[must_use]
464        pub fn said(&self) -> String {
465            self.reports
466                .lock()
467                .unwrap()
468                .iter()
469                .fold(String::new(), |mut all, r| {
470                    all.push_str(&r.summary);
471                    all.push('\n');
472                    all.push_str(&r.detail.join("\n"));
473                    all.push('\n');
474                    all
475                })
476        }
477
478        /// Everything asked, in order.
479        ///
480        /// # Panics
481        ///
482        /// As [`reports`](Self::reports).
483        #[must_use]
484        pub fn questions(&self) -> Vec<Question> {
485            self.questions.lock().unwrap().clone()
486        }
487
488        /// The questions taken back, in order.
489        ///
490        /// # Panics
491        ///
492        /// As [`reports`](Self::reports).
493        #[must_use]
494        pub fn withdrawn(&self) -> Vec<String> {
495            self.withdrawn.lock().unwrap().clone()
496        }
497
498        /// Answer as a person would, to be picked up on the next
499        /// [`answers`](Channel::answers).
500        ///
501        /// # Panics
502        ///
503        /// As [`reports`](Self::reports).
504        pub fn answer(&self, about: &str, choice: Choice) {
505            self.pending.lock().unwrap().push(Answer {
506                about: about.to_owned(),
507                choice,
508            });
509        }
510    }
511
512    impl Channel for Recording {
513        fn report(&self, report: &Report) {
514            self.reports.lock().unwrap().push(report.clone());
515        }
516
517        fn ask(&self, question: &Question) {
518            self.questions.lock().unwrap().push(question.clone());
519        }
520
521        fn withdraw(&self, about: &str) {
522            self.withdrawn.lock().unwrap().push(about.to_owned());
523        }
524
525        fn answers(&self) -> Vec<Answer> {
526            std::mem::take(&mut *self.pending.lock().unwrap())
527        }
528
529        fn insist(&self, report: &Report) {
530            // Recorded apart from `report`, because what is being asserted is
531            // that this one could not be missed rather than that it was said.
532            // The default implementation forwards to `report`; this one does
533            // not, so a test cannot pass by mistaking the two.
534            self.insisted.lock().unwrap().push(report.clone());
535        }
536    }
537}