lazier 0.9.2

A fast terminal user interface for git, written in Rust with ratatui and gitoxide
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
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
mod list;
mod panels;
mod words;

use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};

use crate::app::{App, CommitPurpose, Mode};

pub use list::offset as list_offset;

const KEY: Style = Style::new().fg(Color::Yellow);
const DESC: Style = Style::new().fg(Color::Gray);
const DIM: Style = Style::new().fg(Color::DarkGray);

/// Where each panel sits. The renderer and the mouse both need this, thus
/// one function gives it to both.
pub struct Panes {
    pub left: [Rect; 5],
    pub diff: Rect,
    pub log: Option<Rect>,
}

pub fn panes(area: Rect, show_log: bool) -> Panes {
    let [body, _bar] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
    let [left, main] =
        Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]).areas(body);
    let left: [Rect; 5] = Layout::vertical([
        Constraint::Length(3),
        Constraint::Fill(1),
        Constraint::Fill(1),
        Constraint::Fill(1),
        Constraint::Fill(1),
    ])
    .areas(left);
    if show_log {
        // The diff takes most of the height. The log needs enough rows for
        // the answer of a push, which can carry a link for you to follow.
        // A small terminal keeps most of its height for the diff.
        let rows = (main.height / 3).clamp(6, 12);
        let [diff, log] =
            Layout::vertical([Constraint::Fill(1), Constraint::Length(rows)]).areas(main);
        Panes { left, diff, log: Some(log) }
    } else {
        Panes { left, diff: main, log: None }
    }
}

pub fn render(frame: &mut Frame, app: &App) {
    let [body, bar] =
        Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
    // The zoomed graph view takes the whole body.
    if app.zoom {
        panels::render_zoom(frame, body, app);
        render_bar(frame, bar, app);
        return;
    }
    let p = panes(frame.area(), app.show_log);
    panels::render_left(frame, p.left, app);
    panels::render_main(frame, p.diff, app);
    if let Some(log) = p.log {
        panels::render_log(frame, log, app);
    }
    render_bar(frame, bar, app);
    match &app.mode {
        Mode::Help { scroll } => render_help(frame, body, frame.area(), *scroll),
        Mode::Confirm { prompt, .. } => render_confirm(frame, body, prompt),
        Mode::Worktrees { list, cursor } => {
            let h = (list.len() as u16 + 2).max(4);
            let home = std::env::var("HOME").unwrap_or_default();
            panels::render_worktrees(frame, centered(body, 82, h), list, *cursor, &home);
        }
        Mode::NewWorktree { branch, path, on_path, .. } => {
            render_new_worktree(frame, body, branch, path, *on_path, app)
        }
        Mode::Ignore { pattern, tracked } => render_ignore(frame, body, pattern, *tracked),
        Mode::Stash { path } => render_stash(frame, body, path.as_deref()),
        Mode::Reset { target, subject } => render_reset(frame, body, target, subject),
        Mode::Error { cmd, output } => render_error(frame, body, cmd, output),
        Mode::Reflog { list, cursor } => {
            let h = (list.len() as u16 + 2).min(body.height).max(4);
            panels::render_reflog(frame, centered(body, 80, h), list, *cursor);
        }
        // Blame needs the room, thus it takes the whole body.
        Mode::Blame { path, lines, cursor } => {
            panels::render_blame(frame, body, path, lines, *cursor)
        }
        Mode::Submodules { list, cursor } => {
            let h = (list.len() as u16 + 2).min(body.height).max(4);
            panels::render_submodules(frame, centered(body, 76, h), list, *cursor);
        }
        Mode::CommitMsg { summary, body: text, on_body, purpose } => {
            render_commit(frame, body, summary, text, *on_body, purpose)
        }
        _ => {}
    }
}

// Put a box of the given size in the middle of an area.
fn centered(area: Rect, w: u16, h: u16) -> Rect {
    let w = w.min(area.width);
    let h = h.min(area.height);
    Rect {
        x: area.x + (area.width.saturating_sub(w)) / 2,
        y: area.y + (area.height.saturating_sub(h)) / 2,
        width: w,
        height: h,
    }
}

