Skip to main content

clankerdiff_ratatui/
keybindings.rs

1use crate::{
2    DiffReviewCommand, FocusPane, KeyCode, KeyEvent, KeyModifiers, MarkdownFocusPane,
3    MarkdownReviewCommand, ReviewCommand,
4};
5use clankerdiff_core::{DiffSide, RevealAmount};
6use std::{fmt::Write, ptr};
7use unicode_width::UnicodeWidthStr;
8
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
10pub enum BindingScope {
11    #[default]
12    Any,
13    Navigation,
14    Document,
15    SplitDocument,
16}
17
18impl BindingScope {
19    pub(crate) fn matches(self, document: bool, split: bool) -> bool {
20        match self {
21            Self::Any => true,
22            Self::Navigation => !document,
23            Self::Document => document,
24            Self::SplitDocument => document && split,
25        }
26    }
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct KeyBinding<T> {
31    pub key: KeyEvent,
32    pub command: T,
33    pub label: String,
34    pub scope: BindingScope,
35}
36
37impl<T> KeyBinding<T> {
38    pub fn new(key: KeyEvent, command: T, label: impl Into<String>) -> Self {
39        Self {
40            key,
41            command,
42            label: label.into(),
43            scope: BindingScope::Any,
44        }
45    }
46
47    #[must_use]
48    pub fn with_scope(mut self, scope: BindingScope) -> Self {
49        self.scope = scope;
50        self
51    }
52
53    pub(crate) fn matches(&self, key: KeyEvent, document: bool, split: bool) -> bool {
54        normalize_key(self.key) == normalize_key(key) && self.scope.matches(document, split)
55    }
56
57    pub fn hint(&self) -> String {
58        let mut key = String::new();
59        for (modifier, name) in [
60            (KeyModifiers::CONTROL, "Ctrl-"),
61            (KeyModifiers::ALT, "Alt-"),
62            (KeyModifiers::SUPER, "Super-"),
63            (KeyModifiers::HYPER, "Hyper-"),
64            (KeyModifiers::META, "Meta-"),
65            (KeyModifiers::SHIFT, "Shift-"),
66        ] {
67            if self.key.modifiers.contains(modifier) {
68                key.push_str(name);
69            }
70        }
71        match self.key.code {
72            KeyCode::Char(' ') => key.push_str("Space"),
73            KeyCode::Char(c) => key.push(c),
74            code => {
75                let _ = write!(key, "{code:?}");
76            }
77        }
78        format!("{key} {}", self.label)
79    }
80}
81
82pub(crate) fn binding_for_key<T>(
83    bindings: &[KeyBinding<T>],
84    key: KeyEvent,
85    document: bool,
86    split: bool,
87) -> Option<&KeyBinding<T>> {
88    bindings
89        .iter()
90        .rev()
91        .find(|binding| binding.matches(key, document, split))
92}
93
94pub(crate) fn help_bindings<T>(
95    bindings: &[KeyBinding<T>],
96    navigation_available: bool,
97    enabled: impl Fn(&T) -> bool,
98) -> impl Iterator<Item = &KeyBinding<T>> {
99    bindings.iter().filter(move |binding| {
100        enabled(&binding.command)
101            && [(true, false), (true, true), (false, false)]
102                .into_iter()
103                .filter(|(document, _)| *document || navigation_available)
104                .any(|(document, split)| {
105                    binding_for_key(bindings, binding.key, document, split)
106                        .is_some_and(|resolved| ptr::eq(resolved, *binding))
107                })
108    })
109}
110
111pub(crate) fn footer_hint<T: PartialEq>(
112    bindings: &[KeyBinding<T>],
113    document: bool,
114    split: bool,
115    enabled: impl Fn(&T) -> bool,
116    help: &T,
117    width: usize,
118) -> String {
119    let active: Vec<_> = bindings
120        .iter()
121        .filter(|binding| {
122            enabled(&binding.command)
123                && binding_for_key(bindings, binding.key, document, split)
124                    .is_some_and(|resolved| ptr::eq(resolved, *binding))
125        })
126        .collect();
127    let help = active
128        .iter()
129        .find(|binding| &binding.command == help)
130        .copied();
131    let help_hint = help
132        .map(KeyBinding::hint)
133        .filter(|hint| hint.width() <= width);
134    let mut remaining = width.saturating_sub(help_hint.as_ref().map_or(0, |hint| hint.width() + 2));
135    let mut commands = Vec::new();
136    let mut hints = Vec::new();
137    for binding in active {
138        if help.is_some_and(|help| binding.command == help.command)
139            || commands.contains(&&binding.command)
140        {
141            continue;
142        }
143        let hint = binding.hint();
144        let needed = hint.width() + if hints.is_empty() { 0 } else { 2 };
145        if hints.len() == 5 || needed > remaining {
146            continue;
147        }
148        remaining -= needed;
149        commands.push(&binding.command);
150        hints.push(hint);
151    }
152    hints.extend(help_hint);
153    hints.join("  ")
154}
155
156fn normalize_key(mut key: KeyEvent) -> KeyEvent {
157    if let KeyCode::Char(c) = key.code
158        && !c.is_lowercase()
159    {
160        key.modifiers.remove(KeyModifiers::SHIFT);
161    }
162    key
163}
164
165#[must_use]
166#[expect(
167    clippy::too_many_lines,
168    reason = "Declarative table of default keybindings"
169)]
170pub fn default_diff_keybindings() -> Vec<KeyBinding<DiffReviewCommand>> {
171    use BindingScope::{Any, Document, Navigation, SplitDocument};
172    use DiffReviewCommand as C;
173    use KeyCode as K;
174    let entries = [
175        (K::Esc, ReviewCommand::Cancel.into(), "cancel", Any),
176        (K::Tab, C::ToggleFocus, "switch pane", Any),
177        (K::Left, C::Focus(FocusPane::Files), "files", Document),
178        (K::Char('h'), C::Focus(FocusPane::Files), "files", Document),
179        (K::Left, C::CollapseSelected, "collapse", Navigation),
180        (K::Char('h'), C::CollapseSelected, "collapse", Navigation),
181        (K::Right, C::OpenSelected, "open", Navigation),
182        (K::Char('l'), C::OpenSelected, "open", Navigation),
183        (K::Enter, C::OpenSelected, "open", Navigation),
184        (K::Up, C::MoveSelection(-1), "previous", Any),
185        (K::Char('k'), C::MoveSelection(-1), "previous", Any),
186        (K::Down, C::MoveSelection(1), "next", Any),
187        (K::Char('j'), C::MoveSelection(1), "next", Any),
188        (K::PageUp, C::Page(-1), "page up", Any),
189        (K::PageDown, C::Page(1), "page down", Any),
190        (K::Home, C::First, "first", Any),
191        (K::End, C::Last, "last", Any),
192        (
193            K::Char('c'),
194            ReviewCommand::BeginComment.into(),
195            "comment",
196            Document,
197        ),
198        (
199            K::Char('e'),
200            ReviewCommand::EditComment.into(),
201            "edit comment",
202            Document,
203        ),
204        (
205            K::Char('x'),
206            ReviewCommand::DeleteComment.into(),
207            "delete comment",
208            Document,
209        ),
210        (
211            K::Char('u'),
212            ReviewCommand::UndoComment.into(),
213            "undo comment",
214            Document,
215        ),
216        (K::Char('s'), C::SubmitReview, "submit", Document),
217        (K::Char('y'), C::CopyReview, "copy", Document),
218        (K::Char(' '), C::ToggleStage, "stage/unstage", Navigation),
219        (K::Char('a'), C::StageAll, "stage all", Navigation),
220        (K::Char('A'), C::UnstageAll, "unstage all", Navigation),
221        (K::Char('C'), C::BeginCommit, "commit", Any),
222        (K::Char('d'), C::BeginDiscard, "discard", Any),
223        (
224            K::Char('t'),
225            ReviewCommand::OpenThemePicker.into(),
226            "theme",
227            Any,
228        ),
229        (
230            K::Enter,
231            C::RevealGap(RevealAmount::Step),
232            "reveal gap",
233            Document,
234        ),
235        (
236            K::Char('o'),
237            C::RevealGap(RevealAmount::Step),
238            "reveal gap",
239            Document,
240        ),
241        (
242            K::Char('O'),
243            C::RevealGap(RevealAmount::All),
244            "reveal all",
245            Document,
246        ),
247        (K::Char('f'), C::ToggleFullFile, "full file", Document),
248        (K::Char('v'), C::CycleViewMode, "view", Any),
249        (K::Char('S'), C::CycleScope, "scope", Any),
250        (K::Char('r'), C::Refresh, "refresh", Any),
251        (K::Char('?'), ReviewCommand::ShowHelp.into(), "help", Any),
252        (
253            K::Left,
254            C::SelectSide(DiffSide::Old),
255            "old side",
256            SplitDocument,
257        ),
258        (
259            K::Right,
260            C::SelectSide(DiffSide::New),
261            "new side",
262            SplitDocument,
263        ),
264    ];
265    let mut bindings: Vec<_> = entries
266        .into_iter()
267        .map(|(key, command, label, scope)| {
268            KeyBinding::new(KeyEvent::new(key, KeyModifiers::NONE), command, label)
269                .with_scope(scope)
270        })
271        .collect();
272    bindings.push(KeyBinding::new(
273        KeyEvent::new(K::Char('g'), KeyModifiers::CONTROL),
274        ReviewCommand::Cancel.into(),
275        "cancel",
276    ));
277    bindings
278}
279
280pub(crate) fn markdown_command_label(command: MarkdownReviewCommand) -> &'static str {
281    use MarkdownReviewCommand as C;
282    use ReviewCommand as R;
283    match command {
284        C::Review(command) => match command {
285            R::BeginComment => "comment",
286            R::EditComment => "edit comment",
287            R::DeleteComment => "delete comment",
288            R::UndoComment => "undo comment",
289            R::SubmitComment => "submit comment",
290            R::Cancel => "cancel",
291            R::ShowHelp => "help",
292            R::ScrollHelp(_) => "scroll help",
293            R::OpenThemePicker => "theme",
294            R::SelectTheme(_) => "select theme",
295            R::MoveTheme(_) => "move theme",
296            R::CommitTheme => "apply theme",
297        },
298        C::Focus(MarkdownFocusPane::Document) => "document",
299        C::Focus(MarkdownFocusPane::Outline) => "outline",
300        C::ToggleFocus => "switch pane",
301        C::SelectTarget(_) => "select target",
302        C::SelectHeading(_) => "select heading",
303        C::MoveSelection(lines) => {
304            if lines < 0 {
305                "previous"
306            } else {
307                "next"
308            }
309        }
310        C::Scroll { .. } => "scroll",
311        C::Page(pages) => {
312            if pages < 0 {
313                "page up"
314            } else {
315                "page down"
316            }
317        }
318        C::First => "first",
319        C::Last => "last",
320        C::NextHeading => "next heading",
321        C::PreviousHeading => "previous heading",
322        C::OpenSelected => "open",
323        C::Approve => "approve",
324        C::RequestChanges => "request changes",
325        C::CopyReview(_) => "copy",
326    }
327}
328
329#[must_use]
330pub fn default_markdown_keybindings() -> Vec<KeyBinding<MarkdownReviewCommand>> {
331    use KeyCode as K;
332    use MarkdownReviewCommand as C;
333    let entries = [
334        (K::Esc, ReviewCommand::Cancel.into()),
335        (K::Tab, C::ToggleFocus),
336        (K::Up, C::MoveSelection(-1)),
337        (K::Char('k'), C::MoveSelection(-1)),
338        (K::Down, C::MoveSelection(1)),
339        (K::Char('j'), C::MoveSelection(1)),
340        (K::Home, C::First),
341        (K::Char('g'), C::First),
342        (K::End, C::Last),
343        (K::Char('G'), C::Last),
344        (K::PageUp, C::Page(-1)),
345        (K::PageDown, C::Page(1)),
346        (K::Char('n'), C::NextHeading),
347        (K::Char('p'), C::PreviousHeading),
348        (K::Left, C::Focus(MarkdownFocusPane::Outline)),
349        (K::Char('h'), C::Focus(MarkdownFocusPane::Outline)),
350        (K::Right, C::Focus(MarkdownFocusPane::Document)),
351        (K::Char('l'), C::Focus(MarkdownFocusPane::Document)),
352        (K::Enter, C::OpenSelected),
353        (K::Char('c'), ReviewCommand::BeginComment.into()),
354        (K::Char('e'), ReviewCommand::EditComment.into()),
355        (K::Char('x'), ReviewCommand::DeleteComment.into()),
356        (K::Char('u'), ReviewCommand::UndoComment.into()),
357        (K::Char('a'), C::Approve),
358        (K::Char('r'), C::RequestChanges),
359        (K::Char('t'), ReviewCommand::OpenThemePicker.into()),
360        (K::Char('?'), ReviewCommand::ShowHelp.into()),
361    ];
362    entries
363        .into_iter()
364        .map(|(key, command)| (key.into(), command))
365        .chain([(K::ctrl('g'), ReviewCommand::Cancel.into())])
366        .map(|(key, command)| KeyBinding::new(key, command, markdown_command_label(command)))
367        .collect()
368}