1use std::any::Any;
4use std::borrow::Cow;
5use std::cell::RefCell;
6use std::cmp::min;
7use std::collections::{BTreeMap, HashSet};
8use std::fmt::Write;
9use std::fmt::{Debug, Display};
10use std::hash::Hash;
11use std::path::Path;
12use std::rc::Rc;
13use std::{io, iter, mem, panic};
14
15use crossterm::event::{
16 DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
17 MouseButton, MouseEvent, MouseEventKind,
18};
19use crossterm::terminal::{
20 disable_raw_mode, enable_raw_mode, is_raw_mode_enabled, EnterAlternateScreen,
21 LeaveAlternateScreen,
22};
23use ratatui::backend::{Backend, TestBackend};
24use ratatui::buffer::Buffer;
25use ratatui::style::{Color, Modifier, Style};
26use ratatui::text::{Line, Span};
27use ratatui::widgets::{Block, Borders, Clear, Paragraph};
28use ratatui::{backend::CrosstermBackend, Terminal};
29use tracing::warn;
30use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
31
32use crate::consts::ENV_VAR_DEBUG_UI;
33use crate::render::{
34 centered_rect, Component, DrawnRect, DrawnRects, Mask, Rect, RectSize, Viewport,
35};
36use crate::types::{ChangeType, Commit, RecordError, RecordState, Tristate};
37use crate::util::{IsizeExt, UsizeExt};
38use crate::{File, Section, SectionChangedLine};
39
40const NUM_CONTEXT_LINES: usize = 3;
41
42#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
43struct FileKey {
44 commit_idx: usize,
45 file_idx: usize,
46}
47
48#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
49struct SectionKey {
50 commit_idx: usize,
51 file_idx: usize,
52 section_idx: usize,
53}
54
55#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
56struct LineKey {
57 commit_idx: usize,
58 file_idx: usize,
59 section_idx: usize,
60 line_idx: usize,
61}
62
63#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
64enum QuitDialogButtonId {
65 Quit,
66 GoBack,
67}
68
69#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
70enum SelectionKey {
71 None,
72 File(FileKey),
73 Section(SectionKey),
74 Line(LineKey),
75}
76
77impl Default for SelectionKey {
78 fn default() -> Self {
79 Self::None
80 }
81}
82
83#[derive(Clone, Debug, Default, Eq, PartialEq)]
85pub struct TestingScreenshot {
86 contents: Rc<RefCell<Option<String>>>,
87}
88
89impl TestingScreenshot {
90 fn set(&self, new_contents: String) {
91 let Self { contents } = self;
92 *contents.borrow_mut() = Some(new_contents);
93 }
94
95 pub fn event(&self) -> Event {
97 Event::TakeScreenshot(self.clone())
98 }
99}
100
101impl Display for TestingScreenshot {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 let Self { contents } = self;
104 match contents.borrow().as_ref() {
105 Some(contents) => write!(f, "{contents}"),
106 None => write!(f, "<this screenshot was never assigned>"),
107 }
108 }
109}
110
111#[allow(missing_docs)]
112#[derive(Clone, Debug, Eq, PartialEq)]
113pub enum Event {
114 None,
115 QuitAccept,
116 QuitCancel,
117 QuitInterrupt,
118 QuitEscape,
119 TakeScreenshot(TestingScreenshot),
120 Redraw,
121 EnsureSelectionInViewport,
122 ScrollUp,
123 ScrollDown,
124 PageUp,
125 PageDown,
126 FocusPrev,
127 FocusPrevSameKind,
129 FocusPrevPage,
130 FocusNext,
131 FocusNextSameKind,
133 FocusNextPage,
134 FocusInner,
135 FocusOuter {
139 fold_section: bool,
140 },
141 ToggleItem,
142 ToggleItemAndAdvance,
143 ToggleAll,
144 ToggleAllUniform,
145 ExpandItem,
146 ExpandAll,
147 Click {
148 row: usize,
149 column: usize,
150 },
151 ToggleCommitViewMode, EditCommitMessage,
153 Help,
154}
155
156impl From<crossterm::event::Event> for Event {
157 fn from(event: crossterm::event::Event) -> Self {
158 use crossterm::event::Event;
159 match event {
160 Event::Key(KeyEvent {
161 code: KeyCode::Char('q'),
162 modifiers: KeyModifiers::NONE,
163 kind: KeyEventKind::Press,
164 state: _,
165 }) => Self::QuitCancel,
166
167 Event::Key(KeyEvent {
168 code: KeyCode::Esc,
169 modifiers: KeyModifiers::NONE,
170 kind: KeyEventKind::Press,
171 state: _,
172 }) => Self::QuitEscape,
173
174 Event::Key(KeyEvent {
175 code: KeyCode::Char('c'),
176 modifiers: KeyModifiers::CONTROL,
177 kind: KeyEventKind::Press,
178 state: _,
179 }) => Self::QuitInterrupt,
180
181 Event::Key(KeyEvent {
182 code: KeyCode::Char('c'),
183 modifiers: KeyModifiers::NONE,
184 kind: KeyEventKind::Press,
185 state: _,
186 }) => Self::QuitAccept,
187
188 Event::Key(KeyEvent {
189 code: KeyCode::Char('?'),
190 modifiers: KeyModifiers::NONE,
191 kind: KeyEventKind::Press,
192 state: _,
193 }) => Self::Help,
194
195 Event::Key(KeyEvent {
196 code: KeyCode::Up | KeyCode::Char('y'),
197 modifiers: KeyModifiers::CONTROL,
198 kind: KeyEventKind::Press,
199 state: _,
200 })
201 | Event::Mouse(MouseEvent {
202 kind: MouseEventKind::ScrollUp,
203 column: _,
204 row: _,
205 modifiers: _,
206 }) => Self::ScrollUp,
207 Event::Key(KeyEvent {
208 code: KeyCode::Down | KeyCode::Char('e'),
209 modifiers: KeyModifiers::CONTROL,
210 kind: KeyEventKind::Press,
211 state: _,
212 })
213 | Event::Mouse(MouseEvent {
214 kind: MouseEventKind::ScrollDown,
215 column: _,
216 row: _,
217 modifiers: _,
218 }) => Self::ScrollDown,
219
220 Event::Key(KeyEvent {
221 code: KeyCode::PageUp | KeyCode::Char('b'),
222 modifiers: KeyModifiers::CONTROL,
223 kind: KeyEventKind::Press,
224 state: _,
225 }) => Self::PageUp,
226 Event::Key(KeyEvent {
227 code: KeyCode::PageDown | KeyCode::Char('f'),
228 modifiers: KeyModifiers::CONTROL,
229 kind: KeyEventKind::Press,
230 state: _,
231 }) => Self::PageDown,
232
233 Event::Key(KeyEvent {
234 code: KeyCode::Up | KeyCode::Char('k'),
235 modifiers: KeyModifiers::NONE,
236 kind: KeyEventKind::Press,
237 state: _,
238 }) => Self::FocusPrev,
239 Event::Key(KeyEvent {
240 code: KeyCode::Down | KeyCode::Char('j'),
241 modifiers: KeyModifiers::NONE,
242 kind: KeyEventKind::Press,
243 state: _,
244 }) => Self::FocusNext,
245
246 Event::Key(KeyEvent {
247 code: KeyCode::PageUp,
248 modifiers: KeyModifiers::NONE,
249 kind: KeyEventKind::Press,
250 state: _,
251 }) => Self::FocusPrevSameKind,
252 Event::Key(KeyEvent {
253 code: KeyCode::PageDown,
254 modifiers: KeyModifiers::NONE,
255 kind: KeyEventKind::Press,
256 state: _,
257 }) => Self::FocusNextSameKind,
258
259 Event::Key(KeyEvent {
260 code: KeyCode::Left | KeyCode::Char('h'),
261 modifiers: KeyModifiers::SHIFT,
262 kind: KeyEventKind::Press,
263 state: _,
264 }) => Self::FocusOuter {
265 fold_section: false,
266 },
267 Event::Key(KeyEvent {
268 code: KeyCode::Left | KeyCode::Char('h'),
269 modifiers: KeyModifiers::NONE,
270 kind: KeyEventKind::Press,
271 state: _,
272 }) => Self::FocusOuter { fold_section: true },
273 Event::Key(KeyEvent {
274 code: KeyCode::Right | KeyCode::Char('l'),
275 modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
277 kind: KeyEventKind::Press,
278 state: _,
279 }) => Self::FocusInner,
280
281 Event::Key(KeyEvent {
282 code: KeyCode::Char('u'),
283 modifiers: KeyModifiers::CONTROL,
284 kind: KeyEventKind::Press,
285 state: _,
286 }) => Self::FocusPrevPage,
287 Event::Key(KeyEvent {
288 code: KeyCode::Char('d'),
289 modifiers: KeyModifiers::CONTROL,
290 kind: KeyEventKind::Press,
291 state: _,
292 }) => Self::FocusNextPage,
293
294 Event::Key(KeyEvent {
295 code: KeyCode::Char(' '),
296 modifiers: KeyModifiers::NONE,
297 kind: KeyEventKind::Press,
298 state: _,
299 }) => Self::ToggleItem,
300
301 Event::Key(KeyEvent {
302 code: KeyCode::Enter,
303 modifiers: KeyModifiers::NONE,
304 kind: KeyEventKind::Press,
305 state: _,
306 }) => Self::ToggleItemAndAdvance,
307
308 Event::Key(KeyEvent {
309 code: KeyCode::Char('a'),
310 modifiers: KeyModifiers::NONE,
311 kind: KeyEventKind::Press,
312 state: _,
313 }) => Self::ToggleAll,
314 Event::Key(KeyEvent {
315 code: KeyCode::Char('A'),
316 modifiers: KeyModifiers::SHIFT,
317 kind: KeyEventKind::Press,
318 state: _,
319 }) => Self::ToggleAllUniform,
320
321 Event::Key(KeyEvent {
322 code: KeyCode::Char('f'),
323 modifiers: KeyModifiers::NONE,
324 kind: KeyEventKind::Press,
325 state: _,
326 }) => Self::ExpandItem,
327 Event::Key(KeyEvent {
328 code: KeyCode::Char('F'),
329 modifiers: KeyModifiers::SHIFT,
330 kind: KeyEventKind::Press,
331 state: _,
332 }) => Self::ExpandAll,
333
334 Event::Key(KeyEvent {
335 code: KeyCode::Char('e'),
336 modifiers: KeyModifiers::NONE,
337 kind: KeyEventKind::Press,
338 state: _event,
339 }) => Self::EditCommitMessage,
340
341 Event::Mouse(MouseEvent {
342 kind: MouseEventKind::Down(MouseButton::Left),
343 column,
344 row,
345 modifiers: _,
346 }) => Self::Click {
347 row: row.into(),
348 column: column.into(),
349 },
350
351 _event => Self::None,
352 }
353 }
354}
355
356pub enum TerminalKind {
358 Crossterm,
360
361 Testing {
363 width: usize,
365
366 height: usize,
368 },
369}
370
371pub trait RecordInput {
373 fn terminal_kind(&self) -> TerminalKind;
375
376 fn next_events(&mut self) -> Result<Vec<Event>, RecordError>;
379
380 fn edit_commit_message(&mut self, message: &str) -> Result<String, RecordError>;
385}
386
387fn buffer_view(buffer: &Buffer) -> String {
389 let mut view =
390 String::with_capacity(buffer.content.len() + usize::from(buffer.area.height) * 3);
391 for cells in buffer.content.chunks(buffer.area.width.into()) {
392 let mut overwritten = vec![];
393 let mut skip: usize = 0;
394 view.push('"');
395 for (x, c) in cells.iter().enumerate() {
396 if skip == 0 {
397 view.push_str(c.symbol());
398 } else {
399 overwritten.push((x, c.symbol()))
400 }
401 skip = std::cmp::max(skip, c.symbol().width()).saturating_sub(1);
402 }
403 view.push('"');
404 if !overwritten.is_empty() {
405 write!(
406 &mut view,
407 " Hidden by multi-width symbols: {:?}",
408 overwritten
409 )
410 .unwrap();
411 }
412 view.push('\n');
413 }
414 view
415}
416
417#[derive(Clone, Debug, PartialEq, Eq)]
418enum StateUpdate {
419 None,
420 SetQuitDialog(Option<QuitDialog>),
421 QuitAccept,
422 QuitCancel,
423 SetHelpDialog(Option<HelpDialog>),
424 TakeScreenshot(TestingScreenshot),
425 Redraw,
426 EnsureSelectionInViewport,
427 ScrollTo(isize),
428 SelectItem {
429 selection_key: SelectionKey,
430 ensure_in_viewport: bool,
431 },
432 ToggleItem(SelectionKey),
433 ToggleItemAndAdvance(SelectionKey, SelectionKey),
434 ToggleAll,
435 ToggleAllUniform,
436 SetExpandItem(SelectionKey, bool),
437 ToggleExpandItem(SelectionKey),
438 ToggleExpandAll,
439 UnfocusMenuBar,
440 ClickMenu {
441 menu_idx: usize,
442 },
443 ClickMenuItem(Event),
444 ToggleCommitViewMode,
445 EditCommitMessage {
446 commit_idx: usize,
447 },
448}
449
450#[derive(Clone, Copy, Debug)]
451enum CommitViewMode {
452 Inline,
453 Adjacent,
454}
455
456pub struct Recorder<'state, 'input> {
458 state: RecordState<'state>,
459 input: &'input mut dyn RecordInput,
460 pending_events: Vec<Event>,
461 use_unicode: bool,
462 commit_view_mode: CommitViewMode,
463 expanded_items: HashSet<SelectionKey>,
464 expanded_menu_idx: Option<usize>,
465 selection_key: SelectionKey,
466 focused_commit_idx: usize,
467 quit_dialog: Option<QuitDialog>,
468 help_dialog: Option<HelpDialog>,
469 scroll_offset_y: isize,
470}
471
472impl<'state, 'input> Recorder<'state, 'input> {
473 pub fn new(mut state: RecordState<'state>, input: &'input mut dyn RecordInput) -> Self {
475 state.commits.extend(
477 iter::repeat_with(Commit::default).take(2_usize.saturating_sub(state.commits.len())),
478 );
479 if state.commits.len() > 2 {
480 unimplemented!("more than two commits");
481 }
482
483 let mut recorder = Self {
484 state,
485 input,
486 pending_events: Default::default(),
487 use_unicode: true,
488 commit_view_mode: CommitViewMode::Inline,
489 expanded_items: Default::default(),
490 expanded_menu_idx: Default::default(),
491 selection_key: SelectionKey::None,
492 focused_commit_idx: 0,
493 quit_dialog: None,
494 help_dialog: None,
495 scroll_offset_y: 0,
496 };
497 recorder.expand_initial_items();
498 recorder
499 }
500
501 pub fn run(self) -> Result<RecordState<'state>, RecordError> {
504 #[cfg(feature = "debug")]
505 if std::env::var_os(crate::consts::ENV_VAR_DUMP_UI_STATE).is_some() {
506 let ui_state =
507 serde_json::to_string_pretty(&self.state).map_err(RecordError::SerializeJson)?;
508 std::fs::write(crate::consts::DUMP_UI_STATE_FILENAME, ui_state)
509 .map_err(RecordError::WriteFile)?;
510 }
511
512 match self.input.terminal_kind() {
513 TerminalKind::Crossterm => self.run_crossterm(),
514 TerminalKind::Testing { width, height } => self.run_testing(width, height),
515 }
516 }
517
518 fn run_crossterm(self) -> Result<RecordState<'state>, RecordError> {
520 Self::set_up_crossterm()?;
521 Self::install_panic_hook();
522 let backend = CrosstermBackend::new(io::stdout());
523 let mut term = Terminal::new(backend).map_err(RecordError::SetUpTerminal)?;
524 term.clear().map_err(RecordError::RenderFrame)?;
525 let result = self.run_inner(&mut term);
526 Self::clean_up_crossterm()?;
527 result
528 }
529
530 fn install_panic_hook() {
531 let original_hook = panic::take_hook();
542 panic::set_hook(Box::new(move |panic| {
543 Self::clean_up_crossterm().unwrap();
544 original_hook(panic);
545 }));
546 }
547
548 fn set_up_crossterm() -> Result<(), RecordError> {
549 if !is_raw_mode_enabled().map_err(RecordError::SetUpTerminal)? {
550 crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)
551 .map_err(RecordError::SetUpTerminal)?;
552 enable_raw_mode().map_err(RecordError::SetUpTerminal)?;
553 }
554 Ok(())
555 }
556
557 fn clean_up_crossterm() -> Result<(), RecordError> {
558 if is_raw_mode_enabled().map_err(RecordError::CleanUpTerminal)? {
559 disable_raw_mode().map_err(RecordError::CleanUpTerminal)?;
560 crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)
561 .map_err(RecordError::CleanUpTerminal)?;
562 }
563 Ok(())
564 }
565
566 fn run_testing(self, width: usize, height: usize) -> Result<RecordState<'state>, RecordError> {
567 let backend = TestBackend::new(width.clamp_into_u16(), height.clamp_into_u16());
568 let mut term = Terminal::new(backend).map_err(RecordError::SetUpTerminal)?;
569 self.run_inner(&mut term)
570 }
571
572 fn run_inner(
573 mut self,
574 term: &mut Terminal<impl Backend + Any>,
575 ) -> Result<RecordState<'state>, RecordError> {
576 self.selection_key = self.first_selection_key();
577 let debug = if cfg!(feature = "debug") {
578 std::env::var_os(ENV_VAR_DEBUG_UI).is_some()
579 } else {
580 false
581 };
582
583 'outer: loop {
584 let menu_bar = self.make_menu_bar();
585 let app = self.make_app(menu_bar.clone(), None);
586 let term_height = usize::from(term.get_frame().area().height);
587
588 let mut drawn_rects: Option<DrawnRects<ComponentId>> = None;
589 term.draw(|frame| {
590 drawn_rects = Some(Viewport::<ComponentId>::render_top_level(
591 frame,
592 0,
593 self.scroll_offset_y,
594 &app,
595 ));
596 })
597 .map_err(RecordError::RenderFrame)?;
598 let drawn_rects = drawn_rects.unwrap();
599
600 if debug {
603 let debug_info = AppDebugInfo {
604 term_height,
605 scroll_offset_y: self.scroll_offset_y,
606 selection_key: self.selection_key,
607 selection_key_y: self.selection_key_y(&drawn_rects, self.selection_key),
608 drawn_rects: drawn_rects.clone().into_iter().collect(),
609 };
610 let debug_app = AppView {
611 debug_info: Some(debug_info),
612 ..app.clone()
613 };
614 term.draw(|frame| {
615 Viewport::<ComponentId>::render_top_level(
616 frame,
617 0,
618 self.scroll_offset_y,
619 &debug_app,
620 );
621 })
622 .map_err(RecordError::RenderFrame)?;
623 }
624
625 let events = if self.pending_events.is_empty() {
626 self.input.next_events()?
627 } else {
628 mem::take(&mut self.pending_events)
632 };
633 for event in events {
634 match self.handle_event(event, term_height, &drawn_rects, &menu_bar)? {
635 StateUpdate::None => {}
636 StateUpdate::SetQuitDialog(quit_dialog) => {
637 self.quit_dialog = quit_dialog;
638 }
639 StateUpdate::SetHelpDialog(help_dialog) => {
640 self.help_dialog = help_dialog;
641 }
642 StateUpdate::QuitAccept => {
643 if self.help_dialog.is_some() {
644 self.help_dialog = None;
645 } else {
646 break 'outer;
647 }
648 }
649 StateUpdate::QuitCancel => return Err(RecordError::Cancelled),
650 StateUpdate::TakeScreenshot(screenshot) => {
651 let backend: &dyn Any = term.backend();
652 let test_backend = backend
653 .downcast_ref::<TestBackend>()
654 .expect("TakeScreenshot event generated for non-testing backend");
655 screenshot.set(buffer_view(test_backend.buffer()));
656 }
657 StateUpdate::Redraw => {
658 term.clear().map_err(RecordError::RenderFrame)?;
659 }
660 StateUpdate::EnsureSelectionInViewport => {
661 if let Some(scroll_offset_y) =
662 self.ensure_in_viewport(term_height, &drawn_rects, self.selection_key)
663 {
664 self.scroll_offset_y = scroll_offset_y;
665 }
666 }
667 StateUpdate::ScrollTo(scroll_offset_y) => {
668 self.scroll_offset_y = scroll_offset_y.clamp(0, {
669 let DrawnRect { rect, timestamp: _ } = drawn_rects[&ComponentId::App];
670 rect.height.unwrap_isize() - 1
671 });
672 }
673 StateUpdate::SelectItem {
674 selection_key,
675 ensure_in_viewport,
676 } => {
677 self.selection_key = selection_key;
678 self.expand_item_ancestors(selection_key);
679 if ensure_in_viewport {
680 self.pending_events.push(Event::EnsureSelectionInViewport);
681 }
682 }
683 StateUpdate::ToggleItem(selection_key) => {
684 self.toggle_item(selection_key)?;
685 }
686 StateUpdate::ToggleItemAndAdvance(selection_key, new_key) => {
687 self.toggle_item(selection_key)?;
688 self.selection_key = new_key;
689 self.pending_events.push(Event::EnsureSelectionInViewport);
690 }
691 StateUpdate::ToggleAll => {
692 self.toggle_all();
693 }
694 StateUpdate::ToggleAllUniform => {
695 self.toggle_all_uniform();
696 }
697 StateUpdate::SetExpandItem(selection_key, is_expanded) => {
698 self.set_expand_item(selection_key, is_expanded);
699 self.pending_events.push(Event::EnsureSelectionInViewport);
700 }
701 StateUpdate::ToggleExpandItem(selection_key) => {
702 self.toggle_expand_item(selection_key)?;
703 self.pending_events.push(Event::EnsureSelectionInViewport);
704 }
705 StateUpdate::ToggleExpandAll => {
706 self.toggle_expand_all()?;
707 self.pending_events.push(Event::EnsureSelectionInViewport);
708 }
709 StateUpdate::UnfocusMenuBar => {
710 self.unfocus_menu_bar();
711 }
712 StateUpdate::ClickMenu { menu_idx } => {
713 self.click_menu_header(menu_idx);
714 }
715 StateUpdate::ClickMenuItem(event) => {
716 self.click_menu_item(event);
717 }
718 StateUpdate::ToggleCommitViewMode => {
719 self.commit_view_mode = match self.commit_view_mode {
720 CommitViewMode::Inline => CommitViewMode::Adjacent,
721 CommitViewMode::Adjacent => CommitViewMode::Inline,
722 };
723 }
724 StateUpdate::EditCommitMessage { commit_idx } => {
725 self.pending_events.push(Event::Redraw);
726 self.edit_commit_message(commit_idx)?;
727 }
728 }
729 }
730 }
731
732 Ok(self.state)
733 }
734
735 fn make_menu_bar(&self) -> MenuBar<'static> {
736 MenuBar {
737 menus: vec![
738 Menu {
739 label: Cow::Borrowed("File"),
740 items: vec![
741 MenuItem {
742 label: Cow::Borrowed("Confirm (c)"),
743 event: Event::QuitAccept,
744 },
745 MenuItem {
746 label: Cow::Borrowed("Quit (q)"),
747 event: Event::QuitCancel,
748 },
749 ],
750 },
751 Menu {
752 label: Cow::Borrowed("Edit"),
753 items: vec![
754 MenuItem {
755 label: Cow::Borrowed("Edit message (e)"),
756 event: Event::EditCommitMessage,
757 },
758 MenuItem {
759 label: Cow::Borrowed("Toggle current (space)"),
760 event: Event::ToggleItem,
761 },
762 MenuItem {
763 label: Cow::Borrowed("Toggle current and advance (enter)"),
764 event: Event::ToggleItemAndAdvance,
765 },
766 MenuItem {
767 label: Cow::Borrowed("Invert all items (a)"),
768 event: Event::ToggleAll,
769 },
770 MenuItem {
771 label: Cow::Borrowed("Invert all items uniformly (A)"),
772 event: Event::ToggleAllUniform,
773 },
774 ],
775 },
776 Menu {
777 label: Cow::Borrowed("Select"),
778 items: vec![
779 MenuItem {
780 label: Cow::Borrowed("Previous item (up, k)"),
781 event: Event::FocusPrev,
782 },
783 MenuItem {
784 label: Cow::Borrowed("Next item (down, j)"),
785 event: Event::FocusNext,
786 },
787 MenuItem {
788 label: Cow::Borrowed("Previous item of the same kind (page-up)"),
789 event: Event::FocusPrevSameKind,
790 },
791 MenuItem {
792 label: Cow::Borrowed("Next item of the same kind (page-down)"),
793 event: Event::FocusNextSameKind,
794 },
795 MenuItem {
796 label: Cow::Borrowed(
797 "Outer item without folding (shift-left, shift-h)",
798 ),
799 event: Event::FocusOuter {
800 fold_section: false,
801 },
802 },
803 MenuItem {
804 label: Cow::Borrowed("Outer item with folding (left, h)"),
805 event: Event::FocusOuter { fold_section: true },
806 },
807 MenuItem {
808 label: Cow::Borrowed("Inner item with unfolding (right, l)"),
809 event: Event::FocusInner,
810 },
811 MenuItem {
812 label: Cow::Borrowed("Previous page (ctrl-u)"),
813 event: Event::FocusPrevPage,
814 },
815 MenuItem {
816 label: Cow::Borrowed("Next page (ctrl-d)"),
817 event: Event::FocusNextPage,
818 },
819 ],
820 },
821 Menu {
822 label: Cow::Borrowed("View"),
823 items: vec![
824 MenuItem {
825 label: Cow::Borrowed("Fold/unfold current (f)"),
826 event: Event::ExpandItem,
827 },
828 MenuItem {
829 label: Cow::Borrowed("Fold/unfold all (F)"),
830 event: Event::ExpandAll,
831 },
832 MenuItem {
833 label: Cow::Borrowed("Scroll up (ctrl-up, ctrl-y)"),
834 event: Event::ScrollUp,
835 },
836 MenuItem {
837 label: Cow::Borrowed("Scroll down (ctrl-down, ctrl-e)"),
838 event: Event::ScrollDown,
839 },
840 MenuItem {
841 label: Cow::Borrowed("Previous page (ctrl-page-up, ctrl-b)"),
842 event: Event::PageUp,
843 },
844 MenuItem {
845 label: Cow::Borrowed("Next page (ctrl-page-down, ctrl-f)"),
846 event: Event::PageDown,
847 },
848 ],
849 },
850 ],
851 expanded_menu_idx: self.expanded_menu_idx,
852 }
853 }
854
855 fn make_app(
856 &'state self,
857 menu_bar: MenuBar<'static>,
858 debug_info: Option<AppDebugInfo>,
859 ) -> AppView<'state> {
860 let RecordState {
861 is_read_only,
862 commits,
863 files,
864 } = &self.state;
865 let commit_views = match self.commit_view_mode {
866 CommitViewMode::Inline => {
867 vec![CommitView {
868 debug_info: None,
869 commit_message_view: CommitMessageView {
870 commit_idx: self.focused_commit_idx,
871 commit: &commits[self.focused_commit_idx],
872 },
873 file_views: self.make_file_views(
874 self.focused_commit_idx,
875 files,
876 &debug_info,
877 *is_read_only,
878 ),
879 }]
880 }
881
882 CommitViewMode::Adjacent => commits
883 .iter()
884 .enumerate()
885 .map(|(commit_idx, commit)| CommitView {
886 debug_info: None,
887 commit_message_view: CommitMessageView { commit_idx, commit },
888 file_views: self.make_file_views(commit_idx, files, &debug_info, *is_read_only),
889 })
890 .collect(),
891 };
892 AppView {
893 debug_info: None,
894 menu_bar,
895 commit_view_mode: self.commit_view_mode,
896 commit_views,
897 quit_dialog: self.quit_dialog.clone(),
898 help_dialog: self.help_dialog.clone(),
899 }
900 }
901
902 fn make_file_views(
903 &'state self,
904 commit_idx: usize,
905 files: &'state [File<'state>],
906 debug_info: &Option<AppDebugInfo>,
907 is_read_only: bool,
908 ) -> Vec<FileView<'state>> {
909 files
910 .iter()
911 .enumerate()
912 .map(|(file_idx, file)| {
913 let file_key = FileKey {
914 commit_idx,
915 file_idx,
916 };
917 let file_toggled = self.file_tristate(file_key).unwrap();
918 let file_expanded = self.file_expanded(file_key);
919 let is_focused = match self.selection_key {
920 SelectionKey::None | SelectionKey::Section(_) | SelectionKey::Line(_) => false,
921 SelectionKey::File(selected_file_key) => file_key == selected_file_key,
922 };
923 FileView {
924 debug: debug_info.is_some(),
925 file_key,
926 toggle_box: TristateBox {
927 use_unicode: self.use_unicode,
928 id: ComponentId::ToggleBox(SelectionKey::File(file_key)),
929 icon_style: TristateIconStyle::Check,
930 tristate: file_toggled,
931 is_focused,
932 is_read_only,
933 },
934 expand_box: TristateBox {
935 use_unicode: self.use_unicode,
936 id: ComponentId::ExpandBox(SelectionKey::File(file_key)),
937 icon_style: TristateIconStyle::Expand,
938 tristate: file_expanded,
939 is_focused,
940 is_read_only: false,
941 },
942 is_header_selected: is_focused,
943 old_path: file.old_path.as_deref(),
944 path: &file.path,
945 section_views: {
946 let mut section_views = Vec::new();
947 let total_num_sections = file.sections.len();
948 let total_num_editable_sections = file
949 .sections
950 .iter()
951 .filter(|section| section.is_editable())
952 .count();
953
954 let mut line_num = 1;
955 let mut editable_section_num = 0;
956 for (section_idx, section) in file.sections.iter().enumerate() {
957 let section_key = SectionKey {
958 commit_idx,
959 file_idx,
960 section_idx,
961 };
962 let section_toggled = self.section_tristate(section_key).unwrap();
963 let section_expanded = Tristate::from(
964 self.expanded_items
965 .contains(&SelectionKey::Section(section_key)),
966 );
967 let is_focused = match self.selection_key {
968 SelectionKey::None
969 | SelectionKey::File(_)
970 | SelectionKey::Line(_) => false,
971 SelectionKey::Section(selection_section_key) => {
972 selection_section_key == section_key
973 }
974 };
975 if section.is_editable() {
976 editable_section_num += 1;
977 }
978 section_views.push(SectionView {
979 use_unicode: self.use_unicode,
980 is_read_only,
981 section_key,
982 toggle_box: TristateBox {
983 use_unicode: self.use_unicode,
984 is_read_only,
985 id: ComponentId::ToggleBox(SelectionKey::Section(section_key)),
986 tristate: section_toggled,
987 icon_style: TristateIconStyle::Check,
988 is_focused,
989 },
990 expand_box: TristateBox {
991 use_unicode: self.use_unicode,
992 is_read_only: false,
993 id: ComponentId::ExpandBox(SelectionKey::Section(section_key)),
994 tristate: section_expanded,
995 icon_style: TristateIconStyle::Expand,
996 is_focused,
997 },
998 selection: match self.selection_key {
999 SelectionKey::None | SelectionKey::File(_) => None,
1000 SelectionKey::Section(selected_section_key) => {
1001 if selected_section_key == section_key {
1002 Some(SectionSelection::SectionHeader)
1003 } else {
1004 None
1005 }
1006 }
1007 SelectionKey::Line(LineKey {
1008 commit_idx,
1009 file_idx,
1010 section_idx,
1011 line_idx,
1012 }) => {
1013 let selected_section_key = SectionKey {
1014 commit_idx,
1015 file_idx,
1016 section_idx,
1017 };
1018 if selected_section_key == section_key {
1019 Some(SectionSelection::ChangedLine(line_idx))
1020 } else {
1021 None
1022 }
1023 }
1024 },
1025 total_num_sections,
1026 editable_section_num,
1027 total_num_editable_sections,
1028 section,
1029 line_start_num: line_num,
1030 });
1031
1032 line_num += match section {
1033 Section::Unchanged { lines } => lines.len(),
1034 Section::Changed { lines } => lines
1035 .iter()
1036 .filter(|changed_line| match changed_line.change_type {
1037 ChangeType::Added => false,
1038 ChangeType::Removed => true,
1039 })
1040 .count(),
1041 Section::FileMode { .. } | Section::Binary { .. } => 0,
1042 };
1043 }
1044 section_views
1045 },
1046 }
1047 })
1048 .collect()
1049 }
1050
1051 fn handle_event(
1052 &self,
1053 event: Event,
1054 term_height: usize,
1055 drawn_rects: &DrawnRects<ComponentId>,
1056 menu_bar: &MenuBar,
1057 ) -> Result<StateUpdate, RecordError> {
1058 let state_update = match (&self.quit_dialog, event) {
1059 (_, Event::None) => StateUpdate::None,
1060 (_, Event::Redraw) => StateUpdate::Redraw,
1061 (_, Event::EnsureSelectionInViewport) => StateUpdate::EnsureSelectionInViewport,
1062
1063 (
1064 _,
1065 Event::Help
1066 | Event::QuitEscape
1067 | Event::QuitCancel
1068 | Event::ToggleItem
1069 | Event::ToggleItemAndAdvance,
1070 ) if self.help_dialog.is_some() => {
1071 StateUpdate::SetHelpDialog(None)
1073 }
1074 (_, Event::Help) => StateUpdate::SetHelpDialog(Some(HelpDialog())),
1075
1076 (None, Event::QuitAccept) => StateUpdate::QuitAccept,
1078 (Some(_), Event::QuitAccept) => StateUpdate::None,
1080
1081 (None, Event::QuitCancel | Event::QuitInterrupt) => {
1083 let num_commit_messages = self.num_user_commit_messages()?;
1084 let num_changed_files = self.num_user_file_changes()?;
1085 if num_commit_messages > 0 || num_changed_files > 0 {
1086 StateUpdate::SetQuitDialog(Some(QuitDialog {
1087 num_commit_messages,
1088 num_changed_files,
1089 focused_button: QuitDialogButtonId::Quit,
1090 }))
1091 } else {
1092 StateUpdate::QuitCancel
1093 }
1094 }
1095 (Some(_), Event::QuitCancel | Event::QuitEscape) => StateUpdate::SetQuitDialog(None),
1097 (Some(_), Event::QuitInterrupt) => StateUpdate::QuitCancel,
1099 (Some(quit_dialog), Event::FocusOuter { .. }) => {
1101 StateUpdate::SetQuitDialog(Some(QuitDialog {
1102 focused_button: QuitDialogButtonId::GoBack,
1103 ..quit_dialog.clone()
1104 }))
1105 }
1106 (Some(quit_dialog), Event::FocusInner) => {
1108 StateUpdate::SetQuitDialog(Some(QuitDialog {
1109 focused_button: QuitDialogButtonId::Quit,
1110 ..quit_dialog.clone()
1111 }))
1112 }
1113 (Some(quit_dialog), Event::ToggleItem | Event::ToggleItemAndAdvance) => {
1115 let QuitDialog {
1116 num_commit_messages: _,
1117 num_changed_files: _,
1118 focused_button,
1119 } = quit_dialog;
1120 match focused_button {
1121 QuitDialogButtonId::Quit => StateUpdate::QuitCancel,
1122 QuitDialogButtonId::GoBack => StateUpdate::SetQuitDialog(None),
1123 }
1124 }
1125
1126 (
1128 Some(_),
1129 Event::ScrollUp
1130 | Event::ScrollDown
1131 | Event::PageUp
1132 | Event::PageDown
1133 | Event::FocusPrev
1134 | Event::FocusNext
1135 | Event::FocusPrevSameKind
1136 | Event::FocusNextSameKind
1137 | Event::FocusPrevPage
1138 | Event::FocusNextPage
1139 | Event::ToggleAll
1140 | Event::ToggleAllUniform
1141 | Event::ExpandItem
1142 | Event::ExpandAll
1143 | Event::EditCommitMessage,
1144 ) => StateUpdate::None,
1145
1146 (Some(_) | None, Event::TakeScreenshot(screenshot)) => {
1147 StateUpdate::TakeScreenshot(screenshot)
1148 }
1149 (None, Event::ScrollUp) => {
1150 StateUpdate::ScrollTo(self.scroll_offset_y.saturating_sub(1))
1151 }
1152 (None, Event::ScrollDown) => {
1153 StateUpdate::ScrollTo(self.scroll_offset_y.saturating_add(1))
1154 }
1155 (None, Event::PageUp) => StateUpdate::ScrollTo(
1156 self.scroll_offset_y
1157 .saturating_sub(term_height.unwrap_isize()),
1158 ),
1159 (None, Event::PageDown) => StateUpdate::ScrollTo(
1160 self.scroll_offset_y
1161 .saturating_add(term_height.unwrap_isize()),
1162 ),
1163 (None, Event::FocusPrev) => {
1164 let (keys, index) = self.find_selection();
1165 let selection_key = self.select_prev(&keys, index);
1166 StateUpdate::SelectItem {
1167 selection_key,
1168 ensure_in_viewport: true,
1169 }
1170 }
1171 (None, Event::FocusNext) => {
1172 let (keys, index) = self.find_selection();
1173 let selection_key = self.select_next(&keys, index);
1174 StateUpdate::SelectItem {
1175 selection_key,
1176 ensure_in_viewport: true,
1177 }
1178 }
1179 (None, Event::FocusPrevSameKind) => {
1180 let selection_key =
1181 self.select_prev_or_next_of_same_kind(true);
1182 StateUpdate::SelectItem {
1183 selection_key,
1184 ensure_in_viewport: true,
1185 }
1186 }
1187 (None, Event::FocusNextSameKind) => {
1188 let selection_key =
1189 self.select_prev_or_next_of_same_kind(false);
1190 StateUpdate::SelectItem {
1191 selection_key,
1192 ensure_in_viewport: true,
1193 }
1194 }
1195 (None, Event::FocusPrevPage) => {
1196 let selection_key = self.select_prev_page(term_height, drawn_rects);
1197 StateUpdate::SelectItem {
1198 selection_key,
1199 ensure_in_viewport: true,
1200 }
1201 }
1202 (None, Event::FocusNextPage) => {
1203 let selection_key = self.select_next_page(term_height, drawn_rects);
1204 StateUpdate::SelectItem {
1205 selection_key,
1206 ensure_in_viewport: true,
1207 }
1208 }
1209 (None, Event::FocusOuter { fold_section }) => self.select_outer(fold_section),
1210 (None, Event::FocusInner) => {
1211 let selection_key = self.select_inner();
1212 StateUpdate::SelectItem {
1213 selection_key,
1214 ensure_in_viewport: true,
1215 }
1216 }
1217 (None, Event::ToggleItem) => StateUpdate::ToggleItem(self.selection_key),
1218 (None, Event::ToggleItemAndAdvance) => {
1219 let advanced_key = self.advance_to_next_of_kind();
1220 StateUpdate::ToggleItemAndAdvance(self.selection_key, advanced_key)
1221 }
1222 (None, Event::ToggleAll) => StateUpdate::ToggleAll,
1223 (None, Event::ToggleAllUniform) => StateUpdate::ToggleAllUniform,
1224 (None, Event::ExpandItem) => StateUpdate::ToggleExpandItem(self.selection_key),
1225 (None, Event::ExpandAll) => StateUpdate::ToggleExpandAll,
1226 (None, Event::EditCommitMessage) => StateUpdate::EditCommitMessage {
1227 commit_idx: self.focused_commit_idx,
1228 },
1229
1230 (_, Event::Click { row, column }) => {
1231 let component_id = self.find_component_at(drawn_rects, row, column);
1232 self.click_component(menu_bar, component_id)
1233 }
1234 (_, Event::ToggleCommitViewMode) => StateUpdate::ToggleCommitViewMode,
1235
1236 (_, Event::QuitEscape) => StateUpdate::None,
1238 };
1239 Ok(state_update)
1240 }
1241
1242 fn first_selection_key(&self) -> SelectionKey {
1243 match self.state.files.iter().enumerate().next() {
1244 Some((file_idx, _)) => SelectionKey::File(FileKey {
1245 commit_idx: self.focused_commit_idx,
1246 file_idx,
1247 }),
1248 None => SelectionKey::None,
1249 }
1250 }
1251
1252 fn num_user_commit_messages(&self) -> Result<usize, RecordError> {
1253 let RecordState {
1254 files: _,
1255 commits,
1256 is_read_only: _,
1257 } = &self.state;
1258 Ok(commits
1259 .iter()
1260 .map(|commit| {
1261 let Commit { message } = commit;
1262 match message {
1263 Some(message) if !message.is_empty() => 1,
1264 _ => 0,
1265 }
1266 })
1267 .sum())
1268 }
1269
1270 fn num_user_file_changes(&self) -> Result<usize, RecordError> {
1271 let RecordState {
1272 files,
1273 commits: _,
1274 is_read_only: _,
1275 } = &self.state;
1276 let mut result = 0;
1277 for (file_idx, _file) in files.iter().enumerate() {
1278 match self.file_tristate(FileKey {
1279 commit_idx: self.focused_commit_idx,
1280 file_idx,
1281 })? {
1282 Tristate::False => {}
1283 Tristate::Partial | Tristate::True => {
1284 result += 1;
1285 }
1286 }
1287 }
1288 Ok(result)
1289 }
1290
1291 fn all_selection_keys(&self) -> Vec<SelectionKey> {
1292 let mut result = Vec::new();
1293 for (commit_idx, _) in self.state.commits.iter().enumerate() {
1294 if commit_idx > 0 {
1295 continue;
1297 }
1298 for (file_idx, file) in self.state.files.iter().enumerate() {
1299 result.push(SelectionKey::File(FileKey {
1300 commit_idx,
1301 file_idx,
1302 }));
1303 for (section_idx, section) in file.sections.iter().enumerate() {
1304 match section {
1305 Section::Unchanged { .. } => {}
1306 Section::Changed { lines } => {
1307 result.push(SelectionKey::Section(SectionKey {
1308 commit_idx,
1309 file_idx,
1310 section_idx,
1311 }));
1312 for (line_idx, _line) in lines.iter().enumerate() {
1313 result.push(SelectionKey::Line(LineKey {
1314 commit_idx,
1315 file_idx,
1316 section_idx,
1317 line_idx,
1318 }));
1319 }
1320 }
1321 Section::FileMode {
1322 is_checked: _,
1323 before: _,
1324 after: _,
1325 }
1326 | Section::Binary { .. } => {
1327 result.push(SelectionKey::Section(SectionKey {
1328 commit_idx,
1329 file_idx,
1330 section_idx,
1331 }));
1332 }
1333 }
1334 }
1335 }
1336 }
1337 result
1338 }
1339
1340 fn find_selection(&self) -> (Vec<SelectionKey>, Option<usize>) {
1341 let visible_keys: Vec<_> = self
1343 .all_selection_keys()
1344 .iter()
1345 .cloned()
1346 .filter(|key| match key {
1347 SelectionKey::None => false,
1348 SelectionKey::File(_) => true,
1349 SelectionKey::Section(section_key) => {
1350 let file_key = FileKey {
1351 commit_idx: section_key.commit_idx,
1352 file_idx: section_key.file_idx,
1353 };
1354 match self.file_expanded(file_key) {
1355 Tristate::False => false,
1356 Tristate::Partial | Tristate::True => true,
1357 }
1358 }
1359 SelectionKey::Line(line_key) => {
1360 let file_key = FileKey {
1361 commit_idx: line_key.commit_idx,
1362 file_idx: line_key.file_idx,
1363 };
1364 let section_key = SectionKey {
1365 commit_idx: line_key.commit_idx,
1366 file_idx: line_key.file_idx,
1367 section_idx: line_key.section_idx,
1368 };
1369 self.expanded_items.contains(&SelectionKey::File(file_key))
1370 && self
1371 .expanded_items
1372 .contains(&SelectionKey::Section(section_key))
1373 }
1374 })
1375 .collect();
1376 let index = visible_keys.iter().enumerate().find_map(|(k, v)| {
1377 if v == &self.selection_key {
1378 Some(k)
1379 } else {
1380 None
1381 }
1382 });
1383 (visible_keys, index)
1384 }
1385
1386 fn select_prev(&self, keys: &[SelectionKey], index: Option<usize>) -> SelectionKey {
1387 match index {
1388 None => self.first_selection_key(),
1389 Some(index) => match index.checked_sub(1) {
1390 Some(index) => keys[index],
1391 None => {
1392 *keys.last().unwrap()
1394 }
1395 },
1396 }
1397 }
1398
1399 fn select_next(&self, keys: &[SelectionKey], index: Option<usize>) -> SelectionKey {
1400 match index {
1401 None => self.first_selection_key(),
1402 Some(index) => match keys.get(index + 1) {
1403 Some(key) => *key,
1404 None => keys[0],
1405 },
1406 }
1407 }
1408
1409 fn select_prev_or_next_of_same_kind(&self, select_previous: bool) -> SelectionKey {
1414 let (keys, index) = self.find_selection();
1415 let iterate_keys_with_wrap_around = |i| -> Box<dyn DoubleEndedIterator<Item = _>> {
1416 let forward_iter = keys[i + 1..] .iter()
1418 .chain(keys[..i].iter());
1419 if select_previous {
1420 Box::new(forward_iter.rev())
1421 } else {
1422 Box::new(forward_iter)
1423 }
1424 };
1425 match index {
1426 None => self.first_selection_key(),
1427 Some(index) => {
1428 match iterate_keys_with_wrap_around(index)
1429 .find(|k| std::mem::discriminant(*k) == std::mem::discriminant(&keys[index]))
1430 {
1431 None => keys[index],
1432 Some(key) => *key,
1433 }
1434 }
1435 }
1436 }
1437
1438 fn select_prev_page(
1439 &self,
1440 term_height: usize,
1441 drawn_rects: &DrawnRects<ComponentId>,
1442 ) -> SelectionKey {
1443 let (keys, index) = self.find_selection();
1444 let mut index = match index {
1445 Some(index) => index,
1446 None => return SelectionKey::None,
1447 };
1448
1449 let original_y = match self.selection_key_y(drawn_rects, self.selection_key) {
1450 Some(original_y) => original_y,
1451 None => {
1452 return SelectionKey::None;
1453 }
1454 };
1455 let target_y = original_y.saturating_sub(term_height.unwrap_isize() / 2);
1456 while index > 0 {
1457 index -= 1;
1458 let selection_key_y = self.selection_key_y(drawn_rects, keys[index]);
1459 if let Some(selection_key_y) = selection_key_y {
1460 if selection_key_y <= target_y {
1461 break;
1462 }
1463 }
1464 }
1465 keys[index]
1466 }
1467
1468 fn select_next_page(
1469 &self,
1470 term_height: usize,
1471 drawn_rects: &DrawnRects<ComponentId>,
1472 ) -> SelectionKey {
1473 let (keys, index) = self.find_selection();
1474 let mut index = match index {
1475 Some(index) => index,
1476 None => return SelectionKey::None,
1477 };
1478
1479 let original_y = match self.selection_key_y(drawn_rects, self.selection_key) {
1480 Some(original_y) => original_y,
1481 None => return SelectionKey::None,
1482 };
1483 let target_y = original_y.saturating_add(term_height.unwrap_isize() / 2);
1484 while index + 1 < keys.len() {
1485 index += 1;
1486 let selection_key_y = self.selection_key_y(drawn_rects, keys[index]);
1487 if let Some(selection_key_y) = selection_key_y {
1488 if selection_key_y >= target_y {
1489 break;
1490 }
1491 }
1492 }
1493 keys[index]
1494 }
1495
1496 fn select_inner(&self) -> SelectionKey {
1497 self.all_selection_keys()
1498 .into_iter()
1499 .skip_while(|selection_key| selection_key != &self.selection_key)
1500 .skip(1)
1501 .find(|selection_key| {
1502 match (self.selection_key, selection_key) {
1503 (SelectionKey::None, _) => true,
1504 (_, SelectionKey::None) => false, (SelectionKey::File(_), SelectionKey::File(_)) => false,
1507 (SelectionKey::File(_), SelectionKey::Section(_)) => true,
1508 (SelectionKey::File(_), SelectionKey::Line(_)) => false, (SelectionKey::Section(_), SelectionKey::File(_))
1511 | (SelectionKey::Section(_), SelectionKey::Section(_)) => false,
1512 (SelectionKey::Section(_), SelectionKey::Line(_)) => true,
1513
1514 (SelectionKey::Line(_), _) => false,
1515 }
1516 })
1517 .unwrap_or(self.selection_key)
1518 }
1519
1520 fn select_outer(&self, fold_section: bool) -> StateUpdate {
1521 match self.selection_key {
1522 SelectionKey::None => StateUpdate::None,
1523 selection_key @ SelectionKey::File(_) => {
1524 StateUpdate::SetExpandItem(selection_key, false)
1525 }
1526 selection_key @ SelectionKey::Section(SectionKey {
1527 commit_idx,
1528 file_idx,
1529 section_idx: _,
1530 }) => {
1531 if fold_section && self.expanded_items.contains(&selection_key) {
1534 StateUpdate::SetExpandItem(selection_key, false)
1535 } else {
1536 StateUpdate::SelectItem {
1537 selection_key: SelectionKey::File(FileKey {
1538 commit_idx,
1539 file_idx,
1540 }),
1541 ensure_in_viewport: true,
1542 }
1543 }
1544 }
1545 SelectionKey::Line(LineKey {
1546 commit_idx,
1547 file_idx,
1548 section_idx,
1549 line_idx: _,
1550 }) => StateUpdate::SelectItem {
1551 selection_key: SelectionKey::Section(SectionKey {
1552 commit_idx,
1553 file_idx,
1554 section_idx,
1555 }),
1556 ensure_in_viewport: true,
1557 },
1558 }
1559 }
1560
1561 fn advance_to_next_of_kind(&self) -> SelectionKey {
1562 let (keys, index) = self.find_selection();
1563 let index = match index {
1564 Some(index) => index,
1565 None => return SelectionKey::None,
1566 };
1567 keys.iter()
1568 .skip(index + 1)
1569 .copied()
1570 .find(|key| match (self.selection_key, key) {
1571 (SelectionKey::None, _)
1572 | (SelectionKey::File(_), SelectionKey::File(_))
1573 | (SelectionKey::Section(_), SelectionKey::Section(_))
1574 | (SelectionKey::Line(_), SelectionKey::Line(_)) => true,
1575 (
1576 SelectionKey::File(_),
1577 SelectionKey::None | SelectionKey::Section(_) | SelectionKey::Line(_),
1578 )
1579 | (
1580 SelectionKey::Section(_),
1581 SelectionKey::None | SelectionKey::File(_) | SelectionKey::Line(_),
1582 )
1583 | (
1584 SelectionKey::Line(_),
1585 SelectionKey::None | SelectionKey::File(_) | SelectionKey::Section(_),
1586 ) => false,
1587 })
1588 .unwrap_or(self.selection_key)
1589 }
1590
1591 fn selection_key_y(
1592 &self,
1593 drawn_rects: &DrawnRects<ComponentId>,
1594 selection_key: SelectionKey,
1595 ) -> Option<isize> {
1596 let rect = self.selection_rect(drawn_rects, selection_key)?;
1597 Some(rect.y)
1598 }
1599
1600 fn selection_rect(
1601 &self,
1602 drawn_rects: &DrawnRects<ComponentId>,
1603 selection_key: SelectionKey,
1604 ) -> Option<Rect> {
1605 let id = match selection_key {
1606 SelectionKey::None => return None,
1607 SelectionKey::File(_) | SelectionKey::Section(_) | SelectionKey::Line(_) => {
1608 ComponentId::SelectableItem(selection_key)
1609 }
1610 };
1611 match drawn_rects.get(&id) {
1612 Some(DrawnRect { rect, timestamp: _ }) => Some(*rect),
1613 None => {
1614 if cfg!(debug_assertions) {
1615 panic!(
1616 "could not look up drawn rect for component with ID {id:?}; was it drawn?"
1617 )
1618 } else {
1619 warn!(component_id = ?id, "could not look up drawn rect for component; was it drawn?");
1620 None
1621 }
1622 }
1623 }
1624 }
1625
1626 fn ensure_in_viewport(
1627 &self,
1628 term_height: usize,
1629 drawn_rects: &DrawnRects<ComponentId>,
1630 selection_key: SelectionKey,
1631 ) -> Option<isize> {
1632 let menu_bar_height = 1;
1633 let sticky_file_header_height = match selection_key {
1634 SelectionKey::None | SelectionKey::File(_) => 0,
1635 SelectionKey::Section(_) | SelectionKey::Line(_) => 1,
1636 };
1637 let top_margin = sticky_file_header_height + menu_bar_height;
1638
1639 let viewport_top_y = self.scroll_offset_y + top_margin;
1640 let viewport_height = term_height.unwrap_isize() - top_margin;
1641 let viewport_bottom_y = viewport_top_y + viewport_height;
1642
1643 let selection_rect = self.selection_rect(drawn_rects, selection_key)?;
1644 let selection_top_y = selection_rect.y;
1645 let selection_height = selection_rect.height.unwrap_isize();
1646 let selection_bottom_y = selection_top_y + selection_height;
1647
1648 let result = if viewport_top_y <= selection_top_y && selection_bottom_y < viewport_bottom_y
1660 {
1661 self.scroll_offset_y
1663 } else if (
1664 selection_height >= viewport_height
1666 ) || (
1667 selection_top_y < viewport_top_y
1669 ) {
1670 selection_top_y - top_margin
1671 } else {
1672 selection_bottom_y - top_margin - viewport_height
1675 };
1676 Some(result)
1677 }
1678
1679 fn find_component_at(
1680 &self,
1681 drawn_rects: &DrawnRects<ComponentId>,
1682 row: usize,
1683 column: usize,
1684 ) -> ComponentId {
1685 let x = column.unwrap_isize();
1686 let y = row.unwrap_isize() + self.scroll_offset_y;
1687 drawn_rects
1688 .iter()
1689 .filter(|(id, drawn_rect)| {
1690 let DrawnRect { rect, timestamp: _ } = drawn_rect;
1691 rect.contains_point(x, y)
1692 && match id {
1693 ComponentId::App
1694 | ComponentId::AppFiles
1695 | ComponentId::MenuHeader
1696 | ComponentId::CommitMessageView => false,
1697 ComponentId::MenuBar
1698 | ComponentId::MenuItem(_)
1699 | ComponentId::Menu(_)
1700 | ComponentId::CommitEditMessageButton(_)
1701 | ComponentId::FileViewHeader(_)
1702 | ComponentId::SelectableItem(_)
1703 | ComponentId::ToggleBox(_)
1704 | ComponentId::ExpandBox(_)
1705 | ComponentId::HelpDialog
1706 | ComponentId::HelpDialogQuitButton
1707 | ComponentId::QuitDialog
1708 | ComponentId::QuitDialogButton(_) => true,
1709 }
1710 })
1711 .max_by_key(|(id, rect)| {
1712 let DrawnRect { rect: _, timestamp } = rect;
1713 (timestamp, *id)
1714 })
1715 .map(|(id, _rect)| *id)
1716 .unwrap_or(ComponentId::App)
1717 }
1718
1719 fn click_component(&self, menu_bar: &MenuBar, component_id: ComponentId) -> StateUpdate {
1720 match component_id {
1721 ComponentId::App
1722 | ComponentId::AppFiles
1723 | ComponentId::MenuHeader
1724 | ComponentId::CommitMessageView
1725 | ComponentId::QuitDialog => StateUpdate::None,
1726 ComponentId::MenuBar => StateUpdate::UnfocusMenuBar,
1727 ComponentId::Menu(section_idx) => StateUpdate::ClickMenu {
1728 menu_idx: section_idx,
1729 },
1730 ComponentId::MenuItem(item_idx) => {
1731 StateUpdate::ClickMenuItem(self.get_menu_item_event(menu_bar, item_idx))
1732 }
1733 ComponentId::CommitEditMessageButton(commit_idx) => {
1734 StateUpdate::EditCommitMessage { commit_idx }
1735 }
1736 ComponentId::FileViewHeader(file_key) => StateUpdate::SelectItem {
1737 selection_key: SelectionKey::File(file_key),
1738 ensure_in_viewport: false,
1739 },
1740 ComponentId::SelectableItem(selection_key) => StateUpdate::SelectItem {
1741 selection_key,
1742 ensure_in_viewport: false,
1743 },
1744 ComponentId::ToggleBox(selection_key) => {
1745 if self.selection_key == selection_key {
1746 StateUpdate::ToggleItem(selection_key)
1747 } else {
1748 StateUpdate::SelectItem {
1749 selection_key,
1750 ensure_in_viewport: false,
1751 }
1752 }
1753 }
1754 ComponentId::ExpandBox(selection_key) => {
1755 if self.selection_key == selection_key {
1756 StateUpdate::ToggleExpandItem(selection_key)
1757 } else {
1758 StateUpdate::SelectItem {
1759 selection_key,
1760 ensure_in_viewport: false,
1761 }
1762 }
1763 }
1764 ComponentId::QuitDialogButton(QuitDialogButtonId::GoBack) => {
1765 StateUpdate::SetQuitDialog(None)
1766 }
1767 ComponentId::QuitDialogButton(QuitDialogButtonId::Quit) => StateUpdate::QuitCancel,
1768 ComponentId::HelpDialog => StateUpdate::None,
1769 ComponentId::HelpDialogQuitButton => StateUpdate::SetHelpDialog(None),
1770 }
1771 }
1772
1773 fn get_menu_item_event(&self, menu_bar: &MenuBar, item_idx: usize) -> Event {
1774 let MenuBar {
1775 menus,
1776 expanded_menu_idx,
1777 } = menu_bar;
1778 let menu_idx = match expanded_menu_idx {
1779 Some(section_idx) => section_idx,
1780 None => {
1781 warn!(?item_idx, "Clicking menu item when no menu is expanded");
1782 return Event::None;
1783 }
1784 };
1785 let menu = match menus.get(*menu_idx) {
1786 Some(menu) => menu,
1787 None => {
1788 warn!(?menu_idx, "Clicking out-of-bounds menu");
1789 return Event::None;
1790 }
1791 };
1792 let item = match menu.items.get(item_idx) {
1793 Some(item) => item,
1794 None => {
1795 warn!(
1796 ?menu_idx,
1797 ?item_idx,
1798 "Clicking menu bar section item that is out of bounds"
1799 );
1800 return Event::None;
1801 }
1802 };
1803 item.event.clone()
1804 }
1805
1806 fn toggle_item(&mut self, selection: SelectionKey) -> Result<(), RecordError> {
1807 if self.state.is_read_only {
1808 return Ok(());
1809 }
1810
1811 match selection {
1812 SelectionKey::None => {}
1813 SelectionKey::File(file_key) => {
1814 let tristate = self.file_tristate(file_key)?;
1815 let is_checked_new = match tristate {
1816 Tristate::False => true,
1817 Tristate::Partial | Tristate::True => false,
1818 };
1819 self.visit_file(file_key, |file| {
1820 file.set_checked(is_checked_new);
1821 })?;
1822 }
1823 SelectionKey::Section(section_key) => {
1824 let tristate = self.section_tristate(section_key)?;
1825 let is_checked_new = match tristate {
1826 Tristate::False => true,
1827 Tristate::Partial | Tristate::True => false,
1828 };
1829 self.visit_section(section_key, |section| {
1830 section.set_checked(is_checked_new);
1831 })?;
1832 }
1833 SelectionKey::Line(line_key) => {
1834 self.visit_line(line_key, |line| {
1835 line.is_checked = !line.is_checked;
1836 })?;
1837 }
1838 }
1839 Ok(())
1840 }
1841
1842 fn toggle_all(&mut self) {
1843 if self.state.is_read_only {
1844 return;
1845 }
1846
1847 for file in &mut self.state.files {
1848 file.toggle_all();
1849 }
1850 }
1851
1852 fn toggle_all_uniform(&mut self) {
1853 if self.state.is_read_only {
1854 return;
1855 }
1856
1857 let checked = {
1858 let tristate = self
1859 .state
1860 .files
1861 .iter()
1862 .map(|file| file.tristate())
1863 .fold(None, |acc, elem| match (acc, elem) {
1864 (None, tristate) => Some(tristate),
1865 (Some(acc_tristate), tristate) if acc_tristate == tristate => Some(tristate),
1866 _ => Some(Tristate::Partial),
1867 })
1868 .unwrap_or(Tristate::False);
1869 match tristate {
1870 Tristate::False | Tristate::Partial => true,
1871 Tristate::True => false,
1872 }
1873 };
1874 for file in &mut self.state.files {
1875 file.set_checked(checked);
1876 }
1877 }
1878
1879 fn expand_item_ancestors(&mut self, selection: SelectionKey) {
1880 match selection {
1881 SelectionKey::None | SelectionKey::File(_) => {}
1882 SelectionKey::Section(SectionKey {
1883 commit_idx,
1884 file_idx,
1885 section_idx: _,
1886 }) => {
1887 self.expanded_items.insert(SelectionKey::File(FileKey {
1888 commit_idx,
1889 file_idx,
1890 }));
1891 }
1892 SelectionKey::Line(LineKey {
1893 commit_idx,
1894 file_idx,
1895 section_idx,
1896 line_idx: _,
1897 }) => {
1898 self.expanded_items.insert(SelectionKey::File(FileKey {
1899 commit_idx,
1900 file_idx,
1901 }));
1902 self.expanded_items
1903 .insert(SelectionKey::Section(SectionKey {
1904 commit_idx,
1905 file_idx,
1906 section_idx,
1907 }));
1908 }
1909 }
1910 }
1911
1912 fn set_expand_item(&mut self, selection: SelectionKey, is_expanded: bool) {
1913 if is_expanded {
1914 self.expanded_items.insert(selection);
1915 } else {
1916 self.expanded_items.remove(&selection);
1917 }
1918 }
1919
1920 fn toggle_expand_item(&mut self, selection: SelectionKey) -> Result<(), RecordError> {
1921 match selection {
1922 SelectionKey::None => {}
1923 SelectionKey::File(file_key) => {
1924 if !self.expanded_items.insert(SelectionKey::File(file_key)) {
1925 self.expanded_items.remove(&SelectionKey::File(file_key));
1926 }
1927 }
1928 SelectionKey::Section(section_key) => {
1929 if !self
1930 .expanded_items
1931 .insert(SelectionKey::Section(section_key))
1932 {
1933 self.expanded_items
1934 .remove(&SelectionKey::Section(section_key));
1935 }
1936 }
1937 SelectionKey::Line(_) => {
1938 }
1940 }
1941 Ok(())
1942 }
1943
1944 fn expand_initial_items(&mut self) {
1945 self.expanded_items = self
1946 .all_selection_keys()
1947 .into_iter()
1948 .filter(|selection_key| match selection_key {
1949 SelectionKey::None | SelectionKey::File(_) | SelectionKey::Line(_) => false,
1950 SelectionKey::Section(_) => true,
1951 })
1952 .collect();
1953 }
1954
1955 fn toggle_expand_all(&mut self) -> Result<(), RecordError> {
1956 let all_selection_keys: HashSet<_> = self.all_selection_keys().into_iter().collect();
1957 self.expanded_items = if self.expanded_items == all_selection_keys {
1958 self.selection_key = match self.selection_key {
1960 selection_key @ (SelectionKey::None | SelectionKey::File(_)) => selection_key,
1961 SelectionKey::Section(SectionKey {
1962 commit_idx,
1963 file_idx,
1964 section_idx: _,
1965 })
1966 | SelectionKey::Line(LineKey {
1967 commit_idx,
1968 file_idx,
1969 section_idx: _,
1970 line_idx: _,
1971 }) => SelectionKey::File(FileKey {
1972 commit_idx,
1973 file_idx,
1974 }),
1975 };
1976 Default::default()
1977 } else {
1978 all_selection_keys
1979 };
1980 Ok(())
1981 }
1982
1983 fn unfocus_menu_bar(&mut self) {
1984 self.expanded_menu_idx = None;
1985 }
1986
1987 fn click_menu_header(&mut self, menu_idx: usize) {
1988 let menu_idx = Some(menu_idx);
1989 self.expanded_menu_idx = if self.expanded_menu_idx == menu_idx {
1990 None
1991 } else {
1992 menu_idx
1993 };
1994 }
1995
1996 fn click_menu_item(&mut self, event: Event) {
1997 self.expanded_menu_idx = None;
1998 self.pending_events.push(event);
1999 }
2000
2001 fn edit_commit_message(&mut self, commit_idx: usize) -> Result<(), RecordError> {
2002 let message = &mut self.state.commits[commit_idx].message;
2003 let message_str = match message.as_ref() {
2004 Some(message) => message,
2005 None => return Ok(()),
2006 };
2007 let new_message = {
2008 match self.input.terminal_kind() {
2009 TerminalKind::Testing { .. } => {}
2010 TerminalKind::Crossterm => {
2011 Self::clean_up_crossterm()?;
2012 }
2013 }
2014 let result = self.input.edit_commit_message(message_str);
2015 match self.input.terminal_kind() {
2016 TerminalKind::Testing { .. } => {}
2017 TerminalKind::Crossterm => {
2018 Self::set_up_crossterm()?;
2019 }
2020 }
2021 result?
2022 };
2023 *message = Some(new_message);
2024 Ok(())
2025 }
2026
2027 fn file(&self, file_key: FileKey) -> Result<&File, RecordError> {
2028 let FileKey {
2029 commit_idx: _,
2030 file_idx,
2031 } = file_key;
2032 match self.state.files.get(file_idx) {
2033 Some(file) => Ok(file),
2034 None => Err(RecordError::Bug(format!(
2035 "Out-of-bounds file key: {file_key:?}"
2036 ))),
2037 }
2038 }
2039
2040 fn section(&self, section_key: SectionKey) -> Result<&Section, RecordError> {
2041 let SectionKey {
2042 commit_idx,
2043 file_idx,
2044 section_idx,
2045 } = section_key;
2046 let file = self.file(FileKey {
2047 commit_idx,
2048 file_idx,
2049 })?;
2050 match file.sections.get(section_idx) {
2051 Some(section) => Ok(section),
2052 None => Err(RecordError::Bug(format!(
2053 "Out-of-bounds section key: {section_key:?}"
2054 ))),
2055 }
2056 }
2057
2058 fn visit_file<T>(
2059 &mut self,
2060 file_key: FileKey,
2061 f: impl Fn(&mut File) -> T,
2062 ) -> Result<T, RecordError> {
2063 let FileKey {
2064 commit_idx: _,
2065 file_idx,
2066 } = file_key;
2067 match self.state.files.get_mut(file_idx) {
2068 Some(file) => Ok(f(file)),
2069 None => Err(RecordError::Bug(format!(
2070 "Out-of-bounds file key: {file_key:?}"
2071 ))),
2072 }
2073 }
2074
2075 fn file_tristate(&self, file_key: FileKey) -> Result<Tristate, RecordError> {
2076 let file = self.file(file_key)?;
2077 Ok(file.tristate())
2078 }
2079
2080 fn file_expanded(&self, file_key: FileKey) -> Tristate {
2081 let is_expanded = self.expanded_items.contains(&SelectionKey::File(file_key));
2082 if !is_expanded {
2083 Tristate::False
2084 } else {
2085 let any_section_unexpanded = self
2086 .file(file_key)
2087 .unwrap()
2088 .sections
2089 .iter()
2090 .enumerate()
2091 .any(|(section_idx, section)| {
2092 match section {
2093 Section::Unchanged { .. }
2094 | Section::FileMode { .. }
2095 | Section::Binary { .. } => {
2096 false
2098 }
2099 Section::Changed { .. } => {
2100 let section_key = SectionKey {
2101 commit_idx: file_key.commit_idx,
2102 file_idx: file_key.file_idx,
2103 section_idx,
2104 };
2105 !self
2106 .expanded_items
2107 .contains(&SelectionKey::Section(section_key))
2108 }
2109 }
2110 });
2111 if any_section_unexpanded {
2112 Tristate::Partial
2113 } else {
2114 Tristate::True
2115 }
2116 }
2117 }
2118
2119 fn visit_section<T>(
2120 &mut self,
2121 section_key: SectionKey,
2122 f: impl Fn(&mut Section) -> T,
2123 ) -> Result<T, RecordError> {
2124 let SectionKey {
2125 commit_idx: _,
2126 file_idx,
2127 section_idx,
2128 } = section_key;
2129 let file = match self.state.files.get_mut(file_idx) {
2130 Some(file) => file,
2131 None => {
2132 return Err(RecordError::Bug(format!(
2133 "Out-of-bounds file for section key: {section_key:?}"
2134 )));
2135 }
2136 };
2137 match file.sections.get_mut(section_idx) {
2138 Some(section) => Ok(f(section)),
2139 None => Err(RecordError::Bug(format!(
2140 "Out-of-bounds section key: {section_key:?}"
2141 ))),
2142 }
2143 }
2144
2145 fn section_tristate(&self, section_key: SectionKey) -> Result<Tristate, RecordError> {
2146 let section = self.section(section_key)?;
2147 Ok(section.tristate())
2148 }
2149
2150 fn visit_line(
2151 &mut self,
2152 line_key: LineKey,
2153 f: impl FnOnce(&mut SectionChangedLine),
2154 ) -> Result<(), RecordError> {
2155 let LineKey {
2156 commit_idx: _,
2157 file_idx,
2158 section_idx,
2159 line_idx,
2160 } = line_key;
2161 let section = &mut self.state.files[file_idx].sections[section_idx];
2162 match section {
2163 Section::Changed { lines } => {
2164 let line = &mut lines[line_idx];
2165 f(line);
2166 Ok(())
2167 }
2168 Section::Unchanged { .. } | Section::FileMode { .. } | Section::Binary { .. } => {
2169 Ok(())
2171 }
2172 }
2173 }
2174}
2175
2176#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
2177enum ComponentId {
2178 App,
2179 AppFiles,
2180 MenuBar,
2181 MenuHeader,
2182 Menu(usize),
2183 MenuItem(usize),
2184 CommitMessageView,
2185 CommitEditMessageButton(usize),
2186 FileViewHeader(FileKey),
2187 SelectableItem(SelectionKey),
2188 ToggleBox(SelectionKey),
2189 ExpandBox(SelectionKey),
2190 QuitDialog,
2191 QuitDialogButton(QuitDialogButtonId),
2192 HelpDialog,
2193 HelpDialogQuitButton,
2194}
2195
2196#[derive(Clone, Debug)]
2197enum TristateIconStyle {
2198 Check,
2199 Expand,
2200}
2201
2202#[derive(Clone, Debug)]
2203struct TristateBox<Id> {
2204 use_unicode: bool,
2205 id: Id,
2206 tristate: Tristate,
2207 icon_style: TristateIconStyle,
2208 is_focused: bool,
2209 is_read_only: bool,
2210}
2211
2212impl<Id> TristateBox<Id> {
2213 fn text(&self) -> String {
2214 let Self {
2215 use_unicode,
2216 id: _,
2217 tristate,
2218 icon_style,
2219 is_focused,
2220 is_read_only,
2221 } = self;
2222
2223 let (l, r) = match (is_read_only, is_focused) {
2224 (true, _) => ("<", ">"),
2225 (false, false) => ("[", "]"),
2226 (false, true) => ("(", ")"),
2227 };
2228
2229 let inner = match (icon_style, tristate, use_unicode) {
2230 (TristateIconStyle::Expand, Tristate::False, _) => "+",
2231 (TristateIconStyle::Expand, Tristate::True, _) => "-",
2232 (TristateIconStyle::Expand, Tristate::Partial, false) => "~",
2233 (TristateIconStyle::Expand, Tristate::Partial, true) => "±",
2234
2235 (TristateIconStyle::Check, Tristate::False, false) => " ",
2236 (TristateIconStyle::Check, Tristate::True, false) => "*",
2237 (TristateIconStyle::Check, Tristate::Partial, false) => "~",
2238
2239 (TristateIconStyle::Check, Tristate::False, true) => " ",
2240 (TristateIconStyle::Check, Tristate::True, true) => "●",
2241 (TristateIconStyle::Check, Tristate::Partial, true) => "◐",
2242 };
2243 format!("{l}{inner}{r}")
2244 }
2245}
2246
2247impl<Id: Clone + Debug + Eq + Hash> Component for TristateBox<Id> {
2248 type Id = Id;
2249
2250 fn id(&self) -> Self::Id {
2251 self.id.clone()
2252 }
2253
2254 fn draw(&self, viewport: &mut Viewport<Self::Id>, x: isize, y: isize) {
2255 let style = if self.is_read_only {
2256 Style::default().fg(Color::Gray).add_modifier(Modifier::DIM)
2257 } else {
2258 Style::default().add_modifier(Modifier::BOLD)
2259 };
2260 let span = Span::styled(self.text(), style);
2261 viewport.draw_span(x, y, &span);
2262 }
2263}
2264
2265#[allow(dead_code)]
2266#[derive(Clone, Debug)]
2267struct AppDebugInfo {
2268 term_height: usize,
2269 scroll_offset_y: isize,
2270 selection_key: SelectionKey,
2271 selection_key_y: Option<isize>,
2272 drawn_rects: BTreeMap<ComponentId, DrawnRect>, }
2274
2275#[derive(Clone, Debug)]
2276struct AppView<'a> {
2277 debug_info: Option<AppDebugInfo>,
2278 menu_bar: MenuBar<'a>,
2279 commit_view_mode: CommitViewMode,
2280 commit_views: Vec<CommitView<'a>>,
2281 quit_dialog: Option<QuitDialog>,
2282 help_dialog: Option<HelpDialog>,
2283}
2284
2285impl Component for AppView<'_> {
2286 type Id = ComponentId;
2287
2288 fn id(&self) -> Self::Id {
2289 ComponentId::App
2290 }
2291
2292 fn draw(&self, viewport: &mut Viewport<Self::Id>, x: isize, _y: isize) {
2293 let Self {
2294 debug_info,
2295 menu_bar,
2296 commit_view_mode,
2297 commit_views,
2298 quit_dialog,
2299 help_dialog,
2300 } = self;
2301
2302 if let Some(debug_info) = debug_info {
2303 viewport.debug(format!("app debug info: {debug_info:#?}"));
2304 }
2305
2306 let viewport_rect = viewport.mask_rect();
2307
2308 let menu_bar_height = 1usize;
2309 let commit_view_width = match commit_view_mode {
2310 CommitViewMode::Inline => viewport.rect().width,
2311 CommitViewMode::Adjacent => {
2312 const MAX_COMMIT_VIEW_WIDTH: usize = 120;
2313 MAX_COMMIT_VIEW_WIDTH
2314 .min(viewport.rect().width.saturating_sub(CommitView::MARGIN) / 2)
2315 }
2316 };
2317 let commit_views_mask = Mask {
2318 x: viewport_rect.x,
2319 y: viewport_rect.y + menu_bar_height.unwrap_isize(),
2320 width: Some(viewport_rect.width),
2321 height: None,
2322 };
2323 viewport.with_mask(commit_views_mask, |viewport| {
2324 let mut commit_view_x = 0;
2325 for commit_view in commit_views {
2326 let commit_view_mask = Mask {
2327 x: commit_views_mask.x + commit_view_x,
2328 y: commit_views_mask.y,
2329 width: Some(commit_view_width),
2330 height: None,
2331 };
2332 let commit_view_rect = viewport.with_mask(commit_view_mask, |viewport| {
2333 viewport.draw_component(
2334 commit_view_x,
2335 menu_bar_height.unwrap_isize(),
2336 commit_view,
2337 )
2338 });
2339 commit_view_x += (CommitView::MARGIN
2340 + commit_view_mask.apply(commit_view_rect).width)
2341 .unwrap_isize();
2342 }
2343 });
2344
2345 viewport.draw_component(x, viewport_rect.y, menu_bar);
2346
2347 if let Some(quit_dialog) = quit_dialog {
2348 viewport.draw_component(0, 0, quit_dialog);
2349 }
2350 if let Some(help_dialog) = help_dialog {
2351 viewport.draw_component(0, 0, help_dialog);
2352 }
2353 }
2354}
2355
2356#[derive(Clone, Debug)]
2357struct CommitMessageView<'a> {
2358 commit_idx: usize,
2359 commit: &'a Commit,
2360}
2361
2362impl Component for CommitMessageView<'_> {
2363 type Id = ComponentId;
2364
2365 fn id(&self) -> Self::Id {
2366 ComponentId::CommitMessageView
2367 }
2368
2369 fn draw(&self, viewport: &mut Viewport<Self::Id>, x: isize, y: isize) {
2370 let Self { commit_idx, commit } = self;
2371 match commit {
2372 Commit { message: None } => {}
2373 Commit {
2374 message: Some(message),
2375 } => {
2376 viewport.draw_blank(Rect {
2377 x,
2378 y,
2379 width: viewport.mask_rect().width,
2380 height: 1,
2381 });
2382 let y = y + 1;
2383
2384 let style = Style::default();
2385 let button_rect = viewport.draw_component(
2386 x,
2387 y,
2388 &Button {
2389 id: ComponentId::CommitEditMessageButton(*commit_idx),
2390 label: Cow::Borrowed("Edit message"),
2391 style,
2392 is_focused: false,
2393 },
2394 );
2395 let divider_rect =
2396 viewport.draw_span(button_rect.end_x() + 1, y, &Span::raw(" • "));
2397 viewport.draw_text(
2398 divider_rect.end_x() + 1,
2399 y,
2400 Span::styled(
2401 Cow::Borrowed({
2402 let first_line = match message.split_once('\n') {
2403 Some((before, _after)) => before,
2404 None => message,
2405 };
2406 let first_line = first_line.trim();
2407 if first_line.is_empty() {
2408 "(no message)"
2409 } else {
2410 first_line
2411 }
2412 }),
2413 style.add_modifier(Modifier::UNDERLINED),
2414 ),
2415 );
2416 let y = y + 1;
2417
2418 viewport.draw_blank(Rect {
2419 x,
2420 y,
2421 width: viewport.mask_rect().width,
2422 height: 1,
2423 });
2424 }
2425 }
2426 }
2427}
2428
2429#[derive(Clone, Debug)]
2430struct CommitView<'a> {
2431 debug_info: Option<&'a AppDebugInfo>,
2432 commit_message_view: CommitMessageView<'a>,
2433 file_views: Vec<FileView<'a>>,
2434}
2435
2436impl CommitView<'_> {
2437 const MARGIN: usize = 1;
2438}
2439
2440impl Component for CommitView<'_> {
2441 type Id = ComponentId;
2442
2443 fn id(&self) -> Self::Id {
2444 ComponentId::AppFiles
2445 }
2446
2447 fn draw(&self, viewport: &mut Viewport<Self::Id>, x: isize, y: isize) {
2448 let Self {
2449 debug_info,
2450 commit_message_view,
2451 file_views,
2452 } = self;
2453
2454 let commit_message_view_rect = viewport.draw_component(x, y, commit_message_view);
2455 if file_views.is_empty() {
2456 let message = "There are no changes to view.";
2457 let message_rect = centered_rect(
2458 Rect {
2459 x,
2460 y,
2461 width: viewport.mask_rect().width,
2462 height: viewport.mask_rect().height,
2463 },
2464 RectSize {
2465 width: message.len(),
2466 height: 1,
2467 },
2468 50,
2469 50,
2470 );
2471 viewport.draw_text(message_rect.x, message_rect.y, Span::raw(message));
2472 return;
2473 }
2474
2475 let mut y = y;
2476 y += commit_message_view_rect.height.unwrap_isize();
2477 for file_view in file_views {
2478 let file_view_rect = {
2479 let file_view_mask = Mask {
2480 x,
2481 y,
2482 width: viewport.mask().width,
2483 height: None,
2484 };
2485 viewport.with_mask(file_view_mask, |viewport| {
2486 viewport.draw_component(x, y, file_view)
2487 })
2488 };
2489
2490 let mask = viewport.mask();
2492 if file_view_rect.y < mask.y
2493 && mask.y < file_view_rect.y + file_view_rect.height.unwrap_isize()
2494 {
2495 viewport.with_mask(
2496 Mask {
2497 x,
2498 y: mask.y,
2499 width: Some(viewport.mask_rect().width),
2500 height: Some(1),
2501 },
2502 |viewport| {
2503 viewport.draw_component(
2504 x,
2505 mask.y,
2506 &FileViewHeader {
2507 file_key: file_view.file_key,
2508 path: file_view.path,
2509 old_path: file_view.old_path,
2510 is_selected: file_view.is_header_selected,
2511 toggle_box: file_view.toggle_box.clone(),
2512 expand_box: file_view.expand_box.clone(),
2513 },
2514 );
2515 },
2516 );
2517 }
2518
2519 y += file_view_rect.height.unwrap_isize();
2520
2521 if debug_info.is_some() {
2522 viewport.debug(format!(
2523 "file {} dims: {file_view_rect:?}",
2524 file_view.path.to_string_lossy()
2525 ));
2526 }
2527 }
2528 }
2529}
2530
2531#[derive(Clone, Debug)]
2532struct MenuItem<'a> {
2533 label: Cow<'a, str>,
2534 event: Event,
2535}
2536
2537#[derive(Clone, Debug)]
2538struct Menu<'a> {
2539 label: Cow<'a, str>,
2540 items: Vec<MenuItem<'a>>,
2541}
2542
2543impl Component for Menu<'_> {
2544 type Id = ComponentId;
2545
2546 fn id(&self) -> Self::Id {
2547 ComponentId::MenuHeader
2548 }
2549
2550 fn draw(&self, viewport: &mut Viewport<Self::Id>, x: isize, y: isize) {
2551 let Self { label: _, items } = self;
2552
2553 let buttons = items
2554 .iter()
2555 .enumerate()
2556 .map(|(i, item)| Button {
2557 id: ComponentId::MenuItem(i),
2558 label: Cow::Borrowed(&item.label),
2559 style: Style::default(),
2560 is_focused: false,
2561 })
2562 .collect::<Vec<_>>();
2563 let max_width = buttons
2564 .iter()
2565 .map(|button| button.width())
2566 .max()
2567 .unwrap_or_default();
2568 let mut y = y;
2569 for button in buttons {
2570 viewport.draw_span(
2571 x,
2572 y,
2573 &Span::styled(
2574 " ".repeat(max_width),
2575 Style::reset().add_modifier(Modifier::REVERSED),
2576 ),
2577 );
2578 viewport.draw_component(x, y, &button);
2579 y += 1;
2580 }
2581 }
2582}
2583
2584#[derive(Clone, Debug)]
2585struct MenuBar<'a> {
2586 menus: Vec<Menu<'a>>,
2587 expanded_menu_idx: Option<usize>,
2588}
2589
2590impl Component for MenuBar<'_> {
2591 type Id = ComponentId;
2592
2593 fn id(&self) -> Self::Id {
2594 ComponentId::MenuBar
2595 }
2596
2597 fn draw(&self, viewport: &mut Viewport<Self::Id>, x: isize, y: isize) {
2598 let Self {
2599 menus,
2600 expanded_menu_idx,
2601 } = self;
2602
2603 viewport.draw_blank(viewport.rect().top_row());
2604 highlight_rect(viewport, viewport.rect().top_row());
2605 let mut x = x;
2606 for (i, menu) in menus.iter().enumerate() {
2607 let menu_header = Button {
2608 id: ComponentId::Menu(i),
2609 label: Cow::Borrowed(&menu.label),
2610 style: Style::default(),
2611 is_focused: false,
2612 };
2613 let rect = viewport.draw_component(x, y, &menu_header);
2614 if expanded_menu_idx == &Some(i) {
2615 viewport.draw_component(x, y + 1, menu);
2616 }
2617 x += rect.width.unwrap_isize() + 1;
2618 }
2619 }
2620}
2621
2622#[derive(Clone, Debug)]
2623struct FileView<'a> {
2624 debug: bool,
2625 file_key: FileKey,
2626 toggle_box: TristateBox<ComponentId>,
2627 expand_box: TristateBox<ComponentId>,
2628 is_header_selected: bool,
2629 old_path: Option<&'a Path>,
2630 path: &'a Path,
2631 section_views: Vec<SectionView<'a>>,
2632}
2633
2634impl FileView<'_> {
2635 fn is_expanded(&self) -> bool {
2636 match self.expand_box.tristate {
2637 Tristate::False => false,
2638 Tristate::Partial | Tristate::True => true,
2639 }
2640 }
2641}
2642
2643impl Component for FileView<'_> {
2644 type Id = ComponentId;
2645
2646 fn id(&self) -> Self::Id {
2647 ComponentId::SelectableItem(SelectionKey::File(self.file_key))
2648 }
2649
2650 fn draw(&self, viewport: &mut Viewport<Self::Id>, x: isize, y: isize) {
2651 let Self {
2652 debug,
2653 file_key,
2654 toggle_box,
2655 expand_box,
2656 old_path,
2657 path,
2658 section_views,
2659 is_header_selected,
2660 } = self;
2661
2662 let file_view_header_rect = viewport.draw_component(
2663 x,
2664 y,
2665 &FileViewHeader {
2666 file_key: *file_key,
2667 path,
2668 old_path: *old_path,
2669 is_selected: *is_header_selected,
2670 toggle_box: toggle_box.clone(),
2671 expand_box: expand_box.clone(),
2672 },
2673 );
2674 if self.is_expanded() {
2675 let x = x + 2;
2676 let mut section_y = y + file_view_header_rect.height.unwrap_isize();
2677 let expanded_sections: HashSet<usize> = section_views
2678 .iter()
2679 .enumerate()
2680 .filter_map(|(i, view)| {
2681 if view.is_expanded() && view.section.is_editable() {
2682 return Some(i);
2683 }
2684 None
2685 })
2686 .collect();
2687 for (i, section_view) in section_views.iter().enumerate() {
2688 let context_section = !section_view.section.is_editable();
2691 let prev_is_collapsed = i == 0 || !expanded_sections.contains(&(i - 1));
2692 let next_is_collapsed = !expanded_sections.contains(&(i + 1));
2693 if context_section && prev_is_collapsed && next_is_collapsed {
2694 continue;
2695 }
2696
2697 let section_rect = viewport.draw_component(x, section_y, section_view);
2698 section_y += section_rect.height.unwrap_isize();
2699
2700 if *debug {
2701 viewport.debug(format!("section dims: {section_rect:?}",));
2702 }
2703 }
2704 }
2705 }
2706}
2707
2708struct FileViewHeader<'a> {
2709 file_key: FileKey,
2710 path: &'a Path,
2711 old_path: Option<&'a Path>,
2712 is_selected: bool,
2713 toggle_box: TristateBox<ComponentId>,
2714 expand_box: TristateBox<ComponentId>,
2715}
2716
2717impl Component for FileViewHeader<'_> {
2718 type Id = ComponentId;
2719
2720 fn id(&self) -> Self::Id {
2721 let Self {
2722 file_key,
2723 path: _,
2724 old_path: _,
2725 is_selected: _,
2726 toggle_box: _,
2727 expand_box: _,
2728 } = self;
2729 ComponentId::FileViewHeader(*file_key)
2730 }
2731
2732 fn draw(&self, viewport: &mut Viewport<Self::Id>, x: isize, y: isize) {
2733 let Self {
2734 file_key: _,
2735 path,
2736 old_path,
2737 is_selected,
2738 toggle_box,
2739 expand_box,
2740 } = self;
2741
2742 let expand_box_width = expand_box.text().width().unwrap_isize();
2744 let expand_box_rect = viewport.draw_component(
2745 viewport.mask_rect().end_x() - expand_box_width,
2746 y,
2747 expand_box,
2748 );
2749
2750 viewport.with_mask(
2751 Mask {
2752 x,
2753 y,
2754 width: Some((expand_box_rect.x - x).clamp_into_usize()),
2755 height: Some(1),
2756 },
2757 |viewport| {
2758 viewport.draw_blank(Rect {
2759 x,
2760 y,
2761 width: viewport.mask_rect().width,
2762 height: 1,
2763 });
2764 let toggle_box_rect = viewport.draw_component(x, y, toggle_box);
2765 viewport.draw_text(
2766 x + toggle_box_rect.width.unwrap_isize() + 1,
2767 y,
2768 Span::styled(
2769 format!(
2770 "{}{}",
2771 match old_path {
2772 Some(old_path) => format!("{} => ", old_path.to_string_lossy()),
2773 None => String::new(),
2774 },
2775 path.to_string_lossy(),
2776 ),
2777 if *is_selected {
2778 Style::default().fg(Color::Blue)
2779 } else {
2780 Style::default()
2781 },
2782 ),
2783 );
2784 },
2785 );
2786
2787 if *is_selected {
2788 highlight_rect(
2789 viewport,
2790 Rect {
2791 x: viewport.mask_rect().x,
2792 y,
2793 width: viewport.mask_rect().width,
2794 height: 1,
2795 },
2796 );
2797 }
2798 }
2799}
2800
2801#[derive(Clone, Debug)]
2802enum SectionSelection {
2803 SectionHeader,
2804 ChangedLine(usize),
2805}
2806
2807#[derive(Clone, Debug)]
2808struct SectionView<'a> {
2809 use_unicode: bool,
2810 is_read_only: bool,
2811 section_key: SectionKey,
2812 toggle_box: TristateBox<ComponentId>,
2813 expand_box: TristateBox<ComponentId>,
2814 selection: Option<SectionSelection>,
2815 total_num_sections: usize,
2816 editable_section_num: usize,
2817 total_num_editable_sections: usize,
2818 section: &'a Section<'a>,
2819 line_start_num: usize,
2820}
2821
2822impl SectionView<'_> {
2823 fn is_expanded(&self) -> bool {
2824 match self.expand_box.tristate {
2825 Tristate::False => false,
2826 Tristate::Partial => {
2827 true
2829 }
2830 Tristate::True => true,
2831 }
2832 }
2833}
2834
2835impl Component for SectionView<'_> {
2836 type Id = ComponentId;
2837
2838 fn id(&self) -> Self::Id {
2839 ComponentId::SelectableItem(SelectionKey::Section(self.section_key))
2840 }
2841
2842 fn draw(&self, viewport: &mut Viewport<Self::Id>, x: isize, y: isize) {
2843 let Self {
2844 use_unicode,
2845 is_read_only,
2846 section_key,
2847 toggle_box,
2848 expand_box,
2849 selection,
2850 total_num_sections,
2851 editable_section_num,
2852 total_num_editable_sections,
2853 section,
2854 line_start_num,
2855 } = self;
2856 viewport.draw_blank(Rect {
2857 x,
2858 y,
2859 width: viewport.mask_rect().width,
2860 height: 1,
2861 });
2862
2863 let SectionKey {
2864 commit_idx,
2865 file_idx,
2866 section_idx,
2867 } = *section_key;
2868 match section {
2869 Section::Unchanged { lines } => {
2870 if lines.is_empty() {
2871 return;
2872 }
2873
2874 let lines: Vec<_> = lines.iter().enumerate().collect();
2875 let is_first_section = section_idx == 0;
2876 let is_last_section = section_idx + 1 == *total_num_sections;
2877 let before_ellipsis_lines = &lines[..min(NUM_CONTEXT_LINES, lines.len())];
2878 let after_ellipsis_lines = &lines[lines.len().saturating_sub(NUM_CONTEXT_LINES)..];
2879
2880 match (before_ellipsis_lines, after_ellipsis_lines) {
2881 ([.., (last_before_idx, _)], [(first_after_idx, _), ..])
2882 if *last_before_idx + 1 >= *first_after_idx
2883 && !is_first_section
2884 && !is_last_section =>
2885 {
2886 let first_before_idx = before_ellipsis_lines.first().unwrap().0;
2887 let last_after_idx = after_ellipsis_lines.last().unwrap().0;
2888 let overlapped_lines = &lines[first_before_idx..=last_after_idx];
2889 let overlapped_lines = if is_first_section {
2890 &overlapped_lines
2891 [overlapped_lines.len().saturating_sub(NUM_CONTEXT_LINES)..]
2892 } else if is_last_section {
2893 &overlapped_lines[..lines.len().min(NUM_CONTEXT_LINES)]
2894 } else {
2895 overlapped_lines
2896 };
2897 for (dy, (line_idx, line)) in overlapped_lines.iter().enumerate() {
2898 let line_view = SectionLineView {
2899 line_key: LineKey {
2900 commit_idx,
2901 file_idx,
2902 section_idx,
2903 line_idx: *line_idx,
2904 },
2905 inner: SectionLineViewInner::Unchanged {
2906 line: line.as_ref(),
2907 line_num: line_start_num + line_idx,
2908 },
2909 };
2910 viewport.draw_component(x + 2, y + dy.unwrap_isize(), &line_view);
2911 }
2912 return;
2913 }
2914 _ => {}
2915 };
2916
2917 let mut dy = 0;
2918 if !is_first_section {
2919 for (line_idx, line) in before_ellipsis_lines {
2920 let line_view = SectionLineView {
2921 line_key: LineKey {
2922 commit_idx,
2923 file_idx,
2924 section_idx,
2925 line_idx: *line_idx,
2926 },
2927 inner: SectionLineViewInner::Unchanged {
2928 line: line.as_ref(),
2929 line_num: line_start_num + line_idx,
2930 },
2931 };
2932 viewport.draw_component(x + 2, y + dy, &line_view);
2933 dy += 1;
2934 }
2935 }
2936
2937 let should_render_ellipsis = lines.len() > NUM_CONTEXT_LINES;
2938 if should_render_ellipsis {
2939 let ellipsis = if *use_unicode {
2940 "\u{22EE}" } else {
2942 ":"
2943 };
2944 viewport.draw_span(
2945 x + 6, y + dy,
2947 &Span::styled(ellipsis, Style::default().add_modifier(Modifier::DIM)),
2948 );
2949 dy += 1;
2950 }
2951
2952 if !is_last_section {
2953 for (line_idx, line) in after_ellipsis_lines {
2954 let line_view = SectionLineView {
2955 line_key: LineKey {
2956 commit_idx,
2957 file_idx,
2958 section_idx,
2959 line_idx: *line_idx,
2960 },
2961 inner: SectionLineViewInner::Unchanged {
2962 line: line.as_ref(),
2963 line_num: line_start_num + line_idx,
2964 },
2965 };
2966 viewport.draw_component(x + 2, y + dy, &line_view);
2967 dy += 1;
2968 }
2969 }
2970 }
2971
2972 Section::Changed { lines } => {
2973 let expand_box_width = expand_box.text().width().unwrap_isize();
2975 let expand_box_rect = viewport.draw_component(
2976 viewport.mask_rect().width.unwrap_isize() - expand_box_width,
2977 y,
2978 expand_box,
2979 );
2980
2981 viewport.with_mask(
2983 Mask {
2984 x,
2985 y,
2986 width: Some((expand_box_rect.x - x).clamp_into_usize()),
2987 height: Some(1),
2988 },
2989 |viewport| {
2990 let toggle_box_rect = viewport.draw_component(x, y, toggle_box);
2991 viewport.draw_text(
2992 x + toggle_box_rect.width.unwrap_isize() + 1,
2993 y,
2994 Span::styled(
2995 format!(
2996 "Section {editable_section_num}/{total_num_editable_sections}"
2997 ),
2998 Style::default(),
2999 ),
3000 )
3001 },
3002 );
3003
3004 match selection {
3005 Some(SectionSelection::SectionHeader) => {
3006 highlight_rect(
3007 viewport,
3008 Rect {
3009 x: viewport.mask_rect().x,
3010 y,
3011 width: viewport.mask_rect().width,
3012 height: 1,
3013 },
3014 );
3015 }
3016 Some(SectionSelection::ChangedLine(_)) | None => {}
3017 }
3018
3019 if self.is_expanded() {
3020 let y = y + 1;
3022 for (line_idx, line) in lines.iter().enumerate() {
3023 let SectionChangedLine {
3024 is_checked,
3025 change_type,
3026 line,
3027 } = line;
3028 let is_focused = match selection {
3029 Some(SectionSelection::ChangedLine(selected_line_idx)) => {
3030 line_idx == *selected_line_idx
3031 }
3032 Some(SectionSelection::SectionHeader) | None => false,
3033 };
3034 let line_key = LineKey {
3035 commit_idx,
3036 file_idx,
3037 section_idx,
3038 line_idx,
3039 };
3040 let toggle_box = TristateBox {
3041 use_unicode: *use_unicode,
3042 id: ComponentId::ToggleBox(SelectionKey::Line(line_key)),
3043 icon_style: TristateIconStyle::Check,
3044 tristate: Tristate::from(*is_checked),
3045 is_focused,
3046 is_read_only: *is_read_only,
3047 };
3048 let line_view = SectionLineView {
3049 line_key,
3050 inner: SectionLineViewInner::Changed {
3051 toggle_box,
3052 change_type: *change_type,
3053 line: line.as_ref(),
3054 },
3055 };
3056 let y = y + line_idx.unwrap_isize();
3057 viewport.draw_component(x + 2, y, &line_view);
3058 if is_focused {
3059 highlight_rect(
3060 viewport,
3061 Rect {
3062 x: viewport.mask_rect().x,
3063 y,
3064 width: viewport.mask_rect().width,
3065 height: 1,
3066 },
3067 );
3068 }
3069 }
3070 }
3071 }
3072
3073 Section::FileMode {
3074 is_checked,
3075 before,
3076 after,
3077 } => {
3078 let is_focused = match selection {
3079 Some(SectionSelection::SectionHeader) => true,
3080 Some(SectionSelection::ChangedLine(_)) | None => false,
3081 };
3082 let section_key = SectionKey {
3083 commit_idx,
3084 file_idx,
3085 section_idx,
3086 };
3087 let selection_key = SelectionKey::Section(section_key);
3088 let toggle_box = TristateBox {
3089 use_unicode: *use_unicode,
3090 id: ComponentId::ToggleBox(selection_key),
3091 icon_style: TristateIconStyle::Check,
3092 tristate: Tristate::from(*is_checked),
3093 is_focused,
3094 is_read_only: *is_read_only,
3095 };
3096 let toggle_box_rect = viewport.draw_component(x, y, &toggle_box);
3097 let x = x + toggle_box_rect.width.unwrap_isize() + 1;
3098 let text = format!("File mode changed from {before} to {after}");
3099 viewport.draw_text(x, y, Span::styled(text, Style::default().fg(Color::Blue)));
3100 if is_focused {
3101 highlight_rect(
3102 viewport,
3103 Rect {
3104 x: viewport.mask_rect().x,
3105 y,
3106 width: viewport.mask_rect().width,
3107 height: 1,
3108 },
3109 );
3110 }
3111 }
3112
3113 Section::Binary {
3114 is_checked,
3115 old_description,
3116 new_description,
3117 } => {
3118 let is_focused = match selection {
3119 Some(SectionSelection::SectionHeader) => true,
3120 Some(SectionSelection::ChangedLine(_)) | None => false,
3121 };
3122 let section_key = SectionKey {
3123 commit_idx,
3124 file_idx,
3125 section_idx,
3126 };
3127 let toggle_box = TristateBox {
3128 use_unicode: *use_unicode,
3129 id: ComponentId::ToggleBox(SelectionKey::Section(section_key)),
3130 icon_style: TristateIconStyle::Check,
3131 tristate: Tristate::from(*is_checked),
3132 is_focused,
3133 is_read_only: *is_read_only,
3134 };
3135 let toggle_box_rect = viewport.draw_component(x, y, &toggle_box);
3136 let x = x + toggle_box_rect.width.unwrap_isize() + 1;
3137
3138 let text = {
3139 let mut result =
3140 vec![if old_description.is_some() || new_description.is_some() {
3141 "binary contents:"
3142 } else {
3143 "binary contents"
3144 }
3145 .to_string()];
3146 let description: Vec<_> = [old_description, new_description]
3147 .iter()
3148 .copied()
3149 .flatten()
3150 .map(|s| s.as_ref())
3151 .collect();
3152 result.push(description.join(" -> "));
3153 format!("({})", result.join(" "))
3154 };
3155 viewport.draw_text(x, y, Span::styled(text, Style::default().fg(Color::Blue)));
3156
3157 if is_focused {
3158 highlight_rect(
3159 viewport,
3160 Rect {
3161 x: viewport.mask_rect().x,
3162 y,
3163 width: viewport.mask_rect().width,
3164 height: 1,
3165 },
3166 );
3167 }
3168 }
3169 }
3170 }
3171}
3172
3173#[derive(Clone, Debug)]
3174enum SectionLineViewInner<'a> {
3175 Unchanged {
3176 line: &'a str,
3177 line_num: usize,
3178 },
3179 Changed {
3180 toggle_box: TristateBox<ComponentId>,
3181 change_type: ChangeType,
3182 line: &'a str,
3183 },
3184}
3185
3186fn replace_control_character(character: char) -> Option<&'static str> {
3187 match character {
3188 '\t' => Some("→ "),
3192 '\n' => Some("⏎"),
3193 '\r' => Some("␍"),
3194
3195 '\x00' => Some("␀"),
3196 '\x01' => Some("␁"),
3197 '\x02' => Some("␂"),
3198 '\x03' => Some("␃"),
3199 '\x04' => Some("␄"),
3200 '\x05' => Some("␅"),
3201 '\x06' => Some("␆"),
3202 '\x07' => Some("␇"),
3203 '\x08' => Some("␈"),
3204 '\x0B' => Some("␋"),
3207 '\x0C' => Some("␌"),
3208 '\x0E' => Some("␎"),
3210 '\x0F' => Some("␏"),
3211 '\x10' => Some("␐"),
3212 '\x11' => Some("␑"),
3213 '\x12' => Some("␒"),
3214 '\x13' => Some("␓"),
3215 '\x14' => Some("␔"),
3216 '\x15' => Some("␕"),
3217 '\x16' => Some("␖"),
3218 '\x17' => Some("␗"),
3219 '\x18' => Some("␘"),
3220 '\x19' => Some("␙"),
3221 '\x1A' => Some("␚"),
3222 '\x1B' => Some("␛"),
3223 '\x1C' => Some("␜"),
3224 '\x1D' => Some("␝"),
3225 '\x1E' => Some("␞"),
3226 '\x1F' => Some("␟"),
3227
3228 '\x7F' => Some("␡"),
3229
3230 c if c.width().unwrap_or_default() == 0 => Some("�"),
3231
3232 _ => None,
3233 }
3234}
3235
3236fn push_spans_from_line<'line>(line: &'line str, spans: &mut Vec<Span<'line>>) {
3239 const CONTROL_CHARACTER_STYLE: Style = Style::new().fg(Color::DarkGray);
3240
3241 let mut last_index = 0;
3242 for (idx, char) in line.match_indices(|char| replace_control_character(char).is_some()) {
3244 if let Some(replacement_string) = char.chars().next().and_then(replace_control_character) {
3246 spans.push(Span::raw(&line[last_index..idx]));
3247 spans.push(Span::styled(replacement_string, CONTROL_CHARACTER_STYLE));
3248 last_index = idx + char.len();
3250 }
3251 }
3252 let remaining_line = &line[last_index..];
3254 if !remaining_line.is_empty() {
3255 spans.push(Span::raw(remaining_line));
3256 }
3257}
3258
3259#[derive(Clone, Debug)]
3260struct SectionLineView<'a> {
3261 line_key: LineKey,
3262 inner: SectionLineViewInner<'a>,
3263}
3264
3265impl Component for SectionLineView<'_> {
3266 type Id = ComponentId;
3267
3268 fn id(&self) -> Self::Id {
3269 ComponentId::SelectableItem(SelectionKey::Line(self.line_key))
3270 }
3271
3272 fn draw(&self, viewport: &mut Viewport<Self::Id>, x: isize, y: isize) {
3273 viewport.draw_blank(Rect {
3274 x: viewport.mask_rect().x,
3275 y,
3276 width: viewport.mask_rect().width,
3277 height: 1,
3278 });
3279
3280 match &self.inner {
3281 SectionLineViewInner::Unchanged { line, line_num } => {
3282 let line_number = Span::raw(format!("{line_num:5} "));
3286 let mut spans = vec![line_number];
3287 push_spans_from_line(line, &mut spans);
3288
3289 const UI_UNCHANGED_STYLE: Style = Style::new().add_modifier(Modifier::DIM);
3290 viewport.draw_text(x, y, Line::from(spans).style(UI_UNCHANGED_STYLE));
3291 }
3292
3293 SectionLineViewInner::Changed {
3294 toggle_box,
3295 change_type,
3296 line,
3297 } => {
3298 let toggle_box_rect = viewport.draw_component(x, y, toggle_box);
3299 let x = toggle_box_rect.end_x() + 1;
3300
3301 let (change_type_text, changed_line_style) = match change_type {
3302 ChangeType::Added => ("+ ", Style::default().fg(Color::Green)),
3303 ChangeType::Removed => ("- ", Style::default().fg(Color::Red)),
3304 };
3305
3306 let mut spans = vec![Span::raw(change_type_text)];
3307 push_spans_from_line(line, &mut spans);
3308
3309 viewport.draw_text(x, y, Line::from(spans).style(changed_line_style));
3310 }
3311 }
3312 }
3313}
3314
3315#[derive(Clone, Debug, PartialEq, Eq)]
3316struct QuitDialog {
3317 num_commit_messages: usize,
3318 num_changed_files: usize,
3319 focused_button: QuitDialogButtonId,
3320}
3321
3322impl Component for QuitDialog {
3323 type Id = ComponentId;
3324
3325 fn id(&self) -> Self::Id {
3326 ComponentId::QuitDialog
3327 }
3328
3329 fn draw(&self, viewport: &mut Viewport<Self::Id>, _x: isize, _y: isize) {
3330 let Self {
3331 num_commit_messages,
3332 num_changed_files,
3333 focused_button,
3334 } = self;
3335 let title = "Quit";
3336 let alert_items = {
3337 let mut result = Vec::new();
3338 if *num_commit_messages > 0 {
3339 result.push(format!(
3340 "{num_commit_messages} {}",
3341 if *num_commit_messages == 1 {
3342 "message"
3343 } else {
3344 "messages"
3345 }
3346 ));
3347 }
3348 if *num_changed_files > 0 {
3349 result.push(format!(
3350 "{num_changed_files} {}",
3351 if *num_changed_files == 1 {
3352 "file"
3353 } else {
3354 "files"
3355 }
3356 ));
3357 }
3358 result
3359 };
3360 let alert = if alert_items.is_empty() {
3361 "".to_string()
3363 } else {
3364 format!("You have changes to {}. ", alert_items.join(" and "))
3365 };
3366 let body = format!("{alert}Are you sure you want to quit?",);
3367
3368 let quit_button = Button {
3369 id: ComponentId::QuitDialogButton(QuitDialogButtonId::Quit),
3370 label: Cow::Borrowed("Quit"),
3371 style: Style::default(),
3372 is_focused: match focused_button {
3373 QuitDialogButtonId::Quit => true,
3374 QuitDialogButtonId::GoBack => false,
3375 },
3376 };
3377 let go_back_button = Button {
3378 id: ComponentId::QuitDialogButton(QuitDialogButtonId::GoBack),
3379 label: Cow::Borrowed("Go Back"),
3380 style: Style::default(),
3381 is_focused: match focused_button {
3382 QuitDialogButtonId::GoBack => true,
3383 QuitDialogButtonId::Quit => false,
3384 },
3385 };
3386 let buttons = [quit_button, go_back_button];
3387
3388 let dialog = Dialog {
3389 id: ComponentId::QuitDialog,
3390 title: Cow::Borrowed(title),
3391 body: Cow::Owned(body),
3392 buttons: &buttons,
3393 };
3394 viewport.draw_component(0, 0, &dialog);
3395 }
3396}
3397
3398#[derive(Debug, Clone, Eq, PartialEq)]
3399struct HelpDialog();
3400
3401impl Component for HelpDialog {
3402 type Id = ComponentId;
3403
3404 fn id(&self) -> Self::Id {
3405 ComponentId::HelpDialog
3406 }
3407
3408 fn draw(&self, viewport: &mut Viewport<Self::Id>, _: isize, _: isize) {
3409 let title = "Help";
3410 let body = "You can click the menus with the mouse to view keyboard shortcuts.";
3411
3412 let quit_button = Button {
3413 id: ComponentId::HelpDialogQuitButton,
3414 label: Cow::Borrowed("Close"),
3415 style: Style::default(),
3416 is_focused: true,
3417 };
3418
3419 let buttons = [quit_button];
3420 let dialog = Dialog {
3421 id: self.id(),
3422 title: Cow::Borrowed(title),
3423 body: Cow::Borrowed(body),
3424 buttons: &buttons,
3425 };
3426 viewport.draw_component(0, 0, &dialog);
3427 }
3428}
3429
3430struct Button<'a, Id> {
3431 id: Id,
3432 label: Cow<'a, str>,
3433 style: Style,
3434 is_focused: bool,
3435}
3436
3437impl<Id> Button<'_, Id> {
3438 fn span(&self) -> Span {
3439 let Self {
3440 id: _,
3441 label,
3442 style,
3443 is_focused,
3444 } = self;
3445 if *is_focused {
3446 Span::styled(format!("({label})"), style.add_modifier(Modifier::REVERSED))
3447 } else {
3448 Span::styled(format!("[{label}]"), *style)
3449 }
3450 }
3451
3452 fn width(&self) -> usize {
3453 self.span().width()
3454 }
3455}
3456
3457impl<Id: Clone + Debug + Eq + Hash> Component for Button<'_, Id> {
3458 type Id = Id;
3459
3460 fn id(&self) -> Self::Id {
3461 self.id.clone()
3462 }
3463
3464 fn draw(&self, viewport: &mut Viewport<Self::Id>, x: isize, y: isize) {
3465 let span = self.span();
3466 viewport.draw_span(x, y, &span);
3467 }
3468}
3469
3470struct Dialog<'a, Id> {
3471 id: Id,
3472 title: Cow<'a, str>,
3473 body: Cow<'a, str>,
3474 buttons: &'a [Button<'a, Id>],
3475}
3476
3477impl<Id: Clone + Debug + Eq + Hash> Component for Dialog<'_, Id> {
3478 type Id = Id;
3479
3480 fn id(&self) -> Self::Id {
3481 self.id.clone()
3482 }
3483
3484 fn draw(&self, viewport: &mut Viewport<Self::Id>, _x: isize, _y: isize) {
3485 let Self {
3486 id: _,
3487 title,
3488 body,
3489 buttons,
3490 } = self;
3491 let rect = {
3492 let border_size = 2;
3493 let body_lines = body.lines().count();
3494 let rect = centered_rect(
3495 viewport.rect(),
3496 RectSize {
3497 width: body.width() + border_size,
3500 height: body_lines + border_size,
3501 },
3502 60,
3503 20,
3504 );
3505
3506 let paragraph = Paragraph::new(body.as_ref()).block(
3507 Block::default()
3508 .title(title.as_ref())
3509 .borders(Borders::all()),
3510 );
3511 let tui_rect = viewport.translate_rect(rect);
3512 viewport.draw_widget(tui_rect, Clear);
3513 viewport.draw_widget(tui_rect, paragraph);
3514
3515 rect
3516 };
3517
3518 let mut bottom_x = rect.x + rect.width.unwrap_isize() - 1;
3519 let bottom_y = rect.y + rect.height.unwrap_isize() - 1;
3520 for button in buttons.iter() {
3521 bottom_x -= button.width().unwrap_isize();
3522 let button_rect = viewport.draw_component(bottom_x, bottom_y, button);
3523 bottom_x = button_rect.x - 1;
3524 }
3525 }
3526}
3527
3528fn highlight_rect<Id: Clone + Debug + Eq + Hash>(viewport: &mut Viewport<Id>, rect: Rect) {
3529 viewport.set_style(rect, Style::default().add_modifier(Modifier::REVERSED));
3530}
3531
3532#[cfg(test)]
3533mod tests {
3534 use std::borrow::Cow;
3535
3536 use crate::helpers::TestingInput;
3537
3538 use super::*;
3539
3540 use assert_matches::assert_matches;
3541
3542 #[test]
3543 fn test_event_source_testing() {
3544 let mut event_source = TestingInput::new(80, 24, [Event::QuitCancel]);
3545 assert_matches!(
3546 event_source.next_events().unwrap().as_slice(),
3547 &[Event::QuitCancel]
3548 );
3549 assert_matches!(
3550 event_source.next_events().unwrap().as_slice(),
3551 &[Event::None]
3552 );
3553 }
3554
3555 #[test]
3556 fn test_quit_returns_error() {
3557 let state = RecordState::default();
3558 let mut input = TestingInput::new(80, 24, [Event::QuitCancel]);
3559 let recorder = Recorder::new(state, &mut input);
3560 assert_matches!(recorder.run(), Err(RecordError::Cancelled));
3561
3562 let state = RecordState {
3563 is_read_only: false,
3564 commits: vec![Commit::default(), Commit::default()],
3565 files: vec![File {
3566 old_path: None,
3567 path: Cow::Borrowed(Path::new("foo/bar")),
3568 file_mode: None,
3569 sections: Default::default(),
3570 }],
3571 };
3572 let mut input = TestingInput::new(80, 24, [Event::QuitAccept]);
3573 let recorder = Recorder::new(state.clone(), &mut input);
3574 assert_eq!(recorder.run().unwrap(), state);
3575 }
3576
3577 fn test_push_lines_from_span_impl(line: &str) {
3578 let mut spans = Vec::new();
3579 push_spans_from_line(line, &mut spans); }
3581
3582 proptest::proptest! {
3583 #[test]
3584 fn test_push_lines_from_span(line in ".*") {
3585 test_push_lines_from_span_impl(line.as_str());
3586 }
3587 }
3588}