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