/// The window that shows a command that failed. The command log can be
/// closed, thus a failure needs a window of its own.
fn render_error(frame: &mut Frame, body: Rect, cmd: &str, output: &[String]) {
    let w = 72.min(body.width);
    let inner = w.saturating_sub(4).max(1) as usize;
    // A long line wraps, thus count the rows it will use.
    let rows: usize = output.iter().map(|l| l.chars().count().div_ceil(inner).max(1)).sum();
    let h = (rows as u16 + 5).min(body.height);
    let area = centered(body, w, h);
    frame.render_widget(Clear, area);
    let block = Block::bordered()
        .title(Span::styled(" Failed ", Style::new().fg(Color::Red).add_modifier(Modifier::BOLD)))
        .border_style(Style::new().fg(Color::Red));
    let text = block.inner(area);
    frame.render_widget(block, area);

    let mut lines =
        vec![Line::styled(cmd.to_string(), Style::new().fg(Color::White)), Line::default()];
    for l in output {
        lines.push(Line::styled(l.clone(), Style::new().fg(Color::Red)));
    }
    lines.push(Line::default());
    lines.push(Line::styled("press any key to close", DIM));
    frame.render_widget(
        Paragraph::new(lines).wrap(Wrap { trim: false }),
        Rect { x: text.x + 1, width: text.width.saturating_sub(2), ..text },
    );
}

/// The window that moves HEAD. Each choice says what happens to the work
/// that has no commit.
fn render_reset(frame: &mut Frame, body: Rect, target: &str, subject: &str) {
    let area = centered(body, 70, 8);
    frame.render_widget(Clear, area);
    let block = Block::bordered()
        .title(Span::styled(
            " Move HEAD ",
            Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
        ))
        .border_style(Style::new().fg(Color::Yellow));
    let inner = block.inner(area);
    frame.render_widget(block, area);
    let lines = vec![
        Line::from(vec![
            Span::styled(format!("{target} "), Style::new().fg(Color::Yellow)),
            Span::styled(subject.to_string(), Style::new().fg(Color::White)),
        ]),
        Line::default(),
        Line::from(vec![
            Span::styled("s", KEY),
            Span::styled("  soft ", DESC),
            Span::styled(" your changes wait in the index", DIM),
        ]),
        Line::from(vec![
            Span::styled("m", KEY),
            Span::styled("  mixed", DESC),
            Span::styled("  your changes wait in the work tree", DIM),
        ]),
        Line::from(vec![
            Span::styled("h", KEY),
            Span::styled("  hard ", DESC),
            Span::styled(" your changes go away", Style::new().fg(Color::Red)),
        ]),
    ];
    frame.render_widget(
        Paragraph::new(lines).wrap(Wrap { trim: false }),
        Rect { x: inner.x + 1, width: inner.width.saturating_sub(2), ..inner },
    );
}

/// The window that chooses what goes into a stash.
fn render_stash(frame: &mut Frame, body: Rect, path: Option<&str>) {
    let area = centered(body, 62, if path.is_some() { 8 } else { 7 });
    frame.render_widget(Clear, area);
    let block = Block::bordered()
        .title(Span::styled(" Stash ", Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)))
        .border_style(Style::new().fg(Color::Cyan));
    let inner = block.inner(area);
    frame.render_widget(block, area);
    let row = |k: &'static str, what: &str, note: &str| {
        Line::from(vec![
            Span::styled(k, KEY),
            Span::styled(format!("  {what:<16}"), DESC),
            Span::styled(note.to_string(), DIM),
        ])
    };
    let mut lines = vec![
        row("a", "everything", "the changes git tracks"),
        row("u", "and new files", "those too"),
        row("s", "staged only", "what waits in the index"),
    ];
    if let Some(p) = path {
        lines.push(row("f", "this file", p));
    }
    frame.render_widget(
        Paragraph::new(lines).wrap(Wrap { trim: false }),
        Rect { x: inner.x + 1, width: inner.width.saturating_sub(2), ..inner },
    );
}

