dspk-app-core 0.0.7

DSP App core: the workbench for any multichannel signal (open, view, process, write) and the plugin interface the domain plugins (electrophysiology: dspk-app-synapse) extend
Documentation
//! A time view as a dock panel: channel labels, the rendered image, overlays drawn as elements
//! (playhead, cursor, hover readout, scale bar label) and the time axis.
//!
//! While a new frame renders, the last one is drawn shifted and stretched to the current window,
//! so dragging and zooming answer at once. Input: drag or swipe sideways to move in time,
//! Ctrl+wheel to zoom at the pointer, the wheel to scroll channels, Alt+wheel for gain, a
//! double-click on a heatmap to show that channel in a traces view; keys once the view has focus.

use std::cell::Cell;
use std::rc::Rc;

use gpui_kit::base::dock::{Panel as BasePanel, PanelEvent};
use gpui_kit::component::dock::{Panel, PanelControl};
use gpui_kit::component::menu::{PopupMenu, PopupMenuItem};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::TestSupportExt as _;
use gpui_kit::{
    canvas, div, point, px, relative, rgb, size, App, Bounds, Context, Corners, CursorStyle, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, InteractiveElement as _,
    IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, ScrollEvent, SharedString, StatefulInteractiveElement as _,
    Styled as _, Subscription, WeakEntity, Window,
};

use crate::navigation::actions::*;
use crate::ui::plot::canvas::Pixel;
use crate::screens::explore::trace::renderer::TimeViewKind;
use crate::screens::explore::{ExploreVm, TraceVm};

/// Channel label column and time axis heights (logical px).
pub const GUTTER: f32 = 64.;
pub const AXIS: f32 = 22.;

pub fn color(p: Pixel) -> gpui_kit::Hsla {
    rgb(((p.r as u32) << 16) | ((p.g as u32) << 8) | p.b as u32).into()
}

#[derive(Debug, Clone, Copy)]
struct Drag {
    /// Pointer x and window start when the drag began.
    x: f32,
    start: f64,
    /// Height (fraction) where it began, and whether it moved: a press without movement is a
    /// click, which picks the channel under it (its processing shows in Processing).
    fy: f32,
    moved: bool,
}

/// Pointer travel (logical px) that turns a press into a drag.
const CLICK_SLOP: f32 = 3.0;

pub struct TracePanel {
    vm: Entity<TraceVm>,
    explore: WeakEntity<ExploreVm>,
    focus: FocusHandle,
    plot: Rc<Cell<Bounds<Pixels>>>,
    drag: Option<Drag>,
    /// Wheel movement not yet turned into channel steps.
    wheel: f32,
    /// Pointer in the plot (fractions), for the crosshair.
    pointer: Option<(f32, f32)>,
    _vm: Subscription,
}

impl TracePanel {
    pub fn new(vm: Entity<TraceVm>, explore: WeakEntity<ExploreVm>, cx: &mut Context<Self>) -> Self {
        let sub = cx.observe(&vm, |_, _, cx| cx.notify());
        Self { vm, explore, focus: cx.focus_handle(), plot: Rc::new(Cell::new(Bounds::default())), drag: None, wheel: 0.0, pointer: None, _vm: sub }
    }

    /// Position of `p` in the plot as fractions (clamped).
    fn fractions(&self, p: gpui_kit::Point<Pixels>) -> (f32, f32) {
        let b = self.plot.get();
        let (w, h) = (b.size.width.as_f32().max(1.0), b.size.height.as_f32().max(1.0));
        (((p.x - b.origin.x).as_f32() / w).clamp(0.0, 1.0), ((p.y - b.origin.y).as_f32() / h).clamp(0.0, 1.0))
    }

    fn store_update(&self, cx: &mut Context<Self>, f: impl FnOnce(&mut crate::data::store::Store, &mut Context<crate::data::store::Store>)) {
        let store = self.vm.read(cx).store().clone();
        store.update(cx, f);
    }

    fn on_down(&mut self, e: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
        window.focus(&self.focus, cx);
        self.vm.update(cx, |vm, cx| vm.pressed(cx));
        let (fx, fy) = self.fractions(e.position);
        if e.click_count == 2 && self.vm.read(cx).view.kind == TimeViewKind::Heatmap {
            if let (Some(ch), Some(explore)) = (self.vm.read(cx).channel_at(fy), self.explore.upgrade()) {
                let vm = self.vm.clone();
                explore.update(cx, |ex, cx| ex.reveal_in_traces(&vm, ch, cx));
            }
            return;
        }
        let _ = fx;
        let start = self.vm.read(cx).store().read(cx).timeline.window_start_sec;
        self.drag = Some(Drag { x: e.position.x.as_f32(), start, fy, moved: false });
    }

