1use 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#[derive(Clone, Copy)]
42pub struct HistoryScene<'a> {
43 pub rows: &'a [Row],
46 pub viewing: Viewing,
47 pub head: Rev,
51 pub now: WallTime,
53 pub theme: &'a Theme,
56}
57
58pub struct HistoryPanel<'a> {
62 pub scene: HistoryScene<'a>,
63 pub search: &'a mut String,
64}
65
66pub 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 .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
95enum Picked {
100 Act(Act),
101 Tag(String),
102}
103
104fn 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
130enum Reveal {
131 Now,
132 LeaveTheScroll,
133}
134
135fn 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 Some((pass, seen)) if pass + 1 == now && seen == viewing => Reveal::LeaveTheScroll,
148 _ => Reveal::Now,
149 }
150}
151
152fn 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 if !query.narrows() {
173 picked = current_row(ui, viewing, rows.iter().find(|row| row.rev == head));
174 }
175 let mut shown = 0;
176 let mut heading_shown: Option<Day> = None;
179 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 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
221fn 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
233fn 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#[derive(Clone, Copy, PartialEq, Eq)]
269enum OnCanvas {
270 Yes,
271 No,
272}
273
274fn 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
287fn 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
314fn 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
362fn 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
387fn 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
400fn 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
413fn 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
431fn 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
460fn 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 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
530fn 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
548fn 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 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
576fn meta(ui: &mut egui::Ui, row: &Row) {
578 ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| {
579 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
590fn sides(ui: &egui::Ui) -> egui::containers::Sides {
599 egui::containers::Sides::new()
600 .shrink_left()
601 .spacing(ui.spacing().item_spacing.x)
602}
603
604fn rev_number(rev: Rev) -> egui::RichText {
607 egui::RichText::new(format!("#{}", rev.get()))
608 .small()
609 .weak()
610}
611
612fn 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
630const SEPARATOR: &str = " / ";
633
634const AVATAR: f32 = 26.0;
636const LIVE_DOT: f32 = 4.5;
637
638const ROW_AIR: f32 = 4.0;
641const ROW_RADIUS: u8 = 12;
642
643const CARD_RADIUS: u8 = 14;
646const CARD_PAD: i8 = 12;
647const CARD_GAP: f32 = 8.0;
648const CARD_TITLE: f32 = 15.0;
649
650const CHIP_RADIUS: u8 = 6;
653
654const TAG_FIELD: f32 = 82.0;
658const SUGGESTIONS: usize = 4;
659
660const 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 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 struct Panel {
688 chrome: crate::panels::painted::Chrome,
689 session: Session,
690 }
691
692 struct Session {
693 repo: Repo,
694 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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}