/// The window that adds a path to the ignore rules. It offers the shared
/// file and the private one.
fn render_ignore(frame: &mut Frame, body: Rect, pattern: &str, tracked: bool) {
    let rows = if tracked { 8 } else { 6 };
    let area = centered(body, 66, rows);
    frame.render_widget(Clear, area);
    let block = Block::bordered()
        .title(Span::styled(" Ignore ", Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)))
        .border_style(Style::new().fg(Color::Cyan));
    let inner = block.inner(area);
    frame.render_widget(block, area);

    let mut lines = vec![
        Line::from(Span::styled(pattern.to_string(), Style::new().fg(Color::White))),
        Line::default(),
        Line::from(vec![
            Span::styled("i", KEY),
            Span::styled("  .gitignore", DESC),
            Span::styled("    every person who clones the repository", DIM),
        ]),
        Line::from(vec![
            Span::styled("e", KEY),
            Span::styled("  info/exclude", DESC),
            Span::styled("  only you", DIM),
        ]),
    ];
    // A rule does not remove a file that git already tracks.
    if tracked {
        lines.push(Line::default());
        lines.push(Line::styled(
            "git tracks this file, thus the rule does nothing until you",
            Style::new().fg(Color::Yellow),
        ));
        lines.push(Line::styled("stop tracking it.", Style::new().fg(Color::Yellow)));
    }
    frame.render_widget(
        Paragraph::new(lines).wrap(Wrap { trim: false }),
        Rect { x: inner.x + 1, width: inner.width.saturating_sub(2), ..inner },
    );
}

/// The window that makes a worktree. It asks for a branch and a path. An
/// empty path takes the suggestion under the field.
fn render_new_worktree(
    frame: &mut Frame,
    body_area: Rect,
    branch: &str,
    path: &str,
    on_path: bool,
    app: &App,
) {
    let area = centered(body_area, 76, 12);
    frame.render_widget(Clear, area);
    let outer = Block::bordered()
        .title(Span::styled(
            " New worktree ",
            Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
        ))
        .border_style(Style::new().fg(Color::Cyan));
    let inner = outer.inner(area);
    frame.render_widget(outer, area);

    let [top, mid, note, hint] = Layout::vertical([
        Constraint::Length(3),
        Constraint::Length(3),
        Constraint::Fill(1),
        Constraint::Length(1),
    ])
    .areas(inner);

    let active = Style::new().fg(Color::Green);
    let idle = Style::new().fg(Color::DarkGray);
    let known = app.repo.branches.iter().any(|b| b.name == branch);
    let branch_title = if branch.is_empty() {
        "branch".to_string()
    } else if known {
        "branch (it exists)".to_string()
    } else {
        "branch (a new one)".to_string()
    };
    frame.render_widget(
        Paragraph::new(Line::from(vec![
            Span::styled(branch.to_string(), Style::new().fg(Color::White)),
            Span::styled(if on_path { "" } else { "" }, active),
        ]))
        .block(Block::bordered().title(branch_title).border_style(if on_path {
            idle
        } else {
            active
        })),
        top,
    );
    // An empty path field shows the suggestion, in a dim color.
    let suggested = app.suggested_worktree_path(branch);
    let shown = if path.is_empty() && !on_path {
        Span::styled(suggested.clone(), idle)
    } else {
        Span::styled(path.to_string(), Style::new().fg(Color::White))
    };
    frame.render_widget(
        Paragraph::new(Line::from(vec![
            shown,
            Span::styled(if on_path { "" } else { "" }, active),
        ]))
        .block(Block::bordered().title("path").border_style(if on_path {
            active
        } else {
            idle
        })),
        mid,
    );
    frame.render_widget(
        Paragraph::new(if known {
            "The worktree will use the branch that is there."
        } else {
            "The worktree will make this branch."
        })
        .style(idle)
        .wrap(Wrap { trim: false }),
        Rect { x: note.x + 1, width: note.width.saturating_sub(2), ..note },
    );
    frame.render_widget(
        hint_line(&[&[("<enter>", "Make it"), ("<tab>", "Other field"), ("<esc>", "Cancel")]]),
        hint,
    );
}