    fn on_move(&mut self, e: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
        let (fx, fy) = self.fractions(e.position);
        match self.drag {
            Some(d) if e.pressed_button == Some(MouseButton::Left) => {
                let w = self.plot.get().size.width.as_f32().max(1.0) as f64;
                let dx = (e.position.x.as_f32() - d.x) as f64;
                if dx.abs() as f32 > CLICK_SLOP {
                    if let Some(drag) = &mut self.drag {
                        drag.moved = true;
                    }
                }
                self.store_update(cx, |s, cx| {
                    let span = s.timeline.visible_window_sec;
                    let start = d.start - dx / w * span;
                    s.set_window(start, start + span, cx);
                });
            }
            Some(_) => self.drag = None,
            None => {}
        }
        if self.plot.get().contains(&e.position) {
            self.pointer = Some((fx, fy));
            self.vm.update(cx, |vm, cx| vm.hover_at(fx, fy, cx));
            cx.notify();
        }
    }

    fn on_up(&mut self, _: &MouseUpEvent, _: &mut Window, cx: &mut Context<Self>) {
        if let Some(d) = self.drag.take() {
            if !d.moved {
                if let Some(ch) = self.vm.read(cx).channel_at(d.fy) {
                    self.store_update(cx, |s, cx| s.pick_channel(Some(ch), cx));
                }
            }
        }
    }

    fn on_wheel(&mut self, e: &ScrollEvent, _: &mut Window, cx: &mut Context<Self>) {
        let d = e.delta.pixel_delta(px(20.));
        let (dx, dy) = (d.x.as_f32(), d.y.as_f32());
        let (fx, _) = self.fractions(e.position);
        let w = self.plot.get().size.width.as_f32().max(1.0) as f64;
        let m = e.modifiers;
        if m.control || m.platform {
            // Zoom around the pointer: wheel up = closer
            self.store_update(cx, |s, cx| s.zoom_at((dy as f64 * 0.004).exp(), fx as f64, cx));
        } else if m.alt {
            self.vm.update(cx, |vm, cx| vm.zoom_gain((-dy * 0.004).exp(), cx));
        } else if dx != 0.0 || m.shift {
            // Sideways (trackpad) or Shift+wheel: move in time, the content follows the fingers
            let moved = if dx != 0.0 { dx } else { dy } as f64;
            self.store_update(cx, |s, cx| {
                let dt = -moved / w * s.timeline.visible_window_sec;
                s.pan_time(dt, cx);
            });
        } else {
            self.wheel += dy;
            let steps = (self.wheel / 20.0).trunc();
            if steps != 0.0 {
                self.wheel -= steps * 20.0;
                // One channel per notch; wheel down shows later channels
                self.vm.update(cx, |vm, cx| vm.scroll_by(-(steps as i64), cx));
            }
        }
        cx.stop_propagation();
    }

    fn overlays(&self, cx: &mut Context<Self>) -> Vec<gpui_kit::AnyElement> {
        let vm = self.vm.read(cx);
        let palette = vm.store().read(cx).palette();
        let tl = &vm.store().read(cx).timeline;
        // Overlays drawn over the plot: light on dark plots, dark on light ones
        let ink = |a: f32| if palette.dark { gpui_kit::hsla(0., 0., 1., a) } else { gpui_kit::hsla(0., 0., 0., a) };
        let mut out = Vec::new();
        // Playhead
        let t = (tl.current_time_sec - tl.window_start_sec) / tl.visible_window_sec.max(1e-12);
        if (0.0..=1.0).contains(&t) {
            out.push(div().absolute().top_0().bottom_0().left(relative(t as f32)).w(px(1.5)).bg(color(palette.playhead)).into_any_element());
        }
        // Crosshair
        if let Some((fx, _)) = self.pointer {
            out.push(div().absolute().top_0().bottom_0().left(relative(fx)).w(px(1.)).bg(ink(0.22)).into_any_element());
        }
        // Scale bar label beside the raster's bar (bottom right)
        let label = vm.view.scale_bar_label();
        if !label.is_empty() {
            let y = vm.view.scale_bar_center_frac();
            out.push(
                div()
                    .absolute()
                    .right(px(18.))
                    .top(relative(y))
                    .mt(px(-7.))
                    .text_xs()
                    .text_color(color(palette.text))
                    .child(label)
                    .into_any_element(),
            );
        }
        out
    }
}

