scm_record/
ui.rs

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