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