Skip to main content

openlogi_core/binding/
action_ring.rs

1//! Actions Ring configuration vocabulary.
2//!
3//! The ring is host-side UI: a trigger opens an eight-position layout and the
4//! agent executes the selected action. The types live beside [`Action`] because
5//! they are persisted directly in `config.toml` and shared by the agent and GUI.
6
7use std::collections::BTreeMap;
8use std::collections::btree_map::Entry;
9
10use nutype::nutype;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use super::Action;
15use crate::app::overlay_for;
16
17mod icon;
18
19pub use icon::ActionRingIcon;
20
21/// One of the eight fixed positions in an Actions Ring, clockwise from the top.
22///
23/// Variant names are part of the TOML schema and must remain stable.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
25pub enum ActionRingSlot {
26    /// Twelve o'clock.
27    Top,
28    /// Between top and right.
29    TopRight,
30    /// Three o'clock.
31    Right,
32    /// Between right and bottom.
33    BottomRight,
34    /// Six o'clock.
35    Bottom,
36    /// Between bottom and left.
37    BottomLeft,
38    /// Nine o'clock.
39    Left,
40    /// Between left and top.
41    TopLeft,
42}
43
44impl ActionRingSlot {
45    /// All ring positions in clockwise display order.
46    pub const ALL: [Self; 8] = [
47        Self::Top,
48        Self::TopRight,
49        Self::Right,
50        Self::BottomRight,
51        Self::Bottom,
52        Self::BottomLeft,
53        Self::Left,
54        Self::TopLeft,
55    ];
56
57    /// Unit vector from the ring's centre to this slot, with positive Y
58    /// pointing **down** — the screen convention both GPUI frontends draw in,
59    /// not the mathematical one.
60    #[must_use]
61    pub fn unit_offset(self) -> (f32, f32) {
62        let diagonal = std::f32::consts::FRAC_1_SQRT_2;
63        match self {
64            Self::Top => (0.0, -1.0),
65            Self::TopRight => (diagonal, -diagonal),
66            Self::Right => (1.0, 0.0),
67            Self::BottomRight => (diagonal, diagonal),
68            Self::Bottom => (0.0, 1.0),
69            Self::BottomLeft => (-diagonal, diagonal),
70            Self::Left => (-1.0, 0.0),
71            Self::TopLeft => (-diagonal, -diagonal),
72        }
73    }
74
75    /// Top-left corner at which to place this slot's `slot_size` box, on a
76    /// square `canvas` whose ring has the given `radius`. All in the caller's
77    /// own units; the live overlay and the settings preview differ only in
78    /// what they pass.
79    #[must_use]
80    pub fn placement(self, canvas: f32, radius: f32, slot_size: f32) -> (f32, f32) {
81        let (x, y) = self.unit_offset();
82        (
83            canvas / 2.0 + x * radius - slot_size / 2.0,
84            canvas / 2.0 + y * radius - slot_size / 2.0,
85        )
86    }
87
88    /// Stable display index matching [`Self::ALL`].
89    #[must_use]
90    pub const fn index(self) -> usize {
91        match self {
92            Self::Top => 0,
93            Self::TopRight => 1,
94            Self::Right => 2,
95            Self::BottomRight => 3,
96            Self::Bottom => 4,
97            Self::BottomLeft => 5,
98            Self::Left => 6,
99            Self::TopLeft => 7,
100        }
101    }
102}
103
104/// Why an [`Action`] cannot be placed in an Actions Ring slot.
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
106pub enum RingActionError {
107    /// Empty slots are represented by an absent map entry, not `Action::None`.
108    #[error("Do Nothing is represented by an empty Actions Ring slot")]
109    EmptyAction,
110    /// A ring cannot recursively open itself.
111    #[error("Show Actions Ring cannot be assigned inside an Actions Ring")]
112    RecursiveTrigger,
113}
114
115/// An action that is valid inside an Actions Ring.
116///
117/// Construction and deserialization reject actions that would make the ring's
118/// state ambiguous (`None`) or recursively invoke another ring
119/// (`ShowActionsRing`).
120#[nutype(
121    validate(with = validate_ring_action, error = RingActionError),
122    derive(Clone, Debug, PartialEq, Eq, Hash, AsRef, TryFrom, Into, Serialize, Deserialize),
123)]
124pub struct RingAction(Action);
125
126impl RingAction {
127    /// Validate and wrap an ordinary action for placement in a ring.
128    pub fn new(action: Action) -> Result<Self, RingActionError> {
129        Self::try_new(action)
130    }
131
132    /// The action the agent should execute when this slot is activated.
133    #[must_use]
134    pub fn action(&self) -> &Action {
135        self.as_ref()
136    }
137
138    /// Consume the wrapper and return its action.
139    #[must_use]
140    pub fn into_action(self) -> Action {
141        self.into_inner()
142    }
143}
144
145fn validate_ring_action(action: &Action) -> Result<(), RingActionError> {
146    match action {
147        Action::None => Err(RingActionError::EmptyAction),
148        Action::ShowActionsRing => Err(RingActionError::RecursiveTrigger),
149        _ => Ok(()),
150    }
151}
152
153/// One populated Actions Ring slot.
154///
155/// Keeping the action and optional presentation icon in one value makes an
156/// orphan icon impossible: clearing a slot removes the complete entry.
157#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct ActionRingEntry {
160    action: RingAction,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    icon: Option<ActionRingIcon>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    label: Option<String>,
165}
166
167impl ActionRingEntry {
168    /// Create a slot with its action-derived icon and label.
169    #[must_use]
170    pub const fn new(action: RingAction) -> Self {
171        Self {
172            action,
173            icon: None,
174            label: None,
175        }
176    }
177
178    /// Executable action for this slot.
179    #[must_use]
180    pub fn action(&self) -> &Action {
181        self.action.action()
182    }
183
184    /// User-selected icon, or `None` to derive it from [`Self::action`].
185    #[must_use]
186    pub const fn custom_icon(&self) -> Option<ActionRingIcon> {
187        self.icon
188    }
189
190    /// User-provided display label, or `None` to derive one from
191    /// [`Self::action`]. Free text: the overlay's localization pass returns
192    /// unknown keys verbatim, so user labels render as written.
193    #[must_use]
194    pub fn custom_label(&self) -> Option<&str> {
195        self.label.as_deref()
196    }
197
198    /// Consume the entry into its executable action and presentation
199    /// overrides (icon, label).
200    #[must_use]
201    pub fn into_parts(self) -> (Action, Option<ActionRingIcon>, Option<String>) {
202        (self.action.into_action(), self.icon, self.label)
203    }
204
205    fn replace_action(&mut self, action: RingAction) {
206        self.action = action;
207    }
208
209    fn set_icon(&mut self, icon: Option<ActionRingIcon>) {
210        self.icon = icon;
211    }
212
213    fn set_label(&mut self, label: Option<String>) {
214        self.label = label;
215    }
216}
217
218/// The actions displayed at the eight fixed ring positions.
219#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct ActionRingLayout {
222    /// Populated ring positions. An absent key is an intentionally empty slot.
223    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
224    pub slots: BTreeMap<ActionRingSlot, ActionRingEntry>,
225}
226
227impl ActionRingLayout {
228    /// Replace or clear a slot while preserving its custom icon when replaced.
229    pub fn set_action(&mut self, slot: ActionRingSlot, action: Option<RingAction>) {
230        match (self.slots.entry(slot), action) {
231            (Entry::Occupied(mut entry), Some(action)) => entry.get_mut().replace_action(action),
232            (Entry::Vacant(entry), Some(action)) => {
233                entry.insert(ActionRingEntry::new(action));
234            }
235            (Entry::Occupied(entry), None) => {
236                entry.remove();
237            }
238            (Entry::Vacant(_), None) => {}
239        }
240    }
241
242    /// Set a custom icon for a populated slot. Empty slots remain empty.
243    pub fn set_icon(&mut self, slot: ActionRingSlot, icon: Option<ActionRingIcon>) {
244        if let Some(entry) = self.slots.get_mut(&slot) {
245            entry.set_icon(icon);
246        }
247    }
248
249    /// Set a custom label for a populated slot. Empty slots remain empty.
250    pub fn set_label(&mut self, slot: ActionRingSlot, label: Option<String>) {
251        if let Some(entry) = self.slots.get_mut(&slot) {
252            entry.set_label(label);
253        }
254    }
255}
256
257impl Default for ActionRingLayout {
258    #[expect(
259        clippy::expect_used,
260        reason = "the built-in ring actions are statically known to satisfy RingAction's invariant"
261    )]
262    fn default() -> Self {
263        use ActionRingSlot as Slot;
264
265        let actions = [
266            (Slot::Top, Action::Cut),
267            (Slot::TopRight, Action::Copy),
268            (Slot::Right, Action::Paste),
269            (Slot::BottomRight, Action::BrowserForward),
270            (Slot::Bottom, Action::PlayPause),
271            (Slot::BottomLeft, Action::BrowserBack),
272            (Slot::Left, Action::Undo),
273            (Slot::TopLeft, Action::Redo),
274        ];
275        let slots = actions
276            .into_iter()
277            .map(|(slot, action)| {
278                (
279                    slot,
280                    ActionRingEntry::new(
281                        RingAction::new(action).expect("default ring actions must be valid"),
282                    ),
283                )
284            })
285            .collect();
286        Self { slots }
287    }
288}
289
290/// Per-device Actions Ring settings and application-specific layouts.
291#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(deny_unknown_fields)]
293pub struct ActionRingConfig {
294    /// Whether `ShowActionsRing` opens this device's ring.
295    #[serde(default = "default_true")]
296    pub enabled: bool,
297    /// Whether ring hover and activation transitions play device haptics.
298    #[serde(default = "default_true")]
299    pub haptics: bool,
300    /// Layout used when the foreground application has no override.
301    #[serde(default)]
302    pub default: ActionRingLayout,
303    /// Complete layout overrides keyed by foreground application identifier.
304    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
305    pub per_app: BTreeMap<String, ActionRingLayout>,
306}
307
308impl Default for ActionRingConfig {
309    fn default() -> Self {
310        Self {
311            enabled: true,
312            haptics: true,
313            default: ActionRingLayout::default(),
314            per_app: BTreeMap::new(),
315        }
316    }
317}
318
319impl ActionRingConfig {
320    /// Whether this value is exactly the implicit default and can be omitted
321    /// from `config.toml`.
322    #[must_use]
323    pub fn is_default(&self) -> bool {
324        self == &Self::default()
325    }
326
327    /// Resolve the complete layout for the foreground application.
328    ///
329    /// `per_app` takes the same selectors as `per_app_bindings` and is matched
330    /// the same way ([`overlay_for`]): the exact key, then on Windows the
331    /// `exe:<filename>` fallback a versioned install path needs.
332    #[must_use]
333    pub fn effective_layout(&self, app_id: Option<&str>) -> ActionRingLayout {
334        app_id
335            .and_then(|app| overlay_for(&self.per_app, app))
336            .cloned()
337            .unwrap_or_else(|| self.default.clone())
338    }
339}
340
341const fn default_true() -> bool {
342    true
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn default_layout_populates_every_position() {
351        let layout = ActionRingLayout::default();
352        assert_eq!(layout.slots.len(), ActionRingSlot::ALL.len());
353        assert!(
354            ActionRingSlot::ALL
355                .iter()
356                .all(|slot| layout.slots.contains_key(slot))
357        );
358    }
359
360    #[test]
361    fn invalid_ring_actions_are_rejected() {
362        assert_eq!(
363            RingAction::new(Action::None),
364            Err(RingActionError::EmptyAction)
365        );
366        assert_eq!(
367            RingAction::new(Action::ShowActionsRing),
368            Err(RingActionError::RecursiveTrigger)
369        );
370    }
371
372    #[test]
373    fn ring_action_serializes_like_the_wrapped_action() {
374        #[derive(Serialize)]
375        struct Wrapper {
376            action: RingAction,
377        }
378
379        let action = RingAction::new(Action::Copy).expect("copy must be a valid ring action");
380        let encoded =
381            toml::to_string(&Wrapper { action }).expect("could not serialize ring action");
382        assert_eq!(encoded, "action = \"Copy\"\n");
383    }
384
385    #[test]
386    fn custom_labels_roundtrip_and_survive_action_replacement() {
387        let mut layout: ActionRingLayout = toml::from_str(
388            r#"
389            [slots]
390            Top = { action = "Copy", label = "Copy Invoice" }
391            "#,
392        )
393        .expect("could not deserialize labelled layout");
394        assert_eq!(
395            layout.slots[&ActionRingSlot::Top].custom_label(),
396            Some("Copy Invoice")
397        );
398
399        let encoded = toml::to_string(&layout).expect("could not serialize labelled layout");
400        let decoded = toml::from_str::<ActionRingLayout>(&encoded)
401            .expect("could not deserialize labelled layout");
402        assert_eq!(decoded, layout);
403
404        // Like icons, a label sticks to its slot when the action is replaced.
405        layout.set_action(
406            ActionRingSlot::Top,
407            Some(RingAction::new(Action::Paste).expect("paste must be a valid ring action")),
408        );
409        assert_eq!(
410            layout.slots[&ActionRingSlot::Top].custom_label(),
411            Some("Copy Invoice")
412        );
413    }
414
415    #[test]
416    fn unlabelled_entries_serialize_without_a_label_key() {
417        let layout = ActionRingLayout::default();
418        let encoded = toml::to_string(&layout).expect("could not serialize ring layout");
419        assert!(!encoded.contains("label"));
420    }
421
422    #[test]
423    fn clearing_a_slot_cannot_leave_an_orphan_icon() {
424        let mut layout = ActionRingLayout::default();
425        layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
426        layout.set_action(ActionRingSlot::Top, None);
427        assert!(!layout.slots.contains_key(&ActionRingSlot::Top));
428    }
429
430    #[test]
431    fn custom_icons_roundtrip_without_changing_slot_actions() {
432        let mut layout = ActionRingLayout::default();
433        layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
434        let encoded = toml::to_string(&layout).expect("could not serialize ring layout");
435        let decoded = toml::from_str::<ActionRingLayout>(&encoded)
436            .expect("could not deserialize ring layout");
437        assert_eq!(decoded, layout);
438        assert_eq!(decoded.slots[&ActionRingSlot::Top].action(), &Action::Cut);
439        assert_eq!(
440            decoded.slots[&ActionRingSlot::Top].custom_icon(),
441            Some(ActionRingIcon::Keyboard)
442        );
443    }
444
445    #[test]
446    fn documented_inline_slots_deserialize() {
447        let layout = toml::from_str::<ActionRingLayout>(
448            r#"
449[slots]
450Top = { action = "Copy", icon = "Keyboard" }
451Bottom = { action = { CustomShortcut = "Cmd+Shift+P" } }
452"#,
453        )
454        .expect("documented ring layout failed");
455        assert_eq!(layout.slots[&ActionRingSlot::Top].action(), &Action::Copy);
456        assert_eq!(
457            layout.slots[&ActionRingSlot::Top].custom_icon(),
458            Some(ActionRingIcon::Keyboard)
459        );
460        assert!(matches!(
461            layout.slots[&ActionRingSlot::Bottom].action(),
462            Action::CustomShortcut(_)
463        ));
464    }
465
466    #[test]
467    fn recursive_action_fails_deserialization() {
468        // A bare `"ShowActionsRing"` is not a TOML document, so the slot has to
469        // be deserialized the way config.toml stores it — otherwise the parse
470        // fails on syntax and never reaches the recursion guard.
471        let error = match toml::from_str::<ActionRingEntry>("action = \"ShowActionsRing\"") {
472            Ok(entry) => panic!("a ring slot must not recursively open the ring, got {entry:?}"),
473            Err(error) => error,
474        };
475        assert!(
476            error
477                .to_string()
478                .contains(&RingActionError::RecursiveTrigger.to_string()),
479            "expected the recursion guard to reject the slot, got: {error}"
480        );
481    }
482
483    #[test]
484    fn app_layout_replaces_the_default_layout() {
485        let mut config = ActionRingConfig::default();
486        let safari = ActionRingLayout {
487            slots: BTreeMap::from([(
488                ActionRingSlot::Top,
489                ActionRingEntry::new(
490                    RingAction::new(Action::NewTab).expect("new tab must be a valid ring action"),
491                ),
492            )]),
493        };
494        config
495            .per_app
496            .insert("com.apple.Safari".to_string(), safari.clone());
497
498        assert_eq!(config.effective_layout(Some("com.apple.Safari")), safari);
499        assert_eq!(config.effective_layout(Some("other")), config.default);
500    }
501
502    #[test]
503    fn a_ring_layout_keyed_by_executable_survives_a_versioned_install_path() {
504        let mut config = ActionRingConfig::default();
505        let sharex = ActionRingLayout {
506            slots: BTreeMap::from([(
507                ActionRingSlot::Top,
508                ActionRingEntry::new(
509                    RingAction::new(Action::Copy).expect("copy must be a valid ring action"),
510                ),
511            )]),
512        };
513        config
514            .per_app
515            .insert("exe:sharex.exe".to_string(), sharex.clone());
516
517        // The same selector `per_app_bindings` honours: a Store app's path
518        // changes with every update, its executable name does not.
519        assert_eq!(
520            config.effective_layout(Some(
521                r"c:\program files\windowsapps\sharex_17.1_x64\sharex.exe"
522            )),
523            sharex
524        );
525        assert_eq!(
526            config.effective_layout(Some("com.getsharex.exe")),
527            config.default,
528            "a bundle id is never reinterpreted as an executable"
529        );
530    }
531}