1use crate::settle::Settle;
2
3#[derive(Clone, Copy, PartialEq, Eq)]
5pub enum Mode { Reflect, Journal, Ephemeral }
6
7#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum LineKind { Chrome, Settled, Edge, Question }
11
12pub struct View<'a> {
15 pub mode: Mode,
16 pub question: Option<&'a str>, pub held_label: Option<&'a str>, pub settle: &'a Settle,
19 pub listening: bool, pub elapsed: &'a str, pub cleanup: &'a str, pub show_raw: bool, pub paused: bool, pub confirm_cancel: bool, }
26
27pub 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 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 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 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
90const EDGE_MAX_CHARS: usize = 64;
95const EDGE_TAIL_CHARS: usize = 63; fn edge_line(v: &View) -> String {
98 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
133pub fn compose_close(path: &str, provenance: &str, phrase: &str) -> Vec<String> {
135 vec![
136 format!(" → {} {}", path, provenance),
137 format!(" \"{}\"", phrase),
138 ]
139}
140
141pub 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 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); let lines = compose(&v);
227 let joined = lines.iter().map(|(t, _)| t.clone()).collect::<Vec<_>>().join("\n");
228 assert!(joined.contains("talk · reflect")); assert!(joined.contains("○ ready")); }
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 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 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 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}