impl Render for TracePanel {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let vm = self.vm.read(cx);
        let view = &vm.view;
        let tl = vm.store().read(cx).timeline.clone();
        let heatmap = view.kind == TimeViewKind::Heatmap;
        let hovered_lane = self.pointer.map(|(_, fy)| (fy * view.drawn_channels().len() as f32) as usize);
        let palette = vm.store().read(cx).palette();
        let lanes = view.lane_labels(&palette);
        let ticks = view.time_ticks(&tl);
        let shown = vm.shown.clone();
        let (canvas_w, canvas_h, canvas_scale) = (view.canvas_width, view.canvas_height, view.scale_factor);
        let empty = view.selection.is_empty();
        let view_id = view.id as usize;
        let muted = color(palette.text);
        let _ = window;

        let gutter = div().w(px(GUTTER)).h_full().relative().overflow_hidden().children(lanes.into_iter().enumerate().map(|(i, l)| {
            let strong = !heatmap && hovered_lane == Some(i);
            // Labels at the very top or bottom stay inside the gutter
            let nudge = if l.y_frac <= 0.04 { 0. } else if l.y_frac >= 0.96 { -16. } else { -8. };
            div()
                .absolute()
                .left(px(10.))
                .top(relative(l.y_frac))
                .mt(px(nudge))
                .text_xs()
                .text_color(color(l.color))
                .when(strong, |d| d.font_weight(FontWeight::BOLD))
                .child(SharedString::from(l.label))
        }));

        let plot_cell = self.plot.clone();
        let vm_handle = self.vm.downgrade();
        let raster = canvas(
            move |bounds, window, cx| {
                plot_cell.set(bounds);
                let scale = window.scale_factor();
                let (w, h) = ((bounds.size.width.as_f32() * scale).round() as u32, (bounds.size.height.as_f32() * scale).round() as u32);
                if (w, h, scale) != (canvas_w, canvas_h, canvas_scale) && w > 0 && h > 0 {
                    let vm = vm_handle.clone();
                    cx.defer(move |cx| {
                        let _ = vm.update(cx, |vm, cx| vm.set_plot_size(w, h, scale, cx));
                    });
                }
            },
            move |bounds, _, window, _| {
                let Some(s) = shown else { return };
                // The frame's window placed in the current one: shifted / stretched until the
                // exact frame arrives
                let span = tl.visible_window_sec.max(1e-12);
                let x = bounds.origin.x + bounds.size.width * (((s.start - tl.window_start_sec) / span) as f32);
                let w = bounds.size.width * ((s.window / span) as f32);
                let image_bounds = Bounds { origin: point(x, bounds.origin.y), size: size(w, bounds.size.height) };
                let _ = window.paint_image(bounds, image_bounds, Corners::default(), s.image, 0, false);
            },
        )
        .absolute()
        .size_full();

        let plot = div()
            .id("plot")
            .flex_1()
            .h_full()
            .relative()
            .overflow_hidden()
            .cursor(if self.drag.is_some() { CursorStyle::ClosedHand } else { CursorStyle::Crosshair })
            .on_mouse_down(MouseButton::Left, cx.listener(Self::on_down))
            .on_mouse_move(cx.listener(Self::on_move))
            .on_mouse_up(MouseButton::Left, cx.listener(Self::on_up))
            .on_scroll(cx.listener(Self::on_wheel))
            .on_hover(cx.listener(|this, hovered: &bool, _, cx| {
                if !hovered {
                    this.pointer = None;
                    this.vm.update(cx, |vm, cx| vm.hover_left(cx));
                }
            }))
            .child(raster)
            .children(self.overlays(cx))
            .when(empty, |d| {
                d.child(div().absolute().size_full().flex().items_center().justify_center().text_sm().text_color(muted).child("No channels selected: pick some in Channels"))
            });

        // Ticks at the ends align inward so they are never cut off; the hover readout sits at the
        // right of the axis (off the traces)
        let readout = (!self.vm.read(cx).hover.is_empty() && self.pointer.is_some()).then(|| {
            div().absolute().right_0().top_0().h_full().pl_3().pr_1().flex().items_center().bg(color(palette.background)).text_xs().text_color(color(palette.text)).child(self.vm.read(cx).hover.clone())
        });
        let axis = div().h(px(AXIS)).flex().child(div().w(px(GUTTER))).child(
            div()
                .flex_1()
                .h_full()
                .relative()
                .children(ticks.into_iter().map(|t| {
                    let tick = div().absolute().left(relative(t.frac)).top(px(3.)).w(px(48.)).flex().text_xs().text_color(muted).child(t.label);
                    if t.frac < 0.04 {
                        tick.justify_start()
                    } else if t.frac > 0.96 {
                        tick.ml(px(-48.)).justify_end()
                    } else {
                        tick.ml(px(-24.)).justify_center()
                    }
                }))
                .children(readout),
        );

