//! The egui application: config panel, record/stop controls, case list. //! //! State transitions are driven by: //! - user clicks (new/continue/stop/open/save-config/dismiss-error) //! - recorder events (ffmpeg finalizing → RecorderEvent::Finished) //! - upload events (terminal failure → AppState::Error) //! //! Connectivity and upload-progress indicators live in the footer; they //! never block the main state machine. See //! `berpr-fe-den-plan-lively-journal.md` for the design rationale. use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use crate::case_store::{CaseMarker, CaseStore}; use crate::case_update::{PutOnelinerError, put_oneliner}; use crate::footer_status::{FooterStatus, pick_footer_status}; use crate::server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState}; use crate::snapshot_cache::SnapshotCache; use crate::startup::{ DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, DEFAULT_ORPHAN_MARKER_GRACE, run_startup_cleanup, }; use crate::upload::{PendingUpload, UploadEvent, write_sidecar}; use doctate_common::join_url; use doctate_common::oneliners::OnelinerState; use doctate_common::timestamp::{extract_hhmm, now_rfc3339, recorded_at_to_filename_stem}; use eframe::egui; use tokio::runtime::Runtime; use tokio::sync::{mpsc, watch}; use tracing::{error, info, warn}; use uuid::Uuid; use crate::config::Config; use crate::recorder::{Recorder, RecorderEvent}; use crate::state::AppState; pub struct DoctateApp { runtime: Arc, config: Option, pending_dir: PathBuf, state: AppState, server_url_input: String, api_key_input: String, http_client: Arc, case_store: Arc, snapshot_rx: watch::Receiver>>, snapshot_cache: Arc, recorder: Option, recorder_events: Option>, server_sync: Option, worker_rx: Option>, upload_events: Option>, /// Context for an in-flight ffmpeg flush: we left `AppState::Recording` /// for `AppState::Idle` on Stop (so the UI stays responsive), but we /// still need `case_id` + `recorded_at` when the recorder emits /// `Finished`. `None` when no flush is in progress. finalizing: Option, /// Active inline tap-to-edit on a oneliner row. `None` means /// every row renders in read mode. oneliner_edit: Option, /// Channel for results coming back from background `put_oneliner` /// spawns. The receiver is drained at the start of every frame. save_result_tx: mpsc::UnboundedSender, save_result_rx: mpsc::UnboundedReceiver, /// Toast banner shown until the user dismisses it; populated when /// a `put_oneliner` returns an error. last_save_error: Option, } /// Enough recording identity to finalize a pending flush or emit an /// upload event once the recorder signals `Finished`. #[derive(Debug, Clone)] struct RecordingContext { case_id: Uuid, recorded_at: String, } /// Per-case oneliner edit buffer. Only one case is in edit mode at a /// time; clicking another case (or pressing Esc) clears this. The /// `focus_pending` flag drives a one-shot `request_focus()` on the /// first render after the edit starts, so the user lands in the /// TextEdit without a second click. struct OnelinerEditState { case_id: Uuid, draft: String, focus_pending: bool, } /// Result of a background `put_oneliner` request, posted back to the /// UI thread via mpsc so the next `update()` frame can surface /// success/failure to the user. struct SaveResult { case_id: Uuid, outcome: Result, } enum UiAction { SaveConfig, StartNew, Continue(Uuid), OpenWeb(Uuid), Stop, DismissError, StartOnelinerEdit { case_id: Uuid, current_text: String }, CancelOnelinerEdit, SaveOneliner { case_id: Uuid, text: String }, DismissSaveError, } impl DoctateApp { pub fn new( runtime: Arc, pending_dir: PathBuf, cases_dir: PathBuf, snapshot_cache_path: PathBuf, ) -> Self { let config = match crate::config_path::load() { Ok(Some(c)) => Some(c), Ok(None) => None, Err(e) => { warn!(error = %e, "config load failed"); None } }; let server_url_input = config .as_ref() .map(|c| c.server_url.clone()) .unwrap_or_default(); let api_key_input = config .as_ref() .map(|c| c.api_key.clone()) .unwrap_or_default(); let (store, snapshot_rx) = runtime .block_on(async { CaseStore::open(cases_dir).await }) .expect("open case store"); let case_store = Arc::new(store); let snapshot_cache = Arc::new(SnapshotCache::new(snapshot_cache_path)); let http_client = Arc::new(reqwest::Client::new()); // Startup cleanup — one-shot, before the worker spawns. { let store = case_store.clone(); let pending = pending_dir.clone(); runtime.block_on(async move { run_startup_cleanup( &store, &pending, DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, DEFAULT_ORPHAN_MARKER_GRACE, ) .await; }); } let (state, server_sync, worker_rx, upload_events) = if let Some(cfg) = &config { let (sync, events, rx) = spawn_worker( &runtime, cfg.clone(), pending_dir.clone(), http_client.clone(), case_store.clone(), snapshot_cache.clone(), ); (AppState::Idle, Some(sync), Some(rx), Some(events)) } else { (AppState::NotConfigured, None, None, None) }; let (save_result_tx, save_result_rx) = mpsc::unbounded_channel(); Self { runtime, config, pending_dir, state, server_url_input, api_key_input, http_client, case_store, snapshot_rx, snapshot_cache, recorder: None, recorder_events: None, server_sync, worker_rx, upload_events, finalizing: None, oneliner_edit: None, save_result_tx, save_result_rx, last_save_error: None, } } fn on_save_config(&mut self) { let cfg = Config { server_url: self.server_url_input.trim().to_string(), api_key: self.api_key_input.trim().to_string(), oneliner_poll_interval_seconds: self .config .as_ref() .and_then(|c| c.oneliner_poll_interval_seconds), }; if let Err(e) = crate::config_path::save(&cfg) { error!(error = %e, "config save failed"); self.state = AppState::Error { message: format!("Speichern: {e}"), }; return; } info!("config saved"); let store = self.case_store.clone(); let cache = self.snapshot_cache.clone(); self.runtime.block_on(async move { if let Err(e) = store.clear_all().await { warn!(error = %e, "case store clear failed on config change"); } if let Err(e) = cache.clear().await { warn!(error = %e, "snapshot cache clear failed on config change"); } }); // Drop old worker explicitly so its abort fires before new spawn. self.server_sync = None; self.worker_rx = None; self.upload_events = None; let (sync, events, rx) = spawn_worker( &self.runtime, cfg.clone(), self.pending_dir.clone(), self.http_client.clone(), self.case_store.clone(), self.snapshot_cache.clone(), ); self.server_sync = Some(sync); self.worker_rx = Some(rx); self.upload_events = Some(events); self.config = Some(cfg); self.state = AppState::Idle; self.finalizing = None; } fn on_start_new(&mut self) { self.start_recording_for(Uuid::new_v4(), /* is_continue */ false); } fn on_continue(&mut self, case_id: Uuid) { self.start_recording_for(case_id, /* is_continue */ true); } fn start_recording_for(&mut self, case_id: Uuid, is_continue: bool) { // Two-part guard: user-visible state AND no lingering recorder // from a previous stop that is still flushing. if !matches!(self.state, AppState::Idle) || self.recorder.is_some() { return; } if let Err(e) = std::fs::create_dir_all(&self.pending_dir) { self.state = AppState::Error { message: format!("Pending-Verzeichnis: {e}"), }; return; } let recorded_at = now_rfc3339(); let stem = format!("{}_{}", case_id, recorded_at_to_filename_stem(&recorded_at)); let output_path = self.pending_dir.join(format!("{stem}.m4a")); // Persist the case marker before recording so the UI list // reflects the new case immediately. let store = self.case_store.clone(); let now = time::OffsetDateTime::now_utc(); self.runtime.block_on(async move { let res = if is_continue { store.mark_activity(case_id, now).await.map(|_| ()) } else { store.create_local(case_id, now).await.map(|_| ()) }; if let Err(e) = res { warn!(error = %e, "case store write failed"); } }); let _guard = self.runtime.enter(); match Recorder::start(output_path) { Ok((recorder, events)) => { self.recorder = Some(recorder); self.recorder_events = Some(events); self.state = AppState::Recording { case_id, recorded_at, started_at: Instant::now(), }; } Err(e) => { self.state = AppState::Error { message: format!("Aufnahme starten: {e}"), }; } } } fn on_stop(&mut self) { let AppState::Recording { case_id, recorded_at, .. } = &self.state else { return; }; let case_id = *case_id; let recorded_at = recorded_at.clone(); if let Some(rec) = self.recorder.take() { rec.stop(); } // UI is ready for the next recording immediately — the finalizing // ffmpeg flush shows up as a footer spinner, not a blocking state. self.finalizing = Some(RecordingContext { case_id, recorded_at, }); self.state = AppState::Idle; } fn on_open_web(&self, case_id: Uuid) { let Some(cfg) = &self.config else { return; }; let server_url = cfg.server_url.clone(); let api_key = cfg.api_key.clone(); let http = self.http_client.clone(); let return_to = format!("/cases/{case_id}"); let fallback_url = join_url(&server_url, &return_to); // Fire-and-forget on the runtime. Blocking the egui UI thread on // an HTTP round-trip would freeze the window for ~100–500ms. self.runtime.spawn(async move { let url = match crate::magic_link::build_magic_url(&http, &server_url, &api_key, &return_to) .await { Ok(u) => u, Err(e) => { warn!(error = %e, "magic-link request failed; falling back to plain URL"); fallback_url } }; if let Err(e) = webbrowser::open(&url) { warn!(url = %url, error = %e, "open browser failed"); } }); } fn drain_recorder_events(&mut self) { let Some(rx) = self.recorder_events.as_mut() else { return; }; let mut events = Vec::new(); while let Ok(event) = rx.try_recv() { events.push(event); } for event in events { match event { RecorderEvent::Started => {} RecorderEvent::Finished { output } => { self.handle_recording_finished(output); } RecorderEvent::Failed { error: err } => { error!(error = %err, "recorder failed"); self.finalizing = None; self.state = AppState::Error { message: format!("Aufnahme: {err}"), }; self.recorder_events = None; self.recorder = None; } } } } fn handle_recording_finished(&mut self, output: PathBuf) { // Prefer the explicit finalizing context (Stop-flow). Fall back // to AppState::Recording (recorder terminated before Stop — rare // but possible). let ctx = if let Some(ctx) = self.finalizing.take() { ctx } else if let AppState::Recording { case_id, recorded_at, .. } = &self.state { let ctx = RecordingContext { case_id: *case_id, recorded_at: recorded_at.clone(), }; self.state = AppState::Idle; ctx } else { warn!(state = ?self.state, "recorder finished in unexpected state"); self.recorder_events = None; self.recorder = None; return; }; let RecordingContext { case_id, recorded_at, } = ctx; let store = self.case_store.clone(); let now = time::OffsetDateTime::now_utc(); self.runtime.block_on(async move { if let Err(e) = store.mark_activity(case_id, now).await { warn!(error = %e, "mark_activity on recording finished failed"); } }); let upload = PendingUpload { case_id, recorded_at, file: output, }; if let Err(e) = write_sidecar(&upload) { self.state = AppState::Error { message: format!("Sidecar schreiben: {e}"), }; self.recorder_events = None; self.recorder = None; return; } // Kick the worker so it picks up the fresh file without waiting // for the next poll-tick. if let Some(sync) = &self.server_sync { sync.notify(); } else { warn!("no server_sync active — upload stays on disk until next launch"); } self.recorder_events = None; self.recorder = None; } fn drain_upload_events(&mut self) { let Some(rx) = self.upload_events.as_mut() else { return; }; let mut events = Vec::new(); while let Ok(event) = rx.try_recv() { events.push(event); } for event in events { match event { UploadEvent::Started(case_id) => { info!(case_id = %case_id, "upload attempt started"); } UploadEvent::Succeeded { case_id, status } => { info!(case_id = %case_id, status = ?status, "upload succeeded"); } UploadEvent::Failed { case_id, reason, will_retry, } => { warn!(case_id = %case_id, reason = %reason, will_retry, "upload failed"); if !will_retry { // Terminal = operator action needed. Block UI. self.state = AppState::Error { message: reason }; } } } } } fn render_controls(&mut self, ui: &mut egui::Ui) -> Option { match &self.state { AppState::NotConfigured => { ui.label("Server URL:"); ui.add(egui::TextEdit::singleline(&mut self.server_url_input).desired_width(280.0)); ui.add_space(4.0); ui.label("API Key:"); ui.add( egui::TextEdit::singleline(&mut self.api_key_input) .desired_width(280.0) .password(true), ); ui.add_space(8.0); if ui.button("Speichern").clicked() { return Some(UiAction::SaveConfig); } None } AppState::Idle => { if ui.button("● Neu").clicked() { return Some(UiAction::StartNew); } None } AppState::Recording { started_at, .. } => { let elapsed = started_at.elapsed(); ui.label(format!( "● REC {:02}:{:02}", elapsed.as_secs() / 60, elapsed.as_secs() % 60 )); ui.add_space(4.0); if ui.button("■ Stop").clicked() { return Some(UiAction::Stop); } None } AppState::Error { message } => { ui.colored_label(egui::Color32::RED, message); ui.add_space(8.0); if ui.button("OK").clicked() { return Some(UiAction::DismissError); } None } } } fn render_case_list(&mut self, ui: &mut egui::Ui) -> Option { let snapshot = self.snapshot_rx.borrow().clone(); // Snapshot is already server-filtered — the server's `window_hours` // per user (users.toml) is the single source of truth. let visible: Vec<&CaseMarker> = snapshot.iter().collect(); if visible.is_empty() { ui.add_space(8.0); ui.colored_label(egui::Color32::GRAY, "Noch keine Fälle heute."); return None; } // Pending-upload lookup for the badge. Cheap clone (Arc) from the // worker's last published snapshot; empty set if no worker yet. let pending_ids = self .worker_rx .as_ref() .map(|rx| rx.borrow().pending_case_ids.clone()) .unwrap_or_default(); let mut action = None; // Ready-for-new means the user-intent state is Idle AND no ffmpeg // instance is still flushing. Upload-queue depth does NOT gate. let idle = matches!(self.state, AppState::Idle) && self.recorder.is_none(); egui::ScrollArea::vertical().show(ui, |ui| { for marker in visible { ui.separator(); ui.add_space(4.0); let time_str = extract_hhmm(&marker.last_activity_at); let has_pending = pending_ids.contains(&marker.case_id); let in_edit_mode = self .oneliner_edit .as_ref() .is_some_and(|e| e.case_id == marker.case_id); if in_edit_mode { // Inline edit: TextEdit + ✓/✗. Enter saves, Esc cancels. let edit = self.oneliner_edit.as_mut().unwrap(); ui.horizontal(|ui| { ui.label(format!("{time_str} ·")); let response = ui .add(egui::TextEdit::singleline(&mut edit.draft).desired_width(280.0)); if edit.focus_pending { response.request_focus(); edit.focus_pending = false; } let enter_pressed = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); let save_clicked = ui.button("✓").clicked(); let cancel_clicked = ui.button("✗").clicked(); let esc_pressed = ui.input(|i| i.key_pressed(egui::Key::Escape)); if save_clicked || enter_pressed { action = Some(UiAction::SaveOneliner { case_id: marker.case_id, text: edit.draft.clone(), }); } else if cancel_clicked || esc_pressed { action = Some(UiAction::CancelOnelinerEdit); } }); } else { // Read mode: clickable label, plus a green pencil // for Manual variant so the doctor sees their // override is the source of truth here. let oneliner_text = match &marker.oneliner { Some( OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. }, ) => text.clone(), Some(OnelinerState::Empty { .. }) => "∅".to_owned(), Some(OnelinerState::Error { .. }) => "⚠".to_owned(), None => "⏳".to_owned(), }; let line = if has_pending { format!("⬆ {time_str} · {oneliner_text}") } else { format!("{time_str} · {oneliner_text}") }; ui.horizontal(|ui| { let label = if has_pending { egui::Label::new( egui::RichText::new(line) .color(egui::Color32::from_rgb(200, 140, 0)), ) .sense(egui::Sense::click()) } else { egui::Label::new(line).sense(egui::Sense::click()) }; let resp = ui.add(label); if matches!(marker.oneliner, Some(OnelinerState::Manual { .. })) { // Person-glyph marks doctor-authored titles. // Replaces an earlier pencil icon: the pencil // suggested an edit affordance, but on this row // the marker is informational only — clicking // the title text already opens the editor. ui.colored_label(egui::Color32::GRAY, "👤") .on_hover_text("manuell bearbeitet"); } if resp.clicked() { // Pre-fill the editor with the visible text // (Ready/Manual). For Empty/Error/None start // with an empty buffer so the doctor types // from scratch. let current = match &marker.oneliner { Some( OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. }, ) => text.clone(), _ => String::new(), }; action = Some(UiAction::StartOnelinerEdit { case_id: marker.case_id, current_text: current, }); } }); } ui.horizontal(|ui| { if ui .add_enabled(idle, egui::Button::new("▶ Fortsetzen")) .clicked() { action = Some(UiAction::Continue(marker.case_id)); } if ui.button("🌐 Öffnen").clicked() { action = Some(UiAction::OpenWeb(marker.case_id)); } }); ui.add_space(4.0); } }); action } fn render_footer_status(&self, ui: &mut egui::Ui) { let Some(worker_rx) = &self.worker_rx else { ui.colored_label(egui::Color32::GRAY, "—"); return; }; let snap = worker_rx.borrow().clone(); let last_success_age = snap.last_success.map(|i| i.elapsed()); let last_failure_age = snap.last_failure.map(|i| i.elapsed()); let threshold = self .config .as_ref() .map(|c| std::cmp::max(3 * c.poll_interval(), Duration::from_secs(30))) .unwrap_or(Duration::from_secs(30)); let status = pick_footer_status( self.finalizing.is_some(), snap.state, snap.queue_len, last_success_age, last_failure_age, threshold, ); match status { FooterStatus::Finalizing => { ui.horizontal(|ui| { ui.spinner(); ui.label("Speichere Aufnahme…"); }); } FooterStatus::Uploading { count } => { ui.horizontal(|ui| { ui.spinner(); if count <= 1 { ui.label("Uploading…"); } else { ui.label(format!("Uploading ({count} offen)…")); } }); } FooterStatus::Synchronizing => { ui.horizontal(|ui| { ui.spinner(); ui.label("Synchronisiere…"); }); } FooterStatus::NeverConnected => { ui.colored_label(egui::Color32::GRAY, "⚪ noch keine Verbindung"); } FooterStatus::Offline => { ui.colored_label(egui::Color32::DARK_RED, "🔴 Offline"); } FooterStatus::Online => { ui.colored_label(egui::Color32::DARK_GREEN, "🟢 Online"); } } } fn handle_action(&mut self, action: UiAction) { match action { UiAction::SaveConfig => self.on_save_config(), UiAction::StartNew => self.on_start_new(), UiAction::Continue(id) => self.on_continue(id), UiAction::OpenWeb(id) => self.on_open_web(id), UiAction::Stop => self.on_stop(), UiAction::DismissError => self.state = AppState::Idle, UiAction::StartOnelinerEdit { case_id, current_text, } => { self.oneliner_edit = Some(OnelinerEditState { case_id, draft: current_text, focus_pending: true, }); } UiAction::CancelOnelinerEdit => { self.oneliner_edit = None; } UiAction::SaveOneliner { case_id, text } => { self.on_save_oneliner(case_id, text); } UiAction::DismissSaveError => { self.last_save_error = None; } } } /// Optimistic write + background PUT. Local state is updated /// immediately so the new text appears on the next frame; the /// server round-trip runs in a spawned task and reports back via /// `save_result_tx`. fn on_save_oneliner(&mut self, case_id: Uuid, text: String) { let trimmed = text.trim().to_owned(); if trimmed.is_empty() { // Empty payload would be rejected by the server (400) and // is also a no-op on the local cache. Treat as cancel. self.oneliner_edit = None; return; } let local_state = OnelinerState::Manual { text: trimmed.clone(), set_at: now_rfc3339(), }; let store = self.case_store.clone(); let local_clone = local_state.clone(); // Block briefly on the local write — single tokio::fs::write + // map insert, microseconds in practice. Keeps the UI strictly // monotonic: by the next render frame the new text is visible. self.runtime.block_on(async move { if let Err(e) = store.set_oneliner_local(case_id, local_clone).await { warn!(case_id = %case_id, error = %e, "local set_oneliner failed"); } }); self.oneliner_edit = None; // Background PUT. On failure the next drain_save_results // surfaces the error; on success no further work is needed // because the local state is already correct. let Some(cfg) = self.config.clone() else { warn!("save_oneliner without config — should not happen"); return; }; let http = self.http_client.clone(); let tx = self.save_result_tx.clone(); self.runtime.spawn(async move { let outcome = put_oneliner(&http, &cfg.server_url, &cfg.api_key, case_id, &trimmed).await; let _ = tx.send(SaveResult { case_id, outcome }); }); } fn drain_save_results(&mut self) { while let Ok(result) = self.save_result_rx.try_recv() { match result.outcome { Ok(_) => { info!(case_id = %result.case_id, "oneliner save succeeded"); } Err(e) => { error!(case_id = %result.case_id, error = ?e, "oneliner save failed"); self.last_save_error = Some(format!( "Oneliner für Fall {} konnte nicht gespeichert werden: {}", short_case_id(&result.case_id), user_error_message(&e), )); } } } } fn worker_is_active(&self) -> bool { let Some(rx) = &self.worker_rx else { return false; }; let snap = rx.borrow(); snap.state != WorkerState::Idle || snap.queue_len > 0 } } fn spawn_worker( runtime: &Runtime, config: Config, pending_dir: PathBuf, http: Arc, case_store: Arc, snapshot_cache: Arc, ) -> ( ServerSync, mpsc::UnboundedReceiver, watch::Receiver, ) { let _guard = runtime.enter(); let poll_interval = config.poll_interval(); let sync_config = SyncConfig { server_url: config.server_url, api_key: config.api_key, poll_interval, }; let (sync, events_rx) = ServerSync::spawn(http, sync_config, case_store, snapshot_cache, pending_dir); let snapshot_rx = sync.snapshot(); (sync, events_rx, snapshot_rx) } /// Short prefix of a `Uuid` for human-readable error messages. fn short_case_id(id: &Uuid) -> String { id.to_string().chars().take(8).collect() } /// Map a `PutOnelinerError` to a German user-facing one-liner. fn user_error_message(e: &PutOnelinerError) -> String { match e { PutOnelinerError::Terminal { status, .. } => { format!("Server-Fehler {status} — bitte Konfiguration prüfen.") } PutOnelinerError::Transient(_) => "Netzwerk-Fehler — bitte erneut versuchen.".to_string(), PutOnelinerError::Parse(_) => "Antwort vom Server unverständlich.".to_string(), } } impl eframe::App for DoctateApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.drain_recorder_events(); self.drain_upload_events(); self.drain_save_results(); let mut pending_action: Option = None; // Toast banner for the last save failure. Sits above the // header so the user can still navigate the rest of the app. if let Some(msg) = self.last_save_error.clone() { egui::TopBottomPanel::top("save-error-banner").show(ctx, |ui| { ui.add_space(4.0); ui.horizontal(|ui| { ui.colored_label(egui::Color32::DARK_RED, "⚠"); ui.label(msg); if ui.button("✕").clicked() { pending_action = Some(UiAction::DismissSaveError); } }); ui.add_space(4.0); }); } egui::TopBottomPanel::top("header").show(ctx, |ui| { ui.add_space(4.0); ui.vertical_centered(|ui| { ui.heading("doctate"); ui.label(self.state.label()); }); ui.add_space(4.0); }); egui::TopBottomPanel::bottom("footer").show(ctx, |ui| { ui.add_space(4.0); ui.vertical_centered(|ui| { self.render_footer_status(ui); }); ui.add_space(4.0); }); egui::CentralPanel::default().show(ctx, |ui| { ui.vertical_centered(|ui| { ui.add_space(8.0); if let Some(a) = self.render_controls(ui) { pending_action = Some(a); } ui.add_space(8.0); }); if !matches!(self.state, AppState::NotConfigured) && let Some(a) = self.render_case_list(ui) { pending_action = Some(a); } }); if let Some(action) = pending_action { self.handle_action(action); } // Repaint cadence: fast while visibly active (spinner, REC-timer), // slow (1 Hz) in the quiet steady state. let active_spinner = self.finalizing.is_some() || self.worker_is_active(); let interval = match self.state { AppState::Recording { .. } => Duration::from_millis(250), _ if active_spinner => Duration::from_millis(100), _ => Duration::from_secs(1), }; ctx.request_repaint_after(interval); } }