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)]
145pub struct ActionRingEntry {
146 action: RingAction,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 icon: Option<ActionRingIcon>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 label: Option<String>,
151}
152
153impl ActionRingEntry {
154 #[must_use]
156 pub const fn new(action: RingAction) -> Self {
157 Self {
158 action,
159 icon: None,
160 label: None,
161 }
162 }
163
164 #[must_use]
166 pub fn action(&self) -> &Action {
167 self.action.action()
168 }
169
170 #[must_use]
172 pub const fn custom_icon(&self) -> Option<ActionRingIcon> {
173 self.icon
174 }
175
176 #[must_use]
180 pub fn custom_label(&self) -> Option<&str> {
181 self.label.as_deref()
182 }
183
184 #[must_use]
187 pub fn into_parts(self) -> (Action, Option<ActionRingIcon>, Option<String>) {
188 (self.action.into_action(), self.icon, self.label)
189 }
190
191 fn replace_action(&mut self, action: RingAction) {
192 self.action = action;
193 }
194
195 fn set_icon(&mut self, icon: Option<ActionRingIcon>) {
196 self.icon = icon;
197 }
198
199 fn set_label(&mut self, label: Option<String>) {
200 self.label = label;
201 }
202}
203
204#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
206pub struct ActionRingLayout {
207 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
209 pub slots: BTreeMap<ActionRingSlot, ActionRingEntry>,
210}
211
212impl ActionRingLayout {
213 pub fn set_action(&mut self, slot: ActionRingSlot, action: Option<RingAction>) {
215 match (self.slots.entry(slot), action) {
216 (Entry::Occupied(mut entry), Some(action)) => entry.get_mut().replace_action(action),
217 (Entry::Vacant(entry), Some(action)) => {
218 entry.insert(ActionRingEntry::new(action));
219 }
220 (Entry::Occupied(entry), None) => {
221 entry.remove();
222 }
223 (Entry::Vacant(_), None) => {}
224 }
225 }
226
227 pub fn set_icon(&mut self, slot: ActionRingSlot, icon: Option<ActionRingIcon>) {
229 if let Some(entry) = self.slots.get_mut(&slot) {
230 entry.set_icon(icon);
231 }
232 }
233
234 pub fn set_label(&mut self, slot: ActionRingSlot, label: Option<String>) {
236 if let Some(entry) = self.slots.get_mut(&slot) {
237 entry.set_label(label);
238 }
239 }
240}
241
242impl Default for ActionRingLayout {
243 fn default() -> Self {
244 use ActionRingSlot as Slot;
245
246 let actions = [
247 (Slot::Top, Action::Cut),
248 (Slot::TopRight, Action::Copy),
249 (Slot::Right, Action::Paste),
250 (Slot::BottomRight, Action::BrowserForward),
251 (Slot::Bottom, Action::PlayPause),
252 (Slot::BottomLeft, Action::BrowserBack),
253 (Slot::Left, Action::Undo),
254 (Slot::TopLeft, Action::Redo),
255 ];
256 let slots = actions
257 .into_iter()
258 .map(|(slot, action)| (slot, ActionRingEntry::new(RingAction(action))))
259 .collect();
260 Self { slots }
261 }
262}
263
264#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
266pub struct ActionRingConfig {
267 #[serde(default = "default_true")]
269 pub enabled: bool,
270 #[serde(default = "default_true")]
272 pub haptics: bool,
273 #[serde(default)]
275 pub default: ActionRingLayout,
276 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
278 pub per_app: BTreeMap<String, ActionRingLayout>,
279}
280
281impl Default for ActionRingConfig {
282 fn default() -> Self {
283 Self {
284 enabled: true,
285 haptics: true,
286 default: ActionRingLayout::default(),
287 per_app: BTreeMap::new(),
288 }
289 }
290}
291
292impl ActionRingConfig {
293 #[must_use]
296 pub fn is_default(&self) -> bool {
297 self == &Self::default()
298 }
299
300 #[must_use]
302 pub fn effective_layout(&self, app_id: Option<&str>) -> ActionRingLayout {
303 app_id
304 .and_then(|app| self.per_app.get(app))
305 .cloned()
306 .unwrap_or_else(|| self.default.clone())
307 }
308}
309
310const fn default_true() -> bool {
311 true
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn default_layout_populates_every_position() {
320 let layout = ActionRingLayout::default();
321 assert_eq!(layout.slots.len(), ActionRingSlot::ALL.len());
322 assert!(
323 ActionRingSlot::ALL
324 .iter()
325 .all(|slot| layout.slots.contains_key(slot))
326 );
327 }
328
329 #[test]
330 fn invalid_ring_actions_are_rejected() {
331 assert_eq!(
332 RingAction::new(Action::None),
333 Err(RingActionError::EmptyAction)
334 );
335 assert_eq!(
336 RingAction::new(Action::ShowActionsRing),
337 Err(RingActionError::RecursiveTrigger)
338 );
339 }
340
341 #[test]
342 fn ring_action_serializes_like_the_wrapped_action() {
343 #[derive(Serialize)]
344 struct Wrapper {
345 action: RingAction,
346 }
347
348 let action = RingAction::new(Action::Copy).unwrap_or_else(|error| panic!("{error}"));
349 let encoded = toml::to_string(&Wrapper { action })
350 .unwrap_or_else(|error| panic!("could not serialize ring action: {error}"));
351 assert_eq!(encoded, "action = \"Copy\"\n");
352 }
353
354 #[test]
355 fn custom_labels_roundtrip_and_survive_action_replacement() {
356 let mut layout: ActionRingLayout = toml::from_str(
357 r#"
358 [slots]
359 Top = { action = "Copy", label = "Copy Invoice" }
360 "#,
361 )
362 .unwrap_or_else(|error| panic!("could not deserialize labelled layout: {error}"));
363 assert_eq!(
364 layout.slots[&ActionRingSlot::Top].custom_label(),
365 Some("Copy Invoice")
366 );
367
368 let encoded = toml::to_string(&layout)
369 .unwrap_or_else(|error| panic!("could not serialize labelled layout: {error}"));
370 let decoded = toml::from_str::<ActionRingLayout>(&encoded)
371 .unwrap_or_else(|error| panic!("could not deserialize labelled layout: {error}"));
372 assert_eq!(decoded, layout);
373
374 layout.set_action(
376 ActionRingSlot::Top,
377 Some(RingAction::new(Action::Paste).unwrap_or_else(|error| panic!("{error}"))),
378 );
379 assert_eq!(
380 layout.slots[&ActionRingSlot::Top].custom_label(),
381 Some("Copy Invoice")
382 );
383 }
384
385 #[test]
386 fn unlabelled_entries_serialize_without_a_label_key() {
387 let layout = ActionRingLayout::default();
388 let encoded = toml::to_string(&layout)
389 .unwrap_or_else(|error| panic!("could not serialize ring layout: {error}"));
390 assert!(!encoded.contains("label"));
391 }
392
393 #[test]
394 fn clearing_a_slot_cannot_leave_an_orphan_icon() {
395 let mut layout = ActionRingLayout::default();
396 layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
397 layout.set_action(ActionRingSlot::Top, None);
398 assert!(!layout.slots.contains_key(&ActionRingSlot::Top));
399 }
400
401 #[test]
402 fn custom_icons_roundtrip_without_changing_slot_actions() {
403 let mut layout = ActionRingLayout::default();
404 layout.set_icon(ActionRingSlot::Top, Some(ActionRingIcon::Keyboard));
405 let encoded = toml::to_string(&layout)
406 .unwrap_or_else(|error| panic!("could not serialize ring layout: {error}"));
407 let decoded = toml::from_str::<ActionRingLayout>(&encoded)
408 .unwrap_or_else(|error| panic!("could not deserialize ring layout: {error}"));
409 assert_eq!(decoded, layout);
410 assert_eq!(decoded.slots[&ActionRingSlot::Top].action(), &Action::Cut);
411 assert_eq!(
412 decoded.slots[&ActionRingSlot::Top].custom_icon(),
413 Some(ActionRingIcon::Keyboard)
414 );
415 }
416
417 #[test]
418 fn documented_inline_slots_deserialize() {
419 let layout = toml::from_str::<ActionRingLayout>(
420 r#"
421[slots]
422Top = { action = "Copy", icon = "Keyboard" }
423Bottom = { action = { CustomShortcut = "Cmd+Shift+P" } }
424"#,
425 )
426 .unwrap_or_else(|error| panic!("documented ring layout failed: {error}"));
427 assert_eq!(layout.slots[&ActionRingSlot::Top].action(), &Action::Copy);
428 assert_eq!(
429 layout.slots[&ActionRingSlot::Top].custom_icon(),
430 Some(ActionRingIcon::Keyboard)
431 );
432 assert!(matches!(
433 layout.slots[&ActionRingSlot::Bottom].action(),
434 Action::CustomShortcut(_)
435 ));
436 }
437
438 #[test]
439 fn recursive_action_fails_deserialization() {
440 let parsed = toml::from_str::<RingAction>("\"ShowActionsRing\"");
441 assert!(parsed.is_err());
442 }
443
444 #[test]
445 fn app_layout_replaces_the_default_layout() {
446 let mut config = ActionRingConfig::default();
447 let safari = ActionRingLayout {
448 slots: BTreeMap::from([(
449 ActionRingSlot::Top,
450 ActionRingEntry::new(
451 RingAction::new(Action::NewTab).unwrap_or_else(|error| panic!("{error}")),
452 ),
453 )]),
454 };
455 config
456 .per_app
457 .insert("com.apple.Safari".to_string(), safari.clone());
458
459 assert_eq!(config.effective_layout(Some("com.apple.Safari")), safari);
460 assert_eq!(config.effective_layout(Some("other")), config.default);
461 }
462}