slipcase-open 0.1.7

Open the payload of a Slipcase container in its own application, and write edits back into the container
Documentation
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
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
//! What the tool says to the person using it, and how it asks.
//
// Author: David M. Anderson
// Built with AI assistance (Claude, Anthropic)
//
//! Concept 9. A notification carrying actions is the baseline and exists on all
//! three platforms; the command line is the floor beneath it. This is the trait
//! both sit behind, so the engine can narrate and ask without knowing which of
//! the two is listening, and so the tray — an enhancement on the two platforms
//! that have one — joins later without the engine noticing.
//!
//! **An answer arrives long after the question, or never at all.** A
//! notification with buttons sits in the message list until somebody comes back
//! to it, so [`Channel::ask`] hands the question over and returns, and answers
//! are collected on the loop's own turn by [`Channel::answers`]. Waiting on one
//! would starve the watchers, which are the reason the process is resident
//! (concept 8).
//!
//! **This is the boundary concept 8 draws rather than a breach of it.** The
//! engine holds the session model and calls the trait; the implementations sit
//! beside it and are chosen by `main`. Nothing in `flow`, `writeback` or
//! `recover` knows a notification exists.

use std::fmt;

use crate::i18n::t;

pub mod terminal;

#[cfg(target_os = "linux")]
pub mod freedesktop;

#[cfg(windows)]
pub mod toast;

#[cfg(windows)]
pub mod tray;

/// One session, as the standing list shows it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listed {
    /// What `sessions` prints and `close` takes.
    pub id: String,
    /// The payload and what has become of it, without the id in front. The
    /// command line puts the id back; a menu has no room for one.
    pub label: String,
    /// The payload's name alone, for a menu item that is an action rather than
    /// a line of a report: *Close report.pdf* says what pressing it does, where
    /// the whole label repeats what the tooltip already counted.
    pub payload: String,
    /// Live: open, or closed and waiting for the application to finish. What
    /// the standing list is reassuring somebody about.
    pub live: bool,
    /// A decision only a person can make — a session left behind with an edit
    /// in it. Concept 6.3's three choices are what it needs.
    pub needs_a_person: bool,
    /// Saves written back so far, where the session is one that counts them.
    ///
    /// The one number that changes while somebody works, which is the whole
    /// reason a standing list is worth more than a notification: a toast says
    /// what happened once, and this says what is true now.
    pub write_backs: Option<u64>,
}

/// What the icon is saying, which for most people is the whole of this tool's
/// interface.
///
/// **One question, answered continuously: is my work safe.** Somebody who never
/// opens the menu and never reads a notification should still be able to glance
/// at the clock and know the answer, and a person who learns to ignore it
/// entirely is the success case rather than a failure.
///
/// Ordered, because the icon can only be one colour and what it shows is the
/// worst thing currently true.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum Mood {
    /// Watching, and everything that was saved has landed.
    #[default]
    Settled,
    /// A save is on its way back into its container. Seen for a moment and
    /// gone.
    ///
    /// **The least important of these and the one most worth having.** Pressing
    /// Save and seeing nothing happen anywhere is what makes a person doubt the
    /// tool is running at all, and it is the first thing that was noticed
    /// missing. It sits below the warnings because a flicker must never hide
    /// one.
    Working,
    /// Worth a look, with nothing at risk: a decision waiting, or a container
    /// that did not open because the desktop had nothing to open it with.
    Look,
    /// Work that is not in its container — a write-back that failed, or a
    /// container that moved out from under a session. The one thing this tool
    /// promises is that a save reaches the container, and this is that promise
    /// outstanding.
    AtRisk,
    /// Danger, and nothing else may use it or it stops meaning anything. Today
    /// that is one thing: a payload that is a program wearing a document's
    /// name.
    Danger,
}

/// Something the icon has taken on a colour for, in words.
///
/// The icon says *something is wrong*; the menu says *what*, in one line, about
/// a file the person recognises. Held rather than worked out from the sessions,
/// because most of these are moments — a container that would not open leaves
/// no session to re-read the trouble from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Trouble {
    /// What it is called when it is put down.
    pub id: String,
    /// How much colour it is worth.
    pub mood: Mood,
    /// One line, naming the file.
    pub summary: String,
}

