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}