1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
23pub enum ActionRingSlot {
24 Top,
26 TopRight,
28 Right,
30 BottomRight,
32 Bottom,
34 BottomLeft,
36 Left,
38 TopLeft,
40}
41
42impl ActionRingSlot {
43 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 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
73pub enum RingActionError {
74 #[error("Do Nothing is represented by an empty Actions Ring slot")]
76 EmptyAction,
77 #[error("Show Actions Ring cannot be assigned inside an Actions Ring")]
79 RecursiveTrigger,
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Hash)]
88pub struct RingAction(Action);
89
90impl RingAction {
91 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 #[must_use]
102 pub fn action(&self) -> &Action {
103 &self.0
104 }
105
106 #[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#[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 #[must_use]
157 pub const fn new(action: RingAction) -> Self {
158 Self {
159 action,
160 icon: None,
161 label: None,
162 }
163 }
164
165 #[must_use]
167 pub fn action(&self) -> &Action {
168 self.action.action()
169 }
170
171 #[must_use]
173 pub const fn custom_icon(&self) -> Option<ActionRingIcon> {
174 self.icon
175 }
176
177 #[must_use]
181 pub fn custom_label(&self) -> Option<&str> {
182 self.label.as_deref()
183 }
184
185 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct ActionRingLayout {
209 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
211 pub slots: BTreeMap<ActionRingSlot, ActionRingEntry>,
212}
213
214impl ActionRingLayout {
215 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 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 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub struct ActionRingConfig {
270 #[serde(default = "default_true")]
272 pub enabled: bool,
273 #[serde(default = "default_true")]
275 pub haptics: bool,
276 #[serde(default)]
278 pub default: ActionRingLayout,
279 #[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 #[must_use]
299 pub fn is_default(&self) -> bool {
300 self == &Self::default()
301 }
302
303 #[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 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 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}