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