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