1use 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
26const PREVIEW_CACHE: usize = 6;
28const UNDO_DEPTH: usize = 200;
30const TICK: Duration = Duration::from_millis(16);
32const DOCUMENT_POLL: Duration = Duration::from_millis(500);
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum AppEvent {
37 RecordingChanged,
39 RecordingLoaded(PathBuf),
41 ShowEpochs(String),
43 WindowMoved,
45 PlaybackChanged,
47 SelectionChanged(String),
49 WorkspaceChanged,
50 PaletteChanged,
52 PyramidProgress(String),
54 Status,
56 Document(Changed),
58 Picked,
60 JobChanged,
62 PluginChanged,
64 Floating,
66}
67
68#[derive(Debug, Clone, PartialEq)]
70pub enum Floating {
71 Metadata { path: PathBuf, source: String },
73 Epoch(crate::domain::epoch::EpochDoc),
75}
76
77pub struct RunningJob {
79 pub label: String,
80 pub fraction: Option<f32>,
81 pub line: String,
82 pub cancel: Cancel,
83 pub shell_job: Option<kitchen_shell::jobs::JobHandle>,
86}
87
88#[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
96pub struct Recording {
98 pub sources: Arc<SourceSet>,
99 pub name: String,
100 pub path: Option<PathBuf>,
102 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 pub fn summary(&self) -> String {
117 self.summary_of(&self.sources.default_entry().id)
118 }
119
120 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
129pub struct ViewPlan {
131 pub group: Vec<usize>,
133 pub steps: Vec<(String, PipelineStage)>,
135 pub shown: Vec<usize>,
137 pub fits: Vec<(usize, dspk_pipeline::WhitenFit)>,
139}
140
141pub struct Store {
142 pub recording: Option<Recording>,
144 pub loaded: Vec<Recording>,
146 pub inspected: Option<(PathBuf, String)>,
148 pub timeline: TimelineState,
149 selection: HashMap<String, Vec<usize>>,
151 pub workspace: Workspace,
152 pub session: Session,
153 session_path: Option<PathBuf>,
154 pub opening: Option<String>,
156 pub status: String,
157 pub render_info: String,
159 clock: Option<Task<()>>,
161 dark: bool,
163 pub pyramids: HashMap<String, (u64, u64)>,
165 pub doc: SessionDoc,
167 doc_path: Option<PathBuf>,
168 doc_text: String,
170 _doc_watch: Option<Task<()>>,
172 previews: HashMap<String, Arc<ProcessedSignal>>,
174 preview_cache: Vec<(Vec<String>, Arc<ProcessedSignal>)>,
177 whiten_cache: HashMap<String, dspk_ops::spatial::SpatialWhitening>,
180 whiten_fitting: std::collections::HashSet<String>,
182 pub picked_channel: Option<usize>,
184 pub picked_op: Option<String>,
185 pub job: Option<RunningJob>,
186 pub device: Option<crate::data::device::DeviceInfo>,
188 pub last_failed: Option<(String, String)>,
190 job_nodes: Vec<String>,
192 pub node_status: HashMap<String, NodeRun>,
193 pub floating: Vec<Floating>,
195 history: Vec<SessionDoc>,
197 future: Vec<SessionDoc>,
198 applied_view: Option<(String, Option<Vec<usize>>)>,
200 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 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 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 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 for change in diff(&before, &self.doc) {
313 self.changed(&change, cx);
314 cx.emit(AppEvent::Document(change));
315 }
316 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn active_view(&self) -> Option<&ViewDoc> {
538 self.doc.active()
539 }
540
541 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 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 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 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 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 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 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 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 let mut key = vec![format!("{source}|{group:?}")];
639 key.extend(plan.steps.into_iter().map(|(k, _)| k));
640 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 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 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 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 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 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 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 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 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 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 pub fn redraw(&mut self, cx: &mut Context<Self>) {
816 cx.emit(AppEvent::WindowMoved);
817 cx.notify();
818 }
819
820 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn palette(&self) -> Palette {
1122 Palette::of(self.dark || self.session.dark_plots)
1123 }
1124
1125 pub fn is_dark(&self) -> bool {
1127 self.dark
1128 }
1129
1130 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 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 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 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 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 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 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 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 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 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 pub fn panel_width(&self, key: &str, default: f32) -> f32 {
1244 self.session.panel_widths.get(key).copied().unwrap_or(default)
1245 }
1246
1247 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 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)] 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)] 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 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 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 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 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 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 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 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 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 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 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 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 pub fn recordings(&self) -> impl Iterator<Item = &Recording> {
1458 self.recording.iter().chain(self.loaded.iter())
1459 }
1460
1461 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 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 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 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 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 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 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 fn window_moved(&mut self, cx: &mut Context<Self>) {
1607 cx.emit(AppEvent::WindowMoved);
1608 cx.emit(AppEvent::PlaybackChanged);
1609 }
1610
1611 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 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 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 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 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 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 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
1712fn 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 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
1753pub 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
1767pub 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}