Skip to main content

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, SEPARATOR_NORMAL};
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 editor_utils;
150mod library_editor;
151mod refresh_rate_by_kind_editor;
152mod setting_row;
153mod setting_value;
154
155pub use setting_value::{SettingsEvent, ToggleSettings};
156
157pub use self::bottom_bar::{BottomBarVariant, SettingsEditorBottomBar};
158pub use self::category::Category;
159pub use self::category_button::CategoryButton;
160pub use self::category_editor::CategoryEditor;
161pub use self::category_navigation_bar::CategoryNavigationBar;
162pub use self::category_provider::SettingsCategoryProvider;
163pub use self::kinds::{InputSettingKind, SettingIdentity, SettingKind};
164pub use self::setting_row::SettingRow;
165pub use self::setting_value::SettingValue;
166
167/// Main settings editor view.
168///
169/// This is the top-level view that displays a navigation bar with category tabs
170/// and an embedded category editor below it. When a category tab is selected,
171/// the editor switches to show that category's settings.
172///
173/// # Structure
174///
175/// - `id`: Unique identifier for this view
176/// - `rect`: Bounding rectangle for the entire settings editor
177/// - `children`: Child views including the top bar, separators, navigation bar, and category editor
178/// - `nav_bar_index`: Index of the StackNavigationBar in the children vector
179/// - `editor_index`: Index of the CategoryEditor in the children vector
180/// - `editors`: Pre-built [`CategoryEditor`] instances for all inactive categories, keyed by
181///   [`Category`]. On tab switch the active editor is returned here and the target is pulled
182///   out, avoiding a full view-tree rebuild on every navigation. The [`Category::Libraries`]
183///   editor is included and stays current because every library mutation calls
184///   `rebuild_library_rows` before returning.
185pub struct SettingsEditor {
186    id: Id,
187    rect: Rectangle,
188    children: Vec<Box<dyn View>>,
189    nav_bar_index: usize,
190    editor_index: usize,
191    editors: FxHashMap<Category, Box<dyn View>>,
192}
193
194impl SettingsEditor {
195    #[cfg_attr(feature = "tracing", tracing::instrument(skip(rq, context)))]
196    pub fn new(rect: Rectangle, rq: &mut RenderQueue, context: &mut Context) -> Self {
197        let id = ID_FEEDER.next();
198        let mut children = Vec::new();
199
200        let (bar_height, separator_thickness, separator_top_half, separator_bottom_half) =
201            Self::calculate_dimensions();
202
203        children.push(Self::build_top_bar(
204            &rect,
205            bar_height,
206            separator_top_half,
207            context,
208        ));
209
210        children.push(Self::build_top_separator(
211            &rect,
212            bar_height,
213            separator_top_half,
214            separator_bottom_half,
215        ));
216
217        let nav_bar_rect = rect![
218            rect.min.x,
219            rect.min.y + bar_height + separator_bottom_half,
220            rect.max.x,
221            rect.min.y + bar_height + separator_bottom_half + bar_height
222        ];
223
224        let provider = SettingsCategoryProvider;
225        let mut navigation_bar =
226            StackNavigationBar::new(nav_bar_rect, rect.max.y, 2, provider, Category::General)
227                .disable_resize();
228
229        navigation_bar.set_selected(Category::General, rq, context);
230        let nav_bar_index = children.len();
231        children.push(Box::new(navigation_bar));
232
233        let nav_bar_max_y = children[nav_bar_index].rect().max.y;
234        let (sep_top_half, sep_bottom_half) = halves(separator_thickness);
235        children.push(Self::build_nav_bar_separator(
236            &rect,
237            nav_bar_max_y,
238            sep_top_half,
239            sep_bottom_half,
240        ));
241
242        let content_rect = rect![
243            rect.min.x,
244            nav_bar_max_y + sep_bottom_half,
245            rect.max.x,
246            rect.max.y
247        ];
248
249        let category_editor = CategoryEditor::new(content_rect, Category::General, rq, context);
250
251        let editor_index = children.len();
252        children.push(Box::new(category_editor));
253
254        let mut editors: FxHashMap<Category, Box<dyn View>> = FxHashMap::default();
255
256        for category in Category::all() {
257            if category == Category::General {
258                continue;
259            }
260
261            let editor = CategoryEditor::new(content_rect, category, rq, context);
262            editors.insert(category, Box::new(editor));
263        }
264
265        rq.add(RenderData::new(id, rect, UpdateMode::Gui));
266
267        SettingsEditor {
268            id,
269            rect,
270            children,
271            nav_bar_index,
272            editor_index,
273            editors,
274        }
275    }
276
277    fn calculate_dimensions() -> (i32, i32, i32, i32) {
278        let dpi = CURRENT_DEVICE.dpi;
279        let small_height = scale_by_dpi(SMALL_BAR_HEIGHT, dpi) as i32;
280        let separator_thickness = scale_by_dpi(THICKNESS_MEDIUM, dpi) as i32;
281        let (separator_top_half, separator_bottom_half) = halves(separator_thickness);
282        let bar_height = small_height;
283
284        (
285            bar_height,
286            separator_thickness,
287            separator_top_half,
288            separator_bottom_half,
289        )
290    }
291
292    fn build_top_bar(
293        rect: &Rectangle,
294        bar_height: i32,
295        separator_top_half: i32,
296        context: &mut Context,
297    ) -> Box<dyn View> {
298        let top_bar = TopBar::new(
299            rect![
300                rect.min.x,
301                rect.min.y,
302                rect.max.x,
303                rect.min.y + bar_height - separator_top_half
304            ],
305            TopBarVariant::Back,
306            "Settings".to_string(),
307            context,
308        );
309        Box::new(top_bar) as Box<dyn View>
310    }
311
312    fn build_top_separator(
313        rect: &Rectangle,
314        bar_height: i32,
315        separator_top_half: i32,
316        separator_bottom_half: i32,
317    ) -> Box<dyn View> {
318        let separator = Filler::new(
319            rect![
320                rect.min.x,
321                rect.min.y + bar_height - separator_top_half,
322                rect.max.x,
323                rect.min.y + bar_height + separator_bottom_half
324            ],
325            BLACK,
326        );
327        Box::new(separator) as Box<dyn View>
328    }
329
330    fn build_nav_bar_separator(
331        rect: &Rectangle,
332        nav_bar_max_y: i32,
333        sep_top_half: i32,
334        sep_bottom_half: i32,
335    ) -> Box<dyn View> {
336        let separator = Filler::new(
337            rect![
338                rect.min.x,
339                nav_bar_max_y - sep_top_half,
340                rect.max.x,
341                nav_bar_max_y + sep_bottom_half
342            ],
343            SEPARATOR_NORMAL,
344        );
345        Box::new(separator) as Box<dyn View>
346    }
347}
348
349impl View for SettingsEditor {
350    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, _hub, _bus, rq, context), fields(event = ?evt), ret(level=tracing::Level::TRACE)))]
351    fn handle_event(
352        &mut self,
353        evt: &Event,
354        _hub: &Hub,
355        _bus: &mut Bus,
356        rq: &mut RenderQueue,
357        context: &mut Context,
358    ) -> bool {
359        match evt {
360            Event::FileChooserClosed(_) => {
361                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
362                true
363            }
364            Event::SelectSettingsCategory(category) => {
365                let nav_bar_max_y = {
366                    let nav_bar = self.children[self.nav_bar_index]
367                        .downcast_mut::<StackNavigationBar<SettingsCategoryProvider>>()
368                        .unwrap();
369                    nav_bar.set_selected(*category, rq, context);
370                    nav_bar.rect.max.y
371                };
372
373                let (_, separator_thickness, _, _) = Self::calculate_dimensions();
374                let (sep_top_half, sep_bottom_half) = halves(separator_thickness);
375                let sep_index = self.nav_bar_index + 1;
376                *self.children[sep_index].rect_mut() = rect![
377                    self.rect.min.x,
378                    nav_bar_max_y - sep_top_half,
379                    self.rect.max.x,
380                    nav_bar_max_y + sep_bottom_half
381                ];
382
383                let current_category = self.children[self.editor_index]
384                    .downcast_ref::<CategoryEditor>()
385                    .map(|e| e.category());
386
387                let outgoing = self.children.remove(self.editor_index);
388
389                if let Some(cat) = current_category {
390                    self.editors.insert(cat, outgoing);
391                }
392
393                let content_rect = rect![
394                    self.rect.min.x,
395                    nav_bar_max_y + sep_bottom_half,
396                    self.rect.max.x,
397                    self.rect.max.y
398                ];
399
400                let incoming = self.editors.remove(category).unwrap_or_else(|| {
401                    Box::new(CategoryEditor::new(content_rect, *category, rq, context))
402                        as Box<dyn View>
403                });
404
405                self.children.insert(self.editor_index, incoming);
406
407                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
408                true
409            }
410            Event::NavigationBarResized(_) => {
411                unimplemented!("The settings navigation bar should not be resizable which means this event is not expected to be send.")
412            }
413            Event::ToggleNear(ViewId::MainMenu, rect) => {
414                toggle_main_menu(self, *rect, None, rq, context);
415                true
416            }
417            Event::Close(ViewId::MainMenu) => {
418                toggle_main_menu(self, Rectangle::default(), Some(false), rq, context);
419                true
420            }
421            Event::Close(view_id) => match view_id {
422                ViewId::MainMenu => {
423                    toggle_main_menu(self, Rectangle::default(), Some(false), rq, context);
424                    true
425                }
426                ViewId::FileChooser => {
427                    rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
428                    true
429                }
430                _ => false,
431            },
432            _ => false,
433        }
434    }
435
436    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, _fb, _fonts), fields(rect = ?_rect)))]
437    fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut crate::font::Fonts) {
438    }
439
440    fn rect(&self) -> &Rectangle {
441        &self.rect
442    }
443
444    fn rect_mut(&mut self) -> &mut Rectangle {
445        &mut self.rect
446    }
447
448    fn children(&self) -> &Vec<Box<dyn View>> {
449        &self.children
450    }
451
452    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
453        &mut self.children
454    }
455
456    fn id(&self) -> Id {
457        self.id
458    }
459
460    fn is_background(&self) -> bool {
461        true
462    }
463}