Skip to main content

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            &reg,
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}