openlogi-core 0.6.27

Core types, config, and paths for OpenLogi. No I/O specifics.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
//! Actions Ring configuration vocabulary.
//!
//! The ring is host-side UI: a trigger opens an eight-position layout and the
//! agent executes the selected action. The types live beside [`Action`] because
//! they are persisted directly in `config.toml` and shared by the agent and GUI.

use std::collections::BTreeMap;
use std::collections::btree_map::Entry;

use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use thiserror::Error;

use super::Action;

mod icon;

pub use icon::ActionRingIcon;

/// One of the eight fixed positions in an Actions Ring, clockwise from the top.
///
/// Variant names are part of the TOML schema and must remain stable.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ActionRingSlot {
    /// Twelve o'clock.
    Top,
    /// Between top and right.
    TopRight,
    /// Three o'clock.
    Right,
    /// Between right and bottom.
    BottomRight,
    /// Six o'clock.
    Bottom,
    /// Between bottom and left.
    BottomLeft,
    /// Nine o'clock.
    Left,
    /// Between left and top.
    TopLeft,
}

impl ActionRingSlot {
    /// All ring positions in clockwise display order.
    pub const ALL: [Self; 8] = [
        Self::Top,
        Self::TopRight,
        Self::Right,
        Self::BottomRight,
        Self::Bottom,
        Self::BottomLeft,
        Self::Left,
        Self::TopLeft,
    ];

    /// Stable display index matching [`Self::ALL`].
    #[must_use]
    pub const fn index(self) -> usize {
        match self {
            Self::Top => 0,
            Self::TopRight => 1,
            Self::Right => 2,
            Self::BottomRight => 3,
            Self::Bottom => 4,
            Self::BottomLeft => 5,
            Self::Left => 6,
            Self::TopLeft => 7,
        }
    }
}

/// Why an [`Action`] cannot be placed in an Actions Ring slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
pub enum RingActionError {
    /// Empty slots are represented by an absent map entry, not `Action::None`.
    #[error("Do Nothing is represented by an empty Actions Ring slot")]
    EmptyAction,
    /// A ring cannot recursively open itself.
    #[error("Show Actions Ring cannot be assigned inside an Actions Ring")]
    RecursiveTrigger,
}

/// An action that is valid inside an Actions Ring.
///
/// Construction and deserialization reject actions that would make the ring's
/// state ambiguous (`None`) or recursively invoke another ring
/// (`ShowActionsRing`).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct RingAction(Action);

impl RingAction {
    /// Validate and wrap an ordinary action for placement in a ring.
    pub fn new(action: Action) -> Result<Self, RingActionError> {
        match action {
            Action::None => Err(RingActionError::EmptyAction),
            Action::ShowActionsRing => Err(RingActionError::RecursiveTrigger),
            other => Ok(Self(other)),
        }
    }

    /// The action the agent should execute when this slot is activated.
    #[must_use]
    pub fn action(&self) -> &Action {
        &self.0
    }

    /// Consume the wrapper and return its action.
    #[must_use]
    pub fn into_action(self) -> Action {
        self.0
    }
}

impl TryFrom<Action> for RingAction {
    type Error = RingActionError;

    fn try_from(action: Action) -> Result<Self, Self::Error> {
        Self::new(action)
    }
}

impl Serialize for RingAction {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        self.0.serialize(serializer)
    }
}

impl<'de> Deserialize<'de> for RingAction {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let action = Action::deserialize(deserializer)?;
        Self::new(action).map_err(de::Error::custom)
    }
}

/// One populated Actions Ring slot.
///
/// Keeping the action and optional presentation icon in one value makes an
/// orphan icon impossible: clearing a slot removes the complete entry.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActionRingEntry {
    action: RingAction,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    icon: Option<ActionRingIcon>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    label: Option<String>,
}

impl ActionRingEntry {
    /// Create a slot with its action-derived icon and label.
    #[must_use]
    pub const fn new(action: RingAction) -> Self {
        Self {
            action,
            icon: None,
            label: None,
        }
    }

    /// Executable action for this slot.
    #[must_use]
    pub fn action(&self) -> &Action {
        self.action.action()
    }

    /// User-selected icon, or `None` to derive it from [`Self::action`].
    #[must_use]
    pub const fn custom_icon(&self) -> Option<ActionRingIcon> {
        self.icon
    }

    /// User-provided display label, or `None` to derive one from
    /// [`Self::action`]. Free text: the overlay's localization pass returns
    /// unknown keys verbatim, so user labels render as written.
    #[must_use]
    pub fn custom_label(&self) -> Option<&str> {
        self.label.as_deref()
    }

