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            && !s.is_empty()
251        {
252            return Ok(Trigger::Semantic(s.to_string()));
253        }
254
255        // 1. Try KeyCombination
256        if let Ok(key) = KeyCombination::from_str(value) {
257            return Ok(Trigger::Key(key));
258        }
259
260        // 2. Try MouseEvent
261        let parts: Vec<&str> = value.split('+').collect();
262        if let Some(last) = parts.last()
263            && let Some(kind) = match last.to_lowercase().as_str() {
264                "left" => Some(MouseEventKind::Down(MouseButton::Left)),
265                "middle" => Some(MouseEventKind::Down(MouseButton::Middle)),
266                "right" => Some(MouseEventKind::Down(MouseButton::Right)),
267                "scrolldown" => Some(MouseEventKind::ScrollDown),
268                "scrollup" => Some(MouseEventKind::ScrollUp),
269                "scrollleft" => Some(MouseEventKind::ScrollLeft),
270                "scrollright" => Some(MouseEventKind::ScrollRight),
271                _ => None,
272            }
273        {
274            let mut modifiers = KeyModifiers::empty();
275            for m in &parts[..parts.len() - 1] {
276                match m.to_lowercase().as_str() {
277                    "shift" => modifiers |= KeyModifiers::SHIFT,
278                    "ctrl" => modifiers |= KeyModifiers::CONTROL,
279                    "alt" => modifiers |= KeyModifiers::ALT,
280                    "super" => modifiers |= KeyModifiers::SUPER,
281                    "hyper" => modifiers |= KeyModifiers::HYPER,
282                    "meta" => modifiers |= KeyModifiers::META,
283                    "none" => {}
284                    unknown => return Err(format!("Unknown modifier: {}", unknown)),
285                }
286            }
287
288            return Ok(Trigger::Mouse(SimpleMouseEvent { kind, modifiers }));
289        }
290
291        if let Ok(event) = value.parse::<Event>() {
292            return Ok(Trigger::Event(event));
293        }
294
295        Err(format!("failed to parse trigger from '{}'", value))
296    }
297}
298
299impl<'de> serde::Deserialize<'de> for Trigger {
300    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
301    where
302        D: Deserializer<'de>,
303    {
304        struct TriggerVisitor;
305
306        impl<'de> Visitor<'de> for TriggerVisitor {
307            type Value = Trigger;
308
309            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
310                write!(f, "a string representing a Trigger")
311            }
312
313            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
314            where
315                E: de::Error,
316            {
317                value.parse::<Trigger>().map_err(E::custom)
318            }
319        }
320
321        deserializer.deserialize_str(TriggerVisitor)
322    }
323}
324
325use ratatui::style::Style;
326use ratatui::text::{Line, Span, Text};
327
328pub fn display_binds<A: ActionExt + Display>(
329    binds: &BindMap<A>,
330    cfg: Option<&HelpColorConfig>,
331) -> Text<'static> {
332    // Collect trigger and action strings
333    let mut entries: Vec<(String, String)> = binds
334        .iter()
335        .map(|(trigger, actions)| {
336            let value_str = if actions.len() == 1 {
337                actions[0].to_string()
338            } else {
339                let inner = actions
340                    .iter()
341                    .map(|a| a.to_string())
342                    .collect::<Vec<_>>()
343                    .join(", ");
344                format!("[{inner}]")
345            };
346            (trigger.to_string(), value_str)
347        })
348        .collect();
349
350    // Sort by trigger string
351    entries.sort_by(|a, b| a.1.cmp(&b.1));
352
353    // Build output
354    let Some(cfg) = cfg else {
355        // fallback plain text
356        let mut text = Text::default();
357        for (trigger, value) in entries {
358            text.extend(Text::from(format!("{trigger} = {value}\n")));
359        }
360        return text;
361    };
362
363    let mut text = Text::default();
364
365    for (trigger, value) in entries {
366        let mut spans = vec![];
367
368        // Trigger
369        spans.push(Span::styled(trigger, Style::default().fg(cfg.key)));
370        spans.push(Span::raw(" = "));
371
372        // Value
373        if value.starts_with('[') {
374            // multi-action list: color each item
375            spans.push(Span::raw("["));
376            let inner = &value[1..value.len() - 1];
377            for (i, item) in inner.split(", ").enumerate() {
378                if i > 0 {
379                    spans.push(Span::raw(", "));
380                }
381                spans.push(Span::styled(
382                    item.to_string(),
383                    Style::default().fg(cfg.value),
384                ));
385            }
386            spans.push(Span::raw("]"));
387        } else {
388            spans.push(Span::styled(value, Style::default().fg(cfg.value)));
389        }
390
391        spans.push(Span::raw("\n"));
392        text.extend(Text::from(Line::from(spans)));
393    }
394
395    text
396}
397
398#[cfg(test)]
399mod test {
400    use super::*;
401    use crossterm::event::MouseEvent;
402
403    #[test]
404    fn test_bindmap_trigger() {
405        let mut bind_map: BindMap = BindMap::new();
406
407        // Insert trigger with default actions
408        let trigger0 = Trigger::Mouse(SimpleMouseEvent {
409            kind: MouseEventKind::ScrollDown,
410            modifiers: KeyModifiers::empty(),
411        });
412        bind_map.insert(trigger0.clone(), Actions::default());
413
414        // Construct via From<MouseEvent>
415        let mouse_event = MouseEvent {
416            kind: MouseEventKind::ScrollDown,
417            column: 0,
418            row: 0,
419            modifiers: KeyModifiers::empty(),
420        };
421        let from_event: Trigger = mouse_event.into();
422
423        // Should be retrievable
424        assert!(bind_map.contains_key(&from_event));
425
426        // Shift-modified trigger should NOT be found
427        let shift_trigger = Trigger::Mouse(SimpleMouseEvent {
428            kind: MouseEventKind::ScrollDown,
429            modifiers: KeyModifiers::SHIFT,
430        });
431        assert!(!bind_map.contains_key(&shift_trigger));
432    }
433
434    #[test]
435    fn test_semantic_parsing() {
436        assert_eq!(
437            Trigger::from_str("@foo").unwrap(),
438            Trigger::Semantic("foo".into())
439        );
440        let trigger = Trigger::from_str("@").unwrap();
441        // "@" itself is a valid key, but should NOT be parsed as a Semantic trigger.
442        assert!(matches!(trigger, Trigger::Key(_)));
443
444        assert_eq!(
445            Action::<NullActionExt>::from_str("@foo").unwrap(),
446            Action::Semantic("foo".into())
447        );
448        assert!(Action::<NullActionExt>::from_str("@").is_err());
449    }
450
451    #[test]
452    fn test_check_cycles() {
453        use crate::bindmap;
454        let bind_map: BindMap = bindmap!(
455            Trigger::Semantic("a".into()) => Action::Semantic("b".into()),
456            Trigger::Semantic("b".into()) => Action::Semantic("a".into()),
457        );
458        assert!(bind_map.check_cycles().is_err());
459
460        let bind_map_no_cycle: BindMap = bindmap!(
461            Trigger::Semantic("a".into()) => Action::Semantic("b".into()),
462            Trigger::Semantic("b".into()) => Action::Print("ok".into()),
463        );
464        assert!(bind_map_no_cycle.check_cycles().is_ok());
465
466        let bind_map_self_cycle: BindMap = bindmap!(
467            Trigger::Semantic("a".into()) => Action::Semantic("a".into()),
468        );
469        assert!(bind_map_self_cycle.check_cycles().is_err());
470
471        let bind_map_indirect_cycle: BindMap = bindmap!(
472            key!(a) => Action::Semantic("foo".into()),
473            Trigger::Semantic("foo".into()) => Action::Semantic("foo".into()),
474        );
475        assert!(bind_map_indirect_cycle.check_cycles().is_err());
476    }
477}