use std::rc::Rc;
use gpui_kit::assets::IconName;
use gpui_kit::base::dock::DockPlacement;
use gpui_kit::{App, AppContext as _, Entity, SharedString, WeakEntity, Window};
use kitchen_shell::{CommandSpec, MenuItem, MenuSpec, Registry, Shell};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::data::store::{file_label, Store};
use crate::navigation::route::Workspace;
use crate::screens::explore::trace::renderer::TimeViewKind;
use crate::screens::explore::ExploreView;
use crate::screens::pipeline::catalog::catalog;
use crate::screens::pipeline::PipelineView;
use crate::ui::recording_picker::Pick;
pub const SYNTHETIC: (usize, f64, f64) = (32, 30_000.0, 300.0);
#[derive(Clone)]
pub struct Ctx {
pub store: Entity<Store>,
pub shell: Rc<std::cell::OnceCell<WeakEntity<Shell>>>,
pub explore: WeakEntity<ExploreView>,
pub pipeline: WeakEntity<PipelineView>,
pub toggle_help: Rc<dyn Fn(&mut Window, &mut App)>,
pub toggle_about: Rc<dyn Fn(&mut Window, &mut App)>,
pub open_browser: Rc<dyn Fn(&mut Window, &mut App)>,
}
impl Ctx {
pub fn shell(&self, cx: &mut App, f: impl FnOnce(&mut Shell, &mut gpui_kit::Context<Shell>)) {
if let Some(s) = self.shell.get().and_then(|w| w.upgrade()) {
s.update(cx, f);
}
}
}
pub fn op_id(label: &str) -> String {
let slug: String = label.to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
format!("process.op.{}", slug.split('_').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("_"))
}
pub fn workspace_id(w: Workspace) -> SharedString {
w.title().to_lowercase().into()
}
pub fn prompt_open(store: &Entity<Store>, pick: Pick, cx: &mut App) {
let rx = cx.prompt_for_paths(pick.options("Open recording", false, cx));
let store = store.clone();
cx.spawn(async move |cx| match rx.await {
Ok(Ok(Some(paths))) => {
if let Some(path) = paths.into_iter().next() {
store.update(cx, |s, cx| s.open(path, cx));
}
}
Ok(Err(e)) => store.update(cx, |s, cx| s.set_status(format!("File dialog failed: {e}"), cx)),
_ => {}
})
.detach();
}
pub fn open_synthetic(store: &Entity<Store>, cx: &mut App) {
let (ch, rate, dur) = SYNTHETIC;
store.update(cx, |s, cx| s.open_synthetic(ch, rate, dur, cx));
}
fn tune_device(store: &Entity<Store>, cx: &mut App) {
store.update(cx, |s, cx| s.set_status("Tuning this device in the background (about a minute on a GPU)…", cx));
let store = store.clone();
cx.spawn(async move |cx| {
let tuned = cx
.background_spawn(async move {
let client = dspk_core::ComputeTarget::from_env().map_err(|e| e.to_string())?.client().map_err(|e| e.to_string())?;
dspk_ops::autotune::autotune(&client, &Default::default(), |_, _| {}).map_err(|e| e.to_string())
})
.await;
let text = match tuned {
Ok(done) => format!("This device is tuned: {} sizes in {:.0} s", done.sizes, done.seconds),
Err(e) => format!("Could not tune this device: {e}"),
};
let _ = store.update(cx, |s, cx| s.set_status(text, cx));
})
.detach();
}
fn needs_recording(store: &Entity<Store>) -> impl Fn(&App) -> Result<(), String> + 'static {
let store = store.clone();
move |cx| if store.read(cx).recording.is_some() { Ok(()) } else { Err("Open a recording first".into()) }
}
#[derive(Deserialize, JsonSchema)]
struct PathArgs {
path: String,
}
#[derive(Deserialize, JsonSchema)]
struct RecipeArgs {
name: String,
}
pub fn register(registry: &Registry, c: &Ctx) {
let s = &c.store;
let st = |f: fn(&mut Store, &mut gpui_kit::Context<Store>)| {
let store = s.clone();
move |_: &mut Window, cx: &mut App| store.update(cx, f)
};
let (o1, o2, o3, o4, o5) = (s.clone(), s.clone(), s.clone(), s.clone(), s.clone());
let (help, about, browser) = (c.toggle_help.clone(), c.toggle_about.clone(), c.open_browser.clone());
let recent = s.clone();
let mut list = vec![
CommandSpec::new("file.open", "Open recording…", IconName::FolderOpen, move |_, cx| prompt_open(&o1, Pick::File, cx)).group("File").hint("A recording from this computer: .bin, .cbin, .zarr, .nwb.zarr").key("secondary-o"),
CommandSpec::new("file.open_folder", "Open folder…", IconName::FolderOpen, move |_, cx| prompt_open(&o2, Pick::Folder, cx)).group("File").hint("A recording stored as a folder (Zarr)").key("secondary-shift-o"),
CommandSpec::with_args::<PathArgs>("file.open_path", "Open a file", IconName::FolderOpen, move |a, _, cx| {
o3.update(cx, |s, cx| s.open(a.path.into(), cx));
Ok(Value::Null)
})
.group("File")
.hint("Open a recording by its path"),
CommandSpec::new("file.open_server", "Open from server…", IconName::Server, move |w, cx| browser(w, cx)).group("File").hint("Browse the recordings a dspk server offers"),
CommandSpec::new("file.synthetic", "Synthetic recording", IconName::AudioWaveform, move |_, cx| open_synthetic(&o4, cx)).group("File").hint("32 channels, 5 minutes: to try things out"),
CommandSpec::new("file.tune", "Tune this device", IconName::Gauge, move |_, cx| tune_device(&o5, cx)).group("File").hint("Benchmark this GPU once, in the background"),
CommandSpec::new("file.clear_recent", "Clear recent", IconName::Trash, st(|s, cx| s.forget_recent(None, cx))).group("File").hint("Forget the recent lists (the files stay)").enabled(move |cx| {
let s = &recent.read(cx).session;
if s.recent.is_empty() && s.recent_sortings.is_empty() { Err("Nothing to clear".into()) } else { Ok(()) }
}),
CommandSpec::new("file.close_recording", "Close recording", IconName::X, st(|s, cx| s.close_recording(None, cx))).group("File").hint("Close the recording shown").enabled(needs_recording(s)),
CommandSpec::new("app.quit", "Quit", IconName::Power, |_, cx| cx.quit()).group("File").hint("Close dsp-kitchen").key("secondary-q"),
CommandSpec::new("edit.undo", "Undo", IconName::Undo2, st(|s, cx| s.undo(false, cx))).group("Edit").hint("Undo the last change of the session").key("secondary-z"),
CommandSpec::new("edit.redo", "Redo", IconName::Redo2, st(|s, cx| s.undo(true, cx))).group("Edit").hint("Redo what was undone").key("secondary-shift-z"),
];
let explore = c.explore.clone();
let add = |kind: TimeViewKind| {
let (store, explore) = (s.clone(), explore.clone());
move |_: &mut Window, cx: &mut App| {
store.update(cx, |s, cx| s.set_workspace(Workspace::Explore, cx));
let _ = explore.update(cx, |e, cx| e.add_view(kind, cx));
}
};
let dock = |placement: DockPlacement| {
let (store, explore) = (s.clone(), explore.clone());
move |window: &mut Window, cx: &mut App| {
store.update(cx, |s, cx| s.set_workspace(Workspace::Explore, cx));
let _ = explore.update(cx, |e, cx| e.toggle_dock(placement, window, cx));
}
};
let (d1, d2, f1, f2) = (s.clone(), s.clone(), s.clone(), s.clone());
list.extend([
CommandSpec::new("view.add_traces", "Add a traces view", IconName::AudioWaveform, add(TimeViewKind::Traces)).group("View").hint("In Explore, next to the views open").key("secondary-t").enabled(needs_recording(s)),
CommandSpec::new("view.add_heatmap", "Add a heatmap view", IconName::LayoutGrid, add(TimeViewKind::Heatmap)).group("View").hint("In Explore, next to the views open").key("secondary-h").enabled(needs_recording(s)),
CommandSpec::new("view.add_raster", "Add a raster view", IconName::ChartColumn, add(TimeViewKind::Raster)).group("View").hint("Spikes by unit (needs a sorting's units)").enabled(needs_recording(s)),
CommandSpec::new("view.channels", "Channels", IconName::PanelLeft, dock(DockPlacement::Left)).group("View").hint("Show or hide the channel list").key("secondary-b").enabled(needs_recording(s)),
CommandSpec::new("view.inspector", "Inspector", IconName::PanelRight, dock(DockPlacement::Right)).group("View").hint("Show or hide the inspector").key("secondary-alt-b").enabled(needs_recording(s)),
CommandSpec::new("view.timeline", "Timeline", IconName::PanelBottom, dock(DockPlacement::Bottom)).group("View").hint("Show or hide the timeline").key("secondary-j").enabled(needs_recording(s)),
CommandSpec::new("view.dark_plots", "Dark plots in the light theme", IconName::Moon, move |_, cx| {
let on = d1.read(cx).session.dark_plots;
d1.update(cx, |s, cx| s.set_dark_plots(!on, cx));
})
.group("View")
.hint("Traces, heatmaps and charts on a dark background, whatever the theme")
.checked(move |cx| d2.read(cx).session.dark_plots),
CommandSpec::new("view.follow_system", "Follow the system theme", IconName::SunMoon, move |window, cx| {
let follow = f1.read(cx).session.dark.is_none();
let choice = if follow { Some(f1.read(cx).is_dark()) } else { None };
f1.update(cx, |s, cx| s.set_theme_choice(choice, cx));
crate::app::DspApp::apply_theme(&f1, choice, window, cx);
})
.group("View")
.hint("Dark or light as the system is set, while running")
.checked(move |cx| f2.read(cx).session.dark.is_none()),
]);
let ep = s.clone();
list.push(CommandSpec::new("timeline.new_epoch", "New epoch from the visible window", IconName::Clock, move |_, cx| crate::screens::explore::components::timeline_tab::new_epoch(&ep, cx)).group("Timeline").hint("Marks the span shown as an epoch").enabled(needs_recording(s)));
for cat in catalog() {
for it in cat.items {
let (store, pipeline, template) = (s.clone(), c.pipeline.clone(), it.template.clone());
list.push(
CommandSpec::new(op_id(it.label), it.label, cat.icon, move |window, cx| {
store.update(cx, |s, cx| s.set_workspace(Workspace::Pipeline, cx));
let _ = pipeline.update(cx, |p, cx| p.add(&template, window, cx));
})
.group(cat.name)
.hint(format!("{} · a node on the Pipeline canvas", it.hint)),
);
}
}
let (r1, rp) = (s.clone(), c.pipeline.clone());
list.push(
CommandSpec::with_args::<RecipeArgs>("process.recipe", "Add a recipe", IconName::Workflow, move |a, _, cx| {
let recipe = crate::domain::recipe::builtin().into_iter().chain(r1.read(cx).doc.recipes.values().cloned()).find(|r| r.name == a.name).ok_or_else(|| format!("no recipe {:?}", a.name))?;
r1.update(cx, |s, cx| s.set_workspace(Workspace::Pipeline, cx));
let _ = rp.update(cx, |p, cx| p.add_recipe(recipe, cx));
Ok(Value::Null)
})
.group("Recipe")
.hint("A recipe: a chain of operations as one node"),
);
let fc = c.clone();
list.push(
CommandSpec::new("process.find", "Find an operation…", IconName::Search, move |window, cx| {
let filter: kitchen_shell::search::Filter = Rc::new(|c: &CommandSpec| c.id.starts_with("process.op."));
fc.shell(cx, |sh, cx| sh.open_search_with(Some(("Find an operation".into(), filter)), window, cx));
})
.group("Process")
.hint("Search the operations only")
.key("f3")
.read_only(),
);
list.extend([
CommandSpec::new("help.keys", "Keyboard & mouse", IconName::Keyboard, move |w, cx| help(w, cx)).group("Help").hint("Every key and gesture").key("f1").read_only(),
CommandSpec::new("help.about", "About dsp-kitchen", IconName::Info, move |w, cx| about(w, cx)).group("Help").hint("Version, runtimes").read_only(),
CommandSpec::new("remote.this", "This computer", IconName::Monitor, |_, _| {}).group("App").hint("dsp-kitchen runs on this computer").checked(|_| true).read_only(),
CommandSpec::new("remote.connect", "Connect to a dsp-kitchen server…", IconName::Server, |_, _| {})
.group("App")
.hint("Run on a server")
.enabled(|_| Err("Servers you added in Settings open recordings from File › Open from server".into())),
]);
registry.extend(list);
providers(registry, s);
}
fn providers(registry: &Registry, s: &Entity<Store>) {
let store = s.clone();
registry.provide("file.recent", move |cx| {
let recent = store.read(cx).session.recent.clone();
if recent.is_empty() {
return vec![];
}
let mut out = vec![MenuItem::Separator, MenuItem::label("Recent")];
out.extend(recent.iter().take(8).map(|p| MenuItem::run(file_label(p), "file.open_path", json!({"path": p}))));
out.push(MenuItem::command("file.clear_recent"));
out
});
let store = s.clone();
registry.provide("process.recipes", move |cx| {
let recipes: Vec<String> = crate::domain::recipe::builtin().into_iter().chain(store.read(cx).doc.recipes.values().cloned()).map(|r| r.name).collect();
if recipes.is_empty() {
return vec![];
}
vec![MenuItem::Separator, MenuItem::submenu_with_icon("Recipes", IconName::Workflow, recipes.into_iter().map(|n| MenuItem::run(n.clone(), "process.recipe", json!({"name": n})).icon(IconName::Workflow)).collect())]
});
registry.provide("process.families", |_| {
catalog().into_iter().map(|c| MenuItem::submenu_with_icon(c.name, c.icon, c.items.iter().map(|i| MenuItem::command(op_id(i.label))).collect())).collect()
});
}
pub fn app_menu(cx: &App) -> Vec<MenuItem> {
let mut items: Vec<MenuItem> = crate::ui::recording_picker::open_actions(cx).iter().map(|(_, pick)| MenuItem::command(if *pick == Pick::File { "file.open" } else { "file.open_folder" })).collect();
items.extend([
MenuItem::command("file.open_server"),
MenuItem::command("file.synthetic"),
MenuItem::command("file.tune"),
MenuItem::provided("file.recent"),
MenuItem::Separator,
MenuItem::command("file.close_recording"),
MenuItem::Separator,
MenuItem::command("app.quit"),
]);
items
}
fn process_menu() -> MenuSpec {
MenuSpec::new("Process", vec![MenuItem::command("process.find"), MenuItem::Separator, MenuItem::label("A node on the Pipeline canvas"), MenuItem::provided("process.families"), MenuItem::provided("process.recipes")])
}
pub fn explore_menus() -> Vec<MenuSpec> {
let c = |id: &str| MenuItem::command(id.to_string());
vec![
MenuSpec::new("Edit", vec![c("edit.undo"), c("edit.redo")]),
MenuSpec::new(
"View",
vec![
c("view.add_traces"),
c("view.add_heatmap"),
c("view.add_raster"),
MenuItem::Separator,
c("view.channels"),
c("view.inspector"),
c("view.timeline"),
MenuItem::Separator,
c("view.dark_plots"),
c("view.follow_system"),
c("shell.theme"),
],
),
MenuSpec::new("Timeline", vec![c("timeline.new_epoch")]),
process_menu(),
]
}
pub fn other_menus() -> Vec<MenuSpec> {
let c = |id: &str| MenuItem::command(id.to_string());
vec![MenuSpec::new("Edit", vec![c("edit.undo"), c("edit.redo")]), MenuSpec::new("View", vec![c("view.dark_plots"), c("view.follow_system"), c("shell.theme")]), process_menu()]
}
pub fn modal(content: gpui_kit::AnyView, close: Rc<dyn Fn(&mut Window, &mut App)>, cx: &mut App) -> Entity<crate::app::Modal> {
cx.new(|_| crate::app::Modal { content, close })
}