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}