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)]
145pub struct ActionRingEntry {
146    action: RingAction,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    icon: Option<ActionRingIcon>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    label: Option<String>,
151}
152
153impl ActionRingEntry {
154    /// Create a slot with its action-derived icon and label.
155    #[must_use]
156    pub const fn new(action: RingAction) -> Self {
157        Self {
158            action,
159            icon: None,
160            label: None,
161        }
162    }
163
164    /// Executable action for this slot.
165    #[must_use]
166    pub fn action(&self) -> &Action {
167        self.action.action()
168    }
169
170    /// User-selected icon, or `None` to derive it from [`Self::action`].
171    #[must_use]
172    pub const fn custom_icon(&self) -> Option<ActionRingIcon> {
173        self.icon
174    }
175
176    /// User-provided display label, or `None` to derive one from
177    /// [`Self::action`]. Free text: the overlay's localization pass returns
178    /// unknown keys verbatim, so user labels render as written.
179    #[must_use]
180    pub fn custom_label(&self) -> Option<&str> {
181        self.label.as_deref()
182    }
183
184    /// Consume the entry into its executable action and presentation
185    /// overrides (icon, label).
186    #[must_use]
187    pub fn into_parts(self) -> (Action, Option<ActionRingIcon>, Option<String>) {
188        (self.action.into_action(), self.icon, self.label)
189    }
190
191    fn replace_action(&mut self, action: RingAction) {
192        self.action = action;
193    }
194
195    fn set_icon(&mut self, icon: Option<ActionRingIcon>) {
196        self.icon = icon;
197    }
198
199    fn set_label(&mut self, label: Option<String>) {
200        self.label = label;
201    }
202}
203
204/// The actions displayed at the eight fixed ring positions.
205#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
206pub struct ActionRingLayout {
207    /// Populated ring positions. An absent key is an intentionally empty slot.
208    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
209    pub slots: BTreeMap<ActionRingSlot, ActionRingEntry>,
210}
211
212impl ActionRingLayout {
213    /// Replace or clear a slot while preserving its custom icon when replaced.
214    pub fn set_action(&mut self, slot: ActionRingSlot, action: Option<RingAction>) {
215        match (self.slots.entry(slot), action) {
216            (Entry::Occupied(mut entry), Some(action)) => entry.get_mut().replace_action(action),
217            (Entry::Vacant(entry), Some(action)) => {
218                entry.insert(ActionRingEntry::new(action));
219            }
220            (Entry::Occupied(entry), None) => {
221                entry.remove();
222            }
223            (Entry::Vacant(_), None) => {}
224        }
225    }
226
227    /// Set a custom icon for a populated slot. Empty slots remain empty.
228    pub fn set_icon(&mut self, slot: ActionRingSlot, icon: Option<ActionRingIcon>) {
229        if let Some(entry) = self.slots.get_mut(&slot) {
230            entry.set_icon(icon);
231        }
232    }
233
234    /// Set a custom label for a populated slot. Empty slots remain empty.
235    pub fn set_label(&mut self, slot: ActionRingSlot, label: Option<String>) {
236        if let Some(entry) = self.slots.get_mut(&slot) {
237            entry.set_label(label);
238        }
239    }
240}
241
242impl Default for ActionRingLayout {
243    fn default() -> Self {
244        use ActionRingSlot as Slot;
245
246        let actions = [
247            (Slot::Top, Action::Cut),
248            (Slot::TopRight, Action::Copy),
249            (Slot::Right, Action::Paste),
250            (Slot::BottomRight, Action::BrowserForward),
251            (Slot::Bottom, Action::PlayPause),
252            (Slot::BottomLeft, Action::BrowserBack),
253            (Slot::Left, Action::Undo),
254            (Slot::TopLeft, Action::Redo),
255        ];
256        let slots = actions
257            .into_iter()
258            .map(|(slot, action)| (slot, ActionRingEntry::new(RingAction(action))))
259            .collect();
260        Self { slots }
261    }
262}
263
264/// Per-device Actions Ring settings and application-specific layouts.
265#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
266pub struct ActionRingConfig {
267    /// Whether `ShowActionsRing` opens this device's ring.
268    #[serde(default = "default_true")]
269    pub enabled: bool,
270    /// Whether ring hover and activation transitions play device haptics.
271    #[serde(default = "default_true")]
272    pub haptics: bool,
273    /// Layout used when the foreground application has no override.
274    #[serde(default)]
275    pub default: ActionRingLayout,
276    /// Complete layout overrides keyed by foreground application identifier.
277    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
278    pub per_app: BTreeMap<String, ActionRingLayout>,
279}
280
281impl Default for ActionRingConfig {
282    fn default() -> Self {
283        Self {
284            enabled: true,
285            haptics: true,
286            default: ActionRingLayout::default(),
287            per_app: BTreeMap::new(),
288        }
289    }
290}
291
292impl ActionRingConfig {
293    /// Whether this value is exactly the implicit default and can be omitted
294    /// from `config.toml`.
295    #[must_use]
296    pub fn is_default(&self) -> bool {
297        self == &Self::default()
298    }
299
300    /// Resolve the complete layout for the foreground application.
301    #[must_use]
302    pub fn effective_layout(&self, app_id: Option<&str>) -> ActionRingLayout {
303        app_id
304            .and_then(|app| self.per_app.get(app))
305            .cloned()
306            .unwrap_or_else(|| self.default.clone())
307    }
308}
309
310const fn default_true() -> bool {
311    true
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn default_layout_populates_every_position() {
320        let layout = ActionRingLayout::default();
321        assert_eq!(layout.slots.len(), ActionRingSlot::ALL.len());
322        assert!(
323            ActionRingSlot::ALL
324                .iter()
325                .all(|slot| layout.slots.contains_key(slot))
326        );
327    }
328
329    #[test]
330    fn invalid_ring_actions_are_rejected() {
331        assert_eq!(
332            RingAction::new(Action::None),
333            Err(RingActionError::EmptyAction)
334        );
335        assert_eq!(
336            RingAction::new(Action::ShowActionsRing),
337            Err(RingActionError::RecursiveTrigger)
338        );
339    }
340
341    #[test]
342    fn ring_action_serializes_like_the_wrapped_action() {
343        #[derive(Serialize)]
344        struct Wrapper {
345            action: RingAction,
346        }
347
348        let action = RingAction::new(Action::Copy).unwrap_or_else(|error| panic!("{error}"));
349        let encoded = toml::to_string(&Wrapper { action })
350            .unwrap_or_else(|error| panic!("could not serialize ring action: {error}"));
351        assert_eq!(encoded, "action = \"Copy\"\n");
352    }
353
354    #[test]
355    fn custom_labels_roundtrip_and_survive_action_replacement() {
356        let mut layout: ActionRingLayout = toml::from_str(
357            r#"
358            [slots]
359            Top = { action = "Copy", label = "Copy Invoice" }
360            "#,
361        )
362        .unwrap_or_else(|error| panic!("could not deserialize labelled layout: {error}"));
363        assert_eq!(
364            layout.slots[&ActionRingSlot::Top].custom_label(),
365            Some("Copy Invoice")
366        );
367
368        let encoded = toml::to_string(&layout)
369            .unwrap_or_else(|error| panic!("could not serialize labelled layout: {error}"));
370        let decoded = toml::from_str::<ActionRingLayout>(&encoded)
371            .unwrap_or_else(|error| panic!("could not deserialize labelled layout: {error}"));
372        assert_eq!(decoded, layout);
373
374        // Like icons, a label sticks to its slot when the action is replaced.
375        layout.set_action(
376            ActionRingSlot::Top,
377            Some(RingAction::new(Action::Paste).unwrap_or_else(|error| panic!("{error}"))),
378        );
379        assert_eq!(
380            layout.slots[&ActionRingSlot::Top].custom_label(),
381            Some("Copy Invoice")
382        );
383    }
384
385    #[test]
386    fn unlabelled_entries_serialize_without_a_label_key() {
387        let layout = ActionRingLayout::default();
388        let encoded = toml::to_string(&layout)
389            .unwrap_or_else(|error| panic!("could not serialize ring layout: {error}"));
390        assert!(!encoded.contains("label"));
391    }
392
393    #[test]
394    fn clearing_a_slot_cannot_leave_an_orphan_icon() {
395        let mut layout = ActionRingLayout::default();
396        layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
397        layout.set_action(ActionRingSlot::Top, None);
398        assert!(!layout.slots.contains_key(&ActionRingSlot::Top));
399    }
400
401    #[test]
402    fn custom_icons_roundtrip_without_changing_slot_actions() {
403        let mut layout = ActionRingLayout::default();
404        layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
405        let encoded = toml::to_string(&layout)
406            .unwrap_or_else(|error| panic!("could not serialize ring layout: {error}"));
407        let decoded = toml::from_str::<ActionRingLayout>(&encoded)
408            .unwrap_or_else(|error| panic!("could not deserialize ring layout: {error}"));
409        assert_eq!(decoded, layout);
410        assert_eq!(decoded.slots[&ActionRingSlot::Top].action(), &Action::Cut);
411        assert_eq!(
412            decoded.slots[&ActionRingSlot::Top].custom_icon(),
413            Some(ActionRingIcon::Keyboard)
414        );
415    }
416
417    #[test]
418    fn documented_inline_slots_deserialize() {
419        let layout = toml::from_str::<ActionRingLayout>(
420            r#"
421[slots]
422Top = { action = "Copy", icon = "Keyboard" }
423Bottom = { action = { CustomShortcut = "Cmd+Shift+P" } }
424"#,
425        )
426        .unwrap_or_else(|error| panic!("documented ring layout failed: {error}"));
427        assert_eq!(layout.slots[&ActionRingSlot::Top].action(), &Action::Copy);
428        assert_eq!(
429            layout.slots[&ActionRingSlot::Top].custom_icon(),
430            Some(ActionRingIcon::Keyboard)
431        );
432        assert!(matches!(
433            layout.slots[&ActionRingSlot::Bottom].action(),
434            Action::CustomShortcut(_)
435        ));
436    }
437
438    #[test]
439    fn recursive_action_fails_deserialization() {
440        let parsed = toml::from_str::<RingAction>("\"ShowActionsRing\"");
441        assert!(parsed.is_err());
442    }
443
444    #[test]
445    fn app_layout_replaces_the_default_layout() {
446        let mut config = ActionRingConfig::default();
447        let safari = ActionRingLayout {
448            slots: BTreeMap::from([(
449                ActionRingSlot::Top,
450                ActionRingEntry::new(
451                    RingAction::new(Action::NewTab).unwrap_or_else(|error| panic!("{error}")),
452                ),
453            )]),
454        };
455        config
456            .per_app
457            .insert("com.apple.Safari".to_string(), safari.clone());
458
459        assert_eq!(config.effective_layout(Some("com.apple.Safari")), safari);
460        assert_eq!(config.effective_layout(Some("other")), config.default);
461    }
462}