/// What somebody chose from the standing list.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Chosen {
    /// Put down a trouble that has been read.
    ///
    /// **The one thing the standing list asks of anybody**, and it is there
    /// because a list of things that cannot be acted on is furniture. It is
    /// also the only way an icon that has gone red gets to go back to blue,
    /// which is what makes the colour a statement rather than a decoration.
    Dismiss(String),
    /// Leave, keeping every session recoverable, which is what interrupting
    /// the command line already does.
    Quit,
}

/// The standing list of what is open.
///
/// Concept 12 gives this a tray icon on Windows, a menu bar item on macOS and
/// the command line on Linux — which is why doing nothing is a complete
/// implementation and [`Nowhere`] is one. Concept 9 says the tray joins later
/// without the engine noticing, and not noticing is what this trait is for: the
/// loop hands it lines it was going to format anyway and asks what came back.
pub trait Standing {
    /// What is open, what is wrong, and the colour those add up to, whenever
    /// any of the three changes.
    fn show(&self, sessions: &[Listed], troubles: &[Trouble], mood: Mood);

    /// What has been chosen since this was last asked. Does not block.
    fn taken(&self) -> Vec<Chosen>;

    /// Whether this surface is itself a reason for the instance to stay.
    ///
    /// **A standing list stands until it is dismissed, and that supersedes
    /// concept 8's exit rule wherever there is one.** The rule ends an instance
    /// when no session, no lingering session and no unanswered question remain,
    /// which was right while the process had no face: there was nothing for it
    /// to be, so there was no reason for it to be. An icon changes that. It is
    /// where a warning lives, and a warning that appears in a process already
    /// on its way out has nowhere to go but a notification somebody may never
    /// see — which is the whole reason the colours exist.
    ///
    /// The cost, said plainly: open one container and a background process
    /// stays until it is asked to leave. That is the bargain every sync client
    /// makes, and the icon is what turns it from a surprise into a bargain.
    ///
    /// Where a terminal started this there is no icon, by [`crate::present`]'s
    /// own rule that the command line is the floor — so that invocation keeps
    /// concept 8 exactly as written, and `open` at a prompt still returns.
    fn holding(&self) -> bool {
        false
    }
}

/// No standing list, which is every platform without one and every test.
pub struct Nowhere;

impl Standing for Nowhere {
    fn show(&self, _sessions: &[Listed], _troubles: &[Trouble], _mood: Mood) {}
    fn taken(&self) -> Vec<Chosen> {
        Vec::new()
    }
}

/// How much of the person's attention something is worth.
///
/// **The axis is whether the person asked for it, not how loud it is.** That is
/// what [`crate::policy::Notify`] thresholds on, and it is the distinction a
/// setting called *how much the tool says without being asked* has to be able
/// to make. A confirmation of something somebody just clicked is not chatter
/// however routine it looks, and silencing it would mean pressing a button and
/// getting nothing back.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Weight {
    /// Happens on its own, with nobody waiting on it. A write-back is this.
    /// The only weight a threshold may drop.
    Routine,
    /// The answer to something the person just did — a button pressed, a verb
    /// run — or a standing fact about the machine they should know once, like
    /// concept 10's *settings here are administered*.
    Ordinary,
    /// Worth being interrupted for. Concept 5.1's content check earns this and
    /// says why: it fires close to never, and when it fires it means the
    /// payload is an executable wearing a document's name.
    Interrupt,
}

/// Something said that needs no answer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
    /// One line, and the only line a notification is certain to show.
    pub summary: String,
    /// What follows it, where there is room.
    pub detail: Vec<String>,
    pub weight: Weight,
}

impl Report {
    /// Something that happened on its own. Droppable.
    #[must_use]
    pub fn routine(summary: impl Into<String>) -> Self {
        Self {
            weight: Weight::Routine,
            ..Self::ordinary(summary)
        }
    }

    /// The answer to something somebody did, or a fact worth knowing once.
    #[must_use]
    pub fn ordinary(summary: impl Into<String>) -> Self {
        Self {
            summary: summary.into(),
            detail: Vec::new(),
            weight: Weight::Ordinary,
        }
    }

    /// A report worth interrupting for.
    #[must_use]
    pub fn interrupt(summary: impl Into<String>) -> Self {
        Self {
            weight: Weight::Interrupt,
            ..Self::ordinary(summary)
        }
    }

    /// Another line of detail.
    #[must_use]
    pub fn and(mut self, line: impl Into<String>) -> Self {
        self.detail.push(line.into());
        self
    }
}

