Skip to main content

lingxia_shell/
sidebar_action.rs

1use crate::{ShellError, ShellResult};
2use serde::{Deserialize, Serialize};
3use std::collections::HashSet;
4
5/// The header strip is shared with the window controls, so it holds a few
6/// actions rather than an open-ended list. Exceeding it rejects the whole
7/// declaration instead of truncating: silently dropping one entry would leave
8/// an app believing it published something the user cannot see.
9///
10/// The header is a borrowed corner of the window's caption row, shared with the
11/// traffic lights or app menu and the collapse toggle. Two icon-only buttons is
12/// what reads as a pair a person can learn; past that it becomes a row of
13/// unlabelled glyphs in the part of the window that belongs to the window.
14///
15/// Exceeding it rejects the whole declaration instead of truncating: silently
16/// dropping one would leave an app believing it published something the user
17/// cannot see. Anything that must always be reachable belongs in the footer,
18/// which is unbounded and scrolls.
19pub const MAX_HEADER_SIDEBAR_ACTIONS: usize = 2;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub enum SidebarActionPlacement {
24    Header,
25    Footer,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ShellSidebarAction {
31    pub id: String,
32    pub placement: SidebarActionPlacement,
33    pub label: String,
34    pub icon: String,
35    #[serde(default)]
36    pub disabled: bool,
37}
38
39impl ShellSidebarAction {
40    pub fn validate(mut self) -> ShellResult<Self> {
41        self.id = required(self.id, ShellError::EmptySidebarActionId)?;
42        self.label = required_field(self.label, "label")?;
43        self.icon = required_field(self.icon, "icon")?;
44        Ok(self)
45    }
46}
47
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct ShellSidebarActionUpdate {
50    pub label: Option<String>,
51    pub icon: Option<String>,
52    pub disabled: Option<bool>,
53}
54
55impl ShellSidebarActionUpdate {
56    fn validate(mut self, id: &str) -> ShellResult<Self> {
57        if self.label.is_none() && self.icon.is_none() && self.disabled.is_none() {
58            return Err(ShellError::EmptySidebarActionUpdate { id: id.to_string() });
59        }
60        self.label = optional(self.label, "label")?;
61        self.icon = optional(self.icon, "icon")?;
62        Ok(self)
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct ResolvedShellSidebarAction {
69    pub generation: u64,
70    pub id: String,
71    pub placement: SidebarActionPlacement,
72    pub label: String,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub icon_path: Option<String>,
75    pub disabled: bool,
76}
77
78#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct SidebarActionCollection {
80    generation: u64,
81    declared: bool,
82    items: Vec<ShellSidebarAction>,
83}
84
85impl SidebarActionCollection {
86    pub fn generation(&self) -> u64 {
87        self.generation
88    }
89
90    pub fn declared(&self) -> bool {
91        self.declared
92    }
93
94    pub fn items(&self) -> &[ShellSidebarAction] {
95        &self.items
96    }
97
98    pub fn replace(&mut self, items: Vec<ShellSidebarAction>) -> ShellResult<()> {
99        let items = validate_generation(items)?;
100        self.items = items;
101        self.declared = true;
102        self.generation = self.generation.wrapping_add(1);
103        Ok(())
104    }
105
106    pub fn update(&mut self, id: &str, patch: ShellSidebarActionUpdate) -> ShellResult<()> {
107        let id = id.trim();
108        if id.is_empty() {
109            return Err(ShellError::EmptySidebarActionId);
110        }
111        let patch = patch.validate(id)?;
112        let Some(item) = self.items.iter_mut().find(|item| item.id == id) else {
113            return Err(ShellError::SidebarActionNotFound { id: id.to_string() });
114        };
115        if let Some(label) = patch.label {
116            item.label = label;
117        }
118        if let Some(icon) = patch.icon {
119            item.icon = icon;
120        }
121        if let Some(disabled) = patch.disabled {
122            item.disabled = disabled;
123        }
124        self.declared = true;
125        self.generation = self.generation.wrapping_add(1);
126        Ok(())
127    }
128
129    pub fn remove(&mut self, id: &str) -> ShellResult<()> {
130        let id = id.trim();
131        if id.is_empty() {
132            return Err(ShellError::EmptySidebarActionId);
133        }
134        let before = self.items.len();
135        self.items.retain(|item| item.id != id);
136        if self.items.len() == before {
137            return Err(ShellError::SidebarActionNotFound { id: id.to_string() });
138        }
139        self.declared = true;
140        self.generation = self.generation.wrapping_add(1);
141        Ok(())
142    }
143
144    pub fn clear(&mut self) {
145        self.items.clear();
146        self.declared = true;
147        self.generation = self.generation.wrapping_add(1);
148    }
149}
150
151fn required(value: String, error: ShellError) -> ShellResult<String> {
152    let value = value.trim();
153    if value.is_empty() {
154        Err(error)
155    } else {
156        Ok(value.to_string())
157    }
158}
159
160fn required_field(value: String, field: &'static str) -> ShellResult<String> {
161    let value = value.trim();
162    if value.is_empty() {
163        Err(ShellError::EmptySidebarActionField { field })
164    } else {
165        Ok(value.to_string())
166    }
167}
168
169fn optional(value: Option<String>, field: &'static str) -> ShellResult<Option<String>> {
170    value.map(|value| required_field(value, field)).transpose()
171}
172
173fn validate_generation(items: Vec<ShellSidebarAction>) -> ShellResult<Vec<ShellSidebarAction>> {
174    let mut ids = HashSet::with_capacity(items.len());
175    let items = items
176        .into_iter()
177        .map(ShellSidebarAction::validate)
178        .map(|result| {
179            let item = result?;
180            if !ids.insert(item.id.clone()) {
181                return Err(ShellError::DuplicateSidebarActionId { id: item.id });
182            }
183            Ok(item)
184        })
185        .collect::<ShellResult<Vec<_>>>()?;
186    if items
187        .iter()
188        .filter(|item| item.placement == SidebarActionPlacement::Header)
189        .count()
190        > MAX_HEADER_SIDEBAR_ACTIONS
191    {
192        return Err(ShellError::SidebarActionHeaderLimit {
193            max: MAX_HEADER_SIDEBAR_ACTIONS,
194        });
195    }
196    Ok(items)
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn action(id: &str) -> ShellSidebarAction {
204        ShellSidebarAction {
205            id: id.to_string(),
206            placement: SidebarActionPlacement::Footer,
207            label: format!("Label {id}"),
208            icon: "icons/action.svg".to_string(),
209            disabled: false,
210        }
211    }
212
213    #[test]
214    fn replace_is_atomic_when_a_later_item_is_invalid() {
215        let mut state = SidebarActionCollection::default();
216        state.replace(vec![action("chat")]).unwrap();
217        let before = state.clone();
218
219        let result = state.replace(vec![action("ok"), action("")]);
220
221        assert_eq!(result, Err(ShellError::EmptySidebarActionId));
222        assert_eq!(state, before);
223    }
224
225    #[test]
226    fn clear_is_an_explicit_empty_declaration() {
227        let mut state = SidebarActionCollection::default();
228        state.clear();
229
230        assert!(state.declared());
231        assert!(state.items().is_empty());
232    }
233
234    #[test]
235    fn label_and_icon_are_required() {
236        let mut missing_label = action("sync");
237        missing_label.label.clear();
238        assert_eq!(
239            missing_label.validate(),
240            Err(ShellError::EmptySidebarActionField { field: "label" })
241        );
242
243        let mut missing_icon = action("sync");
244        missing_icon.icon.clear();
245        assert_eq!(
246            missing_icon.validate(),
247            Err(ShellError::EmptySidebarActionField { field: "icon" })
248        );
249    }
250
251    #[test]
252    fn stable_ids_are_unique() {
253        let mut state = SidebarActionCollection::default();
254        let result = state.replace(vec![action("same"), action("same")]);
255
256        assert_eq!(
257            result,
258            Err(ShellError::DuplicateSidebarActionId {
259                id: "same".to_string()
260            })
261        );
262    }
263
264    #[test]
265    fn the_header_limit_rejects_rather_than_truncates() {
266        let mut state = SidebarActionCollection::default();
267        let over_limit: Vec<_> = (0..=MAX_HEADER_SIDEBAR_ACTIONS)
268            .map(|index| {
269                let mut item = action(&format!("header-{index}"));
270                item.placement = SidebarActionPlacement::Header;
271                item
272            })
273            .collect();
274
275        assert_eq!(
276            state.replace(over_limit),
277            Err(ShellError::SidebarActionHeaderLimit {
278                max: MAX_HEADER_SIDEBAR_ACTIONS
279            })
280        );
281        assert_eq!(state.generation(), 0);
282    }
283
284    /// The limit itself must be usable — a declaration exactly at it commits.
285    #[test]
286    fn the_header_limit_is_inclusive() {
287        let mut state = SidebarActionCollection::default();
288        let at_limit: Vec<_> = (0..MAX_HEADER_SIDEBAR_ACTIONS)
289            .map(|index| {
290                let mut item = action(&format!("header-{index}"));
291                item.placement = SidebarActionPlacement::Header;
292                item
293            })
294            .collect();
295
296        assert!(state.replace(at_limit).is_ok());
297        assert_eq!(state.generation(), 1);
298    }
299}