max / audiofiles
8 files changed,
+170 insertions,
-32 deletions
| @@ -92,6 +92,12 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>( | |||
| 92 | 92 | }; | |
| 93 | 93 | let mut mix_buf: Vec<f32> = vec![0.0; initial_frames.saturating_mul(channels.max(1))]; | |
| 94 | 94 | ||
| 95 | + | // Clone for the error callback before `shared` is moved into the data | |
| 96 | + | // callback. A stream fault (e.g. the device was unplugged) flips this | |
| 97 | + | // lock-free flag; the app loop polls it and rebuilds the stream so preview | |
| 98 | + | // recovers instead of going permanently silent. | |
| 99 | + | let err_shared = Arc::clone(&shared); | |
| 100 | + | ||
| 95 | 101 | let stream = device | |
| 96 | 102 | .build_output_stream( | |
| 97 | 103 | config, | |
| @@ -123,8 +129,13 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>( | |||
| 123 | 129 | *out = T::from_sample(mix.clamp(-1.0, 1.0)); | |
| 124 | 130 | } | |
| 125 | 131 | }, | |
| 126 | - | |err| { | |
| 132 | + | move |err| { | |
| 127 | 133 | tracing::error!("audio stream error: {err}"); | |
| 134 | + | // Signal the app loop to rebuild the stream. Lock-free store only | |
| 135 | + | // — this runs in cpal's error context, never take a lock here. | |
| 136 | + | err_shared | |
| 137 | + | .device_lost | |
| 138 | + | .store(true, std::sync::atomic::Ordering::Relaxed); | |
| 128 | 139 | }, | |
| 129 | 140 | None, | |
| 130 | 141 | )?; |
| @@ -103,8 +103,9 @@ fn main() -> eframe::Result<()> { | |||
| 103 | 103 | ||
| 104 | 104 | let shared = Arc::new(SharedState::new()); | |
| 105 | 105 | ||
| 106 | - | // Start cpal audio output stream | |
| 107 | - | let _stream = match audio::start_output_stream(shared.clone()) { | |
| 106 | + | // Start cpal audio output stream. Ownership moves into the app so it can be | |
| 107 | + | // rebuilt on device loss (see AudioFilesApp::poll_audio_device). | |
| 108 | + | let initial_stream = match audio::start_output_stream(shared.clone()) { | |
| 108 | 109 | Ok((stream, device_rate, device_name)) => { | |
| 109 | 110 | shared.device_sample_rate.store(device_rate, std::sync::atomic::Ordering::Relaxed); | |
| 110 | 111 | *shared.preview_device_name.lock() = Some(device_name); | |
| @@ -148,6 +149,7 @@ fn main() -> eframe::Result<()> { | |||
| 148 | 149 | audiofiles_browser::ui::theme::setup_fonts(&cc.egui_ctx); | |
| 149 | 150 | Ok(Box::new(AudioFilesApp::new( | |
| 150 | 151 | config_dir, shared, app_tray, update_checker, prefs, runtime, gtk_ok, | |
| 152 | + | initial_stream, | |
| 151 | 153 | ))) | |
| 152 | 154 | }), | |
| 153 | 155 | ) | |
| @@ -308,6 +310,13 @@ struct AudioFilesApp { | |||
| 308 | 310 | #[cfg_attr(not(target_os = "linux"), allow(dead_code))] | |
| 309 | 311 | gtk_ok: bool, | |
| 310 | 312 | _runtime: tokio::runtime::Runtime, | |
| 313 | + | /// cpal output stream. Kept alive here (dropping it stops audio) and rebuilt | |
| 314 | + | /// by `poll_audio_device` when `SharedState::device_lost` fires. `None` when | |
| 315 | + | /// no output device is available. | |
| 316 | + | audio_stream: Option<cpal::Stream>, | |
| 317 | + | /// Throttles audio-stream rebuild attempts after a device fault so a | |
| 318 | + | /// persistently-missing device doesn't rebuild every frame. | |
| 319 | + | audio_retry_at: Option<std::time::Instant>, | |
| 311 | 320 | ||
| 312 | 321 | // ── Vault state ── | |
| 313 | 322 | vault_registry: Option<VaultRegistry>, | |
| @@ -325,6 +334,7 @@ struct AudioFilesApp { | |||
| 325 | 334 | } | |
| 326 | 335 | ||
| 327 | 336 | impl AudioFilesApp { | |
| 337 | + | #[allow(clippy::too_many_arguments)] | |
| 328 | 338 | fn new( | |
| 329 | 339 | config_dir: PathBuf, | |
| 330 | 340 | shared: Arc<SharedState>, | |
| @@ -333,6 +343,7 @@ impl AudioFilesApp { | |||
| 333 | 343 | prefs: preferences::Preferences, | |
| 334 | 344 | runtime: tokio::runtime::Runtime, | |
| 335 | 345 | gtk_ok: bool, | |
| 346 | + | audio_stream: Option<cpal::Stream>, | |
| 336 | 347 | ) -> Self { | |
| 337 | 348 | let _ = std::fs::create_dir_all(&config_dir); | |
| 338 | 349 | let default_vault = vault::default_vault_path(); | |
| @@ -413,6 +424,8 @@ impl AudioFilesApp { | |||
| 413 | 424 | midi_connection: None, | |
| 414 | 425 | gtk_ok, | |
| 415 | 426 | _runtime: runtime, | |
| 427 | + | audio_stream, | |
| 428 | + | audio_retry_at: None, | |
| 416 | 429 | vault_registry, | |
| 417 | 430 | vault_setup_path: None, | |
| 418 | 431 | vault_setup_name: "Library".to_string(), | |
| @@ -429,6 +442,44 @@ impl AudioFilesApp { | |||
| 429 | 442 | app | |
| 430 | 443 | } | |
| 431 | 444 | ||
| 445 | + | /// Rebuild the cpal output stream when the device faults (unplug, format | |
| 446 | + | /// change). `SharedState::device_lost` is set from the audio error callback; | |
| 447 | + | /// here we drop the dead stream and retry, throttled to ~2s so a | |
| 448 | + | /// persistently-missing device doesn't rebuild every frame. On success the | |
| 449 | + | /// footer's device name and sample rate refresh; on failure preview stays | |
| 450 | + | /// silent until the next retry, but the app never wedges. | |
| 451 | + | fn poll_audio_device(&mut self) { | |
| 452 | + | use std::sync::atomic::Ordering; | |
| 453 | + | if !self.shared.device_lost.load(Ordering::Relaxed) { | |
| 454 | + | return; | |
| 455 | + | } | |
| 456 | + | let now = std::time::Instant::now(); | |
| 457 | + | if let Some(next) = self.audio_retry_at | |
| 458 | + | && now < next | |
| 459 | + | { | |
| 460 | + | return; | |
| 461 | + | } | |
| 462 | + | self.audio_retry_at = Some(now + std::time::Duration::from_secs(2)); | |
| 463 | + | ||
| 464 | + | // Drop the dead stream before opening a new one (a device can't be | |
| 465 | + | // opened twice). | |
| 466 | + | self.audio_stream = None; | |
| 467 | + | match audio::start_output_stream(self.shared.clone()) { | |
| 468 | + | Ok((stream, rate, name)) => { | |
| 469 | + | self.shared.device_sample_rate.store(rate, Ordering::Relaxed); | |
| 470 | + | *self.shared.preview_device_name.lock() = Some(name); | |
| 471 | + | self.shared.device_lost.store(false, Ordering::Relaxed); | |
| 472 | + | self.audio_stream = Some(stream); | |
| 473 | + | tracing::info!("audio output stream rebuilt after device change"); | |
| 474 | + | } | |
| 475 | + | Err(e) => { | |
| 476 | + | *self.shared.preview_device_name.lock() = None; | |
| 477 | + | tracing::warn!("audio output still unavailable, will retry: {e}"); | |
| 478 | + | // device_lost stays set; retry on the next throttled tick. | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | ||
| 432 | 483 | /// Initialise the browser after successful activation. | |
| 433 | 484 | fn activate_browser(&mut self) { | |
| 434 | 485 | let _ = std::fs::create_dir_all(&self.data_dir); | |
| @@ -512,6 +563,9 @@ impl eframe::App for AudioFilesApp { | |||
| 512 | 563 | } | |
| 513 | 564 | } | |
| 514 | 565 | ||
| 566 | + | // Rebuild the audio output stream if the device faulted (e.g. unplugged). | |
| 567 | + | self.poll_audio_device(); | |
| 568 | + | ||
| 515 | 569 | // Cmd/Ctrl+I toggles the About modal. Works on every screen so a | |
| 516 | 570 | // confused user always has one keystroke to "who made this". | |
| 517 | 571 | ctx.input_mut(|i| { |
| @@ -271,16 +271,57 @@ pub fn start_streaming_decode( | |||
| 271 | 271 | path = %path.display(), | |
| 272 | 272 | "symphonia probe failed for streaming, using hound fallback: {e}" | |
| 273 | 273 | ); | |
| 274 | - | // For WAV fallback, just do a full decode (WAV is PCM, fast to read) | |
| 275 | - | let buf = decode_wav_hound_stereo(path)?; | |
| 276 | - | let mut guard = shared.preview.lock(); | |
| 277 | - | let total_frames = buf.data.len() / 2; | |
| 278 | - | guard.buffer = Some(buf); | |
| 279 | - | guard.position_frac = 0.0; | |
| 280 | - | guard.playing = true; | |
| 281 | - | guard.streaming = false; | |
| 282 | - | guard.decoded_frames = total_frames; | |
| 283 | - | guard.total_frames_estimate = Some(total_frames); | |
| 274 | + | // Full-decode fallback for WAVs Symphonia rejects. This MUST run | |
| 275 | + | // off the caller thread: start_streaming_decode is invoked from the | |
| 276 | + | // egui frame thread, and decoding a multi-minute WAV inline froze | |
| 277 | + | // the UI — defeating the whole point of the streaming path. Hand it | |
| 278 | + | // to the same detached, generation-cancelled, panic-isolated worker | |
| 279 | + | // the Symphonia path uses. The buffer stays None (audio callback | |
| 280 | + | // renders silence) until the decode lands. | |
| 281 | + | let my_generation = | |
| 282 | + | shared.decode_generation.fetch_add(1, Ordering::AcqRel) + 1; | |
| 283 | + | { | |
| 284 | + | let mut guard = shared.preview.lock(); | |
| 285 | + | guard.buffer = None; | |
| 286 | + | guard.position_frac = 0.0; | |
| 287 | + | guard.playing = false; | |
| 288 | + | guard.streaming = true; | |
| 289 | + | guard.decoded_frames = 0; | |
| 290 | + | guard.total_frames_estimate = None; | |
| 291 | + | } | |
| 292 | + | let shared = std::sync::Arc::clone(shared); | |
| 293 | + | let path = path.to_path_buf(); | |
| 294 | + | let _ = audiofiles_core::worker_runtime::spawn_detached( | |
| 295 | + | "af-preview-wav-fallback", | |
| 296 | + | move || { | |
| 297 | + | let _lease = StreamingLease { | |
| 298 | + | shared: std::sync::Arc::clone(&shared), | |
| 299 | + | }; | |
| 300 | + | // Superseded before we started. | |
| 301 | + | if shared.decode_generation.load(Ordering::Acquire) != my_generation { | |
| 302 | + | return; | |
| 303 | + | } | |
| 304 | + | let buf = match decode_wav_hound_stereo(&path) { | |
| 305 | + | Ok(b) => b, | |
| 306 | + | Err(e) => { | |
| 307 | + | warn!(path = %path.display(), "hound fallback decode failed: {e}"); | |
| 308 | + | return; // lease clears `streaming` | |
| 309 | + | } | |
| 310 | + | }; | |
| 311 | + | // A newer preview started while we decoded — drop this result. | |
| 312 | + | if shared.decode_generation.load(Ordering::Acquire) != my_generation { | |
| 313 | + | return; | |
| 314 | + | } | |
| 315 | + | let total_frames = buf.data.len() / 2; | |
| 316 | + | let mut guard = shared.preview.lock(); | |
| 317 | + | guard.buffer = Some(buf); | |
| 318 | + | guard.position_frac = 0.0; | |
| 319 | + | guard.playing = true; | |
| 320 | + | guard.streaming = false; | |
| 321 | + | guard.decoded_frames = total_frames; | |
| 322 | + | guard.total_frames_estimate = Some(total_frames); | |
| 323 | + | }, | |
| 324 | + | ); | |
| 284 | 325 | return Ok(()); | |
| 285 | 326 | } | |
| 286 | 327 | return Err(PreviewError::Probe(e.to_string())); |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | use std::fs; | |
| 7 | 7 | use std::path::{Path, PathBuf}; | |
| 8 | 8 | use std::sync::Arc; | |
| 9 | - | use std::sync::atomic::{AtomicU32, AtomicU64}; | |
| 9 | + | use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64}; | |
| 10 | 10 | use std::time::Instant; | |
| 11 | 11 | ||
| 12 | 12 | use tracing::{error, warn}; | |
| @@ -96,6 +96,11 @@ pub struct SharedState { | |||
| 96 | 96 | /// the footer to surface "Preview: <device>" for diagnostic visibility. | |
| 97 | 97 | /// `None` means no device is available (audio output failed to start). | |
| 98 | 98 | pub preview_device_name: Mutex<Option<String>>, | |
| 99 | + | /// Set by the cpal error callback when the output stream faults (e.g. the | |
| 100 | + | /// device was unplugged). The app loop polls this and rebuilds the stream, | |
| 101 | + | /// so preview recovers instead of going permanently silent. Written from the | |
| 102 | + | /// real-time audio thread, so it must stay lock-free (an atomic, never a Mutex). | |
| 103 | + | pub device_lost: AtomicBool, | |
| 99 | 104 | } | |
| 100 | 105 | ||
| 101 | 106 | impl Default for SharedState { | |
| @@ -107,6 +112,7 @@ impl Default for SharedState { | |||
| 107 | 112 | midi_recent_notes: Mutex::new(Vec::new()), | |
| 108 | 113 | decode_generation: AtomicU64::new(0), | |
| 109 | 114 | preview_device_name: Mutex::new(None), | |
| 115 | + | device_lost: AtomicBool::new(false), | |
| 110 | 116 | } | |
| 111 | 117 | } | |
| 112 | 118 | } |
| @@ -221,8 +221,11 @@ fn draw_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 221 | 221 | return; | |
| 222 | 222 | } | |
| 223 | 223 | ||
| 224 | - | // Collect actions to apply after the immutable iteration over the cached list. | |
| 225 | - | let rules = state.classifier.rules.clone(); | |
| 224 | + | // Move the list out to iterate it, then move it back before applying the | |
| 225 | + | // deferred actions (a zero-copy swap replacing a per-frame deep clone of the | |
| 226 | + | // whole rules Vec). The loop body reads only the local list + `ui`, never | |
| 227 | + | // `state.classifier.rules`, so the temporary empty field is never observed. | |
| 228 | + | let rules = std::mem::take(&mut state.classifier.rules); | |
| 226 | 229 | let count = rules.len(); | |
| 227 | 230 | let mut toggle: Option<(String, bool)> = None; | |
| 228 | 231 | let mut edit: Option<usize> = None; | |
| @@ -264,11 +267,14 @@ fn draw_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 264 | 267 | }); | |
| 265 | 268 | } | |
| 266 | 269 | ||
| 270 | + | // Restore the list before applying deferred actions (which mutate it). | |
| 271 | + | state.classifier.rules = rules; | |
| 272 | + | ||
| 267 | 273 | if let Some((id, enabled)) = toggle { | |
| 268 | 274 | state.classifier_toggle_rule(&id, enabled); | |
| 269 | 275 | } | |
| 270 | 276 | if let Some(i) = edit { | |
| 271 | - | let rule = rules[i].clone(); | |
| 277 | + | let rule = state.classifier.rules[i].clone(); | |
| 272 | 278 | state.classifier_edit_rule(&rule); | |
| 273 | 279 | } | |
| 274 | 280 | if let Some(id) = delete { | |
| @@ -499,7 +505,7 @@ fn draw_autotag_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 499 | 505 | ); | |
| 500 | 506 | state.ensure_policies_loaded(); | |
| 501 | 507 | ||
| 502 | - | let policies = state.classifier.policies.clone(); | |
| 508 | + | let policies = std::mem::take(&mut state.classifier.policies); | |
| 503 | 509 | let mut changed: Option<(String, f64, f64)> = None; | |
| 504 | 510 | for (tag, policy) in &policies { | |
| 505 | 511 | let mut review = policy.review_threshold as f32; | |
| @@ -520,6 +526,7 @@ fn draw_autotag_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 520 | 526 | } | |
| 521 | 527 | }); | |
| 522 | 528 | } | |
| 529 | + | state.classifier.policies = policies; | |
| 523 | 530 | if let Some((tag, review, auto)) = changed { | |
| 524 | 531 | state.classifier_set_policy(&tag, review, auto); | |
| 525 | 532 | state.refresh_policies(); | |
| @@ -640,7 +647,7 @@ fn draw_cluster_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 640 | 647 | } | |
| 641 | 648 | ui.add_space(theme::space::SM); | |
| 642 | 649 | ||
| 643 | - | let clusters = state.classifier.clusters.clone(); | |
| 650 | + | let clusters = std::mem::take(&mut state.classifier.clusters); | |
| 644 | 651 | let mut play: Option<String> = None; | |
| 645 | 652 | let mut apply: Option<usize> = None; | |
| 646 | 653 | for (i, cluster) in clusters.iter().enumerate() { | |
| @@ -665,6 +672,7 @@ fn draw_cluster_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 665 | 672 | } | |
| 666 | 673 | }); | |
| 667 | 674 | } | |
| 675 | + | state.classifier.clusters = clusters; | |
| 668 | 676 | if let Some(hash) = play { | |
| 669 | 677 | state.trigger_preview(&hash); | |
| 670 | 678 | } | |
| @@ -723,7 +731,7 @@ fn draw_folder_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 723 | 731 | } | |
| 724 | 732 | ui.add_space(theme::space::SM); | |
| 725 | 733 | ||
| 726 | - | let labels = state.classifier.folder_labels.clone(); | |
| 734 | + | let labels = std::mem::take(&mut state.classifier.folder_labels); | |
| 727 | 735 | let mut apply: Option<usize> = None; | |
| 728 | 736 | for (i, label) in labels.iter().enumerate() { | |
| 729 | 737 | ui.horizontal(|ui| { | |
| @@ -743,6 +751,7 @@ fn draw_folder_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 743 | 751 | } | |
| 744 | 752 | }); | |
| 745 | 753 | } | |
| 754 | + | state.classifier.folder_labels = labels; | |
| 746 | 755 | if let Some(i) = apply { | |
| 747 | 756 | state.apply_folder_label(i); | |
| 748 | 757 | } | |
| @@ -839,8 +848,9 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 839 | 848 | } | |
| 840 | 849 | ||
| 841 | 850 | // ── Imported layers ── | |
| 842 | - | let layers = state.classifier.layers.clone(); | |
| 851 | + | let layers = std::mem::take(&mut state.classifier.layers); | |
| 843 | 852 | if layers.is_empty() { | |
| 853 | + | state.classifier.layers = layers; // restore (empty) — keep field consistent | |
| 844 | 854 | return; | |
| 845 | 855 | } | |
| 846 | 856 | ui.add_space(theme::space::MD); | |
| @@ -929,6 +939,7 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 929 | 939 | ); | |
| 930 | 940 | } | |
| 931 | 941 | } | |
| 942 | + | state.classifier.layers = layers; | |
| 932 | 943 | if let Some(p) = set_pending { | |
| 933 | 944 | state.classifier.pending_layer_remove = p; | |
| 934 | 945 | } |
| @@ -130,10 +130,10 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 130 | 130 | .map(|item| item.name.as_str()) | |
| 131 | 131 | .collect(); | |
| 132 | 132 | if !over_limit.is_empty() { | |
| 133 | - | let msg = if over_limit.len() == 1 { | |
| 133 | + | let msg = if let [only] = over_limit.as_slice() { | |
| 134 | 134 | format!( | |
| 135 | 135 | "\"{}\" may exceed device file size limit ({:.0} MB)", | |
| 136 | - | over_limit[0], | |
| 136 | + | only, | |
| 137 | 137 | max_bytes as f64 / 1_048_576.0, | |
| 138 | 138 | ) | |
| 139 | 139 | } else { |
| @@ -207,20 +207,32 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 207 | 207 | } | |
| 208 | 208 | ||
| 209 | 209 | // Inline rename | |
| 210 | - | if let Some((ref rename_path, _)) = state.settings.rename_target.clone() { | |
| 210 | + | if let Some((rename_path, _)) = state.settings.rename_target.clone() { | |
| 211 | 211 | ui.separator(); | |
| 212 | 212 | ui.horizontal(|ui| { | |
| 213 | 213 | ui.label("New name:"); | |
| 214 | - | let resp = ui.text_edit_singleline( | |
| 215 | - | &mut state.settings.rename_target.as_mut().unwrap().1, | |
| 216 | - | ); | |
| 217 | - | if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { | |
| 218 | - | let new_name = state.settings.rename_target.as_ref().unwrap().1.clone(); | |
| 219 | - | if !new_name.trim().is_empty() { | |
| 214 | + | // Borrow the editable name only for the text field; the mutable | |
| 215 | + | // borrow ends before we may clear rename_target below (avoids | |
| 216 | + | // the previous `as_mut().unwrap()` / `as_ref().unwrap()` pair, | |
| 217 | + | // which panicked-per-frame if the option was cleared mid-frame). | |
| 218 | + | let mut submit = false; | |
| 219 | + | if let Some((_, name)) = state.settings.rename_target.as_mut() { | |
| 220 | + | let resp = ui.text_edit_singleline(name); | |
| 221 | + | submit = resp.lost_focus() | |
| 222 | + | && ui.input(|i| i.key_pressed(egui::Key::Enter)); | |
| 223 | + | } | |
| 224 | + | if submit { | |
| 225 | + | let new_name = state | |
| 226 | + | .settings | |
| 227 | + | .rename_target | |
| 228 | + | .as_ref() | |
| 229 | + | .map(|(_, n)| n.trim().to_string()) | |
| 230 | + | .unwrap_or_default(); | |
| 231 | + | if !new_name.is_empty() { | |
| 220 | 232 | state.settings.pending_action = | |
| 221 | 233 | Some(crate::state::VaultAction::RenameVault { | |
| 222 | 234 | path: rename_path.clone(), | |
| 223 | - | new_name: new_name.trim().to_string(), | |
| 235 | + | new_name, | |
| 224 | 236 | }); | |
| 225 | 237 | } | |
| 226 | 238 | state.settings.rename_target = None; |
| @@ -133,7 +133,10 @@ pub fn modal_window_with_open<R>( | |||
| 133 | 133 | if let Some(w) = default_width { | |
| 134 | 134 | window = window.default_width(w); | |
| 135 | 135 | } | |
| 136 | - | window.show(ctx, |ui| add_contents(ui)).map(|r| r.inner.unwrap()) | |
| 136 | + | // `inner` is None only if the window collapsed; every caller sets | |
| 137 | + | // collapsible(false), but `and_then` degrades to None instead of a | |
| 138 | + | // per-frame panic in the shared modal scaffold if that ever changes. | |
| 139 | + | window.show(ctx, |ui| add_contents(ui)).and_then(|r| r.inner) | |
| 137 | 140 | } | |
| 138 | 141 | ||
| 139 | 142 | /// Render a `[Cancel] [primary]` action row at the bottom of a modal. |