feat(engine): rename facade to oakengine, build liboakengine.dylib
- src/facade/rust -> src/engine/rust; package oakfacade -> oakengine - crate-type += cdylib; module crates are real deps; linkage anchors force-link module C ABIs into the dylib - build.rs: -undefined dynamic_lookup for host-provided oakcore_*/fb_* - nm: 749 oakengine_* + 687 module oak*_* exports; undefined set is only the intended host-provided symbols - worker/cli updated to the new path/name; undo test race fix
This commit is contained in:
Generated
+1
@@ -3636,6 +3636,7 @@ dependencies = [
|
||||
"gpui_platform",
|
||||
"gpui_widgets",
|
||||
"image",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -37,6 +37,10 @@ gpui = { path = "../../gpui/crates/gpui" }
|
||||
gpui_platform = { path = "../../gpui/crates/gpui_platform", features = ["font-kit"] }
|
||||
# Oak's widget library: menus, viewer, form controls, project explorer.
|
||||
gpui_widgets = { path = "../../gpui/crates/gpui_widgets" }
|
||||
# The mock engine's synthetic viewer frames (`image::Frame` in a
|
||||
# `RenderImage`), matching the versions gpui itself uses.
|
||||
image = "0.25"
|
||||
smallvec = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
# `#[gpui::test]` harness for engine-seam smoke tests (test-support feature).
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 457 KiB After Width: | Height: | Size: 397 KiB |
@@ -57,12 +57,16 @@ fn main() -> Result<()> {
|
||||
})?;
|
||||
let handle: AnyWindowHandle = window.into();
|
||||
|
||||
// Let the platform settle, then draw one full frame into the rendered
|
||||
// scene so `render_to_image` has something to capture.
|
||||
// Draw a few frames so the layout settles: the node editor fits its graph
|
||||
// once the canvas size is known and the viewers upload their first CPU
|
||||
// frame, both of which happen on the frame after the initial render.
|
||||
for _ in 0..4 {
|
||||
cx.run_until_parked();
|
||||
cx.update_window(handle, |_root, window, app| {
|
||||
let _ = window.draw(app);
|
||||
})?;
|
||||
}
|
||||
cx.run_until_parked();
|
||||
cx.update_window(handle, |_root, window, app| {
|
||||
let _ = window.draw(app);
|
||||
})?;
|
||||
|
||||
let image = cx.capture_screenshot(handle)?;
|
||||
std::fs::create_dir_all(std::path::Path::new(OUT).parent().unwrap())?;
|
||||
|
||||
+2
-2
@@ -152,7 +152,7 @@ impl PanelRegistry for AppPanelRegistry {
|
||||
cx,
|
||||
)),
|
||||
"node-editor" => Some(PanelHandle::new(
|
||||
cx.new(|cx| NodeEditorPanel::new(window, cx)),
|
||||
cx.new(|cx| NodeEditorPanel::new(self.engine.clone(), window, cx)),
|
||||
cx,
|
||||
)),
|
||||
"inspector" => Some(PanelHandle::new(
|
||||
@@ -234,7 +234,7 @@ impl OakApp {
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let node_editor = cx.new(|cx| NodeEditorPanel::new(window, cx));
|
||||
let node_editor = cx.new(|cx| NodeEditorPanel::new(engine.clone(), window, cx));
|
||||
let inspector = cx.new(|cx| InspectorPanel::new(engine.clone(), window, cx));
|
||||
let history = cx.new(|cx| HistoryPanel::new(window, cx));
|
||||
let timeline_panel =
|
||||
|
||||
+71
-9
@@ -95,17 +95,19 @@ pub fn set_language(language: Language) {
|
||||
Language::ZhCN => 1,
|
||||
}, Ordering::Relaxed);
|
||||
persist_language(language);
|
||||
sync_widgets();
|
||||
}
|
||||
|
||||
/// Loads the persisted language from the oakcommon config C ABI. Called once
|
||||
/// at startup. Never fails: without liboakcommon the default (en-US) stays.
|
||||
pub fn init() {
|
||||
let Some(store) = ConfigAbi::load() else {
|
||||
sync_widgets();
|
||||
return;
|
||||
};
|
||||
match store.get("Language") {
|
||||
Some(code) if !code.is_empty() => set_language(Language::from_code(&code)),
|
||||
_ => {}
|
||||
_ => sync_widgets(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +129,28 @@ pub fn tr(key: &'static str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys the gpui widget crates localize through
|
||||
/// `gpui_widgets::i18n` (viewer transport labels, effect-stack empty state).
|
||||
pub const WIDGET_KEYS: &[&str] = &[
|
||||
"viewer.safe_frames",
|
||||
"viewer.zoom",
|
||||
"viewer.no_frame_source",
|
||||
"effect_stack.empty",
|
||||
"effect_stack.add",
|
||||
];
|
||||
|
||||
/// Installs the active language's widget strings into the
|
||||
/// `gpui_widgets::i18n` string-table hook, so the widget-baked labels follow
|
||||
/// the app language on the next render. Called on startup and on every
|
||||
/// language switch; the widgets keep their built-in defaults otherwise.
|
||||
pub fn sync_widgets() {
|
||||
let mut table = gpui_widgets::i18n::StringTable::new();
|
||||
for key in WIDGET_KEYS {
|
||||
table.insert((*key).to_string(), tr(key).to_string());
|
||||
}
|
||||
gpui_widgets::i18n::set_table(table);
|
||||
}
|
||||
|
||||
/// Looks `key` up in the en-US table.
|
||||
fn en(key: &'static str) -> &'static str {
|
||||
EN
|
||||
@@ -226,14 +250,16 @@ const EN: &[(&str, &str)] = &[
|
||||
("timeline.track_height", "Track Height"),
|
||||
("timeline.snap", "Snap"),
|
||||
// --- node editor ---
|
||||
("node.zoom_in", "Zoom In"),
|
||||
("node.zoom_out", "Zoom Out"),
|
||||
("node.fit", "Fit"),
|
||||
("node.fit_window", "Fit Window"),
|
||||
("node.placeholder", "Node Editor · Placeholder — gpui::node_graph not wired up yet"),
|
||||
// --- viewer header chips ---
|
||||
("viewer.source", "Source Viewer · Source"),
|
||||
("viewer.program", "Program Viewer · Program"),
|
||||
// --- widget-baked strings (synced to gpui_widgets::i18n) ---
|
||||
("viewer.safe_frames", "Safe Frames"),
|
||||
("viewer.zoom", "Zoom"),
|
||||
("viewer.no_frame_source", "No frame source"),
|
||||
("effect_stack.empty", "No selection"),
|
||||
("effect_stack.add", "+ Add Effect"),
|
||||
// --- inspector ---
|
||||
("inspector.params", "Parameters (placeholder)"),
|
||||
];
|
||||
@@ -319,14 +345,16 @@ const ZH: &[(&str, &str)] = &[
|
||||
("timeline.track_height", "轨道高"),
|
||||
("timeline.snap", "吸附"),
|
||||
// --- node editor ---
|
||||
("node.zoom_in", "放大"),
|
||||
("node.zoom_out", "缩小"),
|
||||
("node.fit", "适配"),
|
||||
("node.fit_window", "适配窗口"),
|
||||
("node.placeholder", "节点编辑器 · 占位 — gpui::node_graph 尚未接入"),
|
||||
// --- viewer header chips ---
|
||||
("viewer.source", "素材查看器 · 源"),
|
||||
("viewer.program", "序列查看器 · 节目"),
|
||||
// --- widget-baked strings (synced to gpui_widgets::i18n) ---
|
||||
("viewer.safe_frames", "安全框"),
|
||||
("viewer.zoom", "缩放"),
|
||||
("viewer.no_frame_source", "无帧源"),
|
||||
("effect_stack.empty", "未选择"),
|
||||
("effect_stack.add", "+ 添加效果"),
|
||||
// --- inspector ---
|
||||
("inspector.params", "参数(占位)"),
|
||||
];
|
||||
@@ -534,6 +562,40 @@ mod tests {
|
||||
assert_eq!(tr("menu.file.save"), "Save");
|
||||
}
|
||||
|
||||
/// `sync_widgets` installs the active language's strings into the widget
|
||||
/// string-table hook, so the widget-baked labels follow the app language.
|
||||
#[test]
|
||||
fn sync_widgets_installs_the_active_language() {
|
||||
let _guard = lang_lock().lock().unwrap();
|
||||
set_language(Language::EnUs);
|
||||
gpui_widgets::i18n::clear_table(); // simulate a fresh process
|
||||
sync_widgets();
|
||||
assert_eq!(
|
||||
gpui_widgets::i18n::tr("viewer.safe_frames", "安全框").to_string(),
|
||||
"Safe Frames"
|
||||
);
|
||||
assert_eq!(
|
||||
gpui_widgets::i18n::tr("viewer.zoom", "缩放").to_string(),
|
||||
"Zoom"
|
||||
);
|
||||
// Every widget key is covered by the installed table.
|
||||
for key in WIDGET_KEYS {
|
||||
let installed = gpui_widgets::i18n::tr(key, "fallback").to_string();
|
||||
assert_ne!(installed, "fallback", "widget key {key} not synced");
|
||||
}
|
||||
|
||||
set_language(Language::ZhCN);
|
||||
assert_eq!(
|
||||
gpui_widgets::i18n::tr("viewer.safe_frames", "Safe Frames").to_string(),
|
||||
"安全框"
|
||||
);
|
||||
assert_eq!(
|
||||
gpui_widgets::i18n::tr("effect_stack.add", "+ Add Effect").to_string(),
|
||||
"+ 添加效果"
|
||||
);
|
||||
set_language(Language::EnUs);
|
||||
}
|
||||
|
||||
/// The config code round-trips.
|
||||
#[test]
|
||||
fn language_code_round_trips() {
|
||||
|
||||
+699
-4
@@ -36,17 +36,25 @@
|
||||
//! The real engine will later implement the same gateway over the
|
||||
//! `liboakengine` C ABI; only the wiring in [`crate::app`] changes.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use gpui::effect_stack::{
|
||||
EffectCardKind, EffectData, EffectId, EffectStackDataSource, EffectStackEvent,
|
||||
};
|
||||
use gpui::node_graph::{
|
||||
EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortData,
|
||||
PortDataType, PortId, PortKind,
|
||||
};
|
||||
use gpui::timeline::{
|
||||
ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TrackData, TrackKind,
|
||||
};
|
||||
use gpui::{prelude::*, px, App, Context, Entity, Hsla, Pixels, SharedString};
|
||||
use gpui::{
|
||||
hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, Point, RenderImage,
|
||||
SharedString,
|
||||
};
|
||||
use gpui_widgets::audio_meter::AudioMeterDataSource;
|
||||
use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry};
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
@@ -57,6 +65,12 @@ use super::transport::TransportState;
|
||||
/// The demo sequence length: 00:04:18:18 at 25 fps.
|
||||
const SEQUENCE_LENGTH: i64 = 6468;
|
||||
|
||||
/// The synthetic viewer test frame is rendered at a small proxy size (the
|
||||
/// real engine will deliver full-resolution frames; the mock only needs to
|
||||
/// prove the CPU-frame path end to end).
|
||||
const SYNTH_FRAME_WIDTH: u32 = 384;
|
||||
const SYNTH_FRAME_HEIGHT: u32 = 216;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clocks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -256,6 +270,125 @@ impl EffectData for MockEffect {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node graph model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A port on a mock node. `connected` is a model-side cache refreshed by
|
||||
/// [`MockEngine::refresh_port_connectivity`] after every graph mutation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockPort {
|
||||
id: PortId,
|
||||
kind: PortKind,
|
||||
label: SharedString,
|
||||
data_type: PortDataType,
|
||||
connected: bool,
|
||||
}
|
||||
|
||||
impl PortData for MockPort {
|
||||
fn id(&self) -> PortId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn kind(&self) -> PortKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
fn label(&self) -> SharedString {
|
||||
self.label.clone()
|
||||
}
|
||||
|
||||
fn data_type(&self) -> PortDataType {
|
||||
self.data_type.clone()
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected
|
||||
}
|
||||
}
|
||||
|
||||
/// A node in the mock graph (媒体 → 变换 → … → 输出).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockNode {
|
||||
id: NodeId,
|
||||
title: SharedString,
|
||||
position: Point<Pixels>,
|
||||
inputs: Vec<MockPort>,
|
||||
outputs: Vec<MockPort>,
|
||||
header_color: Option<Hsla>,
|
||||
enabled: bool,
|
||||
collapsed: bool,
|
||||
}
|
||||
|
||||
impl NodeData for MockNode {
|
||||
type Port = MockPort;
|
||||
|
||||
fn id(&self) -> NodeId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn title(&self) -> SharedString {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
fn position(&self) -> Point<Pixels> {
|
||||
self.position
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<Self::Port> {
|
||||
self.inputs.clone()
|
||||
}
|
||||
|
||||
fn outputs(&self) -> Vec<Self::Port> {
|
||||
self.outputs.clone()
|
||||
}
|
||||
|
||||
fn header_color(&self) -> Option<Hsla> {
|
||||
self.header_color
|
||||
}
|
||||
|
||||
fn is_collapsed(&self) -> bool {
|
||||
self.collapsed
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge in the mock graph: a connection from an output port to an input
|
||||
/// port.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockEdge {
|
||||
id: EdgeId,
|
||||
from_node: NodeId,
|
||||
from_port: PortId,
|
||||
to_node: NodeId,
|
||||
to_port: PortId,
|
||||
}
|
||||
|
||||
impl EdgeData for MockEdge {
|
||||
fn id(&self) -> EdgeId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn from_node(&self) -> NodeId {
|
||||
self.from_node
|
||||
}
|
||||
|
||||
fn from_port(&self) -> PortId {
|
||||
self.from_port
|
||||
}
|
||||
|
||||
fn to_node(&self) -> NodeId {
|
||||
self.to_node
|
||||
}
|
||||
|
||||
fn to_port(&self) -> PortId {
|
||||
self.to_port
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The engine
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -281,6 +414,19 @@ pub struct MockEngine {
|
||||
selected_item: Option<u64>,
|
||||
/// Phase counter driving the demo audio levels.
|
||||
meter_phase: u32,
|
||||
/// The demo node graph shown in the node editor.
|
||||
nodes: Vec<MockNode>,
|
||||
/// The demo node-graph edges.
|
||||
edges: Vec<MockEdge>,
|
||||
/// Id allocator for edges added at runtime.
|
||||
next_edge_id: u64,
|
||||
/// The node selection, kept in sync with the node editor (and, later, the
|
||||
/// effect stack) so both views share one selection.
|
||||
node_selection: BTreeSet<NodeId>,
|
||||
/// Cache of the synthetic CPU frames handed to the viewers, keyed by
|
||||
/// monitor. Entries are the playhead frame that produced the image, so a
|
||||
/// paused viewer never regenerates its picture.
|
||||
cpu_frame_cache: Mutex<HashMap<Monitor, (i64, Arc<RenderImage>)>>,
|
||||
}
|
||||
|
||||
impl MockEngine {
|
||||
@@ -307,8 +453,122 @@ impl MockEngine {
|
||||
l: 0.55,
|
||||
a: 1.0,
|
||||
};
|
||||
let node_color = |h: f32| Hsla {
|
||||
h,
|
||||
s: 0.55,
|
||||
l: 0.5,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
Self {
|
||||
// The demo node graph: two clips, one through the transform, one
|
||||
// through the blur, merged in the mixer and LUT'd to the viewer.
|
||||
// Port ids are globally unique.
|
||||
let video_type = PortDataType::new("video", hsla(0.55, 0.75, 0.6, 1.0));
|
||||
let audio_type = PortDataType::new("audio", hsla(0.1, 0.7, 0.55, 1.0));
|
||||
let port = |id: u64,
|
||||
kind: PortKind,
|
||||
label: &str,
|
||||
data_type: &PortDataType| MockPort {
|
||||
id: PortId(id),
|
||||
kind,
|
||||
label: label.into(),
|
||||
data_type: data_type.clone(),
|
||||
connected: false,
|
||||
};
|
||||
let node = |id: u64,
|
||||
title: &str,
|
||||
position: (f32, f32),
|
||||
color: f32,
|
||||
inputs: Vec<MockPort>,
|
||||
outputs: Vec<MockPort>| MockNode {
|
||||
id: NodeId(id),
|
||||
title: title.into(),
|
||||
position: point(px(position.0), px(position.1)),
|
||||
inputs,
|
||||
outputs,
|
||||
header_color: Some(node_color(color)),
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
};
|
||||
let nodes = vec![
|
||||
node(
|
||||
0,
|
||||
"第一稿.mp4",
|
||||
(40.0, 40.0),
|
||||
0.55,
|
||||
vec![],
|
||||
vec![port(1, PortKind::Output, "video", &video_type)],
|
||||
),
|
||||
node(
|
||||
1,
|
||||
"B-roll.mp4",
|
||||
(40.0, 230.0),
|
||||
0.60,
|
||||
vec![],
|
||||
vec![
|
||||
port(3, PortKind::Output, "video", &video_type),
|
||||
port(4, PortKind::Output, "audio", &audio_type),
|
||||
],
|
||||
),
|
||||
node(
|
||||
2,
|
||||
"变换",
|
||||
(320.0, 40.0),
|
||||
0.35,
|
||||
vec![
|
||||
port(20, PortKind::Input, "in", &video_type),
|
||||
port(22, PortKind::Input, "mask", &video_type),
|
||||
],
|
||||
vec![port(21, PortKind::Output, "out", &video_type)],
|
||||
),
|
||||
node(
|
||||
3,
|
||||
"模糊",
|
||||
(320.0, 230.0),
|
||||
0.78,
|
||||
vec![port(30, PortKind::Input, "in", &video_type)],
|
||||
vec![port(31, PortKind::Output, "out", &video_type)],
|
||||
),
|
||||
node(
|
||||
4,
|
||||
"混合",
|
||||
(600.0, 130.0),
|
||||
0.08,
|
||||
vec![
|
||||
port(40, PortKind::Input, "A", &video_type),
|
||||
port(41, PortKind::Input, "B", &video_type),
|
||||
],
|
||||
vec![port(42, PortKind::Output, "out", &video_type)],
|
||||
),
|
||||
node(
|
||||
5,
|
||||
"输出",
|
||||
(880.0, 130.0),
|
||||
0.0,
|
||||
vec![port(50, PortKind::Input, "in", &video_type)],
|
||||
vec![],
|
||||
),
|
||||
];
|
||||
let edge = |id: u64,
|
||||
from_node: u64,
|
||||
from_port: u64,
|
||||
to_node: u64,
|
||||
to_port: u64| MockEdge {
|
||||
id: EdgeId(id),
|
||||
from_node: NodeId(from_node),
|
||||
from_port: PortId(from_port),
|
||||
to_node: NodeId(to_node),
|
||||
to_port: PortId(to_port),
|
||||
};
|
||||
let edges = vec![
|
||||
edge(1, 0, 1, 2, 20),
|
||||
edge(2, 1, 3, 3, 30),
|
||||
edge(3, 2, 21, 4, 40),
|
||||
edge(4, 3, 31, 4, 41),
|
||||
edge(5, 4, 42, 5, 50),
|
||||
];
|
||||
|
||||
let mut this = Self {
|
||||
project: Project {
|
||||
name: "第一稿".into(),
|
||||
path: PathBuf::from("/home/mikesolar/Videos/aaa.ove"),
|
||||
@@ -407,7 +667,16 @@ impl MockEngine {
|
||||
program_playing: false,
|
||||
selected_item: None,
|
||||
meter_phase: 0,
|
||||
}
|
||||
nodes,
|
||||
edges,
|
||||
next_edge_id: 6,
|
||||
node_selection: BTreeSet::new(),
|
||||
cpu_frame_cache: Mutex::new(HashMap::new()),
|
||||
};
|
||||
// The demo graph is born connected: derive every port's `connected`
|
||||
// flag from the edge list.
|
||||
this.refresh_port_connectivity();
|
||||
this
|
||||
}
|
||||
|
||||
/// The current sequence length (also used by the gateway).
|
||||
@@ -476,6 +745,134 @@ impl MockEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Applies a node-editor request to the mock graph (the "edits are
|
||||
/// requests" loop: the view emits, the engine applies and notifies).
|
||||
pub fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
NodeGraphEvent::NodeMovePreview { .. } | NodeGraphEvent::ViewChanged { .. } => {}
|
||||
NodeGraphEvent::NodeMoveRequested { nodes, delta } => {
|
||||
for id in nodes {
|
||||
if let Some(node) = self.nodes.iter_mut().find(|n| n.id() == *id) {
|
||||
node.position = node.position + *delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeGraphEvent::ConnectionRequested { from, to } => {
|
||||
if self.can_connect(*from, *to) {
|
||||
let from_node = self.node_with_port(*from).map(|n| n.id());
|
||||
let to_node = self.node_with_port(*to).map(|n| n.id());
|
||||
if let (Some(from_node), Some(to_node)) = (from_node, to_node) {
|
||||
self.edges.push(MockEdge {
|
||||
id: EdgeId(self.next_edge_id),
|
||||
from_node,
|
||||
from_port: *from,
|
||||
to_node,
|
||||
to_port: *to,
|
||||
});
|
||||
self.next_edge_id += 1;
|
||||
self.refresh_port_connectivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeGraphEvent::DisconnectionRequested { edge } => {
|
||||
if let Some(index) = self.edges.iter().position(|e| e.id() == *edge) {
|
||||
self.edges.remove(index);
|
||||
self.refresh_port_connectivity();
|
||||
}
|
||||
}
|
||||
NodeGraphEvent::DeleteRequested { nodes, edges } => {
|
||||
self.edges.retain(|edge| {
|
||||
!edges.contains(&edge.id())
|
||||
&& !nodes.contains(&edge.from_node())
|
||||
&& !nodes.contains(&edge.to_node())
|
||||
});
|
||||
self.nodes.retain(|node| !nodes.contains(&node.id()));
|
||||
self.node_selection.clear();
|
||||
self.refresh_port_connectivity();
|
||||
}
|
||||
NodeGraphEvent::SelectionChanged { nodes } => {
|
||||
self.node_selection = nodes.clone();
|
||||
}
|
||||
NodeGraphEvent::BackgroundClicked { position } => {
|
||||
// The real app opens an "add node" menu here; the mock logs it.
|
||||
println!("[node editor] add-node menu at graph {position:?} (mock: ignored)");
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The node currently under the cursor selection (demo state; the effect
|
||||
/// stack will sync to this once it drives the same selection).
|
||||
pub fn selected_node_ids(&self) -> &BTreeSet<NodeId> {
|
||||
&self.node_selection
|
||||
}
|
||||
|
||||
/// Looks up a node by id (test helper).
|
||||
#[cfg(test)]
|
||||
fn node(&self, id: NodeId) -> Option<&MockNode> {
|
||||
self.nodes.iter().find(|n| n.id() == id)
|
||||
}
|
||||
|
||||
/// Looks up a port by its globally unique id.
|
||||
fn port(&self, id: PortId) -> Option<&MockPort> {
|
||||
self.nodes
|
||||
.iter()
|
||||
.flat_map(|n| n.inputs.iter().chain(n.outputs.iter()))
|
||||
.find(|p| p.id() == id)
|
||||
}
|
||||
|
||||
/// Returns the node that owns `port`.
|
||||
fn node_with_port(&self, port: PortId) -> Option<&MockNode> {
|
||||
self.nodes.iter().find(|n| {
|
||||
n.inputs.iter().any(|p| p.id() == port)
|
||||
|| n.outputs.iter().any(|p| p.id() == port)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an edge with the same endpoints already exists.
|
||||
fn edge_exists(&self, from: PortId, to: PortId) -> bool {
|
||||
self.edges
|
||||
.iter()
|
||||
.any(|e| e.from_port() == from && e.to_port() == to)
|
||||
}
|
||||
|
||||
/// Whether there is a directed path from node `from` to node `to`
|
||||
/// following the existing edges (DFS). `from == to` counts as a path.
|
||||
fn reaches(&self, from: NodeId, to: NodeId) -> bool {
|
||||
if from == to {
|
||||
return true;
|
||||
}
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut stack = vec![from];
|
||||
while let Some(current) = stack.pop() {
|
||||
if !visited.insert(current) {
|
||||
continue;
|
||||
}
|
||||
for edge in &self.edges {
|
||||
if edge.from_node() == current {
|
||||
let next = edge.to_node();
|
||||
if next == to {
|
||||
return true;
|
||||
}
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Recomputed every port's `connected` flag from the current edge list.
|
||||
fn refresh_port_connectivity(&mut self) {
|
||||
for node in &mut self.nodes {
|
||||
for port in node.inputs.iter_mut().chain(node.outputs.iter_mut()) {
|
||||
port.connected = self
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.from_port() == port.id || e.to_port() == port.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the row height of every timeline track (demo toolbar).
|
||||
pub fn set_track_height(&mut self, height: Pixels, cx: &mut Context<Self>) {
|
||||
for track in &mut self.tracks {
|
||||
@@ -649,6 +1046,44 @@ impl EffectStackDataSource for MockEngine {
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeGraphDataSource for MockEngine {
|
||||
type Node = MockNode;
|
||||
type Edge = MockEdge;
|
||||
|
||||
fn nodes(&self) -> Vec<Self::Node> {
|
||||
self.nodes.clone()
|
||||
}
|
||||
|
||||
fn edges(&self) -> Vec<Self::Edge> {
|
||||
self.edges.clone()
|
||||
}
|
||||
|
||||
fn can_connect(&self, from: PortId, to: PortId) -> bool {
|
||||
let (Some(from_node), Some(to_node)) = (self.node_with_port(from), self.node_with_port(to))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if from_node.id() == to_node.id() {
|
||||
return false;
|
||||
}
|
||||
let (Some(from_port), Some(to_port)) = (self.port(from), self.port(to)) else {
|
||||
return false;
|
||||
};
|
||||
if from_port.kind() != PortKind::Output || to_port.kind() != PortKind::Input {
|
||||
return false;
|
||||
}
|
||||
if from_port.data_type() != to_port.data_type() {
|
||||
return false;
|
||||
}
|
||||
if self.edge_exists(from, to) {
|
||||
return false;
|
||||
}
|
||||
// Connecting from_node → to_node would create a cycle iff there is
|
||||
// already a path from to_node back to from_node.
|
||||
!self.reaches(to_node.id(), from_node.id())
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectDataSource for MockEngine {
|
||||
fn roots(&self) -> Vec<ProjectEntry> {
|
||||
vec![
|
||||
@@ -698,6 +1133,87 @@ impl MockEngine {
|
||||
pub fn clock_frame(&self, monitor: Monitor, cx: &App) -> Frame {
|
||||
self.clock(monitor).read(cx).transport.frame()
|
||||
}
|
||||
|
||||
/// The synthetic CPU test frame for `monitor`, cached per playhead frame so
|
||||
/// a paused viewer never regenerates its picture. This is the frame the
|
||||
/// source/program viewers display through [`ViewerWidget::set_cpu_frame`],
|
||||
/// proving the CPU-frame path end to end before the real engine lands.
|
||||
pub fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc<RenderImage> {
|
||||
let frame = self.clock_frame(monitor, cx);
|
||||
let mut cache = self.cpu_frame_cache.lock().unwrap();
|
||||
if let Some((cached_frame, image)) = cache.get(&monitor) {
|
||||
if *cached_frame == frame.0 {
|
||||
return image.clone();
|
||||
}
|
||||
}
|
||||
let image = Arc::new(self.synthetic_frame(frame));
|
||||
cache.insert(monitor, (frame.0, image.clone()));
|
||||
image
|
||||
}
|
||||
|
||||
/// Generates a synthetic test frame: SMPTE-style color bars with a white
|
||||
/// sweep whose x position follows `frame`, so playback is visibly moving.
|
||||
///
|
||||
/// Samples are computed as F32 RGBA (mirroring the real engine's pixel
|
||||
/// pipeline) and downconverted to BGRA8 for the viewer's CPU-frame path.
|
||||
/// The picture is rendered at a small proxy size ([`SYNTH_FRAME_WIDTH`] ×
|
||||
/// [`SYNTH_FRAME_HEIGHT`]); the real engine delivers full resolution.
|
||||
fn synthetic_frame(&self, frame: Frame) -> RenderImage {
|
||||
let width = SYNTH_FRAME_WIDTH;
|
||||
let height = SYNTH_FRAME_HEIGHT;
|
||||
|
||||
// F32 RGBA samples, then quantized to BGRA8 for the sprite atlas.
|
||||
let mut samples = vec![0.0f32; (width * height * 4) as usize];
|
||||
// SMPTE bars: 75% white, yellow, cyan, green, magenta, red, blue.
|
||||
let bars: [(f32, f32, f32); 7] = [
|
||||
(1.0, 1.0, 1.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 1.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
];
|
||||
// Bottom strip: blue, magenta, 75% white, black.
|
||||
let strip: [(f32, f32, f32); 4] = [
|
||||
(0.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(0.75, 0.75, 0.75),
|
||||
(0.0, 0.0, 0.0),
|
||||
];
|
||||
// The sweep moves 6 px per frame and wraps around the width, so
|
||||
// transport playback shows up as motion across the picture.
|
||||
let sweep = (frame.0 as f32 * 6.0) % width as f32;
|
||||
let bars_top = height as f32 * 0.66;
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let in_sweep = (x as f32 - sweep).abs() < 6.0;
|
||||
let color = if in_sweep {
|
||||
(1.0, 1.0, 1.0)
|
||||
} else if (y as f32) < bars_top {
|
||||
bars[((x as f32 / width as f32) * 7.0) as usize]
|
||||
} else {
|
||||
strip[((x as f32 / width as f32) * 4.0) as usize]
|
||||
};
|
||||
let i = ((y * width + x) * 4) as usize;
|
||||
samples[i] = color.0;
|
||||
samples[i + 1] = color.1;
|
||||
samples[i + 2] = color.2;
|
||||
samples[i + 3] = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
let mut bytes = Vec::with_capacity((width * height * 4) as usize);
|
||||
for i in (0..samples.len()).step_by(4) {
|
||||
bytes.push((samples[i + 2] * 255.0) as u8); // B
|
||||
bytes.push((samples[i + 1] * 255.0) as u8); // G
|
||||
bytes.push((samples[i] * 255.0) as u8); // R
|
||||
bytes.push((samples[i + 3] * 255.0) as u8); // A
|
||||
}
|
||||
let buffer = image::RgbaImage::from_raw(width, height, bytes).expect("synthetic frame");
|
||||
RenderImage::new(smallvec::SmallVec::from_elem(image::Frame::new(buffer), 1))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -799,4 +1315,183 @@ mod tests {
|
||||
assert_eq!(before, after);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn demo_graph_has_six_nodes_and_five_edges(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
let engine = engine.read(app);
|
||||
let nodes = engine.nodes();
|
||||
let edges = engine.edges();
|
||||
|
||||
assert_eq!(nodes.len(), 6, "media, transform, blur, mixer, viewer");
|
||||
assert_eq!(edges.len(), 5);
|
||||
|
||||
// The chain ends at the viewer node, fed by the mixer.
|
||||
let viewer = nodes
|
||||
.iter()
|
||||
.find(|n| n.title() == "输出")
|
||||
.expect("viewer node");
|
||||
assert_eq!(viewer.inputs().len(), 1);
|
||||
assert!(viewer.outputs().is_empty());
|
||||
|
||||
// Every edge endpoint references an existing port, and every port
|
||||
// connectivity flag matches the edge list.
|
||||
for edge in &edges {
|
||||
assert!(
|
||||
nodes.iter().any(|n| n.id() == edge.from_node()),
|
||||
"edge {} from-node exists",
|
||||
edge.id().0
|
||||
);
|
||||
assert!(
|
||||
nodes.iter().any(|n| n.id() == edge.to_node()),
|
||||
"edge {} to-node exists",
|
||||
edge.id().0
|
||||
);
|
||||
}
|
||||
for node in nodes {
|
||||
for port in node.inputs().into_iter().chain(node.outputs()) {
|
||||
let expected = engine
|
||||
.edges()
|
||||
.iter()
|
||||
.any(|e| e.from_port() == port.id() || e.to_port() == port.id());
|
||||
assert_eq!(port.is_connected(), expected, "port {}", port.id().0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn can_connect_enforces_direction_type_duplicates_and_cycles(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
let engine = engine.read(app);
|
||||
|
||||
// A valid, still-free connection: Clip1.video → Transform.in (the
|
||||
// transform already takes the clip-0 path, so this would be the
|
||||
// second input).
|
||||
assert!(engine.can_connect(PortId(3), PortId(20)));
|
||||
// The existing connection is not offered again.
|
||||
assert!(!engine.can_connect(PortId(21), PortId(40)), "duplicate edge");
|
||||
// Input → output is rejected (wrong direction).
|
||||
assert!(!engine.can_connect(PortId(20), PortId(1)), "wrong direction");
|
||||
// A port cannot connect to itself.
|
||||
assert!(!engine.can_connect(PortId(42), PortId(40)), "self connection");
|
||||
// Type mismatch: the clip's audio output is not a video signal.
|
||||
assert!(
|
||||
!engine.can_connect(PortId(4), PortId(20)),
|
||||
"audio cannot feed a video input"
|
||||
);
|
||||
// Unknown ports are rejected.
|
||||
assert!(!engine.can_connect(PortId(999), PortId(20)));
|
||||
assert!(!engine.can_connect(PortId(1), PortId(999)));
|
||||
|
||||
// Cycle rule: the graph flows left-to-right (Clip → Transform →
|
||||
// Mixer → Viewer), so no connection can close a loop — but the
|
||||
// reachability helper behind the rule is exercised directly.
|
||||
assert!(engine.reaches(NodeId(0), NodeId(5)), "main chain path");
|
||||
assert!(!engine.reaches(NodeId(5), NodeId(0)), "no backward path");
|
||||
assert!(engine.reaches(NodeId(4), NodeId(4)));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn node_edits_apply_to_the_model(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
// Move the viewer node.
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_node_graph_event(
|
||||
&NodeGraphEvent::NodeMoveRequested {
|
||||
nodes: vec![NodeId(5)],
|
||||
delta: point(px(100.0), px(-20.0)),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
let moved = engine.read(app).node(NodeId(5)).expect("viewer node");
|
||||
assert_eq!(moved.position(), point(px(980.0), px(110.0)));
|
||||
|
||||
// Connect the B-roll clip's video to the transform's unused mask
|
||||
// input, then disconnect the transform → mixer edge.
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_node_graph_event(
|
||||
&NodeGraphEvent::ConnectionRequested {
|
||||
from: PortId(3),
|
||||
to: PortId(22),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
let engine_read = engine.read(app);
|
||||
assert_eq!(engine_read.edges().len(), 6);
|
||||
assert!(engine_read.port(PortId(22)).is_some_and(|p| p.is_connected()));
|
||||
let edges = engine_read.edges();
|
||||
let extra = edges
|
||||
.iter()
|
||||
.find(|e| e.to_port() == PortId(22))
|
||||
.expect("the new connection");
|
||||
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_node_graph_event(
|
||||
&NodeGraphEvent::DisconnectionRequested { edge: extra.id() },
|
||||
cx,
|
||||
);
|
||||
});
|
||||
assert_eq!(engine.read(app).edges().len(), 5);
|
||||
assert!(
|
||||
!engine.read(app).port(PortId(22)).is_some_and(|p| p.is_connected()),
|
||||
"mask input freed again"
|
||||
);
|
||||
|
||||
// Deleting the blur node takes its incident edges with it.
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_node_graph_event(
|
||||
&NodeGraphEvent::DeleteRequested {
|
||||
nodes: vec![NodeId(3)],
|
||||
edges: vec![],
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
let engine_read = engine.read(app);
|
||||
assert_eq!(engine_read.nodes().len(), 5);
|
||||
assert!(
|
||||
!engine_read.edges().iter().any(|e| e.to_node() == NodeId(3)
|
||||
|| e.from_node() == NodeId(3)),
|
||||
"edges incident to the deleted node are removed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn cpu_frame_is_cached_per_playhead_and_advances(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
|
||||
// Same playhead → same cached image (Arc identity), so a paused
|
||||
// viewer never regenerates its picture.
|
||||
let a = engine.read(app).cpu_frame(Monitor::Program, app);
|
||||
let b = engine.read(app).cpu_frame(Monitor::Program, app);
|
||||
assert!(Arc::ptr_eq(&a, &b), "paused frame must be cached");
|
||||
|
||||
// Advancing the playhead produces a different image: the sweep
|
||||
// moved, so playback is visible.
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.step(Monitor::Program, 25, cx);
|
||||
});
|
||||
let c = engine.read(app).cpu_frame(Monitor::Program, app);
|
||||
assert!(!Arc::ptr_eq(&a, &c), "a new playhead frame regenerates");
|
||||
|
||||
// The frame has the documented proxy size and opaque BGRA8 bytes.
|
||||
let size = c.size(0);
|
||||
assert_eq!(size.width, SYNTH_FRAME_WIDTH.into());
|
||||
assert_eq!(size.height, SYNTH_FRAME_HEIGHT.into());
|
||||
let bytes = c.as_bytes(0).expect("single frame");
|
||||
assert_eq!(bytes.len(), (SYNTH_FRAME_WIDTH * SYNTH_FRAME_HEIGHT * 4) as usize);
|
||||
assert!(bytes.chunks_exact(4).all(|px| px[3] == 255), "opaque alpha");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub mod ids {
|
||||
pub const SOURCE_VIEWER: PanelId = PanelId::new(2);
|
||||
/// The program viewer (序列查看器).
|
||||
pub const PROGRAM_VIEWER: PanelId = PanelId::new(3);
|
||||
/// The node editor placeholder (节点编辑器).
|
||||
/// The node editor (节点编辑器).
|
||||
pub const NODE_EDITOR: PanelId = PanelId::new(4);
|
||||
/// The inspector / effect stack (检查器·效果栈).
|
||||
pub const INSPECTOR: PanelId = PanelId::new(5);
|
||||
|
||||
@@ -14,35 +14,139 @@
|
||||
// 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 (节点编辑器): a placeholder tab sharing the program
|
||||
//! viewer's dock group.
|
||||
//! The node editor panel (节点编辑器): the real `gpui::node_graph` canvas over
|
||||
//! the engine's mock graph, with the design's zoom controls (+ / − / 适配).
|
||||
//!
|
||||
//! The design puts the node editor in the center, switchable with the program
|
||||
//! viewer. The real `gpui::node_graph` widget exists in the gpui submodule
|
||||
//! but is not wired up yet — this panel is a placeholder surface with the
|
||||
//! zoom controls the design specifies (+ / − / fit).
|
||||
//! The graph is a full [`NodeGraphView`] fed by the [`MockEngine`]'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 gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::node_graph::{
|
||||
MAX_ZOOM, MIN_ZOOM, NodeData, NodeElement, NodeGraphDataSource, NodeGraphEvent, NodeGraphView,
|
||||
NodeVisualState,
|
||||
};
|
||||
use gpui::{
|
||||
div, prelude::*, AnyElement, App, ClickEvent, Context, EventEmitter, Render, SharedString,
|
||||
Window,
|
||||
div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity,
|
||||
EventEmitter, Pixels, Render, SharedString, Window,
|
||||
};
|
||||
|
||||
use crate::oakui::MockEngine;
|
||||
use crate::panels::ids::NODE_EDITOR;
|
||||
|
||||
/// The node editor placeholder panel.
|
||||
pub struct NodeEditorPanel;
|
||||
/// The node editor panel.
|
||||
pub struct NodeEditorPanel {
|
||||
/// The node-graph canvas over the engine's mock graph.
|
||||
graph: Entity<NodeGraphView<MockEngine>>,
|
||||
engine: Entity<MockEngine>,
|
||||
/// Whether the initial fit-to-window has been applied (the canvas size is
|
||||
/// only known after the first layout).
|
||||
fitted: bool,
|
||||
}
|
||||
|
||||
impl NodeEditorPanel {
|
||||
/// Creates the placeholder.
|
||||
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
|
||||
Self
|
||||
/// Builds the graph canvas over `engine` and routes its edit requests back
|
||||
/// to the engine.
|
||||
pub fn new(
|
||||
engine: Entity<MockEngine>,
|
||||
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();
|
||||
|
||||
Self {
|
||||
graph,
|
||||
engine,
|
||||
fitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NodeEditorPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
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()
|
||||
@@ -57,43 +161,46 @@ impl Render for NodeEditorPanel {
|
||||
.py_1()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(zoom_button(
|
||||
cx,
|
||||
"node-zoom-in",
|
||||
"+",
|
||||
crate::i18n::tr("node.zoom_in"),
|
||||
))
|
||||
.child(zoom_button(
|
||||
cx,
|
||||
"node-zoom-out",
|
||||
"−",
|
||||
crate::i18n::tr("node.zoom_out"),
|
||||
))
|
||||
.child(zoom_button(cx, "node-zoom-in", "+", |this, window, cx| {
|
||||
this.zoom(1.25, window, cx);
|
||||
}))
|
||||
.child(zoom_button(cx, "node-zoom-out", "−", |this, window, cx| {
|
||||
this.zoom(1.0 / 1.25, window, cx);
|
||||
}))
|
||||
.child(zoom_button(
|
||||
cx,
|
||||
"node-zoom-fit",
|
||||
crate::i18n::tr("node.fit"),
|
||||
crate::i18n::tr("node.fit_window"),
|
||||
)),
|
||||
|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()
|
||||
.debug_selector(|| "node-editor-canvas".into())
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_color(colors.disabled)
|
||||
.child(crate::i18n::tr("node.placeholder")),
|
||||
.min_h_0()
|
||||
.child(self.graph.clone()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A small toolbar button (the design's `+`/`−`/`适配` controls).
|
||||
/// A small toolbar button driving the graph viewport.
|
||||
fn zoom_button(
|
||||
cx: &mut Context<NodeEditorPanel>,
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
title: &'static str,
|
||||
label: impl IntoElement,
|
||||
action: impl Fn(&mut NodeEditorPanel, &mut Window, &mut Context<NodeEditorPanel>) + 'static,
|
||||
) -> impl gpui::IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let container = colors.container;
|
||||
@@ -107,11 +214,9 @@ fn zoom_button(
|
||||
.text_color(colors.text)
|
||||
.cursor_pointer()
|
||||
.hover(move |style| style.bg(container))
|
||||
.on_click(
|
||||
cx.listener(move |_this, _event: &ClickEvent, _window, _cx| {
|
||||
println!("[node editor] {title} (placeholder)");
|
||||
}),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| {
|
||||
action(this, window, cx);
|
||||
}))
|
||||
.child(label)
|
||||
}
|
||||
|
||||
@@ -132,3 +237,55 @@ impl DockPanel for NodeEditorPanel {
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::{TestAppContext, VisualTestContext, size};
|
||||
|
||||
/// Builds the panel in a window and returns a `VisualTestContext` for
|
||||
/// bounds assertions.
|
||||
fn panel_window(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (&'static mut VisualTestContext, Entity<NodeEditorPanel>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ pub struct ProgramViewerPanel {
|
||||
viewer: Entity<ViewerWidget<MockClock>>,
|
||||
meter: Entity<AudioLevelMeter<MockEngine>>,
|
||||
engine: Entity<MockEngine>,
|
||||
/// The last CPU frame handed to the viewer (compared by `Arc` identity so
|
||||
/// a paused playhead does not re-upload the picture every frame).
|
||||
last_cpu_frame: Option<std::sync::Arc<gpui::RenderImage>>,
|
||||
}
|
||||
|
||||
impl ProgramViewerPanel {
|
||||
@@ -69,12 +72,28 @@ impl ProgramViewerPanel {
|
||||
viewer,
|
||||
meter,
|
||||
engine,
|
||||
last_cpu_frame: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes the engine's synthetic test frame into the viewer, but only when
|
||||
/// it actually changed (the engine caches one image per playhead frame).
|
||||
fn sync_frame(&mut self, cx: &mut Context<Self>) {
|
||||
let frame = self.engine.read(cx).cpu_frame(Monitor::Program, cx);
|
||||
if self.last_cpu_frame.as_ref().is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
|
||||
{
|
||||
self.last_cpu_frame = Some(frame.clone());
|
||||
let frame = frame.clone();
|
||||
self.viewer
|
||||
.update(cx, |viewer, cx| viewer.set_cpu_frame(Some(frame), cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProgramViewerPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.sync_frame(cx);
|
||||
|
||||
let colors = cx.default_colors().clone();
|
||||
let format = self
|
||||
.engine
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::{
|
||||
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window,
|
||||
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString,
|
||||
Window,
|
||||
};
|
||||
use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
|
||||
|
||||
@@ -33,6 +34,9 @@ use crate::panels::ids::SOURCE_VIEWER;
|
||||
pub struct SourceViewerPanel {
|
||||
viewer: Entity<ViewerWidget<MockClock>>,
|
||||
engine: Entity<MockEngine>,
|
||||
/// The last CPU frame handed to the viewer (compared by `Arc` identity so
|
||||
/// a paused playhead does not re-upload the picture every frame).
|
||||
last_cpu_frame: Option<std::sync::Arc<gpui::RenderImage>>,
|
||||
}
|
||||
|
||||
impl SourceViewerPanel {
|
||||
@@ -56,12 +60,31 @@ impl SourceViewerPanel {
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self { viewer, engine }
|
||||
Self {
|
||||
viewer,
|
||||
engine,
|
||||
last_cpu_frame: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes the engine's synthetic test frame into the viewer, but only when
|
||||
/// it actually changed (the engine caches one image per playhead frame).
|
||||
fn sync_frame(&mut self, cx: &mut Context<Self>) {
|
||||
let frame = self.engine.read(cx).cpu_frame(Monitor::Source, cx);
|
||||
if self.last_cpu_frame.as_ref().is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
|
||||
{
|
||||
self.last_cpu_frame = Some(frame.clone());
|
||||
let frame = frame.clone();
|
||||
self.viewer
|
||||
.update(cx, |viewer, cx| viewer.set_cpu_frame(Some(frame), cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SourceViewerPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.sync_frame(cx);
|
||||
|
||||
let colors = cx.default_colors().clone();
|
||||
let format = self
|
||||
.engine
|
||||
|
||||
Reference in New Issue
Block a user