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;
15use crate::app::overlay_for;
16
17mod icon;
18
19pub use icon::ActionRingIcon;
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
25pub enum ActionRingSlot {
26 Top,
28 TopRight,
30 Right,
32 BottomRight,
34 Bottom,
36 BottomLeft,
38 Left,
40 TopLeft,
42}
43
44impl ActionRingSlot {
45 pub const ALL: [Self; 8] = [
47 Self::Top,
48 Self::TopRight,
49 Self::Right,
50 Self::BottomRight,
51 Self::Bottom,
52 Self::BottomLeft,
53 Self::Left,
54 Self::TopLeft,
55 ];
56
57 #[must_use]
61 pub fn unit_offset(self) -> (f32, f32) {
62 let diagonal = std::f32::consts::FRAC_1_SQRT_2;
63 match self {
64 Self::Top => (0.0, -1.0),
65 Self::TopRight => (diagonal, -diagonal),
66 Self::Right => (1.0, 0.0),
67 Self::BottomRight => (diagonal, diagonal),
68 Self::Bottom => (0.0, 1.0),
69 Self::BottomLeft => (-diagonal, diagonal),
70 Self::Left => (-1.0, 0.0),
71 Self::TopLeft => (-diagonal, -diagonal),
72 }
73 }
74
75 #[must_use]
80 pub fn placement(self, canvas: f32, radius: f32, slot_size: f32) -> (f32, f32) {
81 let (x, y) = self.unit_offset();
82 (
83 canvas / 2.0 + x * radius - slot_size / 2.0,
84 canvas / 2.0 + y * radius - slot_size / 2.0,
85 )
86 }
87
88 #[must_use]
90 pub const fn index(self) -> usize {
91 match self {
92 Self::Top => 0,
93 Self::TopRight => 1,
94 Self::Right => 2,
95 Self::BottomRight => 3,
96 Self::Bottom => 4,
97 Self::BottomLeft => 5,
98 Self::Left => 6,
99 Self::TopLeft => 7,
100 }
101 }
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
106pub enum RingActionError {
107 #[error("Do Nothing is represented by an empty Actions Ring slot")]
109 EmptyAction,
110 #[error("Show Actions Ring cannot be assigned inside an Actions Ring")]
112 RecursiveTrigger,
113}
114
115#[nutype(
121 validate(with = validate_ring_action, error = RingActionError),
122 derive(Clone, Debug, PartialEq, Eq, Hash, AsRef, TryFrom, Into, Serialize, Deserialize),
123)]
124pub struct RingAction(Action);
125
126impl RingAction {
127 pub fn new(action: Action) -> Result<Self, RingActionError> {
129 Self::try_new(action)
130 }
131
132 #[must_use]
134 pub fn action(&self) -> &Action {
135 self.as_ref()
136 }
137
138 #[must_use]
140 pub fn into_action(self) -> Action {
141 self.into_inner()
142 }
143}
144
145fn validate_ring_action(action: &Action) -> Result<(), RingActionError> {
146 match action {
147 Action::None => Err(RingActionError::EmptyAction),
148 Action::ShowActionsRing => Err(RingActionError::RecursiveTrigger),
149 _ => Ok(()),
150 }
151}
152
153#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct ActionRingEntry {
160 action: RingAction,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 icon: Option<ActionRingIcon>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 label: Option<String>,
165}
166
167impl ActionRingEntry {
168 #[must_use]
170 pub const fn new(action: RingAction) -> Self {
171 Self {
172 action,
173 icon: None,
174 label: None,
175 }
176 }
177
178 #[must_use]
180 pub fn action(&self) -> &Action {
181 self.action.action()
182 }
183
184 #[must_use]
186 pub const fn custom_icon(&self) -> Option<ActionRingIcon> {
187 self.icon
188 }
189
190 #[must_use]
194 pub fn custom_label(&self) -> Option<&str> {
195 self.label.as_deref()
196 }
197
198 #[must_use]
201 pub fn into_parts(self) -> (Action, Option<ActionRingIcon>, Option<String>) {
202 (self.action.into_action(), self.icon, self.label)
203 }
204
205 fn replace_action(&mut self, action: RingAction) {
206 self.action = action;
207 }
208
209 fn set_icon(&mut self, icon: Option<ActionRingIcon>) {
210 self.icon = icon;
211 }
212
213 fn set_label(&mut self, label: Option<String>) {
214 self.label = label;
215 }
216}
217
218#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct ActionRingLayout {
222 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
224 pub slots: BTreeMap<ActionRingSlot, ActionRingEntry>,
225}
226
227impl ActionRingLayout {
228 pub fn set_action(&mut self, slot: ActionRingSlot, action: Option<RingAction>) {
230 match (self.slots.entry(slot), action) {
231 (Entry::Occupied(mut entry), Some(action)) => entry.get_mut().replace_action(action),
232 (Entry::Vacant(entry), Some(action)) => {
233 entry.insert(ActionRingEntry::new(action));
234 }
235 (Entry::Occupied(entry), None) => {
236 entry.remove();
237 }
238 (Entry::Vacant(_), None) => {}
239 }
240 }
241
242 pub fn set_icon(&mut self, slot: ActionRingSlot, icon: Option<ActionRingIcon>) {
244 if let Some(entry) = self.slots.get_mut(&slot) {
245 entry.set_icon(icon);
246 }
247 }
248
249 pub fn set_label(&mut self, slot: ActionRingSlot, label: Option<String>) {
251 if let Some(entry) = self.slots.get_mut(&slot) {
252 entry.set_label(label);
253 }
254 }
255}
256
257impl Default for ActionRingLayout {
258 #[expect(
259 clippy::expect_used,
260 reason = "the built-in ring actions are statically known to satisfy RingAction's invariant"
261 )]
262 fn default() -> Self {
263 use ActionRingSlot as Slot;
264
265 let actions = [
266 (Slot::Top, Action::Cut),
267 (Slot::TopRight, Action::Copy),
268 (Slot::Right, Action::Paste),
269 (Slot::BottomRight, Action::BrowserForward),
270 (Slot::Bottom, Action::PlayPause),
271 (Slot::BottomLeft, Action::BrowserBack),
272 (Slot::Left, Action::Undo),
273 (Slot::TopLeft, Action::Redo),
274 ];
275 let slots = actions
276 .into_iter()
277 .map(|(slot, action)| {
278 (
279 slot,
280 ActionRingEntry::new(
281 RingAction::new(action).expect("default ring actions must be valid"),
282 ),
283 )
284 })
285 .collect();
286 Self { slots }
287 }
288}
289
290#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(deny_unknown_fields)]
293pub struct ActionRingConfig {
294 #[serde(default = "default_true")]
296 pub enabled: bool,
297 #[serde(default = "default_true")]
299 pub haptics: bool,
300 #[serde(default)]
302 pub default: ActionRingLayout,
303 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
305 pub per_app: BTreeMap<String, ActionRingLayout>,
306}
307
308impl Default for ActionRingConfig {
309 fn default() -> Self {
310 Self {
311 enabled: true,
312 haptics: true,
313 default: ActionRingLayout::default(),
314 per_app: BTreeMap::new(),
315 }
316 }
317}
318
319impl ActionRingConfig {
320 #[must_use]
323 pub fn is_default(&self) -> bool {
324 self == &Self::default()
325 }
326
327 #[must_use]
333 pub fn effective_layout(&self, app_id: Option<&str>) -> ActionRingLayout {
334 app_id
335 .and_then(|app| overlay_for(&self.per_app, app))
336 .cloned()
337 .unwrap_or_else(|| self.default.clone())
338 }
339}
340
341const fn default_true() -> bool {
342 true
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn default_layout_populates_every_position() {
351 let layout = ActionRingLayout::default();
352 assert_eq!(layout.slots.len(), ActionRingSlot::ALL.len());
353 assert!(
354 ActionRingSlot::ALL
355 .iter()
356 .all(|slot| layout.slots.contains_key(slot))
357 );
358 }
359
360 #[test]
361 fn invalid_ring_actions_are_rejected() {
362 assert_eq!(
363 RingAction::new(Action::None),
364 Err(RingActionError::EmptyAction)
365 );
366 assert_eq!(
367 RingAction::new(Action::ShowActionsRing),
368 Err(RingActionError::RecursiveTrigger)
369 );
370 }
371
372 #[test]
373 fn ring_action_serializes_like_the_wrapped_action() {
374 #[derive(Serialize)]
375 struct Wrapper {
376 action: RingAction,
377 }
378
379 let action = RingAction::new(Action::Copy).expect("copy must be a valid ring action");
380 let encoded =
381 toml::to_string(&Wrapper { action }).expect("could not serialize ring action");
382 assert_eq!(encoded, "action = \"Copy\"\n");
383 }
384
385 #[test]
386 fn custom_labels_roundtrip_and_survive_action_replacement() {
387 let mut layout: ActionRingLayout = toml::from_str(
388 r#"
389 [slots]
390 Top = { action = "Copy", label = "Copy Invoice" }
391 "#,
392 )
393 .expect("could not deserialize labelled layout");
394 assert_eq!(
395 layout.slots[&ActionRingSlot::Top].custom_label(),
396 Some("Copy Invoice")
397 );
398
399 let encoded = toml::to_string(&layout).expect("could not serialize labelled layout");
400 let decoded = toml::from_str::<ActionRingLayout>(&encoded)
401 .expect("could not deserialize labelled layout");
402 assert_eq!(decoded, layout);
403
404 layout.set_action(
406 ActionRingSlot::Top,
407 Some(RingAction::new(Action::Paste).expect("paste must be a valid ring action")),
408 );
409 assert_eq!(
410 layout.slots[&ActionRingSlot::Top].custom_label(),
411 Some("Copy Invoice")
412 );
413 }
414
415 #[test]
416 fn unlabelled_entries_serialize_without_a_label_key() {
417 let layout = ActionRingLayout::default();
418 let encoded = toml::to_string(&layout).expect("could not serialize ring layout");
419 assert!(!encoded.contains("label"));
420 }
421
422 #[test]
423 fn clearing_a_slot_cannot_leave_an_orphan_icon() {
424 let mut layout = ActionRingLayout::default();
425 layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
426 layout.set_action(ActionRingSlot::Top, None);
427 assert!(!layout.slots.contains_key(&ActionRingSlot::Top));
428 }
429
430 #[test]
431 fn custom_icons_roundtrip_without_changing_slot_actions() {
432 let mut layout = ActionRingLayout::default();
433 layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
434 let encoded = toml::to_string(&layout).expect("could not serialize ring layout");
435 let decoded = toml::from_str::<ActionRingLayout>(&encoded)
436 .expect("could not deserialize ring layout");
437 assert_eq!(decoded, layout);
438 assert_eq!(decoded.slots[&ActionRingSlot::Top].action(), &Action::Cut);
439 assert_eq!(
440 decoded.slots[&ActionRingSlot::Top].custom_icon(),
441 Some(ActionRingIcon::Keyboard)
442 );
443 }
444
445 #[test]
446 fn documented_inline_slots_deserialize() {
447 let layout = toml::from_str::<ActionRingLayout>(
448 r#"
449[slots]
450Top = { action = "Copy", icon = "Keyboard" }
451Bottom = { action = { CustomShortcut = "Cmd+Shift+P" } }
452"#,
453 )
454 .expect("documented ring layout failed");
455 assert_eq!(layout.slots[&ActionRingSlot::Top].action(), &Action::Copy);
456 assert_eq!(
457 layout.slots[&ActionRingSlot::Top].custom_icon(),
458 Some(ActionRingIcon::Keyboard)
459 );
460 assert!(matches!(
461 layout.slots[&ActionRingSlot::Bottom].action(),
462 Action::CustomShortcut(_)
463 ));
464 }
465
466 #[test]
467 fn recursive_action_fails_deserialization() {
468 let error = match toml::from_str::<ActionRingEntry>("action = \"ShowActionsRing\"") {
472 Ok(entry) => panic!("a ring slot must not recursively open the ring, got {entry:?}"),
473 Err(error) => error,
474 };
475 assert!(
476 error
477 .to_string()
478 .contains(&RingActionError::RecursiveTrigger.to_string()),
479 "expected the recursion guard to reject the slot, got: {error}"
480 );
481 }
482
483 #[test]
484 fn app_layout_replaces_the_default_layout() {
485 let mut config = ActionRingConfig::default();
486 let safari = ActionRingLayout {
487 slots: BTreeMap::from([(
488 ActionRingSlot::Top,
489 ActionRingEntry::new(
490 RingAction::new(Action::NewTab).expect("new tab must be a valid ring action"),
491 ),
492 )]),
493 };
494 config
495 .per_app
496 .insert("com.apple.Safari".to_string(), safari.clone());
497
498 assert_eq!(config.effective_layout(Some("com.apple.Safari")), safari);
499 assert_eq!(config.effective_layout(Some("other")), config.default);
500 }
501
502 #[test]
503 fn a_ring_layout_keyed_by_executable_survives_a_versioned_install_path() {
504 let mut config = ActionRingConfig::default();
505 let sharex = ActionRingLayout {
506 slots: BTreeMap::from([(
507 ActionRingSlot::Top,
508 ActionRingEntry::new(
509 RingAction::new(Action::Copy).expect("copy must be a valid ring action"),
510 ),
511 )]),
512 };
513 config
514 .per_app
515 .insert("exe:sharex.exe".to_string(), sharex.clone());
516
517 assert_eq!(
520 config.effective_layout(Some(
521 r"c:\program files\windowsapps\sharex_17.1_x64\sharex.exe"
522 )),
523 sharex
524 );
525 assert_eq!(
526 config.effective_layout(Some("com.getsharex.exe")),
527 config.default,
528 "a bundle id is never reinterpreted as an executable"
529 );
530 }
531}