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