git_record/
ui.rs

1use std::cmp::min;
2use std::path::Path;
3use std::sync::mpsc::Sender;
4
5use cursive::event::Event;
6use cursive::theme::{BaseColor, Effect};
7use cursive::traits::{Nameable, Resizable};
8use cursive::utils::markup::StyledString;
9use cursive::views::{Checkbox, Dialog, HideableView, LinearLayout, ScrollView, TextView};
10use cursive::{CursiveRunnable, CursiveRunner, View};
11use tracing::error;
12
13use crate::cursive_utils::{EventDrivenCursiveApp, EventDrivenCursiveAppExt};
14use crate::tristate::{Tristate, TristateBox};
15use crate::{FileState, RecordError, RecordState, Section, SectionChangedLine};
16
17#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
18pub enum SectionChangedLineType {
19    Before,
20    After,
21}
22
23#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
24pub struct FileKey {
25    file_num: usize,
26}
27
28impl FileKey {
29    fn view_id(&self) -> String {
30        let Self { file_num } = self;
31        format!("FileKey({})", file_num)
32    }
33}
34
35#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
36pub struct SectionKey {
37    file_num: usize,
38    section_num: usize,
39}
40
41impl SectionKey {
42    fn view_id(&self) -> String {
43        let Self {
44            file_num,
45            section_num,
46        } = self;
47        format!("HunkKey({},{})", *file_num, section_num)
48    }
49}
50
51#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
52pub struct SectionLineKey {
53    file_num: usize,
54    section_num: usize,
55    section_type: SectionChangedLineType,
56    section_line_num: usize,
57}
58
59impl SectionLineKey {
60    fn view_id(&self) -> String {
61        let Self {
62            file_num,
63            section_num,
64            section_type,
65            section_line_num,
66        } = self;
67        format!(
68            "HunkLineKey({},{},{},{})",
69            file_num,
70            section_num,
71            match section_type {
72                SectionChangedLineType::Before => "B",
73                SectionChangedLineType::After => "A",
74            },
75            section_line_num
76        )
77    }
78}
79
80/// UI component to record the user's changes.
81pub struct Recorder<'a> {
82    did_user_confirm_exit: bool,
83    state: RecordState<'a>,
84}
85
86impl<'a> Recorder<'a> {
87    /// Constructor.
88    pub fn new(state: RecordState<'a>) -> Self {
89        Self {
90            did_user_confirm_exit: false,
91            state,
92        }
93    }
94
95    /// Run the terminal user interface and have the user interactively select
96    /// changes.
97    pub fn run(self, siv: CursiveRunner<CursiveRunnable>) -> Result<RecordState<'a>, RecordError> {
98        EventDrivenCursiveAppExt::run(self, siv)
99    }
100
101    fn make_main_view(&self, main_tx: Sender<Message>) -> impl View {
102        let mut view = LinearLayout::vertical();
103
104        let RecordState { file_states } = &self.state;
105
106        let global_num_changed_sections: usize = file_states
107            .iter()
108            .map(|(_path, file_state)| file_state.count_changed_sections())
109            .sum();
110        let mut global_changed_section_num = 0;
111
112        for (file_num, (path, file_state)) in file_states.iter().enumerate() {
113            let file_key = FileKey { file_num };
114            view.add_child(self.make_file_view(
115                main_tx.clone(),
116                path,
117                file_key,
118                file_state,
119                &mut global_changed_section_num,
120                global_num_changed_sections,
121            ));
122            if file_num + 1 < file_states.len() {
123                // Render a spacer line. Note that an empty string won't render an
124                // empty line.
125                view.add_child(TextView::new(" "));
126            }
127        }
128
129        view
130    }
131
132    fn make_file_view(
133        &self,
134        main_tx: Sender<Message>,
135        path: &Path,
136        file_key: FileKey,
137        file_state: &FileState,
138        global_changed_section_num: &mut usize,
139        global_num_changed_sections: usize,
140    ) -> impl View {
141        let FileKey { file_num } = file_key;
142
143        let mut file_view = LinearLayout::vertical();
144        let mut line_num: usize = 1;
145        let local_num_changed_sections = file_state.count_changed_sections();
146        let mut local_changed_section_num = 0;
147
148        let file_header_view = LinearLayout::horizontal()
149            .child(
150                TristateBox::new()
151                    .with_state({
152                        all_are_same_value(iter_file_selections(file_state)).into_tristate()
153                    })
154                    .on_change({
155                        let main_tx = main_tx.clone();
156                        move |_, new_value| {
157                            if main_tx
158                                .send(Message::ToggleFile(file_key, new_value))
159                                .is_err()
160                            {
161                                // Do nothing.
162                            }
163                        }
164                    })
165                    .with_name(file_key.view_id()),
166            )
167            .child(TextView::new({
168                let mut s = StyledString::new();
169                s.append_plain(" ");
170                s.append_styled(path.to_string_lossy(), Effect::Bold);
171                s
172            }));
173        file_view.add_child(file_header_view);
174
175        let FileState {
176            file_mode: _,
177            sections,
178        } = file_state;
179        for (section_num, section) in sections.iter().enumerate() {
180            match section {
181                Section::Unchanged { contents } => {
182                    const CONTEXT: usize = 2;
183
184                    // Add the trailing context for the previous section (if any).
185                    if section_num > 0 {
186                        let end_index = min(CONTEXT, contents.len());
187                        for (i, line) in contents[..end_index].iter().enumerate() {
188                            file_view.add_child(TextView::new(format!(
189                                "  {} {}",
190                                line_num + i,
191                                line
192                            )));
193                        }
194                    }
195
196                    // Add vertical ellipsis between sections.
197                    if section_num > 0 && section_num + 1 < sections.len() {
198                        file_view.add_child(TextView::new(":"));
199                    }
200
201                    // Add the leading context for the next section (if any).
202                    if section_num + 1 < sections.len() {
203                        let start_index = contents.len().saturating_sub(CONTEXT);
204                        for (i, line) in contents[start_index..].iter().enumerate() {
205                            file_view.add_child(TextView::new(format!(
206                                "  {} {}",
207                                line_num + start_index + i,
208                                line
209                            )));
210                        }
211                    }
212
213                    line_num += contents.len();
214                }
215
216                Section::Changed { before, after } => {
217                    local_changed_section_num += 1;
218                    *global_changed_section_num += 1;
219                    let description = format!(
220                        "section {}/{} in current file, {}/{} total",
221                        local_changed_section_num,
222                        local_num_changed_sections,
223                        global_changed_section_num,
224                        global_num_changed_sections
225                    );
226
227                    self.make_changed_section_views(
228                        main_tx.clone(),
229                        &mut file_view,
230                        file_num,
231                        section_num,
232                        description,
233                        before,
234                        after,
235                    );
236                    line_num += before.len();
237                }
238
239                Section::FileMode {
240                    is_selected: _,
241                    before: _,
242                    after: _,
243                } => {
244                    unimplemented!("make_file_view with Section::FileMode");
245                }
246            }
247        }
248
249        file_view
250    }
251
252    fn make_changed_section_views(
253        &self,
254        main_tx: Sender<Message>,
255        view: &mut LinearLayout,
256        file_num: usize,
257        section_num: usize,
258        section_description: String,
259        before: &[SectionChangedLine],
260        after: &[SectionChangedLine],
261    ) {
262        let mut section_view = LinearLayout::vertical();
263        let section_key = SectionKey {
264            file_num,
265            section_num,
266        };
267
268        for (section_line_num, section_changed_line) in before.iter().enumerate() {
269            let section_line_key = SectionLineKey {
270                file_num,
271                section_num,
272                section_type: SectionChangedLineType::Before,
273                section_line_num,
274            };
275            section_view.add_child(self.make_changed_line_view(
276                main_tx.clone(),
277                section_line_key,
278                section_changed_line,
279            ));
280        }
281
282        for (section_line_num, section_changed_line) in after.iter().enumerate() {
283            let section_line_key = SectionLineKey {
284                file_num,
285                section_num,
286                section_type: SectionChangedLineType::After,
287                section_line_num,
288            };
289            section_view.add_child(self.make_changed_line_view(
290                main_tx.clone(),
291                section_line_key,
292                section_changed_line,
293            ));
294        }
295
296        view.add_child(
297            LinearLayout::horizontal()
298                .child(TextView::new("  "))
299                .child(
300                    TristateBox::new()
301                        .with_state(
302                            all_are_same_value(before.iter().chain(after.iter()).map(
303                                |SectionChangedLine {
304                                     is_selected,
305                                     line: _,
306                                 }| { *is_selected },
307                            ))
308                            .into_tristate(),
309                        )
310                        .on_change({
311                            let section_key = SectionKey {
312                                file_num,
313                                section_num,
314                            };
315                            let main_tx = main_tx;
316                            move |_, new_value| {
317                                if main_tx
318                                    .send(Message::ToggleHunk(section_key, new_value))
319                                    .is_err()
320                                {
321                                    // Do nothing.
322                                }
323                            }
324                        })
325                        .with_name(section_key.view_id()),
326                )
327                .child(TextView::new({
328                    let mut s = StyledString::new();
329                    s.append_plain(" ");
330                    s.append_plain(section_description);
331                    s
332                })),
333        );
334        view.add_child(HideableView::new(section_view));
335    }
336
337    fn make_changed_line_view(
338        &self,
339        main_tx: Sender<Message>,
340        section_line_key: SectionLineKey,
341        section_changed_line: &SectionChangedLine,
342    ) -> impl View {
343        let SectionChangedLine { is_selected, line } = section_changed_line;
344
345        let line_contents = {
346            let (line, style) = match section_line_key.section_type {
347                SectionChangedLineType::Before => (format!(" -{}", line), BaseColor::Red.dark()),
348                SectionChangedLineType::After => (format!(" +{}", line), BaseColor::Green.dark()),
349            };
350            let mut s = StyledString::new();
351            s.append_styled(line, style);
352            s
353        };
354
355        LinearLayout::horizontal()
356            .child(TextView::new("    "))
357            .child(
358                Checkbox::new()
359                    .with_checked(*is_selected)
360                    .on_change({
361                        move |_, is_selected| {
362                            if main_tx
363                                .send(Message::ToggleHunkLine(section_line_key, is_selected))
364                                .is_err()
365                            {
366                                // Do nothing.
367                            }
368                        }
369                    })
370                    .with_name(section_line_key.view_id()),
371            )
372            .child(TextView::new(line_contents))
373    }
374
375    fn toggle_file(
376        &mut self,
377        siv: &mut CursiveRunner<CursiveRunnable>,
378        file_key: FileKey,
379        new_value: Tristate,
380    ) {
381        let FileKey { file_num } = file_key;
382        let (_path, file_state) = &mut self.state.file_states[file_num];
383
384        let new_value = match new_value {
385            Tristate::Unchecked => false,
386            Tristate::Partial => {
387                // Shouldn't happen.
388                true
389            }
390            Tristate::Checked => true,
391        };
392
393        let FileState {
394            file_mode: _,
395            sections,
396        } = file_state;
397        for (section_num, section) in sections.iter_mut().enumerate() {
398            match section {
399                Section::Unchanged { contents: _ } => {
400                    // Do nothing.
401                }
402                Section::Changed { before, after } => {
403                    for (
404                        section_line_num,
405                        SectionChangedLine {
406                            is_selected,
407                            line: _,
408                        },
409                    ) in before.iter_mut().enumerate()
410                    {
411                        *is_selected = new_value;
412                        let section_line_key = SectionLineKey {
413                            file_num,
414                            section_num,
415                            section_type: SectionChangedLineType::Before,
416                            section_line_num,
417                        };
418                        siv.call_on_name(&section_line_key.view_id(), |checkbox: &mut Checkbox| {
419                            checkbox.set_checked(new_value)
420                        });
421                    }
422
423                    for (
424                        section_line_num,
425                        SectionChangedLine {
426                            is_selected,
427                            line: _,
428                        },
429                    ) in after.iter_mut().enumerate()
430                    {
431                        *is_selected = new_value;
432                        let section_line_key = SectionLineKey {
433                            file_num,
434                            section_num,
435                            section_type: SectionChangedLineType::After,
436                            section_line_num,
437                        };
438                        siv.call_on_name(&section_line_key.view_id(), |checkbox: &mut Checkbox| {
439                            checkbox.set_checked(new_value)
440                        });
441                    }
442                }
443                Section::FileMode {
444                    is_selected: _,
445                    before: _,
446                    after: _,
447                } => {
448                    unimplemented!("toggle_file for Section::FileMode");
449                }
450            }
451
452            let section_key = SectionKey {
453                file_num,
454                section_num,
455            };
456            siv.call_on_name(&section_key.view_id(), |tristate_box: &mut TristateBox| {
457                tristate_box.set_state(if new_value {
458                    Tristate::Checked
459                } else {
460                    Tristate::Unchecked
461                });
462            });
463        }
464    }
465
466    fn toggle_section(
467        &mut self,
468        siv: &mut CursiveRunner<CursiveRunnable>,
469        section_key: SectionKey,
470        new_value: Tristate,
471    ) {
472        let SectionKey {
473            file_num,
474            section_num,
475        } = section_key;
476
477        let new_value = match new_value {
478            Tristate::Unchecked => false,
479            Tristate::Partial => {
480                // Shouldn't happen.
481                true
482            }
483            Tristate::Checked => true,
484        };
485
486        let (before, after) = {
487            let (
488                path,
489                FileState {
490                    file_mode: _,
491                    sections,
492                },
493            ) = &mut self.state.file_states[file_num];
494            match &mut sections[section_num] {
495                Section::Unchanged { contents } => {
496                    error!(
497                        ?section_num,
498                        ?path,
499                        ?contents,
500                        "Invalid section num to change"
501                    );
502                    panic!("Invalid section num to change");
503                }
504                Section::Changed { before, after } => (before, after),
505                Section::FileMode {
506                    is_selected: _,
507                    before: _,
508                    after: _,
509                } => {
510                    unimplemented!("toggle_section for Section::FileMode");
511                }
512            }
513        };
514
515        // Update child checkboxes.
516        for changed_line in before.iter_mut() {
517            changed_line.is_selected = new_value;
518        }
519        for changed_line in after.iter_mut() {
520            changed_line.is_selected = new_value;
521        }
522        let section_line_keys = (0..before.len())
523            .map(|section_line_num| SectionLineKey {
524                file_num,
525                section_num,
526                section_type: SectionChangedLineType::Before,
527                section_line_num,
528            })
529            .chain((0..after.len()).map(|section_line_num| SectionLineKey {
530                file_num,
531                section_num,
532                section_type: SectionChangedLineType::After,
533                section_line_num,
534            }));
535        for section_line_key in section_line_keys {
536            siv.call_on_name(&section_line_key.view_id(), |checkbox: &mut Checkbox| {
537                checkbox.set_checked(new_value);
538            });
539        }
540
541        self.refresh_file(siv, FileKey { file_num });
542    }
543
544    fn toggle_section_line(
545        &mut self,
546        siv: &mut CursiveRunner<CursiveRunnable>,
547        section_line_key: SectionLineKey,
548        new_value: bool,
549    ) {
550        let SectionLineKey {
551            file_num,
552            section_num,
553            section_type,
554            section_line_num,
555        } = section_line_key;
556
557        let (
558            path,
559            FileState {
560                file_mode: _,
561                sections,
562            },
563        ) = &mut self.state.file_states[file_num];
564        let section = &mut sections[section_num];
565        let section_changed_lines = match (section, section_type) {
566            (Section::Unchanged { contents }, _) => {
567                error!(
568                    ?section_num,
569                    ?path,
570                    ?contents,
571                    "Invalid section num to change"
572                );
573                panic!("Invalid section num to change");
574            }
575            (Section::Changed { before, .. }, SectionChangedLineType::Before) => before,
576            (Section::Changed { after, .. }, SectionChangedLineType::After) => after,
577            (
578                Section::FileMode {
579                    is_selected: _,
580                    before: _,
581                    after: _,
582                },
583                _,
584            ) => unimplemented!("toggle_section_line for Section::FileMode"),
585        };
586        section_changed_lines[section_line_num].is_selected = new_value;
587
588        self.refresh_section(
589            siv,
590            SectionKey {
591                file_num,
592                section_num,
593            },
594        );
595        self.refresh_file(siv, FileKey { file_num });
596    }
597
598    fn refresh_section(
599        &mut self,
600        siv: &mut CursiveRunner<CursiveRunnable>,
601        section_key: SectionKey,
602    ) {
603        let SectionKey {
604            file_num,
605            section_num,
606        } = section_key;
607        let (
608            _path,
609            FileState {
610                file_mode: _,
611                sections,
612            },
613        ) = &mut self.state.file_states[file_num];
614
615        let section_selections = iter_section_selections(&sections[section_num]);
616        let section_new_value = all_are_same_value(section_selections).into_tristate();
617        let section_key = SectionKey {
618            file_num,
619            section_num,
620        };
621        siv.call_on_name(&section_key.view_id(), |tristate_box: &mut TristateBox| {
622            tristate_box.set_state(section_new_value);
623        });
624    }
625
626    fn refresh_file(&mut self, siv: &mut CursiveRunner<CursiveRunnable>, file_key: FileKey) {
627        let FileKey { file_num } = file_key;
628        let file_state = &mut self.state.file_states[file_num].1;
629
630        let file_selections = iter_file_selections(file_state);
631        let file_new_value = all_are_same_value(file_selections).into_tristate();
632        siv.call_on_name(&file_key.view_id(), |tristate_box: &mut TristateBox| {
633            tristate_box.set_state(file_new_value);
634        });
635    }
636}
637
638fn iter_file_selections<'a>(file_state: &'a FileState) -> impl Iterator<Item = bool> + 'a {
639    let FileState {
640        file_mode: _,
641        sections,
642    } = file_state;
643    sections.iter().flat_map(iter_section_selections)
644}
645
646fn iter_section_selections<'a>(section: &'a Section) -> impl Iterator<Item = bool> + 'a {
647    let iter: Box<dyn Iterator<Item = bool>> = match section {
648        Section::Changed { before, after } => Box::new(
649            before
650                .iter()
651                .map(|changed_line| changed_line.is_selected)
652                .chain(after.iter().map(|changed_line| changed_line.is_selected)),
653        ),
654        Section::Unchanged { contents: _ } => Box::new(std::iter::empty()),
655        Section::FileMode {
656            is_selected: _,
657            before: _,
658            after: _,
659        } => unimplemented!("iter_section_changed_lines for Section::FileMode"),
660    };
661    iter
662}
663
664#[derive(Clone, Debug)]
665pub enum Message {
666    Init,
667    ToggleFile(FileKey, Tristate),
668    ToggleHunk(SectionKey, Tristate),
669    ToggleHunkLine(SectionLineKey, bool),
670    Confirm,
671    Quit,
672}
673
674impl<'a> EventDrivenCursiveApp for Recorder<'a> {
675    type Message = Message;
676
677    type Return = Result<RecordState<'a>, RecordError>;
678
679    fn get_init_message(&self) -> Self::Message {
680        Message::Init
681    }
682
683    fn get_key_bindings(&self) -> Vec<(Event, Self::Message)> {
684        vec![
685            ('c'.into(), Message::Confirm),
686            ('C'.into(), Message::Confirm),
687            ('q'.into(), Message::Quit),
688            ('Q'.into(), Message::Quit),
689        ]
690    }
691
692    fn handle_message(
693        &mut self,
694        siv: &mut CursiveRunner<CursiveRunnable>,
695        main_tx: Sender<Self::Message>,
696        message: Self::Message,
697    ) {
698        match message {
699            Message::Init => {
700                let main_view = self.make_main_view(main_tx);
701                siv.add_layer(ScrollView::new(
702                    // NB: you can't add `min_width` to the `ScrollView` itself,
703                    // or else the scrollbar stops responding to clicks and
704                    // drags.
705                    main_view.min_width(80),
706                ));
707            }
708
709            Message::ToggleFile(file_key, new_value) => {
710                self.toggle_file(siv, file_key, new_value);
711            }
712
713            Message::ToggleHunk(section_key, new_value) => {
714                self.toggle_section(siv, section_key, new_value);
715            }
716
717            Message::ToggleHunkLine(section_line_key, new_value) => {
718                self.toggle_section_line(siv, section_line_key, new_value);
719            }
720
721            Message::Confirm => {
722                self.did_user_confirm_exit = true;
723                siv.quit();
724            }
725
726            Message::Quit => {
727                let has_changes = {
728                    let RecordState { file_states } = &self.state;
729                    let changed_lines = file_states
730                        .iter()
731                        .flat_map(|(_, file_state)| iter_file_selections(file_state));
732                    match all_are_same_value(changed_lines) {
733                        SameValueResult::Empty | SameValueResult::AllSame(_) => false,
734                        SameValueResult::SomeDifferent => true,
735                    }
736                };
737
738                if has_changes {
739                    siv.add_layer(
740                        Dialog::text(
741                            "Are you sure you want to quit? Your selections will be lost.",
742                        )
743                        .button("Ok", |siv| {
744                            siv.quit();
745                        })
746                        .dismiss_button("Cancel"),
747                    );
748                } else {
749                    siv.quit();
750                }
751            }
752        }
753    }
754
755    fn finish(self) -> Self::Return {
756        if self.did_user_confirm_exit {
757            Ok(self.state)
758        } else {
759            Err(RecordError::Cancelled)
760        }
761    }
762}
763
764enum SameValueResult<T> {
765    Empty,
766    AllSame(T),
767    SomeDifferent,
768}
769
770impl SameValueResult<bool> {
771    fn into_tristate(self) -> Tristate {
772        match self {
773            SameValueResult::Empty | SameValueResult::AllSame(true) => Tristate::Checked,
774            SameValueResult::AllSame(false) => Tristate::Unchecked,
775            SameValueResult::SomeDifferent => Tristate::Partial,
776        }
777    }
778}
779
780fn all_are_same_value<Iter, Item>(iter: Iter) -> SameValueResult<Item>
781where
782    Iter: IntoIterator<Item = Item>,
783    Item: Eq,
784{
785    let mut first_value = None;
786    for value in iter {
787        match &first_value {
788            Some(first_value) => {
789                if &value != first_value {
790                    return SameValueResult::SomeDifferent;
791                }
792            }
793            None => {
794                first_value = Some(value);
795            }
796        }
797    }
798
799    match first_value {
800        Some(value) => SameValueResult::AllSame(value),
801        None => SameValueResult::Empty,
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use std::path::PathBuf;
808    use std::rc::Rc;
809    use std::{borrow::Cow, convert::Infallible};
810
811    use cursive::event::Key;
812
813    use crate::cursive_utils::testing::{
814        screen_to_string, CursiveTestingBackend, CursiveTestingEvent,
815    };
816
817    use super::*;
818
819    fn run_test(
820        state: RecordState,
821        events: Vec<CursiveTestingEvent>,
822    ) -> Result<RecordState, RecordError> {
823        let siv = CursiveRunnable::new::<Infallible, _>(move || {
824            Ok(CursiveTestingBackend::init(events.clone()))
825        });
826        let recorder = Recorder::new(state);
827        recorder.run(siv.into_runner())
828    }
829
830    fn example_record_state() -> RecordState<'static> {
831        RecordState {
832            file_states: vec![(
833                PathBuf::from("foo"),
834                FileState {
835                    file_mode: None,
836                    sections: vec![
837                        Section::Unchanged {
838                            contents: vec![
839                                Cow::Borrowed("unchanged 1\n"),
840                                Cow::Borrowed("unchanged 2\n"),
841                            ],
842                        },
843                        Section::Changed {
844                            before: vec![
845                                SectionChangedLine {
846                                    is_selected: true,
847                                    line: Cow::Borrowed("before 1\n"),
848                                },
849                                SectionChangedLine {
850                                    is_selected: true,
851                                    line: Cow::Borrowed("before 2\n"),
852                                },
853                            ],
854                            after: vec![
855                                SectionChangedLine {
856                                    is_selected: true,
857                                    line: Cow::Borrowed("after 1\n"),
858                                },
859                                SectionChangedLine {
860                                    is_selected: false,
861                                    line: Cow::Borrowed("after 2\n"),
862                                },
863                            ],
864                        },
865                    ],
866                },
867            )],
868        }
869    }
870
871    #[test]
872    fn test_cancel() {
873        let screenshot1 = Default::default();
874        let result = run_test(
875            example_record_state(),
876            vec![
877                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot1)),
878                CursiveTestingEvent::Event('q'.into()),
879                CursiveTestingEvent::Event(Key::Enter.into()),
880            ],
881        );
882        insta::assert_snapshot!(screen_to_string(&screenshot1), @r###"
883        [~] foo
884                              1 unchanged 1
885                              2 unchanged 2
886                              [~] section 1/1 in current file, 1/1 total
887                                [X] -before 1
888                                [X] -before 2
889                                [X] +after 1
890                                [ ] +after 2
891        "###);
892        insta::assert_debug_snapshot!(result, @r###"
893        Err(
894            Cancelled,
895        )
896        "###);
897    }
898
899    #[test]
900    fn test_cancel_no_confirm() {
901        let screenshot1 = Default::default();
902        let result = run_test(
903            example_record_state(),
904            vec![
905                CursiveTestingEvent::Event(Key::Down.into()), // section
906                CursiveTestingEvent::Event(Key::Down.into()), // before 1
907                CursiveTestingEvent::Event(Key::Down.into()), // before 2
908                CursiveTestingEvent::Event(Key::Down.into()), // after 1
909                CursiveTestingEvent::Event(Key::Down.into()), // after 2
910                CursiveTestingEvent::Event(' '.into()),
911                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot1)),
912                CursiveTestingEvent::Event('q'.into()), // don't press enter to confirm
913            ],
914        );
915        insta::assert_snapshot!(screen_to_string(&screenshot1), @r###"
916        [X] foo
917                              1 unchanged 1
918                              2 unchanged 2
919                              [X] section 1/1 in current file, 1/1 total
920                                [X] -before 1
921                                [X] -before 2
922                                [X] +after 1
923                                [X] +after 2
924        "###);
925        insta::assert_debug_snapshot!(result, @r###"
926        Err(
927            Cancelled,
928        )
929        "###);
930    }
931
932    #[test]
933    fn test_section_toggle() {
934        let screenshot1 = Default::default();
935        let screenshot2 = Default::default();
936        let screenshot3 = Default::default();
937        let screenshot4 = Default::default();
938        let result = run_test(
939            example_record_state(),
940            vec![
941                CursiveTestingEvent::Event(Key::Down.into()), // move to section
942                CursiveTestingEvent::Event(' '.into()),       // toggle section
943                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot1)),
944                CursiveTestingEvent::Event(' '.into()), // toggle section
945                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot2)),
946                CursiveTestingEvent::Event(Key::Down.into()), // move to line
947                CursiveTestingEvent::Event(' '.into()),       // toggle line
948                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot3)),
949                CursiveTestingEvent::Event(Key::Up.into()), // move to section
950                CursiveTestingEvent::Event(' '.into()),     // toggle section
951                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot4)),
952                CursiveTestingEvent::Event('c'.into()),
953            ],
954        );
955
956        insta::assert_snapshot!(screen_to_string(&screenshot1), @r###"
957        [X] foo
958                              1 unchanged 1
959                              2 unchanged 2
960                              [X] section 1/1 in current file, 1/1 total
961                                [X] -before 1
962                                [X] -before 2
963                                [X] +after 1
964                                [X] +after 2
965        "###);
966        insta::assert_snapshot!(screen_to_string(&screenshot2), @r###"
967        [ ] foo
968                              1 unchanged 1
969                              2 unchanged 2
970                              [ ] section 1/1 in current file, 1/1 total
971                                [ ] -before 1
972                                [ ] -before 2
973                                [ ] +after 1
974                                [ ] +after 2
975        "###);
976        insta::assert_snapshot!(screen_to_string(&screenshot3), @r###"
977        [~] foo
978                              1 unchanged 1
979                              2 unchanged 2
980                              [~] section 1/1 in current file, 1/1 total
981                                [X] -before 1
982                                [ ] -before 2
983                                [ ] +after 1
984                                [ ] +after 2
985        "###);
986        insta::assert_snapshot!(screen_to_string(&screenshot4), @r###"
987        [X] foo
988                              1 unchanged 1
989                              2 unchanged 2
990                              [X] section 1/1 in current file, 1/1 total
991                                [X] -before 1
992                                [X] -before 2
993                                [X] +after 1
994                                [X] +after 2
995        "###);
996        insta::assert_debug_snapshot!(result, @r###"
997        Ok(
998            RecordState {
999                file_states: [
1000                    (
1001                        "foo",
1002                        FileState {
1003                            file_mode: None,
1004                            sections: [
1005                                Unchanged {
1006                                    contents: [
1007                                        "unchanged 1\n",
1008                                        "unchanged 2\n",
1009                                    ],
1010                                },
1011                                Changed {
1012                                    before: [
1013                                        SectionChangedLine {
1014                                            is_selected: true,
1015                                            line: "before 1\n",
1016                                        },
1017                                        SectionChangedLine {
1018                                            is_selected: true,
1019                                            line: "before 2\n",
1020                                        },
1021                                    ],
1022                                    after: [
1023                                        SectionChangedLine {
1024                                            is_selected: true,
1025                                            line: "after 1\n",
1026                                        },
1027                                        SectionChangedLine {
1028                                            is_selected: true,
1029                                            line: "after 2\n",
1030                                        },
1031                                    ],
1032                                },
1033                            ],
1034                        },
1035                    ),
1036                ],
1037            },
1038        )
1039        "###);
1040    }
1041
1042    #[test]
1043    fn test_file_toggle() {
1044        let screenshot1 = Default::default();
1045        let screenshot2 = Default::default();
1046        let screenshot3 = Default::default();
1047        let screenshot4 = Default::default();
1048        let result = run_test(
1049            example_record_state(),
1050            vec![
1051                CursiveTestingEvent::Event(' '.into()), // toggle file
1052                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot1)),
1053                CursiveTestingEvent::Event(Key::Down.into()), // move to section
1054                CursiveTestingEvent::Event(' '.into()),       // toggle section
1055                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot2)),
1056                CursiveTestingEvent::Event(Key::Down.into()), // move to line
1057                CursiveTestingEvent::Event(' '.into()),       // toggle line
1058                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot3)),
1059                CursiveTestingEvent::Event(Key::Up.into()),
1060                CursiveTestingEvent::Event(Key::Up.into()), // move to file
1061                CursiveTestingEvent::Event(' '.into()),     // toggle file
1062                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot4)),
1063                CursiveTestingEvent::Event('c'.into()),
1064            ],
1065        );
1066
1067        insta::assert_snapshot!(screen_to_string(&screenshot1), @r###"
1068        [X] foo
1069                              1 unchanged 1
1070                              2 unchanged 2
1071                              [X] section 1/1 in current file, 1/1 total
1072                                [X] -before 1
1073                                [X] -before 2
1074                                [X] +after 1
1075                                [X] +after 2
1076        "###);
1077        insta::assert_snapshot!(screen_to_string(&screenshot2), @r###"
1078        [ ] foo
1079                              1 unchanged 1
1080                              2 unchanged 2
1081                              [ ] section 1/1 in current file, 1/1 total
1082                                [ ] -before 1
1083                                [ ] -before 2
1084                                [ ] +after 1
1085                                [ ] +after 2
1086        "###);
1087        insta::assert_snapshot!(screen_to_string(&screenshot3), @r###"
1088        [~] foo
1089                              1 unchanged 1
1090                              2 unchanged 2
1091                              [~] section 1/1 in current file, 1/1 total
1092                                [X] -before 1
1093                                [ ] -before 2
1094                                [ ] +after 1
1095                                [ ] +after 2
1096        "###);
1097        insta::assert_snapshot!(screen_to_string(&screenshot4), @r###"
1098        [X] foo
1099                              1 unchanged 1
1100                              2 unchanged 2
1101                              [X] section 1/1 in current file, 1/1 total
1102                                [X] -before 1
1103                                [X] -before 2
1104                                [X] +after 1
1105                                [X] +after 2
1106        "###);
1107        insta::assert_debug_snapshot!(result, @r###"
1108        Ok(
1109            RecordState {
1110                file_states: [
1111                    (
1112                        "foo",
1113                        FileState {
1114                            file_mode: None,
1115                            sections: [
1116                                Unchanged {
1117                                    contents: [
1118                                        "unchanged 1\n",
1119                                        "unchanged 2\n",
1120                                    ],
1121                                },
1122                                Changed {
1123                                    before: [
1124                                        SectionChangedLine {
1125                                            is_selected: true,
1126                                            line: "before 1\n",
1127                                        },
1128                                        SectionChangedLine {
1129                                            is_selected: true,
1130                                            line: "before 2\n",
1131                                        },
1132                                    ],
1133                                    after: [
1134                                        SectionChangedLine {
1135                                            is_selected: true,
1136                                            line: "after 1\n",
1137                                        },
1138                                        SectionChangedLine {
1139                                            is_selected: true,
1140                                            line: "after 2\n",
1141                                        },
1142                                    ],
1143                                },
1144                            ],
1145                        },
1146                    ),
1147                ],
1148            },
1149        )
1150        "###);
1151    }
1152
1153    #[test]
1154    fn test_initial_tristate_states() {
1155        let state = {
1156            let mut state = example_record_state();
1157            let (
1158                _path,
1159                FileState {
1160                    file_mode: _,
1161                    sections,
1162                },
1163            ) = &mut state.file_states[0];
1164            for section in sections {
1165                if let Section::Changed { before, after: _ } = section {
1166                    before[0].is_selected = true;
1167                }
1168            }
1169            state
1170        };
1171
1172        let screenshot1 = Default::default();
1173        let result = run_test(
1174            state,
1175            vec![
1176                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot1)),
1177                CursiveTestingEvent::Event('q'.into()),
1178                CursiveTestingEvent::Event(Key::Enter.into()),
1179            ],
1180        );
1181        insta::assert_debug_snapshot!(result, @r###"
1182        Err(
1183            Cancelled,
1184        )
1185        "###);
1186        insta::assert_snapshot!(screen_to_string(&screenshot1), @r###"
1187        [~] foo
1188                              1 unchanged 1
1189                              2 unchanged 2
1190                              [~] section 1/1 in current file, 1/1 total
1191                                [X] -before 1
1192                                [X] -before 2
1193                                [X] +after 1
1194                                [ ] +after 2
1195        "###);
1196    }
1197
1198    #[test]
1199    fn test_context() {
1200        let state = RecordState {
1201            file_states: vec![(
1202                PathBuf::from("foo"),
1203                FileState {
1204                    file_mode: None,
1205                    sections: vec![
1206                        Section::Unchanged {
1207                            contents: vec![
1208                                Cow::Borrowed("foo"),
1209                                Cow::Borrowed("bar"),
1210                                Cow::Borrowed("baz"),
1211                                Cow::Borrowed("qux"),
1212                            ],
1213                        },
1214                        Section::Changed {
1215                            before: vec![SectionChangedLine {
1216                                is_selected: false,
1217                                line: Cow::Borrowed("changed 1"),
1218                            }],
1219                            after: vec![
1220                                SectionChangedLine {
1221                                    is_selected: false,
1222                                    line: Cow::Borrowed("changed 2"),
1223                                },
1224                                SectionChangedLine {
1225                                    is_selected: false,
1226                                    line: Cow::Borrowed("changed 3"),
1227                                },
1228                            ],
1229                        },
1230                        Section::Unchanged {
1231                            contents: vec![
1232                                Cow::Borrowed("foo"),
1233                                Cow::Borrowed("bar"),
1234                                Cow::Borrowed("baz"),
1235                                Cow::Borrowed("qux"),
1236                            ],
1237                        },
1238                    ],
1239                },
1240            )],
1241        };
1242
1243        let screenshot = Default::default();
1244        let result = run_test(
1245            state,
1246            vec![
1247                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot)),
1248                CursiveTestingEvent::Event('q'.into()),
1249            ],
1250        );
1251
1252        insta::assert_debug_snapshot!(result, @r###"
1253        Err(
1254            Cancelled,
1255        )
1256        "###);
1257        insta::assert_snapshot!(screen_to_string(&screenshot), @r###"
1258        [ ] foo
1259                              3 baz
1260                              4 qux
1261                              [ ] section 1/1 in current file, 1/1 total
1262                                [ ] -changed 1
1263                                [ ] +changed 2
1264                                [ ] +changed 3
1265                              6 foo
1266                              7 bar
1267        "###);
1268    }
1269
1270    #[test]
1271    fn test_render_multiple_sections() -> eyre::Result<()> {
1272        let mut state = example_record_state();
1273        let sections = {
1274            let (_, FileState { sections, .. }) = &state.file_states[0];
1275            sections.clone()
1276        };
1277        state.file_states[0].1.sections = [sections.clone(), sections].concat();
1278
1279        let screenshot = Default::default();
1280        let result = run_test(
1281            state,
1282            vec![
1283                CursiveTestingEvent::TakeScreenshot(Rc::clone(&screenshot)),
1284                CursiveTestingEvent::Event('q'.into()),
1285                CursiveTestingEvent::Event(Key::Enter.into()),
1286            ],
1287        );
1288
1289        insta::assert_debug_snapshot!(result, @r###"
1290        Err(
1291            Cancelled,
1292        )
1293        "###);
1294        insta::assert_snapshot!(screen_to_string(&screenshot), @r###"
1295        [~] foo
1296                              1 unchanged 1
1297                              2 unchanged 2
1298                              [~] section 1/2 in current file, 1/2 total
1299                                [X] -before 1
1300                                [X] -before 2
1301                                [X] +after 1
1302                                [ ] +after 2
1303                              5 unchanged 1
1304                              6 unchanged 2
1305                            :
1306                              5 unchanged 1
1307                              6 unchanged 2
1308                              [~] section 2/2 in current file, 2/2 total
1309                                [X] -before 1
1310                                [X] -before 2
1311                                [X] +after 1
1312                                [ ] +after 2
1313        "###);
1314
1315        Ok(())
1316    }
1317}