Skip to main content

dspk_app_core/plugin/
mod.rs

1//! What a domain plugin adds to the core app (electrophysiology: `dspk-app-synapse`). The core
2//! works on any multichannel signal; a plugin brings its domain:
3//!
4//! | Extension | The core asks | e.g. electrophysiology |
5//! |---|---|---|
6//! | [`SourceOpener`] | open this file (before dspk-io's generic formats) | SpikeGLX, NWB, with probes |
7//! | [`ChannelLayout`] | how channels group, what to call them | shanks, site positions |
8//! | [`AppPlugin::synthetic`] | a recording made up on demand | drifting units, noise, hum |
9//! | [`AppPlugin::trace_overlay`] | what to draw over the traces of a window | spikes coloured by unit |
10//! | [`AppPlugin::workspace`] | the body of a workspace tab | Sorting |
11//! | [`AppPlugin::recording_epochs`] | the epochs a recording file holds | NWB `/intervals` (epochs, trials) |
12//! | [`AppPlugin::aligned`] | what to show in Analytics around a set of events | units: raster and PETH |
13//! | [`AppPlugin::panel`], [`AppPlugin::view_section`] | a panel on the right side bar, for the active view | Sorting: the linked sorting, units, Sort… |
14//! | per-view data in the session document | `views[i].plugins.<id>` (any JSON) | the linked `.sorting.zarr` |
15//!
16//! Plugins are compiled in (Rust has no stable plugin ABI): the `dspk-app` binary registers the
17//! ones it is built with ([`register`]) before the app starts. Programs under another licence
18//! (the GPL sorters) stay separate processes a plugin runs; they are never linked.
19
20use std::ops::Range;
21use std::path::Path;
22use std::sync::{Arc, OnceLock};
23
24use dspk_core::{DspResult, RecordingSource};
25use dspk_io::SourceEntry;
26use gpui_kit::{AnyElement, AnyView, App, Entity, Window};
27
28use crate::data::store::Store;
29use crate::domain::session::ViewDoc;
30use crate::navigation::route::Workspace;
31use crate::ui::plot::canvas::Pixel;
32
33/// What a view's data holds beyond its continuous signal (traces and heatmaps need only the
34/// signal): spike times (the Spikes layer) and units (the Raster layer).
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct Layers {
37    pub spikes: bool,
38    pub units: bool,
39}
40
41impl Layers {
42    fn or(self, other: Layers) -> Layers {
43        Layers { spikes: self.spikes || other.spikes, units: self.units || other.units }
44    }
45}
46
47/// What the installed plugins can draw for session view `view`, together.
48pub fn layers(store: &Store, view: &ViewDoc, cx: &App) -> Layers {
49    plugins().iter().fold(Layers::default(), |acc, p| acc.or(p.layers(store, view, cx)))
50}
51
52/// Channels in groups (e.g. the shanks of a probe) and a label per channel (e.g. its site).
53#[derive(Debug, Clone, PartialEq, Default)]
54pub struct ChannelLayout {
55    /// What a group is called in lists (`Shank`).
56    pub group_kind: String,
57    pub groups: Vec<ChannelGroup>,
58    /// Per channel (index = channel), shown next to it.
59    pub labels: Vec<Option<String>>,
60}
61
62#[derive(Debug, Clone, PartialEq)]
63pub struct ChannelGroup {
64    pub name: String,
65    /// Channels in display order.
66    pub channels: Vec<usize>,
67}
68
69impl ChannelLayout {
70    pub fn label(&self, channel: usize) -> Option<&str> {
71        self.labels.get(channel)?.as_deref()
72    }
73}
74
75/// Opens the files of a domain's formats.
76pub trait SourceOpener: Send + Sync {
77    /// Whether `path` is one of its formats (cheap: the name or a header).
78    fn reads(&self, path: &Path) -> bool;
79    fn sources(&self, path: &Path) -> DspResult<Vec<SourceEntry>>;
80    fn open(&self, path: &Path, id: &str) -> DspResult<Box<dyn RecordingSource>>;
81    /// The channel layout of source `id` of `path`, when the file stores one.
82    fn layout(&self, _path: &Path, _id: &str) -> Option<ChannelLayout> {
83        None
84    }
85}
86
87/// What to draw over a window of traces: per unit its colour and channels, and marks (spans of
88/// session time) of those units. With `dim`, the traces themselves are drawn faint so the marks
89/// stand out.
90#[derive(Debug, Clone, Default)]
91pub struct TraceOverlay {
92    pub dim: bool,
93    pub units: Vec<OverlayUnit>,
94    pub marks: Vec<OverlayMark>,
95}
96
97#[derive(Debug, Clone)]
98pub struct OverlayUnit {
99    pub color: Pixel,
100    /// Channels the unit's marks cover, sorted.
101    pub channels: Vec<usize>,
102}
103
104#[derive(Debug, Clone, Copy)]
105pub struct OverlayMark {
106    /// Session seconds.
107    pub start_sec: f64,
108    pub end_sec: f64,
109    /// Index into [`TraceOverlay::units`].
110    pub unit: u32,
111}
112
113/// Spike rows for a raster view: per unit its colour, label and spike times (session seconds)
114/// in the window.
115#[derive(Debug, Clone, Default)]
116pub struct Raster {
117    pub rows: Vec<RasterRow>,
118}
119
120#[derive(Debug, Clone)]
121pub struct RasterRow {
122    pub color: Pixel,
123    pub label: String,
124    pub times: Vec<f64>,
125}
126
127/// A domain plugin. Every method has a default: a plugin implements what it adds.
128pub trait AppPlugin: Send + Sync + 'static {
129    /// Its key in the session document (`views[i].plugins.<id>`) and in logs.
130    fn id(&self) -> &'static str;
131    fn name(&self) -> &'static str;
132
133    fn opener(&self) -> Option<&dyn SourceOpener> {
134        None
135    }
136
137    /// A recording computed on demand (`channels` × `duration_sec` at `sample_rate`).
138    fn synthetic(&self, _channels: usize, _sample_rate: f64, _duration_sec: f64) -> Option<anyhow::Result<Arc<dyn RecordingSource>>> {
139        None
140    }
141
142    /// Called once the store exists (subscribe to its events, create the plugin's state).
143    fn install(&self, _store: &Entity<Store>, _cx: &mut App) {}
144
145    /// Marks over the traces of session view `view` for the session-time window `window_sec`.
146    fn trace_overlay(&self, _store: &Store, _view: &ViewDoc, _window_sec: Range<f64>, _cx: &App) -> Option<TraceOverlay> {
147        None
148    }
149
150    /// Raster rows of session view `view` for the session-time window `window_sec`.
151    fn raster(&self, _store: &Store, _view: &ViewDoc, _window_sec: Range<f64>, _cx: &App) -> Option<Raster> {
152        None
153    }
154
155    /// What this plugin can draw for session view `view` (e.g. a linked sorting: spikes, units).
156    fn layers(&self, _store: &Store, _view: &ViewDoc, _cx: &App) -> Layers {
157        Layers::default()
158    }
159
160    /// Epochs the recording file at `path` holds itself (electrophysiology: NWB's `/intervals`
161    /// tables), in seconds from the recording's start, which is `start_sec` of the session.
162    fn recording_epochs(&self, _path: &Path, _start_sec: f64) -> Vec<crate::domain::epoch::EpochDoc> {
163        Vec::new()
164    }
165
166    /// What it shows in Analytics aligned to events `events_s` (seconds of the open recording),
167    /// `pre_ms` before to `post_ms` after each (electrophysiology: each unit's raster and PETH).
168    fn aligned(&self, _store: &Entity<Store>, _events_s: &[f64], _pre_ms: f64, _post_ms: f64, _window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
169        None
170    }
171
172    /// The body of workspace `ws` when this plugin provides it.
173    fn workspace(&self, _ws: Workspace, _store: &Entity<Store>, _window: &mut Window, _cx: &mut App) -> Option<AnyView> {
174        None
175    }
176
177    /// Whether this plugin provides workspace `ws` (its tab is shown).
178    fn provides(&self, _ws: Workspace) -> bool {
179        false
180    }
181
182    /// Its panel on the right side bar: title and icon (its body is [`Self::view_section`]).
183    fn panel(&self) -> Option<(&'static str, std::rc::Rc<dyn Fn() -> gpui_kit::component::Icon>)> {
184        None
185    }
186
187    /// The body of its panel for `view`.
188    fn view_section(&self, _store: &Entity<Store>, _view: &ViewDoc, _window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
189        None
190    }
191}
192
193static PLUGINS: OnceLock<Vec<Arc<dyn AppPlugin>>> = OnceLock::new();
194
195/// Registers the plugins the app is built with (once, before it starts).
196pub fn register(plugins: Vec<Arc<dyn AppPlugin>>) {
197    if PLUGINS.set(plugins).is_err() {
198        tracing::warn!("plugins are registered once");
199    }
200}
201
202/// The registered plugins (none when [`register`] was not called: tests, a core-only build).
203pub fn plugins() -> &'static [Arc<dyn AppPlugin>] {
204    PLUGINS.get().map_or(&[], Vec::as_slice)
205}
206
207/// `path`'s sources: the first plugin that reads the file, else dspk-io's generic formats.
208pub fn sources(path: &Path) -> DspResult<Vec<SourceEntry>> {
209    match opener_of(path) {
210        Some(o) => o.sources(path),
211        None => dspk_io::sources(path),
212    }
213}
214
215/// The plugin opener that reads `path`, if any.
216fn opener_of(path: &Path) -> Option<&'static dyn SourceOpener> {
217    plugins().iter().filter_map(|p| p.opener()).find(|o| o.reads(path))
218}
219
220/// Source `id` of `path`, by the opener that listed it.
221pub fn open_source(path: &Path, id: &str) -> DspResult<Box<dyn RecordingSource>> {
222    match opener_of(path) {
223        Some(o) => o.open(path, id),
224        None => dspk_io::open_source(path, id),
225    }
226}
227
228/// Source `id` of `path` (`None`: the file's default source).
229pub fn open(path: &Path, id: Option<&str>) -> DspResult<Box<dyn RecordingSource>> {
230    match id {
231        Some(id) => open_source(path, id),
232        None => {
233            let list = sources(path)?;
234            let id = dspk_io::default_source(&list).map(|e| e.id.clone()).unwrap_or_default();
235            open_source(path, &id)
236        }
237    }
238}
239
240/// The channel layout of source `id` of `path`, from the opener that reads it.
241pub fn layout(path: &Path, id: &str) -> Option<ChannelLayout> {
242    opener_of(path)?.layout(path, id)
243}
244
245/// A synthetic recording from the first plugin that makes one.
246pub fn synthetic(channels: usize, sample_rate: f64, duration_sec: f64) -> Option<anyhow::Result<Arc<dyn RecordingSource>>> {
247    plugins().iter().find_map(|p| p.synthetic(channels, sample_rate, duration_sec))
248}