/// The window that asks the user to say yes or no. A long path wraps, thus
/// the window grows to hold it.
fn render_confirm(frame: &mut Frame, body: Rect, prompt: &str) {
    let w = 60.min(body.width);
    let inner = w.saturating_sub(4).max(1) as usize;
    let text_rows = prompt.chars().count().div_ceil(inner).max(1) as u16;
    let area = centered(body, w, text_rows + 4);
    frame.render_widget(Clear, area);
    let block = Block::bordered()
        .title(Span::styled(
            " Confirm ",
            Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
        ))
        .border_style(Style::new().fg(Color::Yellow));
    let text = block.inner(area);
    frame.render_widget(block, area);

    let [msg, hint] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(text);
    frame.render_widget(
        Paragraph::new(prompt.to_string())
            .wrap(Wrap { trim: false })
            .style(Style::new().fg(Color::White)),
        Rect { x: msg.x + 1, width: msg.width.saturating_sub(2), ..msg },
    );
    frame.render_widget(
        hint_line(&[&[("y", "Yes"), ("n / esc", "No")]]),
        Rect { x: hint.x + 1, width: hint.width.saturating_sub(2), ..hint },
    );
}

/// The commit message window. The summary line is on top. The body is
/// below it. Tab moves between them.
fn render_commit(
    frame: &mut Frame,
    body_area: Rect,
    summary: &str,
    body: &str,
    on_body: bool,
    purpose: &CommitPurpose,
) {
    let area = centered(body_area, 72, 14);
    frame.render_widget(Clear, area);
    let title = match purpose {
        CommitPurpose::New => " Commit ",
        CommitPurpose::Reword(0) => " Reword HEAD ",
        CommitPurpose::Reword(_) => " Reword (runs a rebase) ",
    };
    let outer = Block::bordered()
        .title(Span::styled(title, Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)))
        .border_style(Style::new().fg(Color::Cyan));
    let inner = outer.inner(area);
    frame.render_widget(outer, area);

    let [top, mid, hint] =
        Layout::vertical([Constraint::Length(3), Constraint::Fill(1), Constraint::Length(1)])
            .areas(inner);

    let active = Style::new().fg(Color::Green);
    let idle = Style::new().fg(Color::DarkGray);
    frame.render_widget(
        Paragraph::new(Line::from(vec![
            Span::styled(summary.to_string(), Style::new().fg(Color::White)),
            Span::styled(if on_body { "" } else { "" }, active),
        ]))
        .block(Block::bordered().title("summary").border_style(if on_body {
            idle
        } else {
            active
        })),
        top,
    );
    frame.render_widget(
        Paragraph::new(Line::from(vec![
            Span::styled(body.to_string(), Style::new().fg(Color::White)),
            Span::styled(if on_body { "" } else { "" }, active),
        ]))
        .wrap(Wrap { trim: false })
        .block(Block::bordered().title("body (optional)").border_style(if on_body {
            active
        } else {
            idle
        })),
        mid,
    );
    let keys: Hints = match (on_body, purpose) {
        (true, _) => &[("enter", "new line"), ("tab", "summary"), ("esc", "cancel")],
        (false, CommitPurpose::New) => &[("enter", "commit"), ("tab", "body"), ("esc", "cancel")],
        (false, _) => &[("enter", "reword"), ("tab", "body"), ("esc", "cancel")],
    };
    frame.render_widget(hint_line(&[keys]), hint);
}

// Key hints for each pane. The bar shows them when there is no message.
type Hints = &'static [(&'static str, &'static str)];
const HINTS: [Hints; 6] = [
    &[("r", "Refresh")],
    &[
        ("<space>", "Stage"),
        ("a", "Stage all"),
        ("c", "Commit"),
        ("d", "Discard"),
        ("x", "Delete"),
        ("i", "Ignore"),
        ("s", "Stash"),
        ("<enter>", "Hunks"),
        ("e", "Open it"),
        ("o/t", "Ours/theirs"),
    ],
    &[
        ("<enter>", "Checkout"),
        ("n", "New"),
        ("F", "Force push"),
        ("d/D", "Delete"),
        ("R", "Rename"),
        ("m", "Merge"),
    ],
    &[
        ("<enter>", "Graph"),
        ("i", "Rebase"),
        ("w", "Reword"),
        ("v", "Revert"),
        ("y", "Cherry-pick"),
        ("f", "Fixup"),
        ("t", "Tag"),
        ("c", "Copy id"),
        ("R", "Reset"),
        ("m", "Main line"),
    ],
    &[("<enter>", "Apply"), ("p", "Pop"), ("d", "Drop")],
    &[("j/k", "Scroll"), ("g/G", "Top/bottom")],
];
// One entry for each key, thus a key the panel takes for itself can be
// left out on its own.
const GLOBAL_HINTS: Hints = &[
    ("P", "Push"),
    ("p", "Pull"),
    ("f", "Fetch"),
    ("/", "Search"),
    (":", "Shell"),
    ("?", "Keys"),
    ("q", "Quit"),
];

