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