cadmus_core/view/settings_editor/
mod.rs

1//! Settings editor module for managing application configuration.
2//!
3//! This module provides a hierarchical settings interface with the following structure:
4//!
5//! ```text
6//! SettingsEditor (Main view)
7//!   ├── TopBar (Back button, "Settings" title)
8//!   ├── StackNavigationBar (Category tabs: General | Libraries | Intermissions)
9//!   └── CategoryEditor (Embedded, shows settings for selected category)
10//!       ├── SettingRow (One for each setting in the category)
11//!       │   ├── Label (Setting name)
12//!       │   └── SettingValue (Current value, can be tapped to edit)
13//!       └── BottomBar (Add Library button for Libraries category)
14//! ```
15//!
16//! ## Components
17//!
18//! - **SettingsEditor**: Top-level view with navigation bar and category editor
19//! - **CategoryNavigationBar**: Horizontal bar with category tabs
20//! - **CategoryEditor**: Embedded editor for a specific category's settings
21//! - **SettingRow**: Individual setting with label and value
22//! - **SettingValue**: Interactive value display that opens editors/menus
23//! - **LibraryEditor**: Specialized editor for library settings
24//!
25//! ## Event Flow
26//!
27//! When a setting is modified, the CategoryEditor directly updates `context.settings`,
28//! providing immediate feedback. Settings are persisted to disk when the settings editor
29//! is closed.
30//!
31//! ## Adding a new setting
32//!
33//! Each setting is a small self-contained struct that implements [`SettingKind`].
34//! The trait carries everything the UI needs.
35//!
36//! ### 1. Add a variant to `SettingIdentity`
37//!
38//! Open `kinds/identity.rs` and add a variant for the new setting:
39//!
40//! ```rust,no_run
41//! pub enum SettingIdentity {
42//!     // ... existing variants
43//!     MyNewSetting,
44//! }
45//! ```
46//!
47//! ### 2. Implement `SettingKind`
48//!
49//! Add a struct in the appropriate `kinds/*.rs` file and implement the trait:
50//!
51//! ```rust,ignore
52//! // This example uses hypothetical types (MyNewSetting, EntryId::EditMyNewSetting)
53//! // that do not exist in the codebase — it illustrates the pattern to follow.
54//! pub struct MyNewSetting;
55//!
56//! impl SettingKind for MyNewSetting {
57//!     fn identity(&self) -> SettingIdentity {
58//!         SettingIdentity::MyNewSetting
59//!     }
60//!
61//!     fn label(&self, _settings: &Settings) -> String {
62//!         "My New Setting".to_string()
63//!     }
64//!
65//!     fn fetch(&self, settings: &Settings) -> SettingData {
66//!         SettingData {
67//!             value: settings.my_new_setting.to_string(),
68//!             widget: WidgetKind::ActionLabel(Event::Select(EntryId::EditMyNewSetting)),
69//!         }
70//!     }
71//! }
72//! ```
73//!
74//! For a **toggle** widget, use `WidgetKind::Toggle { ..., tap_event }` where
75//! `tap_event` is `Event::Toggle(ToggleEvent::Setting(ToggleSettings::MyNewSetting))`
76//! (adding the corresponding `ToggleSettings` variant in `kinds/mod.rs`).
77//!
78//! For a **sub-menu** (radio buttons), use `WidgetKind::SubMenu(entries)` where
79//! `entries` is a `Vec<`[`EntryKind`](crate::view::EntryKind)`>` — the sub-menu
80//! event is built automatically from the entries when the row is tapped.
81//!
82//! ### 3. Register the setting in `Category::settings()`
83//!
84//! Open `category.rs` and add the new kind to the relevant category arm:
85//!
86//! ```rust,ignore
87//! // This example shows a match arm inside Category::settings() — it is a
88//! // partial snippet and cannot compile standalone.
89//! Category::General => vec![
90//!     // ... existing kinds
91//!     Box::new(MyNewSetting),
92//! ],
93//! ```
94//!
95//! ### 4. Handle mutations (usually automatic)
96//!
97//! Most settings do **not** require any changes to `CategoryEditor`. The framework
98//! handles mutations automatically:
99//!
100//! - **Sub-menu / toggle / file-chooser settings**: [`SettingKind::handle`] is called
101//!   when the user makes a selection. Implement `handle` on your struct to mutate
102//!   `context.settings` and return the updated display string.
103//! - **Text-input settings**: Implement [`InputSettingKind`] and its `apply_text`
104//!   method. The overlay and submission flow are handled by [`SettingValue`].
105//!
106//! A `CategoryEditor` handler is only needed for settings with **custom event flows**
107//! not covered by the traits above — for example, library management actions that
108//! must coordinate multiple views or emit side-effect events. In that case, add a
109//! handler method in `category_editor.rs` and dispatch it from `handle_event`. After
110//! mutating `context.settings`, send `SettingsEvent::UpdateValue` so the corresponding
111//! [`SettingValue`] view refreshes its displayed text:
112//!
113//! ```rust,ignore
114//! // This example shows a method inside a CategoryEditor impl block — it is a
115//! // partial snippet and cannot compile standalone.
116//! fn handle_my_new_setting(&mut self, value: f32, hub: &Hub, context: &mut Context) -> bool {
117//!     context.settings.my_new_setting = value;
118//!     hub.send(Event::Settings(SettingsEvent::UpdateValue {
119//!         kind: SettingIdentity::MyNewSetting,
120//!         value: value.to_string(),
121//!     }))
122//!     .ok();
123//!     true
124//! }
125//! ```
126
127use crate::color::BLACK;
128use crate::context::Context;
129use crate::device::CURRENT_DEVICE;
130use crate::framebuffer::{Framebuffer, UpdateMode};
131use crate::geom::{halves, Rectangle};
132use crate::unit::scale_by_dpi;
133use crate::view::common::toggle_main_menu;
134use crate::view::filler::Filler;
135use crate::view::navigation::stack_navigation_bar::StackNavigationBar;
136use crate::view::top_bar::{TopBar, TopBarVariant};
137use crate::view::{Bus, Event, Hub, Id, RenderData, RenderQueue, View, ViewId, ID_FEEDER};
138use crate::view::{SMALL_BAR_HEIGHT, THICKNESS_MEDIUM};
139use fxhash::FxHashMap;
140
141pub mod kinds;
142
143mod bottom_bar;
144mod category;
145mod category_button;
146mod category_editor;
147mod category_navigation_bar;
148mod category_provider;
149mod library_editor;
150mod setting_row;
151mod setting_value;
152
153pub use setting_value::{SettingsEvent, ToggleSettings};
154
155pub use self::bottom_bar::{BottomBarVariant, SettingsEditorBottomBar};
156pub use self::category::Category;
157pub use self::category_button::CategoryButton;
158pub use self::category_editor::CategoryEditor;
159pub use self::category_navigation_bar::CategoryNavigationBar;
160pub use self::category_provider::SettingsCategoryProvider;
161pub use self::kinds::{InputSettingKind, SettingIdentity, SettingKind};
162pub use self::setting_row::SettingRow;
163pub use self::setting_value::SettingValue;
164
165/// Main settings editor view.
166///
167/// This is the top-level view that displays a navigation bar with category tabs
168/// and an embedded category editor below it. When a category tab is selected,
169/// the editor switches to show that category's settings.
170///
171/// # Structure
172///
173/// - `id`: Unique identifier for this view
174/// - `rect`: Bounding rectangle for the entire settings editor
175/// - `children`: Child views including the top bar, separators, navigation bar, and category editor
176/// - `nav_bar_index`: Index of the StackNavigationBar in the children vector
177/// - `editor_index`: Index of the CategoryEditor in the children vector
178/// - `editors`: Pre-built [`CategoryEditor`] instances for all inactive categories, keyed by
179///   [`Category`]. On tab switch the active editor is returned here and the target is pulled
180///   out, avoiding a full view-tree rebuild on every navigation. The [`Category::Libraries`]
181///   editor is included and stays current because every library mutation calls
182///   `rebuild_library_rows` before returning.
183pub struct SettingsEditor {
184    id: Id,
185    rect: Rectangle,
186    children: Vec<Box<dyn View>>,
187    nav_bar_index: usize,
188    editor_index: usize,
189    editors: FxHashMap<Category, Box<dyn View>>,
190}
191
192impl SettingsEditor {
193    pub fn new(rect: Rectangle, rq: &mut RenderQueue, context: &mut Context) -> Self {
194        let id = ID_FEEDER.next();
195        let mut children = Vec::new();
196
197        let (bar_height, _separator_thickness, separator_top_half, separator_bottom_half) =
198            Self::calculate_dimensions();
199
200        children.push(Self::build_top_bar(
201            &rect,
202            bar_height,
203            separator_top_half,
204            context,
205        ));
206
207        children.push(Self::build_top_separator(
208            &rect,
209            bar_height,
210            separator_top_half,
211            separator_bottom_half,
212        ));
213
214        let nav_bar_rect = rect![
215            rect.min.x,
216            rect.min.y + bar_height + separator_bottom_half,
217            rect.max.x,
218            rect.min.y + bar_height + separator_bottom_half + bar_height
219        ];
220
221        let provider = SettingsCategoryProvider;
222        let mut navigation_bar =
223            StackNavigationBar::new(nav_bar_rect, rect.max.y, 1, provider, Category::General)
224                .disable_resize();
225
226        navigation_bar.set_selected(Category::General, rq, context);
227        let nav_bar_index = children.len();
228        children.push(Box::new(navigation_bar));
229
230        let content_rect = rect![
231            rect.min.x,
232            children[nav_bar_index].rect().max.y,
233            rect.max.x,
234            rect.max.y
235        ];
236
237        let category_editor = CategoryEditor::new(content_rect, Category::General, rq, context);
238
239        let editor_index = children.len();
240        children.push(Box::new(category_editor));
241
242        let mut editors: FxHashMap<Category, Box<dyn View>> = FxHashMap::default();
243
244        for category in Category::all() {
245            if category == Category::General {
246                continue;
247            }
248
249            let editor = CategoryEditor::new(content_rect, category, rq, context);
250            editors.insert(category, Box::new(editor));
251        }
252
253        rq.add(RenderData::new(id, rect, UpdateMode::Gui));
254
255        SettingsEditor {
256            id,
257            rect,
258            children,
259            nav_bar_index,
260            editor_index,
261            editors,
262        }
263    }
264
265    fn calculate_dimensions() -> (i32, i32, i32, i32) {
266        let dpi = CURRENT_DEVICE.dpi;
267        let small_height = scale_by_dpi(SMALL_BAR_HEIGHT, dpi) as i32;
268        let separator_thickness = scale_by_dpi(THICKNESS_MEDIUM, dpi) as i32;
269        let (separator_top_half, separator_bottom_half) = halves(separator_thickness);
270        let bar_height = small_height;
271
272        (
273            bar_height,
274            separator_thickness,
275            separator_top_half,
276            separator_bottom_half,
277        )
278    }
279
280    fn build_top_bar(
281        rect: &Rectangle,
282        bar_height: i32,
283        separator_top_half: i32,
284        context: &mut Context,
285    ) -> Box<dyn View> {
286        let top_bar = TopBar::new(
287            rect![
288                rect.min.x,
289                rect.min.y,
290                rect.max.x,
291                rect.min.y + bar_height - separator_top_half
292            ],
293            TopBarVariant::Back,
294            "Settings".to_string(),
295            context,
296        );
297        Box::new(top_bar) as Box<dyn View>
298    }
299
300    fn build_top_separator(
301        rect: &Rectangle,
302        bar_height: i32,
303        separator_top_half: i32,
304        separator_bottom_half: i32,
305    ) -> Box<dyn View> {
306        let separator = Filler::new(
307            rect![
308                rect.min.x,
309                rect.min.y + bar_height - separator_top_half,
310                rect.max.x,
311                rect.min.y + bar_height + separator_bottom_half
312            ],
313            BLACK,
314        );
315        Box::new(separator) as Box<dyn View>
316    }
317}
318
319impl View for SettingsEditor {
320    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _hub, _bus, rq, context), fields(event = ?evt), ret(level=tracing::Level::TRACE)))]
321    fn handle_event(
322        &mut self,
323        evt: &Event,
324        _hub: &Hub,
325        _bus: &mut Bus,
326        rq: &mut RenderQueue,
327        context: &mut Context,
328    ) -> bool {
329        match evt {
330            Event::FileChooserClosed(_) => {
331                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
332                true
333            }
334            Event::SelectSettingsCategory(category) => {
335                let nav_bar_max_y = {
336                    let nav_bar = self.children[self.nav_bar_index]
337                        .downcast_mut::<StackNavigationBar<SettingsCategoryProvider>>()
338                        .unwrap();
339                    nav_bar.set_selected(*category, rq, context);
340                    nav_bar.rect.max.y
341                };
342
343                let current_category = self.children[self.editor_index]
344                    .downcast_ref::<CategoryEditor>()
345                    .map(|e| e.category());
346
347                let outgoing = self.children.remove(self.editor_index);
348
349                if let Some(cat) = current_category {
350                    self.editors.insert(cat, outgoing);
351                }
352
353                let content_rect = rect![
354                    self.rect.min.x,
355                    nav_bar_max_y,
356                    self.rect.max.x,
357                    self.rect.max.y
358                ];
359
360                let incoming = self.editors.remove(category).unwrap_or_else(|| {
361                    Box::new(CategoryEditor::new(content_rect, *category, rq, context))
362                        as Box<dyn View>
363                });
364
365                self.children.insert(self.editor_index, incoming);
366
367                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
368                true
369            }
370            Event::NavigationBarResized(_) => {
371                unimplemented!("The settings navigation bar should not be resizable which means this event is not expected to be send.")
372            }
373            Event::ToggleNear(ViewId::MainMenu, rect) => {
374                toggle_main_menu(self, *rect, None, rq, context);
375                true
376            }
377            Event::Close(ViewId::MainMenu) => {
378                toggle_main_menu(self, Rectangle::default(), Some(false), rq, context);
379                true
380            }
381            Event::Close(view_id) => match view_id {
382                ViewId::MainMenu => {
383                    toggle_main_menu(self, Rectangle::default(), Some(false), rq, context);
384                    true
385                }
386                ViewId::FileChooser => {
387                    rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
388                    true
389                }
390                _ => false,
391            },
392            _ => false,
393        }
394    }
395
396    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _fb, _fonts), fields(rect = ?_rect)))]
397    fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut crate::font::Fonts) {
398    }
399
400    fn rect(&self) -> &Rectangle {
401        &self.rect
402    }
403
404    fn rect_mut(&mut self) -> &mut Rectangle {
405        &mut self.rect
406    }
407
408    fn children(&self) -> &Vec<Box<dyn View>> {
409        &self.children
410    }
411
412    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
413        &mut self.children
414    }
415
416    fn id(&self) -> Id {
417        self.id
418    }
419
420    fn is_background(&self) -> bool {
421        true
422    }
423}