    /// Consume the entry into its executable action and presentation
    /// overrides (icon, label).
    #[must_use]
    pub fn into_parts(self) -> (Action, Option<ActionRingIcon>, Option<String>) {
        (self.action.into_action(), self.icon, self.label)
    }

    fn replace_action(&mut self, action: RingAction) {
        self.action = action;
    }

    fn set_icon(&mut self, icon: Option<ActionRingIcon>) {
        self.icon = icon;
    }

    fn set_label(&mut self, label: Option<String>) {
        self.label = label;
    }
}

/// The actions displayed at the eight fixed ring positions.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActionRingLayout {
    /// Populated ring positions. An absent key is an intentionally empty slot.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub slots: BTreeMap<ActionRingSlot, ActionRingEntry>,
}

impl ActionRingLayout {
    /// Replace or clear a slot while preserving its custom icon when replaced.
    pub fn set_action(&mut self, slot: ActionRingSlot, action: Option<RingAction>) {
        match (self.slots.entry(slot), action) {
            (Entry::Occupied(mut entry), Some(action)) => entry.get_mut().replace_action(action),
            (Entry::Vacant(entry), Some(action)) => {
                entry.insert(ActionRingEntry::new(action));
            }
            (Entry::Occupied(entry), None) => {
                entry.remove();
            }
            (Entry::Vacant(_), None) => {}
        }
    }

    /// Set a custom icon for a populated slot. Empty slots remain empty.
    pub fn set_icon(&mut self, slot: ActionRingSlot, icon: Option<ActionRingIcon>) {
        if let Some(entry) = self.slots.get_mut(&slot) {
            entry.set_icon(icon);
        }
    }

    /// Set a custom label for a populated slot. Empty slots remain empty.
    pub fn set_label(&mut self, slot: ActionRingSlot, label: Option<String>) {
        if let Some(entry) = self.slots.get_mut(&slot) {
            entry.set_label(label);
        }
    }
}

impl Default for ActionRingLayout {
    fn default() -> Self {
        use ActionRingSlot as Slot;

        let actions = [
            (Slot::Top, Action::Cut),
            (Slot::TopRight, Action::Copy),
            (Slot::Right, Action::Paste),
            (Slot::BottomRight, Action::BrowserForward),
            (Slot::Bottom, Action::PlayPause),
            (Slot::BottomLeft, Action::BrowserBack),
            (Slot::Left, Action::Undo),
            (Slot::TopLeft, Action::Redo),
        ];
        let slots = actions
            .into_iter()
            .map(|(slot, action)| (slot, ActionRingEntry::new(RingAction(action))))
            .collect();
        Self { slots }
    }
}

/// Per-device Actions Ring settings and application-specific layouts.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActionRingConfig {
    /// Whether `ShowActionsRing` opens this device's ring.
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Whether ring hover and activation transitions play device haptics.
    #[serde(default = "default_true")]
    pub haptics: bool,
    /// Layout used when the foreground application has no override.
    #[serde(default)]
    pub default: ActionRingLayout,
    /// Complete layout overrides keyed by foreground application identifier.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub per_app: BTreeMap<String, ActionRingLayout>,
}

impl Default for ActionRingConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            haptics: true,
            default: ActionRingLayout::default(),
            per_app: BTreeMap::new(),
        }
    }
}

impl ActionRingConfig {
    /// Whether this value is exactly the implicit default and can be omitted
    /// from `config.toml`.
    #[must_use]
    pub fn is_default(&self) -> bool {
        self == &Self::default()
    }

    /// Resolve the complete layout for the foreground application.
    #[must_use]
    pub fn effective_layout(&self, app_id: Option<&str>) -> ActionRingLayout {
        app_id
            .and_then(|app| self.per_app.get(app))
            .cloned()
            .unwrap_or_else(|| self.default.clone())
    }
}