/// The global hints that still apply in this panel. A key that the panel
/// uses for something else is left out, because offering it would be a
/// lie, and a key the panel already lists would show twice.
fn globals_for(focus: usize, panel: Hints) -> Vec<(&'static str, &'static str)> {
    GLOBAL_HINTS
        .iter()
        .filter(|(key, _)| {
            !crate::keys::panel_claims(focus, key) && !panel.iter().any(|(k, _)| k == key)
        })
        .copied()
        .collect()
}

// Make one line of "Desc: key | Desc: key", the shape lazygit uses.
fn hint_line(groups: &[Hints]) -> Line<'static> {
    hint_line_width(groups, usize::MAX)
}

/// The same line, but it stops before it goes past `width`. A hint that
/// does not fit stays out, thus the bar never runs off the screen.
fn hint_line_width(groups: &[Hints], width: usize) -> Line<'static> {
    let flat: Vec<(&str, &str)> = groups.iter().flat_map(|g| g.iter().copied()).collect();
    hint_items(&flat, width)
}

fn hint_items(items: &[(&str, &str)], width: usize) -> Line<'static> {
    let mut spans: Vec<Span<'static>> = Vec::new();
    let mut used = 0usize;
    let mut dropped = false;
    for (key, desc) in items.iter() {
        let sep = if spans.is_empty() { 0 } else { 3 };
        let need = sep + desc.len() + 2 + key.len();
        // Keep one column free for the mark that says "there is more".
        if used + need > width.saturating_sub(2) {
            dropped = true;
            break;
        }
        if sep > 0 {
            spans.push(Span::styled(" | ", DIM));
        }
        spans.push(Span::styled(format!("{desc}: "), DESC));
        spans.push(Span::styled((*key).to_string(), KEY));
        used += need;
    }
    if dropped {
        spans.push(Span::styled("", DIM));
    }
    Line::from(spans)
}

