Skip to main content

max / audiofiles

Flip Settings to the described screen Settings serves from `quasi::settings` and `ui/settings_panel.rs` is gone. One call, no feature gate on it: the described window is the settings window now, and it is titled "Settings" rather than "Settings (described)". The window is five sections shorter than the one it replaced, and that is the finding rather than an oversight. The flip's premise was that the described half had sat beside the shipped one since the first port and was ready. It covered four of the panel's nine sections: Appearance, Preview, Forge, Display, and half of Advanced. Storage (430 lines of the deleted file), Trash, License, the classifier and Import Theme were never described and are missing from the app until they are. `da48cb6d` is why that is allowed to stand rather than block. Max ruled the same situation in goingson on 2026-08-22: the sections go away at the flip and come back as each is described, because no build ships in the interval and alpha covers everything except a tester's data, secrets, money and the freeze. Five tasks are filed, one per section, each blocking `cefe4159`, and each carries what the section drew so nothing has to be re-read off a deleted file. Two of the five refusals `quasi/settings.rs` recorded have weakened since they were written, and the tasks say so rather than repeating them. Storage was ruled out as the filesystem, and `Outcome::Locate` has landed since. Trash was ruled out as "filesystem sizes and a destructive sweep": the rows are tombstones out of the app's own table, the size is a column, and the sweep runs at startup from somewhere else entirely. `ui/classifier.rs` is the one hole with a different shape. It is 1,432 lines, `draw_settings_panel` was its only caller, and it is left compiling with nothing reaching it. Its task asks the question first: it was a settings section only because of where it was called from, and it is really six screens. The parity test follows the sync flip. It compared the description against four hand-drawn bodies of the shipped file; now it reads `/settings` against `draw_settings`, the way every flipped screen is read. `Parity::gaining` went with it, having had one caller.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 19:20 UTC
Signed with PGP, not checked
Commit: 4f9412df2d2a342ff86ea71d15fe8166ebca8c97
Parent: b0674fa
7 files changed, +43 insertions, -581 deletions
@@ -137,13 +137,11 @@
137 137 crate::quasi::panel::draw_preflight(ctx, state);
138 138 }
139 139
140 - // Settings window
140 + // Settings window. Served from the description now, and `ui/settings_panel.rs`
141 + // is gone with the second call: five of its nine sections are not described
142 + // yet and are missing until they are, which is `da48cb6d`'s ruling applied
143 + // here rather than a coexistence this port would have had to invent.
141 144 if state.settings.show_manager {
142 - crate::ui::settings_panel::draw_settings_panel(ctx, state);
143 - // The described one beside it, on the same toggle, so the two can be
144 - // compared by looking at them. Behind the feature, so a shipped build
145 - // has neither this call nor the module it reaches.
146 - #[cfg(feature = "quasi")]
147 145 crate::quasi::panel::draw_settings(ctx, state);
148 146 }
149 147
@@ -89,14 +89,7 @@
89 89 themes: themes(),
90 90 intents: &intents,
91 91 };
92 - let closed = window(
93 - ctx,
94 - "Settings (described)",
95 - &mut runtime,
96 - &host,
97 - "/settings",
98 - stale,
99 - );
92 + let closed = window(ctx, "Settings", &mut runtime, &host, "/settings", stale);
100 93 state.described.settings = runtime;
101 94 apply(ctx, state, None, intents.into_inner());
102 95 if closed {
@@ -606,7 +606,6 @@
606 606 #[derive(Debug, Clone, Default)]
607 607 pub(super) struct Parity {
608 608 dropped: Vec<String>,
609 - gained: Vec<String>,
610 609 }
611 610
612 611 impl Parity {
@@ -626,16 +625,6 @@
626 625 self
627 626 }
628 627
629 - /// A control the described screen has and the shipped one did not.
630 - ///
631 - /// For what a port fixed on the way through: a dead-end the shipped panel
632 - /// left the user in, an act that was only reachable by a keyboard shortcut.
633 - #[must_use]
634 - pub(super) fn gaining(mut self, label: &str) -> Self {
635 - self.gained.push(label.to_owned());
636 - self
637 - }
638 -
639 628 /// The `egui::Window` a described screen is drawn in, which is chrome.
640 629 ///
641 630 /// Three controls that belong to the frame rather than to the screen: the
@@ -680,9 +669,6 @@
680 669 *want.entry(offer).or_default() += 1;
681 670 }
682 671 for offer in described.sorted() {
683 - if self.gained.contains(&offer.label) {
684 - continue;
685 - }
686 672 *want.entry(offer).or_default() -= 1;
687 673 }
688 674
@@ -811,60 +797,29 @@
811 797 }
812 798
813 799 #[test]
814 - fn settings_offers_what_the_four_described_sections_offer() {
800 + fn the_flipped_settings_screen_serves_what_it_describes() {
801 + // Self-parity now that the window is the description's: what `/settings`
802 + // says and what `draw_settings` draws are read against each other, the way
803 + // every flipped screen is read.
804 + //
805 + // The comparison this replaces was against four hand-drawn bodies of
806 + // `settings_panel.rs`, because the description covered four of the shipped
807 + // panel's nine sections. The file is deleted, so there is nothing left to
808 + // compare against and nothing left holding the other five: Storage, Trash,
809 + // License, the classifier and Import Theme are missing from the app until
810 + // each is described. `da48cb6d` is the ruling that allows the interval.
815 811 let (mut state, _dir) = fixture();
812 + state.settings.show_manager = true;
816 813
817 814 let described = described(&super::panel::described_screen(&state, "/settings"));
818 - // The four sections the description covers, drawn as bodies rather than
819 - // through `draw_settings_panel`. Two reasons, and both would make a
820 - // whole-window comparison meaningless rather than merely noisy.
821 - //
822 - // The description covers four of the panel's nine sections on purpose --
823 - // storage, trash, license and the classifier are about this host's
824 - // filesystem and a licence server, and `settings.rs`'s header is where that
825 - // is argued. Comparing against the whole window would need five sections'
826 - // worth of allowances saying so a second time.
827 - //
828 - // And the panel's sections are collapsing, so a whole-window read sees nine
829 - // headings and the contents of whichever one is open. What is on screen
830 - // would then depend on fold state that neither side describes, and which
831 - // cannot be set from outside: a `CollapsingHeader` derives its id from the
832 - // `ui.vertical` it makes for itself. That is why each body is its own
833 - // function now.
834 - let shipped = shipped(|ui| {
835 - crate::ui::settings_panel::appearance_body(ui, &mut state);
836 - crate::ui::settings_panel::preview_body(ui, &mut state);
837 - crate::ui::settings_panel::forge_body(ui, &mut state);
838 - crate::ui::settings_panel::display_body(ui, &mut state);
815 + let drawn = shipped(|ui| {
816 + super::panel::draw_settings(ui.ctx(), &mut state);
839 817 });
840 818
841 - // What the shipped theme picker is announced as: its current value. The
842 - // combo carries no label of its own, so this reads it the way
843 - // `appearance_body` builds it rather than naming a theme here, which would
844 - // make the test depend on which theme a machine resolved.
845 - let themes = crate::ui::theme::list_themes();
846 - let active = crate::ui::theme::active_id();
847 - let active_name = themes
848 - .iter()
849 - .find(|theme| theme.id == active)
850 - .map_or(active.as_str(), |theme| theme.name.as_str());
851 - let announced = match &state.theme_selection {
852 - crate::ui::theme::ThemeSelection::Follow => format!("System ({active_name})"),
853 - crate::ui::theme::ThemeSelection::Fixed(_) => active_name.to_owned(),
854 - };
855 -
856 819 described.addresses_resolve();
857 820 Parity::strict()
858 - // Two controls the shipped panel leaves unnamed, and the port names.
859 - // The theme combo is announced as whatever theme is picked, because the
860 - // word "Theme" is a separate label beside it; the row-height slider
861 - // draws no text at all, so a screen reader announces an unnamed slider.
862 - // Both are the description attaching a question to its control, which
863 - // is the same fix the detail panel's tag box gets.
864 - .dropping(&announced)
865 - .gaining("Theme")
866 - .gaining("Row height")
867 - .assert(&described, &shipped);
821 + .in_a_window("Settings")
822 + .assert(&described, &drawn);
868 823 }
869 824
870 825 #[test]
@@ -1,12 +1,29 @@
1 1 //! The settings panel, described rather than built.
2 2 //!
3 - //! The first audiofiles screen to go through `quasi`, behind an off-by-default
4 - //! feature so the shipped panel in `ui::settings_panel` stays exactly as it is
5 - //! while this one is proved. Same arrangement goingson's port programme uses.
3 + //! The first audiofiles screen to go through `quasi`, and as of the flip the
4 + //! only settings screen there is: `ui/settings_panel.rs` is deleted and this is
5 + //! what opening Settings opens.
6 + //!
7 + //! # What the flip left out, and where it went
8 + //!
9 + //! **The window is five sections shorter than the one it replaced.** Storage,
10 + //! Trash, License, the classifier and Import Theme were drawn by the deleted
11 + //! file, are not described here, and are therefore missing from the app until
12 + //! each is. That is `da48cb6d` applied, ruled by Max on 2026-08-22 for the same
13 + //! situation in goingson: the sections go away at the flip and come back as
14 + //! each is described, and the interval is survivable because no build ships in
15 + //! it. Each has its own task; none of them is abandoned here.
16 + //!
17 + //! Read the table below as what was *already* describable when the flip landed
18 + //! rather than as a verdict on the other five. Two of them -- Storage and Trash
19 + //! -- have had their stated reason weakened since it was written: `Locate` and
20 + //! `Outcome::File` between them say most of what Storage needed, and Trash is
21 + //! tombstone rows out of the app's own database rather than the filesystem the
22 + //! line below calls it.
6 23 //!
7 24 //! # What is describable here, and what is not
8 25 //!
9 - //! The panel has nine sections and **four of them are describable**. That ratio
26 + //! The panel had nine sections and **four of them are described**. That ratio
10 27 //! is not a disappointment; it is the same result goingson's settings port got
11 28 //! (five of eight not describable) and for the same cause, which is worth
12 29 //! stating precisely because it is easy to misread as a gap in the vocabulary:
@@ -285,7 +285,7 @@
285 285 ///
286 286 /// Six questions, in two groups split by the metadata-sidecar checkbox that
287 287 /// sits between them: a bare toggle is a control rather than a field, which is
288 - /// the rule `settings_panel` set for this port, so it is drawn between the
288 + /// the rule the settings port set, so it is drawn between the
289 289 /// groups instead of being described as one. The destination stays outside for
290 290 /// the other standing reason — there is no `FieldKind::Path`, deliberately, and
291 291 /// a button plus a native dialog is not a question a form holds an answer to.
@@ -7,6 +7,5 @@
7 7 pub mod file_list_menus;
8 8 pub mod instrument_panel;
9 9 pub mod overlays;
10 - pub mod settings_panel;
11 10 pub mod theme;
12 11 pub mod widgets;
@@ -1,1182 +1,0 @@
1 - //! Consolidated Settings window: Storage, Appearance, Preview, Display, License.
2 -
3 - use egui;
4 -
5 - use super::theme;
6 - use super::widgets;
7 - use crate::state::BrowserState;
8 -
9 - /// Draw the Settings window with collapsing sections.
10 - pub fn draw_settings_panel(ctx: &egui::Context, state: &mut BrowserState) {
11 - let mut open = state.settings.show_manager;
12 - widgets::modal_window_with_open(ctx, "Settings", Some(&mut open), true, Some(420.0), |ui| {
13 - egui::ScrollArea::vertical().show(ui, |ui| {
14 - draw_storage_section(ui, state);
15 - ui.add_space(theme::space::bound());
16 - draw_appearance_section(ui, state);
17 - ui.add_space(theme::space::bound());
18 - draw_preview_section(ui, state);
19 - ui.add_space(theme::space::bound());
20 - draw_forge_section(ui, state);
21 - ui.add_space(theme::space::bound());
22 - super::classifier::draw_classifier_section(ui, state);
23 - ui.add_space(theme::space::bound());
24 - draw_display_section(ui, state);
25 - ui.add_space(theme::space::bound());
26 - draw_license_section(ui, state);
27 - ui.add_space(theme::space::bound());
28 - draw_trash_section(ui, state);
29 - ui.add_space(theme::space::bound());
30 - draw_advanced_section(ui, state);
31 - });
32 - });
33 - state.settings.show_manager = open;
34 - }
35 -
36 - /// Format byte counts as B/KB/MB/GB.
37 - /// Collapse the user's home directory to `~` for display, so library paths
38 - /// don't overflow narrow Settings windows. The full path is intended to be
39 - /// surfaced as a tooltip on hover. Returns the original path string if the
40 - /// home directory can't be resolved or the path doesn't sit under it.
41 - fn collapse_home(path: &std::path::Path) -> String {
42 - let display = path.display().to_string();
43 - let Some(home) = dirs::home_dir() else {
44 - return display;
45 - };
46 - let home_str = home.display().to_string();
47 - if let Some(rest) = display.strip_prefix(&home_str) {
48 - if rest.is_empty() {
49 - return "~".to_string();
50 - }
51 - return format!("~{rest}");
52 - }
53 - display
54 - }
55 -
56 - /// Format storage scan freshness. Returns `(text, stale)`, `stale` is true
57 - /// when results are older than 24 hours, signalling the user should re-scan.
58 - fn format_scan_age(age_secs: i64) -> (String, bool) {
59 - let stale = age_secs >= 86_400;
60 - let suffix = if stale { ", re-scan to refresh." } else { "" };
61 - let text = if age_secs < 120 {
62 - format!("Last scanned just now.{suffix}")
63 - } else if age_secs < 3_600 {
64 - format!("Last scanned {} minutes ago.{suffix}", age_secs / 60)
65 - } else if age_secs < 86_400 {
66 - format!(
67 - "Last scanned {} hour{} ago.{suffix}",
68 - age_secs / 3_600,
69 - if age_secs / 3_600 == 1 { "" } else { "s" }
70 - )
71 - } else {
72 - let days = age_secs / 86_400;
73 - format!(
74 - "Last scanned {} day{} ago.{suffix}",
75 - days,
76 - if days == 1 { "" } else { "s" }
77 - )
78 - };
79 - (text, stale)
80 - }
81 -
82 - fn format_bytes(bytes: u64) -> String {
83 - if bytes < 1024 {
84 - format!("{bytes} B")
85 - } else if bytes < 1024 * 1024 {
86 - format!("{:.1} KB", bytes as f64 / 1024.0)
87 - } else if bytes < 1024 * 1024 * 1024 {
88 - format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
89 - } else {
90 - format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
91 - }
92 - }
93 -
94 - // Storage section
95 -
96 - fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
97 - egui::CollapsingHeader::new(egui::RichText::new("Storage").strong())
98 - .default_open(true)
99 - .show(ui, |ui| {
100 - ui.label(
101 - egui::RichText::new("Each library is an independent sample collection with its own database and files. A library can contain multiple vaults (top-level browse buckets).")
102 - .small()
103 - .color(theme::content_muted()),
104 - );
105 - ui.add_space(theme::space::bound());
106 -
107 - // Vault list
108 - let vault_list = state.settings.list.clone();
109 - let active_path = state.data_dir.clone();
110 - let mut remove_path = None;
111 - let mut should_close = false;
112 -
113 - let mut relocate_old_path: Option<std::path::PathBuf> = None;
114 - // Mirror the sidebar's switch flow: clicking a non-active reachable
115 - // row switches that library. The sidebar ComboBox stays the primary
116 - // entry point, but Settings rows visually read as clickable list
117 - // rows (Phase 3's `selectable_row` widget), wiring the click here
118 - // closes the false-affordance gap without duplicating logic.
119 - let mut switch_to: Option<(std::path::PathBuf, String)> = None;
120 -
121 - for (name, path, reachable) in &vault_list {
122 - let is_active = path == &active_path;
123 - ui.horizontal(|ui| {
124 - let status = if is_active {
125 - egui::RichText::new("active").small().color(theme::action())
126 - } else if !reachable {
127 - egui::RichText::new("offline").small().color(theme::danger())
128 - } else {
129 - egui::RichText::new("").small()
130 - };
131 -
132 - let row_resp = widgets::selectable_row(ui, is_active, name);
133 - if row_resp.clicked() && !is_active && *reachable {
134 - switch_to = Some((path.clone(), name.clone()));
135 - }
136 - // Offline badge surfaces the last-known path on hover so the
137 - // user knows where the directory used to live.
138 - let status_label = ui.label(status);
139 - if !reachable && !is_active {
140 - status_label.on_hover_text(format!(
141 - "Last known path: {}. Use Locate... to repoint if the directory moved.",
142 - path.display(),
143 - ));
144 - }
145 -
146 - if ui.small_button("Rename").clicked() {
147 - state.settings.rename_target = Some((path.clone(), name.clone()));
148 - }
149 - // Locate... replaces a stranded registry entry's path. Only
150 - // surfaced for offline non-active vaults; active vault path
151 - // is handled differently (it's the open DB).
152 - if !reachable && !is_active && ui.small_button("Locate...").on_hover_text("Point this library at a new directory").clicked() {
153 - relocate_old_path = Some(path.clone());
154 - }
155 - if !is_active && widgets::danger_small_button(ui, "Remove").clicked() {
156 - remove_path = Some(path.clone());
157 - }
158 - });
159 - ui.label(
160 - egui::RichText::new(collapse_home(path))
161 - .small()
162 - .color(theme::content_muted()),
163 - )
164 - .on_hover_text(path.display().to_string());
165 - // Per-vault sample count + total size for the active vault when
166 - // a fresh scan exists. Makes "which vault is the small one?"
167 - // legible without opening each one (m-8). Only the active vault
168 - // has a cache today, non-active rows stay path-only.
169 - if is_active
170 - && let Some(ref stats) = state.settings.storage_cache {
171 - ui.label(
172 - egui::RichText::new(format!(
173 - "{} samples \u{00B7} {}",
174 - stats.sample_count,
175 - format_bytes(stats.total_bytes),
176 - ))
177 - .small()
178 - .color(theme::content_muted()),
179 - );
180 - }
181 - ui.add_space(theme::space::bound());
182 - }
183 -
184 - if let Some((path, name)) = switch_to {
185 - // Same guard as sidebar.rs: confirm only when in-flight work
186 - // would be interrupted; otherwise switch directly. Closing
187 - // Settings on switch matches the Create-New flow below.
188 - if state.has_in_flight_work() {
189 - state.overlay.pending_confirm = Some(
190 - crate::state::ConfirmAction::SwitchLibrary {
191 - path,
192 - library_name: name,
193 - },
194 - );
195 - } else {
196 - state.settings.pending_action =
197 - Some(crate::state::VaultAction::SwitchVault(path));
198 - }
199 - should_close = true;
200 - }
201 - if let Some(path) = remove_path {
202 - state.settings.pending_action =
203 - Some(crate::state::VaultAction::RemoveVault(path));
204 - }
205 - if let Some(old_path) = relocate_old_path {
206 - state.dialogs.pick_folder("Locate library directory", move |s, new_path| {
207 - s.settings.pending_action =
208 - Some(crate::state::VaultAction::RelocateVault { old_path, new_path });
209 - });
210 - }
211 -
212 - // Inline rename
213 - if let Some((rename_path, _)) = state.settings.rename_target.clone() {
214 - ui.separator();
215 - ui.horizontal(|ui| {
216 - ui.label("New name:");
217 - // Borrow the editable name only for the text field; the mutable
218 - // borrow ends before we may clear rename_target below (avoids
219 - // the previous `as_mut().unwrap()` / `as_ref().unwrap()` pair,
220 - // which panicked-per-frame if the option was cleared mid-frame).
221 - let mut submit = false;
222 - if let Some((_, name)) = state.settings.rename_target.as_mut() {
223 - let resp =
224 - widgets::text_field(ui, egui::TextEdit::singleline(name));
225 - submit = resp.lost_focus()
226 - && ui.input(|i| i.key_pressed(egui::Key::Enter));
227 - }
228 - if submit {
229 - let new_name = state
230 - .settings
231 - .rename_target
232 - .as_ref()
233 - .map(|(_, n)| n.trim().to_string())
234 - .unwrap_or_default();
235 - if !new_name.is_empty() {
236 - state.settings.pending_action =
237 - Some(crate::state::VaultAction::RenameVault {
238 - path: rename_path.clone(),
239 - new_name,
240 - });
241 - }
242 - state.settings.rename_target = None;
243 - }
244 - if ui.button("Cancel").clicked() {
245 - state.settings.rename_target = None;
246 - }
247 - });
248 - }
249 -
250 - // Storage stats
251 - ui.add_space(theme::space::bound());
252 - let scanning = matches!(
253 - state.settings.pending_action,
254 - Some(crate::state::VaultAction::ScanStorage),
255 - );
256 - ui.horizontal(|ui| {
257 - let (label, hover) = if scanning {
258 - ("Scanning...", "Scan in progress")
259 - } else {
260 - ("Scan", "Scan storage usage for this library")
261 - };
262 - if ui
263 - .add_enabled(!scanning, egui::Button::new(label))
264 - .on_hover_text(hover)
265 - .clicked()
266 - {
267 - state.settings.pending_action = Some(crate::state::VaultAction::ScanStorage);
268 - }
269 - if scanning {
270 - ui.spinner();
271 - } else if let Some(ref stats) = state.settings.storage_cache {
272 - ui.label(format!(
273 - "{} samples, {} total, {} database",
274 - stats.sample_count,
275 - format_bytes(stats.total_bytes),
276 - format_bytes(stats.db_bytes),
277 - ));
278 - }
279 - });
280 - // Surface scan freshness so stale cached numbers aren't trusted blindly.
281 - if let Some(at) = state.settings.storage_cache_at {
282 - let now = std::time::SystemTime::now()
283 - .duration_since(std::time::UNIX_EPOCH)
284 - .map_or(at, |d| d.as_secs() as i64);
285 - let age_secs = now.saturating_sub(at).max(0);
286 - let (text, stale) = format_scan_age(age_secs);
287 - let color = if stale { theme::warning() } else { theme::content_muted() };
288 - ui.label(
289 - egui::RichText::new(text).small().color(color),
290 - );
291 - }
292 -
293 - // Cleanup orphans: free disk by removing samples no longer
294 - // referenced by any VFS placement. Sync triggers are
295 - // suppressed for this operation (local-only by design, each
296 - // synced device curates its own orphan set).
297 - ui.add_space(theme::space::bound());
298 - ui.horizontal(|ui| {
299 - if ui
300 - .button("Cleanup orphans")
301 - .on_hover_text(
302 - "Free disk by deleting samples no longer referenced anywhere in the library. \
303 - Local-only: other synced devices keep their own copies.",
304 - )
305 - .clicked()
306 - {
307 - state.cleanup_orphans_now();
308 - }
309 - });
310 -
311 - // Backfill audio features: compute and store the feature data used by
312 - // tag suggestions for samples that lack a current version (e.g. imported
313 - // before the feature store existed). Reuses the analysis worker and runs
314 - // in the background; the button reflects in-progress state.
315 - ui.add_space(theme::space::bound());
316 - ui.horizontal(|ui| {
317 - let running = state.import_wf.backfill_in_progress;
318 - let label = if running {
319 - "Backfilling audio features..."
320 - } else {
321 - "Backfill audio features"
322 - };
323 - if ui
324 - .add_enabled(!running, egui::Button::new(label))
325 - .on_hover_text(
326 - "Compute the audio feature data used by tag suggestions for samples \
327 - that don't have it yet. Runs in the background: keep working; \
328 - it yields to any analysis you start and resumes later.",
329 - )
330 - .clicked()
331 - {
332 - state.start_backfill();
333 - }
334 - if running {
335 - ui.spinner();
336 - }
337 - });
338 -
339 - // Verify library integrity: re-hash every managed blob and report
340 - // any whose bytes no longer match their content address (bit-rot, an
341 - // out-of-band edit through the VFS mirror, a truncated blob). Runs on
342 - // the maintenance worker; the busy flag is shared with the loose-files
343 - // ops, so the button disables while any of them is in flight.
344 - ui.add_space(theme::space::bound());
345 - ui.horizontal(|ui| {
346 - let busy = state.loose_files.loose_files_busy;
347 - if ui
348 - .add_enabled(!busy, egui::Button::new("Verify library integrity"))
349 - .on_hover_text(
350 - "Re-hash every stored sample and confirm its bytes still match its \
351 - content address. Catches silent on-disk corruption. Runs in the \
352 - background: the result appears in the status line.",
353 - )
354 - .clicked()
355 - {
356 - state.verify_store_integrity_now();
357 - }
358 - if busy {
359 - ui.spinner();
360 - }
361 - });
362 -
363 - ui.add_space(theme::space::peer());
364 - ui.separator();
365 - ui.add_space(theme::space::bound());
366 -
367 - // Loose-files mode indicator for active vault
368 - if state.settings.is_loose_files {
369 - ui.add_space(theme::space::bound());
370 - ui.label(
371 - egui::RichText::new("This library uses loose-files mode. Samples are referenced in place, not duplicated.")
372 - .small()
373 - .color(theme::warning()),
374 - );
375 - }
376 -
377 - // Create new library
378 - ui.label(egui::RichText::new("Add Library").strong());
379 - // The name is the one input in this form the description can say.
380 - // The folder picker is a button and a path, and the storage style
381 - // is a radio pair; `FieldKind` has neither, so both stay
382 - // hand-rolled below and `makeover_immediate::group` cannot be used
383 - // for a form with one describable field out of three. Filed on
384 - // GoingsOn `bebfd112` rather than worked around here.
385 - //
386 - // The error is the state the old form left unexplained: a folder
387 - // chosen and no name, where "Create New" disables itself and says
388 - // nothing about why.
389 - let name_missing = state.settings.create_path.is_some()
390 - && state.settings.create_name.trim().is_empty();
391 - let name_field = makeover_layout::Field {
392 - required: true,
393 - error: name_missing.then_some("A library needs a name."),
394 - ..makeover_layout::Field::new(
395 - makeover_layout::FieldKind::Text,
396 - "create_name",
397 - "Name",
398 - )
399 - };
400 - widgets::field(
401 - ui,
402 - &name_field,
403 - makeover_immediate::Filling::Text(&mut state.settings.create_name),
404 - None,
405 - );
406 - ui.add_space(theme::space::bound());
407 - ui.horizontal(|ui| {
408 - if ui.button("Choose folder...").clicked() {
409 - state.dialogs.pick_folder("Choose folder", |s, p| {
410 - s.settings.create_path = Some(p);
411 - });
412 - }
413 - if let Some(ref p) = state.settings.create_path {
414 - ui.label(
415 - egui::RichText::new(p.display().to_string())
416 - .small()
417 - .color(theme::content_secondary()),
418 - );
419 - }
420 - });
421 - // Storage style is a significant choice (copy vs reference in
422 - // place), promote it from a buried checkbox to an explicit radio
423 - // choice so users opt into loose-files mode deliberately.
424 - //
425 - // A described field now, and specifically a `Radio` rather than a
426 - // `Select`: this is the call site `FieldKind::Radio` was added for
427 - // in makeover-layout 0.8.1. The reason the comment above gives is
428 - // the reason the kind exists — the alternatives to an irreversible
429 - // choice have to be readable without opening anything — and it was
430 - // being enforced by hand here because the description could not say
431 - // it.
432 - const STORAGE_STYLES: [makeover_layout::Choice<'static>; 2] = [
433 - makeover_layout::Choice::new("copy", "Copy samples into library (recommended)"),
434 - makeover_layout::Choice::new("reference", "Reference samples in place (loose-files mode)"),
435 - ];
436 - // The description names its options by string and this app holds a
437 - // bool, so the two are marshalled here rather than by reshaping the
438 - // state. "Which of a fixed set" is the question; the bool is this
439 - // app's private encoding of the answer, and it stays private.
440 - let mut storage = String::from(if state.settings.create_loose_files {
441 - "reference"
442 - } else {
443 - "copy"
444 - });
445 - let storage_field = makeover_layout::Field {
446 - // Was the tail of one option's hover text. It is the one fact
447 - // that applies to the whole question rather than to either
448 - // answer, and standing help is shown without being hunted for,
449 - // which an irreversible choice deserves.
450 - hint: Some("Cannot be changed after the library is created."),
451 - ..makeover_layout::Field::radio(
452 - "create_storage_style",
453 - "Storage style",
454 - &STORAGE_STYLES,
455 - )
456 - };
457 - if widgets::field(
458 - ui,
459 - &storage_field,
460 - makeover_immediate::Filling::Text(&mut storage),
461 - None,
462 - )
463 - .is_some_and(|response| response.changed())
464 - {
465 - state.settings.create_loose_files = storage == "reference";
466 - }
467 - if state.settings.create_loose_files {
468 - ui.label(
469 - egui::RichText::new("Moving or deleting originals will break references. This cannot be undone.")
470 - .small()
471 - .color(theme::warning()),
472 - );
473 - }
474 - ui.add_space(theme::space::bound());
475 - ui.horizontal(|ui| {
476 - let can_create = !state.settings.create_name.trim().is_empty()
477 - && state.settings.create_path.is_some();
478 - let has_partial = !state.settings.create_name.trim().is_empty()
479 - || state.settings.create_path.is_some();
480 - if ui.add_enabled(can_create, egui::Button::new("Create New")).clicked()
481 - && let Some(path) = state.settings.create_path.take() {
482 - let name = state.settings.create_name.trim().to_string();
483 - let loose_files = state.settings.create_loose_files;
484 - state.settings.pending_action =
485 - Some(crate::state::VaultAction::CreateVault { name, path, loose_files });
486 - state.settings.create_name.clear();
487 - state.settings.create_loose_files = false;
488 - should_close = true;
489 - }
490 - if ui
491 - .add_enabled(can_create, egui::Button::new("Add Existing"))
492 - .on_hover_text("Add an existing audiofiles library directory")
493 - .clicked()
494 - && let Some(path) = state.settings.create_path.take() {
495 - let name = state.settings.create_name.trim().to_string();
496 - state.settings.pending_action =
497 - Some(crate::state::VaultAction::AddExistingVault { name, path });
498 - state.settings.create_name.clear();
499 - state.settings.create_loose_files = false;
500 - // Both commit paths now close Settings: a Create makes
Lines truncated