fm84 0.43.0

A synthwave dual-pane TUI file manager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
use super::*;

/// The branch follows the path, and is the first to go when the path
/// needs the room; who and where sit by the logo while they fit.
#[test]
fn the_branch_after_the_path_and_who_by_the_logo() {
    let mut app_state = AppState::new();
    app_state.options = Options::default();
    app_state.dir_left = PathBuf::from("/home/colt/fm84");
    app_state.branch_left = Some("main".to_string());
    app_state.user_at_host = ("colt@laserbeak".to_string(), false);
    let row = |width: u16, y: u16, app_state: &mut AppState| {
        let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 30)).unwrap();
        render_ui(&mut terminal, app_state);
        (0..width).map(|x| terminal.backend().buffer()[(x, y)].symbol().to_string()).collect::<String>()
    };

    let path_bar = row(100, 3, &mut app_state);
    assert!(path_bar.contains(&format!("/home/colt/fm84 ─ {ICON_BRANCH} main ─")), "{path_bar}");
    let top = row(100, 0, &mut app_state);
    assert!(top.contains(&format!("{} ─ colt@laserbeak ─", logo_icon(IconStyle::NerdFont))), "{top}");
    app_state.options.icon_style = IconStyle::Plain;
    assert!(row(100, 3, &mut app_state).contains("/home/colt/fm84 ─ git:main ─"));

    // Too narrow for both: the path stays and the branch goes, and so does
    // who and where.
    let narrow = row(44, 3, &mut app_state);
    assert!(!narrow.contains("git:main"), "{narrow}");
    assert!(!row(44, 0, &mut app_state).contains("colt@"));
}

/// A tab is clicked where it is drawn, and a long name is cut short.
#[test]
fn tabs_are_drawn_and_clicked_where_they_are() {
    assert_eq!(tab_label(Path::new("/")), "/");
    assert_eq!(tab_label(Path::new("/home/colt/src")), "src");
    assert_eq!(tab_label(Path::new("/tmp/a-rather-long-directory-name")), "a-rather-long-d…");

    let mut app_state = AppState::new();
    app_state.is_left_active = true;
    app_state.dir_left = "/tmp/b".into();
    app_state.tabs_left = crate::app::Tabs::new(vec!["/a".into(), "/tmp/b".into()], 1);
    tab_strip(&mut app_state, true, Rect::new(0, 1, 40, 1));

    // " " then " a " then " b ".
    assert_eq!(app_state.tab_at(0, 1), None);
    assert_eq!(app_state.tab_at(1, 1), Some((true, StripHit::Item(0))));
    assert_eq!(app_state.tab_at(3, 1), Some((true, StripHit::Item(0))));
    assert_eq!(app_state.tab_at(4, 1), Some((true, StripHit::Item(1))));
    assert_eq!(app_state.tab_at(7, 1), None);
    assert_eq!(app_state.tab_at(4, 0), None);
}

/// Too many drives for the half they are in: a window onto them, with
/// arrows that are there only while there is more that way, and every
/// click mapped to what is drawn under it.
#[test]
fn a_crowded_drive_strip_scrolls() {
    use crate::fs_ops::{Mount, MountKind};
    let mut app_state = AppState::new();
    app_state.options.icon_style = IconStyle::Plain;
    app_state.dir_left = "/".into();
    app_state.mounts = (0..12)
        .map(|index| Mount {
            path: format!("/mnt/{index}").into(),
            label: format!("d{index}"),
            kind: MountKind::Disk,
        })
        .collect();
    let area = Rect::new(0, 0, 30, 1);

    // Plain icons are five columns a slot: 30, less 4 for arrows, holds 5.
    drive_strip(&mut app_state, true, area);
    let strip = &app_state.drive_strip_left;
    assert_eq!((strip.first, strip.visible), (0, 5));
    assert_eq!(strip.hits.first(), Some(&StripHit::Item(0)));
    assert_eq!(strip.hits.last(), Some(&StripHit::Forward));
    assert_eq!(app_state.drive_at(1, 0), None); // no back arrow yet
    assert_eq!(app_state.drive_at(2, 0), Some((true, StripHit::Item(0))));
    assert_eq!(app_state.drive_at(27, 0), Some((true, StripHit::Forward)));

    // A page along, and the back arrow appears with the icons where they were.
    app_state.scroll_drive_strip(true, true);
    drive_strip(&mut app_state, true, area);
    assert_eq!(app_state.drive_at(0, 0), Some((true, StripHit::Back)));
    assert_eq!(app_state.drive_at(2, 0), Some((true, StripHit::Item(5))));

    // At the end, no forward arrow.
    app_state.scroll_drive_strip(true, true);
    drive_strip(&mut app_state, true, area);
    assert_eq!(app_state.drive_strip_left.first, 7);
    assert!(!app_state.drive_strip_left.hits.contains(&StripHit::Forward));

    // Room for all: no arrows and the old layout.
    drive_strip(&mut app_state, true, Rect::new(0, 0, 80, 1));
    assert_eq!(app_state.drive_strip_left.first, 0);
    assert_eq!(app_state.drive_at(1, 0), Some((true, StripHit::Item(0))));
}

