Skip to main content

blockworx/panels/
history_panel.rs

1//! The time machine's panel: the document's revs, newest first, with the
2//! one on the canvas expanded. Rationale: `docs/single-author-playbook.md`.
3//!
4//! The History segment of the [navigator](crate::shell::navigator) — the
5//! navigator's sibling: one walks the document's structure, the other its
6//! past. Picking a row puts that rev on the same canvas, read-only; the
7//! `Current` row at the top is the way back to the live document.
8//!
9//! A history of hundreds of homogeneous items is **rows, not cards**, dense
10//! enough to scan — so the one rev being viewed is the only one that
11//! expands, and it expands *in place* into the card that shows every field
12//! and hosts the tag editor. Ordering follows what people scan for: what
13//! changed, then where, then what it was called; who and when trail behind,
14//! and the rev number is the smallest thing on the row because it is an
15//! identifier rather than something anybody looks for.
16//!
17//! Filtering is one search field, not facets: text search over a generated
18//! description is not what a reader remembers, while scope and tags are.
19//! The prefixes (`tag:`, `by:`, `in:`, `#`) are how a reader says which of
20//! those they mean.
21//!
22//! Thin on purpose. What a row *says* is [`blockworx_store::history::Row`],
23//! shared with `blockworx log`, and what a search *admits* is
24//! [`Query`] beside it, so this file decides nothing another surface
25//! could disagree with.
26
27use crate::canvas::convert::IntoEgui as _;
28use blockworx_doc::rev::Rev;
29use blockworx_store::doc::Viewing;
30use blockworx_store::history::{Day, Query, Row, tag_query};
31use blockworx_store::record::WallTime;
32use blockworx_store::tags::Tagging;
33
34use crate::{
35    theme::{Role, Theme},
36    tools::commands::Act,
37    tools::tool::Action,
38};
39
40/// What the panel lists and which row is on the canvas.
41#[derive(Clone, Copy)]
42pub struct HistoryScene<'a> {
43    /// Oldest first, as the manifest holds them; the panel shows them
44    /// reversed.
45    pub rows: &'a [Row],
46    pub viewing: Viewing,
47    /// The head rev — whichever row carries it is the writable present, so
48    /// a click on it returns rather than opening a lens on what is already
49    /// on the canvas.
50    pub head: Rev,
51    /// What the rows' days are measured against.
52    pub now: WallTime,
53    /// Resolves the badge and avatar roles — the two things the panel
54    /// paints that the surrounding egui chrome has no color for.
55    pub theme: &'a Theme,
56}
57
58/// The panel body and the state it carries between frames: what the reader
59/// has narrowed the history down to, which must survive the panel being
60/// collapsed and reopened.
61pub struct HistoryPanel<'a> {
62    pub scene: HistoryScene<'a>,
63    pub search: &'a mut String,
64}
65
66/// The panel's body, drawn into whatever `ui` the workspace gives it: the
67/// sticky search field over the rows, grouped by day. What the panel is
68/// called and how it is collapsed are the frame's business, not this
69/// file's.
70pub fn body(ui: &mut egui::Ui, panel: HistoryPanel<'_>) -> Option<Act> {
71    let HistoryPanel { scene, search } = panel;
72    let query = Query::parse(search);
73    let shown = scene.rows.iter().filter(|row| query.admits(row)).count();
74    let mut action = None;
75    search_field(ui, search, &query, shown, scene.rows.len());
76    let reveal = reveal(ui.ctx(), scene.viewing);
77    egui::ScrollArea::vertical()
78        .auto_shrink([false, false])
79        // The user: *"Hide the scroll bar on the history list."* The rows
80        // are one column and the bar was the only vertical line in the
81        // panel; the wheel and the drag are untouched.
82        .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
83        .show(ui, |ui| {
84            action = list(ui, &scene, &query, reveal);
85        });
86    if let Some(Picked::Tag(name)) = &action {
87        *search = tag_query(name);
88    }
89    match action {
90        Some(Picked::Act(action)) => Some(action),
91        _ => None,
92    }
93}
94
95/// What a click in the list asked for. A tag chip does nothing to the
96/// document and nothing to a dialog — it narrows the list — so it cannot be
97/// an [`Act`], and making it one variant of what a click returns keeps the
98/// list's rows answering one question instead of two.
99enum Picked {
100    Act(Act),
101    Tag(String),
102}
103
104/// The search field: sticky at the top, with the count reading "N of M"
105/// while a query is active.
106fn search_field(ui: &mut egui::Ui, search: &mut String, query: &Query, shown: usize, total: usize) {
107    let count = if query.narrows() {
108        format!("{shown} of {total}")
109    } else {
110        total.to_string()
111    };
112    sides(ui).show(
113        ui,
114        |ui| {
115            ui.add(
116                egui::TextEdit::singleline(search)
117                    .hint_text("Search history")
118                    .desired_width(f32::INFINITY),
119            );
120        },
121        |ui| {
122            ui.label(egui::RichText::new(count).small().weak());
123        },
124    );
125    ui.add_space(SEARCH_AIR);
126}
127
128/// Whether this frame drags the viewed rev's row into view.
129#[derive(Clone, Copy, PartialEq, Eq, Debug)]
130enum Reveal {
131    Now,
132    LeaveTheScroll,
133}
134
135/// The panel opens wherever the canvas is standing, which is no use if
136/// the row it is standing on is a hundred revs down the list. Scroll to it
137/// on the frame the panel appears, and again whenever the canvas moves to
138/// another rev under it — but not on every frame, or the list could never
139/// be scrolled by hand.
140fn reveal(ctx: &egui::Context, viewing: Viewing) -> Reveal {
141    let id = panel_id().with("last-drawn");
142    let now = ctx.cumulative_pass_nr();
143    let before: Option<(u64, Viewing)> = ctx.data(|d| d.get_temp(id));
144    ctx.data_mut(|d| d.insert_temp(id, (now, viewing)));
145    match before {
146        // A gap in the passes means the panel was not up: it is opening.
147        Some((pass, seen)) if pass + 1 == now && seen == viewing => Reveal::LeaveTheScroll,
148        _ => Reveal::Now,
149    }
150}
151
152/// The rows, newest first, grouped under the day they were written, with
153/// the `Current` row above them. `reveal` scrolls whichever row the canvas
154/// is standing on into view — the frame the panel opens, and whenever the
155/// canvas moves under it.
156fn list(
157    ui: &mut egui::Ui,
158    scene: &HistoryScene<'_>,
159    query: &Query,
160    reveal: Reveal,
161) -> Option<Picked> {
162    let HistoryScene {
163        rows,
164        viewing,
165        head,
166        now,
167        theme,
168    } = *scene;
169    let mut picked = None;
170    // The `Current` row is the head's representation, not a filter result;
171    // a narrowed list is a list of matches, so it stands down.
172    if !query.narrows() {
173        picked = current_row(ui, viewing, rows.iter().find(|row| row.rev == head));
174    }
175    let mut shown = 0;
176    // A session with no clock has no days to group under, so its rows run
177    // on unheaded rather than under one invented heading.
178    let mut heading_shown: Option<Day> = None;
179    // The head has no row of its own. The user: *"With a 'Current' at the
180    // top of the history view, the previous entry in the list is redundant
181    // (clicking r10 does nothing since it's considered the 'Current'
182    // revision - so hide r10 from the list."* It was inert by construction,
183    // which is exactly what makes it a row that says nothing.
184    for row in rows
185        .iter()
186        .rev()
187        .filter(|row| row.rev != head)
188        .filter(|row| query.admits(row))
189    {
190        shown += 1;
191        if let Some(day) = row.day()
192            && heading_shown != Some(day)
193        {
194            heading_shown = Some(day);
195            crate::panels::overlay::group_heading(ui, &day.label(now));
196        }
197        let response = if marked(viewing, row.rev) == OnCanvas::Yes {
198            let (response, act) = rev_card(ui, row, rows, theme);
199            picked = act.or(picked);
200            response
201        } else {
202            let (response, act) = rev_row(ui, row, rows, theme);
203            if response.clicked() {
204                picked = Some(Picked::Act(Action::ViewRev(row.rev).into()));
205            }
206            // A chip narrows the list rather than visiting the rev, so it
207            // answers after the row it sits on.
208            picked = act.or(picked);
209            response
210        };
211        if reveal == Reveal::Now && marked(viewing, row.rev) == OnCanvas::Yes {
212            response.scroll_to_me(Some(egui::Align::Center));
213        }
214    }
215    if shown == 0 && query.narrows() {
216        nothing_matches(ui);
217    }
218    picked
219}
220
221/// The empty result, which names the prefixes rather than only reporting
222/// the emptiness: the reader who typed a word that matched nothing is the
223/// reader who has not met `tag:` yet.
224fn nothing_matches(ui: &mut egui::Ui) {
225    ui.add_space(SEARCH_AIR);
226    ui.label(
227        egui::RichText::new("Nothing matches. Try tag:, by:, in:, or # for a rev number.")
228            .small()
229            .weak(),
230    );
231}
232
233/// The `Current` row: the live document, at the top of the list and above
234/// the newest rev's own day. It is where the lens is let go of — the same
235/// return the viewing pill offers, in the list a reader is already reading.
236fn current_row(ui: &mut egui::Ui, viewing: Viewing, head: Option<&Row>) -> Option<Picked> {
237    let live = OnCanvas::from(viewing == Viewing::Head);
238    let (response, ()) = clickable(ui, live, |ui| {
239        sides(ui).show(
240            ui,
241            |ui| {
242                let (rect, _) =
243                    ui.allocate_exact_size(egui::Vec2::splat(AVATAR), egui::Sense::hover());
244                ui.painter().circle_filled(
245                    rect.center(),
246                    LIVE_DOT,
247                    ui.visuals().selection.stroke.color,
248                );
249                ui.label("Current");
250            },
251            |ui| {
252                ui.label(
253                    egui::RichText::new(match live {
254                        OnCanvas::Yes => "editing",
255                        OnCanvas::No => "return",
256                    })
257                    .small()
258                    .weak(),
259                );
260            },
261        );
262    });
263    let _ = head;
264    (response.clicked() && live == OnCanvas::No).then_some(Picked::Act(Action::ViewHead.into()))
265}
266
267/// Whether a row is the one the list marks — the mockup's `aria-current`.
268#[derive(Clone, Copy, PartialEq, Eq)]
269enum OnCanvas {
270    Yes,
271    No,
272}
273
274/// Which row wears the mark: a rev's row only while the lens is on that rev,
275/// so at head the `Current` row wears it alone and the newest rev is not a
276/// second answer to the same question.
277fn marked(viewing: Viewing, rev: Rev) -> OnCanvas {
278    OnCanvas::from(matches!(viewing, Viewing::Past(at) if at == rev))
279}
280
281impl From<bool> for OnCanvas {
282    fn from(here: bool) -> Self {
283        if here { OnCanvas::Yes } else { OnCanvas::No }
284    }
285}
286
287/// One unexpanded rev: the author's initials, then what changed over
288/// where it changed over what it is called, with the time and the rev
289/// number trailing.
290fn rev_row(
291    ui: &mut egui::Ui,
292    row: &Row,
293    rows: &[Row],
294    theme: &Theme,
295) -> (egui::Response, Option<Picked>) {
296    let mut picked = None;
297    let (response, ()) = clickable(ui, OnCanvas::No, |ui| {
298        sides(ui).show(
299            ui,
300            |ui| {
301                avatar(ui, row, theme);
302                ui.vertical(|ui| {
303                    ui.add(description(ui, row, rows));
304                    scope_line(ui, row);
305                    picked = tag_chips(ui, row, theme);
306                });
307            },
308            |ui| meta(ui, row),
309        );
310    });
311    (response, picked)
312}
313
314/// The rev being viewed, expanded in place: every field in full, and the
315/// tag editor.
316///
317/// The card is where the per-rev affordances live now. A `…` on every row
318/// was a control that said nothing about the row it sat on, and the one
319/// rev a reader is looking at is the one they act on.
320fn rev_card(
321    ui: &mut egui::Ui,
322    row: &Row,
323    rows: &[Row],
324    theme: &Theme,
325) -> (egui::Response, Option<Picked>) {
326    let mut picked = None;
327    let tint = ui.visuals().selection.bg_fill;
328    let frame = egui::Frame::new()
329        .fill(tint)
330        .corner_radius(CARD_RADIUS)
331        .inner_margin(CARD_PAD)
332        .show(ui, |ui| {
333            ui.horizontal(|ui| {
334                avatar(ui, row, theme);
335                let by = row.author();
336                if !by.is_empty() {
337                    ui.label(egui::RichText::new(by).strong());
338                }
339                ui.label(egui::RichText::new(row.full_when()).small().weak());
340                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
341                    ui.label(rev_number(row.rev));
342                });
343            });
344            ui.add_space(CARD_GAP);
345            ui.add(egui::Label::new(
346                egui::RichText::new(blockworx_store::history::said(row, rows)).size(CARD_TITLE),
347            ));
348            let names = row.scope_names();
349            if !names.is_empty() {
350                ui.label(
351                    egui::RichText::new(names.join(SEPARATOR))
352                        .small()
353                        .color(ui.visuals().text_color()),
354                );
355            }
356            ui.add_space(CARD_GAP);
357            picked = tag_editor(ui, row, rows, theme);
358        });
359    (frame.response, picked)
360}
361
362/// The author's initials on a disc, in one of five accent roles picked by
363/// hashing their name, so the same hand keeps the same colour down the list.
364fn avatar(ui: &mut egui::Ui, row: &Row, theme: &Theme) {
365    let (rect, response) = ui.allocate_exact_size(egui::Vec2::splat(AVATAR), egui::Sense::hover());
366    let by = row.author();
367    if by.is_empty() {
368        return;
369    }
370    ui.painter().circle_filled(
371        rect.center(),
372        AVATAR / 2.0,
373        theme
374            .resolve(blockworx_paint::theme::avatar_role(by))
375            .egui(),
376    );
377    ui.painter().text(
378        rect.center(),
379        egui::Align2::CENTER_CENTER,
380        row.initials(),
381        small(ui),
382        theme.resolve(Role::AuthorAvatarText).egui(),
383    );
384    response.on_hover_text(by);
385}
386
387/// The row's primary line. An inverse rev names what it took back —
388/// `Undo — <original>` — and is drawn muted and italic so a reader
389/// scanning for when a thing changed reads past it.
390fn description(ui: &egui::Ui, row: &Row, rows: &[Row]) -> egui::Label {
391    let text = egui::RichText::new(blockworx_store::history::said(row, rows));
392    egui::Label::new(if row.is_inverse() {
393        text.italics().color(ui.visuals().weak_text_color())
394    } else {
395        text
396    })
397    .wrap()
398}
399
400/// The scope line, elided **from the left** so the leaf — the thing the
401/// act actually happened to — is the part that survives a narrow panel.
402fn scope_line(ui: &mut egui::Ui, row: &Row) {
403    let names = row.scope_names();
404    if names.is_empty() {
405        return;
406    }
407    let whole = names.join(SEPARATOR);
408    let shown = fits_from_the_left(ui, names, ui.available_width());
409    ui.label(egui::RichText::new(shown).small().weak())
410        .on_hover_text(whole);
411}
412
413/// The deepest run of `names` that fits in `room`, with a leading ellipsis
414/// where anything was dropped. egui elides from the right, which would
415/// take the leaf and leave the root — the wrong half.
416fn fits_from_the_left(ui: &egui::Ui, names: &[String], room: f32) -> String {
417    for start in 0..names.len() {
418        let kept = names[start..].join(SEPARATOR);
419        let text = if start == 0 {
420            kept
421        } else {
422            format!("\u{2026}{SEPARATOR}{kept}")
423        };
424        if measure(ui, &text) <= room || start + 1 == names.len() {
425            return text;
426        }
427    }
428    String::new()
429}
430
431/// The row's tags as chips. Clicking one narrows the list to it, which is
432/// the whole of what a tag is for on an unexpanded row.
433fn tag_chips(ui: &mut egui::Ui, row: &Row, theme: &Theme) -> Option<Picked> {
434    if row.tags.is_empty() {
435        return None;
436    }
437    let mut picked = None;
438    ui.horizontal_wrapped(|ui| {
439        for tag in &row.tags {
440            if chip(ui, tag, theme).clicked() {
441                picked = Some(Picked::Tag(tag.clone()));
442            }
443        }
444    });
445    picked
446}
447
448fn chip(ui: &mut egui::Ui, tag: &str, theme: &Theme) -> egui::Response {
449    ui.add(
450        egui::Button::new(
451            egui::RichText::new(tag)
452                .small()
453                .color(theme.resolve(Role::TagBadgeText).egui()),
454        )
455        .fill(theme.resolve(Role::TagBadge).egui())
456        .corner_radius(CHIP_RADIUS),
457    )
458}
459
460/// The card's tag editor: the rev's own names as removable chips, and a
461/// field that offers the document's existing vocabulary as it is typed, so
462/// that a diagram tagged in one set of words stays searchable and five
463/// spellings of one word do not accumulate.
464///
465/// The draft lives in egui's own transient store keyed by the rev, so a
466/// half-typed name survives a frame without the editor holding state for
467/// it.
468fn tag_editor(ui: &mut egui::Ui, row: &Row, rows: &[Row], theme: &Theme) -> Option<Picked> {
469    let mut picked = None;
470    let id = panel_id().with(("tag", row.rev.get()));
471    let mut draft: String = ui.data(|d| d.get_temp(id)).unwrap_or_default();
472    ui.horizontal_wrapped(|ui| {
473        for tag in &row.tags {
474            if chip(ui, tag, theme).clicked() {
475                picked = Some(Picked::Tag(tag.clone()));
476            }
477            if ui
478                .add(egui::Button::new("\u{d7}").frame(false))
479                .on_hover_text("Remove this tag")
480                .clicked()
481            {
482                picked = Some(Picked::Act(
483                    Action::TagRev {
484                        at: row.rev,
485                        name: tag.clone(),
486                        how: Tagging::Removed,
487                    }
488                    .into(),
489                ));
490            }
491        }
492        let edit = ui.add(
493            egui::TextEdit::singleline(&mut draft)
494                .hint_text("Add tag")
495                .desired_width(TAG_FIELD),
496        );
497        // `lost_focus`, not `has_focus`: a single-line `TextEdit` takes the
498        // Enter for itself and gives up focus on it, so the key never
499        // reaches a frame where the field still has focus.
500        let entered = edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
501        if entered && !draft.trim().is_empty() {
502            picked = Some(Picked::Act(
503                Action::TagRev {
504                    at: row.rev,
505                    name: draft.clone(),
506                    how: Tagging::Added,
507                }
508                .into(),
509            ));
510            draft.clear();
511        }
512    });
513    for name in suggestions(&draft, row, rows) {
514        if ui.add(egui::Button::new(&name).frame(false)).clicked() {
515            picked = Some(Picked::Act(
516                Action::TagRev {
517                    at: row.rev,
518                    name,
519                    how: Tagging::Added,
520                }
521                .into(),
522            ));
523            draft.clear();
524        }
525    }
526    ui.data_mut(|d| d.insert_temp(id, draft));
527    picked
528}
529
530/// The vocabulary this document already uses, minus what the rev already
531/// carries, narrowed by what has been typed so far.
532fn suggestions(draft: &str, row: &Row, rows: &[Row]) -> Vec<String> {
533    let typed = draft.trim().to_lowercase();
534    let mut seen: Vec<String> = Vec::new();
535    for tag in rows.iter().flat_map(|row| &row.tags) {
536        if row.tags.contains(tag) || seen.contains(tag) {
537            continue;
538        }
539        if typed.is_empty() || tag.contains(&typed) {
540            seen.push(tag.clone());
541        }
542    }
543    seen.sort();
544    seen.truncate(SUGGESTIONS);
545    seen
546}
547
548/// A whole row as one click target — a rev is visited by clicking anywhere
549/// along it, not by finding its label — with the tint behind it drawn from
550/// the response, so hover and current are the one shape.
551fn clickable<R>(
552    ui: &mut egui::Ui,
553    here: OnCanvas,
554    contents: impl FnOnce(&mut egui::Ui) -> R,
555) -> (egui::Response, R) {
556    let backdrop = ui.painter().add(egui::Shape::Noop);
557    let inner = ui.scope_builder(egui::UiBuilder::new().sense(egui::Sense::click()), |ui| {
558        // The row is the target, so the text on it is not: egui makes a
559        // label selectable by default, and a selectable label senses the
560        // click before the row underneath it does.
561        ui.style_mut().interaction.selectable_labels = false;
562        contents(ui)
563    });
564    let fill = match (here, inner.response.hovered()) {
565        (OnCanvas::Yes, _) => ui.visuals().selection.bg_fill,
566        (OnCanvas::No, true) => ui.visuals().widgets.hovered.weak_bg_fill,
567        (OnCanvas::No, false) => egui::Color32::TRANSPARENT,
568    };
569    ui.painter().set(
570        backdrop,
571        egui::epaint::RectShape::filled(inner.response.rect.expand(ROW_AIR), ROW_RADIUS, fill),
572    );
573    (inner.response, inner.inner)
574}
575
576/// A row's trailing cluster: when it happened over which rev it is.
577fn meta(ui: &mut egui::Ui, row: &Row) {
578    ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| {
579        // A session with no clock has no time to stamp its rows with, and
580        // an empty label would still take the line the number wants.
581        let when = row.time();
582        if !when.is_empty() {
583            ui.label(egui::RichText::new(when).small().weak())
584                .on_hover_text(row.full_when());
585        }
586        ui.label(rev_number(row.rev));
587    });
588}
589
590/// The panel's one row shape: a trailing cluster that takes the width it
591/// needs and a leading side that takes the rest, truncating rather than
592/// pushing its neighbour off the edge.
593///
594/// `shrink_left` is the whole of it. Reserving the room by measuring the
595/// trailing text and subtracting — which this file did until the layout
596/// was reviewed — is the same rule written twice and rounded differently
597/// each time.
598fn sides(ui: &egui::Ui) -> egui::containers::Sides {
599    egui::containers::Sides::new()
600        .shrink_left()
601        .spacing(ui.spacing().item_spacing.x)
602}
603
604/// The rev number, the smallest thing on the row: an identifier rather
605/// than something anybody scans for.
606fn rev_number(rev: Rev) -> egui::RichText {
607    egui::RichText::new(format!("#{}", rev.get()))
608        .small()
609        .weak()
610}
611
612/// How wide `text` reads at the small style — the one measurement the
613/// panel still makes, for the one thing no layout gives it: eliding a path
614/// from the left.
615fn measure(ui: &egui::Ui, text: &str) -> f32 {
616    ui.painter()
617        .layout_no_wrap(text.to_owned(), small(ui), egui::Color32::PLACEHOLDER)
618        .rect
619        .width()
620}
621
622fn small(ui: &egui::Ui) -> egui::FontId {
623    egui::TextStyle::Small.resolve(ui.style())
624}
625
626fn panel_id() -> egui::Id {
627    egui::Id::new("history_panel")
628}
629
630/// What separates two names on the scope line, in the spelling the content
631/// path along the canvas bottom uses.
632const SEPARATOR: &str = " / ";
633
634/// The avatar, and the live dot the `Current` row wears in its place.
635const AVATAR: f32 = 26.0;
636const LIVE_DOT: f32 = 4.5;
637
638/// The air a row's tint is drawn out into around its content, and the
639/// inner radius it is drawn with.
640const ROW_AIR: f32 = 4.0;
641const ROW_RADIUS: u8 = 12;
642
643/// The expanded card: its corner, its padding, the air between its
644/// stacked fields, and the 15px description.
645const CARD_RADIUS: u8 = 14;
646const CARD_PAD: i8 = 12;
647const CARD_GAP: f32 = 8.0;
648const CARD_TITLE: f32 = 15.0;
649
650/// A tag chip's corner — small enough to read as a chip rather than a
651/// button.
652const CHIP_RADIUS: u8 = 6;
653
654/// The tag field, and how many of the vocabulary it offers at once. Four,
655/// as the mockup has it: a suggestion list long enough to scroll is a
656/// second list to read.
657const TAG_FIELD: f32 = 82.0;
658const SUGGESTIONS: usize = 4;
659
660/// The air under the search field, before the first day heading.
661const SEARCH_AIR: f32 = 6.0;
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use crate::canvas::convert::IntoGeom as _;
667    use blockworx_doc::repo::Repo;
668    use blockworx_geom::{Rect, pos2, vec2};
669    use blockworx_store::history::{self, Journal};
670    use blockworx_store::manifest::{Row as Written, RowKind};
671    use blockworx_store::record::{Camera, Digest, Identity, ScopePath};
672    use blockworx_store::tags::Tags;
673
674    /// What the panel reads: a container's rows, or — with none — the
675    /// commits a session with no files holds.
676    fn journal<'a>(repo: &'a Repo, written: &'a [Written]) -> Journal<'a> {
677        if written.is_empty() {
678            Journal::Session(repo.log())
679        } else {
680            Journal::Recorded(written)
681        }
682    }
683
684    /// The panel, driven through real frames by what it actually painted —
685    /// so a row that laid out but never drew, or that moved out from under
686    /// the pointer, fails here.
687    struct Panel {
688        chrome: crate::panels::painted::Chrome,
689        session: Session,
690    }
691
692    struct Session {
693        repo: Repo,
694        /// The rows a container wrote beside the commits — empty for a
695        /// scratch session, which has neither clock nor author.
696        written: Vec<Written>,
697        tags: Tags,
698        now: WallTime,
699        search: String,
700        viewing: Viewing,
701        fired: Option<Act>,
702    }
703
704    impl Session {
705        fn frame(&mut self, ui: &mut egui::Ui) {
706            let theme = crate::theme::Theme::default();
707            let rows = history::rows(journal(&self.repo, &self.written), &self.tags);
708            let mut panel = ui.new_child(
709                egui::UiBuilder::new()
710                    .id(egui::Id::new("history_body"))
711                    .max_rect(egui::Rect::from_min_size(
712                        egui::pos2(20.0, 40.0),
713                        egui::vec2(312.0, 640.0),
714                    ))
715                    .layout(egui::Layout::top_down(egui::Align::Min)),
716            );
717            let fired = body(
718                &mut panel,
719                HistoryPanel {
720                    scene: HistoryScene {
721                        rows: &rows,
722                        viewing: self.viewing,
723                        head: self.repo.rev(),
724                        now: self.now,
725                        theme: &theme,
726                    },
727                    search: &mut self.search,
728                },
729            );
730            if fired.is_some() {
731                self.fired = fired;
732            }
733        }
734    }
735
736    impl Panel {
737        fn over(repo: Repo, written: Vec<Written>, tags: Tags, now: WallTime) -> Self {
738            let mut panel = Panel {
739                chrome: crate::panels::painted::Chrome::new(Rect::from_min_size(
740                    pos2(0.0, 0.0),
741                    vec2(1000.0, 800.0),
742                )),
743                session: Session {
744                    repo,
745                    written,
746                    tags,
747                    now,
748                    search: String::new(),
749                    viewing: Viewing::Head,
750                    fired: None,
751                },
752            };
753            panel.settle();
754            panel
755        }
756
757        fn settle(&mut self) {
758            let Self { chrome, session } = self;
759            chrome.settle(|ui| session.frame(ui));
760        }
761
762        fn click_at(&mut self, at: egui::Pos2) {
763            let Self { chrome, session } = self;
764            chrome.click_at(at.geom(), |ui| session.frame(ui));
765        }
766
767        fn click_on(&mut self, text: &str) {
768            let Self { chrome, session } = self;
769            chrome.click_on(text, |ui| session.frame(ui));
770        }
771
772        fn type_text(&mut self, text: &str) {
773            let Self { chrome, session } = self;
774            chrome.type_text(text, |ui| session.frame(ui));
775        }
776
777        fn press(&mut self, key: egui::Key) {
778            let Self { chrome, session } = self;
779            chrome.press(key, |ui| session.frame(ui));
780        }
781
782        /// Put the caret in the search box and type — the box is the only
783        /// text field on an unexpanded list.
784        fn search_for(&mut self, text: &str) {
785            self.click_on("Search history");
786            self.type_text(text);
787            self.settle();
788        }
789
790        fn viewing(&mut self, at: Viewing) {
791            self.session.viewing = at;
792            self.settle();
793        }
794    }
795
796    /// What the panel asked for, named — neither an `Act` nor an `Action` is
797    /// `Debug`, and a failure that cannot say what fired instead is a failure
798    /// you have to re-run under a debugger.
799    fn fired(panel: &Panel) -> String {
800        match &panel.session.fired {
801            None => "nothing".to_owned(),
802            Some(Act::Edit(Action::ViewHead)) => "ViewHead".to_owned(),
803            Some(Act::Edit(Action::ViewRev(at))) => format!("ViewRev(r{})", at.get()),
804            Some(Act::Edit(Action::TagRev { at, name, how })) => {
805                format!("TagRev(r{}, {name:?}, {how:?})", at.get())
806            }
807            Some(_) => "another act".to_owned(),
808        }
809    }
810
811    const TODAY: u64 = 1_756_000_000_000;
812    const A_DAY: u64 = 86_400_000;
813
814    /// One manifest row, unchained — the panel reads rows, never the links
815    /// between them.
816    fn row(n: u64, at: u64, kind: RowKind, by: &str) -> Written {
817        Written {
818            rev: blockworx_doc::fixtures::rev(n),
819            kind,
820            wall_time: WallTime::from_unix_millis(at),
821            author: Identity::new(by),
822            label: label_of(n),
823            scope: ScopePath::default(),
824            scope_names: vec!["engine".to_owned(), "left motor mount".to_owned()],
825            camera: Camera::UNSEEN,
826            touched: vec![blockworx_doc::id::EntityRef::Block(
827                blockworx_doc::fixtures::block_id(n as u32),
828            )],
829            truncated: false,
830            hash: Digest::of(format!("rev {n}").as_bytes()),
831            parent: Digest::genesis(),
832        }
833    }
834
835    fn label_of(n: u64) -> String {
836        match n {
837            1 => "Added Adder",
838            2 => "Undo Added Adder",
839            3 => "Added Mux",
840            _ => "Added Register",
841        }
842        .to_owned()
843    }
844
845    fn recorded() -> Panel {
846        recorded_with(Tags::default())
847    }
848
849    fn recorded_with(tags: Tags) -> Panel {
850        let repo = Repo::folding(&[
851            blockworx_store::fixture::commit(
852                "Added Adder",
853                vec![blockworx_store::fixture::block_create(1, "Adder")],
854            ),
855            blockworx_store::fixture::commit(
856                "Undo Added Adder",
857                vec![blockworx_store::fixture::block_create(2, "Summer")],
858            ),
859            blockworx_store::fixture::commit(
860                "Added Mux",
861                vec![blockworx_store::fixture::block_create(3, "Mux")],
862            ),
863            // The head, which the list does not draw — so every row the
864            // tests below read is one the panel really shows.
865            blockworx_store::fixture::commit(
866                "Added Register",
867                vec![blockworx_store::fixture::block_create(4, "Register")],
868            ),
869        ])
870        .expect("the fixture commits fold");
871        let written = vec![
872            row(1, TODAY - 2 * A_DAY, RowKind::Edit, "ada lovelace"),
873            row(
874                2,
875                TODAY,
876                RowKind::Undo {
877                    of: blockworx_doc::fixtures::rev(1),
878                },
879                "ada lovelace",
880            ),
881            row(3, TODAY, RowKind::Edit, "grace hopper"),
882            row(4, TODAY, RowKind::Edit, "grace hopper"),
883        ];
884        Panel::over(repo, written, tags, WallTime::from_unix_millis(TODAY))
885    }
886
887    /// A row, field by field: the author's initials on the avatar, the
888    /// description, the scope under it, and the rev number trailing.
889    #[test]
890    fn a_row_carries_its_author_its_description_its_scope_and_its_number() {
891        let panel = recorded();
892        for expected in ["AL", "Added Mux", "engine / left motor mount", "#3"] {
893            assert!(
894                panel.chrome.says(expected),
895                "a row does not show {expected:?}: {:?}",
896                panel.chrome.texts(),
897            );
898        }
899    }
900
901    /// The scope is truncated from the *left*, so the leaf — the thing the
902    /// act actually happened to — is the half that survives a narrow panel.
903    #[test]
904    fn the_scope_line_keeps_its_leaf_and_drops_its_root() {
905        let ctx = egui::Context::default();
906        let names: Vec<String> = ["engine", "left motor mount", "base plate"]
907            .iter()
908            .map(|name| (*name).to_owned())
909            .collect();
910        let mut shown = String::new();
911        ctx.run_ui(egui::RawInput::default(), |ui| {
912            let whole = fits_from_the_left(ui, &names, 4_000.0);
913            assert_eq!(
914                whole, "engine / left motor mount / base plate",
915                "a line with room to spare was elided anyway",
916            );
917            shown = fits_from_the_left(ui, &names, 60.0);
918        })
919        .drop_without_applying_deltas();
920        assert!(
921            shown.ends_with("base plate"),
922            "the leaf was elided away: {shown:?}",
923        );
924        assert!(
925            shown.starts_with('\u{2026}'),
926            "an elided line does not say so: {shown:?}",
927        );
928    }
929
930    /// An inverse rev names what it took back, and is muted and italic
931    /// where its neighbours are neither, so a reader scanning the log can
932    /// read past it.
933    #[test]
934    fn an_undo_rev_names_what_it_took_back_and_is_muted_and_italic() {
935        let panel = recorded();
936        let undone = panel
937            .chrome
938            .format_of("Undo \u{2014} Added Adder")
939            .expect("the undo row does not name the rev it took back");
940        let plain = panel
941            .chrome
942            .format_of("Added Mux")
943            .expect("an ordinary row painted its label");
944        assert!(!plain.italics, "an ordinary rev is already italic");
945        assert!(undone.italics, "the inverse rev is not italic");
946        assert_ne!(
947            undone.color, plain.color,
948            "the inverse rev is not muted from its neighbours",
949        );
950    }
951
952    /// The rows fall under the day they were written, and the last two days
953    /// are named rather than dated.
954    #[test]
955    fn the_rows_group_under_the_day_they_were_written() {
956        let panel = recorded();
957        assert!(
958            panel.chrome.says("Today"),
959            "no day heading: {:?}",
960            panel.chrome.texts(),
961        );
962        assert!(
963            panel
964                .chrome
965                .texts()
966                .iter()
967                .any(|text| *text != "Today" && text.contains('-') && text.starts_with("20")),
968            "the older day was not dated: {:?}",
969            panel.chrome.texts(),
970        );
971    }
972
973    /// A session with no container has no clock, so it invents neither a day
974    /// to group under nor a time to stamp its rows with.
975    #[test]
976    fn a_session_with_no_clock_shows_no_day_headings() {
977        let repo =
978            Repo::folding(&blockworx_store::fixture::edits(3)).expect("the fixture commits fold");
979        let panel = Panel::over(repo, Vec::new(), Tags::default(), history::now());
980        assert!(
981            !panel.chrome.says("Today"),
982            "a session with no clock invented a day: {:?}",
983            panel.chrome.texts(),
984        );
985    }
986
987    /// The count reads the whole history until a query narrows it, then
988    /// how much of it survived.
989    #[test]
990    fn the_count_reads_n_of_m_only_while_a_query_narrows() {
991        let mut panel = recorded();
992        assert!(
993            panel.chrome.says("4"),
994            "the unnarrowed count is not the whole history: {:?}",
995            panel.chrome.texts(),
996        );
997        panel.search_for("Mux");
998        assert!(
999            panel.chrome.says("1 of 4"),
1000            "a narrowed list does not say how much it kept: {:?}",
1001            panel.chrome.texts(),
1002        );
1003    }
1004
1005    /// The `Current` row is the head's representation, not a match — so a
1006    /// narrowed list, which is a list of matches, does not carry it.
1007    #[test]
1008    fn the_current_row_stands_down_while_a_query_narrows_the_list() {
1009        let mut panel = recorded();
1010        assert!(panel.chrome.says("Current"));
1011        panel.search_for("Mux");
1012        assert!(
1013            !panel.chrome.says("Current"),
1014            "the Current row survived a filter it is not a match for",
1015        );
1016    }
1017
1018    /// An empty result teaches the prefixes rather than only reporting
1019    /// the emptiness.
1020    #[test]
1021    fn an_empty_result_names_the_prefixes() {
1022        let mut panel = recorded();
1023        panel.search_for("nothing whatsoever");
1024        assert!(
1025            panel
1026                .chrome
1027                .says("Nothing matches. Try tag:, by:, in:, or # for a rev number."),
1028            "an empty result said nothing useful: {:?}",
1029            panel.chrome.texts(),
1030        );
1031    }
1032
1033    /// The prefixes narrow to one column: `by:` reaches an author whose name
1034    /// appears in no description.
1035    #[test]
1036    fn a_by_prefix_narrows_to_the_author() {
1037        let mut panel = recorded();
1038        panel.search_for("by:grace");
1039        assert!(
1040            panel.chrome.says("Added Mux"),
1041            "grace's row was filtered out: {:?}",
1042            panel.chrome.texts(),
1043        );
1044        assert!(
1045            !panel.chrome.says("Added Adder"),
1046            "ada's row survived a search for grace: {:?}",
1047            panel.chrome.texts(),
1048        );
1049    }
1050
1051    /// Clicking a tag chip anywhere in the list sets `tag:<name>`, which
1052    /// is the whole of what a tag does on an unexpanded row.
1053    #[test]
1054    fn clicking_a_tag_chip_narrows_the_list_to_that_tag() {
1055        let mut tags = Tags::default();
1056        tags.add(blockworx_doc::fixtures::rev(3), "vendor");
1057        let mut panel = recorded_with(tags);
1058        assert!(
1059            panel.chrome.says("vendor"),
1060            "the tag chip was not drawn: {:?}",
1061            panel.chrome.texts(),
1062        );
1063        panel.click_on("vendor");
1064        panel.settle();
1065        assert_eq!(panel.session.search, "tag:vendor");
1066        assert!(
1067            panel.chrome.says("Added Mux") && !panel.chrome.says("Added Adder"),
1068            "the chip did not narrow the list: {:?}",
1069            panel.chrome.texts(),
1070        );
1071    }
1072
1073    /// The rev on the canvas expands in place into the card: every field
1074    /// in full, where the row had only what fits.
1075    #[test]
1076    fn the_viewed_rev_expands_into_a_card_carrying_every_field() {
1077        let mut panel = recorded();
1078        panel.viewing(Viewing::Past(blockworx_doc::fixtures::rev(3)));
1079        for expected in ["grace hopper", "Added Mux", "#3"] {
1080            assert!(
1081                panel.chrome.says(expected),
1082                "the card does not show {expected:?}: {:?}",
1083                panel.chrome.texts(),
1084            );
1085        }
1086        assert!(
1087            panel
1088                .chrome
1089                .texts()
1090                .iter()
1091                .any(|text| text.contains("2025") && text.contains(':')),
1092            "the card does not carry the full timestamp: {:?}",
1093            panel.chrome.texts(),
1094        );
1095    }
1096
1097    /// The card's tag editor puts a name on the rev it shows.
1098    #[test]
1099    fn the_cards_tag_field_names_the_rev_it_shows() {
1100        let mut panel = recorded();
1101        let at = blockworx_doc::fixtures::rev(3);
1102        panel.viewing(Viewing::Past(at));
1103        panel.click_on("Add tag");
1104        panel.type_text("released");
1105        panel.press(egui::Key::Enter);
1106        assert!(
1107            matches!(
1108                &panel.session.fired,
1109                Some(Act::Edit(Action::TagRev { at: fired, name, how }))
1110                    if *fired == at && name == "released" && *how == Tagging::Added,
1111            ),
1112            "the tag field did not name the rev: {}",
1113            fired(&panel),
1114        );
1115    }
1116
1117    /// The card takes a name back off, naming which one — the whole reason
1118    /// an untag is its own row kind now that a rev holds a set.
1119    #[test]
1120    fn the_cards_chip_takes_one_name_back_off() {
1121        let at = blockworx_doc::fixtures::rev(3);
1122        let mut tags = Tags::default();
1123        tags.add(at, "released");
1124        tags.add(at, "vendor");
1125        let mut panel = recorded_with(tags);
1126        panel.viewing(Viewing::Past(at));
1127        // The two chips each carry a remove button; the first is
1128        // "released", since a rev's names read alphabetically.
1129        let remove = panel
1130            .chrome
1131            .rects("\u{d7}")
1132            .first()
1133            .copied()
1134            .expect("the card drew no remove button");
1135        panel.click_at(remove.center().egui());
1136        assert!(
1137            matches!(
1138                &panel.session.fired,
1139                Some(Act::Edit(Action::TagRev { at: fired, name, how }))
1140                    if *fired == at && name == "released" && *how == Tagging::Removed,
1141            ),
1142            "the chip did not take its own name off: {}",
1143            fired(&panel),
1144        );
1145    }
1146
1147    /// The panel opens on the rev the canvas is standing on, wherever that
1148    /// row has scrolled to — but it must not drag the list back every frame,
1149    /// or it could never be scrolled by hand.
1150    #[test]
1151    fn the_viewed_row_is_revealed_on_opening_and_on_moving_but_not_after() {
1152        let ctx = egui::Context::default();
1153        let at = Viewing::Past(blockworx_doc::fixtures::rev(2));
1154        let mut seen = Vec::new();
1155        let mut look = |viewing: Option<Viewing>| {
1156            ctx.clone()
1157                .run_ui(egui::RawInput::default(), |ui| {
1158                    if let Some(viewing) = viewing {
1159                        seen.push(reveal(ui.ctx(), viewing));
1160                    }
1161                })
1162                .drop_without_applying_deltas();
1163        };
1164        // Two consecutive passes at one rev, a third after the canvas moved,
1165        // then a gap (the panel closed) and one more.
1166        look(Some(at));
1167        look(Some(at));
1168        look(Some(Viewing::Head));
1169        look(None);
1170        look(Some(Viewing::Head));
1171        assert_eq!(
1172            seen,
1173            vec![
1174                Reveal::Now,
1175                Reveal::LeaveTheScroll,
1176                Reveal::Now,
1177                Reveal::Now
1178            ],
1179        );
1180    }
1181
1182    /// The `Current` row is the way back, and it is the only thing in the
1183    /// list that offers one.
1184    #[test]
1185    fn the_current_row_returns_to_the_live_document() {
1186        let mut panel = recorded();
1187        panel.viewing(Viewing::Past(blockworx_doc::fixtures::rev(1)));
1188        panel.click_on("Current");
1189        assert!(
1190            matches!(panel.session.fired, Some(Act::Edit(Action::ViewHead))),
1191            "the Current row did not return to the present: {}",
1192            fired(&panel),
1193        );
1194    }
1195
1196    /// Nothing in the list dispatches a return while the present is already
1197    /// on the canvas — there is nowhere to return from.
1198    #[test]
1199    fn nothing_returns_to_a_present_already_shown() {
1200        let mut panel = recorded();
1201        panel.click_on("Current");
1202        assert!(
1203            panel.session.fired.is_none(),
1204            "a return fired where the present is already shown: {}",
1205            fired(&panel),
1206        );
1207    }
1208
1209    /// A click on a row puts that rev on the canvas; the head has no row of
1210    /// its own to click.
1211    #[test]
1212    fn clicking_a_row_visits_its_rev_and_the_head_has_no_row() {
1213        let mut panel = recorded();
1214        assert!(
1215            !panel.chrome.says("Added Register"),
1216            "the head still draws a row of its own: {:?}",
1217            panel.chrome.texts(),
1218        );
1219        panel.click_on("Added Mux");
1220        assert!(
1221            matches!(
1222                panel.session.fired,
1223                Some(Act::Edit(Action::ViewRev(at))) if at == blockworx_doc::fixtures::rev(3),
1224            ),
1225            "a click on r3's row did not visit r3: {}",
1226            fired(&panel),
1227        );
1228    }
1229}