/// What can be done about a session, as a button.
///
/// The set is concept 6.3's: write-back, discard, and reveal-the-folder. It is
/// deliberately small, because every member has to be expressible as a button
/// on all three platforms and as a verb on the command line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Choice {
    /// Put the payload back into its container.
    WriteBack,
    /// Throw the payload away and remove the session.
    Discard,
    /// Show the payload directory, and decide later.
    Reveal,
}

impl Choice {
    /// The name this travels under, which a notification hands back rather than
    /// the label. Stable, because a running notification service may hold a
    /// question across an upgrade of this binary.
    #[must_use]
    /// **Never translated.** This is what the notification service is handed
    /// and what it echoes back when a button is pressed, and [`Self::from_key`]
    /// matches on it: a German key would be a button whose press this build
    /// cannot recognise. [`Self::label`] beside it is the half a person reads.
    pub fn key(self) -> &'static str {
        match self {
            Self::WriteBack => "write-back",
            Self::Discard => "discard",
            Self::Reveal => "reveal",
        }
    }

    /// What the button says.
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            Self::WriteBack => t("Write it back"),
            Self::Discard => t("Discard it"),
            Self::Reveal => t("Show me"),
        }
    }

    /// The choice a notification service handed back, where it is one this
    /// build knows.
    #[must_use]
    pub fn from_key(key: &str) -> Option<Self> {
        [Self::WriteBack, Self::Discard, Self::Reveal]
            .into_iter()
            .find(|c| c.key() == key)
    }
}

impl fmt::Display for Choice {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.key())
    }
}

/// Something asked, which nothing acts on until it is answered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Question {
    /// The session the answer applies to, spelled as `sessions` names it. An
    /// answer carries this back rather than a handle, because the question may
    /// outlive the process that asked it.
    pub about: String,
    pub summary: String,
    pub detail: Vec<String>,
    /// In the order they should be offered. The first is the default where a
    /// platform distinguishes one.
    pub choices: Vec<Choice>,
}

/// What somebody chose.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Answer {
    pub about: String,
    pub choice: Choice,
}

/// How the tool speaks, and how it is answered.
///
/// Object-safe on purpose: `main` picks one implementation at startup from what
/// the machine turns out to have, and the engine holds it as a reference in
/// [`crate::outside::Outside`].
pub trait Channel {
    /// Say something that needs no answer.
    fn report(&self, report: &Report);

    /// Ask something, and return. The answer, if one comes, arrives through
    /// [`answers`](Self::answers).
    fn ask(&self, question: &Question);

    /// Take back a question that no longer applies, named by its `about`.
    ///
    /// The case is a session that resolved itself while its question was still
    /// sitting in somebody's message list — a lingering editor's last save
    /// landing (concept 8), or the same question answered at the command line.
    /// A button that would act on a session that has gone is answerable and
    /// pointless, and the answer would have to be a refusal.
    fn withdraw(&self, about: &str);

    /// The answers that have arrived since this was last asked.
    ///
    /// Does not block. The resident loop calls this each turn between pumping
    /// the watchers.
    fn answers(&self) -> Vec<Answer>;

    /// Say something in a way that cannot be missed, because something was
    /// refused and the person is owed an explanation for it.
    ///
    /// **Not a question, and it does not block the caller.** By the time this
    /// is called the refusal has already happened — nothing is waiting on an
    /// answer, and there is nothing to decide. What it buys over
    /// [`report`](Self::report) is that a notification can be missed and this
    /// one must not be: a double-click that produced no document and no
    /// message is the tool looking broken at the exact moment it worked.
    ///
    /// The default is [`report`](Self::report), which is right wherever the
    /// channel has nothing louder. Concept 12 gives Windows a native message
    /// box and that is where this becomes a dialog.
    fn insist(&self, report: &Report) {
        self.report(report);
    }

    /// Wait for anything this channel put on the screen that belongs to this
    /// process, before the process ends.
    ///
    /// **The other half of [`insist`](Self::insist), and it exists because a
    /// refusal was silently lost.** Measured against the installed package on
    /// 2026-09-06: a container whose payload is a program, double-clicked with
    /// no instance already running, produced nothing at all — no box, no window,
    /// the process gone inside 400ms. With an instance running it produced the
    /// box every time. The difference is entirely who was left alive: the
    /// Windows box is a thread, because the resident loop must not stop pumping
    /// watchers for as long as somebody leaves a dialog up, and an invocation
    /// that refused and held nothing returns immediately — taking the thread
    /// down with it before it had drawn.
    ///
    /// So the rule is not *show it* but *do not leave while it is up*, and that
    /// is what this is. A channel whose output outlives the process — a toast,
    /// a freedesktop notification, a line already written to a terminal — has
    /// nothing to wait for, which is why the default does nothing.
    ///
    /// Called at the points where the process is about to end, not after each
    /// [`insist`](Self::insist): the resident loop insists mid-flight and must
    /// carry on, and the whole reason the box is on a thread is so that it can.
    fn stay_until_seen(&self) {}
}

