use super::size_sync::{self, fitted_size};
use crate::ui_tree::UiTree;
use crate::ui_tree::plot::{is_container, resolve_color, split_channels};
use crate::widget::node_inspector;
use crate::widget::node_inspector::radio_option;
use crate::{
ContextMenuResponse, Env, InspectorRowsResponse, NodeCtx, NodeUi, NodeUiResponse,
NodeViewResponse, SocketDoc, SocketKind,
};
use gantz_core::node::{self, ExprCtx, ExprResult, MetaCtx, RegCtx};
use gantz_nodetag::NodeTag;
use serde::{Deserialize, Serialize};
use steel::gc::Gc;
use steel::steel_vm::register_fn::RegisterFn;
use steel::{SteelVal, Vector};
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
#[serde(transparent)]
pub struct F32(pub f32);
impl F32 {
fn get(self) -> f32 {
self.0
}
}
impl std::hash::Hash for F32 {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
std::hash::Hash::hash(&self.0.to_bits(), state);
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
pub enum PlotMode {
Scope,
Signal,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
pub enum PlotStyle {
Bars,
Line,
}
#[derive(Clone, Debug, Hash, Deserialize, Serialize, NodeTag)]
pub struct Plot {
mode: PlotMode,
style: PlotStyle,
capacity: u32,
width: u16,
height: u16,
color: Option<[u8; 4]>,
show_grid: bool,
show_axes: bool,
interactive: bool,
margin: bool,
y_min: Option<F32>,
y_max: Option<F32>,
}
impl Plot {
pub const DEFAULT_SIZE: [u16; 2] = [120, 80];
pub const DEFAULT_CAPACITY: u32 = 256;
}
impl Default for Plot {
fn default() -> Self {
Self {
mode: PlotMode::Scope,
style: PlotStyle::Bars,
capacity: Self::DEFAULT_CAPACITY,
width: Self::DEFAULT_SIZE[0],
height: Self::DEFAULT_SIZE[1],
color: None,
show_grid: false,
show_axes: false,
interactive: false,
margin: true,
y_min: None,
y_max: None,
}
}
}
fn plot_push(state: SteelVal, val: SteelVal, cap: SteelVal) -> SteelVal {
let cap = match cap {
SteelVal::IntV(n) if n > 0 => n as usize,
_ => 0,
};
if let Some(chans) = per_channel_elems(&val) {
let old: Vec<SteelVal> = match &state {
SteelVal::VectorV(v) if v.iter().any(is_container) => v.iter().cloned().collect(),
SteelVal::ListV(l) if l.iter().any(is_container) => l.iter().cloned().collect(),
_ => Vec::new(),
};
let channels: Vector<SteelVal> = chans
.iter()
.enumerate()
.map(|(c, ch)| {
let history = push_capped(history_of(old.get(c)), ch, cap);
SteelVal::VectorV(Gc::new(history).into())
})
.collect();
return SteelVal::VectorV(Gc::new(channels).into());
}
let history = push_capped(history_of(Some(&state)), &val, cap);
SteelVal::VectorV(Gc::new(history).into())
}
fn is_num(v: &SteelVal) -> bool {
matches!(v, SteelVal::NumV(_) | SteelVal::IntV(_))
}
fn per_channel_elems(val: &SteelVal) -> Option<Vec<SteelVal>> {
let elems: Vec<SteelVal> = match val {
SteelVal::ListV(list) => list.iter().cloned().collect(),
SteelVal::VectorV(vec) => vec.iter().cloned().collect(),
_ => return None,
};
elems.iter().any(is_container).then_some(elems)
}
fn history_of(state: Option<&SteelVal>) -> Vector<SteelVal> {
match state {
Some(SteelVal::VectorV(v)) if !v.iter().any(is_container) => (**v).clone(),
Some(SteelVal::ListV(list)) => list.iter().filter(|v| is_num(v)).cloned().collect(),
_ => Vector::new(),
}
}
fn push_capped(mut history: Vector<SteelVal>, val: &SteelVal, cap: usize) -> Vector<SteelVal> {
match val {
SteelVal::ListV(items) => {
for v in items.iter().filter(|v| is_num(v)) {
history.push_back(v.clone());
}
}
SteelVal::VectorV(items) => {
for v in items.iter().filter(|v| is_num(v)) {
history.push_back(v.clone());
}
}
num @ (SteelVal::NumV(_) | SteelVal::IntV(_)) => history.push_back(num.clone()),
_ => {}
}
while history.len() > cap {
history.pop_front();
}
history
}
fn series(ctx: &NodeCtx) -> Vec<Vec<f64>> {
match ctx.extract_value() {
Ok(Some(val)) => split_channels(&val),
_ => Vec::new(),
}
}
impl gantz_core::Node for Plot {
fn n_inputs(&self, _ctx: MetaCtx) -> usize {
1
}
fn n_outputs(&self, _ctx: MetaCtx) -> usize {
1
}
fn stateful(&self, _ctx: MetaCtx) -> bool {
true
}
fn expr(&self, ctx: ExprCtx<'_, '_>) -> ExprResult {
let expr = match ctx.inputs().get(0) {
Some(Some(val)) => match self.mode {
PlotMode::Scope => format!(
"(begin (set! state (plot-push state {val} {cap})) {val})",
cap = self.capacity,
),
PlotMode::Signal => format!("(begin (set! state {val}) {val})"),
},
_ => "(begin state)".to_string(),
};
node::parse_expr(&expr)
}
fn register(&self, mut ctx: RegCtx<'_, '_>) {
let path = ctx.path();
node::state::init_value_if_absent(ctx.vm(), path, || {
SteelVal::VectorV(std::iter::empty::<SteelVal>().collect())
})
.unwrap();
if ctx.vm().extract_value("plot-push").is_err() {
ctx.vm().register_fn("plot-push", plot_push);
}
}
}
impl Plot {
fn fragment(&self, id: node::Id, size: Option<egui::Vec2>) -> gantz_ui::Element {
gantz_ui::Element::Plot(gantz_ui::Plot {
bind: Some(gantz_ui::BindPath(vec![id])),
mode: Some(match self.mode {
PlotMode::Scope => gantz_ui::PlotMode::Scope,
PlotMode::Signal => gantz_ui::PlotMode::Signal,
}),
style: Some(match self.style {
PlotStyle::Bars => gantz_ui::PlotStyle::Bars,
PlotStyle::Line => gantz_ui::PlotStyle::Line,
}),
color: self.color.map(gantz_ui::Rgba),
grid: self.show_grid,
axes: self.show_axes,
interactive: self.interactive,
y_min: self.y_min.map(F32::get),
y_max: self.y_max.map(F32::get),
w: size.map(|s| s.x),
h: size.map(|s| s.y),
key: None,
})
}
}
impl NodeUi for Plot {
fn name(&self, _: &Env<'_>) -> std::borrow::Cow<'_, str> {
"plot".into()
}
fn description(&self) -> Option<&'static str> {
Some("Plot incoming values as a scrolling scope or a signal/array")
}
fn ui(&mut self, mut ctx: NodeCtx, uictx: egui_graph::NodeCtx) -> NodeUiResponse {
let mut changed = false;
let style = uictx.style();
let interaction = uictx.interaction();
let mut frame = egui_graph::node::default_frame(style, interaction);
frame.fill = style.visuals.extreme_bg_color;
if !self.margin {
frame.inner_margin = egui::Margin::ZERO;
frame.corner_radius = egui::CornerRadius::ZERO;
}
let node_egui_id = uictx.egui_id();
let resize_id = node_egui_id.with("resize");
let root_id = node_egui_id.with("gui");
let min_size = egui::Vec2::splat(style.interaction.interact_radius * 2.0);
let default_size = egui::vec2(self.width as f32, self.height as f32);
let (&id, prefix) = ctx.path().split_last().expect("a node path is never empty");
let size_sync_id = node_egui_id.with("size_sync");
let framed = uictx.framed_with(frame, |ui, _sockets| {
let size_sync::Decisions {
resizing,
push_external,
drag_released,
} = size_sync::begin(ui, size_sync_id, resize_id, [self.width, self.height]);
let resize = egui::containers::Resize::default()
.id(resize_id)
.with_stroke(false);
let resize = if push_external {
ui.ctx().request_repaint();
let w = (self.width as f32).max(min_size.x);
let h = (self.height as f32).max(min_size.y);
resize.fixed_size(egui::vec2(w, h))
} else {
let resizable = egui::Vec2b::new(interaction.selected, interaction.selected);
resize
.resizable(resizable)
.default_size(default_size)
.min_size(min_size)
};
let inner = resize.show(ui, |ui| {
let avail = ui.available_size();
let fitted = fitted_size(avail.x.max(min_size.x), avail.y.max(min_size.y));
if drag_released && [self.width, self.height] != fitted {
[self.width, self.height] = fitted;
changed = true;
}
let tree = self.fragment(id, Some(avail));
let r = UiTree::new(root_id)
.instance_prefix(prefix)
.show(&tree, &mut ctx, ui);
r.inner.unwrap_or_else(|| ui.response())
});
size_sync::store(
ui,
size_sync_id,
[self.width, self.height],
push_external,
resizing,
);
inner
});
let mut resp = NodeUiResponse::new(framed);
resp.set_changed(changed);
resp
}
fn view_no_margin(&self) -> bool {
true
}
fn view_ui(&mut self, mut ctx: NodeCtx, ui: &mut egui::Ui) -> NodeViewResponse {
let (&id, prefix) = ctx.path().split_last().expect("a node path is never empty");
let tree = self.fragment(id, None);
let r = UiTree::new(ui.id().with("gui"))
.instance_prefix(prefix)
.show(&tree, &mut ctx, ui);
let mut out = NodeViewResponse::default();
out.inner = r.inner;
out.payloads = r.payloads;
out
}
fn inspector_rows(
&mut self,
ctx: &mut NodeCtx,
body: &mut egui_extras::TableBody,
) -> InspectorRowsResponse {
let row_h = node_inspector::table_row_h(body.ui_mut());
let mut changed = false;
let chans = series(ctx);
let total: usize = chans.iter().map(Vec::len).sum();
let summary = if chans.len() > 1 {
format!("{total} samples · {} channels", chans.len())
} else {
format!("{total} samples")
};
body.row(row_h, |mut row| {
row.col(|ui| {
ui.label("state");
});
row.col(|ui| {
ui.label(summary);
});
});
body.row(row_h, |mut row| {
row.col(|ui| {
ui.label("mode");
});
row.col(|ui| {
ui.horizontal(|ui| {
changed |= radio_option(
ui,
&mut self.mode,
PlotMode::Scope,
"scope",
"accumulate a scrolling history",
);
changed |= radio_option(
ui,
&mut self.mode,
PlotMode::Signal,
"signal",
"plot the incoming value directly",
);
});
});
});
body.row(row_h, |mut row| {
row.col(|ui| {
ui.label("style");
});
row.col(|ui| {
ui.horizontal(|ui| {
changed |= radio_option(
ui,
&mut self.style,
PlotStyle::Bars,
"bars",
"draw as contiguous bars",
);
changed |= radio_option(
ui,
&mut self.style,
PlotStyle::Line,
"line",
"draw as a connected line",
);
});
});
});
body.row(row_h, |mut row| {
row.col(|ui| {
ui.label("capacity");
});
row.col(|ui| {
let mut c = self.capacity as i32;
if ui
.add(egui::DragValue::new(&mut c).range(1..=4096).speed(1.0))
.on_hover_text("max samples retained in scope mode")
.changed()
{
self.capacity = c.clamp(1, 4096) as u32;
changed = true;
}
});
});
body.row(row_h, |mut row| {
row.col(|ui| {
ui.label("margin");
});
row.col(|ui| {
if ui
.checkbox(&mut self.margin, "")
.on_hover_text(
"inset the data within the node frame's margin (rounded corners)",
)
.changed()
{
changed = true;
}
});
});
body.row(row_h, |mut row| {
row.col(|ui| {
ui.label("colour");
});
row.col(|ui| {
ui.horizontal(|ui| {
let mut col = resolve_color(self.color, ui);
if ui
.color_edit_button_srgba(&mut col)
.on_hover_text("the line/bar colour")
.changed()
{
self.color = Some([col.r(), col.g(), col.b(), col.a()]);
changed = true;
}
if self.color.is_some()
&& ui
.button("theme")
.on_hover_text("follow the theme's strong text colour")
.clicked()
{
self.color = None;
changed = true;
}
});
});
});
body.row(row_h, |mut row| {
row.col(|ui| {
ui.label("range");
});
row.col(|ui| {
egui::Grid::new("plot_range").num_columns(2).show(ui, |ui| {
let mut y_min = self.y_min.map(F32::get);
if node_inspector::bound_col(ui, "minimum", &mut y_min) {
self.y_min = y_min.map(F32);
changed = true;
}
let mut y_max = self.y_max.map(F32::get);
if node_inspector::bound_col(ui, "maximum", &mut y_max) {
self.y_max = y_max.map(F32);
changed = true;
}
ui.end_row();
});
});
});
body.row(row_h, |mut row| {
row.col(|ui| {
ui.label("display");
});
row.col(|ui| {
ui.horizontal(|ui| {
changed |= ui
.checkbox(&mut self.show_grid, "grid")
.on_hover_text("draw the background grid")
.changed();
changed |= ui
.checkbox(&mut self.show_axes, "axes")
.on_hover_text("draw the axes")
.changed();
changed |= ui
.checkbox(&mut self.interactive, "interactive")
.on_hover_text("show a crosshair and value readout on hover")
.changed();
});
});
});
let mut resp = InspectorRowsResponse::default();
resp.set_changed(changed);
resp
}
fn context_menu(&mut self, ctx: &mut NodeCtx, ui: &mut egui::Ui) -> ContextMenuResponse {
if ui
.button("clear history")
.on_hover_text("empty the plotted series")
.clicked()
{
ctx.update_value(SteelVal::VectorV(std::iter::empty::<SteelVal>().collect()))
.ok();
ui.close();
}
ContextMenuResponse::default()
}
fn socket_doc(&self, _: &Env<'_>, kind: SocketKind, _ix: usize) -> Option<SocketDoc> {
Some(match kind {
SocketKind::Input => SocketDoc::ty("number or list").with_description(
"scope: a number (or list) appended to the history; signal: the value to plot",
),
SocketKind::Output => {
SocketDoc::ty("any").with_description("the input value, unchanged")
}
})
}
fn show_state(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_tree::plot::steel_num;
use gantz_core::node::{Node, WithPushEval};
use gantz_core::{
Edge, ROOT_STATE,
compile::{entry_fn_name, entrypoint, push_pull_entrypoints},
};
use steel::steel_vm::engine::Engine;
fn no_lookup(_: &gantz_ca::ContentAddr) -> Option<&'static dyn Node> {
None
}
fn vm_for(g: &petgraph::graph::DiGraph<Box<dyn Node>, Edge>) -> Engine {
let eps = push_pull_entrypoints(&no_lookup, g);
let module = gantz_core::compile::module(&no_lookup, g, &eps, &Default::default()).unwrap();
let mut vm = Engine::new_base();
vm.register_value(ROOT_STATE, SteelVal::empty_hashmap());
gantz_core::graph::register(&no_lookup, g, &[], &mut vm);
for f in module {
vm.run(format!("{f}")).unwrap();
}
vm
}
fn fire(
vm: &mut Engine,
g: &petgraph::graph::DiGraph<Box<dyn Node>, Edge>,
ix: usize,
n: usize,
) {
let ctx = node::MetaCtx::new(&no_lookup);
let outs = g[petgraph::graph::NodeIndex::new(ix)].n_outputs(ctx) as u8;
let ep = entrypoint::push(vec![ix], outs);
let fn_name = entry_fn_name(&ep.id());
for _ in 0..n {
vm.call_function_by_name_with_args(&fn_name, vec![])
.unwrap();
}
}
fn samples_of(vm: &Engine, ix: usize) -> Vec<f64> {
match node::state::extract_value(vm, &[ix]).unwrap().unwrap() {
SteelVal::ListV(list) => list.iter().filter_map(steel_num).collect(),
SteelVal::VectorV(vec) => vec.iter().filter_map(steel_num).collect(),
other => panic!("expected list/vector state, got {other:?}"),
}
}
fn graph_with(
src: Box<dyn Node>,
plot: Plot,
) -> (petgraph::graph::DiGraph<Box<dyn Node>, Edge>, usize, usize) {
let mut g = petgraph::graph::DiGraph::new();
let s = g.add_node(src);
let p = g.add_node(Box::new(plot) as Box<dyn Node>);
g.add_edge(s, p, Edge::from((0, 0)));
(g, s.index(), p.index())
}
#[test]
fn scope_accumulates_bounded_history() {
let src = gantz_core::node::expr("5").unwrap().with_push_eval();
let plot = Plot {
mode: PlotMode::Scope,
capacity: 3,
..Default::default()
};
let (g, s, p) = graph_with(Box::new(src) as Box<dyn Node>, plot);
let mut vm = vm_for(&g);
fire(&mut vm, &g, s, 5);
assert_eq!(samples_of(&vm, p), vec![5.0, 5.0, 5.0]);
}
#[test]
fn scope_extends_with_list() {
let src = gantz_core::node::expr("(list 1 2 3)")
.unwrap()
.with_push_eval();
let plot = Plot {
mode: PlotMode::Scope,
capacity: 10,
..Default::default()
};
let (g, s, p) = graph_with(Box::new(src) as Box<dyn Node>, plot);
let mut vm = vm_for(&g);
fire(&mut vm, &g, s, 2);
assert_eq!(samples_of(&vm, p), vec![1.0, 2.0, 3.0, 1.0, 2.0, 3.0]);
}
#[test]
fn scope_list_over_capacity_keeps_tail() {
let src = gantz_core::node::expr("(list 1 2 3 4 5)")
.unwrap()
.with_push_eval();
let plot = Plot {
mode: PlotMode::Scope,
capacity: 3,
..Default::default()
};
let (g, s, p) = graph_with(Box::new(src) as Box<dyn Node>, plot);
let mut vm = vm_for(&g);
fire(&mut vm, &g, s, 1);
assert_eq!(samples_of(&vm, p), vec![3.0, 4.0, 5.0]);
fire(&mut vm, &g, s, 1);
assert_eq!(samples_of(&vm, p), vec![3.0, 4.0, 5.0]);
}
#[test]
fn scope_accumulates_per_channel_histories() {
let src = gantz_core::node::expr("(list (list 1 2) (list -1 -2))")
.unwrap()
.with_push_eval();
let plot = Plot {
mode: PlotMode::Scope,
capacity: 3,
..Default::default()
};
let (g, s, p) = graph_with(Box::new(src) as Box<dyn Node>, plot);
let mut vm = vm_for(&g);
fire(&mut vm, &g, s, 2);
let state = node::state::extract_value(&vm, &[p]).unwrap().unwrap();
assert_eq!(
split_channels(&state),
vec![vec![2.0, 1.0, 2.0], vec![-2.0, -1.0, -2.0]],
);
}
#[test]
fn scope_shape_switch_discards_prior_history() {
let num = |n: f64| SteelVal::NumV(n);
let list = |vals: Vec<SteelVal>| SteelVal::ListV(vals.into_iter().collect());
let cap = SteelVal::IntV(8);
let flat = plot_push(SteelVal::Void, num(1.0), cap.clone());
let chans = plot_push(flat, list(vec![list(vec![num(2.0)])]), cap.clone());
assert_eq!(split_channels(&chans), vec![vec![2.0]]);
let flat_again = plot_push(chans, num(3.0), cap);
assert_eq!(split_channels(&flat_again), vec![vec![3.0]]);
}
#[test]
fn scope_list_trims_oldest_to_cap() {
let src = gantz_core::node::expr("(list 1 2 3)")
.unwrap()
.with_push_eval();
let plot = Plot {
mode: PlotMode::Scope,
capacity: 4,
..Default::default()
};
let (g, s, p) = graph_with(Box::new(src) as Box<dyn Node>, plot);
let mut vm = vm_for(&g);
fire(&mut vm, &g, s, 2);
assert_eq!(samples_of(&vm, p), vec![3.0, 1.0, 2.0, 3.0]);
}
#[test]
fn plot_push_accepts_vector() {
let num = |n: f64| SteelVal::NumV(n);
let vector = |xs: Vec<SteelVal>| SteelVal::VectorV(xs.into_iter().collect());
let empty = SteelVal::VectorV(std::iter::empty::<SteelVal>().collect());
let s1 = plot_push(
empty,
vector(vec![num(1.0), num(2.0), num(3.0)]),
SteelVal::IntV(4),
);
let s2 = plot_push(s1, vector(vec![num(4.0), num(5.0)]), SteelVal::IntV(4));
let got: Vec<f64> = match s2 {
SteelVal::VectorV(v) => v.iter().filter_map(steel_num).collect(),
other => panic!("expected vector state, got {other:?}"),
};
assert_eq!(got, vec![2.0, 3.0, 4.0, 5.0]);
}
#[test]
fn signal_stores_list() {
let src = gantz_core::node::expr("(list 1 2 3)")
.unwrap()
.with_push_eval();
let plot = Plot {
mode: PlotMode::Signal,
..Default::default()
};
let (g, s, p) = graph_with(Box::new(src) as Box<dyn Node>, plot);
let mut vm = vm_for(&g);
fire(&mut vm, &g, s, 1);
assert_eq!(samples_of(&vm, p), vec![1.0, 2.0, 3.0]);
}
#[test]
fn signal_stores_scalar() {
let src = gantz_core::node::expr("7").unwrap().with_push_eval();
let plot = Plot {
mode: PlotMode::Signal,
..Default::default()
};
let (g, s, p) = graph_with(Box::new(src) as Box<dyn Node>, plot);
let mut vm = vm_for(&g);
fire(&mut vm, &g, s, 1);
let state = node::state::extract_value(&vm, &[p]).unwrap().unwrap();
assert!(matches!(state, SteelVal::IntV(7)));
}
#[test]
fn fragment_bakes_weight_attrs_and_bind() {
let plot = Plot {
mode: PlotMode::Signal,
style: PlotStyle::Line,
color: Some([1, 2, 3, 4]),
show_grid: true,
show_axes: true,
interactive: true,
y_min: Some(F32(-1.0)),
y_max: Some(F32(1.0)),
..Default::default()
};
let expected = gantz_ui::Element::Plot(gantz_ui::Plot {
bind: Some(gantz_ui::BindPath(vec![4])),
mode: Some(gantz_ui::PlotMode::Signal),
style: Some(gantz_ui::PlotStyle::Line),
color: Some(gantz_ui::Rgba([1, 2, 3, 4])),
grid: true,
axes: true,
interactive: true,
y_min: Some(-1.0),
y_max: Some(1.0),
w: Some(120.0),
h: Some(80.0),
key: None,
});
assert_eq!(plot.fragment(4, Some(egui::vec2(120.0, 80.0))), expected);
let gantz_ui::Element::Plot(p) = plot.fragment(4, None) else {
panic!("plot fragment is a plot element");
};
assert_eq!((p.w, p.h), (None, None));
}
#[test]
fn re_registration_keeps_plot_push_working() {
let src = gantz_core::node::expr("5").unwrap().with_push_eval();
let plot = Plot {
mode: PlotMode::Scope,
capacity: 3,
..Default::default()
};
let (g, s, p) = graph_with(Box::new(src) as Box<dyn Node>, plot);
let mut vm = vm_for(&g);
gantz_core::graph::register(&no_lookup, &g, &[], &mut vm);
fire(&mut vm, &g, s, 5);
assert_eq!(samples_of(&vm, p), vec![5.0, 5.0, 5.0]);
}
}