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