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