const fn default_true() -> bool {
    true
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_layout_populates_every_position() {
        let layout = ActionRingLayout::default();
        assert_eq!(layout.slots.len(), ActionRingSlot::ALL.len());
        assert!(
            ActionRingSlot::ALL
                .iter()
                .all(|slot| layout.slots.contains_key(slot))
        );
    }

    #[test]
    fn invalid_ring_actions_are_rejected() {
        assert_eq!(
            RingAction::new(Action::None),
            Err(RingActionError::EmptyAction)
        );
        assert_eq!(
            RingAction::new(Action::ShowActionsRing),
            Err(RingActionError::RecursiveTrigger)
        );
    }

    #[test]
    fn ring_action_serializes_like_the_wrapped_action() {
        #[derive(Serialize)]
        struct Wrapper {
            action: RingAction,
        }

        let action = RingAction::new(Action::Copy).unwrap_or_else(|error| panic!("{error}"));
        let encoded = toml::to_string(&Wrapper { action })
            .unwrap_or_else(|error| panic!("could not serialize ring action: {error}"));
        assert_eq!(encoded, "action = \"Copy\"\n");
    }

    #[test]
    fn custom_labels_roundtrip_and_survive_action_replacement() {
        let mut layout: ActionRingLayout = toml::from_str(
            r#"
            [slots]
            Top = { action = "Copy", label = "Copy Invoice" }
            "#,
        )
        .unwrap_or_else(|error| panic!("could not deserialize labelled layout: {error}"));
        assert_eq!(
            layout.slots[&ActionRingSlot::Top].custom_label(),
            Some("Copy Invoice")
        );

        let encoded = toml::to_string(&layout)
            .unwrap_or_else(|error| panic!("could not serialize labelled layout: {error}"));
        let decoded = toml::from_str::<ActionRingLayout>(&encoded)
            .unwrap_or_else(|error| panic!("could not deserialize labelled layout: {error}"));
        assert_eq!(decoded, layout);

        // Like icons, a label sticks to its slot when the action is replaced.
        layout.set_action(
            ActionRingSlot::Top,
            Some(RingAction::new(Action::Paste).unwrap_or_else(|error| panic!("{error}"))),
        );
        assert_eq!(
            layout.slots[&ActionRingSlot::Top].custom_label(),
            Some("Copy Invoice")
        );
    }

    #[test]
    fn unlabelled_entries_serialize_without_a_label_key() {
        let layout = ActionRingLayout::default();
        let encoded = toml::to_string(&layout)
            .unwrap_or_else(|error| panic!("could not serialize ring layout: {error}"));
        assert!(!encoded.contains("label"));
    }

    #[test]
    fn clearing_a_slot_cannot_leave_an_orphan_icon() {
        let mut layout = ActionRingLayout::default();
        layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
        layout.set_action(ActionRingSlot::Top, None);
        assert!(!layout.slots.contains_key(&ActionRingSlot::Top));
    }

    #[test]
    fn custom_icons_roundtrip_without_changing_slot_actions() {
        let mut layout = ActionRingLayout::default();
        layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
        let encoded = toml::to_string(&layout)
            .unwrap_or_else(|error| panic!("could not serialize ring layout: {error}"));
        let decoded = toml::from_str::<ActionRingLayout>(&encoded)
            .unwrap_or_else(|error| panic!("could not deserialize ring layout: {error}"));
        assert_eq!(decoded, layout);
        assert_eq!(decoded.slots[&ActionRingSlot::Top].action(), &Action::Cut);
        assert_eq!(
            decoded.slots[&ActionRingSlot::Top].custom_icon(),
            Some(ActionRingIcon::Keyboard)
        );
    }

    #[test]
    fn documented_inline_slots_deserialize() {
        let layout = toml::from_str::<ActionRingLayout>(
            r#"
[slots]
Top = { action = "Copy", icon = "Keyboard" }
Bottom = { action = { CustomShortcut = "Cmd+Shift+P" } }
"#,
        )
        .unwrap_or_else(|error| panic!("documented ring layout failed: {error}"));
        assert_eq!(layout.slots[&ActionRingSlot::Top].action(), &Action::Copy);
        assert_eq!(
            layout.slots[&ActionRingSlot::Top].custom_icon(),
            Some(ActionRingIcon::Keyboard)
        );
        assert!(matches!(
            layout.slots[&ActionRingSlot::Bottom].action(),
            Action::CustomShortcut(_)
        ));
    }

    #[test]
    fn recursive_action_fails_deserialization() {
        let parsed = toml::from_str::<RingAction>("\"ShowActionsRing\"");
        assert!(parsed.is_err());
    }

    #[test]
    fn app_layout_replaces_the_default_layout() {
        let mut config = ActionRingConfig::default();
        let safari = ActionRingLayout {
            slots: BTreeMap::from([(
                ActionRingSlot::Top,
                ActionRingEntry::new(
                    RingAction::new(Action::NewTab).unwrap_or_else(|error| panic!("{error}")),
                ),
            )]),
        };
        config
            .per_app
            .insert("com.apple.Safari".to_string(), safari.clone());

        assert_eq!(config.effective_layout(Some("com.apple.Safari")), safari);
        assert_eq!(config.effective_layout(Some("other")), config.default);
    }
}