1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
24pub enum ActionRingSlot {
25 Top,
27 TopRight,
29 Right,
31 BottomRight,
33 Bottom,
35 BottomLeft,
37 Left,
39 TopLeft,
41}
42
43impl ActionRingSlot {
44 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 #[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 #[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 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
105pub enum RingActionError {
106 #[error("Do Nothing is represented by an empty Actions Ring slot")]
108 EmptyAction,
109 #[error("Show Actions Ring cannot be assigned inside an Actions Ring")]
111 RecursiveTrigger,
112}
113
114#[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 pub fn new(action: Action) -> Result<Self, RingActionError> {
128 Self::try_new(action)
129 }
130
131 #[must_use]
133 pub fn action(&self) -> &Action {
134 self.as_ref()
135 }
136
137 #[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#[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 #[must_use]
169 pub const fn new(action: RingAction) -> Self {
170 Self {
171 action,
172 icon: None,
173 label: None,
174 }
175 }
176
177 #[must_use]
179 pub fn action(&self) -> &Action {
180 self.action.action()
181 }
182
183 #[must_use]
185 pub const fn custom_icon(&self) -> Option<ActionRingIcon> {
186 self.icon
187 }
188
189 #[must_use]
193 pub fn custom_label(&self) -> Option<&str> {
194 self.label.as_deref()
195 }
196
197 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(deny_unknown_fields)]
220pub struct ActionRingLayout {
221 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
223 pub slots: BTreeMap<ActionRingSlot, ActionRingEntry>,
224}
225
226impl ActionRingLayout {
227 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 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 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(deny_unknown_fields)]
292pub struct ActionRingConfig {
293 #[serde(default = "default_true")]
295 pub enabled: bool,
296 #[serde(default = "default_true")]
298 pub haptics: bool,
299 #[serde(default)]
301 pub default: ActionRingLayout,
302 #[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 #[must_use]
322 pub fn is_default(&self) -> bool {
323 self == &Self::default()
324 }
325
326 #[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)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn default_layout_populates_every_position() {
346 let layout = ActionRingLayout::default();
347 assert_eq!(layout.slots.len(), ActionRingSlot::ALL.len());
348 assert!(
349 ActionRingSlot::ALL
350 .iter()
351 .all(|slot| layout.slots.contains_key(slot))
352 );
353 }
354
355 #[test]
356 fn invalid_ring_actions_are_rejected() {
357 assert_eq!(
358 RingAction::new(Action::None),
359 Err(RingActionError::EmptyAction)
360 );
361 assert_eq!(
362 RingAction::new(Action::ShowActionsRing),
363 Err(RingActionError::RecursiveTrigger)
364 );
365 }
366
367 #[test]
368 fn ring_action_serializes_like_the_wrapped_action() {
369 #[derive(Serialize)]
370 struct Wrapper {
371 action: RingAction,
372 }
373
374 let action = RingAction::new(Action::Copy).expect("copy must be a valid ring action");
375 let encoded =
376 toml::to_string(&Wrapper { action }).expect("could not serialize ring action");
377 assert_eq!(encoded, "action = \"Copy\"\n");
378 }
379
380 #[test]
381 fn custom_labels_roundtrip_and_survive_action_replacement() {
382 let mut layout: ActionRingLayout = toml::from_str(
383 r#"
384 [slots]
385 Top = { action = "Copy", label = "Copy Invoice" }
386 "#,
387 )
388 .expect("could not deserialize labelled layout");
389 assert_eq!(
390 layout.slots[&ActionRingSlot::Top].custom_label(),
391 Some("Copy Invoice")
392 );
393
394 let encoded = toml::to_string(&layout).expect("could not serialize labelled layout");
395 let decoded = toml::from_str::<ActionRingLayout>(&encoded)
396 .expect("could not deserialize labelled layout");
397 assert_eq!(decoded, layout);
398
399 layout.set_action(
401 ActionRingSlot::Top,
402 Some(RingAction::new(Action::Paste).expect("paste must be a valid ring action")),
403 );
404 assert_eq!(
405 layout.slots[&ActionRingSlot::Top].custom_label(),
406 Some("Copy Invoice")
407 );
408 }
409
410 #[test]
411 fn unlabelled_entries_serialize_without_a_label_key() {
412 let layout = ActionRingLayout::default();
413 let encoded = toml::to_string(&layout).expect("could not serialize ring layout");
414 assert!(!encoded.contains("label"));
415 }
416
417 #[test]
418 fn clearing_a_slot_cannot_leave_an_orphan_icon() {
419 let mut layout = ActionRingLayout::default();
420 layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
421 layout.set_action(ActionRingSlot::Top, None);
422 assert!(!layout.slots.contains_key(&ActionRingSlot::Top));
423 }
424
425 #[test]
426 fn custom_icons_roundtrip_without_changing_slot_actions() {
427 let mut layout = ActionRingLayout::default();
428 layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
429 let encoded = toml::to_string(&layout).expect("could not serialize ring layout");
430 let decoded = toml::from_str::<ActionRingLayout>(&encoded)
431 .expect("could not deserialize ring layout");
432 assert_eq!(decoded, layout);
433 assert_eq!(decoded.slots[&ActionRingSlot::Top].action(), &Action::Cut);
434 assert_eq!(
435 decoded.slots[&ActionRingSlot::Top].custom_icon(),
436 Some(ActionRingIcon::Keyboard)
437 );
438 }
439
440 #[test]
441 fn documented_inline_slots_deserialize() {
442 let layout = toml::from_str::<ActionRingLayout>(
443 r#"
444[slots]
445Top = { action = "Copy", icon = "Keyboard" }
446Bottom = { action = { CustomShortcut = "Cmd+Shift+P" } }
447"#,
448 )
449 .expect("documented ring layout failed");
450 assert_eq!(layout.slots[&ActionRingSlot::Top].action(), &Action::Copy);
451 assert_eq!(
452 layout.slots[&ActionRingSlot::Top].custom_icon(),
453 Some(ActionRingIcon::Keyboard)
454 );
455 assert!(matches!(
456 layout.slots[&ActionRingSlot::Bottom].action(),
457 Action::CustomShortcut(_)
458 ));
459 }
460
461 #[test]
462 fn recursive_action_fails_deserialization() {
463 let error = match toml::from_str::<ActionRingEntry>("action = \"ShowActionsRing\"") {
467 Ok(entry) => panic!("a ring slot must not recursively open the ring, got {entry:?}"),
468 Err(error) => error,
469 };
470 assert!(
471 error
472 .to_string()
473 .contains(&RingActionError::RecursiveTrigger.to_string()),
474 "expected the recursion guard to reject the slot, got: {error}"
475 );
476 }
477
478 #[test]
479 fn app_layout_replaces_the_default_layout() {
480 let mut config = ActionRingConfig::default();
481 let safari = ActionRingLayout {
482 slots: BTreeMap::from([(
483 ActionRingSlot::Top,
484 ActionRingEntry::new(
485 RingAction::new(Action::NewTab).expect("new tab must be a valid ring action"),
486 ),
487 )]),
488 };
489 config
490 .per_app
491 .insert("com.apple.Safari".to_string(), safari.clone());
492
493 assert_eq!(config.effective_layout(Some("com.apple.Safari")), safari);
494 assert_eq!(config.effective_layout(Some("other")), config.default);
495 }
496}