Skip to main content

max / audiofiles

24.7 KB · 676 lines History Blame Raw
1 //! Reusable UI widgets: tag chips, classification badges, modals, and progress indicators.
2 //!
3 //! See `docs/design-system.md` for the canonical primitive set. Every shared
4 //! visual recipe lives here; panel files compose these helpers and never
5 //! reinvent rows, headers, modals, or buttons inline.
6 //!
7 //! ## Brand-rule glyph exceptions
8 //!
9 //! Per `docs/design-system.md`, user-facing strings should not contain emoji or
10 //! checkmark glyphs. The following are *documented* exceptions:
11 //!
12 //! * Sort-direction arrows (`U+25B2`, `U+25BC`) in `file_list.rs::draw_sort_header`
13 //! — functional column-header affordance, no word equivalent fits the layout.
14 //! * Typography (em-dash `U+2014`, right-arrow `U+2192`, middle-dot `U+00B7`,
15 //! bullet `U+2022`) in prose — these are punctuation, not emoji.
16 //!
17 //! Everything else (✓ ✖ ▶ ⏹ 🎵 🔍 🎹 🔁 ⚙ ↩ 💾 ⚠ ☰) was migrated to words
18 //! during Batch 5 of the consolidation plan.
19
20 use egui;
21
22 use super::theme;
23
24 // --- Modal scaffolds ---------------------------------------------------------
25
26 /// Outcome of a modal that ends in a Confirm/Cancel action row.
27 pub enum ConfirmOutcome {
28 /// User hasn't acted yet this frame.
29 None,
30 /// User pressed the confirm button (or Enter, where applicable).
31 Confirmed,
32 /// User pressed Cancel.
33 Cancelled,
34 }
35
36 /// Outcome of a single-field name modal (used by create/rename modals).
37 pub enum NameModalOutcome {
38 /// User hasn't acted yet this frame.
39 None,
40 /// User submitted the (trimmed) name.
41 Submitted(String),
42 /// User cancelled.
43 Cancelled,
44 }
45
46 /// Paint a semi-opaque full-window scrim that swallows pointer input, so a modal
47 /// drawn *after* this call is genuinely modal — the underlying file list no
48 /// longer responds to clicks behind it. (The Escape handler already enforces
49 /// keyboard dismissal priority; this closes the mouse-leakage gap, P2.)
50 ///
51 /// Call this immediately before drawing a modal window. Both the scrim and the
52 /// modal live in `Order::Middle`; the scrim is created first so it sits below
53 /// the modal but above the panels.
54 pub fn modal_scrim(ctx: &egui::Context) {
55 let screen = ctx.content_rect();
56 egui::Area::new(egui::Id::new("modal_scrim"))
57 .order(egui::Order::Middle)
58 .fixed_pos(screen.min)
59 .show(ctx, |ui| {
60 // Full-screen interactive surface consumes clicks/drags that miss
61 // the modal, preventing them from reaching the live UI beneath.
62 let resp = ui.allocate_response(screen.size(), egui::Sense::click_and_drag());
63 ui.painter()
64 .rect_filled(screen, 0.0, egui::Color32::from_black_alpha(128));
65 resp
66 });
67 }
68
69 /// Canonical center-anchored, non-resizable modal scaffold.
70 ///
71 /// Replaces the inline
72 /// `Window::new(title).collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])`
73 /// recipe repeated across `overlays.rs`. Pass `resizable: true` only for the
74 /// bulk-rename modal — every other modal uses the default.
75 pub fn modal_window<R>(
76 ctx: &egui::Context,
77 title: &str,
78 resizable: bool,
79 default_width: Option<f32>,
80 add_contents: impl FnOnce(&mut egui::Ui) -> R,
81 ) -> Option<R> {
82 modal_window_with_open(ctx, title, None, resizable, default_width, add_contents)
83 }
84
85 /// Floating tool window scaffold.
86 ///
87 /// Distinct from [`modal_window`]: not anchored, resizable, collapsible, and
88 /// user-dismissible via an `open` bool. Use for tool surfaces that the user
89 /// keeps open alongside the main UI (the sample editor, the MIDI/instrument
90 /// panel). Anything that demands focus and blocks the rest of the UI is a
91 /// modal — use [`modal_window`] or [`confirm_modal`] instead.
92 pub fn tool_window<R>(
93 ctx: &egui::Context,
94 title: &str,
95 open: &mut bool,
96 default_width: f32,
97 min_width: f32,
98 add_contents: impl FnOnce(&mut egui::Ui) -> R,
99 ) -> Option<R> {
100 egui::Window::new(title)
101 .open(open)
102 .resizable(true)
103 .collapsible(true)
104 .default_width(default_width)
105 .min_width(min_width)
106 .show(ctx, |ui| add_contents(ui))
107 .and_then(|r| r.inner)
108 }
109
110 /// Like `modal_window`, but with an optional `open` bool the user can toggle by
111 /// clicking the close (×) chrome. Used by the help overlay.
112 pub fn modal_window_with_open<R>(
113 ctx: &egui::Context,
114 title: &str,
115 open: Option<&mut bool>,
116 resizable: bool,
117 default_width: Option<f32>,
118 add_contents: impl FnOnce(&mut egui::Ui) -> R,
119 ) -> Option<R> {
120 let mut window = egui::Window::new(title)
121 .collapsible(false)
122 .resizable(resizable)
123 .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]);
124 if let Some(o) = open {
125 window = window.open(o);
126 }
127 if let Some(w) = default_width {
128 window = window.default_width(w);
129 }
130 window.show(ctx, |ui| add_contents(ui)).map(|r| r.inner.unwrap())
131 }
132
133 /// Render a `[Cancel] [primary]` action row at the bottom of a modal.
134 ///
135 /// Action ordering follows platform convention: Cancel on the left, primary on
136 /// the right. The user's muscle memory from macOS/Windows native dialogs is
137 /// "the affirmative button is on the right" — matching that avoids surprise
138 /// clicks, especially in destructive modals where Delete-on-the-right-cursor
139 /// would be catastrophic. The primary button is enabled when `can_confirm` is
140 /// true.
141 pub fn confirm_action_row(
142 ui: &mut egui::Ui,
143 confirm_label: &str,
144 can_confirm: bool,
145 danger: bool,
146 ) -> ConfirmOutcome {
147 let mut outcome = ConfirmOutcome::None;
148 ui.horizontal(|ui| {
149 if ui.button("Cancel").clicked() {
150 outcome = ConfirmOutcome::Cancelled;
151 }
152 let label = if danger {
153 egui::RichText::new(confirm_label).color(theme::accent_red())
154 } else {
155 egui::RichText::new(confirm_label)
156 };
157 if ui.add_enabled(can_confirm, egui::Button::new(label)).clicked() {
158 outcome = ConfirmOutcome::Confirmed;
159 }
160 });
161 outcome
162 }
163
164 /// Spec for a destructive-confirm modal.
165 pub struct ConfirmSpec<'a> {
166 pub title: &'a str,
167 pub prompt: &'a str,
168 pub detail: Option<&'a str>,
169 pub confirm_label: &'a str,
170 pub danger: bool,
171 }
172
173 /// Render a confirm modal with a prompt, optional detail body, and a
174 /// `[confirm] [Cancel]` action row. Returns the outcome for the caller to
175 /// act on after the closure exits (egui borrow constraints).
176 pub fn confirm_modal(ctx: &egui::Context, spec: &ConfirmSpec) -> ConfirmOutcome {
177 let mut outcome = ConfirmOutcome::None;
178 modal_window(ctx, spec.title, false, None, |ui| {
179 ui.label(spec.prompt);
180 if let Some(detail) = spec.detail {
181 ui.add_space(theme::space::SM);
182 ui.label(
183 egui::RichText::new(detail)
184 .small()
185 .color(theme::text_secondary()),
186 );
187 }
188 ui.add_space(theme::space::LG);
189 outcome = confirm_action_row(ui, spec.confirm_label, true, spec.danger);
190 });
191 outcome
192 }
193
194 /// Single-field name modal: title, optional hint, label, text input,
195 /// submit/cancel. Enter in the field submits.
196 ///
197 /// Autofocus: the text field grabs focus on first open (detected as "input is
198 /// empty and nothing in the app currently has focus"). After the user clicks
199 /// any widget the autofocus stops firing, so Cancel/Submit clicks aren't
200 /// stolen back by the input.
201 pub fn name_modal(
202 ctx: &egui::Context,
203 title: &str,
204 hint: Option<&str>,
205 label: &str,
206 input: &mut String,
207 submit_label: &str,
208 error: Option<&str>,
209 ) -> NameModalOutcome {
210 let mut outcome = NameModalOutcome::None;
211 modal_window(ctx, title, false, None, |ui| {
212 if let Some(h) = hint {
213 ui.label(egui::RichText::new(h).small().color(theme::text_muted()));
214 ui.add_space(theme::space::SM);
215 }
216 ui.label(label);
217 let resp = ui.text_edit_singleline(input);
218 if input.is_empty() && ui.memory(|m| m.focused().is_none()) {
219 resp.request_focus();
220 }
221 // C-3: inline error below the input. Re-focus the input when an error
222 // is surfaced so the user can edit and retry without re-clicking.
223 if let Some(err) = error {
224 ui.add_space(theme::space::XS);
225 ui.label(
226 egui::RichText::new(err)
227 .small()
228 .color(theme::accent_red()),
229 );
230 if !resp.has_focus() {
231 resp.request_focus();
232 }
233 }
234 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
235 outcome = NameModalOutcome::Submitted(input.trim().to_string());
236 }
237 ui.add_space(theme::space::MD);
238 ui.horizontal(|ui| {
239 if ui.button("Cancel").clicked() {
240 outcome = NameModalOutcome::Cancelled;
241 }
242 if ui.button(submit_label).clicked() {
243 outcome = NameModalOutcome::Submitted(input.trim().to_string());
244 }
245 });
246 });
247 outcome
248 }
249
250 // --- Empty state and banner --------------------------------------------------
251
252 /// CTA slot for an empty-state panel.
253 pub struct EmptyStateCta<'a> {
254 pub label: &'a str,
255 pub tooltip: Option<&'a str>,
256 }
257
258 /// Centered empty-state panel.
259 ///
260 /// Renders a centred column with a heading (`text_secondary`, 20 px), an
261 /// optional body (`text_muted`), and an optional CTA button. The vertical
262 /// offset is 15% of the available height to keep the column visually anchored.
263 /// Returns `true` if the CTA was clicked (always `false` when no CTA).
264 pub fn empty_state(
265 ui: &mut egui::Ui,
266 heading: &str,
267 body: Option<&str>,
268 cta: Option<EmptyStateCta>,
269 ) -> bool {
270 let mut clicked = false;
271 ui.vertical_centered(|ui| {
272 ui.add_space(ui.available_height() * 0.15);
273 ui.label(
274 egui::RichText::new(heading)
275 .size(20.0)
276 .color(theme::text_secondary()),
277 );
278 if let Some(body_text) = body {
279 ui.add_space(theme::space::MD);
280 ui.label(egui::RichText::new(body_text).color(theme::text_muted()));
281 }
282 if let Some(cta) = cta {
283 ui.add_space(theme::space::LG);
284 let btn = secondary_button(ui, cta.label);
285 let btn = if let Some(t) = cta.tooltip { btn.on_hover_text(t) } else { btn };
286 if btn.clicked() {
287 clicked = true;
288 }
289 }
290 });
291 clicked
292 }
293
294 /// Format a byte count as B / KB / MB / GB. Three other call sites in this
295 /// crate define their own private copies — new callers should reach for this
296 /// one; the legacy copies can be migrated opportunistically.
297 pub fn format_bytes(bytes: u64) -> String {
298 if bytes < 1024 {
299 format!("{bytes} B")
300 } else if bytes < 1024 * 1024 {
301 format!("{:.1} KB", bytes as f64 / 1024.0)
302 } else if bytes < 1024 * 1024 * 1024 {
303 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
304 } else {
305 format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
306 }
307 }
308
309 /// Inline informational banner: rounded frame, `bg_tertiary` fill, body text
310 /// in `text_secondary`. Used for one-time tips and unobtrusive panel notices.
311 pub fn info_banner(ui: &mut egui::Ui, body: &str) {
312 egui::Frame::new()
313 .fill(theme::bg_tertiary())
314 .corner_radius(egui::CornerRadius::same(4))
315 .inner_margin(egui::Margin::same(8))
316 .show(ui, |ui| {
317 ui.label(
318 egui::RichText::new(body)
319 .small()
320 .color(theme::text_secondary()),
321 );
322 });
323 }
324
325 /// Inline warning banner: same shape as `info_banner` but body text in
326 /// `accent_yellow` at body weight (not `.small()`). Used for actions whose
327 /// consequences are important enough that the weak/small footnote style would
328 /// under-sell them — currently the irrecoverable encryption-password setup.
329 pub fn warning_banner(ui: &mut egui::Ui, body: &str) {
330 egui::Frame::new()
331 .fill(theme::bg_tertiary())
332 .corner_radius(egui::CornerRadius::same(4))
333 .inner_margin(egui::Margin::same(8))
334 .show(ui, |ui| {
335 ui.label(egui::RichText::new(body).color(theme::accent_yellow()));
336 });
337 }
338
339 // --- Toolbar toggle and segmented pills --------------------------------------
340
341 /// Toolbar toggle button.
342 ///
343 /// Active state colours the label `accent_blue`; inactive state colours it
344 /// `text_muted`. Returns true on click. Optional `count` renders a parenthesised
345 /// suffix (e.g. "Filters (3)") for active-with-count toolbar buttons.
346 pub fn toolbar_toggle(
347 ui: &mut egui::Ui,
348 label: &str,
349 active: bool,
350 tooltip: &str,
351 count: Option<usize>,
352 ) -> bool {
353 let text = match count {
354 Some(n) if n > 0 => format!("{label} ({n})"),
355 _ => label.to_string(),
356 };
357 let colour = if active { theme::accent_blue() } else { theme::text_muted() };
358 ui.button(egui::RichText::new(text).color(colour))
359 .on_hover_text(tooltip)
360 .clicked()
361 }
362
363 /// Mutually-exclusive segmented pill control.
364 ///
365 /// `options` is a list of `(value, label, tooltip)` triples. Returns
366 /// `Some(value)` if a non-current option was clicked, `None` otherwise.
367 /// Caller assigns the returned value to its state.
368 pub fn toggle_pills<T: Clone + PartialEq>(
369 ui: &mut egui::Ui,
370 current: &T,
371 options: &[(T, &str, &str)],
372 ) -> Option<T> {
373 let mut chosen = None;
374 ui.horizontal(|ui| {
375 for (value, label, tooltip) in options {
376 let is_active = value == current;
377 if ui
378 .selectable_label(is_active, *label)
379 .on_hover_text(*tooltip)
380 .clicked()
381 && !is_active
382 {
383 chosen = Some(value.clone());
384 }
385 }
386 });
387 chosen
388 }
389
390 // --- Button hierarchy --------------------------------------------------------
391 //
392 // Three button weights:
393 // - `primary_button` — strong label; the single primary action in a row.
394 // - `secondary_button` — default weight; cancel and peer actions.
395 // - `danger_button` — `accent_red` label; destructive primary actions.
396 //
397 // `confirm_action_row` (above) composes these for the standard modal pattern;
398 // reach for these directly only when building a non-modal action row.
399
400 /// Primary action button. Strong label weight.
401 pub fn primary_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
402 ui.add(egui::Button::new(egui::RichText::new(label).strong()))
403 }
404
405 /// Secondary / peer action button. Default weight. Use for Cancel and for any
406 /// action that isn't the primary focus of the row.
407 pub fn secondary_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
408 ui.button(label)
409 }
410
411 /// Destructive primary action. Label rendered in `accent_red` so the user
412 /// reads the consequence before clicking. Used for Delete, Purge, Discard.
413 pub fn danger_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
414 ui.add(egui::Button::new(egui::RichText::new(label).color(theme::accent_red())))
415 }
416
417 /// Destructive action that may be disabled (e.g. Delete-vault when only one
418 /// vault remains). Same red colouring as [`danger_button`]; routes through
419 /// `add_enabled` so disabled state and hover text work consistently.
420 pub fn danger_button_enabled(ui: &mut egui::Ui, label: &str, enabled: bool) -> egui::Response {
421 ui.add_enabled(
422 enabled,
423 egui::Button::new(egui::RichText::new(label).color(theme::accent_red())),
424 )
425 }
426
427 /// Small destructive action (per-row Remove/Delete affordances, context-menu
428 /// items inside a tighter layout). Same colouring as [`danger_button`].
429 pub fn danger_small_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
430 ui.add(egui::Button::new(egui::RichText::new(label).color(theme::accent_red())).small())
431 }
432
433 // --- Section headers ---------------------------------------------------------
434
435 /// Panel section heading: strong, `text_secondary` label, separator, small gap.
436 pub fn section_header(ui: &mut egui::Ui, label: &str) {
437 ui.label(egui::RichText::new(label).strong().color(theme::text_secondary()));
438 ui.separator();
439 ui.add_space(theme::space::SM);
440 }
441
442 /// Sub-block label inside an already-headed section. No separator, no gap.
443 pub fn subsection_label(ui: &mut egui::Ui, label: &str) {
444 ui.label(egui::RichText::new(label).strong().color(theme::text_secondary()));
445 }
446
447 /// Filter-panel collapsing section.
448 ///
449 /// Header is suffixed with `" *"` when `active` is true (visual indicator of an
450 /// in-effect filter); the section is `default_open` when active so the user
451 /// sees what's filtering them.
452 pub fn filter_section<R>(
453 ui: &mut egui::Ui,
454 label: &str,
455 active: bool,
456 add_contents: impl FnOnce(&mut egui::Ui) -> R,
457 ) {
458 let header = if active { format!("{label} *") } else { label.to_string() };
459 egui::CollapsingHeader::new(header)
460 .default_open(active)
461 .show(ui, |ui| {
462 add_contents(ui);
463 });
464 }
465
466 // --- Selectable rows ---------------------------------------------------------
467
468 /// Render a selectable label that truncates with an ellipsis instead of
469 /// expanding the row (which would force the sidebar wider than its clip range),
470 /// and shows the full `full_text` on hover so a clipped name is still readable.
471 fn selectable_truncating(
472 ui: &mut egui::Ui,
473 active: bool,
474 rich: egui::RichText,
475 full_text: &str,
476 ) -> egui::Response {
477 let prev = ui.style().wrap_mode;
478 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
479 let resp = ui.selectable_label(active, rich);
480 ui.style_mut().wrap_mode = prev;
481 resp.on_hover_text(full_text)
482 }
483
484 /// Primary selectable list row.
485 ///
486 /// Active state renders the label as `strong()` + `accent_blue`. Inactive state
487 /// renders as `text_primary`. Used for top-level list items where the inactive
488 /// state is meant to read as "default text" (e.g. VFS rows, breadcrumb).
489 pub fn selectable_row(ui: &mut egui::Ui, active: bool, label: impl Into<String>) -> egui::Response {
490 let text = label.into();
491 let rich = if active {
492 egui::RichText::new(text.clone()).strong().color(theme::accent_blue())
493 } else {
494 egui::RichText::new(text.clone()).color(theme::text_primary())
495 };
496 selectable_truncating(ui, active, rich, &text)
497 }
498
499 /// Secondary selectable list row.
500 ///
501 /// Same active state as [`selectable_row`], but inactive state uses
502 /// `text_secondary`. Used for nested or de-emphasised lists (collections,
503 /// sort headers, secondary navigation).
504 pub fn selectable_row_secondary(
505 ui: &mut egui::Ui,
506 active: bool,
507 label: impl Into<String>,
508 ) -> egui::Response {
509 let text = label.into();
510 let rich = if active {
511 egui::RichText::new(text.clone()).strong().color(theme::accent_blue())
512 } else {
513 egui::RichText::new(text.clone()).color(theme::text_secondary())
514 };
515 selectable_truncating(ui, active, rich, &text)
516 }
517
518 /// Tag-tree row.
519 ///
520 /// Active state uses `accent_blue` *without* `strong()` weight — tag leaves
521 /// are dense and the bold weight reads too heavy. Inactive state is
522 /// `text_secondary`. Use [`selectable_row`] family for non-tag rows.
523 pub fn selectable_tag(ui: &mut egui::Ui, active: bool, label: impl Into<String>) -> egui::Response {
524 let text = label.into();
525 let rich = if active {
526 egui::RichText::new(text.clone()).color(theme::accent_blue())
527 } else {
528 egui::RichText::new(text.clone()).color(theme::text_secondary())
529 };
530 selectable_truncating(ui, active, rich, &text)
531 }
532
533 /// Render a wizard step indicator: a horizontal row of step labels with the
534 /// current step in `accent_blue` strong, completed steps in `text_secondary`,
535 /// and upcoming steps in `text_muted`. Use at the top of any multi-screen flow
536 /// (import wizard, export wizard, future onboarding tour). Steps are separated
537 /// by a middle-dot.
538 pub fn wizard_steps(ui: &mut egui::Ui, steps: &[&str], current: usize) {
539 ui.horizontal_wrapped(|ui| {
540 ui.spacing_mut().item_spacing.x = theme::space::SM;
541 for (i, step) in steps.iter().enumerate() {
542 let label = format!("{}. {}", i + 1, step);
543 let colored = if i == current {
544 egui::RichText::new(label).strong().color(theme::accent_blue())
545 } else if i < current {
546 egui::RichText::new(label).color(theme::text_secondary())
547 } else {
548 egui::RichText::new(label).color(theme::text_muted())
549 };
550 ui.label(colored);
551 if i + 1 < steps.len() {
552 ui.label(egui::RichText::new("\u{00B7}").color(theme::text_muted()));
553 }
554 }
555 });
556 ui.add_space(theme::space::MD);
557 ui.separator();
558 ui.add_space(theme::space::MD);
559 }
560
561 /// Render a numbered step label: `strong()` `accent_blue` "N." used to head
562 /// each line of a numbered onboarding list. Distinct from `selectable_row` —
563 /// these aren't clickable, they're just emphasised list markers.
564 pub fn step_number(ui: &mut egui::Ui, n: u32) {
565 ui.label(accent_strong(format!("{n}.")));
566 }
567
568 /// `RichText` builder for the canonical "this is the active thing" label:
569 /// `strong()` weight, `accent_blue` colour. Use for non-selectable labels that
570 /// signal the current context (e.g. the active collection name in the
571 /// breadcrumb). For selectable list rows, prefer [`selectable_row`].
572 pub fn accent_strong(label: impl Into<String>) -> egui::RichText {
573 egui::RichText::new(label.into()).strong().color(theme::accent_blue())
574 }
575
576 // --- Tag and classification widgets ------------------------------------------
577
578 /// Draw a colored classification badge.
579 pub fn classification_badge(ui: &mut egui::Ui, class: &str) {
580 let color = theme::classification_color(class);
581 let label = egui::RichText::new(class)
582 .small()
583 .color(color);
584 ui.label(label);
585 }
586
587 /// Draw a tag as a small colored chip.
588 ///
589 /// Uses custom rendering (`allocate_exact_size` + `painter()`) instead of a standard
590 /// egui widget because tag chips need a specific rounded-rect background, precise
591 /// font size (11pt), and hover highlighting that standard `Label` doesn't provide.
592 pub fn tag_chip(ui: &mut egui::Ui, tag: &str) -> egui::Response {
593 // Estimate width from character count * average glyph width + padding.
594 let (rect, response) = ui.allocate_exact_size(
595 egui::vec2(ui.ctx().fonts_mut(|f| f.glyph_width(&egui::TextStyle::Small.resolve(ui.style()), ' ')) * tag.len() as f32 + 16.0, 20.0),
596 egui::Sense::click(),
597 );
598
599 if ui.is_rect_visible(rect) {
600 let bg = if response.hovered() {
601 theme::bg_hover()
602 } else {
603 theme::bg_surface()
604 };
605 ui.painter().rect_filled(rect, 4.0, bg);
606 ui.painter().text(
607 rect.center(),
608 egui::Align2::CENTER_CENTER,
609 tag,
610 egui::FontId::proportional(11.0),
611 theme::accent_blue(),
612 );
613 }
614
615 response
616 }
617
618 /// Draw a tag chip with an X remove button. Returns true if X was clicked.
619 ///
620 /// When `hover_only_remove` is true, the X is rendered dimmed until the chip
621 /// (or the X itself) is hovered — reduces the accidental-click surface in
622 /// browse-heavy surfaces like the detail panel.
623 pub fn tag_chip_removable(ui: &mut egui::Ui, tag: &str, hover_only_remove: bool) -> bool {
624 // Pre-flight: compute the row's expected rect from the pending cursor so we
625 // can check hover before drawing — egui style is sticky once a widget is
626 // added, so we need to know the hover state up front.
627 let mut removed = false;
628 ui.horizontal(|ui| {
629 ui.spacing_mut().item_spacing.x = 2.0;
630 let label_resp = ui.label(
631 egui::RichText::new(tag)
632 .small()
633 .color(theme::accent_blue()),
634 );
635 let row_hovered = label_resp.hovered()
636 || ui.rect_contains_pointer(label_resp.rect.expand2(egui::vec2(20.0, 0.0)));
637 let x_color = if hover_only_remove && !row_hovered {
638 theme::text_muted()
639 } else {
640 theme::accent_red()
641 };
642 let btn = ui
643 .add(
644 egui::Button::new(egui::RichText::new("x").small().color(x_color))
645 .small(),
646 )
647 .on_hover_text("Remove tag");
648 if btn.clicked() {
649 removed = true;
650 }
651 });
652 removed
653 }
654
655 /// Format duration as mm:ss or just seconds for short durations.
656 pub fn format_duration(seconds: f64) -> String {
657 if seconds < 60.0 {
658 format!("{:.1}s", seconds)
659 } else {
660 let mins = (seconds / 60.0).floor() as u32;
661 let secs = seconds % 60.0;
662 format!("{}:{:04.1}", mins, secs)
663 }
664 }
665
666 /// Format BPM for display: show as integer when close to a whole number,
667 /// otherwise one decimal place. The 0.05 threshold avoids displaying "120.0"
668 /// for values like 119.97 that are effectively integer BPMs.
669 pub fn format_bpm(bpm: f64) -> String {
670 if (bpm - bpm.round()).abs() < 0.05 {
671 format!("{:.0}", bpm)
672 } else {
673 format!("{:.1}", bpm)
674 }
675 }
676