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)]
341#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn default_layout_populates_every_position() {
347 let layout = ActionRingLayout::default();
348 assert_eq!(layout.slots.len(), ActionRingSlot::ALL.len());
349 assert!(
350 ActionRingSlot::ALL
351 .iter()
352 .all(|slot| layout.slots.contains_key(slot))
353 );
354 }
355
356 #[test]
357 fn invalid_ring_actions_are_rejected() {
358 assert_eq!(
359 RingAction::new(Action::None),
360 Err(RingActionError::EmptyAction)
361 );
362 assert_eq!(
363 RingAction::new(Action::ShowActionsRing),
364 Err(RingActionError::RecursiveTrigger)
365 );
366 }
367
368 #[test]
369 fn ring_action_serializes_like_the_wrapped_action() {
370 #[derive(Serialize)]
371 struct Wrapper {
372 action: RingAction,
373 }
374
375 let action = RingAction::new(Action::Copy).expect("copy must be a valid ring action");
376 let encoded =
377 toml::to_string(&Wrapper { action }).expect("could not serialize ring action");
378 assert_eq!(encoded, "action = \"Copy\"\n");
379 }
380
381 #[test]
382 fn custom_labels_roundtrip_and_survive_action_replacement() {
383 let mut layout: ActionRingLayout = toml::from_str(
384 r#"
385 [slots]
386 Top = { action = "Copy", label = "Copy Invoice" }
387 "#,
388 )
389 .expect("could not deserialize labelled layout");
390 assert_eq!(
391 layout.slots[&ActionRingSlot::Top].custom_label(),
392 Some("Copy Invoice")
393 );
394
395 let encoded = toml::to_string(&layout).expect("could not serialize labelled layout");
396 let decoded = toml::from_str::<ActionRingLayout>(&encoded)
397 .expect("could not deserialize labelled layout");
398 assert_eq!(decoded, layout);
399
400 layout.set_action(
402 ActionRingSlot::Top,
403 Some(RingAction::new(Action::Paste).expect("paste must be a valid ring action")),
404 );
405 assert_eq!(
406 layout.slots[&ActionRingSlot::Top].custom_label(),
407 Some("Copy Invoice")
408 );
409 }
410
411 #[test]
412 fn unlabelled_entries_serialize_without_a_label_key() {
413 let layout = ActionRingLayout::default();
414 let encoded = toml::to_string(&layout).expect("could not serialize ring layout");
415 assert!(!encoded.contains("label"));
416 }
417
418 #[test]
419 fn clearing_a_slot_cannot_leave_an_orphan_icon() {
420 let mut layout = ActionRingLayout::default();
421 layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
422 layout.set_action(ActionRingSlot::Top, None);
423 assert!(!layout.slots.contains_key(&ActionRingSlot::Top));
424 }
425
426 #[test]
427 fn custom_icons_roundtrip_without_changing_slot_actions() {
428 let mut layout = ActionRingLayout::default();
429 layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
430 let encoded = toml::to_string(&layout).expect("could not serialize ring layout");
431 let decoded = toml::from_str::<ActionRingLayout>(&encoded)
432 .expect("could not deserialize ring layout");
433 assert_eq!(decoded, layout);
434 assert_eq!(decoded.slots[&ActionRingSlot::Top].action(), &Action::Cut);
435 assert_eq!(
436 decoded.slots[&ActionRingSlot::Top].custom_icon(),
437 Some(ActionRingIcon::Keyboard)
438 );
439 }
440
441 #[test]
442 fn documented_inline_slots_deserialize() {
443 let layout = toml::from_str::<ActionRingLayout>(
444 r#"
445[slots]
446Top = { action = "Copy", icon = "Keyboard" }
447Bottom = { action = { CustomShortcut = "Cmd+Shift+P" } }
448"#,
449 )
450 .expect("documented ring layout failed");
451 assert_eq!(layout.slots[&ActionRingSlot::Top].action(), &Action::Copy);
452 assert_eq!(
453 layout.slots[&ActionRingSlot::Top].custom_icon(),
454 Some(ActionRingIcon::Keyboard)
455 );
456 assert!(matches!(
457 layout.slots[&ActionRingSlot::Bottom].action(),
458 Action::CustomShortcut(_)
459 ));
460 }
461
462 #[test]
463 fn recursive_action_fails_deserialization() {
464 let error = match toml::from_str::<ActionRingEntry>("action = \"ShowActionsRing\"") {
468 Ok(entry) => panic!("a ring slot must not recursively open the ring, got {entry:?}"),
469 Err(error) => error,
470 };
471 assert!(
472 error
473 .to_string()
474 .contains(&RingActionError::RecursiveTrigger.to_string()),
475 "expected the recursion guard to reject the slot, got: {error}"
476 );
477 }
478
479 #[test]
480 fn app_layout_replaces_the_default_layout() {
481 let mut config = ActionRingConfig::default();
482 let safari = ActionRingLayout {
483 slots: BTreeMap::from([(
484 ActionRingSlot::Top,
485 ActionRingEntry::new(
486 RingAction::new(Action::NewTab).expect("new tab must be a valid ring action"),
487 ),
488 )]),
489 };
490 config
491 .per_app
492 .insert("com.apple.Safari".to_string(), safari.clone());
493
494 assert_eq!(config.effective_layout(Some("com.apple.Safari")), safari);
495 assert_eq!(config.effective_layout(Some("other")), config.default);
496 }
497}