lattice_multibuffer/providers/clock_report.rs
1//! OA.16 — the clock report: clocked time, rolled up an outline.
2//!
3//! Design: `docs/dev/architecture/org-agenda.md` §3. Slice plan:
4//! [`org-agenda.md`](../../../../docs/dev/operations/slice-plans/org-agenda.md).
5//!
6//! Emacs calls this a clocktable, and `org-agenda-clockreport-mode` puts one at
7//! the top of the agenda. It answers "where did the week go", which is a
8//! different question from "what should I do next" — and that is why it is a
9//! REPORT over the scan's clock spans rather than a view of the agenda's rows.
10//!
11//! ## Totals roll UP, and that is the whole shape
12//!
13//! A span is filed against the headline it was clocked on. A report that listed
14//! only those would answer "which leaf did I clock" and never "how long did the
15//! project take", which is the question anyone opens a clocktable for. So every
16//! span contributes to each of its ancestors as well as to itself.
17//!
18//! [`ClockSpan::outline`] carries the whole chain precisely because of this: an
19//! ancestor that logged no time of its own emits no span at all, so the chain
20//! is the only way to name it. Rebuilding the tree from leaf names plus levels
21//! would invent a parent for every orphan and mis-nest two projects that happen
22//! to share a child name.
23//!
24//! ## Own time versus total time
25//!
26//! Both are kept. `total` is what rolls up; `own` is what was clocked on that
27//! headline itself. A tree showing only totals cannot distinguish a project
28//! whose time is all in its children from one where half was spent on the
29//! parent, and that difference is usually the interesting part of the report.
30//!
31//! ## The range is a display choice, not a scan
32//!
33//! Spans are held per view unfiltered (see `ScanViewState::clock`), so
34//! switching the report between a day, a week and a year filters data already
35//! in hand. Re-walking the corpus to answer a question the data contains would
36//! make a toggle cost a scan.
37
38use lattice_mode::ClockSpan;
39
40/// One line of the report: a headline, its depth, and its two totals.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ReportRow {
43 /// Outline depth, 0 for a top-level headline. Drives the indent.
44 pub depth: usize,
45 pub title: String,
46 /// Minutes clocked on this headline and everything under it.
47 pub total: u32,
48 /// Minutes clocked on this headline itself.
49 pub own: u32,
50}
51
52/// A whole report: its rows in outline order, and the grand total.
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct ClockReport {
55 pub rows: Vec<ReportRow>,
56 pub total: u32,
57}
58
59impl ClockReport {
60 pub fn is_empty(&self) -> bool {
61 self.rows.is_empty()
62 }
63}
64
65/// Build a report from `spans`, keeping only those filed in `days`.
66///
67/// `days` is an inclusive epoch-day range. An empty range or no matching span
68/// yields an empty report, which the caller renders as "no clocked time" rather
69/// than as an empty table — a table with no rows and a `0:00` total reads as a
70/// broken report, where a sentence reads as an answer.
71///
72/// Rows come out in **outline order**: a parent immediately before its
73/// children, siblings in first-seen order. Not sorted by time, deliberately —
74/// the report is a picture of the outline, and re-ordering it by duration
75/// breaks the one structural cue that says which rows belong to which project.
76pub fn build(spans: &[ClockSpan], days: std::ops::RangeInclusive<i64>) -> ClockReport {
77 // Insertion-ordered accumulation keyed by the outline PREFIX, so a path is
78 // the identity of a node and two projects sharing a child name never merge.
79 let mut order: Vec<Vec<String>> = Vec::new();
80 let mut totals: std::collections::HashMap<Vec<String>, (u32, u32)> =
81 std::collections::HashMap::new();
82
83 for span in spans.iter().filter(|s| days.contains(&s.day)) {
84 for depth in 0..span.outline.len() {
85 let prefix: Vec<String> = span.outline[..=depth].to_vec();
86 let entry = totals.entry(prefix.clone()).or_insert_with(|| {
87 order.push(prefix.clone());
88 (0, 0)
89 });
90 // Every ancestor takes the span's minutes as TOTAL; only the
91 // headline it was clocked on takes them as OWN.
92 entry.0 = entry.0.saturating_add(span.minutes);
93 if depth + 1 == span.outline.len() {
94 entry.1 = entry.1.saturating_add(span.minutes);
95 }
96 }
97 }
98
99 // Outline order: sort the collected paths lexicographically by their
100 // first-seen index at each level, which is what `order` already records —
101 // a stable sort on the path itself would alphabetise siblings and lose the
102 // file's own order.
103 let index: std::collections::HashMap<&Vec<String>, usize> =
104 order.iter().enumerate().map(|(i, p)| (p, i)).collect();
105 let mut paths = order.clone();
106 paths.sort_by_key(|p| {
107 // A node sorts by the first-seen index of each of its ancestors in
108 // turn, so a child always follows its parent and siblings keep the
109 // order the scan met them in.
110 (0..p.len())
111 .map(|d| index.get(&p[..=d].to_vec()).copied().unwrap_or(usize::MAX))
112 .collect::<Vec<_>>()
113 });
114
115 let rows: Vec<ReportRow> = paths
116 .iter()
117 .filter_map(|p| {
118 let (total, own) = totals.get(p)?;
119 Some(ReportRow {
120 depth: p.len() - 1,
121 title: p.last().cloned().unwrap_or_default(),
122 total: *total,
123 own: *own,
124 })
125 })
126 .collect();
127
128 // The grand total is the sum of the TOP-LEVEL rows, not of every row:
129 // summing all of them would count a child's minutes once for itself and
130 // again in each ancestor.
131 let total = rows
132 .iter()
133 .filter(|r| r.depth == 0)
134 .fold(0u32, |acc, r| acc.saturating_add(r.total));
135
136 ClockReport { rows, total }
137}
138
139/// `H:MM`, org's own clocktable spelling.
140///
141/// Not `1.5h`: org writes `1:30`, every clocktable a user has seen writes
142/// `1:30`, and a report that agreed with the file's `=> 1:30` lines in every
143/// place but its own summary would read as a rounding bug.
144pub fn format_minutes(minutes: u32) -> String {
145 format!("{}:{:02}", minutes / 60, minutes % 60)
146}
147
148#[cfg(test)]
149mod tests {
150 #![allow(clippy::unwrap_used, clippy::panic)]
151 use super::*;
152
153 fn span(outline: &[&str], day: i64, minutes: u32) -> ClockSpan {
154 ClockSpan {
155 line: 0,
156 outline: outline.iter().map(|s| s.to_string()).collect(),
157 day,
158 minutes,
159 }
160 }
161
162 #[test]
163 fn no_spans_is_an_empty_report() {
164 assert!(build(&[], 0..=10).is_empty());
165 }
166
167 /// The range filters. A report titled "this week" that quietly included
168 /// last week's time would be wrong in the direction nobody checks.
169 #[test]
170 fn only_spans_in_range_count() {
171 let spans = [span(&["A"], 5, 60), span(&["A"], 99, 30)];
172 let r = build(&spans, 0..=10);
173 assert_eq!(r.total, 60);
174 assert_eq!(r.rows.len(), 1);
175 }
176
177 /// The whole shape: time clocked on a child counts for every ancestor.
178 /// Without this the report answers "which leaf did I clock" and never
179 /// "how long did the project take".
180 #[test]
181 fn totals_roll_up_the_outline() {
182 let spans = [span(&["Project", "Task"], 1, 90)];
183 let r = build(&spans, 0..=10);
184 assert_eq!(
185 r.rows,
186 vec![
187 ReportRow {
188 depth: 0,
189 title: "Project".into(),
190 total: 90,
191 own: 0,
192 },
193 ReportRow {
194 depth: 1,
195 title: "Task".into(),
196 total: 90,
197 own: 90,
198 },
199 ]
200 );
201 assert_eq!(r.total, 90);
202 }
203
204 /// `own` is what distinguishes a project whose time is all in its children
205 /// from one where half was spent on the parent — usually the interesting
206 /// part of the report.
207 #[test]
208 fn own_time_is_kept_beside_the_total() {
209 let spans = [span(&["Project"], 1, 30), span(&["Project", "Task"], 1, 60)];
210 let r = build(&spans, 0..=10);
211 assert_eq!(r.rows[0].total, 90, "the parent totals both");
212 assert_eq!(r.rows[0].own, 30, "…and owns only its own");
213 assert_eq!(r.total, 90, "the grand total counts the time ONCE");
214 }
215
216 /// An ancestor that logged no time of its own still appears — it emits no
217 /// span, so the outline chain is the only thing that can name it.
218 #[test]
219 fn a_parent_that_clocked_nothing_is_still_named() {
220 let spans = [span(&["Silent", "Loud"], 1, 15)];
221 let r = build(&spans, 0..=10);
222 assert_eq!(r.rows[0].title, "Silent");
223 assert_eq!(r.rows[0].own, 0);
224 }
225
226 /// Two projects with a same-named child must not merge. The path is the
227 /// identity; a name plus a level is not.
228 #[test]
229 fn same_named_children_under_different_parents_stay_apart() {
230 let spans = [span(&["A", "Notes"], 1, 10), span(&["B", "Notes"], 1, 20)];
231 let r = build(&spans, 0..=10);
232 let notes: Vec<&ReportRow> = r.rows.iter().filter(|x| x.title == "Notes").collect();
233 assert_eq!(notes.len(), 2, "two distinct rows: {:?}", r.rows);
234 assert_eq!(r.total, 30);
235 }
236
237 /// A child follows its parent, and siblings keep the order the scan met
238 /// them in — the report is a picture of the outline, and sorting by
239 /// duration would break the cue that says which rows belong together.
240 #[test]
241 fn rows_come_out_in_outline_order() {
242 let spans = [
243 span(&["A", "Second"], 1, 5),
244 span(&["A", "First"], 1, 500),
245 span(&["B"], 1, 1),
246 ];
247 let r = build(&spans, 0..=10);
248 let shape: Vec<(usize, &str)> =
249 r.rows.iter().map(|x| (x.depth, x.title.as_str())).collect();
250 assert_eq!(
251 shape,
252 vec![(0, "A"), (1, "Second"), (1, "First"), (0, "B")],
253 "parent first, siblings in first-seen order — NOT by duration"
254 );
255 }
256
257 /// Several spans on one headline across the range add up.
258 #[test]
259 fn repeated_spans_on_one_headline_sum() {
260 let spans = [span(&["A"], 1, 20), span(&["A"], 2, 25)];
261 assert_eq!(build(&spans, 0..=10).total, 45);
262 }
263
264 /// org's own spelling. A report that disagreed with the `=> 1:30` lines in
265 /// the file would read as a rounding bug.
266 #[test]
267 fn minutes_format_the_way_org_writes_them() {
268 assert_eq!(format_minutes(0), "0:00");
269 assert_eq!(format_minutes(5), "0:05");
270 assert_eq!(format_minutes(60), "1:00");
271 assert_eq!(format_minutes(90), "1:30");
272 assert_eq!(format_minutes(605), "10:05");
273 }
274}
275
276// ── The virtual-row provider ────────────────────────────────────────────────
277
278use lattice_cells::Cell;
279use lattice_cells::virtual_rows::{AnchorPosition, VirtualRow, VirtualRowKind, VirtualRowProvider};
280use lattice_core::BufferId;
281use lattice_theme::{
282 ColorRef, ElementId, ElementName, ElementOwner, StyleSpec, ThemeRegistryHandle,
283};
284use std::sync::{Arc, RwLock};
285
286/// Element name: the clock report's own rows.
287pub const ELEM_CLOCKREPORT: &str = "scan-view.clockreport";
288/// Element name: the report's total line, which is the row people look at.
289pub const ELEM_CLOCKREPORT_TOTAL: &str = "scan-view.clockreport.total";
290
291/// The interned ids the report's rows paint with, captured once when the mode
292/// activates so `collect()` is an array-index resolve rather than a name lookup
293/// per row.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub struct ClockReportElementIds {
296 pub row: ElementId,
297 pub total: ElementId,
298}
299
300/// Register the report's two theme elements and return their interned ids.
301///
302/// Owned by the mode, not by core: nothing outside this report paints these,
303/// and the host has no business naming an element only the clock report uses.
304/// Idempotent by name, so a re-activation re-interns rather than duplicating.
305///
306/// `subtext` and `text` rather than hex: a hardcoded grey survives
307/// `:colorscheme` and then sits at the top of the view in the *previous*
308/// theme's palette, which is the one place a stale colour is impossible to
309/// miss. The total takes the brighter role because it is the row people
310/// actually read; the tree takes the muted one because it is context.
311pub fn register_clock_report_theme_elements(
312 reg: &dyn lattice_theme::ThemeRegistry,
313 owner: ElementOwner,
314) -> ClockReportElementIds {
315 let row = reg.register(
316 ElementName::from(ELEM_CLOCKREPORT.to_string()),
317 owner.clone(),
318 StyleSpec::new().fg(ColorRef::Palette("subtext".into())),
319 "Clock report tree row foreground.",
320 );
321 let total = reg.register(
322 ElementName::from(ELEM_CLOCKREPORT_TOTAL.to_string()),
323 owner,
324 StyleSpec::new().fg(ColorRef::Palette("text".into())),
325 "Clock report grand-total row foreground.",
326 );
327 ClockReportElementIds { row, total }
328}
329
330/// The `ProviderId` a view's clock report registers under.
331///
332/// Derived from the view's `BufferId` exactly as the multibuffer's own two
333/// providers are, so two open scan views each get their own report and
334/// re-activating a mode replaces its rows rather than doubling them
335/// (`register_virtual_row_provider` dedups by id — the OA.14 spike's finding).
336pub fn clock_report_provider_id(view: BufferId) -> u64 {
337 // A distinct salt from the excerpt-header / status providers so the three
338 // never collide on one view.
339 u64::from(view.0).wrapping_mul(4).wrapping_add(3)
340}
341
342/// Renders a [`ClockReport`] as virtual rows above line 0 of a scan view.
343///
344/// Display-only, and that is the design (org-agenda.md §3): a clock report has
345/// no source range and nothing to jump to, which is exactly what `VirtualRow`
346/// models. The cost — you cannot put the cursor on a report line — is stated in
347/// the design rather than discovered here; making those lines actionable means
348/// excerpts over a synthetic pathless source, which is deferred.
349pub struct ClockReportProvider {
350 view: BufferId,
351 state: Arc<RwLock<crate::providers::scan_view::ScanViewState>>,
352 /// The inclusive epoch-day range the report covers, recomputed by the mode
353 /// when the view's span changes.
354 days: RwLock<std::ops::RangeInclusive<i64>>,
355 /// `None` on the test paths that wire no theme registry; the rows then
356 /// carry the `0` "use the renderer's default" sentinel.
357 theme: Option<ThemeRegistryHandle>,
358 elements: Option<ClockReportElementIds>,
359 /// Bumped whenever the range changes, so the worker re-runs `collect`
360 /// without waiting for the document's line count to move.
361 version: std::sync::atomic::AtomicU64,
362}
363
364impl std::fmt::Debug for ClockReportProvider {
365 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366 f.debug_struct("ClockReportProvider")
367 .field("view", &self.view)
368 .finish()
369 }
370}
371
372impl ClockReportProvider {
373 /// Unthemed — the rows paint with the renderer's default foreground.
374 /// Test convenience; production goes through [`Self::with_theme`].
375 pub fn new(
376 view: BufferId,
377 state: Arc<RwLock<crate::providers::scan_view::ScanViewState>>,
378 days: std::ops::RangeInclusive<i64>,
379 ) -> Self {
380 Self {
381 view,
382 state,
383 days: RwLock::new(days),
384 theme: None,
385 elements: None,
386 version: std::sync::atomic::AtomicU64::new(1),
387 }
388 }
389
390 /// The production constructor: colours resolve from the registry on every
391 /// `collect()`, and the resolved theme's version rides in `version()`, so a
392 /// `:colorscheme` swap re-paints the report instead of leaving it in the
393 /// previous palette.
394 pub fn with_theme(
395 view: BufferId,
396 state: Arc<RwLock<crate::providers::scan_view::ScanViewState>>,
397 days: std::ops::RangeInclusive<i64>,
398 theme: ThemeRegistryHandle,
399 elements: ClockReportElementIds,
400 ) -> Self {
401 Self {
402 view,
403 state,
404 days: RwLock::new(days),
405 theme: Some(theme),
406 elements: Some(elements),
407 version: std::sync::atomic::AtomicU64::new(1),
408 }
409 }
410
411 /// Re-point the report at a different range — what `gD`'s day/week/month
412 /// toggles change. Bumps the version so the rows rebuild.
413 pub fn set_days(&self, days: std::ops::RangeInclusive<i64>) {
414 if let Ok(mut d) = self.days.write() {
415 *d = days;
416 }
417 self.version
418 .fetch_add(1, std::sync::atomic::Ordering::Release);
419 }
420
421 /// The report as lines of text, which is what both the renderer and the
422 /// tests want — splitting this out keeps the row-building testable without
423 /// a theme or a worker.
424 pub fn lines(&self) -> Vec<String> {
425 let Ok(state) = self.state.read() else {
426 return Vec::new();
427 };
428 let days = self
429 .days
430 .read()
431 .map(|d| d.clone())
432 .unwrap_or_else(|_| 0..=0);
433 let report = build(&state.clock, days);
434 if report.is_empty() {
435 // A sentence, not an empty table. A table with no rows and a `0:00`
436 // total reads as a broken report; "no clocked time" reads as an
437 // answer, and it is the answer more often than not.
438 return vec![" No clocked time in range".to_string()];
439 }
440 let mut out = Vec::with_capacity(report.rows.len() + 1);
441 out.push(format!(" Clock total {}", format_minutes(report.total)));
442 for row in &report.rows {
443 // Two columns: total, then own in parentheses when it differs —
444 // showing `(0:00)` on every leaf-less parent would be noise, and
445 // showing own == total on a leaf says nothing.
446 let own = if row.own != row.total && row.own > 0 {
447 format!(" ({})", format_minutes(row.own))
448 } else {
449 String::new()
450 };
451 out.push(format!(
452 " {:indent$}{} {}{}",
453 "",
454 row.title,
455 format_minutes(row.total),
456 own,
457 indent = row.depth * 2,
458 ));
459 }
460 out
461 }
462}
463
464impl VirtualRowProvider for ClockReportProvider {
465 fn id(&self) -> u64 {
466 clock_report_provider_id(self.view)
467 }
468
469 fn version(&self) -> u64 {
470 // The scan's own generation folded in, so a completed re-scan rebuilds
471 // the report — otherwise `gr` would leave last scan's totals on screen
472 // under this scan's rows, which is the class of staleness a report can
473 // least afford.
474 let scan = self.state.read().map(|s| s.clock.len() as u64).unwrap_or(0);
475 // …and the resolved theme's, so `:colorscheme` re-paints the report.
476 // Without this term the rows keep the palette they were baked in, at
477 // the top of the view, where a stale colour is impossible to miss.
478 let theme = self
479 .theme
480 .as_ref()
481 .map(|t| t.resolved().version())
482 .unwrap_or(0);
483 self.version
484 .load(std::sync::atomic::Ordering::Acquire)
485 .wrapping_add(scan)
486 .wrapping_add(theme)
487 }
488
489 fn collect(&self) -> Vec<VirtualRow> {
490 // Resolved ONCE per collect (off the UI thread) and baked into the
491 // rows. `0` is the Cell "use the renderer's default" sentinel, which
492 // is what an unthemed harness and an unresolved element both get.
493 let resolved = self.theme.as_ref().map(|t| t.resolved());
494 let fg_for = |id: Option<ElementId>| -> u32 {
495 id.and_then(|id| resolved.as_ref().and_then(|r| r.get(id).fg))
496 .map(|c| c.to_rgb_u32(0))
497 .unwrap_or(0)
498 };
499 let row_fg = fg_for(self.elements.map(|e| e.row));
500 let total_fg = fg_for(self.elements.map(|e| e.total));
501 self.lines()
502 .into_iter()
503 .enumerate()
504 .map(|(i, text)| {
505 let fg = if i == 0 { total_fg } else { row_fg };
506 let cells: Vec<Cell> = text
507 .chars()
508 .map(|c| Cell::new(c as u32, fg, 0, 0))
509 .collect();
510 VirtualRow {
511 anchor_line: 0,
512 // Above line 0, so the report sits at the top of the view
513 // the way emacs' clocktable does.
514 position: AnchorPosition::Above,
515 cells: Arc::from(cells),
516 height: 1,
517 // `Annotation`: content that scrolls with its anchor and
518 // paints no backdrop. `Generic` carries the diff
519 // deletion-block backdrop, which on a report would read as
520 // removed lines.
521 kind: VirtualRowKind::Annotation,
522 bg: None,
523 scales: None,
524 media: None,
525 gutter_line: None,
526 gutter_fg: None,
527 }
528 })
529 .collect()
530 }
531}
532
533#[cfg(test)]
534mod provider_tests {
535 #![allow(clippy::unwrap_used, clippy::panic)]
536 use super::*;
537 use crate::providers::scan_view::ScanViewState;
538
539 fn state_with(spans: Vec<ClockSpan>) -> Arc<RwLock<ScanViewState>> {
540 Arc::new(RwLock::new(ScanViewState {
541 provider: "agenda".to_string(),
542 options: Default::default(),
543 clock: spans,
544 annotations: Vec::new(),
545 annotations_version: 0,
546 }))
547 }
548
549 fn span(outline: &[&str], day: i64, minutes: u32) -> ClockSpan {
550 ClockSpan {
551 line: 0,
552 outline: outline.iter().map(|s| s.to_string()).collect(),
553 day,
554 minutes,
555 }
556 }
557
558 fn provider(spans: Vec<ClockSpan>, days: std::ops::RangeInclusive<i64>) -> ClockReportProvider {
559 ClockReportProvider::new(BufferId(7), state_with(spans), days)
560 }
561
562 /// A sentence, not an empty table. `0:00` under a header reads as a broken
563 /// report; this reads as an answer, and it is the answer more often than
564 /// not.
565 #[test]
566 fn an_empty_range_says_so_rather_than_drawing_an_empty_table() {
567 let p = provider(vec![span(&["A"], 99, 60)], 0..=10);
568 assert_eq!(p.lines(), vec![" No clocked time in range"]);
569 }
570
571 #[test]
572 fn the_total_leads_and_the_tree_follows() {
573 let p = provider(
574 vec![span(&["Project"], 1, 30), span(&["Project", "Task"], 1, 60)],
575 0..=10,
576 );
577 let lines = p.lines();
578 assert_eq!(lines[0], " Clock total 1:30");
579 assert!(lines[1].starts_with(" Project 1:30"), "{lines:?}");
580 assert!(
581 lines[1].contains("(0:30)"),
582 "own time is shown when it differs from the total: {lines:?}"
583 );
584 assert!(
585 lines[2].starts_with(" Task 1:00"),
586 "the child is indented under it: {lines:?}"
587 );
588 assert!(
589 !lines[2].contains('('),
590 "a leaf's own IS its total, so saying it twice is noise: {lines:?}"
591 );
592 }
593
594 /// Re-pointing the range is what `gD`'s day/week toggles do, and the rows
595 /// have to rebuild — a version that did not move would leave last range's
596 /// totals on screen.
597 #[test]
598 fn changing_the_range_bumps_the_version_and_the_rows() {
599 let p = provider(vec![span(&["A"], 5, 60)], 0..=1);
600 let before = p.version();
601 assert_eq!(p.lines(), vec![" No clocked time in range"]);
602 p.set_days(0..=10);
603 assert!(p.version() > before, "the worker must be told to rebuild");
604 assert_eq!(p.lines()[0], " Clock total 1:00");
605 }
606
607 /// Rows sit ABOVE line 0 and paint no backdrop — `Generic` carries the
608 /// diff deletion-block backdrop, which on a report would read as removed
609 /// lines.
610 #[test]
611 fn rows_are_annotations_above_the_first_line() {
612 let p = provider(vec![span(&["A"], 1, 60)], 0..=10);
613 let rows = p.collect();
614 assert!(!rows.is_empty());
615 for row in &rows {
616 assert_eq!(row.anchor_line, 0);
617 assert_eq!(row.position, AnchorPosition::Above);
618 assert_eq!(row.kind, VirtualRowKind::Annotation);
619 }
620 }
621
622 /// Two views get two providers, so opening a second agenda cannot make one
623 /// report replace the other's.
624 #[test]
625 fn each_view_gets_its_own_provider_id() {
626 assert_ne!(
627 clock_report_provider_id(BufferId(1)),
628 clock_report_provider_id(BufferId(2))
629 );
630 }
631
632 fn registry() -> Arc<lattice_theme::InMemoryThemeRegistry> {
633 // The default palette so `subtext` / `text` resolve, and NO core
634 // builtins — which is what proves the mode is the sole registrant of
635 // these two elements.
636 Arc::new(lattice_theme::InMemoryThemeRegistry::new(
637 lattice_theme::default_palette(),
638 ))
639 }
640
641 fn owner() -> ElementOwner {
642 ElementOwner::Mode(
643 ScanViewClockReportMode::mode_id()
644 .as_str()
645 .to_string()
646 .into(),
647 )
648 }
649
650 /// The colours come from the REGISTRY, not from a constant. A hardcoded
651 /// grey survives `:colorscheme` and then sits at the top of the view in
652 /// the previous theme's palette.
653 #[test]
654 fn row_colours_resolve_from_the_theme_registry() {
655 let reg = registry();
656 let ids = register_clock_report_theme_elements(reg.as_ref(), owner());
657 use lattice_theme::ThemeRegistry;
658 let resolved = reg.resolved();
659 let expect = |id: ElementId| {
660 resolved
661 .get(id)
662 .fg
663 .map(|c: lattice_theme::Color| c.to_rgb_u32(0))
664 .expect("the element registers a default fg")
665 };
666 let theme: lattice_theme::ThemeRegistryHandle = reg.clone();
667 let p = ClockReportProvider::with_theme(
668 BufferId(7),
669 state_with(vec![
670 span(&["Project"], 1, 30),
671 span(&["Project", "T"], 1, 60),
672 ]),
673 0..=10,
674 theme,
675 ids,
676 );
677 let rows = p.collect();
678 assert!(rows.len() >= 2, "a total line and at least one tree row");
679 assert_eq!(
680 rows[0].cells[0].fg,
681 expect(ids.total),
682 "the total row paints in the registered total element"
683 );
684 assert_eq!(
685 rows[1].cells[0].fg,
686 expect(ids.row),
687 "the tree rows paint in the registered row element"
688 );
689 assert_ne!(
690 expect(ids.total),
691 expect(ids.row),
692 "the total is the row people read; it must not be the tree's grey"
693 );
694 }
695
696 /// …and a `:colorscheme` swap has to REPAINT them. The worker's
697 /// fingerprint is `(id, version)`, so without the theme term the rows keep
698 /// the palette they were baked in.
699 #[test]
700 fn a_theme_swap_rebuilds_the_rows() {
701 let reg = registry();
702 let ids = register_clock_report_theme_elements(reg.as_ref(), owner());
703 let theme: lattice_theme::ThemeRegistryHandle = reg.clone();
704 let p = ClockReportProvider::with_theme(
705 BufferId(7),
706 state_with(vec![span(&["A"], 1, 60)]),
707 0..=10,
708 theme,
709 ids,
710 );
711 let before = p.version();
712 let before_fg = p.collect()[0].cells[0].fg;
713 let recoloured = lattice_theme::default_palette()
714 .with("text", lattice_theme::Color::Rgb(0x01, 0x02, 0x03));
715 reg.set_palette(recoloured);
716 assert_ne!(
717 p.version(),
718 before,
719 "a palette change must make the worker re-run collect"
720 );
721 assert_eq!(
722 p.collect()[0].cells[0].fg,
723 0x010203,
724 "…and the re-run must paint the NEW palette, not the baked one \
725 (was {before_fg:#08x})"
726 );
727 }
728
729 /// An unthemed harness gets the `0` "renderer's default" sentinel rather
730 /// than a colour invented here.
731 #[test]
732 fn without_a_registry_the_rows_carry_no_baked_colour() {
733 let p = provider(vec![span(&["A"], 1, 60)], 0..=10);
734 for row in p.collect() {
735 assert!(row.cells.iter().all(|c| c.fg == 0));
736 }
737 }
738}
739
740// ── The mode ────────────────────────────────────────────────────────────────
741
742/// The keymap target `cr` fires.
743pub const TOGGLE_ACTION: &str = "action:scan-view-clockreport-toggle";
744
745/// Removes the view's report when the mode deactivates.
746///
747/// The mode owns its full surface, and that cuts both ways: nothing else in
748/// the host knows this provider exists, so nothing else can take it down. A
749/// toggle whose "off" left the rows on screen would be the two-states-that-
750/// disagree failure the mode-as-the-switch shape exists to prevent.
751pub struct ClockReportRegistration {
752 registrar: Arc<dyn lattice_mode::VirtualRowRegistrar>,
753 view: BufferId,
754}
755
756impl Drop for ClockReportRegistration {
757 fn drop(&mut self) {
758 self.registrar
759 .unregister(self.view, clock_report_provider_id(self.view));
760 }
761}
762
763/// OA.16 — `scan-view-clockreport-mode`: the clock report, on a scan view.
764///
765/// Named generically rather than `org-agenda-clockreport-mode`, because
766/// nothing here is org's. The data is [`ClockSpan`], a field of the generic
767/// `ScanResult` any scanned-excerpt-source may report; a source that reports
768/// clock spans gets a clock report, and one that does not gets an empty one.
769/// Calling it `org-…` in the host would be the org-shaped constant MV.3
770/// removed from this module once already.
771///
772/// **The toggle is the MODE, not the provider.** Activating registers the
773/// provider, deactivating unregisters it, so there is one switch rather than
774/// two states that can disagree — and `:describe-mode` answers "is the clock
775/// report on" without anyone having to expose provider state.
776#[derive(Debug, Default)]
777pub struct ScanViewClockReportMode;
778
779impl ScanViewClockReportMode {
780 pub fn mode_id() -> lattice_mode::ModeId {
781 lattice_mode::ModeId::new("scan-view-clockreport-mode")
782 }
783}
784
785impl lattice_mode::Mode for ScanViewClockReportMode {
786 /// `Option`, because the harnesses that wire no registrar activate the
787 /// mode successfully and register nothing — the alternative is failing
788 /// activation over a missing display service, which would make the mode
789 /// unusable in exactly the tests that exercise everything around it.
790 type Guard = Option<ClockReportRegistration>;
791
792 fn id(&self) -> lattice_mode::ModeId {
793 Self::mode_id()
794 }
795
796 fn kind(&self) -> lattice_mode::ModeKind {
797 lattice_mode::ModeKind::Minor
798 }
799
800 /// Manual, for `ScanViewMode`'s reason: no policy can say "the view this
801 /// provider just built", and one keyed on `BufferKind::Multibuffer` would
802 /// attach a clock report to every search result and diff.
803 fn activation_policy(&self) -> lattice_mode::ActivationPolicy {
804 lattice_mode::ActivationPolicy::Manual
805 }
806
807 /// Registers the report's rows for the buffer it was activated on, and
808 /// returns the registration whose drop takes them down again.
809 ///
810 /// `&self` throughout: `VirtualRowRegistrar` is a service with interior
811 /// mutability precisely so a mode can do this from `on_activate`, where
812 /// the `&mut` `ModeActivator` is not reachable.
813 ///
814 /// A view with no scan state yet registers nothing rather than an empty
815 /// report — the mode can be activated before the first scan lands, and a
816 /// report that said "no clocked time" while the scan was still running
817 /// would be answering a question nobody had asked yet.
818 fn on_activate(
819 &self,
820 ctx: lattice_mode::ModeContext,
821 ) -> lattice_mode::LifecycleFuture<'_, Option<ClockReportRegistration>> {
822 Box::pin(async move {
823 // `ModeContext` speaks the protocol's `BufferId`; every registry
824 // here speaks core's. One conversion at the boundary rather than
825 // one per call.
826 let view = lattice_core::BufferId(ctx.buffer_id().0 as u32);
827 let Some(svc) = ctx.service::<crate::providers::scan_view::ScanViewServiceHandle>()
828 else {
829 return Ok(None);
830 };
831 let Some(state) = svc.state(view) else {
832 return Ok(None);
833 };
834 let Some(registrar) = ctx.service::<Arc<dyn lattice_mode::VirtualRowRegistrar>>()
835 else {
836 return Ok(None);
837 };
838 let registrar: Arc<dyn lattice_mode::VirtualRowRegistrar> = (*registrar).clone();
839 // The mode owns its element vocabulary, and registering here is
840 // what makes that true — idempotent by name, so the second
841 // activation re-interns rather than duplicating.
842 let themed = ctx
843 .service::<ThemeRegistryHandle>()
844 .map(|outer| (*outer).clone())
845 .map(|theme| {
846 let owner = ElementOwner::Mode(Self::mode_id().as_str().to_string().into());
847 let ids = register_clock_report_theme_elements(theme.as_ref(), owner);
848 (theme, ids)
849 });
850 // Today by default; `gD` (OA.18) widens it. See `today_range`.
851 let days = today_range();
852 let provider: Arc<dyn VirtualRowProvider> = match themed {
853 Some((theme, ids)) => Arc::new(ClockReportProvider::with_theme(
854 view, state, days, theme, ids,
855 )),
856 None => Arc::new(ClockReportProvider::new(view, state, days)),
857 };
858 // `register` refuses to replace a live id, so clear whatever an
859 // earlier activation on this view left behind.
860 registrar.unregister(view, clock_report_provider_id(view));
861 registrar.register(view, provider);
862 Ok(Some(ClockReportRegistration { registrar, view }))
863 })
864 }
865}
866
867/// Register the `cr` target. Unlike `refreshable-view-mode`'s dead body, this
868/// one *is* the behaviour: the effect carries a mode name and nothing else, so
869/// there is no per-buffer state to read and no handler contribution to forget.
870///
871/// It resolves to the same [`lattice_grammar::Effect::ToggleMode`] the
872/// auto-generated `:scan-view-clockreport-mode` ex-command returns, so the
873/// chord and the command are one switch rather than two paths that can drift.
874pub fn register_clock_report_actions(registry: &mut lattice_grammar::CommandRegistry) {
875 use lattice_grammar::effect::Effect;
876 use lattice_grammar::registry::ActionSpec;
877 registry.register_action(
878 TOGGLE_ACTION,
879 "Toggle the clock report at the top of this view.",
880 ActionSpec {
881 apply: Arc::new(|_| {
882 Ok(Effect::ToggleMode {
883 mode_name: ScanViewClockReportMode::mode_id().as_str().to_string(),
884 })
885 }),
886 args_schema: vec![],
887 },
888 );
889}
890
891/// The inclusive epoch-day range a fresh report covers: today.
892///
893/// **The report's range is its OWN, not the view's.** The host cannot read the
894/// view's window even in principle — the span and offset live in `scan_args`,
895/// which the host carries "as something it can route but not read" (see
896/// `begin`) because they are the guest's vocabulary. That is not a gap here:
897/// `ScanViewState::clock` is documented as holding every span UNFILTERED
898/// precisely so the range stays a display choice, switched between day, week,
899/// month and year by `gD` (OA.18) without re-walking the corpus.
900///
901/// Today, because a day is the range whose answer changes most and the one a
902/// glance is usually asking about. `set_days` widens it.
903fn today_range() -> std::ops::RangeInclusive<i64> {
904 let today = std::time::SystemTime::now()
905 .duration_since(std::time::UNIX_EPOCH)
906 .map(|d| (d.as_secs() / 86_400) as i64)
907 // An unreadable clock yields day 0 rather than a guess: 1970 reports
908 // nothing, which is conspicuously wrong, where a plausible wrong day
909 // would report someone else's hours as today's.
910 .unwrap_or(0);
911 today..=today
912}
913
914#[cfg(test)]
915mod mode_tests {
916 #![allow(clippy::unwrap_used, clippy::panic)]
917 use super::*;
918 use lattice_mode::Mode;
919
920 /// The mode is a MANUAL minor, for `ScanViewMode`'s reason: no policy can
921 /// say "the view this provider just built", and one keyed on
922 /// `BufferKind::Multibuffer` would put a clock report on every search
923 /// result and every diff.
924 #[test]
925 fn it_is_a_manual_minor() {
926 let m = ScanViewClockReportMode;
927 assert_eq!(m.kind(), lattice_mode::ModeKind::Minor);
928 assert!(matches!(
929 m.activation_policy(),
930 lattice_mode::ActivationPolicy::Manual
931 ));
932 assert_eq!(m.id().as_str(), "scan-view-clockreport-mode");
933 }
934
935 /// Generically named. Nothing in the report is org's — the data is
936 /// `ClockSpan`, a field of the generic `ScanResult` that any
937 /// scanned-excerpt-source may report — and an `org-` prefix in the host is
938 /// the vocabulary MV.3 removed from this module once already.
939 #[test]
940 fn the_mode_is_not_named_after_org() {
941 assert!(
942 !ScanViewClockReportMode::mode_id().as_str().contains("org"),
943 "the host does not name a generic mechanism after one plugin"
944 );
945 }
946
947 /// `cr`'s target flips THIS mode. A toggle that named some other mode — or
948 /// that did its own registering beside the mode's — would be the two
949 /// states that disagree.
950 #[test]
951 fn the_toggle_action_flips_this_mode() {
952 let mut reg = lattice_grammar::CommandRegistry::new();
953 register_clock_report_actions(&mut reg);
954 let id = reg
955 .lookup_by_name(TOGGLE_ACTION)
956 .expect("cr's target must resolve, or the chord silently does nothing")
957 .id;
958 // Dispatched the way the chord dispatches it, not by reaching into the
959 // spec — a body reached only by a test path is a body nobody proves
960 // the chord can run.
961 let mut doc = lattice_core::Document::empty();
962 let effect = lattice_grammar::execute(
963 ®,
964 &mut doc,
965 lattice_core::BufferId(1),
966 Default::default(),
967 lattice_grammar::CommandInvocation::of(id),
968 &lattice_grammar::CancellationToken::new(),
969 )
970 .expect("the toggle takes no arguments and cannot fail");
971 match effect {
972 lattice_grammar::effect::Effect::ToggleMode { mode_name } => {
973 assert_eq!(mode_name, "scan-view-clockreport-mode")
974 }
975 other => panic!("cr must flip this mode, got {other:?}"),
976 }
977 }
978
979 /// Turning the report OFF must take the rows down. A guard that dropped
980 /// without unregistering would leave last activation's report on screen
981 /// with the mode reporting itself inactive — exactly the disagreement the
982 /// mode-is-the-switch shape exists to prevent.
983 #[test]
984 fn dropping_the_registration_unregisters_the_provider() {
985 #[derive(Default)]
986 struct Spy {
987 removed: std::sync::Mutex<Vec<(BufferId, u64)>>,
988 }
989 impl lattice_mode::VirtualRowRegistrar for Spy {
990 fn register(&self, _b: BufferId, _p: Arc<dyn VirtualRowProvider>) -> bool {
991 true
992 }
993 fn unregister(&self, buffer: BufferId, id: u64) -> bool {
994 self.removed.lock().expect("spy lock").push((buffer, id));
995 true
996 }
997 }
998 let spy = Arc::new(Spy::default());
999 let registrar: Arc<dyn lattice_mode::VirtualRowRegistrar> = spy.clone();
1000 let view = BufferId(9);
1001 drop(ClockReportRegistration { registrar, view });
1002 assert_eq!(
1003 *spy.removed.lock().expect("spy lock"),
1004 vec![(view, clock_report_provider_id(view))]
1005 );
1006 }
1007}