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}