use crate::{
Action, CopyNodes, CreateNestedGraph, CreateNode, CutNodes, DuplicateNodes, Env,
ExportAllNamed, ExportHead, HeadAccess, Keymap, NodeCtx, NodeUi, OpenLogs, OpenNodePalette,
OpenNodeView, Paste, Redo, ReplaceHead, ResetTilesLayout, Undo, export,
node::NodeCodec,
response::{DynResponse, Responses},
widget::{self, GraphScene, GraphSceneState, graph_scene},
};
use gantz_core::node;
use petgraph::visit::IntoNodeIdentifiers;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use steel::steel_vm::engine::Engine;
#[derive(Debug)]
pub struct FileDrop {
pub bytes: Vec<u8>,
pub target: FileDropTarget,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FileDropTarget {
Graphs,
GraphScene,
}
pub const NESTED_GRAPH_TYPE: &str = "graph";
pub struct Gantz<'a> {
env: &'a Env<'a>,
codec: &'a NodeCodec,
base_names: &'a crate::reg::Names,
log_source: Option<LogSource>,
perf_vm: Option<&'a mut widget::PerfCapture>,
perf_gui: Option<&'a mut widget::PerfCapture>,
base_immutable: bool,
compile_config: Option<gantz_core::compile::Config>,
validate_change_tracking: Option<bool>,
settings_tabs: &'a mut [&'a mut dyn widget::SettingsTab],
ext_panes: &'a mut [&'a mut dyn widget::ExtPane],
ref_ext_uis: &'a [&'a dyn crate::node::RefExtUi],
edge_styles: &'a [&'a dyn widget::EdgeStyle],
base_sources: Option<BaseSourcesCtx<'a>>,
pane_window_mode: PaneWindowMode,
collab: Option<&'a crate::collab::CollabUiState>,
clipboard: Option<&'a dyn Fn() -> Option<String>>,
}
#[derive(Clone, Copy)]
pub struct BaseSourcesCtx<'a> {
pub sources: &'a [&'a str],
pub name_sources: &'a HashMap<String, &'static str>,
pub default_source: &'a str,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum PaneWindowMode {
#[default]
EguiWindow,
HostNative,
}
enum LogSource {
Logger(widget::log_view::Logger),
#[cfg(feature = "tracing")]
TraceCapture(
widget::trace_view::TraceCapture,
tracing::level_filters::LevelFilter,
),
}
#[derive(serde::Deserialize, serde::Serialize)]
pub struct GantzState {
#[serde(serialize_with = "gantz_ca::serde_sorted::serialize_map")]
pub open_heads: OpenHeadStates,
pub view_toggles: ViewToggles,
#[serde(default, alias = "command_palette")]
pub node_palette: widget::NodePalette,
#[serde(default)]
pub layout_config: LayoutConfig,
#[serde(default)]
pub scene_config: SceneConfig,
#[serde(default)]
pub keymap: Keymap,
#[serde(default)]
pub collab: crate::collab::CollabConfig,
#[serde(default)]
pub merge_resolutions: gantz_ca::merge::Resolutions,
#[serde(default, serialize_with = "gantz_ca::serde_sorted::serialize_map")]
pub redo_stacks: HashMap<gantz_ca::Head, Vec<gantz_ca::CommitAddr>>,
#[serde(default, serialize_with = "gantz_ca::serde_sorted::serialize_map")]
pub undo_cursors: HashMap<gantz_ca::Head, crate::ops::RevertCursor>,
#[serde(default = "default_sidebar_width")]
pub sidebar_width: f32,
#[serde(default = "default_tray_height")]
pub tray_height: f32,
#[serde(default)]
pub windowed_geometry: HashMap<String, PaneWindowGeometry>,
}
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)]
pub struct PaneWindowGeometry {
pub width: f32,
pub height: f32,
}
fn default_sidebar_width() -> f32 {
270.0
}
fn default_tray_height() -> f32 {
300.0
}
pub type OpenHeadStates = HashMap<gantz_ca::Head, OpenHeadState>;
#[derive(serde::Deserialize, serde::Serialize)]
pub struct OpenHeadState {
pub scene: GraphSceneState,
#[serde(default = "default_layout_flow")]
pub layout_flow: egui::Direction,
}
fn default_layout_flow() -> egui::Direction {
GantzState::DEFAULT_DIRECTION
}
impl Default for OpenHeadState {
fn default() -> Self {
Self {
scene: GraphSceneState::default(),
layout_flow: GantzState::DEFAULT_DIRECTION,
}
}
}
#[derive(Clone, Copy, serde::Deserialize, serde::Serialize)]
pub struct LayoutConfig {
#[serde(default = "default_layer_gap")]
pub layer_gap: f32,
#[serde(default = "default_node_gap")]
pub node_gap: f32,
#[serde(default = "default_component_gap")]
pub component_gap: f32,
#[serde(default = "default_socket_aware")]
pub socket_aware: bool,
}
fn default_layer_gap() -> f32 {
egui_graph::LayoutParams::DEFAULT_LAYER_GAP
}
fn default_node_gap() -> f32 {
egui_graph::LayoutParams::DEFAULT_NODE_GAP
}
fn default_component_gap() -> f32 {
egui_graph::LayoutParams::DEFAULT_COMPONENT_GAP
}
fn default_socket_aware() -> bool {
true
}
impl Default for LayoutConfig {
fn default() -> Self {
Self {
layer_gap: default_layer_gap(),
node_gap: default_node_gap(),
component_gap: default_component_gap(),
socket_aware: default_socket_aware(),
}
}
}
impl LayoutConfig {
pub fn to_params(&self, flow: egui::Direction) -> egui_graph::LayoutParams {
egui_graph::LayoutParams::new(flow)
.layer_gap(self.layer_gap)
.node_gap(self.node_gap)
.component_gap(self.component_gap)
.socket_aware(self.socket_aware)
}
}
#[derive(Clone, Copy, Default, serde::Deserialize, serde::Serialize)]
pub struct SceneConfig {
#[serde(default)]
pub grid: GridConfig,
#[serde(default)]
pub snap: SnapConfig,
#[serde(default)]
pub align: AlignConfig,
}
#[derive(Clone, Copy, serde::Deserialize, serde::Serialize)]
pub struct GridConfig {
#[serde(default = "default_grid_show")]
pub show: bool,
#[serde(default = "default_grid_step")]
pub step: f32,
}
fn default_grid_show() -> bool {
true
}
fn default_grid_step() -> f32 {
20.0
}
impl Default for GridConfig {
fn default() -> Self {
Self {
show: default_grid_show(),
step: default_grid_step(),
}
}
}
#[derive(Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub enum SnapMode {
#[default]
Point,
Grid,
}
#[derive(Clone, Copy, serde::Deserialize, serde::Serialize)]
pub struct SnapConfig {
#[serde(default)]
pub mode: SnapMode,
#[serde(default = "default_grid_ratio")]
pub grid_ratio: f32,
}
fn default_grid_ratio() -> f32 {
1.0
}
impl Default for SnapConfig {
fn default() -> Self {
Self {
mode: SnapMode::default(),
grid_ratio: default_grid_ratio(),
}
}
}
#[derive(Clone, Copy, serde::Deserialize, serde::Serialize)]
pub struct AlignConfig {
#[serde(default = "default_align_enabled")]
pub enabled: bool,
#[serde(default = "default_align_edges")]
pub edges: bool,
#[serde(default = "default_align_centers")]
pub centers: bool,
}
fn default_align_enabled() -> bool {
true
}
fn default_align_edges() -> bool {
true
}
fn default_align_centers() -> bool {
false
}
impl Default for AlignConfig {
fn default() -> Self {
Self {
enabled: default_align_enabled(),
edges: default_align_edges(),
centers: default_align_centers(),
}
}
}
impl SceneConfig {
pub fn apply(self, graph: egui_graph::Graph) -> egui_graph::Graph {
let snap_step = match self.snap.mode {
SnapMode::Point => 1.0,
SnapMode::Grid => self.grid.step * self.snap.grid_ratio,
};
graph
.resize_behavior(egui_graph::ResizeBehavior::MaintainView)
.dot_grid(self.grid.show)
.dot_grid_step(self.grid.step)
.snap(Some(egui_graph::Snap::Round))
.snap_step(snap_step)
.align(self.align.enabled)
.align_targets(egui_graph::AlignTargets {
edges: self.align.edges,
centers: self.align.centers,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum Pane {
Ext(String),
GraphConfig,
GraphScene,
Graphs,
GuiDebug,
GuiPerf,
History,
Logs,
NodeInspector,
NodeView(NodeViewPane),
Settings,
Steel,
VmPerf,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
struct GraphPane(gantz_ca::Head);
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct NodeViewPane {
head: gantz_ca::Head,
path: Vec<node::Id>,
ty_name: String,
}
const GRAPH_TREE_ID: &str = "gantz-graph-tiles-tree";
fn load_ron<T: serde::de::DeserializeOwned>(ctx: &egui::Context, id: egui::Id) -> Option<T> {
let ron = ctx.memory_mut(|m| m.data.get_persisted::<String>(id))?;
ron::from_str(&ron).ok()
}
fn store_ron<T: serde::Serialize>(ctx: &egui::Context, id: egui::Id, value: &T) {
if let Ok(ron) = ron::to_string(value) {
ctx.memory_mut(|m| m.data.insert_persisted(id, ron));
}
}
fn load_tree<P: serde::de::DeserializeOwned>(
ctx: &egui::Context,
id: egui::Id,
) -> Option<egui_tiles::Tree<P>> {
load_ron(ctx, id)
}
fn store_tree<P: serde::Serialize>(ctx: &egui::Context, id: egui::Id, tree: &egui_tiles::Tree<P>) {
store_ron(ctx, id, tree)
}
fn clear_egui_memory_id() -> egui::Id {
egui::Id::new("gantz-clear-egui-memory-request")
}
pub fn request_clear_egui_memory(ctx: &egui::Context) {
ctx.data_mut(|d| d.insert_temp(clear_egui_memory_id(), true));
}
pub fn update_graph_pane_head(
ctx: &egui::Context,
old_head: &gantz_ca::Head,
new_head: &gantz_ca::Head,
) {
if old_head == new_head {
return;
}
let graph_tree_id = egui::Id::new(GRAPH_TREE_ID);
let Some(mut tree) = load_tree::<GraphPane>(ctx, graph_tree_id) else {
return;
};
let mut changed = false;
for (_, tile) in tree.tiles.iter_mut() {
if let egui_tiles::Tile::Pane(GraphPane(head)) = tile {
if head == old_head {
*head = new_head.clone();
changed = true;
break;
}
}
}
if changed {
store_tree(ctx, graph_tree_id, &tree);
}
}
fn migrate_node_view_paths(
tree: &mut egui_tiles::Tree<Pane>,
head: &gantz_ca::Head,
reindex: &crate::ops::Reindex,
) {
if reindex.is_empty() {
return;
}
let mut to_remove = Vec::new();
for (id, tile) in tree.tiles.iter_mut() {
let egui_tiles::Tile::Pane(Pane::NodeView(pane)) = tile else {
continue;
};
if pane.head != *head {
continue;
}
let [ix] = pane.path[..] else { continue };
match reindex.apply_to_index(ix) {
Some(new_ix) => pane.path = vec![new_ix],
None => to_remove.push(*id),
}
}
for id in to_remove {
tree.tiles.remove(id);
}
}
struct PaneCtx<'a, 's, Access>
where
Access: HeadAccess,
{
gantz: &'a mut Gantz<'s>,
state: &'a mut GantzState,
access: &'a mut Access,
focused_head: usize,
base_names: &'a crate::reg::Names,
response: &'a mut GantzResponse,
}
struct TreeBehaviour<'a, 's, Access>
where
Access: HeadAccess,
{
gantz: &'a mut Gantz<'s>,
state: &'a mut GantzState,
access: &'a mut Access,
focused_head: usize,
base_names: &'a crate::reg::Names,
gantz_response: &'a mut GantzResponse,
windowed: &'a mut Vec<Pane>,
}
#[derive(Debug)]
pub struct GantzResponse {
pub focused_head: usize,
pub graph_select: Option<widget::graph_select::GraphSelectResponse>,
pub closed_heads: Vec<gantz_ca::Head>,
pub new_branch: Option<(gantz_ca::Head, String)>,
pub file_drops: Vec<FileDrop>,
pub demo_changed: Option<(gantz_ca::Head, Option<String>)>,
pub description_changed: Option<(gantz_ca::Head, String)>,
pub reset_base_graph: Option<gantz_ca::Head>,
pub reset_all_demos: bool,
pub compile_config: Option<gantz_core::compile::Config>,
pub validate_change_tracking: Option<bool>,
pub base_source_changed: Option<(gantz_ca::Head, String)>,
pub changed_heads: Vec<gantz_ca::Head>,
pub responses: Responses,
pub windowed_panes: Vec<WindowedPane>,
pub(crate) node_view_reindexes: Vec<(gantz_ca::Head, crate::ops::Reindex)>,
}
#[derive(Clone, Debug)]
pub struct WindowedPane {
pub pane: Pane,
pub title: String,
}
#[derive(Clone, Default)]
struct TabEditState {
editing_tile_id: Option<egui_tiles::TileId>,
edit_text: String,
request_focus: bool,
}
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct ViewToggles {
pub sidebar_open: bool,
pub graphs: bool,
pub history: bool,
pub settings: bool,
pub logs: bool,
pub node_inspector: bool,
pub perf_gui: bool,
pub perf_vm: bool,
pub steel: bool,
pub gui_debug: bool,
pub graph_config: bool,
pub ext: BTreeMap<String, bool>,
}
impl Default for ViewToggles {
fn default() -> Self {
Self {
sidebar_open: false,
graphs: true,
history: true,
settings: true,
logs: false,
node_inspector: true,
perf_gui: false,
perf_vm: false,
steel: false,
gui_debug: false,
graph_config: true,
ext: BTreeMap::new(),
}
}
}
struct NodeTyCmd<'a> {
env: &'a Env<'a>,
name: &'a str,
}
impl GantzResponse {
fn new(focused_head: usize) -> Self {
GantzResponse {
focused_head,
graph_select: None,
closed_heads: Vec::new(),
new_branch: None,
file_drops: Vec::new(),
demo_changed: None,
description_changed: None,
reset_base_graph: None,
reset_all_demos: false,
compile_config: None,
validate_change_tracking: None,
base_source_changed: None,
changed_heads: Vec::new(),
responses: Responses::default(),
windowed_panes: Vec::new(),
node_view_reindexes: Vec::new(),
}
}
pub fn new_graph(&self) -> bool {
self.graph_select
.as_ref()
.map(|g| g.new_graph)
.unwrap_or(false)
}
pub fn graph_replaced(&self) -> Option<&gantz_ca::Head> {
self.graph_select.as_ref().and_then(|g| g.replaced.as_ref())
}
pub fn graph_opened(&self) -> Option<&gantz_ca::Head> {
self.graph_select.as_ref().and_then(|g| g.opened.as_ref())
}
pub fn graph_closed(&self) -> Option<&gantz_ca::Head> {
self.graph_select.as_ref().and_then(|g| g.closed.as_ref())
}
pub fn graph_name_removed(&self) -> Option<gantz_ca::Name> {
self.graph_select
.as_ref()
.and_then(|g| g.name_removed.clone())
}
pub fn new_branch(&self) -> Option<&(gantz_ca::Head, String)> {
self.new_branch.as_ref()
}
pub fn import(&self) -> bool {
self.graph_select
.as_ref()
.map(|g| g.import)
.unwrap_or(false)
}
}
impl<'a> Gantz<'a> {
pub fn new(env: &'a Env<'a>, base_names: &'a crate::reg::Names) -> Self {
Self {
env,
codec: env.codec,
base_names,
log_source: None,
perf_vm: None,
perf_gui: None,
base_immutable: true,
compile_config: None,
validate_change_tracking: None,
settings_tabs: &mut [],
ext_panes: &mut [],
ref_ext_uis: &[],
edge_styles: &[],
base_sources: None,
pane_window_mode: PaneWindowMode::default(),
collab: None,
clipboard: None,
}
}
pub fn collab(mut self, collab: &'a crate::collab::CollabUiState) -> Self {
self.collab = Some(collab);
self
}
pub fn clipboard(mut self, clipboard: &'a dyn Fn() -> Option<String>) -> Self {
self.clipboard = Some(clipboard);
self
}
pub fn pane_window_mode(mut self, mode: PaneWindowMode) -> Self {
self.pane_window_mode = mode;
self
}
pub fn compile_config(mut self, config: gantz_core::compile::Config) -> Self {
self.compile_config = Some(config);
self
}
pub fn validate_change_tracking(mut self, enabled: bool) -> Self {
self.validate_change_tracking = Some(enabled);
self
}
pub fn settings_tabs(mut self, tabs: &'a mut [&'a mut dyn widget::SettingsTab]) -> Self {
self.settings_tabs = tabs;
self
}
pub fn ext_panes(mut self, panes: &'a mut [&'a mut dyn widget::ExtPane]) -> Self {
self.ext_panes = panes;
self
}
pub fn ref_ext_uis(mut self, uis: &'a [&'a dyn crate::node::RefExtUi]) -> Self {
self.ref_ext_uis = uis;
self
}
pub fn edge_styles(mut self, styles: &'a [&'a dyn widget::EdgeStyle]) -> Self {
self.edge_styles = styles;
self
}
pub fn base_sources(mut self, ctx: BaseSourcesCtx<'a>) -> Self {
self.base_sources = Some(ctx);
self
}
pub fn logger(mut self, logger: widget::log_view::Logger) -> Self {
self.log_source = Some(LogSource::Logger(logger));
self
}
#[cfg(feature = "tracing")]
pub fn trace_capture(
mut self,
trace_capture: widget::trace_view::TraceCapture,
level: tracing::level_filters::LevelFilter,
) -> Self {
self.log_source = Some(LogSource::TraceCapture(trace_capture, level));
self
}
pub fn perf_captures(
mut self,
perf_vm: &'a mut widget::PerfCapture,
perf_gui: &'a mut widget::PerfCapture,
) -> Self {
self.perf_vm = Some(perf_vm);
self.perf_gui = Some(perf_gui);
self
}
pub fn base_immutable(mut self, base_immutable: bool) -> Self {
self.base_immutable = base_immutable;
self
}
pub fn show<'s, Access>(
mut self,
state: &'s mut GantzState,
focused_head: usize,
access: &'s mut Access,
ui: &'s mut egui::Ui,
) -> GantzResponse
where
's: 'a,
Access: HeadAccess,
{
if ui
.ctx()
.data(|d| d.get_temp::<bool>(clear_egui_memory_id()))
.unwrap_or(false)
{
ui.ctx().memory_mut(|m| m.data.clear());
}
let tree_id = egui::Id::new("gantz-tiles-tree-storage-v4");
let mut tree: egui_tiles::Tree<Pane> =
load_tree(ui.ctx(), tree_id).unwrap_or_else(create_tree);
let mut windowed: Vec<Pane> = load_ron(ui.ctx(), windowed_panes_id()).unwrap_or_default();
let ext_keys: Vec<&str> = self.ext_panes.iter().map(|p| p.key()).collect();
sync_ext_panes(&mut tree, &ext_keys);
sync_singleton_pane(&mut tree, Pane::GuiDebug);
set_tile_visibility(&mut tree, &state.view_toggles);
simplify_tree(&mut tree, ui.ctx());
let widget_area = ui.available_rect_before_wrap();
impose_fixed_sizes(&mut tree, state, widget_area);
let mut response = GantzResponse::new(focused_head);
let base_names = self.base_names;
let mut behaviour = TreeBehaviour {
gantz: &mut self,
state: &mut *state,
access: &mut *access,
focused_head,
base_names,
gantz_response: &mut response,
windowed: &mut windowed,
};
tree.ui(&mut behaviour, ui);
response.focused_head = behaviour.focused_head;
capture_fixed_sizes(&tree, state, widget_area);
response.file_drops = collect_gantz_file_drops(ui.ctx());
for _ in response.responses.take::<OpenNodePalette>() {
state.node_palette.toggle();
}
for _ in response.responses.take::<ResetTilesLayout>() {
tree = create_tree();
state.sidebar_width = default_sidebar_width();
state.tray_height = default_tray_height();
let sidebar_open = state.view_toggles.sidebar_open;
state.view_toggles = ViewToggles::default();
state.view_toggles.sidebar_open = sidebar_open;
}
for _ in response.responses.take::<OpenLogs>() {
state.view_toggles.logs = true;
}
for (head, reindex) in &response.node_view_reindexes {
migrate_node_view_paths(&mut tree, head, reindex);
migrate_windowed_node_views(&mut windowed, head, reindex);
}
for (head, view) in response.responses.take::<OpenNodeView>() {
let Some(head) = head else { continue };
let windowed_already = windowed.iter().any(
|p| matches!(p, Pane::NodeView(np) if np.head == head && np.path == view.path),
);
if windowed_already {
continue;
}
add_node_view_pane(&mut tree, head, view.path, view.ty_name);
}
windowed
.retain(|p| matches!(p, Pane::NodeView(_)) || !pane_is_visible(&state.view_toggles, p));
let mut redock: Vec<Pane> = drain_pending(ui.ctx(), pending_redock_id());
let close: Vec<Pane> = drain_pending(ui.ctx(), pending_close_id());
if self.pane_window_mode == PaneWindowMode::EguiWindow {
for pane in &mut windowed {
let title = pane_title(&self, &*access, focused_head, pane);
let mut open = true;
egui::Window::new(title)
.id(egui::Id::new(("gantz-windowed-pane", pane_key(pane))))
.open(&mut open)
.show(ui.ctx(), |ui| {
ui.push_id(("gantz-windowed", pane_key(pane)), |ui| {
let mut cx = PaneCtx {
gantz: &mut self,
state: &mut *state,
access: &mut *access,
focused_head,
base_names,
response: &mut response,
};
render_pane(&mut cx, ui, pane);
});
});
if !open {
redock.push(pane.clone());
}
}
}
for pane in redock {
let key = pane_key(&pane);
windowed.retain(|p| pane_key(p) != key);
redock_pane(&mut state.view_toggles, &mut tree, pane);
}
for pane in close {
let key = pane_key(&pane);
windowed.retain(|p| pane_key(p) != key);
if !matches!(pane, Pane::NodeView(_)) {
redock_pane(&mut state.view_toggles, &mut tree, pane);
}
}
response.windowed_panes = windowed
.iter()
.map(|pane| WindowedPane {
pane: pane.clone(),
title: pane_title(&self, &*access, focused_head, pane),
})
.collect();
store_tree(ui.ctx(), tree_id, &tree);
store_ron(ui.ctx(), windowed_panes_id(), &windowed);
response
}
pub fn render_windowed_pane<'s, Access>(
mut self,
state: &'s mut GantzState,
focused_head: usize,
access: &'s mut Access,
pane: &mut Pane,
ui: &mut egui::Ui,
) -> GantzResponse
where
's: 'a,
Access: HeadAccess,
{
let mut response = GantzResponse::new(focused_head);
let base_names = self.base_names;
let mut cx = PaneCtx {
gantz: &mut self,
state,
access,
focused_head,
base_names,
response: &mut response,
};
render_pane(&mut cx, ui, pane);
response
}
}
impl GantzState {
pub const DEFAULT_DIRECTION: egui::Direction = egui::Direction::TopDown;
pub fn new() -> Self {
Self::from_open_heads(Default::default())
}
pub fn from_open_heads(open_heads: OpenHeadStates) -> Self {
Self {
open_heads,
node_palette: widget::NodePalette::default(),
view_toggles: ViewToggles::default(),
layout_config: LayoutConfig::default(),
scene_config: SceneConfig::default(),
keymap: Keymap::default(),
collab: Default::default(),
merge_resolutions: Default::default(),
redo_stacks: HashMap::new(),
undo_cursors: HashMap::new(),
sidebar_width: default_sidebar_width(),
tray_height: default_tray_height(),
windowed_geometry: HashMap::new(),
}
}
pub fn migrate_head(&mut self, old: &gantz_ca::Head, new: &gantz_ca::Head, clear_redo: bool) {
if let Some(state) = self.open_heads.remove(old) {
self.open_heads.insert(new.clone(), state);
}
migrate_or_clear(&mut self.redo_stacks, old, new, clear_redo);
migrate_or_clear(&mut self.undo_cursors, old, new, clear_redo);
}
}
fn migrate_or_clear<V>(
map: &mut HashMap<gantz_ca::Head, V>,
old: &gantz_ca::Head,
new: &gantz_ca::Head,
clear: bool,
) {
if clear {
map.remove(old);
map.remove(new);
} else if let Some(v) = map.remove(old) {
map.insert(new.clone(), v);
}
}
impl<'a, 's, Access> egui_tiles::Behavior<Pane> for TreeBehaviour<'a, 's, Access>
where
Access: HeadAccess,
{
fn tab_title_for_pane(&mut self, pane: &Pane) -> egui::WidgetText {
pane_title(self.gantz, self.access, self.focused_head, pane).into()
}
fn on_tab_button(
&mut self,
tiles: &mut egui_tiles::Tiles<Pane>,
tile_id: egui_tiles::TileId,
button_response: egui::Response,
) -> egui::Response {
let pane = match tiles.get(tile_id) {
Some(egui_tiles::Tile::Pane(pane))
if pane_is_hideable(pane) || pane_is_poppable(pane) =>
{
pane.clone()
}
_ => return button_response,
};
button_response.context_menu(|ui| {
if pane_is_hideable(&pane) && ui.button("hide").clicked() {
set_pane_visible(&mut self.state.view_toggles, &pane, false);
ui.close();
}
if pane_is_poppable(&pane) && ui.button("pop out to window").clicked() {
detach_pane(
&mut self.state.view_toggles,
self.windowed,
tiles,
tile_id,
&pane,
);
ui.close();
}
});
button_response
}
fn is_tab_closable(
&self,
tiles: &egui_tiles::Tiles<Pane>,
tile_id: egui_tiles::TileId,
) -> bool {
matches!(
tiles.get_pane(&tile_id),
Some(Pane::Logs | Pane::Steel | Pane::NodeView(_))
)
}
fn on_tab_close(
&mut self,
tiles: &mut egui_tiles::Tiles<Pane>,
tile_id: egui_tiles::TileId,
) -> bool {
match tiles.get_pane(&tile_id) {
Some(Pane::NodeView(_)) => true,
Some(pane) => {
let pane = pane.clone();
set_pane_visible(&mut self.state.view_toggles, &pane, false);
false
}
None => false,
}
}
fn tab_ui(
&mut self,
tiles: &mut egui_tiles::Tiles<Pane>,
ui: &mut egui::Ui,
id: egui::Id,
tile_id: egui_tiles::TileId,
state: &egui_tiles::TabState,
) -> egui::Response {
let title = self.tab_title_for_tile(tiles, tile_id);
let res = widget::Tab::new(title, id)
.active(state.active)
.closable(state.closable)
.show(ui);
if res.close.is_some_and(|r| r.clicked()) && self.on_tab_close(tiles, tile_id) {
tiles.remove(tile_id);
}
self.on_tab_button(tiles, tile_id, res.tab)
}
fn tab_bar_color(&self, visuals: &egui::Visuals) -> egui::Color32 {
visuals.panel_fill
}
fn resize_stroke(
&self,
style: &egui::Style,
_resize_state: egui_tiles::ResizeState,
) -> egui::Stroke {
let w = 2.0;
egui::Stroke::new(w, style.visuals.extreme_bg_color)
}
fn tab_outline_stroke(
&self,
_visuals: &egui::Visuals,
_tiles: &egui_tiles::Tiles<Pane>,
_tile_id: egui_tiles::TileId,
_state: &egui_tiles::TabState,
) -> egui::Stroke {
egui::Stroke::NONE
}
fn simplification_options(&self) -> egui_tiles::SimplificationOptions {
egui_tiles::SimplificationOptions::OFF
}
fn pane_ui(
&mut self,
ui: &mut egui::Ui,
_tile_id: egui_tiles::TileId,
pane: &mut Pane,
) -> egui_tiles::UiResponse {
let mut cx = PaneCtx {
gantz: &mut *self.gantz,
state: &mut *self.state,
access: &mut *self.access,
focused_head: self.focused_head,
base_names: self.base_names,
response: &mut *self.gantz_response,
};
render_pane(&mut cx, ui, pane);
self.focused_head = cx.focused_head;
egui_tiles::UiResponse::None
}
}
fn render_pane<Access>(cx: &mut PaneCtx<'_, '_, Access>, ui: &mut egui::Ui, pane: &mut Pane)
where
Access: HeadAccess,
{
let PaneCtx {
gantz,
state,
access,
focused_head,
base_names,
response: gantz_response,
} = cx;
match pane {
Pane::Ext(key) => {
let focused = access.heads().get(*focused_head).cloned();
let mut selection: Vec<node::Id> = focused
.as_ref()
.and_then(|h| state.open_heads.get(h))
.map(|hs| {
hs.scene
.interaction
.selection
.nodes
.iter()
.map(|n| n.index())
.collect()
})
.unwrap_or_default();
selection.sort_unstable();
match gantz.ext_panes.iter_mut().find(|p| p.key() == *key) {
Some(p) => {
let cx = widget::ExtPaneCtx {
focused: focused.as_ref(),
selection: &selection,
};
let res = pane_ui(ui, |ui| p.ui(cx, ui));
let mut ext_responses = res.inner;
gantz_response
.responses
.extend(focused.as_ref(), ext_responses.drain().map(|(_, d)| d));
}
None => {
ui.centered_and_justified(|ui| {
ui.weak(format!("'{key}' pane unavailable (no provider)"));
});
}
}
}
Pane::GraphConfig => match access.heads().get(*focused_head).cloned() {
Some(head) => {
let merge_resolutions = &mut state.merge_resolutions;
let head_state = state.open_heads.entry(head.clone()).or_default();
let names = crate::reg::names(gantz.env.registry);
let is_base = match &head {
gantz_ca::Head::Branch(name) => base_names.contains_key(name),
_ => false,
};
let immutable = head_immutable(&head, gantz.base_immutable, base_names);
let demo_names: Vec<String> = names
.iter()
.filter(|(n, _)| widget::graph_select::is_demo(n))
.map(|(n, _)| n.to_string())
.collect();
let demo_names_vec: Vec<&str> = demo_names.iter().map(|s| s.as_str()).collect();
let current_demo = match &head {
gantz_ca::Head::Branch(name) => gantz.env.demo_graph(&name.to_string()),
_ => None,
};
let current_description = match &head {
gantz_ca::Head::Branch(name) => {
crate::section::description(gantz.env.registry, name)
}
_ => None,
};
let session = gantz.collab.map(|c| match &head {
gantz_ca::Head::Branch(name) => c.sessions.get(name),
_ => None,
});
let res = pane_ui(ui, |ui| {
let mut config = widget::GraphConfig::new(&head, head_state, &names)
.is_base(is_base)
.immutable(immutable)
.demo_names(&demo_names_vec)
.current_demo(current_demo.as_deref())
.current_description(current_description.as_deref())
.merge_env(gantz.env, merge_resolutions);
if let (Some(ctx), gantz_ca::Head::Branch(name)) = (&gantz.base_sources, &head)
{
let current = ctx
.name_sources
.get(&name.to_string())
.copied()
.unwrap_or(ctx.default_source);
config = config.base_sources(ctx.sources, Some(current));
}
if let Some(session) = session {
config = config.collab(session);
}
config.show(ui)
});
if res.inner.new_branch.is_some() {
gantz_response.new_branch = res.inner.new_branch;
}
if let Some(merge) = res.inner.merge {
gantz_response.responses.push(Some(head.clone()), merge);
}
if res.inner.share {
gantz_response
.responses
.push(Some(head.clone()), crate::ShareHead { public: true });
}
if res.inner.stop_sharing {
gantz_response
.responses
.push(Some(head.clone()), crate::StopSharing);
}
if let Some(demo_val) = res.inner.demo_changed {
gantz_response.demo_changed = Some((head.clone(), demo_val));
}
if let Some(description) = res.inner.description_changed {
gantz_response.description_changed = Some((head.clone(), description));
}
if res.inner.reset_base_graph {
gantz_response.reset_base_graph = Some(head.clone());
}
if let Some(source) = res.inner.base_source_changed {
gantz_response.base_source_changed = Some((head.clone(), source));
}
if res.inner.export {
gantz_response.responses.push(Some(head), ExportHead);
}
}
None => {
pane_ui(ui, |ui| {
ui.label("No graph focused");
});
}
},
Pane::GraphScene => {
paint_gantz_file_hover_overlay(ui);
let rect = ui.available_rect_before_wrap();
let ext_panes = ext_pane_entries(gantz);
let graph_tree_id = egui::Id::new(GRAPH_TREE_ID);
let mut graph_tree: egui_tiles::Tree<GraphPane> =
load_tree(ui.ctx(), graph_tree_id).unwrap_or_else(create_empty_graph_tree);
sync_graph_panes(&mut graph_tree, access.heads());
if let Some(fh) = access.heads().get(*focused_head) {
let fh = fh.clone();
graph_tree.make_active(|_, tile| match tile {
egui_tiles::Tile::Pane(GraphPane(head)) => *head == fh,
_ => false,
});
}
let mut graph_behaviour = GraphTreeBehaviour {
env: gantz.env,
codec: gantz.codec,
access: *access,
state,
focused_head,
closed_heads: &mut gantz_response.closed_heads,
new_branch: &mut gantz_response.new_branch,
responses: &mut gantz_response.responses,
changed_heads: &mut gantz_response.changed_heads,
reindexes: &mut gantz_response.node_view_reindexes,
base_names,
base_immutable: gantz.base_immutable,
validate_change_tracking: gantz
.validate_change_tracking
.unwrap_or(cfg!(debug_assertions)),
ext_panes: &ext_panes,
edge_styles: gantz.edge_styles,
collab: gantz.collab,
};
graph_tree.ui(&mut graph_behaviour, ui);
store_tree(ui.ctx(), graph_tree_id, &graph_tree);
if let Some(fh) = access.heads().get(*focused_head).cloned() {
let focused_immutable = head_immutable(&fh, gantz.base_immutable, base_names);
let head_state = state.open_heads.entry(fh.clone()).or_default();
if !ui.ctx().egui_wants_keyboard_input() {
let keymap = &state.keymap;
if keymap.consume(ui, Action::Copy) {
let nodes = head_state.scene.interaction.selection.nodes.clone();
gantz_response
.responses
.push(Some(fh.clone()), CopyNodes(nodes));
}
if keymap.consume(ui, Action::NewGraph) {
let gs = gantz_response
.graph_select
.get_or_insert_with(Default::default);
gs.new_graph = true;
}
if !focused_immutable {
let paste_text = ui.input(|i| {
i.events.iter().find_map(|e| match e {
egui::Event::Paste(s) => Some(s.clone()),
_ => None,
})
});
if paste_text.is_some() || keymap.consume(ui, Action::Paste) {
let paste = Paste {
text: paste_text,
pos: crate::PastePos::Offset(egui::vec2(20.0, 20.0)),
};
gantz_response.responses.push(Some(fh.clone()), paste);
}
if keymap.consume(ui, Action::Redo) {
gantz_response.responses.push(Some(fh.clone()), Redo);
}
if keymap.consume(ui, Action::Undo) {
gantz_response.responses.push(Some(fh.clone()), Undo);
}
if keymap.consume(ui, Action::Cut) {
let nodes = head_state.scene.interaction.selection.nodes.clone();
gantz_response
.responses
.push(Some(fh.clone()), CutNodes(nodes));
}
if keymap.consume(ui, Action::Duplicate) {
let nodes = head_state.scene.interaction.selection.nodes.clone();
gantz_response
.responses
.push(Some(fh.clone()), DuplicateNodes(nodes));
}
}
}
if !focused_immutable {
let editing_name = match &fh {
gantz_ca::Head::Branch(name) => Some(name.to_string()),
_ => None,
};
let editing = editing_name.as_deref();
let pointer_pos = head_state.scene.interaction.last_pointer_pos;
let created = node_palette(
gantz.env,
editing,
&mut state.node_palette,
&state.keymap,
ui,
);
match created {
Some(PaletteChoice::Node(mut create)) => {
create.pos = pointer_pos;
gantz_response.responses.push(Some(fh), create);
}
Some(PaletteChoice::NestedGraph(mut create)) => {
create.pos = pointer_pos;
gantz_response.responses.push(Some(fh), create);
}
None => {}
}
}
}
let space = ui.style().interaction.interact_radius * 3.0;
let anchor = rect.left_bottom() + egui::vec2(space, -space);
sidebar_toggle(ui.ctx(), anchor, &mut state.view_toggles.sidebar_open);
}
Pane::Graphs => {
ui.ctx().memory_mut(|m| {
m.data
.insert_temp(egui::Id::new(GRAPHS_PANE_RECT_ID), ui.max_rect())
});
paint_gantz_file_hover_overlay(ui);
let heads = access.heads();
let mut res = graph_select(
gantz.env,
heads,
*focused_head,
*base_names,
gantz.collab,
gantz.clipboard,
ui,
);
if res.inner.export_all {
gantz_response.responses.push(None, ExportAllNamed);
}
if let Some(ticket) = res.inner.join_ticket.take() {
gantz_response
.responses
.push(None, crate::JoinSession { ticket });
}
match &mut gantz_response.graph_select {
Some(gs) => *gs |= res.inner,
None => gantz_response.graph_select = Some(res.inner),
}
}
Pane::GuiPerf => {
if let Some(ref mut capture) = gantz.perf_gui {
perf_view("GUI Perf", capture, ui);
}
}
Pane::History => {
let heads = access.heads();
let res = history_view(gantz.env, heads, *focused_head, ui);
match &mut gantz_response.graph_select {
Some(gs) => *gs |= res.inner,
None => gantz_response.graph_select = Some(res.inner),
}
}
Pane::Logs => match &gantz.log_source {
None => (),
Some(LogSource::Logger(logger)) => {
let focused = access.heads().get(*focused_head).cloned();
let mut labels: HashMap<Vec<node::Id>, String> = HashMap::new();
if let Some(fh) = &focused {
let paths: BTreeSet<Vec<node::Id>> = logger
.get_entries()
.iter()
.filter_map(|e| gantz_std::log::parse_log_target(&e.target))
.collect();
if !paths.is_empty() {
let env = gantz.env;
let codec = gantz.codec;
access.with_head_mut(fh, |data| {
for path in paths {
let [ix] = path[..] else { continue };
let Some(weight) =
data.graph.node_weight(graph_scene::NodeIndex::new(ix))
else {
continue;
};
let Ok(inst) = codec.reify_ui(weight) else {
continue;
};
labels.insert(path, inst.node.name(env).to_string());
}
});
}
}
let res = log_view(logger, &labels, ui);
if let (Some(path), Some(fh)) = (res.inner.clicked_path, focused) {
if let [node_id] = path[..] {
let head_state = state.open_heads.entry(fh.clone()).or_default();
let selection = &mut head_state.scene.interaction.selection;
selection.clear();
selection.nodes.insert(graph_scene::NodeIndex::new(node_id));
}
}
}
#[cfg(feature = "tracing")]
Some(LogSource::TraceCapture(trace_capture, level)) => {
trace_view(trace_capture, *level, ui);
}
},
Pane::NodeInspector => {
if let Some(fh) = access.heads().get(*focused_head).cloned() {
let immutable = head_immutable(&fh, gantz.base_immutable, base_names);
let head_state = state.open_heads.entry(fh.clone()).or_default();
let ref_ext_uis = gantz.ref_ext_uis;
let codec = gantz.codec;
let result = access.with_head_mut(&fh, |data| {
node_inspector(
gantz.env,
codec,
data.graph,
data.instances,
data.vm,
head_state,
&fh,
immutable,
ref_ext_uis,
ui,
)
.inner
});
if let Some((changed, payloads)) = result {
if changed {
gantz_response.changed_heads.push(fh.clone());
}
gantz_response.responses.extend(Some(&fh), payloads);
}
}
}
Pane::NodeView(view) => {
let head = view.head.clone();
let path = view.path.clone();
let codec = gantz.codec;
let no_margin = access
.with_head_mut(&head, |data| {
let &[ix] = path.as_slice() else {
return false;
};
data.graph
.node_weight(graph_scene::NodeIndex::new(ix))
.is_some_and(|w| match data.instances.peek(ix, w) {
Some(inst) => inst.node.view_no_margin(),
None => codec
.reify_ui(w)
.is_ok_and(|inst| inst.node.view_no_margin()),
})
})
.unwrap_or(false);
let mut frame = egui::Frame::central_panel(ui.style());
if no_margin {
frame.inner_margin = egui::Margin::ZERO;
}
egui::CentralPanel::default()
.frame(frame)
.show_inside(ui, |ui| {
if !access.heads().iter().any(|h| h == &head) {
ui.centered_and_justified(|ui| {
ui.weak("node's graph is not open");
});
return;
}
let env = gantz.env;
let mut writes = Vec::new();
let result = ui
.push_id((&head, &path), |ui| {
access.with_head_mut(&head, |data| {
let &[n_ix] = path.as_slice() else {
return None;
};
let (inlets, outlets) = crate::inlet_outlet_ids(env, data.graph);
let n_id = graph_scene::NodeIndex::new(n_ix);
let weight = data.graph.node_weight(n_id)?;
let mut entry = data.instances.take(codec, n_ix, weight).ok()?;
let ctx = NodeCtx::new(
env,
&path,
&inlets,
&outlets,
&[],
data.vm,
&mut writes,
);
let r = entry.inst.node.view_ui(ctx, ui);
if r.changed {
match entry.inst.erase() {
Ok(node_data) => {
entry.src = node_data.clone();
data.graph[n_id] = node_data;
data.instances.put(n_ix, entry);
}
Err(e) => log::error!(
"node view {n_ix}: failed to erase edited node, \
edit dropped: {e}"
),
}
} else {
data.instances.put(n_ix, entry);
}
Some((r.changed, r.payloads))
})
})
.inner;
match result {
Some(Some((changed, payloads))) => {
if changed {
gantz_response.changed_heads.push(head.clone());
}
gantz_response.responses.extend(Some(&head), payloads);
gantz_response
.responses
.extend(Some(&head), crate::action::state_written(&mut writes));
}
_ => {
ui.centered_and_justified(|ui| {
ui.weak("node not found");
});
}
}
});
}
Pane::Steel => {
let focused = access.heads().get(*focused_head).cloned();
let compile_error = focused.as_ref().and_then(|h| access.compile_error(h));
let compiled_steel = focused
.as_ref()
.and_then(|h| access.module(h))
.map(|m| m.src.as_str())
.unwrap_or("");
let mut highlights: Vec<std::ops::Range<usize>> = vec![];
let mut scroll_to = None;
let mut errors: Vec<std::ops::Range<usize>> = vec![];
if let Some(h) = &focused {
errors = access
.diagnostics(h)
.iter()
.filter_map(|d| d.span.clone())
.collect();
let head_state = state.open_heads.get(h);
if let (Some(module), Some(head_state)) = (access.module(h), head_state) {
let mut selected: Vec<node::Id> = head_state
.scene
.interaction
.selection
.nodes
.iter()
.map(|n| n.index())
.collect();
selected.sort_unstable();
for &ix in &selected {
let spans = module.map.node_spans(&[ix]);
highlights.extend(spans.defs);
highlights.extend(spans.refs);
}
let state_id = egui::Id::new("steel_view_selection");
let current = egui::Id::new(("steel_sel", h, &selected));
let prev: Option<egui::Id> = ui.ctx().data(|d| d.get_temp(state_id));
if prev != Some(current) {
ui.ctx().data_mut(|d| d.insert_temp(state_id, current));
scroll_to = highlights.iter().map(|r| r.start).min();
}
}
}
steel_view(
compiled_steel,
compile_error,
&highlights,
&errors,
scroll_to,
ui,
);
}
Pane::GuiDebug => {
let scratch_id = egui::Id::new("gantz-gui-debug-scratch-mode");
let mut scratch: bool = ui
.ctx()
.data_mut(|d| d.get_persisted(scratch_id))
.unwrap_or(false);
egui::Panel::top(egui::Id::new("gui-debug-mode-panel")).show_inside(ui, |ui| {
ui.horizontal(|ui| {
if ui
.selectable_label(!scratch, "marker")
.on_hover_text("the focused graph's own gui marker tree")
.clicked()
{
scratch = false;
}
if ui
.selectable_label(scratch, "scratch")
.on_hover_text("an editable tree literal")
.clicked()
{
scratch = true;
}
});
});
ui.ctx()
.data_mut(|d| d.insert_persisted(scratch_id, scratch));
if scratch {
let cache = egui::Panel::left(egui::Id::new("gui-debug-editor-panel"))
.resizable(true)
.show_inside(ui, |ui| {
egui::ScrollArea::vertical()
.show(ui, |ui| {
let id = egui::Id::new("gantz-gui-debug-editor");
let out = super::gui_debug::tree_editor(id, ui);
if let Some(err) = &out.cache.eval_err {
ui.colored_label(ui.visuals().error_fg_color, err);
}
if let Some(decoded) = &out.cache.decoded {
gui_debug_warnings(decoded, ui);
}
out.cache
})
.inner
})
.inner;
egui::CentralPanel::default().show_inside(ui, |ui| {
let Some(decoded) = &cache.decoded else {
ui.weak("nothing to render");
return;
};
let Some(head) = access.heads().get(*focused_head).cloned() else {
ui.weak("no focused graph");
return;
};
let env = gantz.env;
let codec = gantz.codec;
egui::ScrollArea::both().show(ui, |ui| {
let payloads = access.with_head_mut(&head, |data| {
gui_debug_tree(
env, codec, &head, data.graph, data.vm, decoded, "scratch", ui,
)
});
if let Some(payloads) = payloads {
gantz_response.responses.extend(Some(&head), payloads);
}
});
});
} else {
let Some(head) = access.heads().get(*focused_head).cloned() else {
ui.weak("no focused graph");
return;
};
let env = gantz.env;
let codec = gantz.codec;
let payloads = access.with_head_mut(&head, |data| {
let markers = crate::node::gui::markers(data.graph);
let decoded = egui::Panel::left(egui::Id::new("gui-debug-marker-panel"))
.resizable(true)
.show_inside(ui, |ui| {
egui::ScrollArea::vertical()
.show(ui, |ui| {
if markers.is_empty() {
ui.weak("add a `gui` node to this graph to define its GUI");
return None;
}
let role_id = egui::Id::new(("gantz-gui-debug-role", &head));
let role = ui
.ctx()
.data(|d| d.get_temp::<crate::node::GuiRole>(role_id))
.filter(|r| markers.iter().any(|(_, g)| g.role == *r))
.unwrap_or_else(|| {
markers
.iter()
.map(|&(_, g)| g.role)
.find(|&r| r == crate::node::GuiRole::Body)
.unwrap_or(markers[0].1.role)
});
let mut role = role;
ui.horizontal(|ui| {
for r in crate::node::GuiRole::ALL {
if markers.iter().any(|(_, g)| g.role == r)
&& ui
.selectable_label(role == r, r.as_str())
.clicked()
{
role = r;
}
}
});
ui.ctx().data_mut(|d| d.insert_temp(role_id, role));
let &(ix, _) = markers
.iter()
.find(|(_, g)| g.role == role)
.expect("role picked from existing markers");
let val = node::state::extract_value(data.vm, &[ix])
.ok()
.flatten()
.filter(|v| !matches!(v, steel::SteelVal::Void));
let Some(val) = val else {
ui.weak("the marker has no stored tree yet");
return None;
};
ui.separator();
ui.add(
egui::Label::new(
egui::RichText::new(format!("{val}")).monospace(),
)
.selectable(true),
);
let decoded = gantz_ui::codec::steel::decode(
&val,
&gantz_ui::Limits::default(),
);
gui_debug_warnings(&decoded, ui);
Some(decoded)
})
.inner
})
.inner;
let mut payloads = Vec::new();
egui::CentralPanel::default().show_inside(ui, |ui| {
let Some(decoded) = &decoded else {
ui.weak("nothing to render");
return;
};
egui::ScrollArea::both().show(ui, |ui| {
payloads = gui_debug_tree(
env, codec, &head, data.graph, data.vm, decoded, "marker", ui,
);
});
});
payloads
});
if let Some(payloads) = payloads {
gantz_response.responses.extend(Some(&head), payloads);
}
}
}
Pane::VmPerf => {
if let Some(ref mut capture) = gantz.perf_vm {
perf_view("VM Perf", capture, ui);
}
}
Pane::Settings => {
let compile_config = gantz.compile_config;
let validate_change_tracking = gantz.validate_change_tracking;
let ext_panes = ext_pane_entries(gantz);
let ext_tabs = &mut *gantz.settings_tabs;
let res = pane_ui(ui, |ui| {
widget::settings(
&mut state.view_toggles,
compile_config,
validate_change_tracking,
&mut state.layout_config,
&mut state.scene_config,
&mut state.keymap,
ext_tabs,
&ext_panes,
ui,
)
});
if let Some(cfg) = res.inner.compile_config {
gantz_response.compile_config = Some(cfg);
}
if let Some(v) = res.inner.validate_change_tracking {
gantz_response.validate_change_tracking = Some(v);
}
if res.inner.reset_all_demos {
gantz_response.reset_all_demos = true;
}
if res.inner.reset_layout {
gantz_response.responses.push(None, ResetTilesLayout);
}
let mut ext_responses = res.inner.responses;
gantz_response
.responses
.extend(None, ext_responses.drain().map(|(_, d)| d));
}
}
}
struct GraphTreeBehaviour<'a, Access>
where
Access: HeadAccess,
{
env: &'a Env<'a>,
codec: &'a NodeCodec,
access: &'a mut Access,
state: &'a mut GantzState,
focused_head: &'a mut usize,
closed_heads: &'a mut Vec<gantz_ca::Head>,
new_branch: &'a mut Option<(gantz_ca::Head, String)>,
responses: &'a mut Responses,
changed_heads: &'a mut Vec<gantz_ca::Head>,
reindexes: &'a mut Vec<(gantz_ca::Head, crate::ops::Reindex)>,
base_names: &'a crate::reg::Names,
base_immutable: bool,
validate_change_tracking: bool,
ext_panes: &'a [widget::ExtPaneEntry],
edge_styles: &'a [&'a dyn widget::EdgeStyle],
collab: Option<&'a crate::collab::CollabUiState>,
}
impl<'a, Access> egui_tiles::Behavior<GraphPane> for GraphTreeBehaviour<'a, Access>
where
Access: HeadAccess,
{
fn tab_title_for_pane(&mut self, pane: &GraphPane) -> egui::WidgetText {
let GraphPane(head) = pane;
head.to_string().into()
}
fn tab_bar_color(&self, visuals: &egui::Visuals) -> egui::Color32 {
visuals.panel_fill
}
fn resize_stroke(
&self,
style: &egui::Style,
_resize_state: egui_tiles::ResizeState,
) -> egui::Stroke {
let w = 2.0;
egui::Stroke::new(w, style.visuals.extreme_bg_color)
}
fn tab_outline_stroke(
&self,
_visuals: &egui::Visuals,
_tiles: &egui_tiles::Tiles<GraphPane>,
_tile_id: egui_tiles::TileId,
_state: &egui_tiles::TabState,
) -> egui::Stroke {
egui::Stroke::NONE
}
fn simplification_options(&self) -> egui_tiles::SimplificationOptions {
egui_tiles::SimplificationOptions {
all_panes_must_have_tabs: true,
..Default::default()
}
}
fn is_tab_closable(
&self,
_tiles: &egui_tiles::Tiles<GraphPane>,
_tile_id: egui_tiles::TileId,
) -> bool {
self.access.heads().len() > 1
}
fn on_tab_close(
&mut self,
tiles: &mut egui_tiles::Tiles<GraphPane>,
tile_id: egui_tiles::TileId,
) -> bool {
if let Some(GraphPane(head)) = tiles.get_pane(&tile_id).cloned() {
self.closed_heads.push(head);
}
true
}
fn tab_ui(
&mut self,
tiles: &mut egui_tiles::Tiles<GraphPane>,
ui: &mut egui::Ui,
id: egui::Id,
tile_id: egui_tiles::TileId,
state: &egui_tiles::TabState,
) -> egui::Response {
let edit_state_id = egui::Id::new("tab_edit_state");
let mut edit_state: TabEditState = ui
.memory_mut(|m| m.data.get_temp(edit_state_id))
.unwrap_or_default();
let is_editing = edit_state.editing_tile_id == Some(tile_id);
let response = if is_editing {
let head = tiles.get_pane(&tile_id).map(|GraphPane(h)| h.clone());
let names = crate::reg::names(self.env.registry);
let name_res = head.as_ref().map(|h| {
ui.scope(|ui| {
ui.set_max_width(ui.available_width().min(150.0));
widget::head_name_edit(h, &mut edit_state.edit_text, &names, ui)
})
.inner
});
let Some(name_res) = name_res else {
edit_state.editing_tile_id = None;
edit_state.edit_text.clear();
ui.memory_mut(|m| m.data.insert_temp(edit_state_id, edit_state));
return ui.label("");
};
if edit_state.request_focus {
name_res.response.request_focus();
edit_state.request_focus = false;
}
let editing_ended =
name_res.response.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Escape));
if editing_ended {
if let Some(new_branch) = name_res.new_branch {
*self.new_branch = Some(new_branch);
}
edit_state.editing_tile_id = None;
edit_state.edit_text.clear();
}
name_res.response
} else {
let mut title = self.tab_title_for_tile(tiles, tile_id).text().to_string();
let mut session = None;
if let Some(GraphPane(head)) = tiles.get_pane(&tile_id) {
let heads = self.access.heads();
if crate::head_is_focused(heads, *self.focused_head, head) {
title.push_str(" âš«");
}
if let (Some(collab), gantz_ca::Head::Branch(name)) = (self.collab, head) {
session = collab.sessions.get(name);
}
}
let mut tab = widget::Tab::new(title, id)
.active(state.active)
.closable(state.closable)
.hint("double-click to rename");
if let Some(display) = session {
tab = tab.status_dot(display.conn.color(), display.hover_text());
}
let res = tab.show(ui);
if res.tab.double_clicked() {
if let Some(GraphPane(head)) = tiles.get_pane(&tile_id) {
let initial_text = match head {
gantz_ca::Head::Branch(name) => name.to_string(),
gantz_ca::Head::Commit(_) => String::new(),
};
edit_state.editing_tile_id = Some(tile_id);
edit_state.edit_text = initial_text;
edit_state.request_focus = true;
}
}
if res.tab.clicked() {
if let Some(GraphPane(head)) = tiles.get_pane(&tile_id) {
if let Some(ix) = self.access.heads().iter().position(|h| h == head) {
*self.focused_head = ix;
}
}
}
if res.close.is_some_and(|r| r.clicked()) {
if self.on_tab_close(tiles, tile_id) {
tiles.remove(tile_id);
}
}
res.tab
};
ui.memory_mut(|m| m.data.insert_temp(edit_state_id, edit_state));
response
}
fn pane_ui(
&mut self,
ui: &mut egui::Ui,
_tile_id: egui_tiles::TileId,
pane: &mut GraphPane,
) -> egui_tiles::UiResponse {
let GraphPane(pane_head) = pane;
let ix = self
.access
.heads()
.iter()
.position(|h| h == pane_head)
.expect("pane head not found in heads");
let immutable = head_immutable(pane_head, self.base_immutable, self.base_names);
let diagnostics = self.access.diagnostics(pane_head).to_vec();
let layout_config = self.state.layout_config;
let scene_config = self.state.scene_config;
let head_state = self.state.open_heads.entry(pane_head.clone()).or_default();
let layout_params = layout_config.to_params(head_state.layout_flow);
let view_toggles = &mut self.state.view_toggles;
let keymap = &self.state.keymap;
let rect = ui.available_rect_before_wrap();
let (graph_response, camera) = match self.access.with_head_mut(pane_head, |data| {
let camera = data.view.camera;
let res = graph_scene(
self.env,
self.codec,
data.graph,
data.instances,
pane_head,
head_state,
view_toggles,
self.ext_panes,
self.edge_styles,
data.view,
layout_params,
scene_config,
immutable,
self.validate_change_tracking,
keymap,
&diagnostics,
data.vm,
ui,
);
(res, camera)
}) {
Some((res, camera)) => (res, Some(camera)),
None => (None, None),
};
if let Some(response) = graph_response {
if response.scene.clicked() || response.any_node_interacted() {
*self.focused_head = ix;
}
if response.changed {
self.changed_heads.push(pane_head.clone());
}
if !response.reindex.is_empty() {
self.reindexes.push((pane_head.clone(), response.reindex));
}
self.responses.extend(Some(&*pane_head), response.responses);
}
let crumbs = name_breadcrumb(rect, pane_head, ui);
self.responses.extend(Some(&*pane_head), crumbs);
if let (Some(collab), gantz_ca::Head::Branch(name)) = (self.collab, &*pane_head) {
if let Some(display) = collab.sessions.get(name) {
if self.state.collab.show_pointers && !display.pointers.is_empty() {
if let Some(camera) = camera {
paint_peer_pointers(rect, camera, &display.pointers, ui);
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(100));
}
}
paint_session_overlay(rect, display, ui);
}
}
egui_tiles::UiResponse::None
}
}
fn paint_peer_pointers(
rect: egui::Rect,
camera: crate::Camera,
pointers: &[crate::collab::PointerDisplay],
ui: &egui::Ui,
) {
let layer = egui::LayerId::new(egui::Order::Foreground, ui.id().with("peer_pointers"));
let mut painter = ui.ctx().layer_painter(layer);
painter.set_clip_rect(rect);
for pointer in pointers {
let screen = rect.center() + (pointer.pos - camera.center) * camera.zoom;
if !rect.expand(24.0).contains(screen) {
continue;
}
painter.circle(
screen,
4.0,
pointer.color,
egui::Stroke::new(1.0, egui::Color32::from_black_alpha(160)),
);
painter.text(
screen + egui::vec2(8.0, 6.0),
egui::Align2::LEFT_TOP,
&pointer.label,
egui::FontId::proportional(11.0),
pointer.color,
);
}
}
fn paint_session_overlay(rect: egui::Rect, display: &crate::collab::SessionDisplay, ui: &egui::Ui) {
use crate::collab::SessionConn;
if !display.awaiting_snapshot {
return;
}
let (heading, detail, color) = if let Some(error) = &display.error {
(
"Failed to connect".to_string(),
Some(error.clone()),
SessionConn::Degraded.color(),
)
} else {
let dots = 1 + (ui.input(|i| i.time) * 2.0) as usize % 3;
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(250));
let heading = format!("Connecting{}", ".".repeat(dots));
(
heading,
Some(display.sync_status()),
ui.visuals().strong_text_color(),
)
};
let layer = egui::LayerId::new(egui::Order::Foreground, ui.id().with("session_overlay"));
let mut painter = ui.ctx().layer_painter(layer);
painter.set_clip_rect(rect);
painter.rect_filled(rect, 0.0, egui::Color32::from_black_alpha(120));
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
heading,
egui::FontId::proportional(20.0),
color,
);
if let Some(detail) = detail {
painter.text(
rect.center() + egui::vec2(0.0, 28.0),
egui::Align2::CENTER_CENTER,
detail,
egui::FontId::proportional(14.0),
ui.visuals().weak_text_color(),
);
}
}
fn node_view_title(pane: &NodeViewPane) -> String {
use std::fmt::Write;
let mut s = format!("{}", pane.head);
let last = pane.path.len().saturating_sub(1);
for (i, seg) in pane.path.iter().enumerate() {
if i == last {
let _ = write!(s, ":{seg}-{}", pane.ty_name);
} else {
let _ = write!(s, ":{seg}");
}
}
s
}
fn pane_title<Access>(
gantz: &Gantz<'_>,
access: &Access,
focused_head: usize,
pane: &Pane,
) -> String
where
Access: HeadAccess,
{
let with_head = |label: &str| match access.heads().get(focused_head) {
Some(head) => format!("{label} - {head}"),
None => label.to_string(),
};
match pane {
Pane::Ext(key) => match gantz.ext_panes.iter().find(|p| p.key() == *key) {
Some(p) => with_head(p.title()),
None => key.clone(),
},
Pane::GraphConfig => with_head("Graph"),
Pane::GraphScene => "Graphs".to_string(),
Pane::Graphs => "Graphs".to_string(),
Pane::GuiPerf => "GUI Perf".to_string(),
Pane::History => "History".to_string(),
Pane::Settings => "Settings".to_string(),
Pane::Logs => match gantz.log_source {
None => "Logs (No Source)".to_string(),
Some(LogSource::Logger(_)) => "Logs".to_string(),
#[cfg(feature = "tracing")]
Some(LogSource::TraceCapture(..)) => "Tracing".to_string(),
},
Pane::NodeInspector => with_head("Node Inspector"),
Pane::NodeView(p) => node_view_title(p),
Pane::Steel => with_head("Steel"),
Pane::GuiDebug => with_head("GUI Debug"),
Pane::VmPerf => "VM Perf".to_string(),
}
}
impl widget::node_palette::Command for NodeTyCmd<'_> {
fn text(&self) -> &str {
self.name
}
fn description(&self) -> Option<std::borrow::Cow<'static, str>> {
self.env.node_description(self.name)
}
fn info_ui(&self, ui: &mut egui::Ui) {
crate::node_info_ui(&self.env.command_info(self.name), ui);
}
fn formatted_kb_shortcut(&self, ctx: &egui::Context) -> Option<String> {
self.env.command_formatted_kb_shortcut(ctx, self.name)
}
}
impl Clone for NodeTyCmd<'_> {
fn clone(&self) -> Self {
Self {
env: self.env,
name: self.name,
}
}
}
impl Copy for NodeTyCmd<'_> {}
impl Default for GantzState {
fn default() -> Self {
Self::new()
}
}
fn create_tree() -> egui_tiles::Tree<Pane> {
let mut tiles = egui_tiles::Tiles::default();
let graph_config = tiles.insert_pane(Pane::GraphConfig);
let graph_scene = tiles.insert_pane(Pane::GraphScene);
let graphs = tiles.insert_pane(Pane::Graphs);
let gui_perf = tiles.insert_pane(Pane::GuiPerf);
let history = tiles.insert_pane(Pane::History);
let logs = tiles.insert_pane(Pane::Logs);
let node_inspector = tiles.insert_pane(Pane::NodeInspector);
let settings = tiles.insert_pane(Pane::Settings);
let steel = tiles.insert_pane(Pane::Steel);
let vm_perf = tiles.insert_pane(Pane::VmPerf);
let graphs_history_settings = tiles.insert_tab_tile(vec![graphs, history, settings]);
let perf = tiles.insert_horizontal_tile(vec![vm_perf, gui_perf]);
let mut shares = egui_tiles::Shares::default();
shares.set_share(graphs_history_settings, 0.30);
shares.set_share(perf, 0.05);
shares.set_share(graph_config, 0.13);
shares.set_share(node_inspector, 0.25);
let left_column = tiles.insert_container(egui_tiles::Linear {
children: vec![graphs_history_settings, perf, graph_config, node_inspector],
dir: egui_tiles::LinearDir::Vertical,
shares,
});
let tray = tiles.insert_horizontal_tile(vec![logs, steel]);
let right_column = tiles.insert_container(egui_tiles::Linear::new_binary(
egui_tiles::LinearDir::Vertical,
[graph_scene, tray],
0.7,
));
let root = tiles.insert_container(egui_tiles::Linear::new_binary(
egui_tiles::LinearDir::Horizontal,
[left_column, right_column],
0.18,
));
egui_tiles::Tree::new("gantz-tiles-tree", root, tiles)
}
fn create_empty_graph_tree() -> egui_tiles::Tree<GraphPane> {
egui_tiles::Tree::empty("graph-tiles")
}
fn add_node_view_pane(
tree: &mut egui_tiles::Tree<Pane>,
head: gantz_ca::Head,
path: Vec<node::Id>,
ty_name: String,
) {
let existing = tree.tiles.iter().find_map(|(id, tile)| match tile {
egui_tiles::Tile::Pane(Pane::NodeView(p)) if p.head == head && p.path == path => Some(*id),
_ => None,
});
if let Some(id) = existing {
tree.make_active(|tile_id, _| tile_id == id);
return;
}
let pane = Pane::NodeView(NodeViewPane {
head,
path,
ty_name,
});
insert_tray_pane(tree, pane);
}
fn insert_tray_pane(tree: &mut egui_tiles::Tree<Pane>, pane: Pane) {
let pane_id = tree.tiles.insert_pane(pane);
let container = layout_anchors(tree).map(|a| a.tray).or_else(|| tree.root());
match container {
Some(c) => tree.move_tile_to_container(pane_id, c, usize::MAX, true),
None => tree.root = Some(pane_id),
}
}
fn sync_ext_panes(tree: &mut egui_tiles::Tree<Pane>, keys: &[&str]) {
for &key in keys {
let exists = tree
.tiles
.iter()
.any(|(_, tile)| matches!(tile, egui_tiles::Tile::Pane(Pane::Ext(k)) if k == key));
if !exists {
insert_tray_pane(tree, Pane::Ext(key.to_string()));
}
}
}
fn sync_singleton_pane(tree: &mut egui_tiles::Tree<Pane>, pane: Pane) {
let exists = tree
.tiles
.iter()
.any(|(_, tile)| matches!(tile, egui_tiles::Tile::Pane(p) if *p == pane));
if !exists {
insert_tray_pane(tree, pane);
}
}
fn sync_graph_panes(tree: &mut egui_tiles::Tree<GraphPane>, heads: &[gantz_ca::Head]) {
use std::collections::HashSet;
let existing: HashSet<gantz_ca::Head> = tree
.tiles
.iter()
.filter_map(|(_, tile)| match tile {
egui_tiles::Tile::Pane(GraphPane(head)) => Some(head.clone()),
_ => None,
})
.collect();
let current: HashSet<gantz_ca::Head> = heads.iter().cloned().collect();
for head in heads {
if !existing.contains(head) {
let pane_id = tree.tiles.insert_pane(GraphPane(head.clone()));
if let Some(root_id) = tree.root() {
tree.move_tile_to_container(pane_id, root_id, usize::MAX, true);
} else {
let root = tree.tiles.insert_tab_tile(vec![pane_id]);
tree.root = Some(root);
}
}
}
let orphaned: Vec<egui_tiles::TileId> = tree
.tiles
.iter()
.filter_map(|(id, tile)| match tile {
egui_tiles::Tile::Pane(GraphPane(head)) if !current.contains(head) => Some(*id),
_ => None,
})
.collect();
for id in orphaned {
tree.tiles.remove(id);
}
}
fn simplify_tree(tree: &mut egui_tiles::Tree<Pane>, ctx: &egui::Context) {
tree.simplify(&egui_tiles::SimplificationOptions {
all_panes_must_have_tabs: true,
..Default::default()
});
if tree.dragged_id(ctx).is_some() {
return;
}
let Some(graph_scene_id) = tree.tiles.find_pane(&Pane::GraphScene) else {
return;
};
let Some(parent_id) = tree.tiles.parent_of(graph_scene_id) else {
return;
};
let Some(parent) = tree.tiles.get_container(parent_id) else {
return;
};
if parent.num_children() == 1 {
tree.tiles.remove(graph_scene_id);
tree.tiles
.insert(parent_id, egui_tiles::Tile::Pane(Pane::GraphScene));
}
}
const TILE_GAP: f32 = 1.0;
const MIN_PANE_SIZE: f32 = 80.0;
struct LayoutAnchors {
root: egui_tiles::TileId,
left_column: egui_tiles::TileId,
right_column: egui_tiles::TileId,
graph_scene: egui_tiles::TileId,
tray: egui_tiles::TileId,
}
fn layout_anchors(tree: &egui_tiles::Tree<Pane>) -> Option<LayoutAnchors> {
let graph_scene = tree.tiles.find_pane(&Pane::GraphScene)?;
let right_column = tree.tiles.parent_of(graph_scene)?;
let root = tree.root()?;
let &[a, b] = linear_children(tree, root, egui_tiles::LinearDir::Horizontal)?.as_slice() else {
return None;
};
let left_column = match (a == right_column, b == right_column) {
(false, true) => a,
(true, false) => b,
_ => return None,
};
let &[c, d] = linear_children(tree, right_column, egui_tiles::LinearDir::Vertical)?.as_slice()
else {
return None;
};
let tray = match (c == graph_scene, d == graph_scene) {
(true, false) => d,
(false, true) => c,
_ => return None,
};
Some(LayoutAnchors {
root,
left_column,
right_column,
graph_scene,
tray,
})
}
fn linear_children(
tree: &egui_tiles::Tree<Pane>,
id: egui_tiles::TileId,
dir: egui_tiles::LinearDir,
) -> Option<Vec<egui_tiles::TileId>> {
match tree.tiles.get_container(id)? {
egui_tiles::Container::Linear(l) if l.dir == dir => Some(l.children.clone()),
_ => None,
}
}
fn set_linear_shares(
tree: &mut egui_tiles::Tree<Pane>,
container: egui_tiles::TileId,
a: egui_tiles::TileId,
a_share: f32,
b: egui_tiles::TileId,
b_share: f32,
) {
if let Some(egui_tiles::Tile::Container(egui_tiles::Container::Linear(l))) =
tree.tiles.get_mut(container)
{
l.shares.set_share(a, a_share);
l.shares.set_share(b, b_share);
}
}
fn impose_fixed_sizes(tree: &mut egui_tiles::Tree<Pane>, state: &GantzState, area: egui::Rect) {
let Some(anchors) = layout_anchors(tree) else {
return;
};
if state.view_toggles.sidebar_open {
let avail = area.width() - TILE_GAP;
let width = state
.sidebar_width
.clamp(MIN_PANE_SIZE, (avail - MIN_PANE_SIZE).max(MIN_PANE_SIZE));
set_linear_shares(
tree,
anchors.root,
anchors.left_column,
width,
anchors.right_column,
(avail - width).max(1.0),
);
}
if state.view_toggles.logs || state.view_toggles.steel || state.view_toggles.gui_debug {
let avail = area.height() - TILE_GAP;
let height = state
.tray_height
.clamp(MIN_PANE_SIZE, (avail - MIN_PANE_SIZE).max(MIN_PANE_SIZE));
set_linear_shares(
tree,
anchors.right_column,
anchors.graph_scene,
(avail - height).max(1.0),
anchors.tray,
height,
);
}
}
fn capture_fixed_sizes(tree: &egui_tiles::Tree<Pane>, state: &mut GantzState, area: egui::Rect) {
let Some(anchors) = layout_anchors(tree) else {
return;
};
if tree.is_visible(anchors.left_column) {
if let Some(width) = linear_child_points(
tree,
anchors.root,
anchors.left_column,
area.width() - TILE_GAP,
) {
if width > 1.0 {
state.sidebar_width = width;
}
}
}
if tree.is_visible(anchors.tray) {
if let Some(height) = linear_child_points(
tree,
anchors.right_column,
anchors.tray,
area.height() - TILE_GAP,
) {
if height > 1.0 {
state.tray_height = height;
}
}
}
}
fn linear_child_points(
tree: &egui_tiles::Tree<Pane>,
container: egui_tiles::TileId,
child: egui_tiles::TileId,
available: f32,
) -> Option<f32> {
let egui_tiles::Container::Linear(l) = tree.tiles.get_container(container)? else {
return None;
};
let total: f32 = l
.children
.iter()
.filter(|&&c| tree.is_visible(c))
.map(|&c| l.shares[c])
.sum();
(total > 0.0).then(|| available * l.shares[child] / total)
}
fn ext_pane_entries(gantz: &Gantz) -> Vec<widget::ExtPaneEntry> {
gantz
.ext_panes
.iter()
.map(|p| widget::ExtPaneEntry {
key: p.key().to_string(),
title: p.title().to_string(),
description: p.description().to_string(),
})
.collect()
}
fn pane_is_hideable(pane: &Pane) -> bool {
!matches!(pane, Pane::GraphScene | Pane::NodeView(_))
}
fn set_pane_visible(view: &mut ViewToggles, pane: &Pane, visible: bool) {
match pane {
Pane::Ext(key) => {
view.ext.insert(key.clone(), visible);
}
Pane::Graphs => view.graphs = visible,
Pane::History => view.history = visible,
Pane::Settings => view.settings = visible,
Pane::GraphConfig => view.graph_config = visible,
Pane::NodeInspector => view.node_inspector = visible,
Pane::VmPerf => view.perf_vm = visible,
Pane::GuiPerf => view.perf_gui = visible,
Pane::Logs => view.logs = visible,
Pane::Steel => view.steel = visible,
Pane::GuiDebug => view.gui_debug = visible,
Pane::GraphScene | Pane::NodeView(_) => {}
}
}
fn pane_is_visible(view: &ViewToggles, pane: &Pane) -> bool {
match pane {
Pane::Ext(key) => view.ext.get(key).copied().unwrap_or(false),
Pane::Graphs => view.graphs,
Pane::History => view.history,
Pane::Settings => view.settings,
Pane::GraphConfig => view.graph_config,
Pane::NodeInspector => view.node_inspector,
Pane::VmPerf => view.perf_vm,
Pane::GuiPerf => view.perf_gui,
Pane::Logs => view.logs,
Pane::Steel => view.steel,
Pane::GuiDebug => view.gui_debug,
Pane::GraphScene | Pane::NodeView(_) => true,
}
}
fn pane_is_poppable(pane: &Pane) -> bool {
!matches!(pane, Pane::GraphScene)
}
pub fn pane_key(pane: &Pane) -> String {
match pane {
Pane::Ext(key) => format!("ext:{key}"),
Pane::GraphConfig => "graph-config".to_string(),
Pane::GraphScene => "graph-scene".to_string(),
Pane::Graphs => "graphs".to_string(),
Pane::GuiDebug => "gui-debug".to_string(),
Pane::GuiPerf => "gui-perf".to_string(),
Pane::History => "history".to_string(),
Pane::Logs => "logs".to_string(),
Pane::NodeInspector => "node-inspector".to_string(),
Pane::Settings => "settings".to_string(),
Pane::Steel => "steel".to_string(),
Pane::VmPerf => "vm-perf".to_string(),
Pane::NodeView(p) => {
use std::fmt::Write;
let mut s = format!("node-view:{}", p.head);
for seg in &p.path {
let _ = write!(s, ":{seg}");
}
s
}
}
}
fn windowed_panes_id() -> egui::Id {
egui::Id::new("gantz-windowed-panes-storage-v1")
}
fn pending_redock_id() -> egui::Id {
egui::Id::new("gantz-pending-redock")
}
fn pending_close_id() -> egui::Id {
egui::Id::new("gantz-pending-close")
}
fn drain_pending(ctx: &egui::Context, id: egui::Id) -> Vec<Pane> {
let pending: Vec<Pane> = load_ron(ctx, id).unwrap_or_default();
if !pending.is_empty() {
store_ron(ctx, id, &Vec::<Pane>::new());
}
pending
}
fn enqueue_pending(ctx: &egui::Context, id: egui::Id, pane: &Pane) {
let mut pending: Vec<Pane> = load_ron(ctx, id).unwrap_or_default();
pending.push(pane.clone());
store_ron(ctx, id, &pending);
}
pub fn redock_windowed_pane(ctx: &egui::Context, pane: &Pane) {
enqueue_pending(ctx, pending_redock_id(), pane);
}
pub fn close_windowed_pane(ctx: &egui::Context, pane: &Pane) {
enqueue_pending(ctx, pending_close_id(), pane);
}
fn push_windowed(windowed: &mut Vec<Pane>, pane: Pane) {
let key = pane_key(&pane);
if windowed.iter().all(|p| pane_key(p) != key) {
windowed.push(pane);
}
}
fn detach_pane(
view: &mut ViewToggles,
windowed: &mut Vec<Pane>,
tiles: &mut egui_tiles::Tiles<Pane>,
tile_id: egui_tiles::TileId,
pane: &Pane,
) {
match pane {
Pane::NodeView(_) => {
tiles.remove(tile_id);
}
_ => set_pane_visible(view, pane, false),
}
push_windowed(windowed, pane.clone());
}
fn redock_pane(view: &mut ViewToggles, tree: &mut egui_tiles::Tree<Pane>, pane: Pane) {
match pane {
Pane::NodeView(p) => add_node_view_pane(tree, p.head, p.path, p.ty_name),
pane => set_pane_visible(view, &pane, true),
}
}
fn migrate_windowed_node_views(
windowed: &mut Vec<Pane>,
head: &gantz_ca::Head,
reindex: &crate::ops::Reindex,
) {
if reindex.is_empty() {
return;
}
windowed.retain_mut(|pane| {
let Pane::NodeView(p) = pane else {
return true;
};
if p.head != *head {
return true;
}
let [ix] = p.path[..] else {
return true;
};
match reindex.apply_to_index(ix) {
Some(new_ix) => {
p.path = vec![new_ix];
true
}
None => false,
}
});
}
fn set_tile_visibility(tree: &mut egui_tiles::Tree<Pane>, view: &ViewToggles) {
let ids: Vec<_> = tree.tiles.tile_ids().collect();
let open = view.sidebar_open;
for &id in &ids {
if let Some(pane) = tree.tiles.get_pane(&id) {
match pane {
Pane::GraphScene => (),
Pane::Ext(key) => tree.set_visible(id, view.ext.get(key).copied().unwrap_or(false)),
Pane::Settings => tree.set_visible(id, open && view.settings),
Pane::GraphConfig => tree.set_visible(id, open && view.graph_config),
Pane::Graphs => tree.set_visible(id, open && view.graphs),
Pane::GuiPerf => tree.set_visible(id, open && view.perf_gui),
Pane::History => tree.set_visible(id, open && view.history),
Pane::NodeInspector => tree.set_visible(id, open && view.node_inspector),
Pane::VmPerf => tree.set_visible(id, open && view.perf_vm),
Pane::Logs => tree.set_visible(id, view.logs),
Pane::Steel => tree.set_visible(id, view.steel),
Pane::GuiDebug => tree.set_visible(id, view.gui_debug),
Pane::NodeView(_) => tree.set_visible(id, true),
}
}
}
for &id in &ids {
if let Some(container) = tree.tiles.get_container(id) {
let has_visible_child = container.children().any(|&id| tree.is_visible(id));
tree.set_visible(id, has_visible_child);
}
}
}
const GRAPHS_PANE_RECT_ID: &str = "gantz-graphs-pane-rect";
fn paint_gantz_file_hover_overlay(ui: &mut egui::Ui) {
let rect = ui.max_rect();
let latest_pos = ui.ctx().input(|i| i.pointer.latest_pos());
let pointer_over = latest_pos.map(|p| rect.contains(p)).unwrap_or(false);
let has_hovered = ui.ctx().input(|i| {
i.raw
.hovered_files
.iter()
.any(|f| export::is_maybe_gantz(f.path.as_deref()))
});
if has_hovered && pointer_over {
let painter = ui.painter();
painter.rect_filled(rect, 0.0, egui::Color32::from_black_alpha(100));
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
"Drop to import",
egui::FontId::proportional(24.0),
egui::Color32::WHITE,
);
}
}
fn collect_gantz_file_drops(ctx: &egui::Context) -> Vec<FileDrop> {
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
if dropped.is_empty() {
return Vec::new();
}
let graphs_rect: Option<egui::Rect> =
ctx.memory(|m| m.data.get_temp(egui::Id::new(GRAPHS_PANE_RECT_ID)));
let latest_pos = ctx.input(|i| i.pointer.latest_pos());
let over_graphs = match (graphs_rect, latest_pos) {
(Some(rect), Some(pos)) => rect.contains(pos),
_ => false,
};
let target = if over_graphs {
FileDropTarget::Graphs
} else {
FileDropTarget::GraphScene
};
dropped
.iter()
.filter(|f| export::is_maybe_gantz(f.path.as_deref()))
.filter_map(|f| export::read_dropped_file(f))
.map(|bytes| FileDrop { bytes, target })
.collect()
}
#[allow(clippy::too_many_arguments)]
fn gui_debug_tree(
env: &Env<'_>,
codec: &crate::node::NodeCodec,
head: &gantz_ca::Head,
graph: &gantz_ca::DataGraph,
vm: &mut Engine,
decoded: &gantz_ui::Decoded,
salt: &'static str,
ui: &mut egui::Ui,
) -> Vec<crate::response::DynResponse> {
let (inlets, outlets) = crate::inlet_outlet_ids(env, graph);
let head_ca: Option<gantz_ca::ContentAddr> = env
.registry
.head_commit(head)
.map(|commit| commit.graph.into());
let n_outs = super::gui_debug::node_output_counts(env, codec, graph);
let resolver = |p: &[node::Id]| -> Option<usize> {
match p {
[ix] => n_outs.get(ix).copied(),
_ => crate::reg::n_outputs_at(env, head_ca.as_ref()?, p),
}
};
let ref_gui = |chain: &[node::Id]| -> Option<node::Id> {
let (_, marker) = crate::reg::resolve_ref_chain(env, head_ca?, chain)?;
Some(marker)
};
let mut writes = Vec::new();
let mut node_ctx = NodeCtx::new(env, &[], &inlets, &outlets, &[], vm, &mut writes);
let root_id = egui::Id::new(("gantz-gui-debug", head, salt));
let r = crate::ui_tree::UiTree::new(root_id)
.n_outputs(&resolver)
.ref_gui(&ref_gui)
.show(&decoded.root, &mut node_ctx, ui);
let mut payloads = r.payloads;
payloads.extend(
writes
.drain(..)
.map(|w| crate::DynResponse::new(crate::StateWritten(w))),
);
payloads
}
fn gui_debug_warnings(decoded: &gantz_ui::Decoded, ui: &mut egui::Ui) {
if decoded.warnings.is_empty() {
return;
}
let title = format!("warnings ({})", decoded.warnings.len());
egui::CollapsingHeader::new(title).show(ui, |ui| {
for w in &decoded.warnings {
ui.horizontal_wrapped(|ui| {
ui.weak(format!("{:?}", w.path.0));
ui.label(w.kind.to_string());
});
}
});
}
fn pane_ui<R>(ui: &mut egui::Ui, pane: impl FnOnce(&mut egui::Ui) -> R) -> egui::InnerResponse<R> {
egui::CentralPanel::default().show_inside(ui, |ui| pane(ui))
}
const SIDEBAR_TOGGLE_ICON_SIZE: f32 = 18.0;
fn sidebar_toggle(ctx: &egui::Context, anchor_pos: egui::Pos2, open: &mut bool) {
let id = egui::Id::new("gantz-sidebar-toggle");
egui::Area::new(id)
.pivot(egui::Align2::LEFT_BOTTOM)
.fixed_pos(anchor_pos)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
egui::Frame::NONE.show(ui, |ui| {
let font = egui::FontId::proportional(SIDEBAR_TOGGLE_ICON_SIZE);
let galley =
ui.painter()
.layout_no_wrap("☰".to_owned(), font, egui::Color32::PLACEHOLDER);
let (rect, response) = ui.allocate_exact_size(galley.size(), egui::Sense::click());
let color = if response.hovered() {
ui.visuals().weak_text_color()
} else {
ui.style().noninteractive().bg_stroke.color
};
ui.painter().galley(rect.min, galley, color);
if response.clicked() {
*open = !*open;
}
let hint = if *open {
"close sidebar"
} else {
"open sidebar"
};
response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(hint);
});
});
}
fn graph_select(
env: &Env<'_>,
heads: &[gantz_ca::Head],
focused_head: usize,
base_names: &crate::reg::Names,
collab: Option<&crate::collab::CollabUiState>,
clipboard: Option<&dyn Fn() -> Option<String>>,
ui: &mut egui::Ui,
) -> egui::InnerResponse<widget::graph_select::GraphSelectResponse> {
pane_ui(ui, |ui| {
widget::GraphSelect::new(env, heads, base_names)
.focused_head(focused_head)
.collab(collab)
.clipboard(clipboard)
.show(ui)
})
}
fn history_view(
env: &Env<'_>,
heads: &[gantz_ca::Head],
focused_head: usize,
ui: &mut egui::Ui,
) -> egui::InnerResponse<widget::graph_select::GraphSelectResponse> {
pane_ui(ui, |ui| {
widget::HistoryView::new(env, heads)
.focused_head(focused_head)
.show(ui)
})
}
fn perf_view(title: &str, capture: &mut widget::PerfCapture, ui: &mut egui::Ui) {
egui::CentralPanel::default()
.frame(egui::Frame::NONE)
.show_inside(ui, |ui| {
widget::PerfView::new(title, capture).show(ui);
});
}
#[allow(clippy::too_many_arguments)]
fn graph_scene(
registry: &Env<'_>,
codec: &NodeCodec,
graph: &mut gantz_ca::DataGraph,
instances: &mut crate::node::NodeInstances,
head: &gantz_ca::Head,
head_state: &mut OpenHeadState,
view_toggles: &mut ViewToggles,
ext_panes: &[widget::ExtPaneEntry],
edge_styles: &[&dyn widget::EdgeStyle],
head_view: &mut crate::SceneView,
layout_params: egui_graph::LayoutParams,
scene_config: SceneConfig,
immutable: bool,
validate_change_tracking: bool,
keymap: &Keymap,
diagnostics: &[gantz_core::Diagnostic],
vm: &mut Engine,
ui: &mut egui::Ui,
) -> Option<graph_scene::GraphSceneResponse> {
let id = egui::Id::new(head);
if !ui.ctx().egui_wants_keyboard_input() && keymap.consume(ui, Action::SelectAll) {
head_state.scene.interaction.selection.nodes = graph.node_indices().collect();
head_state.scene.interaction.selection.edges.clear();
}
if head_view.layout.is_empty() {
head_view.layout =
widget::graph_scene::layout(registry, codec, graph, id, &layout_params, ui.ctx(), None);
let mut bounds: Option<egui::Rect> = None;
for &pos in head_view.layout.values() {
let r = egui::Rect::from_min_size(pos, egui::Vec2::ZERO);
bounds = Some(bounds.map_or(r, |b| b.union(r)));
}
head_view.camera = crate::Camera {
center: bounds.map_or(egui::Pos2::ZERO, |b| b.center()),
zoom: 1.0,
};
}
let response = GraphScene::new(registry, codec, graph, instances)
.with_id(id)
.layout_params(layout_params)
.scene_config(scene_config)
.immutable(immutable)
.validate_change_tracking(validate_change_tracking)
.view_toggles(view_toggles)
.ext_panes(ext_panes)
.edge_styles(head, edge_styles)
.show(head_view, &mut head_state.scene, vm, ui);
graph_scene::paint_diagnostics(diagnostics, &[], &response, ui);
Some(response)
}
fn name_breadcrumb(
scene_rect: egui::Rect,
head: &gantz_ca::Head,
ui: &mut egui::Ui,
) -> Vec<DynResponse> {
let mut responses = Vec::new();
let gantz_ca::Head::Branch(name) = head else {
return responses;
};
if !name.is_nested() {
return responses; }
let segs: Vec<&str> = name.segments().iter().map(|s| s.as_str()).collect();
let sep_str = gantz_ca::name::SEP.to_string();
let space = ui.style().interaction.interact_radius * 3.0;
let toggle_w = SIDEBAR_TOGGLE_ICON_SIZE + ui.style().spacing.item_spacing.x;
egui::Window::new("breadcrumb_window")
.pivot(egui::Align2::LEFT_BOTTOM)
.fixed_pos(scene_rect.left_bottom() + egui::vec2(space + toggle_w, -space))
.title_bar(false)
.resizable(false)
.collapsible(false)
.frame(egui::Frame::NONE)
.show(ui.ctx(), |ui| {
fn button(s: &str) -> widget::LabelButton {
let text = egui::RichText::new(s).size(24.0);
widget::LabelButton::new(text)
}
let col_w = ui.style().interaction.interact_radius * 4.0;
egui::Grid::new("breadcrumb")
.min_col_width(col_w)
.max_col_width(col_w)
.show(ui, |ui| {
for (i, seg) in segs.iter().enumerate() {
let is_current = i + 1 == segs.len();
let prefix = segs[..=i].join(&sep_str);
let (label, hover) = if i == 0 {
("R".to_string(), format!("navigate to {seg} root"))
} else {
(seg.to_string(), format!("navigate to {prefix}"))
};
ui.vertical_centered_justified(|ui| {
let resp = ui.add(button(&label)).on_hover_text(hover);
if resp.clicked() && !is_current {
responses.push(DynResponse::new(ReplaceHead(
gantz_ca::Head::Branch(prefix.parse().expect("infallible")),
)));
}
});
}
})
});
responses
}
enum PaletteChoice {
Node(CreateNode),
NestedGraph(CreateNestedGraph),
}
fn node_palette(
env: &Env<'_>,
editing: Option<&str>,
node_palette: &mut widget::NodePalette,
keymap: &Keymap,
ui: &mut egui::Ui,
) -> Option<PaletteChoice> {
if !ui.ctx().egui_wants_keyboard_input() && keymap.consume(ui, Action::ToggleNodePalette) {
node_palette.toggle();
}
let types: Vec<&str> = env
.node_types()
.into_iter()
.filter(|&k| k == NESTED_GRAPH_TYPE || editing.is_none_or(|e| !env.would_ref_cycle(k, e)))
.collect();
let cmds = types.iter().map(|&k| NodeTyCmd { env, name: k });
let scene_rect = ui.max_rect();
node_palette.show(ui.ctx(), scene_rect, cmds).map(|cmd| {
if cmd.name == NESTED_GRAPH_TYPE {
PaletteChoice::NestedGraph(CreateNestedGraph { pos: None })
} else {
PaletteChoice::Node(CreateNode {
node_type: cmd.name.to_string(),
pos: None,
})
}
})
}
fn log_view(
logger: &widget::log_view::Logger,
node_labels: &HashMap<Vec<node::Id>, String>,
ui: &mut egui::Ui,
) -> egui::InnerResponse<widget::log_view::LogViewResponse> {
pane_ui(ui, |ui| {
widget::log_view::LogView::new("log-view".into(), logger.clone())
.node_labels(node_labels)
.show(ui)
})
}
fn trace_view(
trace_capture: &widget::trace_view::TraceCapture,
level: tracing::level_filters::LevelFilter,
ui: &mut egui::Ui,
) -> egui::InnerResponse<()> {
pane_ui(ui, |ui| {
widget::trace_view::TraceView::new("trace-view".into(), trace_capture.clone(), level)
.show(ui);
})
}
fn head_immutable(
head: &gantz_ca::Head,
base_immutable: bool,
base_names: &crate::reg::Names,
) -> bool {
let is_base = matches!(head, gantz_ca::Head::Branch(name) if base_names.contains_key(name));
let is_demo =
matches!(head, gantz_ca::Head::Branch(name) if widget::graph_select::is_demo(name));
base_immutable && is_base && !is_demo
}
#[allow(clippy::too_many_arguments)]
fn node_inspector<'a>(
registry: &'a Env<'a>,
codec: &NodeCodec,
root: &mut gantz_ca::DataGraph,
instances: &mut crate::node::NodeInstances,
vm: &mut Engine,
head_state: &mut OpenHeadState,
head: &gantz_ca::Head,
immutable: bool,
ref_ext_uis: &'a [&'a dyn crate::node::RefExtUi],
ui: &mut egui::Ui,
) -> egui::InnerResponse<(bool, Vec<DynResponse>)> {
pane_ui(ui, |ui| {
let mut responses = Vec::new();
let mut changed = false;
egui::ScrollArea::vertical()
.auto_shrink(egui::Vec2b::FALSE)
.show(ui, |ui| {
let graph = &mut *root;
let ids: Vec<_> = graph.node_identifiers().collect();
let (inlets, outlets) = crate::inlet_outlet_ids(registry, graph);
let mut selected_rect: Option<egui::Rect> = None;
let mut writes = Vec::new();
for id in ids {
let mut frame = egui::Frame::group(ui.style());
let is_selected = head_state.scene.interaction.selection.nodes.contains(&id);
if is_selected {
frame.stroke.color = ui.visuals().selection.stroke.color;
}
let frame_resp = frame.show(ui, |ui| {
let Some(weight) = graph.node_weight(id) else {
return;
};
let ix = id.index();
let Ok(mut entry) = instances.take(codec, ix, weight) else {
ui.weak(format!("{} (unknown node type)", weight.tag));
return;
};
let path = [ix];
let ctx = NodeCtx::new(
registry,
&path[..],
&inlets,
&outlets,
ref_ext_uis,
vm,
&mut writes,
);
let resp = widget::NodeInspector::new(&mut entry.inst.node, ctx, immutable)
.show(ui);
if resp.changed {
changed = true;
match entry.inst.erase() {
Ok(node_data) => {
entry.src = node_data.clone();
graph[id] = node_data;
instances.put(ix, entry);
}
Err(e) => log::error!(
"inspector: failed to erase edited node {ix}, \
edit dropped: {e}"
),
}
} else {
instances.put(ix, entry);
}
responses.extend(resp.payloads);
if resp.label_response.clicked() {
let sel = &mut head_state.scene.interaction.selection.nodes;
if ui.input(|i| i.modifiers.command) {
if !sel.remove(&id) {
sel.insert(id);
}
} else {
sel.clear();
sel.insert(id);
}
}
});
responses.extend(crate::action::state_written(&mut writes));
if is_selected && selected_rect.is_none() {
selected_rect = Some(frame_resp.response.rect);
}
}
let state_id = egui::Id::new("node_inspector_selection");
let mut selected: Vec<node::Id> = head_state
.scene
.interaction
.selection
.nodes
.iter()
.map(|n| n.index())
.collect();
selected.sort_unstable();
let current = egui::Id::new(("inspector_sel", head, &selected));
let prev: Option<egui::Id> = ui.ctx().data(|d| d.get_temp(state_id));
if prev != Some(current) {
ui.ctx().data_mut(|d| d.insert_temp(state_id, current));
if let Some(rect) = selected_rect {
ui.scroll_to_rect(rect, Some(egui::Align::Center));
}
}
});
(changed, responses)
})
}
fn steel_view(
compiled_steel: &str,
compile_error: Option<&str>,
highlights: &[std::ops::Range<usize>],
errors: &[std::ops::Range<usize>],
scroll_to: Option<usize>,
ui: &mut egui::Ui,
) -> egui::InnerResponse<()> {
pane_ui(ui, |ui| {
egui::ScrollArea::vertical()
.auto_shrink(egui::Vec2b::FALSE)
.show(ui, |ui| {
if let Some(error) = compile_error {
let color = ui.visuals().error_fg_color;
let text = egui::RichText::new(error).monospace().color(color);
ui.add(egui::Label::new(text).selectable(true));
if !compiled_steel.is_empty() {
ui.separator();
}
}
widget::SteelView::new(compiled_steel)
.highlights(highlights)
.errors(errors)
.scroll_to(scroll_to)
.show(ui);
});
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn impose_sets_sidebar_width_on_left_column() {
let mut tree = create_tree();
let anchors = layout_anchors(&tree).expect("default tree has layout anchors");
let graph_scene = tree.tiles.find_pane(&Pane::GraphScene).unwrap();
assert_ne!(anchors.left_column, anchors.right_column);
assert_eq!(
tree.tiles.parent_of(graph_scene),
Some(anchors.right_column)
);
let mut state = GantzState::new();
state.view_toggles.sidebar_open = true;
state.sidebar_width = 240.0;
let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0));
impose_fixed_sizes(&mut tree, &state, area);
let Some(egui_tiles::Container::Linear(root)) = tree.tiles.get_container(anchors.root)
else {
panic!("root is not a linear container");
};
let avail = 1000.0 - TILE_GAP;
assert!((root.shares[anchors.left_column] - 240.0).abs() < 0.01);
assert!((root.shares[anchors.right_column] - (avail - 240.0)).abs() < 0.01);
}
#[test]
fn capture_round_trips_imposed_sidebar_width() {
let mut tree = create_tree();
let mut state = GantzState::new();
state.view_toggles.sidebar_open = true;
state.sidebar_width = 240.0;
let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0));
impose_fixed_sizes(&mut tree, &state, area);
capture_fixed_sizes(&tree, &mut state, area);
assert!((state.sidebar_width - 240.0).abs() < 0.01);
}
#[test]
fn capture_skips_while_sidebar_laid_out_hidden() {
let mut tree = create_tree();
let anchors = layout_anchors(&tree).unwrap();
tree.set_visible(anchors.left_column, false);
let mut state = GantzState::new();
state.view_toggles.sidebar_open = true;
state.sidebar_width = 240.0;
let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0));
capture_fixed_sizes(&tree, &mut state, area);
assert!((state.sidebar_width - 240.0).abs() < 0.01);
}
fn node_view(head: &str, path: &[node::Id]) -> Pane {
Pane::NodeView(NodeViewPane {
head: gantz_ca::Head::Branch(head.parse().unwrap()),
path: path.to_vec(),
ty_name: "plot".to_string(),
})
}
#[test]
fn pane_key_identity() {
assert_eq!(pane_key(&Pane::Logs), "logs");
assert_ne!(pane_key(&Pane::Logs), pane_key(&Pane::Steel));
let plot = node_view("main", &[3]);
let same_node_number = Pane::NodeView(NodeViewPane {
head: gantz_ca::Head::Branch("main".parse().unwrap()),
path: vec![3],
ty_name: "number".to_string(),
});
assert_eq!(pane_key(&plot), pane_key(&same_node_number));
assert_ne!(pane_key(&plot), pane_key(&node_view("main", &[4])));
assert_ne!(pane_key(&plot), pane_key(&node_view("other", &[3])));
}
#[test]
fn push_windowed_dedupes() {
let mut windowed = Vec::new();
push_windowed(&mut windowed, Pane::Logs);
push_windowed(&mut windowed, Pane::Logs);
push_windowed(&mut windowed, node_view("main", &[3]));
push_windowed(
&mut windowed,
Pane::NodeView(NodeViewPane {
head: gantz_ca::Head::Branch("main".parse().unwrap()),
path: vec![3],
ty_name: "number".to_string(),
}),
);
assert_eq!(windowed.len(), 2);
}
#[test]
fn migrate_windowed_node_views_drops_and_rewrites() {
let head = gantz_ca::Head::Branch("main".parse().unwrap());
let mut windowed = vec![
node_view("main", &[1]), node_view("main", &[3]), node_view("main", &[2]), node_view("other", &[1]), Pane::Logs, ];
let reindex = crate::ops::Reindex(vec![crate::ops::RemoveOp {
removed: 1,
moved_from: Some(3),
}]);
migrate_windowed_node_views(&mut windowed, &head, &reindex);
let main_paths: Vec<Vec<node::Id>> = windowed
.iter()
.filter_map(|p| match p {
Pane::NodeView(nv) if nv.head == head => Some(nv.path.clone()),
_ => None,
})
.collect();
assert_eq!(main_paths, vec![vec![1], vec![2]]);
assert!(windowed.iter().any(|p| matches!(p, Pane::Logs)));
assert!(
windowed
.iter()
.any(|p| matches!(p, Pane::NodeView(nv) if matches!(&nv.head, gantz_ca::Head::Branch(n) if n.to_string() == "other")))
);
assert_eq!(windowed.len(), 4);
}
}