/// A file name can hold anything but '/' and NUL - an escape sequence, a
/// tab, a bell - and what a widget is handed goes to the terminal as it is.
/// None of it may reach a cell: not in the row, the detail lines under it,
/// the preview beside it, the status bar, or a popup that names the entry.
#[test]
fn a_hostile_name_never_reaches_the_terminal() {
    let dir = std::env::temp_dir().join(format!("fm84-hostile-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let nasty = "a\u{1b}[41;97mHACKED\u{1b}[0m\tbell\u{7}.txt";
    std::fs::write(dir.join(nasty), "contents").unwrap();

    let mut app_state = AppState::new();
    app_state.options = Options::default();
    app_state.show_preview = true;
    app_state.open_dir(true, dir.clone(), Some(nasty.as_ref()));
    app_state.refresh_cursor_detail();
    app_state.refresh_preview();
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();

    let mut clean = |app_state: &mut AppState, what: &str| {
        render_ui(&mut terminal, app_state);
        let dirty: Vec<String> = terminal.backend().buffer().content().iter().filter(|cell| cell.symbol().chars().any(char::is_control)).map(|cell| format!("{:?}", cell.symbol())).collect();
        assert!(dirty.is_empty(), "{what} drew {dirty:?}");
    };

    clean(&mut app_state, "the panel");

    app_state.dialog = Some(Dialog::Delete {
        items: vec![(dir.join(nasty), false)],
        to_trash: true,
    });
    clean(&mut app_state, "the delete popup");

    let mut input = TextInput::new();
    input.set(nasty.to_string());
    app_state.dialog = Some(Dialog::Rename(input));
    clean(&mut app_state, "the rename field");
    let mut input = TextInput::new();
    input.set(nasty.to_string());
    app_state.dialog = Some(Dialog::Prompt(crate::app::PromptKind::GoTo, input));
    clean(&mut app_state, "the go to field");
    app_state.dialog = None;

    app_state.error = Some(format!("No such file or directory: {nasty}"));
    clean(&mut app_state, "the error popup");

    std::fs::remove_dir_all(&dir).unwrap();
}

/// Every field being typed into is drawn as a name being renamed is, in
/// the same colours, with the terminal's cursor in it - or, for Classic,
/// the cell it is on reversed. The marks that found it never reach the
/// terminal.
#[test]
fn every_input_field_wears_the_rename_colours() {
    use crate::app::PromptKind;
    use crate::options::CursorShape;
    use ratatui::backend::Backend;
    // A file for F2 to be renaming.
    let dir = std::env::temp_dir().join(format!("fm84-input-fields-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    std::fs::write(dir.join("file.txt"), "").unwrap();
    let mut app_state = AppState::new();
    app_state.options = Options::default();
    app_state.is_left_active = true;
    app_state.open_dir(true, dir.clone(), Some("file.txt".as_ref()));
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
    let typed = || {
        let mut input = TextInput::new();
        input.set("typed".to_string());
        input
    };
    let dialogs: Vec<(&str, Dialog)> = vec![
        ("F2", Dialog::Rename(typed())),
        ("F7", Dialog::Create { is_dir: true, input: typed() }),
        (
            "F5",
            Dialog::Transfer {
                sources: vec![(std::path::PathBuf::from("/a"), false)],
                is_copy: true,
                input: typed(),
                opened_with: String::new(),
                default: std::path::PathBuf::from("/b"),
            },
        ),
        (
            "Ctrl+Shift+F5",
            Dialog::Symlink {
                sources: vec![(std::path::PathBuf::from("/a"), false)],
                input: typed(),
                opened_with: String::new(),
                default: std::path::PathBuf::from("/b"),
                hard: false,
            },
        ),
        ("+", Dialog::Prompt(PromptKind::Select, typed())),
        ("Ctrl+L", Dialog::Prompt(PromptKind::GoTo, typed())),
        ("Options", Dialog::Options { editing: Some(typed()) }),
    ];
    let reversed = |terminal: &Terminal<ratatui::backend::TestBackend>| terminal.backend().buffer().content().iter().filter(|cell| cell.modifier.contains(Modifier::REVERSED) && cell.bg == palette().rename_background).count();
    let marked = |terminal: &Terminal<ratatui::backend::TestBackend>| {
        terminal
            .backend()
            .buffer()
            .content()
            .iter()
            .any(|cell| cell.modifier.intersects(FIELD_CURSOR | EDITOR_CURSOR | crate::app::CURSOR_AFTER | crate::app::PAST_END))
    };
    for (what, dialog) in dialogs {
        app_state.dialog = Some(dialog);
        for shape in [CursorShape::Bar, CursorShape::Classic] {
            app_state.options.cursor = shape;
            render_ui(&mut terminal, &mut app_state);
            assert!(!marked(&terminal), "{what}, {shape:?}: a mark reached the terminal");
            if shape == CursorShape::Classic {
                assert_eq!(reversed(&terminal), 1, "{what}: no cursor in the rename colours");
            } else {
                // Straight after what is typed.
                let at = terminal.backend_mut().get_cursor_position().unwrap();
                let last = &terminal.backend().buffer()[(at.x - 1, at.y)];
                assert_eq!((last.symbol(), last.bg), ("d", palette().rename_background), "{what}: the cursor is not after the text");
                assert_eq!(reversed(&terminal), 0, "{what}: a cell reversed as well");
            }
            let text = terminal
                .backend()
                .buffer()
                .content()
                .iter()
                .filter(|cell| cell.bg == palette().rename_background && cell.fg == palette().selected_foreground && !cell.modifier.contains(Modifier::REVERSED))
                .count();
            assert!(text >= "typed".len() - 1, "{what}: typed text not in the rename colours");
        }
    }
    app_state.open_find_files();
    render_ui(&mut terminal, &mut app_state);
    assert_eq!(reversed(&terminal), 1, "Alt+F7");
    app_state.open_history();
    app_state.dialog_input().unwrap().set("typed".to_string());
    render_ui(&mut terminal, &mut app_state);
    assert_eq!(reversed(&terminal), 1, "Alt+Down");
    assert!(!marked(&terminal), "Alt+Down: a mark reached the terminal");
    std::fs::remove_dir_all(&dir).unwrap();
}

/// Every popup is one width, and no taller than the panels it sits over,
/// leaving the bars above and below be. F11 and F1 scroll within that.
#[test]
fn popups_share_a_width_and_keep_to_the_panels() {
    let mut app_state = AppState::new();
    app_state.options = Options::default();
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
    // The popup titled `title`: its left edge, width, top and bottom rows.
    let frame = |terminal: &Terminal<ratatui::backend::TestBackend>, title: &str| {
        let buffer = terminal.backend().buffer();
        let rows: Vec<String> = (0..buffer.area.height).map(|y| (0..buffer.area.width).map(|x| buffer[(x, y)].symbol()).collect()).collect();
        let top = rows.iter().position(|row| row.contains(title)).unwrap_or_else(|| panic!("{title} not drawn"));
        // The corners either side of the title: on a popup as tall as
        // the screen, the screen's own are on that row too.
        let chars: Vec<char> = rows[top].chars().collect();
        let at = rows[top].find(title).map(|byte| rows[top][..byte].chars().count()).unwrap();
        let left = chars[..at].iter().rposition(|&c| c == '┌').unwrap();
        let right = at + chars[at..].iter().position(|&c| c == '┐').unwrap();
        let bottom = (top..rows.len()).find(|&y| rows[y].chars().nth(left) == Some('└')).unwrap();
        (left, right - left + 1, top, bottom)
    };
    app_state.open_history();
    let history = app_state.dialog.take().unwrap();
    let mut input = TextInput::new();
    input.set("/b".to_string());
    let dialogs = vec![
        (" Help/About ", Dialog::Help),
        (" Create Directory ", Dialog::Create { is_dir: true, input: TextInput::new() }),
        (
            " Copy file ",
            Dialog::Transfer {
                sources: vec![(PathBuf::from("/a"), false)],
                is_copy: true,
                input,
                opened_with: String::new(),
                default: PathBuf::from("/b"),
            },
        ),
        (" Options ", Dialog::Options { editing: None }),
        (
            " Delete: ",
            Dialog::Delete {
                items: vec![(PathBuf::from("/a"), false)],
                to_trash: false,
            },
        ),
        (" Unsaved Changes ", Dialog::SaveChanges { then_quit: false }),
        (" Bookmarks ", Dialog::Bookmarks { list: vec![PathBuf::from("/a")], index: 0 }),
        (" Directory History ", history),
        (
            " User Menu ",
            Dialog::UserMenu(Box::new(crate::app::UserMenu {
                entries: vec![crate::options::MenuEntry {
                    label: "Disk use".to_string(),
                    command: "du -sh {files}".to_string(),
                }],
                index: 0,
                file: None,
            })),
        ),
        (
            " Rename 2 Entries ",
            Dialog::MultiRename(Box::new(crate::app::MultiRename::new(
                ["a.txt", "b.txt"]
                    .iter()
                    .map(|name| crate::app::RenameEntry {
                        path: PathBuf::from("/").join(name),
                        old: name.to_string(),
                        readable: true,
                    })
                    .collect(),
            ))),
        ),
        (
            " Open a With ",
            Dialog::OpenWith(Box::new(crate::app::OpenWith {
                path: PathBuf::from("/a"),
                openers: vec![crate::opener::Opener {
                    label: "System default".to_string(),
                    detail: String::new(),
                    launch: crate::opener::Launch::Default,
                    terminal: false,
                }],
                index: 0,
            })),
        ),
    ];
    let mut widths = Vec::new();
    for (title, dialog) in dialogs {
        app_state.dialog = Some(dialog);
        render_ui(&mut terminal, &mut app_state);
        let (left, width, top, bottom) = frame(&terminal, title);
        widths.push((title, left, width));
        // Three rows of top bar and the path bar above the panels; the
        // status bar and three of keys below.
        assert!(top >= 4 && bottom <= 30 - 5, "{title} runs from row {top} to {bottom}");
        let screen: String = terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect();
        if title == " Options " {
            assert!(screen.contains(&format!(" 1/{} ", OPTION_ROWS.len())), "F11 does not say it scrolls");
        }
        if title == " Help/About " {
            assert!(screen.contains(" 1-"), "F1 does not say it scrolls");
        }
    }
    assert!(widths.iter().all(|&(_, left, width)| (left, width) == (widths[0].1, widths[0].2)), "{widths:?}");

    // Scrolled past its end, F1 stops with its last line at the bottom.
    app_state.dialog = Some(Dialog::Help);
    app_state.help_scroll = usize::MAX;
    render_ui(&mut terminal, &mut app_state);
    let screen: String = terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect();
    let help = help_lines();
    assert!(screen.contains(help[help.len() - 1].0.trim()), "F1's last line");
    assert!(screen.contains(" About "), "the About heading");
    assert!(screen.contains(&format!("v{}", env!("CARGO_PKG_VERSION"))), "F1's About");
    assert!(screen.contains(&format!("-{}/{} ", help.len(), help.len())), "F1's corner");
}

/// With the cursor at the end, a directory being renamed reads `[doc]`
/// and a path being typed ends where it ends: the terminal's cursor sits
/// on what follows, with no space put in for it. Classic's cursor is a
/// cell, and has one.
#[test]
fn nothing_is_added_past_the_end_for_the_cursor() {
    use crate::options::CursorShape;
    let dir = std::env::temp_dir().join(format!("fm84-past-end-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(dir.join("doc")).unwrap();
    let mut app_state = AppState::new();
    app_state.options = Options::default();
    app_state.is_left_active = true;
    app_state.open_dir(true, dir.clone(), Some("doc".as_ref()));
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
    let screen = |terminal: &Terminal<ratatui::backend::TestBackend>| terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect::<String>();
    let typed = |text: &str| {
        let mut input = TextInput::new();
        input.set(text.to_string());
        input
    };

    app_state.dialog = Some(Dialog::Rename(typed("doc")));
    render_ui(&mut terminal, &mut app_state);
    assert!(screen(&terminal).contains("[doc]"), "F2 on a directory");
    app_state.options.cursor = CursorShape::Classic;
    render_ui(&mut terminal, &mut app_state);
    assert!(screen(&terminal).contains("[doc ]"), "Classic's cursor has a cell");

    app_state.options.cursor = CursorShape::Bar;
    app_state.dialog = Some(Dialog::Prompt(PromptKind::GoTo, typed("/somewhere")));
    render_ui(&mut terminal, &mut app_state);
    assert!(screen(&terminal).contains("/somewhere ─"), "Ctrl+L");
    std::fs::remove_dir_all(&dir).unwrap();
}

/// The editor's cursor is the terminal's, on the character it is before;
/// a prompt over the editor takes it, as that is where the typing goes.
/// Classic paints the cell instead, in the colours it always had.
#[test]
fn the_editor_cursor_gives_way_to_a_prompt() {
    use crate::options::CursorShape;
    use ratatui::backend::Backend;
    let file = std::env::temp_dir().join(format!("fm84-editor-cursor-{}.txt", std::process::id()));
    std::fs::write(&file, "hello\n").unwrap();
    let mut app_state = AppState::new();
    app_state.options = Options::default();
    app_state.open_editor(file.clone()).unwrap();
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();

    render_ui(&mut terminal, &mut app_state);
    let in_editor = terminal.backend_mut().get_cursor_position().unwrap();
    assert_eq!(terminal.backend().buffer()[in_editor].symbol(), "h");

    app_state.open_prompt(crate::app::PromptKind::Find);
    app_state.dialog_input().unwrap().set("he".to_string());
    render_ui(&mut terminal, &mut app_state);
    let at = terminal.backend_mut().get_cursor_position().unwrap();
    let last = &terminal.backend().buffer()[(at.x - 1, at.y)];
    assert_eq!((last.symbol(), last.bg), ("e", palette().rename_background), "the prompt has the cursor");

    app_state.dialog = None;
    app_state.options.cursor = CursorShape::Classic;
    render_ui(&mut terminal, &mut app_state);
    let cell = &terminal.backend().buffer()[in_editor];
    assert_eq!((cell.symbol(), cell.bg), ("h", palette().selected_background));
    std::fs::remove_file(&file).unwrap();
}

/// Ctrl+L types over the active panel's path, in the line above it, and
/// leaves the other panel's and the foot of the screen as they were. A
/// path longer than the room keeps its end, where the typing is, in view.
#[test]
fn go_to_is_typed_over_the_panels_own_path() {
    let mut app_state = AppState::new();
    app_state.options = Options::default();
    app_state.dir_left = std::path::PathBuf::from("/left/dir");
    app_state.dir_right = std::path::PathBuf::from("/right/dir");
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap();
    let row = |terminal: &Terminal<ratatui::backend::TestBackend>, y: u16| -> String {
        let buffer = terminal.backend().buffer();
        (0..buffer.area.width).map(|x| buffer[(x, y)].symbol().to_string()).collect()
    };

    for (is_left, typed) in [(true, "/typed/here"), (false, "/somewhere/else"), (true, &format!("/{}/end", "long".repeat(30))[..])] {
        app_state.is_left_active = is_left;
        let mut input = TextInput::new();
        input.set(typed.to_string());
        app_state.dialog = Some(Dialog::Prompt(PromptKind::GoTo, input));
        render_ui(&mut terminal, &mut app_state);
        let bar = row(&terminal, 3);
        let (left, right) = bar.split_at(bar.find('┬').unwrap());
        let (typed_in, other) = if is_left { (left, right) } else { (right, left) };
        assert!(typed_in.contains(if typed.len() > 40 { "long/end" } else { typed }), "{bar}");
        assert!(other.contains(if is_left { "/right/dir" } else { "/left/dir" }), "{bar}");
        assert!(bar.starts_with("├──") && bar.ends_with("─┤") && bar.chars().count() == 80, "{bar}");
        assert!(!row(&terminal, 19).contains("Go to"));
    }
}

/// The hexdump takes the widest layout its pane holds and never runs past
/// it, at every width from one that fits nothing to one that fits the lot.
#[test]
fn hex_preview_fits_the_pane_it_is_given() {
    let bytes: Vec<u8> = (0..=255u8).collect();
    let mut seen = Vec::new();
    for width in 1..100u16 {
        let rows = hex_preview(&bytes, width, 4);
        let widest = rows.iter().map(|row| row.chars().count()).max().unwrap();
        // One column is the space every preview line opens with. The
        // narrowest layout is wider than the narrowest pane, which is the
        // one case where the text is left to be clipped.
        if width > 1 + crate::viewer::hex_row_width(4, 0) as u16 {
            assert!(widest < width as usize, "{width} columns held a {widest}-column row");
        }
        let bytes_shown: usize = rows.iter().map(|row| row.chars().filter(|c| *c == '|').count()).sum();
        assert_eq!(bytes_shown, 8, "{width}: every row keeps its gutter");
        seen.push((width, widest));
    }
    // Widest first, so a wider pane never shows less.
    assert!(seen.windows(2).all(|pair| pair[0].1 <= pair[1].1), "{seen:?}");
    assert_eq!(hex_preview(&bytes, 100, 4)[0].chars().count(), crate::viewer::hex_row_width(16, 4));
}

#[test]
fn scrolling_sideways_goes_past_what_a_u16_holds() {
    let text = |spans: &[Span<'static>]| spans.iter().map(|span| span.content.as_ref()).collect::<String>();
    let spans = vec![Span::raw("ab"), Span::styled("cdef", style_title())];
    assert_eq!(text(&skip_columns(spans.clone(), 0)), "abcdef");
    assert_eq!(text(&skip_columns(spans.clone(), 3)), "def");
    // The style stays with what is left of its span.
    assert_eq!(skip_columns(spans.clone(), 3)[0].style, style_title());
    assert!(skip_columns(spans, 10).is_empty());

    // Half of a wide character is a space.
    assert_eq!(text(&skip_columns(vec![Span::raw("界x")], 1)), " x");

    // A minified line, scrolled further than 65,535 columns.
    let long = format!("{}END", "x".repeat(70_000));
    assert_eq!(text(&skip_columns(vec![Span::raw(long)], 70_000)), "END");
}

#[test]
fn popup_text_wraps_at_spaces_and_keeps_the_last_line_in_view() {
    assert_eq!(wrap_text("one two three", 7), ["one two", "three"]);
    assert_eq!(wrap_text("short", 20), ["short"]);
    // A word wider than a row is broken inside it.
    assert_eq!(wrap_text("abcdefghij", 4), ["abcd", "efgh", "ij"]);
    assert_eq!(wrap_text("", 4), [""]);
    // Wide characters count as the two columns they take.
    assert_eq!(wrap_text("界界界", 4), ["界界", "界"]);

    // Twenty rows of names, a destination and the keys, in seven rows:
    // the names give way, and the keys are still there.
    let names = (0..60).map(|n| format!("file{n:02}")).collect::<Vec<_>>().join(", ");
    let lines = vec![Line::from(names), Line::from("to: /tmp"), Line::from("Y / Enter - Yes")];
    let blocks = fit_rows(lines.into_iter().map(|line| wrap_line(line, 30)).collect(), 7);
    let rows: Vec<String> = blocks.iter().flatten().map(|line| line.to_string()).collect();
    assert_eq!(rows.len(), 7);
    assert!(rows[4].ends_with('…'), "{rows:?}");
    assert_eq!(rows[5..], ["to: /tmp", "Y / Enter - Yes"]);
}

/// On a wrapped line, the matches on each row are marked where they are,
/// however far along the line the row is - and only those.
#[test]
fn matches_on_wrapped_rows_are_marked_where_they_fall() {
    let path = std::env::temp_dir().join(format!("fm84-wrap-marks-{}.txt", std::process::id()));
    let words: Vec<String> = (0..40).map(|n| if n % 7 == 3 { "needle".to_string() } else { format!("w{n:02}") }).collect();
    std::fs::write(&path, words.join(" ")).unwrap();
    let mut app_state = AppState::new();
    app_state.open_viewer(path.clone()).unwrap();
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 20)).unwrap();
    render_ui(&mut terminal, &mut app_state);
    app_state.viewer_toggle_wrap();
    app_state.find_term = "needle".to_string();
    app_state.find_shown = true;
    render_ui(&mut terminal, &mut app_state);

    // Every run of marked cells, row by row, reads "needle" - one for each
    // of the six in the line.
    let buffer = terminal.backend().buffer().clone();
    let mut marked = Vec::new();
    for y in 0..20 {
        let mut run = String::new();
        for x in 0..40 {
            let cell = &buffer[(x, y)];
            if cell.modifier.contains(Modifier::UNDERLINED) {
                run.push_str(cell.symbol());
            } else if !run.is_empty() {
                marked.push(std::mem::take(&mut run));
            }
        }
        if !run.is_empty() {
            marked.push(run);
        }
    }
    assert_eq!(marked, vec!["needle"; 6]);
    std::fs::remove_file(path).unwrap();
}

#[test]
fn centered_sits_in_the_middle_and_never_overflows() {
    let area = Rect::new(10, 5, 80, 20);
    assert_eq!(centered(area, 40, 20), Rect::new(30, 5, 40, 20));
    assert_eq!(centered(area, 80, 10), Rect::new(10, 10, 80, 10));
    // Larger than the area, as a filled image is: clipped to it, not shifted.
    assert_eq!(centered(area, 200, 90), area);
    // An odd leftover puts the extra column on the right.
    assert_eq!(centered(area, 79, 20).x, 10);
}

/// An editor open on a file of `lines` numbered lines, and a terminal to
/// draw it on. The file is removed again when the test is done with it.
fn editor_on(name: &str, lines: usize) -> (AppState, Terminal<ratatui::backend::TestBackend>, std::path::PathBuf) {
    let path = std::env::temp_dir().join(format!("fm84-{name}-{}.txt", std::process::id()));
    let text: String = (1..=lines).map(|n| format!("{n}\n")).collect();
    std::fs::write(&path, text).unwrap();
    let mut app_state = AppState::new();
    app_state.options = Options::default();
    app_state.open_editor(path.clone()).unwrap();
    let terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
    (app_state, terminal, path)
}

#[test]
fn deleting_everything_from_the_bottom_of_a_long_file_draws() {
    let (mut app_state, mut terminal, path) = editor_on("select-all", 100);
    render_ui(&mut terminal, &mut app_state);
    // Ctrl+A scrolls to the end; Backspace then leaves a single line. The
    // scroll used to stay where Ctrl+A put it, and the next frame sliced
    // the lines with its start past its end.
    app_state.editor_select_all();
    app_state.editor_backspace();
    render_ui(&mut terminal, &mut app_state);
    let state = app_state.editor().unwrap();
    assert_eq!(state.lines, [""]);
    assert_eq!(state.scroll_offset, 0);
    std::fs::remove_file(path).unwrap();
}

#[test]
fn a_long_paste_leaves_the_cursor_on_screen() {
    let (mut app_state, mut terminal, path) = editor_on("paste", 3);
    render_ui(&mut terminal, &mut app_state);
    let pasted: String = (0..200).map(|n| format!("pasted {n}\n")).collect();
    app_state.editor_insert_text(&pasted);
    render_ui(&mut terminal, &mut app_state);
    let state = app_state.editor().unwrap();
    let height = app_state.editor_viewport_height;
    assert!(state.cursor_line >= state.scroll_offset && state.cursor_line < state.scroll_offset + height);
    std::fs::remove_file(path).unwrap();
}

/// Scrolled as far as it goes and then given more room, F1 and the diff
/// keep to what the room allows: the first key back up moves them.
#[test]
fn scrolling_keeps_to_the_room_there_is() {
    let mut app_state = AppState::new();
    app_state.options = Options::default();

    // F1, scrolled to the end of a short screen, then the screen grows.
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
    app_state.dialog = Some(Dialog::Help);
    app_state.help_scroll = usize::MAX;
    render_ui(&mut terminal, &mut app_state);
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 50)).unwrap();
    render_ui(&mut terminal, &mut app_state);
    assert_eq!(app_state.help_scroll, help_lines().len() - app_state.help_room, "F1 past its end");

    // A diff whose last difference is near the end: jumping to it stops
    // with the last row at the bottom, not past it.
    app_state.dialog = None;
    let left: Vec<String> = (0..100).map(|n| n.to_string()).collect();
    let mut right = left.clone();
    right[98] = "changed".to_string();
    app_state.screen = Screen::Diff(crate::diff::DiffState::new(PathBuf::from("/a"), PathBuf::from("/b"), left, right));
    render_ui(&mut terminal, &mut app_state);
    let Screen::Diff(state) = &app_state.screen else { unreachable!() };
    assert_eq!(state.scroll_offset, state.rows.len() - app_state.diff_viewport_height, "the diff past its end");
}

/// A diff of two long paths in a narrow window shows the end of each - the
/// file's own name - with "..." where the rest was cut. The "..." was laid on
/// top of a path already as wide as its column, and pushed the last three
/// characters of the name off the edge.
#[test]
fn the_diff_header_keeps_the_end_of_a_long_path() {
    let mut app_state = AppState::new();
    app_state.options = Options::default();
    let deep = "/home/user/projects/somewhere/rather/deep/inside";
    let (left, right) = (PathBuf::from(format!("{deep}/left_name.txt")), PathBuf::from(format!("{deep}/right_name.txt")));
    app_state.screen = Screen::Diff(crate::diff::DiffState::new(left, right, vec!["a".to_string()], vec!["b".to_string()]));
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 20)).unwrap();
    render_ui(&mut terminal, &mut app_state);
    let buffer = terminal.backend().buffer();
    let rows: Vec<String> = (0..buffer.area.height).map(|y| (0..buffer.area.width).map(|x| buffer[(x, y)].symbol().to_string()).collect()).collect();
    let header = rows.iter().find(|row| row.contains("...") && row.contains("_name")).expect("the paths are cut short");
    assert!(header.contains("left_name.txt") && header.contains("right_name.txt"), "{header}");
}

/// The cells a scroll bar's thumb is drawn in, by column and row, and which
/// of the two thumbs each is. Every one is in the title's colour.
fn thumb_cells(terminal: &Terminal<ratatui::backend::TestBackend>) -> Vec<(u16, u16, &'static str)> {
    let buffer = terminal.backend().buffer();
    let area = buffer.area;
    let cells: Vec<(u16, u16, &'static str)> = (area.y..area.bottom())
        .flat_map(|y| (area.x..area.right()).map(move |x| (x, y)))
        .filter_map(|(x, y)| {
            let (down, along) = ScrollBarStyle::Heavy.thumbs();
            [down, along].into_iter().find(|&thumb| buffer[(x, y)].symbol() == thumb).map(|thumb| (x, y, thumb))
        })
        .collect();
    for &(x, y, _) in &cells {
        assert_eq!(buffer[(x, y)].fg, crate::display::palette().title, "the thumb at {x},{y}");
    }
    cells
}

/// F3 and F4 have scroll bars in their borders, down the right for the lines
/// and along the bottom for the columns, only when there is more than fits -
/// and none across a wrapped view, where nothing runs past the edge.
#[test]
fn the_viewer_and_the_editor_show_scroll_bars_when_there_is_more() {
    let dir = std::env::temp_dir().join(format!("fm84-scroll-bars-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let short = dir.join("short.txt");
    std::fs::write(&short, "one\ntwo\n").unwrap();
    let tall = dir.join("tall.txt");
    std::fs::write(&tall, (1..=200).map(|n| format!("{n}\n")).collect::<String>()).unwrap();
    let wide = dir.join("wide.txt");
    std::fs::write(&wide, "x".repeat(500)).unwrap();

    let (width, height) = (80, 24);
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
    let mut app_state = AppState::new();
    app_state.options = Options::default();
    let mut shown = |app_state: &mut AppState| {
        render_ui(&mut terminal, app_state);
        thumb_cells(&terminal)
    };

    for edit in [false, true] {
        let open = |app_state: &mut AppState, path: &PathBuf| {
            if edit {
                app_state.open_editor(path.clone()).unwrap();
            } else {
                app_state.open_viewer(path.clone()).unwrap();
            }
        };
        open(&mut app_state, &short);
        assert_eq!(shown(&mut app_state), [], "everything fits (edit: {edit})");

        // Tall: a thumb down one column, at the top of its track.
        open(&mut app_state, &tall);
        let cells = shown(&mut app_state);
        assert!(!cells.is_empty(), "edit: {edit}");
        let column = cells[0].0;
        assert!(cells.iter().all(|&(x, _, thumb)| x == column && thumb == ScrollBarStyle::Heavy.thumbs().0), "one column: {cells:?}");
        let top = cells.iter().map(|&(_, y, _)| y).min().unwrap();
        // Scrolled to the end, the thumb is at the bottom of its track.
        if edit {
            app_state.editor_mut().unwrap().cursor_line = 199;
        } else {
            app_state.viewer_end();
        }
        let bottom = shown(&mut app_state).iter().map(|&(_, y, _)| y).max().unwrap();
        assert!(bottom > top + 10, "moved down: {top} to {bottom}");

        // Wide: a thumb along one row.
        open(&mut app_state, &wide);
        let cells = shown(&mut app_state);
        assert!(!cells.is_empty(), "edit: {edit}");
        let row = cells[0].1;
        assert!(cells.iter().all(|&(_, y, thumb)| y == row && thumb == ScrollBarStyle::Heavy.thumbs().1), "one row: {cells:?}");
    }

    // Wrapped, the long line fits across, and the bar goes.
    app_state.open_viewer(wide).unwrap();
    app_state.viewer_mut().unwrap().wrap = true;
    assert_eq!(shown(&mut app_state), []);
    std::fs::remove_dir_all(&dir).unwrap();
}

/// F11's scroll bar style is what both thumbs are drawn with, stepped through
/// in turn, Heavy first.
#[test]
fn the_scroll_bar_style_from_f11_draws_both_thumbs() {
    let path = std::env::temp_dir().join(format!("fm84-bar-style-{}.txt", std::process::id()));
    std::fs::write(&path, (1..=200).map(|_| format!("{}\n", "x".repeat(300))).collect::<String>()).unwrap();
    let mut app_state = AppState::new();
    app_state.options = Options::default();
    assert_eq!(app_state.options.scroll_bar, ScrollBarStyle::Heavy);
    app_state.open_viewer(path.clone()).unwrap();
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();

    let mut seen = Vec::new();
    for _ in ScrollBarStyle::ALL {
        let style = app_state.options.scroll_bar;
        seen.push(style);
        render_ui(&mut terminal, &mut app_state);
        let buffer = terminal.backend().buffer();
        let drawn = |bar: &crate::scroll_bar::ScrollBar| -> Vec<String> {
            let track = bar.track;
            (track.y..track.bottom()).flat_map(|y| (track.x..track.right()).map(move |x| (x, y))).map(|(x, y)| buffer[(x, y)].symbol().to_string()).collect()
        };
        let (down, along) = style.thumbs();
        for bar in &app_state.scroll_bars {
            let thumb = if bar.vertical { down } else { along };
            assert!(drawn(bar).iter().any(|symbol| symbol == thumb), "{style:?}: {:?}", drawn(bar));
        }
        assert_eq!(app_state.scroll_bars.len(), 2, "{style:?}");
        app_state.options.cycle(crate::options::OptionRow::ScrollBars, true);
    }
    assert_eq!(seen, ScrollBarStyle::ALL);
    assert_eq!(app_state.options.scroll_bar, ScrollBarStyle::Heavy, "round to the first again");
    assert_eq!(app_state.options.value(crate::options::OptionRow::ScrollBars), "Heavy ┃━");
    std::fs::remove_file(path).unwrap();
}

/// In a repository, what git says of each entry is marked at the far end of
/// its name, once git has answered; a name too long for the room is cut to
/// leave it showing.
#[test]
fn git_marks_sit_at_the_end_of_the_name() {
    let dir = std::env::temp_dir().join(format!("fm84-git-marks-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let run = |arguments: &[&str]| {
        std::process::Command::new("git")
            .arg("-C")
            .arg(&dir)
            .args(["-c", "user.name=fm84", "-c", "user.email=fm84@localhost", "-c", "commit.gpgsign=false"])
            .args(arguments)
            .output()
            .is_ok_and(|output| output.status.success())
    };
    if !run(&["init", "--quiet"]) {
        let _ = std::fs::remove_dir_all(&dir);
        return;
    }
    let long = format!("{}.txt", "a".repeat(80));
    std::fs::write(dir.join("kept.txt"), "one").unwrap();
    std::fs::write(dir.join(&long), "one").unwrap();
    assert!(run(&["add", "."]) && run(&["commit", "--quiet", "-m", "first"]));
    std::fs::write(dir.join("kept.txt"), "two").unwrap();
    std::fs::write(dir.join(&long), "two").unwrap();
    std::fs::write(dir.join("new.txt"), "").unwrap();

    let mut app_state = AppState::new();
    app_state.options = Options::default();
    app_state.options.column_ext = false;
    app_state.open_dir(true, dir.clone(), None);
    let started = std::time::Instant::now();
    while app_state.git_status(true).is_none() {
        assert!(started.elapsed() < std::time::Duration::from_secs(10), "git never answered");
        std::thread::sleep(std::time::Duration::from_millis(5));
        app_state.poll_git_marks();
    }
    let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
    render_ui(&mut terminal, &mut app_state);
    let buffer = terminal.backend().buffer();
    let rows: Vec<String> = (0..buffer.area.height).map(|y| (0..60).map(|x| buffer[(x, y)].symbol()).collect()).collect();
    // The Name column ends where its separator starts.
    let ends_with = |name: &str, mark: &str| {
        rows.iter().any(|row| {
            let before = row.split('│').nth(1).unwrap_or_default();
            before.contains(name) && before.trim_end().ends_with(&format!(" {mark}"))
        })
    };
    assert!(ends_with("kept.txt", "M"), "{rows:#?}");
    assert!(ends_with("new.txt", "?"), "{rows:#?}");
    assert!(ends_with("aaaa", "M"), "the long name cut for its mark: {rows:#?}");

    // Off in F11: gone with the reread that follows, and git not asked.
    app_state.options.git_marks = false;
    app_state.reload_panel(true, None);
    assert!(app_state.git_status(true).is_none() && app_state.git_left.dir == dir);
    render_ui(&mut terminal, &mut app_state);
    let buffer = terminal.backend().buffer();
    let marked = (0..buffer.area.height).any(|y| {
        let row: String = (0..60).map(|x| buffer[(x, y)].symbol()).collect();
        row.split('│').nth(1).unwrap_or_default().trim_end().ends_with(" M")
    });
    assert!(!marked);
    std::fs::remove_dir_all(&dir).unwrap();
}

/// In Serbian, longer than English: the headings take the room they need,
/// arrow and all, and the F-key labels, the options and the Find files
/// form are drawn whole.
#[test]
fn another_language_fits_where_english_did() {
    crate::i18n::set_language(crate::i18n::Language::Serbian);
    let mut app_state = AppState::new();
    app_state.options = Options::default();
    app_state.options.sort_key = SortKey::Size;
    let screen = |app_state: &mut AppState, width: u16, height: u16| {
        let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
        render_ui(&mut terminal, app_state);
        let buffer = terminal.backend().buffer().clone();
        (0..height).map(|y| (0..width).map(|x| buffer[(x, y)].symbol().to_string()).collect::<String>()).collect::<Vec<_>>()
    };

    let rows = screen(&mut app_state, 160, 30);
    let headings = rows.iter().find(|row| row.contains("Ime")).expect("no headings");
    for heading in ["Ekst", "Veličina ↑", "Izmenjeno", "Atributi"] {
        assert!(headings.contains(heading), "{heading} cut in: {headings}");
    }
    let keys = rows.iter().rev().find(|row| row.contains("F1 ")).expect("no F-key bar");
    for label in ["F1 Pomoć", "F5 Kopiraj", "F8 Korpa", "F10 Izlaz", "F12 Prikaz"] {
        assert!(keys.contains(label), "{label} not whole in: {keys}");
    }

    app_state.dialog = Some(Dialog::Options { editing: None });
    let rows = screen(&mut app_state, 100, 60).join("\n");
    assert!(rows.contains("Prikaži skrivene datoteke") && rows.contains("Jezik") && rows.contains("Izgled"), "{rows}");

    app_state.dialog = Some(Dialog::FindFiles(Box::new(crate::app::FindFiles::Asking(crate::app::FindForm::default()))));
    let rows = screen(&mut app_state, 100, 30);
    // The labels in one column, as wide as the widest of them.
    let form = rows.iter().position(|row| row.contains("Sadrži")).expect("no form") - 1;
    let at = |label: &str| rows[form..].iter().find_map(|row| row.find(label).map(|byte| row[..byte].chars().count())).unwrap_or_else(|| panic!("{label} not drawn"));
    let column = at("Ime ");
    for label in ["Sadrži", "Veličina", "Izmenjeno", "Regex"] {
        assert_eq!(at(label), column, "{label} out of line");
    }
    crate::i18n::set_language(crate::i18n::Language::English);
}