Skip to main content

dspk_app_core/data/
store.rs

1//! The app's shared state, as one entity: the open recording, the timeline every view follows,
2//! the channel selection of each source, the workspace shown, recent files and the status line.
3//! Changes go through its methods, which emit the [`AppEvent`] they caused (never a blanket
4//! `notify`); view models subscribe to the events they show.
5
6use dspk_ops::PipelineStage;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use gpui_kit::{AppContext as _, Context, EventEmitter, Task};
13
14use dspk_view::{Progress, SignalBackend};
15use crate::data::jobs::{self, Cancel, JobMessage};
16use crate::data::recording::processed::{PreviewSource, ProcessedSignal};
17use crate::data::recording::{Dataset, SourceSet, SpikeEventStore};
18use crate::ui::theme::palette::Palette;
19use crate::domain::timeline::TimelineState;
20use crate::screens::explore::trace::view::parse_channel_ranges;
21use crate::data::preferences::Session;
22use crate::domain::command::{apply, Changed, CommandError, SessionCommand};
23use crate::domain::session::{RecordingRef, SessionDoc, Show, ViewDoc};
24use crate::navigation::route::Workspace;
25
26/// Previews kept by chain.
27const PREVIEW_CACHE: usize = 6;
28/// Changes of the session that can be undone.
29const UNDO_DEPTH: usize = 200;
30/// Playback clock period.
31const TICK: Duration = Duration::from_millis(16);
32/// How often the session document file is checked for edits made outside the app (an agent).
33const DOCUMENT_POLL: Duration = Duration::from_millis(500);
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum AppEvent {
37    /// A recording was opened (or closed): views start over.
38    RecordingChanged,
39    /// A recording was loaded beside the open one (its views can be added).
40    RecordingLoaded(PathBuf),
41    /// Show the Epochs view of session view `id` in Explore.
42    ShowEpochs(String),
43    /// The visible time window moved (views redraw).
44    WindowMoved,
45    /// The playhead, play state, loop or speed changed (overlays and transport only).
46    PlaybackChanged,
47    /// The channel selection of a source changed.
48    SelectionChanged(String),
49    WorkspaceChanged,
50    /// Plot colours changed (theme or "dark plots"): views redraw.
51    PaletteChanged,
52    /// More of a source's min/max pyramid is built (views of it that drew empty columns redraw).
53    PyramidProgress(String),
54    /// The status line or the recent list changed.
55    Status,
56    /// The session document changed (by the UI or from its file): what changed.
57    Document(Changed),
58    /// A channel (or an op of the active view) was picked: the Processing panel follows.
59    Picked,
60    /// A job started, progressed or ended (status bar, panels).
61    JobChanged,
62    /// A plugin's own state changed (its panel redraws).
63    PluginChanged,
64    /// A floating window opened or closed.
65    Floating,
66}
67
68/// A floating window over the workspace.
69#[derive(Debug, Clone, PartialEq)]
70pub enum Floating {
71    /// What is known about a recording's source.
72    Metadata { path: PathBuf, source: String },
73    /// An epoch to save (new or edited).
74    Epoch(crate::domain::epoch::EpochDoc),
75}
76
77/// Work running for the UI (one at a time): what it is, how far, how to stop it.
78pub struct RunningJob {
79    pub label: String,
80    pub fraction: Option<f32>,
81    pub line: String,
82    pub cancel: Cancel,
83    /// Its entry in the frame's job list (kitchen-shell): the status bar shows it with a cancel
84    /// button; it ends when this does.
85    pub shell_job: Option<kitchen_shell::jobs::JobHandle>,
86}
87
88/// What a graph node's last job is doing (the Pipeline canvas shows it on the node).
89#[derive(Debug, Clone, PartialEq)]
90pub enum NodeRun {
91    Running { line: String, fraction: Option<f32>, since: Instant },
92    Done { took: Duration },
93    Failed(String),
94}
95
96/// The open recording.
97pub struct Recording {
98    pub sources: Arc<SourceSet>,
99    pub name: String,
100    /// Its file (`None`: in memory or synthetic).
101    pub path: Option<PathBuf>,
102    /// Spike events shown on traces and in the timeline overview (empty until an extraction runs).
103    pub events: Arc<SpikeEventStore>,
104}
105
106impl Recording {
107    pub fn new(sources: SourceSet, path: Option<PathBuf>) -> Self {
108        let name = match &path {
109            Some(p) => p.file_name().map_or_else(|| p.display().to_string(), |n| n.to_string_lossy().into_owned()),
110            None => sources.default_entry().name.clone(),
111        };
112        Self { sources: Arc::new(sources), name, path, events: Arc::new(SpikeEventStore::default()) }
113    }
114
115    /// `32 ch · 30.0 kHz · 5 min 00 s` of the default source.
116    pub fn summary(&self) -> String {
117        self.summary_of(&self.sources.default_entry().id)
118    }
119
120    /// `32 ch · 30.0 kHz · 5 min 00 s` of source `id`.
121    pub fn summary_of(&self, id: &str) -> String {
122        let e = self.sources.entry(id);
123        let hz = e.sample_rate.rate_hz();
124        let rate = if hz >= 1000.0 { format!("{:.1} kHz", hz / 1000.0) } else { format!("{hz:.1} Hz") };
125        format!("{} ch · {rate} · {}", e.channels, duration(e.duration_sec()))
126    }
127}
128
129/// How a view is previewed ([`Store::view_plan`]).
130pub struct ViewPlan {
131    /// Source channels read, in order.
132    pub group: Vec<usize>,
133    /// Device steps and their keys (for sharing computed prefixes).
134    pub steps: Vec<(String, PipelineStage)>,
135    /// Source channels shown, in order.
136    pub shown: Vec<usize>,
137    /// Whitening steps (by index in `steps`, identities until fitted) and how to fit them.
138    pub fits: Vec<(usize, dspk_pipeline::WhitenFit)>,
139}
140
141pub struct Store {
142    /// The active recording (what Explore draws and Processing edits).
143    pub recording: Option<Recording>,
144    /// Other recordings loaded this session (each file once): switching to one is instant.
145    pub loaded: Vec<Recording>,
146    /// A recording's source the Inspector shows (picked under Recordings; a view's focus clears it).
147    pub inspected: Option<(PathBuf, String)>,
148    pub timeline: TimelineState,
149    /// Channels shown, per source id, in display order.
150    selection: HashMap<String, Vec<usize>>,
151    pub workspace: Workspace,
152    pub session: Session,
153    session_path: Option<PathBuf>,
154    /// What is being opened, while a file opens.
155    pub opening: Option<String>,
156    pub status: String,
157    /// Render time and density of the latest frame (status bar).
158    pub render_info: String,
159    /// The playback clock while playing.
160    clock: Option<Task<()>>,
161    /// The app is drawn dark (the user's choice or the system's).
162    dark: bool,
163    /// Pyramid progress per source id being built: (samples built, of all).
164    pub pyramids: HashMap<String, (u64, u64)>,
165    /// The session document: views, their op chains, display and linked sortings.
166    pub doc: SessionDoc,
167    doc_path: Option<PathBuf>,
168    /// The text last written to or read from `doc_path` (an edit from outside differs from it).
169    doc_text: String,
170    /// Watches `doc_path` for edits made outside the app.
171    _doc_watch: Option<Task<()>>,
172    /// Live preview of each view's op chain (views of the open recording with enabled ops).
173    previews: HashMap<String, Arc<ProcessedSignal>>,
174    /// Previews by chain (source, channels, enabled ops), most recent last: views with the same
175    /// chain share one, and a branch reads the preview of the ops it shares with another.
176    preview_cache: Vec<(Vec<String>, Arc<ProcessedSignal>)>,
177    /// Whitenings fitted for previews, by the key of the chain up to them (fitting reads the
178    /// recording: once per chain, not per redraw).
179    whiten_cache: HashMap<String, dspk_ops::spatial::SpatialWhitening>,
180    /// Whitening fits running in the background (their keys).
181    whiten_fitting: std::collections::HashSet<String>,
182    /// The channel the Processing panel is about, and the op selected in it.
183    pub picked_channel: Option<usize>,
184    pub picked_op: Option<String>,
185    pub job: Option<RunningJob>,
186    /// The compute device processing runs on (named once at start, off the UI thread).
187    pub device: Option<crate::data::device::DeviceInfo>,
188    /// The last job that failed: its label and why (cleared when the next job starts).
189    pub last_failed: Option<(String, String)>,
190    /// Graph nodes the running job works on, and every node's last run.
191    job_nodes: Vec<String>,
192    pub node_status: HashMap<String, NodeRun>,
193    /// Floating windows, front last.
194    pub floating: Vec<Floating>,
195    /// Earlier documents (undo) and undone ones (redo).
196    history: Vec<SessionDoc>,
197    future: Vec<SessionDoc>,
198    /// The active view and channel subset last applied to the selection.
199    applied_view: Option<(String, Option<Vec<usize>>)>,
200    /// Event tables read so far, by path (read on first use; dropped when the set is set again).
201    event_tables: HashMap<PathBuf, Arc<Vec<crate::data::events::Event>>>,
202}
203
204impl EventEmitter<AppEvent> for Store {}
205
206impl Store {
207    pub fn new(session: Session, session_path: Option<PathBuf>) -> Self {
208        crate::data::remote::set_profiles(&session.servers);
209        Self {
210            recording: None,
211            loaded: Vec::new(),
212            inspected: None,
213            timeline: TimelineState::new(1.0),
214            selection: HashMap::new(),
215            workspace: session.workspace,
216            session,
217            session_path,
218            opening: None,
219            status: String::new(),
220            render_info: String::new(),
221            clock: None,
222            dark: true,
223            pyramids: HashMap::new(),
224            doc: SessionDoc::default(),
225            doc_path: None,
226            doc_text: String::new(),
227            _doc_watch: None,
228            previews: HashMap::new(),
229            preview_cache: Vec::new(),
230            whiten_cache: HashMap::new(),
231            whiten_fitting: Default::default(),
232            picked_channel: None,
233            picked_op: None,
234            job: None,
235            device: None,
236            last_failed: None,
237            job_nodes: Vec::new(),
238            node_status: HashMap::new(),
239            floating: Vec::new(),
240            history: Vec::new(),
241            future: Vec::new(),
242            applied_view: None,
243            event_tables: HashMap::new(),
244        }
245    }
246
247    // ------------------------------------------------------------------------
248    // Session document (one code path for the UI and an agent)
249    // ------------------------------------------------------------------------
250
251    /// Loads the session document at `path` (an unreadable one is reported and replaced by an
252    /// empty one when first saved) and follows the file: edits made outside the app (an agent)
253    /// are applied as they land.
254    pub fn attach_document(&mut self, path: PathBuf, cx: &mut Context<Self>) {
255        match std::fs::read_to_string(&path) {
256            Ok(text) => match SessionDoc::from_json(&text) {
257                Ok(doc) => {
258                    self.doc = doc;
259                    self.doc_text = text;
260                }
261                Err(e) => tracing::warn!("{}: {e}", path.display()),
262            },
263            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
264            Err(e) => tracing::warn!("{}: {e}", path.display()),
265        }
266        self.doc_path = Some(path.clone());
267        self._doc_watch = Some(cx.spawn(async move |this, cx| loop {
268            cx.background_executor().timer(DOCUMENT_POLL).await;
269            let text = std::fs::read_to_string(&path).unwrap_or_default();
270            if this.update(cx, |s, cx| s.document_file_changed(text, cx)).is_err() {
271                break;
272            }
273        }));
274        cx.emit(AppEvent::Document(Changed::Views));
275    }
276
277    /// Applies an edit of the document file made outside the app.
278    fn document_file_changed(&mut self, text: String, cx: &mut Context<Self>) {
279        if text.is_empty() || text == self.doc_text {
280            return;
281        }
282        self.doc_text = text;
283        match SessionDoc::from_json(&self.doc_text) {
284            Ok(doc) if doc != self.doc => {
285                let before = std::mem::replace(&mut self.doc, doc);
286                for change in diff(&before, &self.doc) {
287                    self.changed(&change, cx);
288                    cx.emit(AppEvent::Document(change));
289                }
290                self.set_status("Session updated from its file", cx);
291            }
292            Ok(_) => {}
293            Err(e) => self.set_status(format!("Session file not applied: {e}"), cx),
294        }
295    }
296
297    /// Runs a command on the session document, saves it and emits what changed.
298    pub fn execute(&mut self, cmd: SessionCommand, cx: &mut Context<Self>) -> Result<Changed, CommandError> {
299        let before = self.doc.clone();
300        let result = apply(&mut self.doc, cmd);
301        match &result {
302            Ok(_) => {
303                if before != self.doc {
304                    self.history.push(before.clone());
305                    if self.history.len() > UNDO_DEPTH {
306                        self.history.remove(0);
307                    }
308                    self.future.clear();
309                }
310                self.save_document();
311                // What the command changed in the views (a graph edit can change several chains)
312                for change in diff(&before, &self.doc) {
313                    self.changed(&change, cx);
314                    cx.emit(AppEvent::Document(change));
315                }
316                // A chain built in the Pipeline becomes the one shown, while the shown one holds
317                // no work yet (the view a recording opens with): Explore then draws it at once
318                // (a view whose chain changed, or a new view at the end of one)
319                let edited = self.doc.views.iter().find(|v| {
320                    !v.ops.is_empty() && self.doc.active_view.as_deref() != Some(v.id.as_str()) && before.view(&v.id).is_none_or(|b| b.ops != v.ops)
321                });
322                let idle = self.doc.active().is_none_or(|a| a.ops.is_empty());
323                if let Some(id) = edited.filter(|_| idle).map(|v| v.id.clone()) {
324                    let _ = self.execute(SessionCommand::SelectView { view: id }, cx);
325                }
326            }
327            Err(e) => self.set_status(e.to_string(), cx),
328        }
329        if result.is_ok() && before.epochs != self.doc.epochs {
330            self.write_epochs(&before, cx);
331        }
332        result
333    }
334
335    /// Back to the document before the last change (`redo`: forward again).
336    pub fn undo(&mut self, redo: bool, cx: &mut Context<Self>) {
337        let next = if redo { self.future.pop() } else { self.history.pop() };
338        let Some(doc) = next else {
339            self.set_status(if redo { "Nothing to redo" } else { "Nothing to undo" }, cx);
340            return;
341        };
342        let before = std::mem::replace(&mut self.doc, doc);
343        if redo { self.history.push(before.clone()) } else { self.future.push(before.clone()) }
344        self.save_document();
345        for change in diff(&before, &self.doc) {
346            self.changed(&change, cx);
347            cx.emit(AppEvent::Document(change));
348        }
349        if before.epochs != self.doc.epochs {
350            self.write_epochs(&before, cx);
351        }
352        self.set_status(if redo { "Redone" } else { "Undone" }, cx);
353    }
354
355    /// Writes the epochs file of every recording whose epochs changed.
356    fn write_epochs(&mut self, before: &SessionDoc, cx: &mut Context<Self>) {
357        let of = |d: &SessionDoc, r: &Path| d.epochs.iter().filter(|e| e.recording == r).cloned().collect::<Vec<_>>();
358        let mut paths: Vec<PathBuf> = before.epochs.iter().chain(&self.doc.epochs).map(|e| e.recording.clone()).collect();
359        paths.sort();
360        paths.dedup();
361        for r in paths {
362            let now = of(&self.doc, &r);
363            if now == of(before, &r) || !r.exists() {
364                continue;
365            }
366            if let Err(e) = crate::data::epochs::save(&r, &now) {
367                self.set_status(format!("Epochs of {} not saved: {e}", crate::domain::paths::short_path(&r)), cx);
368            }
369        }
370    }
371
372    /// The events of `set`, once its table is read ([`Self::add_event_set`], or on opening the
373    /// recording).
374    pub fn events_of(&self, set: &crate::domain::epoch::EventSetDoc) -> Option<Arc<Vec<crate::data::events::Event>>> {
375        self.event_tables.get(&set.table).cloned()
376    }
377
378    /// Reads `set`'s table (again) and adds the set to the session, or updates it.
379    pub fn add_event_set(&mut self, set: crate::domain::epoch::EventSetDoc, cx: &mut Context<Self>) {
380        match crate::data::events::read(&set.table) {
381            Ok(events) => {
382                self.event_tables.insert(set.table.clone(), Arc::new(events));
383            }
384            Err(e) => self.set_status(format!("Events {}: {e}", set.name), cx),
385        }
386        let _ = self.execute(SessionCommand::SetEventSet { set }, cx);
387    }
388
389    /// Every event of set `id` that has an offset becomes an epoch of type `response`, named after
390    /// the set (one change: one undo step).
391    pub fn epochs_from_events(&mut self, id: &str, cx: &mut Context<Self>) {
392        let Some(set) = self.doc.event_sets.iter().find(|s| s.id == id).cloned() else { return };
393        let Some(events) = self.events_of(&set) else {
394            self.set_status(format!("Events {}: not read", set.name), cx);
395            return;
396        };
397        let epochs: Vec<crate::domain::epoch::EpochDoc> = events
398            .iter()
399            .filter_map(|e| Some((e.onset_s, e.offset_s?)))
400            .filter(|(a, b)| b > a)
401            .enumerate()
402            .map(|(i, (a, b))| crate::domain::epoch::EpochDoc { id: String::new(), recording: set.recording.clone(), name: format!("{} {}", set.name, i + 1), kind: "response".into(), start_sec: a, end_sec: b, notes: String::new() })
403            .collect();
404        let n = epochs.len();
405        if self.execute(SessionCommand::SetEpochs { epochs }, cx).is_ok() {
406            self.set_status(format!("{n} epochs from {}", set.name), cx);
407        }
408    }
409
410    /// Moves the window to the first event of set `id` after the window's centre (wrapping to the
411    /// first), and returns its onset.
412    pub fn next_event(&mut self, id: &str, cx: &mut Context<Self>) -> Option<f64> {
413        let set = self.doc.event_sets.iter().find(|s| s.id == id)?.clone();
414        let events = self.events_of(&set)?;
415        let (a, w) = (self.timeline.window_start_sec, self.timeline.visible_window_sec);
416        let mid = a + w / 2.0;
417        let e = events.iter().find(|e| e.onset_s > mid + 1e-9).or_else(|| events.first())?;
418        let t = e.onset_s;
419        self.set_window(t - w / 2.0, t + w / 2.0, cx);
420        Some(t)
421    }
422
423    /// Reads the tables of the session's event sets not read yet (a session opened from a file).
424    pub fn read_event_tables(&mut self) {
425        for set in &self.doc.event_sets {
426            if !self.event_tables.contains_key(&set.table) {
427                if let Ok(events) = crate::data::events::read(&set.table) {
428                    self.event_tables.insert(set.table.clone(), Arc::new(events));
429                }
430            }
431        }
432    }
433
434    /// The event sets in `path`'s derived store that the session does not list yet.
435    fn load_event_sets(&mut self, path: &Path, cx: &mut Context<Self>) {
436        let Ok(dir) = std::fs::read_dir(crate::data::derived::events_dir(path)) else { return };
437        let mut tables: Vec<PathBuf> = dir.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.join("zarr.json").exists()).collect();
438        tables.sort();
439        for table in tables {
440            if self.doc.event_sets.iter().any(|s| s.table == table) {
441                continue;
442            }
443            let name = table.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned());
444            self.add_event_set(crate::domain::epoch::EventSetDoc::new(path.to_path_buf(), name, table), cx);
445        }
446    }
447
448    /// The epochs saved next to `path` that the session does not have yet.
449    fn load_epochs(&mut self, path: &Path, cx: &mut Context<Self>) {
450        let mut found = match crate::data::epochs::load(path) {
451            Ok(found) => found,
452            Err(e) => {
453                self.set_status(format!("Epochs of {}: {e}", crate::domain::paths::short_path(&path)), cx);
454                Vec::new()
455            }
456        };
457        // Epochs the file holds itself (e.g. NWB intervals), from the recording's start
458        let start = self.sources().map_or(0.0, |s| s.get(&s.default_entry().id.clone()).start_time_sec);
459        found.extend(crate::plugin::plugins().iter().flat_map(|p| p.recording_epochs(path, start)));
460        let same = |a: &crate::domain::epoch::EpochDoc, b: &crate::domain::epoch::EpochDoc| a.recording == b.recording && (a.start_sec - b.start_sec).abs() < 1e-9 && (a.end_sec - b.end_sec).abs() < 1e-9;
461        let mut new: Vec<crate::domain::epoch::EpochDoc> = Vec::new();
462        for mut e in found {
463            if self.doc.epochs.iter().chain(&new).any(|d| same(d, &e)) {
464                continue;
465            }
466            if self.doc.epochs.iter().chain(&new).any(|d| d.id == e.id) {
467                e.id.clear();
468            }
469            new.push(e);
470        }
471        // Ids for the ones without, unique among the new ones too
472        let mut next = 1;
473        for e in new.iter_mut().filter(|e| e.id.is_empty()) {
474            while self.doc.epochs.iter().any(|d| d.id == format!("e{next}")) {
475                next += 1;
476            }
477            e.id = format!("e{next}");
478            next += 1;
479        }
480        if !new.is_empty() {
481            let _ = self.execute(SessionCommand::SetEpochs { epochs: new }, cx);
482        }
483    }
484
485    /// Opens a floating window (one of each kind and target).
486    pub fn open_floating(&mut self, f: Floating, cx: &mut Context<Self>) {
487        self.floating.retain(|x| std::mem::discriminant(x) != std::mem::discriminant(&f) || matches!((x, &f), (Floating::Metadata { path: a, .. }, Floating::Metadata { path: b, .. }) if a != b));
488        self.floating.push(f);
489        cx.emit(AppEvent::Floating);
490        cx.notify();
491    }
492
493    pub fn close_floating(&mut self, index: usize, cx: &mut Context<Self>) {
494        if index < self.floating.len() {
495            self.floating.remove(index);
496            cx.emit(AppEvent::Floating);
497            cx.notify();
498        }
499    }
500
501    fn save_document(&mut self) {
502        let Some(path) = &self.doc_path else { return };
503        let text = self.doc.to_json();
504        if text == self.doc_text {
505            return;
506        }
507        match self.doc.save(path) {
508            Ok(()) => self.doc_text = text,
509            Err(e) => tracing::warn!("could not save the session document: {e}"),
510        }
511    }
512
513    /// A view of `path`'s `source` (the active one if it shows it; else the first; else a new one).
514    fn ensure_view(&mut self, path: &Path, source: &str, cx: &mut Context<Self>) {
515        let shows = |v: &ViewDoc| v.recording.path == path && v.recording.source.as_deref().unwrap_or(source) == source;
516        if !self.doc.active().is_some_and(shows) {
517            if let Some(id) = self.doc.views.iter().find(|v| shows(v)).map(|v| v.id.clone()) {
518                let _ = self.execute(SessionCommand::SelectView { view: id }, cx);
519            } else {
520                let recording = RecordingRef { path: path.to_path_buf(), source: Some(source.to_string()), channels: None };
521                let _ = self.execute(SessionCommand::OpenView { recording, name: None }, cx);
522            }
523        }
524        // The previews of every view of this recording, and the active view's channels
525        let ids: Vec<String> = self.doc.views.iter().map(|v| v.id.clone()).collect();
526        for id in ids {
527            self.rebuild_preview(&id, cx);
528        }
529        self.apply_active_view(cx);
530    }
531
532    // ------------------------------------------------------------------------
533    // Views of the session document on the open recording
534    // ------------------------------------------------------------------------
535
536    /// The active view of the session document.
537    pub fn active_view(&self) -> Option<&ViewDoc> {
538        self.doc.active()
539    }
540
541    /// Whether `view` reads source `source` of the open recording.
542    fn on_open_recording(&self, view: &ViewDoc, source: &str) -> bool {
543        let Some(rec) = &self.recording else { return false };
544        let Some(path) = &rec.path else { return false };
545        let default = rec.sources.default_entry().id.clone();
546        view.recording.path == *path && view.recording.source.as_deref().unwrap_or(&default) == source
547    }
548
549    /// Source id (of the open recording) a view reads.
550    pub fn view_source(&self, view: &ViewDoc) -> String {
551        let default = self.sources().map(|s| s.default_entry().id.clone()).unwrap_or_default();
552        view.recording.source.clone().unwrap_or(default)
553    }
554
555    /// What views of `source` draw: the active view's processed preview when it reads `source`,
556    /// shows `processed` and has enabled ops; else `None` (the raw recording).
557    pub fn signal_for(&self, source: &str) -> Option<Arc<dyn SignalBackend>> {
558        let v = self.doc.active()?;
559        if v.display.show != Show::Processed || !self.on_open_recording(v, source) {
560            return None;
561        }
562        self.previews.get(&v.id).map(|p| p.clone() as Arc<dyn SignalBackend>)
563    }
564
565    /// The session view showing `source` of the open recording with all its channels (made when
566    /// none does; it becomes the active view then); `None` for a recording without a file.
567    pub fn view_for_source(&mut self, source: &str, cx: &mut Context<Self>) -> Option<String> {
568        let rec = self.recording.as_ref()?;
569        let path = rec.path.clone()?;
570        let default = rec.sources.default_entry().id.clone();
571        let shows = |v: &ViewDoc| v.recording.path == path && v.recording.source.as_deref().unwrap_or(&default) == source && v.recording.channels.is_none();
572        if let Some(v) = self.doc.views.iter().find(|v| shows(v)) {
573            return Some(v.id.clone());
574        }
575        let recording = RecordingRef { path: path.clone(), source: Some(source.to_string()), channels: None };
576        self.execute(SessionCommand::OpenView { recording, name: None }, cx).ok()?;
577        let id = self.doc.views.iter().find(|v| shows(v)).map(|v| v.id.clone());
578        if let Some(id) = &id {
579            self.rebuild_preview(id, cx);
580        }
581        id
582    }
583
584    /// Closes session view `id` when it holds no work: no ops (its source feeds it directly),
585    /// all channels, its default name, no plugin data. Views with work stay (the Pipeline shows them).
586    pub fn close_view_if_untouched(&mut self, id: &str, cx: &mut Context<Self>) {
587        let Some(v) = self.doc.view(id) else { return };
588        let direct = self.doc.graph.input(id).and_then(|i| self.doc.graph.node(i)).is_some_and(|n| matches!(n.kind, crate::domain::graph::NodeKind::Source { .. }));
589        let untouched = direct && v.ops.is_empty() && v.recording.channels.is_none() && v.name == crate::domain::session::view_name(&v.recording) && v.plugins.is_empty();
590        if untouched {
591            let _ = self.execute(SessionCommand::CloseView { view: id.to_string() }, cx);
592        }
593    }
594
595    /// What session view `view` draws when it shows its processed preview (it has enabled ops and
596    /// `Show::Processed`); `None`: the recording as stored.
597    pub fn preview_of(&self, view: &str) -> Option<Arc<dyn SignalBackend>> {
598        let v = self.doc.view(view)?;
599        if v.display.show != Show::Processed {
600            return None;
601        }
602        self.previews.get(view).map(|p| p.clone() as Arc<dyn SignalBackend>)
603    }
604
605    /// Whether the visible window of `samples` samples is previewed processed (else raw).
606    pub fn preview_covers(&self, source: &str, samples: u64) -> Option<bool> {
607        let v = self.doc.active()?;
608        if v.display.show != Show::Processed || !self.on_open_recording(v, source) {
609            return None;
610        }
611        self.previews.get(&v.id).map(|p| p.covers(samples))
612    }
613
614    /// Rebuilds the preview of view `id` from its enabled ops (none: no preview).
615    fn rebuild_preview(&mut self, id: &str, cx: &mut Context<Self>) {
616        self.previews.remove(id);
617        let Some(v) = self.doc.view(id).cloned() else { return };
618        let source = self.view_source(&v);
619        if !self.on_open_recording(&v, &source) {
620            return;
621        }
622        let Some(sources) = self.sources().cloned() else { return };
623        let ds = sources.get(&source);
624        let Some(raw) = ds.source.clone() else { return };
625        let plan = match self.view_plan(&v, ds.total_channels) {
626            Ok(p) => p,
627            Err(e) => {
628                self.set_status(format!("{}: {e}", v.name), cx);
629                return;
630            }
631        };
632        if plan.steps.is_empty() {
633            return;
634        }
635        let group = plan.group;
636        let mut stages: Vec<PipelineStage> = plan.steps.iter().map(|(_, st)| st.clone()).collect();
637        // The chain's key, step by step: [source and channels, step 1, step 2, …]
638        let mut key = vec![format!("{source}|{group:?}")];
639        key.extend(plan.steps.into_iter().map(|(k, _)| k));
640        // Whitenings fitted on the signal of the steps before them, as the pipeline does. A fit
641        // reads the recording: it runs in the background (the view draws the raw signal
642        // meanwhile) and the preview is built again when it is done
643        for (at, fit) in plan.fits {
644            let up_to = key[..at + 2].join("\n");
645            let Some(fitted) = self.whiten_cache.get(&up_to).cloned() else {
646                if self.whiten_fitting.insert(up_to.clone()) {
647                    self.set_status(format!("{}: fitting the whitening…", v.name), cx);
648                    let (before, group, raw, view, name) = (stages[..at].to_vec(), group.clone(), raw.clone(), id.to_string(), v.name.clone());
649                    let task = cx.background_spawn(async move {
650                        let target = dspk_core::ComputeTarget::from_env().map_err(|e| e.to_string())?;
651                        dspk_pipeline::fit_whitening(target, raw.as_ref(), &group, &before, &fit).map_err(|e| e.to_string())
652                    });
653                    cx.spawn(async move |this, cx| {
654                        let result = task.await;
655                        let _ = this.update(cx, |s, cx| {
656                            s.whiten_fitting.remove(&up_to);
657                            match result {
658                                Ok(w) => {
659                                    s.whiten_cache.insert(up_to, w);
660                                    s.set_status(format!("{name}: whitening fitted"), cx);
661                                    s.rebuild_preview(&view, cx);
662                                    cx.emit(AppEvent::WindowMoved);
663                                }
664                                Err(e) => s.set_status(format!("{name}: whiten: {e}"), cx),
665                            }
666                        });
667                    })
668                    .detach();
669                }
670                return;
671            };
672            stages[at] = PipelineStage::SpatialWhitening(fitted);
673        }
674        // The longest computed prefix of this chain
675        let found = (2..=key.len()).rev().find_map(|k| self.preview_cache.iter().find(|(c, _)| c[..] == key[..k]).map(|(_, p)| (k, p.clone())));
676        let built = match found {
677            Some((k, p)) if k == key.len() => Ok(p),
678            // A branch reads the shared prefix's rows, in the order that prefix leaves them
679            Some((k, p)) => ProcessedSignal::new(ds.signal().clone(), Arc::new(PreviewSource::new(p.clone())), p.out_channels().to_vec(), stages[k - 1..].to_vec()).map(Arc::new),
680            None => ProcessedSignal::new(ds.signal().clone(), raw, group, stages).map(Arc::new),
681        };
682        match built {
683            Ok(p) => {
684                self.preview_cache.retain(|(c, _)| *c != key);
685                self.preview_cache.push((key, p.clone()));
686                if self.preview_cache.len() > PREVIEW_CACHE {
687                    self.preview_cache.remove(0);
688                }
689                self.previews.insert(id.to_string(), p);
690            }
691            Err(e) => self.set_status(format!("{}: {e}", v.name), cx),
692        }
693    }
694
695    /// How view `v` is previewed, as the pipeline runs it: `group`, the channels it reads (its
696    /// subset, or all `total`, through the remaps that open its chain: they fold into the read
697    /// and may drop); `steps`, its processing on the device with each step's key (a remap between
698    /// processing steps reorders there and keeps every channel); `shown`, the channels it ends
699    /// with (through the remaps after the last processing step: they may drop). Resampling is not
700    /// previewed.
701    pub fn view_plan(&self, v: &ViewDoc, total: usize) -> Result<ViewPlan, String> {
702        let mut group: Vec<usize> = v.recording.channels.clone().unwrap_or_else(|| (0..total).collect());
703        let base_dir = self.doc_path.as_ref().and_then(|p| p.parent().map(Path::to_path_buf)).unwrap_or_default();
704        let ops: Vec<&dspk_pipeline::Node> = v.ops.iter().filter(|o| o.enabled).map(|o| &o.node).collect();
705        let is_remap = |n: &dspk_pipeline::Node| matches!(n, dspk_pipeline::Node::Remap { .. });
706        let map_of = |n: &dspk_pipeline::Node| n.channel_map(Some(&base_dir)).map_err(|e| e.to_string()).map(|m| m.expect("a remap has a map"));
707        let lead = ops.iter().take_while(|n| is_remap(n)).count();
708        for n in &ops[..lead] {
709            let order = map_of(n)?.resolve(group.len()).map_err(|e| e.to_string())?;
710            group = order.into_iter().map(|i| group[i]).collect();
711        }
712        let last_processing = ops.iter().rposition(|n| !is_remap(n));
713        let mut steps = Vec::new();
714        let mut fits = Vec::new();
715        let mut current = group.clone();
716        for (i, n) in ops.iter().enumerate().skip(lead) {
717            let key = serde_json::to_string(n).unwrap_or_default();
718            if is_remap(n) {
719                let order = map_of(n)?.resolve(current.len()).map_err(|e| e.to_string())?;
720                if last_processing.is_some_and(|last| i > last) {
721                    // After the last processing step: the channels shown (may drop)
722                    current = order.into_iter().map(|j| current[j]).collect();
723                    continue;
724                }
725                let mut seen = order.clone();
726                seen.sort_unstable();
727                if seen != (0..current.len()).collect::<Vec<_>>() {
728                    return Err(format!("a remap between processing steps keeps every channel ({}; it reorders them): drop channels in a remap first or last", current.len()));
729                }
730                current = order.iter().map(|&j| current[j]).collect();
731                steps.push((key, PipelineStage::ReorderChannels { order }));
732            } else if let Some(fit) = n.whiten_fit() {
733                n.stage().map_err(|e| e.to_string())?;
734                fits.push((steps.len(), fit));
735                steps.push((key, PipelineStage::SpatialWhitening(dspk_pipeline::unfitted_whitening(current.len()))));
736            } else if let Some(stage) = n.stage().map_err(|e| e.to_string())? {
737                // A grid's operator is made for its electrodes
738                if let PipelineStage::SurfaceLaplacian(lap) = &stage
739                    && lap.num_channels != current.len()
740                {
741                    return Err(format!("the Laplacian's grid has {} electrodes; the view has {} channels", lap.num_channels, current.len()));
742                }
743                steps.push((key, stage));
744            }
745        }
746        Ok(ViewPlan { group, steps, shown: current, fits })
747    }
748
749    /// The channels view `v` shows, in order (see [`Self::view_plan`]).
750    pub fn view_channels(&self, v: &ViewDoc, total: usize) -> Result<Vec<usize>, String> {
751        self.view_plan(v, total).map(|p| p.shown)
752    }
753
754    /// The active view's channels (its subset, through its remaps) become the shown
755    /// selection of its source (once per change of them, so the user's own selection is kept
756    /// otherwise).
757    fn apply_active_view(&mut self, cx: &mut Context<Self>) {
758        let Some(v) = self.doc.active().cloned() else { return };
759        let source = self.view_source(&v);
760        if !self.on_open_recording(&v, &source) {
761            return;
762        }
763        let total = self.sources().map_or(0, |ss| ss.entry(&source).channels);
764        let channels = match self.view_channels(&v, total) {
765            Ok(c) => c,
766            Err(e) => {
767                self.set_status(format!("{}: {e}", v.name), cx);
768                v.recording.channels.clone().unwrap_or_else(|| (0..total).collect())
769            }
770        };
771        let subset = (channels.len() != total || channels.iter().enumerate().any(|(i, &c)| i != c)).then_some(channels);
772        let key = (v.id.clone(), subset.clone());
773        if self.applied_view.as_ref() == Some(&key) {
774            return;
775        }
776        self.applied_view = Some(key);
777        match subset {
778            Some(ch) => self.set_selection(&source, ch, cx),
779            None => self.select_all(&source, cx),
780        }
781    }
782
783    /// What follows a change of the document: previews, the selection, redraws.
784    fn changed(&mut self, change: &Changed, cx: &mut Context<Self>) {
785        match change {
786            Changed::Views => {
787                let ids: Vec<String> = self.doc.views.iter().map(|v| v.id.clone()).collect();
788                self.previews.retain(|id, _| ids.contains(id));
789                if let Some(id) = self.doc.active_view.clone() {
790                    if !self.previews.contains_key(&id) {
791                        self.rebuild_preview(&id, cx);
792                    }
793                }
794                self.picked_op = None;
795                self.apply_active_view(cx);
796                cx.emit(AppEvent::WindowMoved);
797            }
798            Changed::Ops(id) => {
799                self.rebuild_preview(id, cx);
800                // A remap at the head of the chain changes the channels shown
801                if self.doc.active_view.as_deref() == Some(id.as_str()) {
802                    self.apply_active_view(cx);
803                }
804                if self.picked_op.as_ref().is_some_and(|op| self.doc.view(id).is_some_and(|v| v.op(op).is_none())) {
805                    self.picked_op = None;
806                }
807                cx.emit(AppEvent::WindowMoved);
808            }
809            Changed::Display(_) | Changed::Plugin(..) => cx.emit(AppEvent::WindowMoved),
810            Changed::Graph | Changed::Timeline => {}
811        }
812    }
813
814    /// Views redraw (a plugin's overlay changed).
815    pub fn redraw(&mut self, cx: &mut Context<Self>) {
816        cx.emit(AppEvent::WindowMoved);
817        cx.notify();
818    }
819
820    /// The channel the Processing panel is about (`None`: none).
821    pub fn pick_channel(&mut self, channel: Option<usize>, cx: &mut Context<Self>) {
822        self.picked_channel = channel;
823        cx.emit(AppEvent::Picked);
824        cx.notify();
825    }
826
827    /// The op of the active view selected in the Processing panel.
828    pub fn pick_op(&mut self, op: Option<String>, cx: &mut Context<Self>) {
829        self.picked_op = op;
830        cx.emit(AppEvent::Picked);
831        cx.notify();
832    }
833
834    // ------------------------------------------------------------------------
835    // Jobs (one at a time)
836    // ------------------------------------------------------------------------
837
838    /// Starts a job: `start` gets the sender of its messages and returns its cancel handle;
839    /// `done` runs on the UI thread with the file it wrote.
840    /// Starts a job ([`Self::start_job`]) that works on graph nodes `nodes`: they show running on
841    /// the Pipeline canvas until it ends, then done or failed. `false` (nothing marked) when
842    /// another job is running.
843    pub fn start_job_on(
844        &mut self,
845        nodes: impl IntoIterator<Item = String>,
846        label: impl Into<String>,
847        start: impl FnOnce(Box<dyn Fn(JobMessage) + Send + Sync>) -> Cancel,
848        done: impl FnOnce(&mut Self, PathBuf, &mut Context<Self>) + 'static,
849        cx: &mut Context<Self>,
850    ) -> bool {
851        if !self.start_job(label, start, done, cx) {
852            return false;
853        }
854        self.mark_job_nodes(nodes, cx);
855        true
856    }
857
858    /// The running job's graph nodes (only from [`Self::start_job_on`], right after it starts).
859    fn mark_job_nodes(&mut self, ids: impl IntoIterator<Item = String>, cx: &mut Context<Self>) {
860        let since = Instant::now();
861        self.job_nodes = ids.into_iter().collect();
862        for id in &self.job_nodes {
863            self.node_status.insert(id.clone(), NodeRun::Running { line: String::new(), fraction: None, since });
864        }
865        cx.emit(AppEvent::JobChanged);
866    }
867
868    /// The job's nodes: done (with how long it took) or failed.
869    fn finish_job_nodes(&mut self, failed: Option<String>) {
870        for id in std::mem::take(&mut self.job_nodes) {
871            let status = match (&failed, self.node_status.get(&id)) {
872                (Some(e), _) => NodeRun::Failed(e.clone()),
873                (None, Some(NodeRun::Running { since, .. })) => NodeRun::Done { took: since.elapsed() },
874                (None, _) => NodeRun::Done { took: Duration::ZERO },
875            };
876            self.node_status.insert(id, status);
877        }
878    }
879
880    /// The nodes a write of `id` runs: `id` and everything upstream of it.
881    fn chain_nodes(&self, id: &str) -> Vec<String> {
882        let mut ids: Vec<String> = self.doc.graph.ancestors(id).into_iter().collect();
883        ids.push(id.to_string());
884        ids
885    }
886
887    pub fn start_job(
888        &mut self,
889        label: impl Into<String>,
890        start: impl FnOnce(Box<dyn Fn(JobMessage) + Send + Sync>) -> Cancel,
891        done: impl FnOnce(&mut Self, PathBuf, &mut Context<Self>) + 'static,
892        cx: &mut Context<Self>,
893    ) -> bool {
894        if self.job.is_some() {
895            self.set_status("Another job is running: wait for it or cancel it", cx);
896            return false;
897        }
898        let label = label.into();
899        self.last_failed = None;
900        let (tx, rx) = async_channel::unbounded::<JobMessage>();
901        let cancel = start(Box::new(move |m| {
902            let _ = tx.try_send(m);
903        }));
904        let shell_job = kitchen_shell::jobs::start("job", &label, false).ok();
905        self.job = Some(RunningJob { label: label.clone(), fraction: None, line: String::new(), cancel, shell_job });
906        self.set_status(format!("{label}…"), cx);
907        cx.emit(AppEvent::JobChanged);
908        // The frame's cancel button cancels the job (checked every 250 ms while it runs)
909        cx.spawn(async move |this, cx| {
910            loop {
911                cx.background_executor().timer(std::time::Duration::from_millis(250)).await;
912                let running = this.update(cx, |s, cx| {
913                    let Some(j) = &s.job else { return false };
914                    if j.shell_job.as_ref().is_some_and(|h| h.cancelled()) && !j.cancel.cancelled() {
915                        s.cancel_job(cx);
916                    }
917                    true
918                });
919                if !matches!(running, Ok(true)) {
920                    break;
921                }
922            }
923        })
924        .detach();
925        let mut done = Some(done);
926        cx.spawn(async move |this, cx| {
927            while let Ok(m) = rx.recv().await {
928                let finished = matches!(m, JobMessage::Done(_) | JobMessage::Failed(_));
929                let alive = this.update(cx, |s, cx| {
930                    match m {
931                        JobMessage::Progress(line, fraction) => {
932                            if let Some(j) = &mut s.job {
933                                j.line = line.clone();
934                                j.fraction = fraction.or(j.fraction);
935                                if let Some(h) = &j.shell_job {
936                                    h.set(&format!("{label}: {line}"), j.fraction);
937                                }
938                            }
939                            for id in s.job_nodes.clone() {
940                                if let Some(NodeRun::Running { line: l, fraction: f, .. }) = s.node_status.get_mut(&id) {
941                                    *l = line.clone();
942                                    *f = fraction.or(*f);
943                                }
944                            }
945                            s.status = format!("{label}: {line}");
946                            cx.emit(AppEvent::Status);
947                        }
948                        JobMessage::Done(path) => {
949                            s.job = None;
950                            s.finish_job_nodes(None);
951                            s.set_status(format!("{label}: done ({})", file_label(&path)), cx);
952                            if let Some(done) = done.take() {
953                                done(s, path, cx);
954                            }
955                        }
956                        JobMessage::Failed(e) => {
957                            s.job = None;
958                            s.last_failed = Some((label.clone(), e.clone()));
959                            s.finish_job_nodes(Some(e.clone()));
960                            s.set_status(format!("{label}: {e}"), cx);
961                        }
962                    }
963                    cx.emit(AppEvent::JobChanged);
964                });
965                if alive.is_err() || finished {
966                    break;
967                }
968            }
969        })
970        .detach();
971        true
972    }
973
974    pub fn cancel_job(&mut self, cx: &mut Context<Self>) {
975        if let Some(j) = &self.job {
976            j.cancel.cancel();
977            self.set_status(format!("Cancelling {}…", j.label), cx);
978        }
979    }
980
981    /// Writes the chain of graph node `id` (a write or view node) to
982    /// `<recording>.<id>.processed.zarr` next to its recording.
983    pub fn write_node(&mut self, id: &str, cx: &mut Context<Self>) {
984        let Some((recording, ops)) = self.doc.graph.chain(id, &self.doc.recipes) else {
985            self.set_status(format!("{id} is not connected to a recording"), cx);
986            return;
987        };
988        let v = ViewDoc { id: id.to_string(), name: id.to_string(), recording, ops, display: Default::default(), plugins: Default::default() };
989        self.write_view(v, cx);
990    }
991
992    /// The write nodes fed by source node `id` (with their chains).
993    pub fn writes_of(&self, id: &str) -> Vec<String> {
994        let g = &self.doc.graph;
995        g.descendants(id).into_iter().filter(|d| matches!(g.node(d).map(|n| &n.kind), Some(crate::domain::graph::NodeKind::Write {}))).collect()
996    }
997
998    /// Writes every output of source node `id` in one read of its recording.
999    pub fn write_source(&mut self, id: &str, cx: &mut Context<Self>) {
1000        let writes = self.writes_of(id);
1001        if writes.len() < 2 {
1002            if let Some(w) = writes.first().cloned() {
1003                self.write_node(&w, cx);
1004            }
1005            return;
1006        }
1007        let mut chains = Vec::new();
1008        let mut outputs = Vec::new();
1009        for w in &writes {
1010            let Some((recording, ops)) = self.doc.graph.chain(w, &self.doc.recipes) else { continue };
1011            let v = ViewDoc { id: w.clone(), name: w.clone(), recording: recording.clone(), ops: ops.clone(), display: Default::default(), plugins: Default::default() };
1012            if jobs::gives_events(&v) {
1013                // Events are found on their own (a branched run writes signals)
1014                self.set_status(format!("{w} gives events: run it on its own (double-click it)"), cx);
1015                continue;
1016            }
1017            outputs.push(jobs::processed_path(&recording.path, w));
1018            chains.push(ViewDoc { id: w.clone(), name: w.clone(), recording, ops, display: Default::default(), plugins: Default::default() });
1019        }
1020        let n = chains.len();
1021        let nodes: std::collections::BTreeSet<String> = writes.iter().flat_map(|w| self.chain_nodes(w)).collect();
1022        self.start_job_on(
1023            nodes,
1024            format!("Write {n} outputs"),
1025            move |send| jobs::write_branches(chains, outputs, move |m| send(m)),
1026            move |s, _, cx| s.set_status(format!("Written: {n} outputs in one read"), cx),
1027            cx,
1028        );
1029    }
1030
1031    /// Shows view `id` in Explore, opening its recording when another one is open.
1032    pub fn show_view(&mut self, id: &str, cx: &mut Context<Self>) {
1033        let Some(v) = self.doc.view(id).cloned() else {
1034            self.set_status(format!("{id} is not connected to a recording yet"), cx);
1035            return;
1036        };
1037        let _ = self.execute(SessionCommand::SelectView { view: id.to_string() }, cx);
1038        let open = self.recording.as_ref().and_then(|r| r.path.clone());
1039        if open.as_deref() != Some(v.recording.path.as_path()) {
1040            self.open(v.recording.path.clone(), cx);
1041        }
1042        self.set_workspace(Workspace::Explore, cx);
1043        if v.display.layout == crate::domain::session::ViewLayout::Epochs {
1044            cx.emit(AppEvent::ShowEpochs(id.to_string()));
1045        }
1046    }
1047
1048    /// The event set a Write of `view`'s chain saved (the same recording and ops), if any.
1049    pub fn saved_events_of(&self, view: &ViewDoc) -> Option<&crate::domain::epoch::EventSetDoc> {
1050        let g = &self.doc.graph;
1051        g.nodes.iter().filter(|n| matches!(n.kind, crate::domain::graph::NodeKind::Write {})).find_map(|w| {
1052            let (recording, ops) = g.chain(&w.id, &self.doc.recipes)?;
1053            if recording.path != view.recording.path || ops != view.ops {
1054                return None;
1055            }
1056            let table = crate::data::derived::events_group(&recording.path, &w.id);
1057            self.doc.event_sets.iter().find(|s| s.table == table)
1058        })
1059    }
1060
1061    /// Writes the active view (its recording through its enabled ops) to
1062    /// `<recording>.<view>.processed.zarr` next to the recording.
1063    pub fn write_active_view(&mut self, cx: &mut Context<Self>) {
1064        let Some(v) = self.doc.active().cloned() else { return };
1065        self.write_view(v, cx);
1066    }
1067
1068    fn write_view(&mut self, v: ViewDoc, cx: &mut Context<Self>) {
1069        if !v.ops.iter().any(|o| o.enabled) {
1070            self.set_status(format!("{}: no enabled ops to write", v.name), cx);
1071            return;
1072        }
1073        if jobs::gives_events(&v) {
1074            return self.write_events(v, cx);
1075        }
1076        let output = jobs::processed_path(&v.recording.path, &v.id);
1077        let nodes = self.chain_nodes(&v.id);
1078        self.start_job_on(
1079            nodes,
1080            format!("Write {}", v.name),
1081            move |send| jobs::write_view(&v, output, move |m| send(m)),
1082            |s, path, cx| s.set_status(format!("Written: {}", crate::domain::paths::short_path(&path)), cx),
1083            cx,
1084        );
1085    }
1086
1087    /// Finds the events of `v` (a chain ending with Crossings) into the recording's derived store,
1088    /// `events/<write id>`, and adds them to the session as an event set (ticks on its traces).
1089    fn write_events(&mut self, v: ViewDoc, cx: &mut Context<Self>) {
1090        let recording = v.recording.path.clone();
1091        if let Err(e) = crate::data::derived::ensure(&recording, &["/events"]) {
1092            self.set_status(format!("Events of {}: {e}", crate::domain::paths::short_path(&recording)), cx);
1093            return;
1094        }
1095        let output = crate::data::derived::events_group(&recording, &v.id);
1096        let nodes = self.chain_nodes(&v.id);
1097        let name = v.name.clone();
1098        self.start_job_on(
1099            nodes,
1100            format!("Events {}", v.name),
1101            move |send| jobs::write_events(&v, output, move |m| send(m)),
1102            move |s, path, cx| {
1103                // The set keeps its look when it is found again
1104                let set = match s.doc.event_sets.iter().find(|e| e.table == path) {
1105                    Some(e) => e.clone(),
1106                    None => crate::domain::epoch::EventSetDoc::new(recording.clone(), name.clone(), path.clone()),
1107                };
1108                s.add_event_set(set, cx);
1109                let n = s.doc.event_sets.iter().find(|e| e.table == path).and_then(|e| s.events_of(e)).map_or(0, |e| e.len());
1110                s.set_status(format!("{n} events: {}", crate::domain::paths::short_path(&path)), cx);
1111            },
1112            cx,
1113        );
1114    }
1115
1116    // ------------------------------------------------------------------------
1117    // Theme
1118    // ------------------------------------------------------------------------
1119
1120    /// Plot colours: dark with the dark theme, or when the user keeps plots dark.
1121    pub fn palette(&self) -> Palette {
1122        Palette::of(self.dark || self.session.dark_plots)
1123    }
1124
1125    /// Whether the app is drawn dark now (the user's choice or the system's).
1126    pub fn is_dark(&self) -> bool {
1127        self.dark
1128    }
1129
1130    /// The theme the app is drawn with now.
1131    pub fn set_dark(&mut self, dark: bool, cx: &mut Context<Self>) {
1132        let before = self.palette();
1133        self.dark = dark;
1134        if self.palette() != before {
1135            cx.emit(AppEvent::PaletteChanged);
1136        }
1137    }
1138
1139    /// The user's theme choice (`None`: follow the system), saved.
1140    pub fn set_theme_choice(&mut self, dark: Option<bool>, cx: &mut Context<Self>) {
1141        self.session.dark = dark;
1142        self.save_session();
1143        cx.notify();
1144    }
1145
1146    /// The accent the user picked (`None`: lime), saved. The caller applies the look
1147    /// (`DspApp::apply_theme`).
1148    pub fn set_accent(&mut self, accent: Option<kitchen_ui::Rgb>, cx: &mut Context<Self>) {
1149        self.session.accent = accent;
1150        self.save_session();
1151        cx.notify();
1152    }
1153
1154    /// The servers the user works with (Settings), saved.
1155    pub fn set_servers(&mut self, servers: Vec<crate::data::remote::ServerProfile>, cx: &mut Context<Self>) {
1156        crate::data::remote::set_profiles(&servers);
1157        self.session.servers = servers;
1158        self.save_session();
1159        cx.notify();
1160    }
1161
1162    /// Where results fetched from servers go (`None`: the default), saved.
1163    pub fn set_results_dir(&mut self, dir: Option<std::path::PathBuf>, cx: &mut Context<Self>) {
1164        self.session.results_dir = dir;
1165        self.save_session();
1166        cx.notify();
1167    }
1168
1169    /// Whether the last recording opens at start, saved.
1170    pub fn set_reopen_last(&mut self, on: bool, cx: &mut Context<Self>) {
1171        self.session.reopen_last = on;
1172        self.save_session();
1173        cx.notify();
1174    }
1175
1176    /// Forgets `path` from the recent lists (`None`: all of them), saved. The files stay.
1177    pub fn forget_recent(&mut self, path: Option<&std::path::Path>, cx: &mut Context<Self>) {
1178        match path {
1179            Some(p) => self.session.forget_recent(p),
1180            None => self.session.clear_recent(),
1181        }
1182        self.save_session();
1183        cx.notify();
1184    }
1185
1186    /// The compute runtime for the next start (`None`: the first compiled in), saved.
1187    pub fn set_runtime(&mut self, runtime: Option<String>, cx: &mut Context<Self>) {
1188        self.session.runtime = runtime;
1189        self.save_session();
1190        cx.notify();
1191    }
1192
1193    pub fn set_dark_plots(&mut self, on: bool, cx: &mut Context<Self>) {
1194        let before = self.palette();
1195        self.session.dark_plots = on;
1196        self.save_session();
1197        if self.palette() != before {
1198            cx.emit(AppEvent::PaletteChanged);
1199        }
1200        cx.notify();
1201    }
1202
1203    pub fn sources(&self) -> Option<&Arc<SourceSet>> {
1204        self.recording.as_ref().map(|r| &r.sources)
1205    }
1206
1207    /// At the first launch, says how to avoid the slow first run.
1208    pub fn suggest_tuning_once(&mut self, cx: &mut Context<Self>) {
1209        if !self.session.tune_suggested {
1210            self.session.tune_suggested = true;
1211            self.save_session();
1212            self.set_status("First run on this device? File → Tune this device makes first runs and previews faster", cx);
1213        }
1214    }
1215
1216    fn save_session(&mut self) {
1217        self.session.workspace = self.workspace;
1218        if let Err(e) = self.session.save(self.session_path.as_deref()) {
1219            tracing::warn!("could not save the session: {e}");
1220        }
1221    }
1222
1223    /// Remembers whether Explore shows the Pipeline docked.
1224    pub fn set_pipeline_dock(&mut self, on: bool) {
1225        if self.session.explore.pipeline_dock != on {
1226            self.session.explore.pipeline_dock = on;
1227            self.save_session();
1228        }
1229    }
1230
1231    pub fn set_explore_docks(&mut self, left: bool, right: bool, bottom: bool) {
1232        let ex = &mut self.session.explore;
1233        if (ex.left_open, ex.right_open, ex.bottom_open) != (left, right, bottom) {
1234            ex.left_open = left;
1235            ex.right_open = right;
1236            ex.bottom_open = bottom;
1237            self.save_session();
1238        }
1239    }
1240
1241    /// The panel shown on the right side bar (saved).
1242    /// A resizable panel's size (`default` until the user drags it).
1243    pub fn panel_width(&self, key: &str, default: f32) -> f32 {
1244        self.session.panel_widths.get(key).copied().unwrap_or(default)
1245    }
1246
1247    /// Whether a side panel shows (`true` until the user hides it).
1248    pub fn panel_open(&self, key: &str) -> bool {
1249        self.session.panel_open.get(key).copied().unwrap_or(true)
1250    }
1251
1252    pub fn set_panel_open(&mut self, key: &str, open: bool, cx: &mut Context<Self>) {
1253        self.session.panel_open.insert(key.to_string(), open);
1254        self.save_session();
1255        cx.notify();
1256    }
1257
1258    pub fn set_panel_width(&mut self, key: &str, width: f32, cx: &mut Context<Self>) {
1259        if self.session.panel_widths.get(key) != Some(&width) {
1260            self.session.panel_widths.insert(key.to_string(), width);
1261            self.save_session();
1262            cx.notify();
1263        }
1264    }
1265
1266    pub fn set_explore_right(&mut self, key: &str) {
1267        if self.session.explore.right_panel != key {
1268            self.session.explore.right_panel = key.to_string();
1269            self.save_session();
1270        }
1271    }
1272
1273    /// A plugin's own state changed (its panels redraw; views do not).
1274    pub fn plugin_changed(&mut self, cx: &mut Context<Self>) {
1275        cx.emit(AppEvent::PluginChanged);
1276        cx.notify();
1277    }
1278
1279    pub fn set_explore_views(&mut self, views: Vec<crate::data::preferences::SavedTimeView>) {
1280        if self.session.explore.views != views {
1281            self.session.explore.views = views;
1282            self.save_session();
1283        }
1284    }
1285
1286    #[allow(dead_code)] // Curation (parked) opens sortings
1287    pub fn push_recent_sorting(&mut self, path: &Path) {
1288        self.session.push_recent_sorting(path);
1289        self.save_session();
1290    }
1291
1292    #[allow(dead_code)] // set by spike extraction, which this branch does not run yet
1293    pub fn set_events(&mut self, events: Arc<SpikeEventStore>, cx: &mut Context<Self>) {
1294        if let Some(rec) = &mut self.recording {
1295            rec.events = events;
1296            cx.emit(AppEvent::WindowMoved);
1297        }
1298    }
1299
1300    /// Records the compute device found at start (the status bar and Settings › Compute show it).
1301    pub fn set_device(&mut self, device: crate::data::device::DeviceInfo, cx: &mut Context<Self>) {
1302        self.device = Some(device);
1303        cx.emit(AppEvent::Status);
1304        cx.notify();
1305    }
1306
1307    pub fn set_status(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
1308        self.status = text.into();
1309        cx.emit(AppEvent::Status);
1310    }
1311
1312    /// Latest frame's numbers (no event: the status bar observes the store).
1313    pub fn set_render_info(&mut self, text: String, cx: &mut Context<Self>) {
1314        if self.render_info != text {
1315            self.render_info = text;
1316            cx.notify();
1317        }
1318    }
1319
1320    // ------------------------------------------------------------------------
1321    // Recording
1322    // ------------------------------------------------------------------------
1323
1324    /// Lists and opens `path`'s sources on a background thread.
1325    pub fn open(&mut self, path: PathBuf, cx: &mut Context<Self>) {
1326        if self.recording.as_ref().is_some_and(|r| r.path.as_ref() == Some(&path)) {
1327            return;
1328        }
1329        if let Some(at) = self.loaded.iter().position(|r| r.path.as_ref() == Some(&path)) {
1330            let recording = self.loaded.remove(at);
1331            self.install(recording, cx);
1332            return;
1333        }
1334        let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned());
1335        self.opening = Some(name.clone());
1336        self.set_status(format!("Opening {name}…"), cx);
1337        let task = cx.background_spawn({
1338            let path = path.clone();
1339            async move { SourceSet::open(&path) }
1340        });
1341        cx.spawn(async move |this, cx| {
1342            let result = task.await;
1343            let _ = this.update(cx, |store, cx| {
1344                store.opening = None;
1345                match result {
1346                    Ok(sources) => {
1347                        store.session.push_recent(&path);
1348                        let source = sources.default_entry().id.clone();
1349                        store.install(Recording::new(sources, Some(path.clone())), cx);
1350                        store.ensure_view(&path, &source, cx);
1351                        store.load_epochs(&path, cx);
1352                        store.load_event_sets(&path, cx);
1353                        store.read_event_tables();
1354                    }
1355                    Err(e) => store.set_status(format!("Could not open {name}: {e:#}"), cx),
1356                }
1357            });
1358        })
1359        .detach();
1360    }
1361
1362    /// Loads the recording at `path` beside the open one (it stays the active one), for views of
1363    /// both; [`AppEvent::RecordingLoaded`] when it is ready (at once when already loaded).
1364    pub fn load_beside(&mut self, path: PathBuf, cx: &mut Context<Self>) {
1365        if self.recordings().any(|r| r.path.as_ref() == Some(&path)) {
1366            cx.emit(AppEvent::RecordingLoaded(path));
1367            return;
1368        }
1369        let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned());
1370        self.set_status(format!("Loading {name}…"), cx);
1371        let task = cx.background_spawn({
1372            let path = path.clone();
1373            async move { SourceSet::open(&path) }
1374        });
1375        cx.spawn(async move |this, cx| {
1376            let result = task.await;
1377            let _ = this.update(cx, |store, cx| match result {
1378                Ok(sources) => {
1379                    store.loaded.retain(|r| r.path.as_ref() != Some(&path));
1380                    store.loaded.push(Recording::new(sources, Some(path.clone())));
1381                    store.set_status(format!("Loaded {name} beside the open recording"), cx);
1382                    cx.emit(AppEvent::RecordingLoaded(path));
1383                }
1384                Err(e) => store.set_status(format!("Could not load {name}: {e:#}"), cx),
1385            });
1386        })
1387        .detach();
1388    }
1389
1390    /// The sources of the loaded recording at `path` (the open one, or one loaded beside it).
1391    pub fn sources_of(&self, path: &Path) -> Option<&Arc<SourceSet>> {
1392        self.recordings().find(|r| r.path.as_deref() == Some(path)).map(|r| &r.sources)
1393    }
1394
1395    /// Seconds to add to the open recording's time to get `path`'s: the difference of their
1396    /// placements on the session's time (0 when either is not placed).
1397    pub fn time_offset_of(&self, path: &Path) -> f64 {
1398        let open = self.recording.as_ref().and_then(|r| r.path.clone());
1399        let at = |p: &Path| self.doc.placements.iter().find(|pl| pl.path == p).map(|pl| pl.start_sec);
1400        match (open.as_deref().and_then(at), at(path)) {
1401            (Some(a), Some(b)) => a - b,
1402            _ => 0.0,
1403        }
1404    }
1405
1406    /// Opens the recording at `path` on a server (a `dspk://` path: drawn from the server's
1407    /// pyramid, read there, nothing copied whole), like any recording: a view, recent files.
1408    pub fn open_remote(&mut self, profile: crate::data::remote::ServerProfile, path: String, cx: &mut Context<Self>) {
1409        crate::data::remote::set_profiles(&self.session.servers);
1410        self.open(crate::data::remote::uri(&profile.name, &path), cx);
1411    }
1412
1413    /// Opens a procedural recording (noise, hum, drifting units) computed on demand.
1414    pub fn open_synthetic(&mut self, channels: usize, sample_rate: f64, duration_sec: f64, cx: &mut Context<Self>) {
1415        match Dataset::procedural(channels, sample_rate, duration_sec) {
1416            Ok(ds) => self.install(Recording::new(SourceSet::single(ds), None), cx),
1417            Err(e) => self.set_status(format!("Could not make a synthetic recording: {e:#}"), cx),
1418        }
1419    }
1420
1421    /// Installs a recording directly (tests, command line).
1422    pub fn install(&mut self, recording: Recording, cx: &mut Context<Self>) {
1423        self.stop_clock();
1424        self.timeline = TimelineState::new(recording.sources.extent_sec());
1425        self.selection = recording.sources.entries().iter().map(|e| (e.id.clone(), (0..e.channels).collect())).collect();
1426        self.pyramids.clear();
1427        self.previews.clear();
1428        self.preview_cache.clear();
1429        self.whiten_cache.clear();
1430        self.applied_view = None;
1431        self.picked_channel = None;
1432        self.picked_op = None;
1433        self.status = format!("Opened {} ({})", recording.name, recording.summary());
1434        // The one it replaces stays loaded (files only: a synthetic one is gone once replaced)
1435        if let Some(old) = self.recording.take().filter(|r| r.path.is_some() && r.path != recording.path) {
1436            self.loaded.retain(|r| r.path != old.path);
1437            self.loaded.push(old);
1438        }
1439        self.loaded.retain(|r| r.path.is_none() || r.path != recording.path);
1440        self.recording = Some(recording);
1441        self.save_session();
1442        cx.emit(AppEvent::RecordingChanged);
1443        cx.emit(AppEvent::Status);
1444    }
1445
1446    /// Shows `source` of the recording at `path` in the Inspector (`None`: back to the view).
1447    pub fn inspect(&mut self, what: Option<(PathBuf, String)>, cx: &mut Context<Self>) {
1448        if self.inspected != what {
1449            let show = what.is_some();
1450            self.inspected = what;
1451            cx.emit(if show { AppEvent::Picked } else { AppEvent::Status });
1452            cx.notify();
1453        }
1454    }
1455
1456    /// Every loaded recording, the active one first.
1457    pub fn recordings(&self) -> impl Iterator<Item = &Recording> {
1458        self.recording.iter().chain(self.loaded.iter())
1459    }
1460
1461    /// Unloads the recording at `path` (`None`: the active one). Its views stay in the session
1462    /// document; closing the active one switches to the last loaded, or to no recording.
1463    pub fn close_recording(&mut self, path: Option<&Path>, cx: &mut Context<Self>) {
1464        let active = self.recording.as_ref().is_some_and(|r| path.is_none() || r.path.as_deref() == path);
1465        if !active {
1466            self.loaded.retain(|r| r.path.as_deref() != path);
1467            cx.emit(AppEvent::RecordingChanged);
1468            return;
1469        }
1470        let closed = self.recording.take().map(|r| r.name).unwrap_or_default();
1471        if let Some(next) = self.loaded.pop() {
1472            self.install(next, cx);
1473        } else {
1474            self.stop_clock();
1475            self.timeline = TimelineState::new(1.0);
1476            self.selection.clear();
1477            self.pyramids.clear();
1478            self.previews.clear();
1479            self.preview_cache.clear();
1480        self.whiten_cache.clear();
1481            self.applied_view = None;
1482            self.picked_channel = None;
1483            self.picked_op = None;
1484            cx.emit(AppEvent::RecordingChanged);
1485        }
1486        self.set_status(format!("Closed {closed}"), cx);
1487    }
1488
1489    pub fn set_workspace(&mut self, workspace: Workspace, cx: &mut Context<Self>) {
1490        if self.workspace != workspace {
1491            self.workspace = workspace;
1492            self.save_session();
1493            cx.emit(AppEvent::WorkspaceChanged);
1494        }
1495    }
1496
1497    /// Views of `source` look at `focus_sec`: its backend builds the min/max pyramid there first
1498    /// (the build starts on the first call; later calls move the focus). Progress is followed
1499    /// once per source and emitted as [`AppEvent::PyramidProgress`].
1500    pub fn focus_pyramid(&mut self, source: &str, focus_sec: f64, cx: &mut Context<Self>) {
1501        let Some(sources) = self.sources().cloned() else { return };
1502        let ds = sources.get(source);
1503        let signal = ds.signal().clone();
1504        let progress = signal.progress();
1505        if progress.done >= progress.total {
1506            return;
1507        }
1508        if !self.pyramids.contains_key(source) {
1509            self.pyramids.insert(source.to_string(), (progress.done, progress.total));
1510            let (tx, rx) = async_channel::unbounded::<Progress>();
1511            let id = source.to_string();
1512            cx.spawn(async move |this, cx| {
1513                while let Ok(p) = rx.recv().await {
1514                    let alive = this.update(cx, |s, cx| {
1515                        if let Some(entry) = s.pyramids.get_mut(&id) {
1516                            *entry = (p.done, p.total);
1517                            cx.emit(AppEvent::PyramidProgress(id.clone()));
1518                        }
1519                    });
1520                    if alive.is_err() {
1521                        break;
1522                    }
1523                }
1524            })
1525            .detach();
1526            signal.watch(Arc::new(move |p| {
1527                let _ = tx.try_send(p);
1528            }));
1529        }
1530        let focus = ((focus_sec - ds.start_time_sec) * ds.sample_rate).max(0.0) as u64;
1531        signal.focus(focus);
1532    }
1533
1534    /// `Building overview 12 / 45 s` while the default source's pyramid builds.
1535    pub fn pyramid_label(&self) -> Option<String> {
1536        let sources = self.sources()?;
1537        let e = sources.default_entry();
1538        let &(done, total) = self.pyramids.get(&e.id)?;
1539        let hz = e.sample_rate.rate_hz();
1540        (done < total && hz > 0.0).then(|| format!("Building overview {:.0} / {:.0} s", done as f64 / hz, total as f64 / hz))
1541    }
1542
1543    // ------------------------------------------------------------------------
1544    // Channel selection (shared by every view of a source)
1545    // ------------------------------------------------------------------------
1546
1547    /// Channels of `source` shown, in display order.
1548    pub fn selection(&self, source: &str) -> &[usize] {
1549        self.selection.get(source).map_or(&[], Vec::as_slice)
1550    }
1551
1552    fn channels_of(&self, source: &str) -> usize {
1553        self.sources().and_then(|s| s.entries().iter().find(|e| e.id == source)).map_or(0, |e| e.channels)
1554    }
1555
1556    /// Replaces the selection (duplicates and out-of-range channels dropped, order kept).
1557    pub fn set_selection(&mut self, source: &str, channels: impl IntoIterator<Item = usize>, cx: &mut Context<Self>) {
1558        let total = self.channels_of(source);
1559        let mut seen = vec![false; total];
1560        let list: Vec<usize> = channels.into_iter().filter(|&c| c < total && !std::mem::replace(&mut seen[c], true)).collect();
1561        self.selection.insert(source.to_string(), list);
1562        cx.emit(AppEvent::SelectionChanged(source.to_string()));
1563    }
1564
1565    /// Adds or removes one channel (added channels keep ascending order).
1566    pub fn toggle_channel(&mut self, source: &str, channel: usize, cx: &mut Context<Self>) {
1567        let mut list = self.selection(source).to_vec();
1568        match list.iter().position(|&c| c == channel) {
1569            Some(i) => {
1570                list.remove(i);
1571            }
1572            None => {
1573                let at = list.partition_point(|&c| c < channel);
1574                list.insert(at, channel);
1575            }
1576        }
1577        self.set_selection(source, list, cx);
1578    }
1579
1580    pub fn select_all(&mut self, source: &str, cx: &mut Context<Self>) {
1581        let total = self.channels_of(source);
1582        self.set_selection(source, 0..total, cx);
1583    }
1584
1585    pub fn select_none(&mut self, source: &str, cx: &mut Context<Self>) {
1586        self.set_selection(source, [], cx);
1587    }
1588
1589    pub fn select_invert(&mut self, source: &str, cx: &mut Context<Self>) {
1590        let total = self.channels_of(source);
1591        let keep: Vec<usize> = (0..total).filter(|c| !self.selection(source).contains(c)).collect();
1592        self.set_selection(source, keep, cx);
1593    }
1594
1595    /// Applies a range expression (`0-31, 40`); the error message to show otherwise.
1596    pub fn select_ranges(&mut self, source: &str, text: &str, cx: &mut Context<Self>) -> Result<(), String> {
1597        let channels = parse_channel_ranges(text, self.channels_of(source))?;
1598        self.set_selection(source, channels, cx);
1599        Ok(())
1600    }
1601
1602    // ------------------------------------------------------------------------
1603    // Timeline (one for the whole app)
1604    // ------------------------------------------------------------------------
1605
1606    fn window_moved(&mut self, cx: &mut Context<Self>) {
1607        cx.emit(AppEvent::WindowMoved);
1608        cx.emit(AppEvent::PlaybackChanged);
1609    }
1610
1611    /// Moves the window by `dt` seconds (the playhead stays).
1612    pub fn pan_time(&mut self, dt: f64, cx: &mut Context<Self>) {
1613        let before = self.timeline.window_start_sec;
1614        self.timeline.pan_time(dt);
1615        if self.timeline.window_start_sec != before {
1616            cx.emit(AppEvent::WindowMoved);
1617        }
1618    }
1619
1620    /// Moves the window by a fraction of its length.
1621    pub fn pan_fraction(&mut self, fraction: f64, cx: &mut Context<Self>) {
1622        self.pan_time(fraction * self.timeline.visible_window_sec, cx);
1623    }
1624
1625    /// Scales the window by `factor`, keeping the time at `anchor` (0 = start, 1 = end) in place.
1626    pub fn zoom_at(&mut self, factor: f64, anchor: f64, cx: &mut Context<Self>) {
1627        self.timeline.zoom_at(factor, anchor);
1628        cx.emit(AppEvent::WindowMoved);
1629    }
1630
1631    /// Sets the window to `[start, end]` seconds.
1632    pub fn set_window(&mut self, start: f64, end: f64, cx: &mut Context<Self>) {
1633        self.timeline.set_window_range(start.min(end), start.max(end));
1634        cx.emit(AppEvent::WindowMoved);
1635    }
1636
1637    /// Puts the playhead at `t` seconds (the window follows it).
1638    pub fn scrub_to(&mut self, t: f64, cx: &mut Context<Self>) {
1639        self.timeline.scrub_to(t);
1640        self.window_moved(cx);
1641    }
1642
1643    pub fn jump_to_start(&mut self, cx: &mut Context<Self>) {
1644        self.scrub_to(0.0, cx);
1645    }
1646
1647    pub fn jump_to_end(&mut self, cx: &mut Context<Self>) {
1648        self.scrub_to(self.timeline.total_duration_sec, cx);
1649    }
1650
1651    /// Steps the playhead by a tenth of the window.
1652    pub fn step(&mut self, forward: bool, cx: &mut Context<Self>) {
1653        let step = self.timeline.visible_window_sec * 0.1;
1654        if forward {
1655            self.timeline.step_forward(step);
1656        } else {
1657            self.timeline.step_backward(step);
1658        }
1659        self.window_moved(cx);
1660    }
1661
1662    pub fn toggle_loop(&mut self, cx: &mut Context<Self>) {
1663        self.timeline.toggle_loop();
1664        cx.emit(AppEvent::PlaybackChanged);
1665    }
1666
1667    pub fn set_speed(&mut self, speed: f64, cx: &mut Context<Self>) {
1668        self.timeline.playback_speed = speed;
1669        cx.emit(AppEvent::PlaybackChanged);
1670    }
1671
1672    pub fn toggle_play(&mut self, cx: &mut Context<Self>) {
1673        if self.recording.is_none() {
1674            return;
1675        }
1676        self.timeline.toggle_play();
1677        if self.timeline.is_playing {
1678            self.start_clock(cx);
1679        } else {
1680            self.stop_clock();
1681        }
1682        cx.emit(AppEvent::PlaybackChanged);
1683    }
1684
1685    fn stop_clock(&mut self) {
1686        self.timeline.is_playing = false;
1687        self.clock = None;
1688    }
1689
1690    /// Advances playback every [`TICK`] while playing; dropped (and so stopped) on pause.
1691    fn start_clock(&mut self, cx: &mut Context<Self>) {
1692        self.clock = Some(cx.spawn(async move |this, cx| {
1693            let mut last = Instant::now();
1694            loop {
1695                cx.background_executor().timer(TICK).await;
1696                let now = Instant::now();
1697                let dt = now.duration_since(last).as_secs_f64();
1698                last = now;
1699                let playing = this.update(cx, |store, cx| {
1700                    store.timeline.advance(dt);
1701                    store.window_moved(cx);
1702                    store.timeline.is_playing
1703                });
1704                if !matches!(playing, Ok(true)) {
1705                    break;
1706                }
1707            }
1708        }));
1709    }
1710}
1711
1712/// What differs between two documents, as the changes a command would have reported.
1713fn diff(before: &SessionDoc, after: &SessionDoc) -> Vec<Changed> {
1714    let mut out = diff_views(before, after);
1715    if before.graph != after.graph || before.recipes != after.recipes {
1716        out.push(Changed::Graph);
1717    }
1718    if before.epochs != after.epochs || before.placements != after.placements {
1719        out.push(Changed::Timeline);
1720    }
1721    out
1722}
1723
1724fn diff_views(before: &SessionDoc, after: &SessionDoc) -> Vec<Changed> {
1725    let ids = |d: &SessionDoc| d.views.iter().map(|v| (v.id.clone(), v.name.clone(), v.recording.clone())).collect::<Vec<_>>();
1726    if ids(before) != ids(after) || before.active_view != after.active_view {
1727        // Chains of the views kept may have changed too
1728        let mut out = vec![Changed::Views];
1729        for b in &after.views {
1730            if before.view(&b.id).is_some_and(|a| a.ops != b.ops) {
1731                out.push(Changed::Ops(b.id.clone()));
1732            }
1733        }
1734        return out;
1735    }
1736    let mut out = Vec::new();
1737    for (a, b) in before.views.iter().zip(&after.views) {
1738        if a.ops != b.ops {
1739            out.push(Changed::Ops(b.id.clone()));
1740        }
1741        if a.display != b.display {
1742            out.push(Changed::Display(b.id.clone()));
1743        }
1744        for id in a.plugins.keys().chain(b.plugins.keys()) {
1745            if a.plugins.get(id) != b.plugins.get(id) && !out.contains(&Changed::Plugin(b.id.clone(), id.clone())) {
1746                out.push(Changed::Plugin(b.id.clone(), id.clone()));
1747            }
1748        }
1749    }
1750    out
1751}
1752
1753/// Seconds as `1 h 02 min`, `3 min 05 s`, `4.20 s` or `33 ms`.
1754pub fn duration(seconds: f64) -> String {
1755    let s = seconds.max(0.0);
1756    if s >= 3600.0 {
1757        format!("{} h {:02} min", (s / 3600.0) as u64, ((s % 3600.0) / 60.0) as u64)
1758    } else if s >= 60.0 {
1759        format!("{} min {:02} s", (s / 60.0) as u64, (s % 60.0) as u64)
1760    } else if s >= 1.0 {
1761        format!("{s:.2} s")
1762    } else {
1763        format!("{:.0} ms", s * 1e3)
1764    }
1765}
1766
1767/// The file name of `path` for labels.
1768pub fn file_label(path: &Path) -> String {
1769    path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned())
1770}