//! Reusable UI widgets: tag chips, classification badges, modals, and progress indicators. //! //! See `docs/design-system.md` for the canonical primitive set. Every shared //! visual recipe lives here; panel files compose these helpers and never //! reinvent rows, headers, modals, or buttons inline. //! //! ## Brand-rule glyph exceptions //! //! Per `docs/design-system.md`, user-facing strings should not contain emoji or //! checkmark glyphs. The following are *documented* exceptions: //! //! * Sort-direction arrows (`U+25B2`, `U+25BC`) in `file_list.rs::draw_sort_header` //! — functional column-header affordance, no word equivalent fits the layout. //! * Typography (em-dash `U+2014`, right-arrow `U+2192`, middle-dot `U+00B7`, //! bullet `U+2022`) in prose — these are punctuation, not emoji. //! //! Everything else (✓ ✖ ▶ ⏹ 🎵 🔍 🎹 🔁 ⚙ ↩ 💾 ⚠ ☰) was migrated to words //! during Batch 5 of the consolidation plan. use egui; use super::theme; // --- Modal scaffolds --------------------------------------------------------- /// Outcome of a modal that ends in a Confirm/Cancel action row. pub enum ConfirmOutcome { /// User hasn't acted yet this frame. None, /// User pressed the confirm button (or Enter, where applicable). Confirmed, /// User pressed Cancel. Cancelled, } /// Outcome of a single-field name modal (used by create/rename modals). pub enum NameModalOutcome { /// User hasn't acted yet this frame. None, /// User submitted the (trimmed) name. Submitted(String), /// User cancelled. Cancelled, } /// Paint a semi-opaque full-window scrim that swallows pointer input, so a modal /// drawn *after* this call is genuinely modal — the underlying file list no /// longer responds to clicks behind it. (The Escape handler already enforces /// keyboard dismissal priority; this closes the mouse-leakage gap, P2.) /// /// Call this immediately before drawing a modal window. Both the scrim and the /// modal live in `Order::Middle`; the scrim is created first so it sits below /// the modal but above the panels. pub fn modal_scrim(ctx: &egui::Context) { let screen = ctx.content_rect(); egui::Area::new(egui::Id::new("modal_scrim")) .order(egui::Order::Middle) .fixed_pos(screen.min) .show(ctx, |ui| { // Full-screen interactive surface consumes clicks/drags that miss // the modal, preventing them from reaching the live UI beneath. let resp = ui.allocate_response(screen.size(), egui::Sense::click_and_drag()); ui.painter() .rect_filled(screen, 0.0, egui::Color32::from_black_alpha(128)); resp }); } /// Canonical center-anchored, non-resizable modal scaffold. /// /// Replaces the inline /// `Window::new(title).collapsible(false).resizable(false).anchor(CENTER_CENTER, [0,0])` /// recipe repeated across `overlays.rs`. Pass `resizable: true` only for the /// bulk-rename modal — every other modal uses the default. pub fn modal_window( ctx: &egui::Context, title: &str, resizable: bool, default_width: Option, add_contents: impl FnOnce(&mut egui::Ui) -> R, ) -> Option { modal_window_with_open(ctx, title, None, resizable, default_width, add_contents) } /// Floating tool window scaffold. /// /// Distinct from [`modal_window`]: not anchored, resizable, collapsible, and /// user-dismissible via an `open` bool. Use for tool surfaces that the user /// keeps open alongside the main UI (the sample editor, the MIDI/instrument /// panel). Anything that demands focus and blocks the rest of the UI is a /// modal — use [`modal_window`] or [`confirm_modal`] instead. pub fn tool_window( ctx: &egui::Context, title: &str, open: &mut bool, default_width: f32, min_width: f32, add_contents: impl FnOnce(&mut egui::Ui) -> R, ) -> Option { egui::Window::new(title) .open(open) .resizable(true) .collapsible(true) .default_width(default_width) .min_width(min_width) .show(ctx, |ui| add_contents(ui)) .and_then(|r| r.inner) } /// Like `modal_window`, but with an optional `open` bool the user can toggle by /// clicking the close (×) chrome. Used by the help overlay. pub fn modal_window_with_open( ctx: &egui::Context, title: &str, open: Option<&mut bool>, resizable: bool, default_width: Option, add_contents: impl FnOnce(&mut egui::Ui) -> R, ) -> Option { let mut window = egui::Window::new(title) .collapsible(false) .resizable(resizable) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]); if let Some(o) = open { window = window.open(o); } if let Some(w) = default_width { window = window.default_width(w); } window.show(ctx, |ui| add_contents(ui)).map(|r| r.inner.unwrap()) } /// Render a `[Cancel] [primary]` action row at the bottom of a modal. /// /// Action ordering follows platform convention: Cancel on the left, primary on /// the right. The user's muscle memory from macOS/Windows native dialogs is /// "the affirmative button is on the right" — matching that avoids surprise /// clicks, especially in destructive modals where Delete-on-the-right-cursor /// would be catastrophic. The primary button is enabled when `can_confirm` is /// true. pub fn confirm_action_row( ui: &mut egui::Ui, confirm_label: &str, can_confirm: bool, danger: bool, ) -> ConfirmOutcome { let mut outcome = ConfirmOutcome::None; ui.horizontal(|ui| { if ui.button("Cancel").clicked() { outcome = ConfirmOutcome::Cancelled; } let label = if danger { egui::RichText::new(confirm_label).color(theme::accent_red()) } else { egui::RichText::new(confirm_label) }; if ui.add_enabled(can_confirm, egui::Button::new(label)).clicked() { outcome = ConfirmOutcome::Confirmed; } }); outcome } /// Spec for a destructive-confirm modal. pub struct ConfirmSpec<'a> { pub title: &'a str, pub prompt: &'a str, pub detail: Option<&'a str>, pub confirm_label: &'a str, pub danger: bool, } /// Render a confirm modal with a prompt, optional detail body, and a /// `[confirm] [Cancel]` action row. Returns the outcome for the caller to /// act on after the closure exits (egui borrow constraints). pub fn confirm_modal(ctx: &egui::Context, spec: &ConfirmSpec) -> ConfirmOutcome { let mut outcome = ConfirmOutcome::None; modal_window(ctx, spec.title, false, None, |ui| { ui.label(spec.prompt); if let Some(detail) = spec.detail { ui.add_space(theme::space::SM); ui.label( egui::RichText::new(detail) .small() .color(theme::text_secondary()), ); } ui.add_space(theme::space::LG); outcome = confirm_action_row(ui, spec.confirm_label, true, spec.danger); }); outcome } /// Single-field name modal: title, optional hint, label, text input, /// submit/cancel. Enter in the field submits. /// /// Autofocus: the text field grabs focus on first open (detected as "input is /// empty and nothing in the app currently has focus"). After the user clicks /// any widget the autofocus stops firing, so Cancel/Submit clicks aren't /// stolen back by the input. pub fn name_modal( ctx: &egui::Context, title: &str, hint: Option<&str>, label: &str, input: &mut String, submit_label: &str, error: Option<&str>, ) -> NameModalOutcome { let mut outcome = NameModalOutcome::None; modal_window(ctx, title, false, None, |ui| { if let Some(h) = hint { ui.label(egui::RichText::new(h).small().color(theme::text_muted())); ui.add_space(theme::space::SM); } ui.label(label); let resp = ui.text_edit_singleline(input); if input.is_empty() && ui.memory(|m| m.focused().is_none()) { resp.request_focus(); } // C-3: inline error below the input. Re-focus the input when an error // is surfaced so the user can edit and retry without re-clicking. if let Some(err) = error { ui.add_space(theme::space::XS); ui.label( egui::RichText::new(err) .small() .color(theme::accent_red()), ); if !resp.has_focus() { resp.request_focus(); } } if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { outcome = NameModalOutcome::Submitted(input.trim().to_string()); } ui.add_space(theme::space::MD); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { outcome = NameModalOutcome::Cancelled; } if ui.button(submit_label).clicked() { outcome = NameModalOutcome::Submitted(input.trim().to_string()); } }); }); outcome } // --- Empty state and banner -------------------------------------------------- /// CTA slot for an empty-state panel. pub struct EmptyStateCta<'a> { pub label: &'a str, pub tooltip: Option<&'a str>, } /// Centered empty-state panel. /// /// Renders a centred column with a heading (`text_secondary`, 20 px), an /// optional body (`text_muted`), and an optional CTA button. The vertical /// offset is 15% of the available height to keep the column visually anchored. /// Returns `true` if the CTA was clicked (always `false` when no CTA). pub fn empty_state( ui: &mut egui::Ui, heading: &str, body: Option<&str>, cta: Option, ) -> bool { let mut clicked = false; ui.vertical_centered(|ui| { ui.add_space(ui.available_height() * 0.15); ui.label( egui::RichText::new(heading) .size(20.0) .color(theme::text_secondary()), ); if let Some(body_text) = body { ui.add_space(theme::space::MD); ui.label(egui::RichText::new(body_text).color(theme::text_muted())); } if let Some(cta) = cta { ui.add_space(theme::space::LG); let btn = secondary_button(ui, cta.label); let btn = if let Some(t) = cta.tooltip { btn.on_hover_text(t) } else { btn }; if btn.clicked() { clicked = true; } } }); clicked } /// Format a byte count as B / KB / MB / GB. Three other call sites in this /// crate define their own private copies — new callers should reach for this /// one; the legacy copies can be migrated opportunistically. pub fn format_bytes(bytes: u64) -> String { if bytes < 1024 { format!("{bytes} B") } else if bytes < 1024 * 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else if bytes < 1024 * 1024 * 1024 { format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) } else { format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } /// Inline informational banner: rounded frame, `bg_tertiary` fill, body text /// in `text_secondary`. Used for one-time tips and unobtrusive panel notices. pub fn info_banner(ui: &mut egui::Ui, body: &str) { egui::Frame::new() .fill(theme::bg_tertiary()) .corner_radius(egui::CornerRadius::same(4)) .inner_margin(egui::Margin::same(8)) .show(ui, |ui| { ui.label( egui::RichText::new(body) .small() .color(theme::text_secondary()), ); }); } /// Inline warning banner: same shape as `info_banner` but body text in /// `accent_yellow` at body weight (not `.small()`). Used for actions whose /// consequences are important enough that the weak/small footnote style would /// under-sell them — currently the irrecoverable encryption-password setup. pub fn warning_banner(ui: &mut egui::Ui, body: &str) { egui::Frame::new() .fill(theme::bg_tertiary()) .corner_radius(egui::CornerRadius::same(4)) .inner_margin(egui::Margin::same(8)) .show(ui, |ui| { ui.label(egui::RichText::new(body).color(theme::accent_yellow())); }); } // --- Toolbar toggle and segmented pills -------------------------------------- /// Toolbar toggle button. /// /// Active state colours the label `accent_blue`; inactive state colours it /// `text_muted`. Returns true on click. Optional `count` renders a parenthesised /// suffix (e.g. "Filters (3)") for active-with-count toolbar buttons. pub fn toolbar_toggle( ui: &mut egui::Ui, label: &str, active: bool, tooltip: &str, count: Option, ) -> bool { let text = match count { Some(n) if n > 0 => format!("{label} ({n})"), _ => label.to_string(), }; let colour = if active { theme::accent_blue() } else { theme::text_muted() }; ui.button(egui::RichText::new(text).color(colour)) .on_hover_text(tooltip) .clicked() } /// Mutually-exclusive segmented pill control. /// /// `options` is a list of `(value, label, tooltip)` triples. Returns /// `Some(value)` if a non-current option was clicked, `None` otherwise. /// Caller assigns the returned value to its state. pub fn toggle_pills( ui: &mut egui::Ui, current: &T, options: &[(T, &str, &str)], ) -> Option { let mut chosen = None; ui.horizontal(|ui| { for (value, label, tooltip) in options { let is_active = value == current; if ui .selectable_label(is_active, *label) .on_hover_text(*tooltip) .clicked() && !is_active { chosen = Some(value.clone()); } } }); chosen } // --- Button hierarchy -------------------------------------------------------- // // Three button weights: // - `primary_button` — strong label; the single primary action in a row. // - `secondary_button` — default weight; cancel and peer actions. // - `danger_button` — `accent_red` label; destructive primary actions. // // `confirm_action_row` (above) composes these for the standard modal pattern; // reach for these directly only when building a non-modal action row. /// Primary action button. Strong label weight. pub fn primary_button(ui: &mut egui::Ui, label: &str) -> egui::Response { ui.add(egui::Button::new(egui::RichText::new(label).strong())) } /// Secondary / peer action button. Default weight. Use for Cancel and for any /// action that isn't the primary focus of the row. pub fn secondary_button(ui: &mut egui::Ui, label: &str) -> egui::Response { ui.button(label) } /// Destructive primary action. Label rendered in `accent_red` so the user /// reads the consequence before clicking. Used for Delete, Purge, Discard. pub fn danger_button(ui: &mut egui::Ui, label: &str) -> egui::Response { ui.add(egui::Button::new(egui::RichText::new(label).color(theme::accent_red()))) } /// Destructive action that may be disabled (e.g. Delete-vault when only one /// vault remains). Same red colouring as [`danger_button`]; routes through /// `add_enabled` so disabled state and hover text work consistently. pub fn danger_button_enabled(ui: &mut egui::Ui, label: &str, enabled: bool) -> egui::Response { ui.add_enabled( enabled, egui::Button::new(egui::RichText::new(label).color(theme::accent_red())), ) } /// Small destructive action (per-row Remove/Delete affordances, context-menu /// items inside a tighter layout). Same colouring as [`danger_button`]. pub fn danger_small_button(ui: &mut egui::Ui, label: &str) -> egui::Response { ui.add(egui::Button::new(egui::RichText::new(label).color(theme::accent_red())).small()) } // --- Section headers --------------------------------------------------------- /// Panel section heading: strong, `text_secondary` label, separator, small gap. pub fn section_header(ui: &mut egui::Ui, label: &str) { ui.label(egui::RichText::new(label).strong().color(theme::text_secondary())); ui.separator(); ui.add_space(theme::space::SM); } /// Sub-block label inside an already-headed section. No separator, no gap. pub fn subsection_label(ui: &mut egui::Ui, label: &str) { ui.label(egui::RichText::new(label).strong().color(theme::text_secondary())); } /// Filter-panel collapsing section. /// /// Header is suffixed with `" *"` when `active` is true (visual indicator of an /// in-effect filter); the section is `default_open` when active so the user /// sees what's filtering them. pub fn filter_section( ui: &mut egui::Ui, label: &str, active: bool, add_contents: impl FnOnce(&mut egui::Ui) -> R, ) { let header = if active { format!("{label} *") } else { label.to_string() }; egui::CollapsingHeader::new(header) .default_open(active) .show(ui, |ui| { add_contents(ui); }); } // --- Selectable rows --------------------------------------------------------- /// Render a selectable label that truncates with an ellipsis instead of /// expanding the row (which would force the sidebar wider than its clip range), /// and shows the full `full_text` on hover so a clipped name is still readable. fn selectable_truncating( ui: &mut egui::Ui, active: bool, rich: egui::RichText, full_text: &str, ) -> egui::Response { let prev = ui.style().wrap_mode; ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); let resp = ui.selectable_label(active, rich); ui.style_mut().wrap_mode = prev; resp.on_hover_text(full_text) } /// Primary selectable list row. /// /// Active state renders the label as `strong()` + `accent_blue`. Inactive state /// renders as `text_primary`. Used for top-level list items where the inactive /// state is meant to read as "default text" (e.g. VFS rows, breadcrumb). pub fn selectable_row(ui: &mut egui::Ui, active: bool, label: impl Into) -> egui::Response { let text = label.into(); let rich = if active { egui::RichText::new(text.clone()).strong().color(theme::accent_blue()) } else { egui::RichText::new(text.clone()).color(theme::text_primary()) }; selectable_truncating(ui, active, rich, &text) } /// Secondary selectable list row. /// /// Same active state as [`selectable_row`], but inactive state uses /// `text_secondary`. Used for nested or de-emphasised lists (collections, /// sort headers, secondary navigation). pub fn selectable_row_secondary( ui: &mut egui::Ui, active: bool, label: impl Into, ) -> egui::Response { let text = label.into(); let rich = if active { egui::RichText::new(text.clone()).strong().color(theme::accent_blue()) } else { egui::RichText::new(text.clone()).color(theme::text_secondary()) }; selectable_truncating(ui, active, rich, &text) } /// Tag-tree row. /// /// Active state uses `accent_blue` *without* `strong()` weight — tag leaves /// are dense and the bold weight reads too heavy. Inactive state is /// `text_secondary`. Use [`selectable_row`] family for non-tag rows. pub fn selectable_tag(ui: &mut egui::Ui, active: bool, label: impl Into) -> egui::Response { let text = label.into(); let rich = if active { egui::RichText::new(text.clone()).color(theme::accent_blue()) } else { egui::RichText::new(text.clone()).color(theme::text_secondary()) }; selectable_truncating(ui, active, rich, &text) } /// Render a wizard step indicator: a horizontal row of step labels with the /// current step in `accent_blue` strong, completed steps in `text_secondary`, /// and upcoming steps in `text_muted`. Use at the top of any multi-screen flow /// (import wizard, export wizard, future onboarding tour). Steps are separated /// by a middle-dot. pub fn wizard_steps(ui: &mut egui::Ui, steps: &[&str], current: usize) { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = theme::space::SM; for (i, step) in steps.iter().enumerate() { let label = format!("{}. {}", i + 1, step); let colored = if i == current { egui::RichText::new(label).strong().color(theme::accent_blue()) } else if i < current { egui::RichText::new(label).color(theme::text_secondary()) } else { egui::RichText::new(label).color(theme::text_muted()) }; ui.label(colored); if i + 1 < steps.len() { ui.label(egui::RichText::new("\u{00B7}").color(theme::text_muted())); } } }); ui.add_space(theme::space::MD); ui.separator(); ui.add_space(theme::space::MD); } /// Render a numbered step label: `strong()` `accent_blue` "N." used to head /// each line of a numbered onboarding list. Distinct from `selectable_row` — /// these aren't clickable, they're just emphasised list markers. pub fn step_number(ui: &mut egui::Ui, n: u32) { ui.label(accent_strong(format!("{n}."))); } /// `RichText` builder for the canonical "this is the active thing" label: /// `strong()` weight, `accent_blue` colour. Use for non-selectable labels that /// signal the current context (e.g. the active collection name in the /// breadcrumb). For selectable list rows, prefer [`selectable_row`]. pub fn accent_strong(label: impl Into) -> egui::RichText { egui::RichText::new(label.into()).strong().color(theme::accent_blue()) } // --- Tag and classification widgets ------------------------------------------ /// Draw a colored classification badge. pub fn classification_badge(ui: &mut egui::Ui, class: &str) { let color = theme::classification_color(class); let label = egui::RichText::new(class) .small() .color(color); ui.label(label); } /// Draw a tag as a small colored chip. /// /// Uses custom rendering (`allocate_exact_size` + `painter()`) instead of a standard /// egui widget because tag chips need a specific rounded-rect background, precise /// font size (11pt), and hover highlighting that standard `Label` doesn't provide. pub fn tag_chip(ui: &mut egui::Ui, tag: &str) -> egui::Response { // Estimate width from character count * average glyph width + padding. let (rect, response) = ui.allocate_exact_size( egui::vec2(ui.ctx().fonts_mut(|f| f.glyph_width(&egui::TextStyle::Small.resolve(ui.style()), ' ')) * tag.len() as f32 + 16.0, 20.0), egui::Sense::click(), ); if ui.is_rect_visible(rect) { let bg = if response.hovered() { theme::bg_hover() } else { theme::bg_surface() }; ui.painter().rect_filled(rect, 4.0, bg); ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, tag, egui::FontId::proportional(11.0), theme::accent_blue(), ); } response } /// Draw a tag chip with an X remove button. Returns true if X was clicked. /// /// When `hover_only_remove` is true, the X is rendered dimmed until the chip /// (or the X itself) is hovered — reduces the accidental-click surface in /// browse-heavy surfaces like the detail panel. pub fn tag_chip_removable(ui: &mut egui::Ui, tag: &str, hover_only_remove: bool) -> bool { // Pre-flight: compute the row's expected rect from the pending cursor so we // can check hover before drawing — egui style is sticky once a widget is // added, so we need to know the hover state up front. let mut removed = false; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let label_resp = ui.label( egui::RichText::new(tag) .small() .color(theme::accent_blue()), ); let row_hovered = label_resp.hovered() || ui.rect_contains_pointer(label_resp.rect.expand2(egui::vec2(20.0, 0.0))); let x_color = if hover_only_remove && !row_hovered { theme::text_muted() } else { theme::accent_red() }; let btn = ui .add( egui::Button::new(egui::RichText::new("x").small().color(x_color)) .small(), ) .on_hover_text("Remove tag"); if btn.clicked() { removed = true; } }); removed } /// Format duration as mm:ss or just seconds for short durations. pub fn format_duration(seconds: f64) -> String { if seconds < 60.0 { format!("{:.1}s", seconds) } else { let mins = (seconds / 60.0).floor() as u32; let secs = seconds % 60.0; format!("{}:{:04.1}", mins, secs) } } /// Format BPM for display: show as integer when close to a whole number, /// otherwise one decimal place. The 0.05 threshold avoids displaying "120.0" /// for values like 119.97 that are effectively integer BPMs. pub fn format_bpm(bpm: f64) -> String { if (bpm - bpm.round()).abs() < 0.05 { format!("{:.0}", bpm) } else { format!("{:.1}", bpm) } }