Skip to main content

talk_core/
render_model.rs

1use crate::settle::Settle;
2
3/// Which mode's chrome to show.
4#[derive(Clone, Copy, PartialEq, Eq)]
5pub enum Mode { Reflect, Journal, Ephemeral }
6
7/// The tone a line paints in: Settled = bright core text, Edge = dim live edge,
8/// Chrome = the dimmest border/hint/status tone.
9#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum LineKind { Chrome, Settled, Edge, Question }
11
12/// Everything the screen needs, with no I/O. The live session rebuilds this each
13/// frame from the Settle machine + clock + listening flag.
14pub struct View<'a> {
15    pub mode: Mode,
16    pub question: Option<&'a str>, // reflect only
17    pub held_label: Option<&'a str>, // e.g. "held 3 days"; None hides the box line
18    pub settle: &'a Settle,
19    pub listening: bool,           // streaming-partial activity latch (decays in silence)
20    pub elapsed: &'a str,          // "2:14"
21    pub cleanup: &'a str,          // "Light"
22    pub show_raw: bool,            // `u` toggle: show raw verbatim instead of clean
23    pub paused: bool,              // `p` toggle: timer frozen, source not drained
24    pub confirm_cancel: bool,      // esc pressed once: showing the discard prompt
25}
26
27/// Compose the full screen as (line, tone) pairs (top to bottom). Pure — unit-testable.
28pub fn compose(v: &View) -> Vec<(String, LineKind)> {
29    let mut out: Vec<(String, LineKind)> = Vec::new();
30    out.push((header_line(v), LineKind::Chrome));
31    out.push((String::new(), LineKind::Chrome));
32
33    if let (Mode::Reflect, Some(q)) = (v.mode, v.question) {
34        if let Some(h) = v.held_label {
35            out.push((format!("┌─ {} ", h) + &"─".repeat(60), LineKind::Chrome));
36        } else {
37            out.push(("┌".to_string() + &"─".repeat(64), LineKind::Chrome));
38        }
39        out.push((format!("│  {}", q), LineKind::Question));
40        out.push(("└".to_string() + &"─".repeat(64), LineKind::Chrome));
41        out.push((String::new(), LineKind::Chrome));
42    }
43    if v.mode == Mode::Ephemeral {
44        out.push(("Say it. This keeps nothing.".to_string(), LineKind::Chrome));
45        out.push((String::new(), LineKind::Chrome));
46    }
47
48    // Settled blocks (bright/locked), then the committing block, then the edge.
49    let mut body = 0usize;
50    for b in v.settle.settled() {
51        out.push((if v.show_raw { b.raw.clone() } else { b.clean.clone() }, LineKind::Settled));
52        body += 1;
53    }
54    if let Some(c) = v.settle.committing() {
55        // Bright = pass-2-final: the committing block stays DIM (Edge) until it is
56        // revised/upgraded, then brightens to Settled. Settled blocks never move.
57        let kind = if v.settle.committing_revised() { LineKind::Settled } else { LineKind::Edge };
58        out.push((if v.show_raw { c.raw.clone() } else { c.clean.clone() }, kind));
59        body += 1;
60    }
61    // Empty body, not listening: a single dim placeholder keeps the region deterministic.
62    if body == 0 && !v.listening {
63        out.push(("  …".to_string(), LineKind::Edge));
64    }
65    out.push((String::new(), LineKind::Chrome));
66    out.push((edge_line(v), LineKind::Edge));
67    out.push(("─".repeat(66), LineKind::Chrome));
68    out.push((status_line(v), LineKind::Chrome));
69    out
70}
71
72fn header_line(v: &View) -> String {
73    let label = match v.mode {
74        Mode::Reflect => "talk · reflect",
75        Mode::Journal => "talk · journal",
76        Mode::Ephemeral => "talk · unburden",
77    };
78    let privacy = if v.mode == Mode::Ephemeral {
79        "● local · no network · ✦ nothing saved"
80    } else {
81        "● local · no network"
82    };
83    format!("{}{}{}", label, " ".repeat(privacy_gap(label, privacy)), privacy)
84}
85
86fn privacy_gap(label: &str, privacy: &str) -> usize {
87    66usize.saturating_sub(label.chars().count() + privacy.chars().count()).max(2)
88}
89
90/// The edge line never exceeds the 66-column frame the rest of the chrome draws
91/// ("  " prefix + content), so it cannot wrap-and-bounce on terminals narrower
92/// than the partial — the one-line contract holds in rendered rows, not just
93/// character count.
94const EDGE_MAX_CHARS: usize = 64;
95const EDGE_TAIL_CHARS: usize = 63; // EDGE_MAX_CHARS minus the '…' marker
96
97fn edge_line(v: &View) -> String {
98    // The live edge: the streaming partial (dim, jittering) — held to ONE line so
99    // the layout never bounces; long partials show their tail. Else a calm dot.
100    let live = v.settle.live();
101    if !live.is_empty() {
102        let chars: Vec<char> = live.chars().collect();
103        if chars.len() > EDGE_MAX_CHARS {
104            let tail: String = chars[chars.len() - EDGE_TAIL_CHARS..].iter().collect();
105            format!("  …{tail}")
106        } else {
107            format!("  {live}")
108        }
109    } else if v.listening {
110        "  …".to_string()
111    } else {
112        String::new()
113    }
114}
115
116fn status_line(v: &View) -> String {
117    if v.confirm_cancel {
118        return "discard this reflection? [y] yes · [n] keep going".to_string();
119    }
120    if v.paused {
121        return format!("⏸ paused  {}   {}    [p] resume · [space] done · esc cancel", v.elapsed, v.cleanup);
122    }
123    let dot = if v.listening { "● listening" } else { "○ ready" };
124    match v.mode {
125        Mode::Ephemeral => format!("{}  {}   ✦ ephemeral    [space] release · esc cancel", dot, v.elapsed),
126        _ => format!(
127            "{}  {}   {}    [space] done · u raw⇄clean · p pause · esc cancel",
128            dot, v.elapsed, v.cleanup
129        ),
130    }
131}
132
133/// The closing screen after `[space]` in reflect/journal.
134pub fn compose_close(path: &str, provenance: &str, phrase: &str) -> Vec<String> {
135    vec![
136        format!("  → {}     {}", path, provenance),
137        format!("  \"{}\"", phrase),
138    ]
139}
140
141/// The ephemeral release screen.
142pub fn compose_released() -> Vec<String> {
143    vec!["Released. Nothing was written.".to_string()]
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::settle::Settle;
150
151    /// Join just the line text (drops the LineKind) for `.contains` asserts.
152    fn text(v: &View) -> String {
153        compose(v).iter().map(|(s, _)| s.clone()).collect::<Vec<_>>().join("\n")
154    }
155
156    fn settled_one() -> Settle {
157        let mut s = Settle::new();
158        s.commit("um the raw words", "The clean words.");
159        s.finalize();
160        s
161    }
162
163    fn base<'a>(mode: Mode, settle: &'a Settle) -> View<'a> {
164        View {
165            mode, question: None, held_label: None, settle, listening: false,
166            elapsed: "0:01", cleanup: "Light", show_raw: false,
167            paused: false, confirm_cancel: false,
168        }
169    }
170
171    #[test]
172    fn reflect_shows_question_box_and_settled_text() {
173        let s = settled_one();
174        let mut v = base(Mode::Reflect, &s);
175        v.question = Some("What am I avoiding?");
176        v.held_label = Some("held 3 days");
177        v.elapsed = "2:14";
178        let joined = text(&v);
179        assert!(joined.contains("talk · reflect") && joined.contains("● local · no network"));
180        assert!(joined.contains("What am I avoiding?"));
181        assert!(joined.contains("held 3 days"));
182        assert!(joined.contains("The clean words."));
183        assert!(joined.contains("[space] done"));
184    }
185
186    #[test]
187    fn raw_toggle_shows_verbatim() {
188        let s = settled_one();
189        let mut v = base(Mode::Reflect, &s);
190        v.question = Some("Q?");
191        v.show_raw = true;
192        let joined = text(&v);
193        assert!(joined.contains("um the raw words"));
194        assert!(!joined.contains("The clean words."));
195    }
196
197    #[test]
198    fn ephemeral_shows_keeps_nothing_chrome() {
199        let s = Settle::new();
200        let mut v = base(Mode::Ephemeral, &s);
201        v.listening = true;
202        v.elapsed = "0:48";
203        let joined = text(&v);
204        assert!(joined.contains("✦ nothing saved"));
205        assert!(joined.contains("Say it. This keeps nothing."));
206        assert!(joined.contains("[space] release"));
207    }
208
209    #[test]
210    fn listening_flag_drives_the_indicator() {
211        let s = Settle::new();
212        let mk = |listening| {
213            let mut v = base(Mode::Journal, &s);
214            v.listening = listening;
215            v.cleanup = "Medium";
216            text(&v)
217        };
218        assert!(mk(true).contains("● listening"));
219        assert!(mk(false).contains("○ ready"));
220    }
221
222    #[test]
223    fn empty_state_renders_edge_and_status_without_panicking() {
224        let s = Settle::new();
225        let v = base(Mode::Reflect, &s); // settled+committing empty, not listening
226        let lines = compose(&v);
227        let joined = lines.iter().map(|(t, _)| t.clone()).collect::<Vec<_>>().join("\n");
228        assert!(joined.contains("talk · reflect")); // chrome present
229        assert!(joined.contains("○ ready"));        // status present
230    }
231
232    #[test]
233    fn paused_status_renders_paused_marker() {
234        let s = Settle::new();
235        let mut v = base(Mode::Reflect, &s);
236        v.paused = true;
237        assert!(text(&v).contains("⏸ paused"));
238    }
239
240    #[test]
241    fn confirm_cancel_renders_discard_prompt() {
242        let s = Settle::new();
243        let mut v = base(Mode::Reflect, &s);
244        v.confirm_cancel = true;
245        assert!(text(&v).contains("discard this reflection?"));
246    }
247
248    #[test]
249    fn live_partial_renders_at_the_edge() {
250        let mut s = Settle::new();
251        s.on_partial("the thing i keep");
252        let v = base(Mode::Reflect, &s);
253        let joined = text(&v);
254        assert!(joined.contains("the thing i keep"));
255    }
256
257    #[test]
258    fn empty_partial_falls_back_to_the_listening_dot() {
259        let s = Settle::new();
260        let mut v = base(Mode::Reflect, &s);
261        v.listening = true;
262        assert!(compose(&v).iter().any(|(l, k)| l.contains('…') && *k == LineKind::Edge));
263    }
264
265    #[test]
266    fn long_partial_renders_one_truncated_tail_line() {
267        let mut s = Settle::new();
268        let long = "x".repeat(200);
269        s.on_partial(&long);
270        let v = base(Mode::Reflect, &s);
271        let edge = compose(&v)
272            .into_iter()
273            .find(|(l, k)| *k == LineKind::Edge && l.contains('x'))
274            .map(|(l, _)| l)
275            .expect("edge line with partial");
276        assert!(edge.contains('…'));
277        assert!(edge.ends_with(&"x".repeat(63)));
278        assert_eq!(edge.chars().filter(|c| *c == 'x').count(), 63);
279        assert!(!edge.contains('\n'));
280        // The whole line fits the 66-column frame — it can't wrap-and-bounce.
281        assert!(edge.chars().count() <= 66, "edge line wider than the frame");
282    }
283
284    #[test]
285    fn multibyte_partial_truncates_on_char_boundaries() {
286        let mut s = Settle::new();
287        let long = "é".repeat(100) + "末尾";
288        s.on_partial(&long);
289        let v = base(Mode::Reflect, &s);
290        let edge = compose(&v)
291            .into_iter()
292            .find(|(l, k)| *k == LineKind::Edge && l.contains("末尾"))
293            .map(|(l, _)| l)
294            .expect("edge line with truncated partial");
295        assert!(edge.starts_with("  …"), "long multibyte partial must show a truncated tail");
296        assert!(edge.ends_with("末尾"), "tail must keep the newest characters");
297        assert!(edge.chars().count() <= 66);
298    }
299
300    #[test]
301    fn raw_toggle_works_on_the_unrevised_committing_block() {
302        // Before pass-2 lands, raw holds the lowercased streaming text — the `u`
303        // toggle must surface it on the committing (still dim) block too.
304        let mut s = Settle::new();
305        s.commit("loud streaming text", "Clean text.");
306        let mut v = base(Mode::Journal, &s);
307        v.show_raw = true;
308        let joined = text(&v);
309        assert!(joined.contains("loud streaming text"));
310        assert!(!joined.contains("Clean text."));
311    }
312
313    #[test]
314    fn clearing_the_partial_drops_the_stale_edge_text() {
315        // Pause clears the live edge (live.rs calls `settle.on_partial("")` on
316        // entering pause): the stale partial must vanish so a paused frame doesn't
317        // keep advertising in-flight, now off-record, speech.
318        let mut s = Settle::new();
319        s.on_partial("the thing i was mid saying");
320        let mut v = base(Mode::Reflect, &s);
321        assert!(text(&v).contains("the thing i was mid saying"));
322
323        s.on_partial("");
324        v = base(Mode::Reflect, &s);
325        v.paused = true;
326        let joined = text(&v);
327        assert!(!joined.contains("the thing i was mid saying"));
328        assert!(joined.contains("⏸ paused"));
329    }
330
331    #[test]
332    fn committing_block_dims_until_revised() {
333        let mut s = Settle::new();
334        s.commit("raw words", "Clean words.");
335        let v = base(Mode::Journal, &s);
336        let committing_kind = compose(&v)
337            .into_iter()
338            .find(|(l, _)| l.contains("Clean words."))
339            .map(|(_, k)| k)
340            .expect("committing line present");
341        assert_eq!(committing_kind, LineKind::Edge);
342
343        s.revise_committing("better raw", "Better clean.");
344        let v = base(Mode::Journal, &s);
345        let revised_kind = compose(&v)
346            .into_iter()
347            .find(|(l, _)| l.contains("Better clean."))
348            .map(|(_, k)| k)
349            .expect("revised committing line present");
350        assert_eq!(revised_kind, LineKind::Settled);
351    }
352
353    #[test]
354    fn close_frame_shows_path_and_phrase() {
355        let lines = compose_close("~/talk/what-am-i-avoiding.md", "entry 3 · held 3 days", "Stillness carries forward.");
356        let joined = lines.join("\n");
357        assert!(joined.contains("→ ~/talk/what-am-i-avoiding.md"));
358        assert!(joined.contains("entry 3 · held 3 days"));
359        assert!(joined.contains("Stillness carries forward."));
360    }
361
362    #[test]
363    fn released_frame_is_the_keeps_nothing_line() {
364        assert_eq!(compose_released(), vec!["Released. Nothing was written.".to_string()]);
365    }
366
367    #[test]
368    fn question_line_is_its_own_kind_not_chrome() {
369        let s = Settle::new();
370        let mut v = base(Mode::Reflect, &s);
371        v.question = Some("What am I avoiding?");
372        let kind = compose(&v)
373            .into_iter()
374            .find(|(l, _)| l.contains("What am I avoiding?"))
375            .map(|(_, k)| k)
376            .expect("question line present");
377        assert_eq!(kind, LineKind::Question);
378    }
379}