fn render_bar(frame: &mut Frame, area: Rect, app: &App) {
    let w = area.width as usize;
    let line = match &app.mode {
        Mode::Input { prompt, buffer, .. } => Line::from(vec![
            Span::styled(format!("{prompt}: "), Style::new().fg(Color::Cyan)),
            Span::styled(format!("{buffer}"), Style::new().fg(Color::White)),
        ]),
        // The confirm window draws its own text and keys.
        Mode::Confirm { .. } => Line::default(),
        Mode::Hunks { picked, .. } => {
            let mut spans = vec![Span::styled(
                format!("{} marked  ", picked.len()),
                Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
            )];
            spans.extend(
                hint_line(&[&[
                    ("<space>", "Mark line"),
                    ("<enter>", "Stage marked"),
                    ("a", "Stage hunk"),
                    ("J/K", "Hunk"),
                    ("<esc>", "Back"),
                ]])
                .spans,
            );
            Line::from(spans)
        }
        Mode::Rebase { .. } => hint_line(&[&[
            ("p", "Pick"),
            ("r", "Reword"),
            ("e", "Edit"),
            ("s", "Squash"),
            ("f", "Fixup"),
            ("d", "Drop"),
            ("J/K", "Move"),
            ("<enter>", "Run"),
            ("<esc>", "Cancel"),
        ]]),
        Mode::Help { .. } => Line::styled(
            "scroll: j / k, arrows, wheel    close: any other key",
            Style::new().fg(Color::Cyan),
        ),
        Mode::Worktrees { .. } => hint_line(&[&[
            ("<enter>", "Go to it"),
            ("n", "New"),
            ("d", "Remove"),
            ("p", "Prune"),
            ("<esc>", "Close"),
        ]]),
        // The window draws its own keys.
        Mode::NewWorktree { .. } => Line::default(),
        // The window draws its own note.
        Mode::Error { .. } => Line::default(),
        Mode::Reset { .. } => hint_line(&[&[
            ("s", "Keep in the index"),
            ("m", "Keep in the work tree"),
            ("h", "Throw away"),
            ("<esc>", "Cancel"),
        ]]),
        Mode::Stash { .. } => hint_line(&[&[
            ("a", "All"),
            ("u", "With new files"),
            ("s", "Staged only"),
            ("f", "This file"),
            ("<esc>", "Cancel"),
        ]]),
        Mode::Blame { .. } => hint_line(&[&[
            ("j/k", "Move"),
            ("d/u", "Page"),
            ("g/G", "Top/end"),
            ("<esc>", "Close"),
        ]]),
        Mode::Submodules { .. } => {
            hint_line(&[&[("<enter>", "Update this one"), ("u", "Update all"), ("<esc>", "Close")]])
        }
        Mode::Reflog { .. } => {
            hint_line(&[&[("<enter>", "Move HEAD there"), ("j/k", "Move"), ("<esc>", "Close")]])
        }
        Mode::Ignore { .. } => hint_line(&[&[
            ("i", "Share the rule"),
            ("e", "Keep it to yourself"),
            ("<esc>", "Cancel"),
        ]]),
        // A bisect takes over four keys of the commits panel.
        Mode::Normal if app.repo.bisecting => {
            let mut spans = vec![Span::styled(
                "BISECT  ",
                Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
            )];
            spans.extend(
                hint_line(&[&[("b", "Bad"), ("o", "Good"), ("S", "Skip"), ("A", "Reset")]]).spans,
            );
            Line::from(spans)
        }
        // The commit window draws its own key hints.
        Mode::CommitMsg { .. } => Line::default(),
        // A stopped rebase takes over three keys, but not on the files
        // panel, where you stage and commit the parts of the work.
        Mode::Normal if app.rebase.is_some() => {
            let r = app.rebase.as_ref().unwrap();
            let word = if app.splitting { "SPLIT" } else { "REBASE" };
            let mut spans = vec![Span::styled(
                format!("{word} {}/{}  ", r.step, r.total),
                Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
            )];
            let keys: Hints = if app.focus == 1 {
                &[("<space>", "Stage a part"), ("c", "Commit it"), ("[1] then c", "Go on")]
            } else {
                &[("c", "Continue"), ("s", "Skip"), ("A", "Abort")]
            };
            spans.extend(hint_line(&[keys]).spans);
            Line::from(spans)
        }
        // A failed command shows its message in red.
        Mode::Normal if !app.message.is_empty() => {
            let color = if app.message_ok { Color::Green } else { Color::Red };
            Line::styled(app.message.clone(), Style::new().fg(color))
        }
        // A running network command comes first, thus you always see it.
        Mode::Normal if !app.running.is_empty() => {
            let frame = app.spinner().unwrap_or(' ');
            let mut spans = vec![Span::styled(
                format!("{frame} {}  ", app.running.join(", ")),
                Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
            )];
            let left = w.saturating_sub(spans[0].content.chars().count());
            spans.extend(hint_line_width(&[GLOBAL_HINTS], left).spans);
            Line::from(spans)
        }
        Mode::Normal => {
            let panel = HINTS[app.focus.min(5)];
            let mut items: Vec<(&str, &str)> = panel.to_vec();
            items.extend(globals_for(app.focus.min(5), panel));
            hint_items(&items, w)
        }
    };
    frame.render_widget(line, area);
}