#[cfg(test)]
pub mod testing {
    //! A channel that remembers rather than shows, and can be answered by hand.

    use super::{Answer, Channel, Choice, Question, Report};
    use std::sync::Mutex;

    /// A channel that discards everything, for the tests of code that does not
    /// speak. `flow` is all of it: concept 8 keeps the narration out of the
    /// engine, so a flow test that had to build a recorder would be asserting
    /// the absence of something by carrying it around.
    pub struct Silent;

    impl Channel for Silent {
        fn report(&self, _report: &Report) {}
        fn ask(&self, _question: &Question) {}
        fn withdraw(&self, _about: &str) {}
        fn answers(&self) -> Vec<Answer> {
            Vec::new()
        }
    }

    /// Records what it was told and what it was asked, and hands back whatever
    /// answers a test has put in it.
    #[derive(Default)]
    pub struct Recording {
        reports: Mutex<Vec<Report>>,
        questions: Mutex<Vec<Question>>,
        withdrawn: Mutex<Vec<String>>,
        pending: Mutex<Vec<Answer>>,
        insisted: Mutex<Vec<Report>>,
    }

    impl Recording {
        /// Everything insisted on, in order. Not included in
        /// [`reports`](Self::reports).
        ///
        /// # Panics
        ///
        /// As [`reports`](Self::reports).
        #[must_use]
        pub fn insisted(&self) -> Vec<Report> {
            self.insisted.lock().unwrap().clone()
        }

        /// Everything reported, in order.
        ///
        /// # Panics
        ///
        /// If a previous caller panicked while holding the lock, which in a
        /// test means the test that did so has already failed.
        #[must_use]
        pub fn reports(&self) -> Vec<Report> {
            self.reports.lock().unwrap().clone()
        }

        /// Every summary and detail line reported, joined, for the tests that
        /// only care that something was said.
        ///
        /// # Panics
        ///
        /// As [`reports`](Self::reports).
        #[must_use]
        pub fn said(&self) -> String {
            self.reports
                .lock()
                .unwrap()
                .iter()
                .fold(String::new(), |mut all, r| {
                    all.push_str(&r.summary);
                    all.push('\n');
                    all.push_str(&r.detail.join("\n"));
                    all.push('\n');
                    all
                })
        }

        /// Everything asked, in order.
        ///
        /// # Panics
        ///
        /// As [`reports`](Self::reports).
        #[must_use]
        pub fn questions(&self) -> Vec<Question> {
            self.questions.lock().unwrap().clone()
        }

        /// The questions taken back, in order.
        ///
        /// # Panics
        ///
        /// As [`reports`](Self::reports).
        #[must_use]
        pub fn withdrawn(&self) -> Vec<String> {
            self.withdrawn.lock().unwrap().clone()
        }

        /// Answer as a person would, to be picked up on the next
        /// [`answers`](Channel::answers).
        ///
        /// # Panics
        ///
        /// As [`reports`](Self::reports).
        pub fn answer(&self, about: &str, choice: Choice) {
            self.pending.lock().unwrap().push(Answer {
                about: about.to_owned(),
                choice,
            });
        }
    }

    impl Channel for Recording {
        fn report(&self, report: &Report) {
            self.reports.lock().unwrap().push(report.clone());
        }

        fn ask(&self, question: &Question) {
            self.questions.lock().unwrap().push(question.clone());
        }

        fn withdraw(&self, about: &str) {
            self.withdrawn.lock().unwrap().push(about.to_owned());
        }

        fn answers(&self) -> Vec<Answer> {
            std::mem::take(&mut *self.pending.lock().unwrap())
        }

        fn insist(&self, report: &Report) {
            // Recorded apart from `report`, because what is being asserted is
            // that this one could not be missed rather than that it was said.
            // The default implementation forwards to `report`; this one does
            // not, so a test cannot pass by mistaking the two.
            self.insisted.lock().unwrap().push(report.clone());
        }
    }
}