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