workspace: kebab-case crates, app under crates/oak-app, shared versions
CI / Build & test (Windows) (push) Failing after 7s

All crates take the oak-* kebab-case naming (oak-audio, oak-codec,
oak-common, oak-core, oak-ffmpeg-link, oak-node, oak-otio, oak-plugin,
oak-render, oak-storage, oak-task, oak-timeline, oak-undo), with the
lib identifiers rewritten (oakrender:: -> oak_render::, oakcore_rs:: ->
oak_core::, ...) across all 226 referencing files.

The GUI application moves from the workspace root into
crates/oak-app/: src/, build.rs (paths fixed for the new location) and
tests/ travel with it, the root Cargo.toml becomes workspace-only
([workspace] + workspace.package + profiles), and the app package
inherits the workspace version. The screenshots example becomes a
standalone crate examples/simple_player/ with its own Cargo.toml.

Every crate now inherits the single workspace version
(version.workspace = true), and the workflows' crate paths and the
build docs follow the renames.

Validated with a clean cargo check --workspace.
This commit is contained in:
2026-08-22 16:58:37 +08:00
parent 7e2b3cb4b8
commit 244d5e860f
438 changed files with 2375 additions and 2309 deletions
+713
View File
@@ -0,0 +1,713 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The node editor panel (节点编辑器): the real `gpui::node_graph` canvas over
//! the engine's graph (the mock's demo graph, or the real engine's current
//! sequence graph), with the design's zoom controls (+ / − / 适配).
//!
//! The graph is a full [`NodeGraphView`] fed by the engine's
//! [`NodeGraphDataSource`] implementation. Every gesture the view emits
//! (move, connect, disconnect, delete, selection) is forwarded to the engine
//! as a request; the engine applies it to its model and notifies, so the
//! view re-reads on the next frame. The toolbar buttons drive the viewport
//! directly: zoom in/out at the canvas center, or fit the whole graph.
use std::collections::BTreeSet;
use gpui::colors::DefaultColors;
use gpui::dock::{DockPanel, PanelEvent};
use gpui::node_graph::{
NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeId, NodeVisualState, MAX_ZOOM,
MIN_ZOOM,
};
use gpui::{
div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity,
EventEmitter, MouseButton, Pixels, Point, Render, SharedString, Window,
};
use crate::oakui::component::menu::{Menu, MenuItem};
use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered};
use crate::oakui::component::menu;
use crate::oakui::{AppEngine, NodeLibraryEntry};
use crate::panels::commands::PanelCommandHandler;
use crate::panels::ids::NODE_EDITOR;
/// The node editor panel.
pub struct NodeEditorPanel<E: AppEngine> {
/// The node-graph canvas over the engine's graph data.
graph: Entity<NodeGraphView<E>>,
engine: Entity<E>,
/// Whether the initial fit-to-window has been applied (the canvas size is
/// only known after the first layout).
fitted: bool,
/// The right-click context menu.
context_menu: ContextMenuHandle,
/// Window position of the last right-click: `BackgroundClicked` only
/// carries a graph-space position, so the background menu is placed at
/// the recorded pointer position (the right-click bubbles up to the
/// panel).
last_right_click: Option<Point<Pixels>>,
/// The graph-space position of the last background click — the spot a
/// node added through the Add menu lands on.
add_node_position: Option<Point<Pixels>>,
/// The Add-menu item ids currently on offer, mapped to their factory
/// type ids (rebuilt whenever the menu opens).
add_menu_ids: Vec<(usize, String)>,
/// The engine's node-graph selection mirror the widget was last synced
/// to (see [`Self::sync_graph_selection`]): the panel only pushes into
/// the widget when the engine's authoritative selection changes, so a
/// marquee or click selection inside the graph is never overwritten.
last_graph_selection: Option<u64>,
}
impl<E: AppEngine> NodeEditorPanel<E> {
/// Builds the graph canvas over `engine` and routes its edit requests back
/// to the engine.
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let graph = cx.new(|cx| NodeGraphView::new(engine.clone(), window, cx));
// The "edits are requests" loop: every graph gesture goes to the
// engine, which applies it to its model and notifies.
cx.subscribe(&graph, |this, _graph, event: &NodeGraphEvent, cx| {
this.engine
.update(cx, |engine, cx| engine.apply_node_graph_event(event, cx));
})
.detach();
// The panel-side half of the graph events: the context menus.
cx.subscribe(
&graph,
|this, _graph, event: &NodeGraphEvent, cx| match event {
NodeGraphEvent::BackgroundClicked { position } => {
this.add_node_position = Some(*position);
if let Some(window_position) = this.last_right_click {
let menu = background_menu(
this.engine.read(cx).node_library(),
&mut this.add_menu_ids,
);
this.context_menu.show(window_position, menu, cx);
}
}
NodeGraphEvent::NodeContextMenuRequested { position, .. } => {
this.context_menu.show(*position, node_menu(), cx);
}
_ => {}
},
)
.detach();
// The engine's selection mirror is the single source of truth for
// what the graph highlights: a timeline clip selection (req: the
// selected clip's block node) and an inspector card click both land
// in `selected_graph_node`, and this pushes it into the widget.
cx.observe(&engine, |this, _engine, cx| {
this.sync_graph_selection(cx);
})
.detach();
let context_menu = ContextMenuHandle::new(Self::on_local_menu_item, window, cx);
let mut panel = Self {
graph,
engine,
fitted: false,
context_menu,
last_right_click: None,
add_node_position: None,
add_menu_ids: Vec::new(),
last_graph_selection: None,
};
// If a clip (or graph node) is already selected when the panel is
// built, push the highlight immediately (the observe only fires on
// the next engine notify).
panel.sync_graph_selection(cx);
panel
}
/// Pushes the engine's selection mirror into the graph widget: the
/// single selected node becomes the widget selection, so a timeline
/// clip selection highlights that clip's block node and an inspector
/// card click highlights the effect's node. `None` is never pushed —
/// the widget keeps its live selection (e.g. a marquee) until the
/// engine names a new authoritative node.
fn sync_graph_selection(&mut self, cx: &mut Context<Self>) {
let node = self.engine.read(cx).selected_graph_node();
if node == self.last_graph_selection {
return;
}
self.last_graph_selection = node;
let Some(node) = node else {
return;
};
self.graph
.update(cx, |graph, cx| graph.set_selection(BTreeSet::from([NodeId(node)]), cx));
}
/// Handles the node editor's local (non-registry) context-menu items.
fn on_local_menu_item(&mut self, item: usize, cx: &mut Context<Self>) {
if let Some(color) = menu::color_label_index(item) {
println!("[node editor] set node color label to {color}");
return;
}
if item >= LOCAL_ADD_NODE_BASE {
let type_id = self
.add_menu_ids
.iter()
.find(|entry| entry.0 == item)
.map(|entry| entry.1.clone());
if let Some(type_id) = type_id {
let position = self.add_node_position.unwrap_or_default();
if let Err(err) = self.engine.update(cx, |engine, cx| {
engine.add_node_at(&type_id, position, cx)
}) {
println!("[node editor] add node failed: {err}");
}
}
return;
}
println!("[node editor] context-menu item {item} (not implemented yet)");
}
/// The union of every node's bounds in graph space, if the graph is
/// non-empty.
fn graph_bounds(&self, cx: &App) -> Option<Bounds<Pixels>> {
let nodes = self.engine.read(cx).nodes();
if nodes.is_empty() {
return None;
}
let mut min_x = f32::MAX;
let mut min_y = f32::MAX;
let mut max_x = f32::MIN;
let mut max_y = f32::MIN;
for node in &nodes {
let element = NodeElement::from_node(node, NodeVisualState::default());
let position = node.position();
let width = gpui::node_graph::DEFAULT_NODE_WIDTH;
let height = element.height();
let (x, y) = (f32::from(position.x), f32::from(position.y));
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x + f32::from(width));
max_y = max_y.max(y + f32::from(height));
}
Some(Bounds::from_corners(
point(px(min_x), px(min_y)),
point(px(max_x), px(max_y)),
))
}
/// The canvas size to fit against: the graph view's own painted size once
/// known, otherwise the window (before the first layout).
fn fit_viewport(&self, window: &Window, cx: &App) -> gpui::Size<Pixels> {
let viewport = self.graph.read(cx).viewport_size();
if viewport.width > px(0.0) && viewport.height > px(0.0) {
viewport
} else {
window.viewport_size()
}
}
/// Fits the whole graph into the canvas (the 适配 button).
fn fit_graph(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(rect) = self.graph_bounds(cx) else {
return;
};
let viewport = self.fit_viewport(window, cx);
self.graph.update(cx, |graph, cx| {
graph.state_mut().fit_to_rect(rect, viewport);
cx.notify();
});
}
/// Zooms the canvas by `factor` at its center (`+` / `−` buttons).
fn zoom(&mut self, factor: f32, window: &mut Window, cx: &mut Context<Self>) {
let viewport = self.fit_viewport(window, cx);
let anchor = point(viewport.width * 0.5, viewport.height * 0.5);
self.graph.update(cx, |graph, cx| {
graph.state_mut().zoom_at(anchor, factor);
cx.notify();
});
}
}
/// The node editor implements no focused-panel commands: everything falls
/// through to the shell's global handler.
impl<E: AppEngine> PanelCommandHandler for NodeEditorPanel<E> {}
impl<E: AppEngine> Render for NodeEditorPanel<E> {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Fit the graph once the canvas size is known (first layout). Before
// that the viewport is zero-sized, so ask for another frame instead.
if !self.fitted {
if self.graph.read(cx).viewport_size() != Default::default() {
self.fitted = true;
self.fit_graph(window, cx);
} else {
cx.notify();
}
}
let colors = cx.default_colors().clone();
div()
.size_full()
.flex()
.flex_col()
// Any click inside the panel makes it the focused panel (the
// dock re-emits this as `DockEvent::PanelFocused`, which the
// shell uses to route focused-panel commands).
.on_mouse_down(MouseButton::Left, {
cx.listener(|_this, _event: &gpui::MouseDownEvent, _window, cx| {
cx.emit(PanelEvent::Focused);
})
})
// The graph's `BackgroundClicked` only carries a graph-space
// position; record the pointer here (right-clicks bubble up) so
// the background menu can open at the window position.
.on_mouse_down(MouseButton::Right, {
cx.listener(|this, event: &gpui::MouseDownEvent, _window, _cx| {
this.last_right_click = Some(event.position);
})
})
.child(
div()
.flex()
.items_center()
.gap_1()
.px_2()
.py_1()
.border_b_1()
.border_color(colors.border)
.child(zoom_button(
cx,
"node-zoom-in",
Some(crate::oakui::icons::ICON_ZOOM_IN),
"+",
"timeline.zoom_in",
|this, window, cx| {
this.zoom(1.25, window, cx);
},
))
.child(zoom_button(
cx,
"node-zoom-out",
Some(crate::oakui::icons::ICON_ZOOM_OUT),
"−",
"timeline.zoom_out",
|this, window, cx| {
this.zoom(1.0 / 1.25, window, cx);
},
))
.child(zoom_button(
cx,
"node-zoom-fit",
None,
crate::i18n::tr("node.fit"),
"node.fit",
|this, window, cx| this.fit_graph(window, cx),
))
.child(div().flex_1())
.child(div().text_color(colors.disabled).child(format!(
"{}% · {}–{}",
(self.graph.read(cx).state().zoom() * 100.0).round(),
MIN_ZOOM,
MAX_ZOOM,
))),
)
.child(
div()
.id("node-editor-canvas")
.debug_selector(|| "node-editor-canvas".into())
.flex_1()
.min_h_0()
// An effect dragged out of the effect library drops onto
// the canvas as a new node at the drop position (the
// background "add node" menu path).
.on_drop::<gpui::effect_stack::LibraryEffectDrag>(cx.listener(
|this, payload: &gpui::effect_stack::LibraryEffectDrag, window, cx| {
let graph_position = this
.graph
.read(cx)
.graph_position_at(window.mouse_position());
let type_id = payload.type_id.to_string();
this.engine.update(cx, |engine, cx| {
if let Err(err) = engine.add_node_at(&type_id, graph_position, cx)
{
println!("[node editor] add node failed: {err}");
}
});
},
))
.can_drop(|payload, _window, _cx| {
payload.is::<gpui::effect_stack::LibraryEffectDrag>()
})
.child(self.graph.clone()),
)
// The right-click popup renders anchored above the panel.
.child(self.context_menu.widget())
}
}
/// A small toolbar button driving the graph viewport. With `icon_name`, the
/// button shows the 16px icon on a 24px hit target; otherwise the `label`
/// text. Both get a localized `tooltip`.
fn zoom_button<E: AppEngine>(
cx: &mut Context<NodeEditorPanel<E>>,
id: &'static str,
icon_name: Option<&'static str>,
label: impl IntoElement,
tooltip: &'static str,
action: impl Fn(&mut NodeEditorPanel<E>, &mut Window, &mut Context<NodeEditorPanel<E>>) + 'static,
) -> impl gpui::IntoElement {
let colors = cx.default_colors().clone();
let container = colors.container;
let tooltip_label = crate::i18n::tr(tooltip);
let mut el = div()
.id(id)
.size(px(24.0))
.flex()
.items_center()
.justify_center()
.rounded_md()
.border_1()
.border_color(colors.border)
.text_color(colors.text)
.cursor_pointer()
.hover(move |style| style.bg(container))
.tooltip(move |window, cx| {
gpui_widgets::tooltip::tooltip_view(tooltip_label.into(), window, cx)
})
.on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| {
action(this, window, cx);
}));
if let Some(name) = icon_name {
el = el.child(gpui::img(crate::oakui::icons::icon_path(name, cx)).size(px(16.0)));
} else {
el = el.child(label);
}
el
}
impl<E: AppEngine> EventEmitter<PanelEvent> for NodeEditorPanel<E> {}
impl<E: AppEngine> EventEmitter<ContextMenuTriggered> for NodeEditorPanel<E> {}
impl<E: AppEngine> DockPanel for NodeEditorPanel<E> {
fn panel_id(&self) -> gpui::dock::PanelId {
NODE_EDITOR
}
fn title(&self, _cx: &App) -> SharedString {
crate::i18n::tr("panel.node_editor").into()
}
fn tab_content(&self, _cx: &App) -> AnyElement {
div()
.child(crate::i18n::tr("panel.node_editor"))
.into_any_element()
}
}
// ---------------------------------------------------------------------------
// Context menus — the Rust counterpart of the C++ `NodeView::
// show_context_menu` (`app/widget/nodeview/nodeview.cpp`).
// ---------------------------------------------------------------------------
/// Local (non-registry) item ids of the node editor's context menus.
const LOCAL_SMOOTH_EDGES: usize = 2401;
const LOCAL_DIR_TOP_BOTTOM: usize = 2402;
const LOCAL_DIR_BOTTOM_TOP: usize = 2403;
const LOCAL_DIR_LEFT_RIGHT: usize = 2404;
const LOCAL_DIR_RIGHT_LEFT: usize = 2405;
const LOCAL_GROUP: usize = 2406;
const LOCAL_UNGROUP: usize = 2407;
const LOCAL_OPEN_IN_VIEWER: usize = 2408;
const LOCAL_SHOW_IN_PARAM_EDITOR: usize = 2409;
const LOCAL_NODE_PROPERTIES: usize = 2410;
/// The Add-menu items occupy `LOCAL_ADD_NODE_BASE..` (one id per library
/// entry; the panel maps them back to factory type ids).
const LOCAL_ADD_NODE_BASE: usize = 2420;
/// The node context menu: the shared edit section, grouping, color labels,
/// viewer/parameter-editor reveals and properties (the C++ node branch).
pub(crate) fn node_menu() -> Menu {
use crate::i18n::tr;
let mut items = menu::edit_section(false);
if let Some(last) = items.last_mut() {
last.separator_after = true;
}
items.push(MenuItem::new(LOCAL_GROUP, tr("node.context.group")));
items.push(MenuItem::new(LOCAL_UNGROUP, tr("node.context.ungroup")));
items.push(menu::color_label_item(None).separated());
items.push(MenuItem::new(LOCAL_OPEN_IN_VIEWER, tr("node.context.open_in_viewer")));
items.push(MenuItem::new(
LOCAL_SHOW_IN_PARAM_EDITOR,
tr("node.context.show_in_param_editor"),
));
items.push(MenuItem::new(LOCAL_NODE_PROPERTIES, tr("menu.context.properties")));
Menu::new(items)
}
/// The background context menu: edge smoothing, flow direction and the Add
/// submenu built from the engine's node library (grouped by category,
/// alphabetical inside each group — the C++ `create_add_menu` order).
/// `add_menu_ids` is rewritten to map the fresh item ids to type ids.
pub(crate) fn background_menu(
library: Vec<NodeLibraryEntry>,
add_menu_ids: &mut Vec<(usize, String)>,
) -> Menu {
use crate::i18n::tr;
add_menu_ids.clear();
// Group the library by category key (BTreeMap = alphabetical category
// order), then sort each group's entries by name.
let mut groups: std::collections::BTreeMap<&'static str, Vec<NodeLibraryEntry>> =
std::collections::BTreeMap::new();
for entry in library {
groups.entry(entry.category_key).or_default().push(entry);
}
let mut add_items: Vec<MenuItem> = Vec::new();
let mut next_id = LOCAL_ADD_NODE_BASE;
for (category_key, mut entries) in groups {
entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
let mut submenu = Vec::with_capacity(entries.len());
for entry in entries {
submenu.push(MenuItem::new(next_id, entry.name.clone()));
add_menu_ids.push((next_id, entry.type_id));
next_id += 1;
}
add_items.push(
MenuItem::new(0, tr(category_key)).with_submenu(Menu::new(submenu)),
);
}
let direction_menu = Menu::new(vec![
MenuItem::new(LOCAL_DIR_TOP_BOTTOM, tr("node.context.dir_top_bottom"))
.with_checked(true),
MenuItem::new(LOCAL_DIR_BOTTOM_TOP, tr("node.context.dir_bottom_top"))
.with_checked(false),
MenuItem::new(LOCAL_DIR_LEFT_RIGHT, tr("node.context.dir_left_right"))
.with_checked(false),
MenuItem::new(LOCAL_DIR_RIGHT_LEFT, tr("node.context.dir_right_left"))
.with_checked(false),
]);
Menu::new(vec![
MenuItem::new(LOCAL_SMOOTH_EDGES, tr("node.context.smooth_edges"))
.with_checked(false)
.separated(),
MenuItem::new(0, tr("node.context.direction"))
.with_submenu(direction_menu)
.separated(),
MenuItem::new(0, tr("node.context.add")).with_submenu(Menu::new(add_items)),
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oakui::MockEngine;
use gpui::effect_stack::{EffectId, EffectStackEvent};
use gpui::{size, TestAppContext, VisualTestContext};
/// Builds the panel in a window and returns a `VisualTestContext` for
/// bounds assertions.
fn panel_window(
cx: &mut TestAppContext,
) -> (
&'static mut VisualTestContext,
Entity<NodeEditorPanel<MockEngine>>,
) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(640.0), px(480.0)), |window, cx| {
let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx));
NodeEditorPanel::new(engine, window, cx)
});
cx.run_until_parked();
// The graph canvas reports its size only after the first paint, so the
// initial fit applies on the following frame: draw a few more.
for _ in 0..3 {
cx.update_window(window.into(), |_root, window, app| {
let _ = window.draw(app);
})
.expect("window still open");
cx.run_until_parked();
}
let panel = window.root(cx).expect("node editor panel root");
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
(cx, panel)
}
/// The panel lays out a graph canvas below the zoom toolbar, and the
/// initial fit centers the graph so every demo node is on screen.
#[gpui::test]
async fn canvas_fills_the_panel_below_the_toolbar(cx: &mut TestAppContext) {
let (cx, panel) = panel_window(cx);
let canvas = cx
.debug_bounds("node-editor-canvas")
.expect("graph canvas rendered");
assert!(canvas.size.width > px(0.0));
assert!(canvas.size.height > px(0.0));
// The initial fit moved the viewport off the default origin, so the
// graph is framed rather than clipped at the corner.
let state = cx.read(|app| panel.read(app).graph.read(app).state().clone());
assert_ne!(
state.offset(),
gpui::node_graph::GraphViewState::new().offset()
);
assert!(state.zoom() > 0.0);
}
/// The node menu wraps the plain edit section with grouping, color
/// labels, the reveal entries and properties.
#[test]
fn node_menu_carries_grouping_and_reveals() {
// The color-label lookup below matches on a localized label: pin
// en-US under the shared language lock.
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
crate::i18n::set_language_code("en-US");
let menu = node_menu();
let ids: Vec<usize> = menu.items.iter().map(|item| item.id).collect();
assert!(ids.contains(&LOCAL_GROUP));
assert!(ids.contains(&LOCAL_UNGROUP));
assert!(ids.contains(&LOCAL_OPEN_IN_VIEWER));
assert!(ids.contains(&LOCAL_SHOW_IN_PARAM_EDITOR));
assert!(ids.contains(&LOCAL_NODE_PROPERTIES));
let color = menu
.items
.iter()
.find(|item| item.label == crate::i18n::tr("menu.color.label"))
.expect("color label item");
assert!(color.separator_after);
}
fn entry(type_id: &str, name: &str, category_key: &'static str) -> NodeLibraryEntry {
NodeLibraryEntry {
type_id: type_id.to_string(),
name: name.to_string(),
category_key,
}
}
/// The background menu groups the library alphabetically by category,
/// sorts entries inside each group, and records the id → type-id map
/// starting at `LOCAL_ADD_NODE_BASE`.
#[test]
fn background_menu_groups_the_library() {
let library = vec![
entry("video.solid", "Solid", "node.category.generator"),
entry("math.add", "Add", "node.category.math"),
entry("video.bars", "Color Bars", "node.category.generator"),
entry("math.multiply", "Multiply", "node.category.math"),
];
let mut add_menu_ids = Vec::new();
let menu = background_menu(library, &mut add_menu_ids);
// Top level: smooth edges, direction, add.
assert_eq!(menu.items.len(), 3);
assert_eq!(menu.items[0].id, LOCAL_SMOOTH_EDGES);
let add = &menu.items[2];
assert_eq!(add.label, crate::i18n::tr("node.context.add"));
// Categories sort alphabetically: generator before math, entries
// sorted case-insensitively inside each group.
let categories = add.submenu.as_ref().unwrap();
let labels: Vec<_> = categories
.items
.iter()
.map(|item| item.label.clone())
.collect();
assert_eq!(
labels,
vec![
crate::i18n::tr("node.category.generator"),
crate::i18n::tr("node.category.math"),
]
);
let generator = &categories.items[0].submenu.as_ref().unwrap().items;
let names: Vec<_> = generator.iter().map(|item| item.label.clone()).collect();
assert_eq!(names, vec!["Color Bars", "Solid"]);
// The id map starts at the base and matches submenu order.
assert_eq!(add_menu_ids[0].0, LOCAL_ADD_NODE_BASE);
let mapped: std::collections::HashMap<usize, String> =
add_menu_ids.iter().cloned().collect();
assert_eq!(
mapped.get(&generator[0].id),
Some(&"video.bars".to_string())
);
assert_eq!(mapped.len(), 4);
}
/// An empty library still yields the smoothing/direction entries, with
/// an empty Add submenu.
#[test]
fn background_menu_survives_an_empty_library() {
let mut add_menu_ids = Vec::new();
let menu = background_menu(Vec::new(), &mut add_menu_ids);
assert_eq!(menu.items.len(), 3);
assert!(add_menu_ids.is_empty());
assert!(menu.items[2].submenu.as_ref().unwrap().items.is_empty());
}
/// The engine's selection mirror is pushed into the graph widget: a node
/// selection (a node click, or an inspector card click) lands in the
/// engine through the panel's event loop, and the panel's observe then
/// sets the widget selection — so the graph, the inspector and the
/// timeline share one highlight.
#[gpui::test]
async fn engine_selection_syncs_into_the_graph_widget(cx: &mut TestAppContext) {
let (cx, panel) = panel_window(cx);
// A node click round trip: the engine applies the SelectionChanged
// (as the panel subscription would forward it), and the observe
// pushes the mirrored selection into the widget.
cx.update(|_window, app| {
let engine = panel.read(app).engine.clone();
engine.update(app, |engine, cx| {
engine.apply_node_graph_event(
&NodeGraphEvent::SelectionChanged {
nodes: BTreeSet::from([NodeId(2)]),
},
cx,
);
});
});
cx.run_until_parked();
let selection = cx.read(|app| panel.read(app).graph.read(app).state().selection().clone());
assert!(
selection.contains(&NodeId(2)),
"the widget highlights the mirrored node (got {selection:?})"
);
// An inspector card click (the "变换" card) selects the same-named
// node through the same mirror.
cx.update(|_window, app| {
let engine = panel.read(app).engine.clone();
engine.update(app, |engine, cx| {
engine.apply_effect_event(&EffectStackEvent::CardSelected { effect: EffectId(1) }, cx);
});
});
cx.run_until_parked();
let selection = cx.read(|app| panel.read(app).graph.read(app).state().selection().clone());
assert!(
selection.contains(&NodeId(2)),
"the card click highlights the matching node (got {selection:?})"
);
}
}