// Each section has a title and its key rows.
const HELP: &[(&str, Hints)] = &[
    (
        "Global",
        &[
            ("tab / shift-tab", "cycle the panes"),
            ("1 2 3 4 5", "go to a panel"),
            ("0", "go to the diff pane"),
            ("j / k", "move the selection"),
            ("ctrl-d / ctrl-u", "move one page"),
            ("g / G", "go to the top or the end"),
            ("J / K", "scroll the diff pane"),
            ("r", "read the repository again"),
            ("/ esc", "search the commit messages, or show them all"),
            (":", "run a command line through the shell"),
            ("P / p / f", "push, pull, or fetch, in the background"),
            ("F", "force push, with a lease that protects other people"),
            ("W / M / U", "worktrees, submodules, where HEAD has been"),
            ("? / @ / q", "help, command log, quit"),
        ],
    ),
    (
        "Files [2]",
        &[
            ("space", "stage or unstage the file or the directory"),
            ("a", "stage all files"),
            ("enter", "open the hunks, or fold the directory"),
            ("d", "discard the changes (it asks first)"),
            ("x", "delete it from the disk (it asks first)"),
            ("i", "ignore it: i shares the rule, e keeps it to yourself"),
            ("A", "add the staged changes to the last commit"),
            ("z", "send each staged change back to the commit that wrote it"),
            ("e", "open the file in your editor"),
            ("b", "see who last changed each line"),
            ("c / C", "open the commit window, or use the editor"),
            ("s", "stash: it asks which changes to put away"),
            ("o / t", "take ours or theirs in a conflict"),
            ("v", "mark a file, thus the next key works on every mark"),
            ("esc", "drop the marks"),
            ("SM name", "the two marks of git status, then the file"),
        ],
    ),
    (
        "Branches [3]",
        &[
            ("enter", "check out the branch, if you are not on it"),
            ("n", "make a new branch"),
            ("d / D", "delete it, or delete it by force"),
            ("R", "give the branch a new name"),
            ("m", "merge the branch into the current one"),
            ("o", "put your commits on top of that branch"),
            ("* ↑ ↓", "current branch, to push, to pull"),
        ],
    ),
    ("Submodules", &[("M", "open the list"), ("enter / u", "update this one, or every one")]),
    (
        "Go back",
        &[
            ("U", "the list of places HEAD has been"),
            ("R", "on a commit: move HEAD to it"),
            ("s / m / h", "keep in the index, in the work tree, or not"),
        ],
    ),
    (
        "Hunk view",
        &[
            ("j / k", "move to another line"),
            ("space", "mark the line, or remove the mark"),
            ("enter", "stage the marked lines"),
            ("a", "stage the full hunk"),
            ("J / K", "go to another hunk"),
        ],
    ),
    (
        "Commit window",
        &[
            ("enter", "commit, or make a new line in the body"),
            ("tab", "move between the summary and the body"),
            ("esc", "cancel"),
        ],
    ),
    (
        "Commits [4]",
        &[
            ("enter", "open the full graph view"),
            ("m", "the main line alone, or the whole history again"),
            ("i", "start an interactive rebase here"),
            ("s", "open the commit, so it can become several commits"),
            ("w", "give the commit a new message"),
            ("v", "revert the commit"),
            ("y", "put its changes in the index, with no commit"),
            ("f", "fold the staged changes into this commit"),
            ("t / T", "make a tag here, or push every tag"),
            ("c", "copy the id to the clipboard"),
            ("/ esc", "search the messages, or show them all again"),
            ("space", "mark a commit, then move to see what lies between"),
            ("", "this commit is not on the upstream branch"),
            ("", "a commit that carries a tag"),
        ],
    ),
    (
        "Bisect [4]",
        &[
            ("b", "start a bisect here, or mark a bad commit"),
            ("o", "mark a good commit"),
            ("S / A", "skip this commit, or end the bisect"),
        ],
    ),
    (
        "Worktrees",
        &[
            ("W", "open the list"),
            ("enter", "go to that worktree"),
            ("n", "make one: it asks for a branch and a path"),
            ("d / p", "remove one, or drop the records of lost ones"),
        ],
    ),
    (
        "Stash [5]",
        &[
            ("enter / a", "apply the stash"),
            ("p / d", "pop it, or drop it"),
            ("a u s f", "in the stash window: all, new files too, staged, one file"),
        ],
    ),
    (
        "Diff pane [0]",
        &[
            ("j / k", "scroll it"),
            ("g / G", "go to the top or the end"),
            ("marked words", "only the words that changed are marked"),
        ],
    ),
    (
        "Rebase",
        &[
            ("p r e s f d", "pick, reword, edit, squash, fixup, drop"),
            ("J / K", "move the commit in the list"),
            ("enter / esc", "run the rebase, or cancel it"),
            ("c / s / A", "while stopped: continue, skip, abort"),
        ],
    ),
];

