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}