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