Skip to main content

matchmaker/
binds.rs

1use std::{
2    cmp::Ordering,
3    collections::HashMap,
4    fmt::{self, Display},
5    str::FromStr,
6};
7
8use serde::{
9    Deserializer,
10    de::{self, Visitor},
11    ser,
12};
13
14use crate::{
15    action::{Action, ActionExt, Actions, NullActionExt},
16    config::HelpColorConfig,
17    message::Event,
18};
19
20pub use crate::bindmap;
21pub use crokey::{KeyCombination, key};
22pub use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
23
24#[allow(type_alias_bounds)]
25pub type BindMap<A: ActionExt = NullActionExt> = HashMap<Trigger, Actions<A>>;
26
27#[easy_ext::ext(BindMapExt)]
28impl<A: ActionExt> BindMap<A> {
29    #[allow(unused_mut)]
30    pub fn default_binds() -> Self {
31        let mut ret = bindmap!(
32            key!(ctrl-c) => Action::Quit(1),
33            key!(esc) => Action::Quit(1),
34            key!(up) => Action::Up(1),
35            key!(down) => Action::Down(1),
36            key!(enter) => Action::Accept,
37            key!(right) => Action::ForwardChar,
38            key!(left) => Action::BackwardChar,
39            key!(backspace) => Action::DeleteChar,
40            key!(ctrl-right) => Action::ForwardWord,
41            key!(ctrl-left) => Action::BackwardWord,
42            key!(ctrl-h) => Action::DeleteWord,
43            key!(ctrl-u) => Action::Cancel,
44            key!(alt-a) => Action::QueryPos(0),
45            key!(alt-h) => Action::Help("".to_string()),
46            key!(ctrl-'[') => Action::ToggleWrap,
47            key!(ctrl-']') => Action::TogglePreviewWrap,
48            key!(ctrl-shift-right) => Action::HScroll(1),
49            key!(ctrl-shift-left) => Action::HScroll(-1),
50            key!(ctrl-shift-up) => Action::VScroll(1),
51            key!(ctrl-shift-down) => Action::VScroll(-1),
52            key!(PageDown) => Action::HalfPageDown,
53            key!(PageUp) => Action::HalfPageUp,
54            key!(Home) => Action::Pos(0),
55            key!(End) => Action::Pos(-1),
56            key!(shift-PageDown) => Action::PreviewHalfPageDown,
57            key!(shift-PageUp) => Action::PreviewHalfPageUp,
58            key!(shift-Home) => Action::PreviewJump,
59            key!(shift-End) => Action::PreviewJump,
60            key!('?') => Action::SwitchPreview(None)
61        );
62
63        #[cfg(target_os = "macos")]
64        {
65            let ext = bindmap!(
66                key!(alt-left) => Action::ForwardWord,
67                key!(alt-right) => Action::BackwardWord,
68                key!(alt-backspace) => Action::DeleteWord,
69            );
70            ret.extend(ext);
71        }
72
73        ret
74    }
75
76    /// Check for infinite loops in semantic actions.
77    pub fn check_cycles(&self) -> Result<(), String> {
78        for actions in self.values() {
79            for action in actions {
80                if let Action::Semantic(s) = action {
81                    let mut path = Vec::new();
82                    self.dfs_semantic(s, &mut path)?;
83                }
84            }
85        }
86        Ok(())
87    }
88
89    pub fn dfs_semantic(&self, current: &str, path: &mut Vec<String>) -> Result<(), String> {
90        if path.contains(&current.to_string()) {
91            return Err(format!(
92                "Infinite loop detected in semantic actions: {} -> {}",
93                path.join(" -> "),
94                current
95            ));
96        }
97
98        path.push(current.to_string());
99        if let Some(actions) = self.get(&Trigger::Semantic(current.to_string())) {
100            for action in actions {
101                if let Action::Semantic(next) = action {
102                    self.dfs_semantic(next, path)?;
103                }
104            }
105        }
106        path.pop();
107
108        Ok(())
109    }
110}
111
112#[derive(Debug, Hash, PartialEq, Eq, Clone)]
113pub enum Trigger {
114    Key(KeyCombination),
115    Mouse(SimpleMouseEvent),
116    Event(Event),
117    /// A "semantic" trigger, such as `Open`, which should be resolved or rejected before starting the picker.
118    /// This is serialized/deserialized with a `::` prefix, such as "::Open" = "Execute(open {})"
119    Semantic(String),
120}
121
122// impl Ord for Trigger {
123//     fn cmp(&self, other: &Self) -> Ordering {
124//         use Trigger::*;
125
126//         match (self, other) {
127//             (Key(a), Key(b)) => a.to_string().cmp(&b.to_string()),
128//             (Mouse(a), Mouse(b)) => a.cmp(b),
129//             (Event(a), Event(b)) => a.cmp(b),
130
131//             // define variant order
132//             (Key(_), _) => Ordering::Less,
133//             (Mouse(_), Key(_)) => Ordering::Greater,
134//             (Mouse(_), Event(_)) => Ordering::Less,
135//             (Event(_), _) => Ordering::Greater,
136//         }
137//     }
138// }
139
140// impl PartialOrd for Trigger {
141//     fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
142//         Some(self.cmp(other))
143//     }
144// }
145
146/// Crossterm mouse event without location
147#[derive(Debug, Eq, Clone, PartialEq, Hash)]
148pub struct SimpleMouseEvent {
149    pub kind: MouseEventKind,
150    pub modifiers: KeyModifiers,
151}
152
153impl Ord for SimpleMouseEvent {
154    fn cmp(&self, other: &Self) -> Ordering {
155        match self.kind.partial_cmp(&other.kind) {
156            Some(Ordering::Equal) | None => self.modifiers.bits().cmp(&other.modifiers.bits()),
157            Some(o) => o,
158        }
159    }
160}
161
162impl PartialOrd for SimpleMouseEvent {
163    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
164        Some(self.cmp(other))
165    }
166}
167
168// ---------- BOILERPLATE
169impl From<crossterm::event::MouseEvent> for Trigger {
170    fn from(e: crossterm::event::MouseEvent) -> Self {
171        Trigger::Mouse(SimpleMouseEvent {
172            kind: e.kind,
173            modifiers: e.modifiers,
174        })
175    }
176}
177
178impl From<KeyCombination> for Trigger {
179    fn from(key: KeyCombination) -> Self {
180        Trigger::Key(key)
181    }
182}
183
184impl From<Event> for Trigger {
185    fn from(event: Event) -> Self {
186        Trigger::Event(event)
187    }
188}
189// ------------ SERDE
190
191impl Display for Trigger {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        match self {
194            Trigger::Key(key) => write!(f, "{}", key),
195            Trigger::Mouse(event) => {
196                if event.modifiers.contains(KeyModifiers::SHIFT) {
197                    write!(f, "shift+")?;
198                }
199                if event.modifiers.contains(KeyModifiers::CONTROL) {
200                    write!(f, "ctrl+")?;
201                }
202                if event.modifiers.contains(KeyModifiers::ALT) {
203                    write!(f, "alt+")?;
204                }
205                if event.modifiers.contains(KeyModifiers::SUPER) {
206                    write!(f, "super+")?;
207                }
208                if event.modifiers.contains(KeyModifiers::HYPER) {
209                    write!(f, "hyper+")?;
210                }
211                if event.modifiers.contains(KeyModifiers::META) {
212                    write!(f, "meta+")?;
213                }
214                write!(f, "{}", mouse_event_kind_as_str(event.kind))
215            }
216            Trigger::Event(event) => write!(f, "{}", event),
217            Trigger::Semantic(alias) => write!(f, "::{alias}"),
218        }
219    }
220}
221
222impl ser::Serialize for Trigger {
223    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
224    where
225        S: ser::Serializer,
226    {
227        serializer.serialize_str(&self.to_string())
228    }
229}
230
231pub fn mouse_event_kind_as_str(kind: MouseEventKind) -> &'static str {
232    match kind {
233        MouseEventKind::Down(MouseButton::Left) => "left",
234        MouseEventKind::Down(MouseButton::Middle) => "middle",
235        MouseEventKind::Down(MouseButton::Right) => "right",
236        MouseEventKind::ScrollDown => "scrolldown",
237        MouseEventKind::ScrollUp => "scrollup",
238        MouseEventKind::ScrollLeft => "scrollleft",
239        MouseEventKind::ScrollRight => "scrollright",
240        _ => "", // Other kinds are not handled in deserialize
241    }
242}
243
244impl FromStr for Trigger {
245    type Err = String;
246
247    fn from_str(value: &str) -> Result<Self, Self::Err> {
248        // try semantic
249        if let Some(s) = value.strip_prefix("::") {
250            return Ok(Trigger::Semantic(s.to_string()));
251        }
252
253        // 1. Try KeyCombination
254        if let Ok(key) = KeyCombination::from_str(value) {
255            return Ok(Trigger::Key(key));
256        }
257
258        // 2. Try MouseEvent
259        let parts: Vec<&str> = value.split('+').collect();
260        if let Some(last) = parts.last()
261            && let Some(kind) = match last.to_lowercase().as_str() {
262                "left" => Some(MouseEventKind::Down(MouseButton::Left)),
263                "middle" => Some(MouseEventKind::Down(MouseButton::Middle)),
264                "right" => Some(MouseEventKind::Down(MouseButton::Right)),
265                "scrolldown" => Some(MouseEventKind::ScrollDown),
266                "scrollup" => Some(MouseEventKind::ScrollUp),
267                "scrollleft" => Some(MouseEventKind::ScrollLeft),
268                "scrollright" => Some(MouseEventKind::ScrollRight),
269                _ => None,
270            }
271        {
272            let mut modifiers = KeyModifiers::empty();
273            for m in &parts[..parts.len() - 1] {
274                match m.to_lowercase().as_str() {
275                    "shift" => modifiers |= KeyModifiers::SHIFT,
276                    "ctrl" => modifiers |= KeyModifiers::CONTROL,
277                    "alt" => modifiers |= KeyModifiers::ALT,
278                    "super" => modifiers |= KeyModifiers::SUPER,
279                    "hyper" => modifiers |= KeyModifiers::HYPER,
280                    "meta" => modifiers |= KeyModifiers::META,
281                    "none" => {}
282                    unknown => return Err(format!("Unknown modifier: {}", unknown)),
283                }
284            }
285
286            return Ok(Trigger::Mouse(SimpleMouseEvent { kind, modifiers }));
287        }
288
289        if let Ok(event) = value.parse::<Event>() {
290            return Ok(Trigger::Event(event));
291        }
292
293        Err(format!("failed to parse trigger from '{}'", value))
294    }
295}
296
297impl<'de> serde::Deserialize<'de> for Trigger {
298    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
299    where
300        D: Deserializer<'de>,
301    {
302        struct TriggerVisitor;
303
304        impl<'de> Visitor<'de> for TriggerVisitor {
305            type Value = Trigger;
306
307            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
308                write!(f, "a string representing a Trigger")
309            }
310
311            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
312            where
313                E: de::Error,
314            {
315                value.parse::<Trigger>().map_err(E::custom)
316            }
317        }
318
319        deserializer.deserialize_str(TriggerVisitor)
320    }
321}
322
323use ratatui::style::Style;
324use ratatui::text::{Line, Span, Text};
325
326pub fn display_binds<A: ActionExt + Display>(
327    binds: &BindMap<A>,
328    cfg: Option<&HelpColorConfig>,
329) -> Text<'static> {
330    // Collect trigger and action strings
331    let mut entries: Vec<(String, String)> = binds
332        .iter()
333        .map(|(trigger, actions)| {
334            let value_str = if actions.len() == 1 {
335                actions[0].to_string()
336            } else {
337                let inner = actions
338                    .iter()
339                    .map(|a| a.to_string())
340                    .collect::<Vec<_>>()
341                    .join(", ");
342                format!("[{inner}]")
343            };
344            (trigger.to_string(), value_str)
345        })
346        .collect();
347
348    // Sort by trigger string
349    entries.sort_by(|a, b| a.1.cmp(&b.1));
350
351    // Build output
352    let Some(cfg) = cfg else {
353        // fallback plain text
354        let mut text = Text::default();
355        for (trigger, value) in entries {
356            text.extend(Text::from(format!("{trigger} = {value}\n")));
357        }
358        return text;
359    };
360
361    let mut text = Text::default();
362
363    for (trigger, value) in entries {
364        let mut spans = vec![];
365
366        // Trigger
367        spans.push(Span::styled(trigger, Style::default().fg(cfg.key)));
368        spans.push(Span::raw(" = "));
369
370        // Value
371        if value.starts_with('[') {
372            // multi-action list: color each item
373            spans.push(Span::raw("["));
374            let inner = &value[1..value.len() - 1];
375            for (i, item) in inner.split(", ").enumerate() {
376                if i > 0 {
377                    spans.push(Span::raw(", "));
378                }
379                spans.push(Span::styled(
380                    item.to_string(),
381                    Style::default().fg(cfg.value),
382                ));
383            }
384            spans.push(Span::raw("]"));
385        } else {
386            spans.push(Span::styled(value, Style::default().fg(cfg.value)));
387        }
388
389        spans.push(Span::raw("\n"));
390        text.extend(Text::from(Line::from(spans)));
391    }
392
393    text
394}
395
396#[cfg(test)]
397mod test {
398    use super::*;
399    use crossterm::event::MouseEvent;
400
401    #[test]
402    fn test_bindmap_trigger() {
403        let mut bind_map: BindMap = BindMap::new();
404
405        // Insert trigger with default actions
406        let trigger0 = Trigger::Mouse(SimpleMouseEvent {
407            kind: MouseEventKind::ScrollDown,
408            modifiers: KeyModifiers::empty(),
409        });
410        bind_map.insert(trigger0.clone(), Actions::default());
411
412        // Construct via From<MouseEvent>
413        let mouse_event = MouseEvent {
414            kind: MouseEventKind::ScrollDown,
415            column: 0,
416            row: 0,
417            modifiers: KeyModifiers::empty(),
418        };
419        let from_event: Trigger = mouse_event.into();
420
421        // Should be retrievable
422        assert!(bind_map.contains_key(&from_event));
423
424        // Shift-modified trigger should NOT be found
425        let shift_trigger = Trigger::Mouse(SimpleMouseEvent {
426            kind: MouseEventKind::ScrollDown,
427            modifiers: KeyModifiers::SHIFT,
428        });
429        assert!(!bind_map.contains_key(&shift_trigger));
430    }
431
432    #[test]
433    fn test_check_cycles() {
434        use crate::bindmap;
435        let bind_map: BindMap = bindmap!(
436            Trigger::Semantic("a".into()) => Action::Semantic("b".into()),
437            Trigger::Semantic("b".into()) => Action::Semantic("a".into()),
438        );
439        assert!(bind_map.check_cycles().is_err());
440
441        let bind_map_no_cycle: BindMap = bindmap!(
442            Trigger::Semantic("a".into()) => Action::Semantic("b".into()),
443            Trigger::Semantic("b".into()) => Action::Print("ok".into()),
444        );
445        assert!(bind_map_no_cycle.check_cycles().is_ok());
446
447        let bind_map_self_cycle: BindMap = bindmap!(
448            Trigger::Semantic("a".into()) => Action::Semantic("a".into()),
449        );
450        assert!(bind_map_self_cycle.check_cycles().is_err());
451
452        let bind_map_indirect_cycle: BindMap = bindmap!(
453            key!(a) => Action::Semantic("foo".into()),
454            Trigger::Semantic("foo".into()) => Action::Semantic("foo".into()),
455        );
456        assert!(bind_map_indirect_cycle.check_cycles().is_err());
457    }
458}