        div()
            .id(("time-view", view_id))
            .test_support()
            .key_context(PLOT)
            .track_focus(&self.focus)
            .size_full()
            .flex()
            .flex_col()
            .bg(color(palette.background))
            .on_action(cx.listener(|this, _: &PlayPause, _, cx| this.store_update(cx, |s, cx| s.toggle_play(cx))))
            .on_action(cx.listener(|this, _: &PanBack, _, cx| this.store_update(cx, |s, cx| s.pan_fraction(-0.1, cx))))
            .on_action(cx.listener(|this, _: &PanForward, _, cx| this.store_update(cx, |s, cx| s.pan_fraction(0.1, cx))))
            .on_action(cx.listener(|this, _: &PageBack, _, cx| this.store_update(cx, |s, cx| s.pan_fraction(-1.0, cx))))
            .on_action(cx.listener(|this, _: &PageForward, _, cx| this.store_update(cx, |s, cx| s.pan_fraction(1.0, cx))))
            .on_action(cx.listener(|this, _: &ZoomIn, _, cx| this.store_update(cx, |s, cx| s.zoom_at(0.8, 0.5, cx))))
            .on_action(cx.listener(|this, _: &ZoomOut, _, cx| this.store_update(cx, |s, cx| s.zoom_at(1.25, 0.5, cx))))
            .on_action(cx.listener(|this, _: &JumpStart, _, cx| this.store_update(cx, |s, cx| s.jump_to_start(cx))))
            .on_action(cx.listener(|this, _: &JumpEnd, _, cx| this.store_update(cx, |s, cx| s.jump_to_end(cx))))
            .on_action(cx.listener(|this, _: &ToggleLoop, _, cx| this.store_update(cx, |s, cx| s.toggle_loop(cx))))
            .on_action(cx.listener(|this, _: &ChannelUp, _, cx| this.vm.update(cx, |vm, cx| vm.scroll_by(-1, cx))))
            .on_action(cx.listener(|this, _: &ChannelDown, _, cx| this.vm.update(cx, |vm, cx| vm.scroll_by(1, cx))))
            .on_action(cx.listener(|this, _: &ChannelPageUp, _, cx| this.vm.update(cx, |vm, cx| vm.page(false, cx))))
            .on_action(cx.listener(|this, _: &ChannelPageDown, _, cx| this.vm.update(cx, |vm, cx| vm.page(true, cx))))
            .on_action(cx.listener(|this, _: &GainUp, _, cx| this.vm.update(cx, |vm, cx| vm.zoom_gain(1.0 / 0.75, cx))))
            .on_action(cx.listener(|this, _: &GainDown, _, cx| this.vm.update(cx, |vm, cx| vm.zoom_gain(0.75, cx))))
            .child(div().flex_1().min_h_0().flex().child(gutter).child(plot))
            .child(axis)
    }
}

impl EventEmitter<PanelEvent> for TracePanel {}

impl Focusable for TracePanel {
    fn focus_handle(&self, _: &App) -> FocusHandle {
        self.focus.clone()
    }
}

impl BasePanel for TracePanel {
    fn panel_name(&self) -> &'static str {
        "dspk-app.time-view"
    }

    fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context<Self>) {
        self.vm.update(cx, |vm, cx| vm.set_active(active, cx));
    }

    fn on_removed(&mut self, _: &mut Window, cx: &mut Context<Self>) {
        let id = self.vm.read(cx).id();
        if let Some(explore) = self.explore.upgrade() {
            explore.update(cx, |ex, cx| ex.removed(id, cx));
        }
    }
}

impl Panel for TracePanel {
    /// The kind and the source, cut to fit; the whole title in the tooltip.
    fn title(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let view = &self.vm.read(cx).view;
        let full = SharedString::from(view.title.clone());
        div().id(("view-title", view.id as usize)).max_w(px(280.)).truncate().child(full.clone()).tooltip(move |window, cx| gpui_kit::component::tooltip::Tooltip::new(full.clone()).build(window, cx))
    }

    /// Maximise / restore as a button in the view's toolbar.
    fn zoom_control(&self, _: &App) -> Option<PanelControl> {
        Some(PanelControl::Toolbar)
    }

    fn dropdown_menu(&mut self, menu: PopupMenu, _: &mut Window, _: &mut Context<Self>) -> PopupMenu {
        let explore = self.explore.clone();
        let add = move |kind: TimeViewKind| {
            let explore = explore.clone();
            move |_: &gpui_kit::ClickEvent, _: &mut Window, cx: &mut App| {
                if let Some(ex) = explore.upgrade() {
                    ex.update(cx, |ex, cx| {
                        ex.add(kind, cx);
                    });
                }
            }
        };
        menu.item(PopupMenuItem::new("Add traces view").on_click(add(TimeViewKind::Traces))).item(PopupMenuItem::new("Add heatmap view").on_click(add(TimeViewKind::Heatmap)))
    }

    fn inner_padding(&self, _: &App) -> bool {
        false
    }
}