/// The largest scroll that still shows rows. The last row of the list then
/// sits at the foot of the window. A larger number would only show empty
/// space, thus the keys must not go past it.
pub fn help_max_scroll(area: Rect) -> u16 {
    let (rows, h) = help_shape(area);
    rows.saturating_sub(h.saturating_sub(2))
}

/// The rows the list needs after wrapping, and the height of the window.
/// The renderer and the keys both need these, thus one place gives both.
fn help_shape(area: Rect) -> (u16, u16) {
    // The bar takes one row away from the body.
    let body_height = area.height.saturating_sub(1);
    let w = 64.min(area.width);
    let inner = w.saturating_sub(2).max(1) as usize;
    // A long line wraps, thus it takes more than one row.
    let rows: u16 = help_lines(inner).iter().map(|l| l.width().div_ceil(inner).max(1) as u16).sum();
    (rows, (rows + 2).min(body_height))
}

/// The rows of the key list. `inner` is the width inside the border.
fn help_lines(inner: usize) -> Vec<Line<'static>> {
    let mut lines: Vec<Line> = Vec::new();
    for (i, (title, rows)) in HELP.iter().enumerate() {
        if i > 0 {
            lines.push(Line::styled("".repeat(inner), DIM));
        }
        lines.push(Line::styled(
            format!(" {title}"),
            Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
        ));
        for (key, desc) in rows.iter() {
            lines.push(Line::from(vec![
                Span::styled(format!("  {key:>15}  "), KEY),
                Span::styled(*desc, DESC),
            ]));
        }
    }
    lines
}

fn render_help(frame: &mut Frame, body: Rect, area_for_shape: Rect, scroll: u16) {
    let w = 64.min(body.width);
    let inner = w.saturating_sub(2).max(1) as usize;
    let lines = help_lines(inner);
    let (rows, h) = help_shape(area_for_shape);
    let area = Rect {
        x: body.x + (body.width.saturating_sub(w)) / 2,
        y: body.y + (body.height.saturating_sub(h)) / 2,
        width: w,
        height: h,
    };
    // The window shows only a part of the list, thus never scroll past the
    // last row.
    let seen = h.saturating_sub(2);
    let scroll = scroll.min(rows.saturating_sub(seen));
    let mut block = Block::bordered()
        .title(Span::styled(" Keys ", Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)))
        .border_style(Style::new().fg(Color::Cyan));
    // Say where you are, but only when the list does not fit.
    if rows > seen {
        let end = (scroll + seen).min(rows);
        block = block
            .title_bottom(Line::styled(format!(" j/k  {end} of {rows} "), DIM).right_aligned());
    }
    frame.render_widget(Clear, area);
    frame.render_widget(
        Paragraph::new(lines).block(block).wrap(Wrap { trim: false }).scroll((scroll, 0)),
        area,
    );
}

#[cfg(test)]
mod tests {
    use super::*;

    // No key may appear twice in the bar, and a key the panel uses for
    // something else must not be offered with its global meaning.
    #[test]
    fn the_hint_bar_never_repeats_or_lies() {
        for (focus, panel) in HINTS.iter().enumerate() {
            let panel = *panel;
            let globals = globals_for(focus, panel);
            let mut keys: Vec<&str> = panel.iter().map(|(k, _)| *k).collect();
            keys.extend(globals.iter().map(|(k, _)| *k));
            let mut seen = std::collections::HashSet::new();
            for k in &keys {
                assert!(seen.insert(*k), "panel {focus} shows {k} twice");
            }
            for (k, _) in &globals {
                assert!(
                    !crate::keys::panel_claims(focus, k),
                    "panel {focus} offers {k} but uses it for something else"
                );
            }
        }
    }

    // The two that the panels take over: fixup on the commits panel and
    // pop on the stash panel.
    #[test]
    fn a_panel_key_hides_the_global_one() {
        let commits = globals_for(3, HINTS[3]);
        assert!(!commits.iter().any(|(k, _)| *k == "f"), "f is fixup there");
        assert!(commits.iter().any(|(k, _)| *k == "P"), "push still works");
        let stash = globals_for(4, HINTS[4]);
        assert!(!stash.iter().any(|(k, _)| *k == "p"), "p is pop there");
        assert!(stash.iter().any(|(k, _)| *k == "f"), "fetch still works");
    }
}