UI.9: share docking transactions and preserve viewer lifecycle

Preserve legacy selection repair, modifier routing, viewer lifecycle and workspace persistence through the shared docking integration. Qualify the complete workspace gate by refreshing the 18 stale Audio Spectrum hashes for the existing linear renderer, with an independent synthetic-tone regression.
This commit is contained in:
skylar taylor-barrick 2026-10-10 17:14:12 -06:00
parent 1446e27d85
commit 3dba472943
28 changed files with 2333 additions and 440 deletions

10
Cargo.lock generated
View File

@ -962,6 +962,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@ -1608,6 +1617,7 @@ dependencies = [
name = "effectcraft-ui-egui"
version = "0.7.0"
dependencies = [
"craft-ui",
"effectcraft-color",
"effectcraft-engine",
"effectcraft-geom",

View File

@ -98,6 +98,8 @@ bytemuck = "1"
pollster = "0.4"
# ui
# Shared frontend components; keep this dependency above the engine boundary.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"

View File

@ -2,6 +2,10 @@
- 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese.
- 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English.
- UI.6: Viewer breadcrumbs use theme colors for readable active text. The bottom control row scrolls horizontally at compact widths; keyboard focus reveals the selected control while dropdowns, exposure adjustments and snapshot holds retain their behavior.
- UI.3: Shared docking transactions and pointer targets now handle panel moves, splits, tab ordering and floating groups, preserving accordion metadata and the saved workspace schema. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tiny layouts no longer reach an invalid floating-point clamp; workspace trees and saved layouts are retained.
- 2026-10-09: Settings ▸ General ▸ Language now translates the interface below the menu bar as well: panels, dialogs, buttons and tooltips read their text through `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string. Simplified and Traditional Chinese are complete (384 strings); Japanese rows can follow in the same file. A test fails when a converted panel gains a hard-coded string, and another keeps the catalog free of unused rows.
- 2026-10-08: `effectcraft-cli render --out` resolves a relative path against the working directory, like `--project` (it used the project's folder, which doubled a path that already named it). Render Queue outputs set in a project are unchanged.
- 2026-10-08: Long-press a grouped toolbar button to choose its tools, including Horizontal/Vertical Type; releasing the hold keeps the menu open. Existing point/paragraph text creation is unchanged.

View File

@ -259,6 +259,9 @@ fn render_out_is_relative_to_the_working_directory() {
let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere"));
std::fs::create_dir_all(&proj).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
// current_dir resolves aliases such as macOS /var -> /private/var.
let root = root.canonicalize().unwrap();
let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere"));
let project = proj.join("x.ecproj");
let p = project.to_str().unwrap();
ok_json(&[

View File

@ -2,8 +2,9 @@
//!
//! The GPU compositor rasterises these effects from geometry the CPU computes (bolts, strokes,
//! glyphs, audio marks, wave lists, flood-fill regions, sampled colours); the CPU effects were
//! refactored to build the same plans, and must render exactly what they did before. Each
//! effect renders on a generated layer with masks, another layer and an audio track, at default
//! refactored to build the same plans. The hashes guard against unintended output changes;
//! intended behavior changes require a focused regression test before re-pinning. Each effect
//! renders on a generated layer with masks, another layer and an audio track, at default
//! and non-default parameters and two layer times, in an 8 bpc pipeline (input and output
//! clamped and quantised) and a 32 bpc one, at full and at half resolution. Each frame's pixels
//! are hashed (FNV-1a over the `f32` bits) and compared with the pinned hashes below.
@ -323,6 +324,70 @@ fn hashes() -> Vec<(String, u64)> {
v
}
/// Pin the intended linear spectrum behavior independently of the pixel hash table.
#[test]
fn audio_spectrum_uses_linear_frequency_and_amplitude() {
struct Tone {
frequency: f64,
amplitude: f64,
}
impl EffectHost for Tone {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>> {
Some(
(0..frames)
.flat_map(|i| {
let t = start + i as f64 / rate as f64;
let sample = (self.amplitude * (t * self.frequency * std::f64::consts::TAU).sin()) as f32;
[sample, sample]
})
.collect(),
)
}
}
let id = "ec.generate.audiospectrum";
let spec = find(id).unwrap();
let size = [320.0, 100.0];
let mut params = Params { values: spec.params.iter().map(|p| (p.id.to_string(), default_value(p, size))).collect() };
for (key, value) in [
("audioLayer", Value::Layer(Some(MAP))),
("startPoint", pt(0.0, 50.0)),
("endPoint", pt(320.0, 50.0)),
("startFrequency", n(0.0)),
("endFrequency", n(1500.0)),
("frequencyBands", n(33.0)),
("maximumHeight", n(40.0)),
("audioDuration", n(256.0)),
] {
params.values.insert(key.to_string(), value);
}
let buf = Buf { img: Image::new(320, 100), offset: [0.0; 2], scale: 1.0 };
for (frequency, expected_band) in [(375.0, 8), (750.0, 16), (1125.0, 24)] {
let mut peaks = Vec::new();
for amplitude in [0.0, 0.125, 0.25] {
let host = Tone { frequency, amplitude };
let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() };
let ctx = EffectCtx { params: &params, time: 1.0, layer_size: size, seed: 1, adjustment: false, env };
let plan = effectcraft_effects::marks_plan(id, &ctx, &buf).unwrap();
assert_eq!(plan.segs.len(), 33);
let heights: Vec<f64> = plan.segs.iter().map(|seg| (seg.b[1] - seg.a[1]).abs()).collect();
assert!(heights.iter().all(|height| height.is_finite()));
if amplitude == 0.0 {
assert!(heights.iter().all(|height| *height == 0.0), "silence has no bar height");
continue;
}
let (band, height) = heights.iter().enumerate().max_by(|(_, a), (_, b)| a.total_cmp(b)).unwrap();
assert_eq!(band, expected_band, "{frequency} Hz uses its linear frequency position");
// A windowed pure tone retains its amplitude, allowing for the finite Hann window.
assert!((height - amplitude * 40.0).abs() < 0.01, "{frequency} Hz at {amplitude}: height {height}");
peaks.push(*height);
}
assert!((peaks[1] - 2.0 * peaks[0]).abs() < 1e-6, "doubling the signal doubles the height: {peaks:?}");
}
}
#[test]
fn plan_effects_golden_hashes() {
let got = hashes();
@ -520,24 +585,25 @@ const GOLDEN: &[(&str, u64)] = &[
("ec.generate.eyedropperfill/2/32bpc/t1.3", 0x500e27795935722a),
("ec.generate.eyedropperfill/2/32bpc/half/t0.4", 0x480fd6a293c6597c),
("ec.generate.eyedropperfill/2/32bpc/half/t1.3", 0x480fd6a293c6597c),
("ec.generate.audiospectrum/0/8bpc/t0.4", 0xb33f32432e79920c),
("ec.generate.audiospectrum/0/8bpc/t1.3", 0xb8256632165ce18a),
("ec.generate.audiospectrum/0/32bpc/t0.4", 0xf98f14d21a3c442e),
("ec.generate.audiospectrum/0/32bpc/t1.3", 0x18211baf9f0d4d41),
("ec.generate.audiospectrum/0/32bpc/half/t0.4", 0xa8d8b213c0f7d53e),
("ec.generate.audiospectrum/0/32bpc/half/t1.3", 0xc4fe7346c0da92b8),
("ec.generate.audiospectrum/1/8bpc/t0.4", 0xf37ca291072917fa),
("ec.generate.audiospectrum/1/8bpc/t1.3", 0xb59f0c33274846b9),
("ec.generate.audiospectrum/1/32bpc/t0.4", 0xaf6d6567f830fc1e),
("ec.generate.audiospectrum/1/32bpc/t1.3", 0x48ba15c0c33741c5),
("ec.generate.audiospectrum/1/32bpc/half/t0.4", 0xaceadbc6ccabd6dc),
("ec.generate.audiospectrum/1/32bpc/half/t1.3", 0xc62932f485afd259),
("ec.generate.audiospectrum/2/8bpc/t0.4", 0x20267f8c48b2a0c4),
("ec.generate.audiospectrum/2/8bpc/t1.3", 0xaf6447ee94fe284c),
("ec.generate.audiospectrum/2/32bpc/t0.4", 0x6850f20c7cfea994),
("ec.generate.audiospectrum/2/32bpc/t1.3", 0x7fc36e46f726f3f4),
("ec.generate.audiospectrum/2/32bpc/half/t0.4", 0x96e53e6547ea4822),
("ec.generate.audiospectrum/2/32bpc/half/t1.3", 0x69643e75820123ef),
// Linear frequency bands and amplitude from 581326ec; all other effects retain their hashes.
("ec.generate.audiospectrum/0/8bpc/t0.4", 0x07be49f76890493c),
("ec.generate.audiospectrum/0/8bpc/t1.3", 0xc0109e19c3958517),
("ec.generate.audiospectrum/0/32bpc/t0.4", 0x24fe2e48cc432c1f),
("ec.generate.audiospectrum/0/32bpc/t1.3", 0x9bdbf835e7d42f78),
("ec.generate.audiospectrum/0/32bpc/half/t0.4", 0x445e16a81e313a40),
("ec.generate.audiospectrum/0/32bpc/half/t1.3", 0x88849fde30e6b048),
("ec.generate.audiospectrum/1/8bpc/t0.4", 0xf95a769e610688cc),
("ec.generate.audiospectrum/1/8bpc/t1.3", 0x7df3a63a59a10c38),
("ec.generate.audiospectrum/1/32bpc/t0.4", 0x53f3e442e240599b),
("ec.generate.audiospectrum/1/32bpc/t1.3", 0x213066492a32f6a8),
("ec.generate.audiospectrum/1/32bpc/half/t0.4", 0x38ceb79d523c7402),
("ec.generate.audiospectrum/1/32bpc/half/t1.3", 0x7d63c37d260eb112),
("ec.generate.audiospectrum/2/8bpc/t0.4", 0x73c15c41ad557cec),
("ec.generate.audiospectrum/2/8bpc/t1.3", 0x0bb38170a3fbb6fd),
("ec.generate.audiospectrum/2/32bpc/t0.4", 0x2ac65e1ca97db691),
("ec.generate.audiospectrum/2/32bpc/t1.3", 0xa8be6d0571415ab0),
("ec.generate.audiospectrum/2/32bpc/half/t0.4", 0x318b82f955d58300),
("ec.generate.audiospectrum/2/32bpc/half/t1.3", 0x4775b23a8cc7c6c6),
("ec.generate.audiowaveform/0/8bpc/t0.4", 0x68a7733a023bc9f1),
("ec.generate.audiowaveform/0/8bpc/t1.3", 0x57e13a1aaa458b14),
("ec.generate.audiowaveform/0/32bpc/t0.4", 0xb78e946d794b5ade),

View File

@ -14,6 +14,16 @@ use std::sync::Mutex;
/// Named text blobs (`prefs.json`, `shortcuts.json`, `session.lock`).
pub trait ConfigStore: Send + Sync {
fn read(&self, name: &str) -> Option<String>;
/// Read a bounded UTF-8 entry, distinguishing missing entries from read errors.
/// The compatible default checks an existing store's returned string. Stores that read
/// from disk should override this to enforce the limit while reading.
fn read_bounded(&self, name: &str, max_bytes: usize) -> std::io::Result<Option<String>> {
let text = self.read(name);
if text.as_ref().is_some_and(|text| text.len() > max_bytes) {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "configuration entry exceeds its size limit"));
}
Ok(text)
}
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
fn remove(&self, name: &str) -> std::io::Result<()>;
/// Names of the entries directly in `dir` (a `/`-separated name prefix such as `Scripts`),
@ -114,6 +124,24 @@ impl ConfigStore for DirConfig {
fn read(&self, name: &str) -> Option<String> {
std::fs::read_to_string(self.dir.join(name)).ok()
}
fn read_bounded(&self, name: &str, max_bytes: usize) -> std::io::Result<Option<String>> {
use std::io::Read;
let limit = u64::try_from(max_bytes)
.ok()
.and_then(|n| n.checked_add(1))
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "configuration read limit is too large"))?;
let file = match std::fs::File::open(self.dir.join(name)) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let mut text = String::new();
file.take(limit).read_to_string(&mut text)?;
if text.len() > max_bytes {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "configuration entry exceeds its size limit"));
}
Ok(Some(text))
}
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
let path = self.dir.join(name);
std::fs::create_dir_all(path.parent().unwrap_or(&self.dir))?;

View File

@ -10,6 +10,7 @@ rust-version.workspace = true
workspace = true
[dependencies]
craft-ui = { workspace = true }
effectcraft-time = { workspace = true }
effectcraft-geom = { workspace = true }
effectcraft-color = { workspace = true }

View File

@ -10,6 +10,8 @@ pub struct Element {
pub id: String,
pub label: String,
pub rect: [f32; 4],
#[serde(skip)]
pub(crate) response_id: Option<egui::Id>,
}
#[derive(Default)]
@ -21,7 +23,19 @@ pub struct Registry {
impl Registry {
pub fn add(&mut self, id: &str, rect: Rect, label: &str) {
self.elements.push(Element { id: id.to_string(), label: label.to_string(), rect: [rect.min.x, rect.min.y, rect.width(), rect.height()] });
self.elements.push(Element {
id: id.to_string(),
label: label.to_string(),
rect: [rect.min.x, rect.min.y, rect.width(), rect.height()],
response_id: None,
});
}
/// Bind an automation name to the widget that actually receives keyboard input.
pub fn add_response(&mut self, id: &str, response: &egui::Response, label: &str) {
self.add(id, response.rect, label);
if let Some(element) = self.elements.last_mut() {
element.response_id = Some(response.id);
}
}
pub fn begin_frame(&mut self) {
self.previous = std::mem::take(&mut self.elements);

View File

@ -507,6 +507,21 @@ pub fn base64(data: &[u8]) -> String {
pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value {
let size = ctx.content_rect().size();
let keyboard_focus = ctx.memory(|m| m.focused()).map(|id| {
let response = ctx.read_response(id);
let rect = response.as_ref().map(|r| [r.rect.min.x, r.rect.min.y, r.rect.width(), r.rect.height()]);
// The registry names screen elements independently of egui's internal ids. Report
// exact rectangle matches rather than guessing which enclosing panel owns focus.
let candidates: Vec<&str> = app
.auto
.previous
.iter()
.filter(|e| rect.is_some_and(|r| e.rect.iter().zip(r).all(|(a, b)| (*a - b).abs() < 0.01)))
.map(|e| e.id.as_str())
.collect();
let automation_id = app.auto.elements.iter().chain(app.auto.previous.iter()).find(|e| e.response_id == Some(id)).map(|e| e.id.as_str());
json!({"id": format!("{:016x}", id.value()), "rect": rect, "automationId": automation_id, "registryCandidates": candidates})
});
json!({
"window": [size.x, size.y],
"pixelsPerPoint": ctx.pixels_per_point(),
@ -518,6 +533,9 @@ pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value {
"selectedLayers": app.session.state.selected_layers.iter().map(|c| c.0).collect::<Vec<_>>(),
"selectedKeys": app.session.state.selected_keys.len(),
"elements": app.auto.previous.len(),
"keyboardFocus": keyboard_focus,
"windowFocused": ctx.input(|i| i.focused),
"workspaceStorageError": app.workspace_store.error,
"dialog": app.dialog.map(|d| format!("{d:?}")),
"renderMs": app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0),
})

View File

@ -5,6 +5,8 @@
use egui::{Align2, Rect, Sense, Stroke, pos2, vec2};
use serde::{Deserialize, Serialize};
use craft_ui::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize as SharedSplitSize, split_rect};
use crate::icons::{self, Icon};
use crate::theme::Tokens;
@ -168,6 +170,16 @@ pub enum SplitSize {
FixedB(f32),
}
impl SplitSize {
fn shared(self) -> SharedSplitSize {
match self {
Self::Ratio(value) => SharedSplitSize::Ratio(value),
Self::FixedA(value) => SharedSplitSize::FixedFirst(value),
Self::FixedB(value) => SharedSplitSize::FixedSecond(value),
}
}
}
/// One panel in a [`DockNode::Stack`].
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct StackEntry {
@ -411,39 +423,17 @@ impl DockNode {
}
/// Make `p` the active tab of its group. Returns false if not present.
pub fn activate(&mut self, p: PanelKind) -> bool {
match self {
DockNode::Split { a, b, .. } => a.activate(p) || b.activate(p),
DockNode::Tabs { panels, active } => {
if let Some(i) = panels.iter().position(|x| *x == p) {
*active = i;
true
} else {
false
}
}
DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.panel == p) {
Some(e) => {
e.open = true;
true
}
None => false,
},
}
crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Activate { panel: p })
}
/// Expand or collapse a stacked panel. Returns false if `p` is not in a stack.
pub fn toggle_stacked(&mut self, p: PanelKind) -> bool {
match self {
DockNode::Split { a, b, .. } => a.toggle_stacked(p) || b.toggle_stacked(p),
DockNode::Tabs { .. } => false,
DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.panel == p) {
Some(e) => {
e.open = !e.open;
true
}
None => false,
},
if crate::dock_shared::encode(self, &[]).is_err() {
return false;
}
let open = !self.is_visible(p);
crate::dock_shared::apply_tree(self, craft_ui::docking::Action::SetStackOpen { panel: p, open })
}
pub fn is_visible(&self, p: PanelKind) -> bool {
match self {
DockNode::Split { a, b, .. } => a.is_visible(p) || b.is_visible(p),
@ -453,57 +443,29 @@ impl DockNode {
}
/// Close a panel (remove its tab; empty groups collapse their split).
pub fn close(&mut self, p: PanelKind) {
if let DockNode::Split { a, b, .. } = self {
a.close(p);
b.close(p);
let empty = |n: &DockNode| match n {
DockNode::Tabs { panels, .. } => panels.is_empty(),
DockNode::Stack { entries } => entries.is_empty(),
DockNode::Split { .. } => false,
};
if empty(a) {
*self = (**b).clone();
} else if empty(b) {
*self = (**a).clone();
}
} else if let DockNode::Tabs { panels, active } = self {
let shown = panels.get(*active).copied();
panels.retain(|x| *x != p);
*active = shown.and_then(|shown| panels.iter().position(|x| *x == shown)).unwrap_or((*active).min(panels.len().saturating_sub(1)));
} else if let DockNode::Stack { entries } = self {
entries.retain(|e| e.panel != p);
}
crate::dock_shared::close_tree(self, p);
}
/// Add a panel as a tab next to `near` (or into the first group).
pub fn open_near(&mut self, p: PanelKind, near: PanelKind) {
if self.contains(p) {
self.activate(p);
if crate::dock_shared::encode(self, &[]).is_err() {
return;
}
fn add(n: &mut DockNode, p: PanelKind, near: PanelKind) -> bool {
match n {
DockNode::Split { a, b, .. } => add(a, p, near) || add(b, p, near),
DockNode::Tabs { panels, active } => {
if panels.contains(&near) {
panels.push(p);
*active = panels.len() - 1;
true
} else {
false
}
}
DockNode::Stack { entries } => match entries.iter().position(|e| e.panel == near) {
Some(i) => {
entries.insert(i + 1, StackEntry { panel: p, open: true, height: None });
true
}
None => false,
},
let anchor = [near, PanelKind::EffectsPresets, PanelKind::Project].into_iter().find(|panel| self.contains(*panel));
fn in_stack(node: &DockNode, panel: PanelKind) -> bool {
match node {
DockNode::Split { a, b, .. } => in_stack(a, panel) || in_stack(b, panel),
DockNode::Stack { entries } => entries.iter().any(|entry| entry.panel == panel),
DockNode::Tabs { .. } => false,
}
}
if !add(self, p, near) && !add(self, p, PanelKind::EffectsPresets) {
add(self, p, PanelKind::Project);
}
// Accordion entries open immediately after their anchor; ordinary tab groups append.
let action = match anchor {
Some(anchor) if !self.contains(p) && in_stack(self, anchor) => {
craft_ui::docking::Action::OpenAt { panel: p, anchor, placement: craft_ui::docking::Placement::Split(craft_ui::docking::Zone::Center) }
}
_ => craft_ui::docking::Action::Open { panel: p, anchor },
};
crate::dock_shared::apply_tree(self, action);
}
}
@ -518,6 +480,16 @@ pub enum Zone {
}
impl Zone {
pub(crate) fn shared(self) -> craft_ui::docking::Zone {
match self {
Self::Center => craft_ui::docking::Zone::Center,
Self::Left => craft_ui::docking::Zone::Left,
Self::Right => craft_ui::docking::Zone::Right,
Self::Top => craft_ui::docking::Zone::Top,
Self::Bottom => craft_ui::docking::Zone::Bottom,
}
}
pub fn from_name(s: &str) -> Option<Zone> {
Some(match s.to_ascii_lowercase().as_str() {
"center" | "tab" => Zone::Center,
@ -534,26 +506,18 @@ impl Zone {
/// strip and the middle of the group are Center; within a quarter of the size from an edge,
/// the nearest edge.
pub fn drop_zone(rect: Rect, tab_h: f32, pos: egui::Pos2) -> Zone {
if pos.y < rect.min.y + tab_h {
return Zone::Center;
match craft_ui::docking::drop_zone(rect, tab_h, pos) {
Some(craft_ui::docking::Zone::Left) => Zone::Left,
Some(craft_ui::docking::Zone::Right) => Zone::Right,
Some(craft_ui::docking::Zone::Top) => Zone::Top,
Some(craft_ui::docking::Zone::Bottom) => Zone::Bottom,
_ => Zone::Center,
}
let fx = ((pos.x - rect.min.x) / rect.width().max(1.0)).clamp(0.0, 1.0);
let fy = ((pos.y - rect.min.y) / rect.height().max(1.0)).clamp(0.0, 1.0);
let d = [(fx, Zone::Left), (1.0 - fx, Zone::Right), (fy, Zone::Top), (1.0 - fy, Zone::Bottom)];
let (m, z) = d.into_iter().min_by(|a, b| a.0.total_cmp(&b.0)).unwrap_or((1.0, Zone::Center));
if m > 0.25 { Zone::Center } else { z }
}
/// The part of a group `rect` a drop into `zone` would occupy (the AE-style highlight).
/// The shared preview region, or an empty rectangle for invalid geometry.
pub fn zone_rect(rect: Rect, zone: Zone) -> Rect {
let c = rect.center();
match zone {
Zone::Center => rect,
Zone::Left => Rect::from_min_max(rect.min, pos2(c.x, rect.max.y)),
Zone::Right => Rect::from_min_max(pos2(c.x, rect.min.y), rect.max),
Zone::Top => Rect::from_min_max(rect.min, pos2(rect.max.x, c.y)),
Zone::Bottom => Rect::from_min_max(pos2(rect.min.x, c.y), rect.max),
}
craft_ui::docking::zone_rect(rect, zone.shared()).unwrap_or(Rect::NOTHING)
}
/// A panel group floating over the dock (undocked).
@ -570,79 +534,13 @@ impl DockNode {
/// (Center) or in a new group split off to one side. `p` is removed from its old place
/// first. Returns false (tree unchanged) if `anchor` isn't docked or is `p` itself alone.
pub fn dock_panel(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool {
if p == anchor || !self.contains(anchor) {
return false;
}
let before = self.clone();
self.close(p);
fn rec(n: &mut DockNode, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool {
let here = match n {
DockNode::Split { a, b, .. } => return rec(a, p, anchor, zone) || rec(b, p, anchor, zone),
DockNode::Tabs { panels, .. } => panels.contains(&anchor),
DockNode::Stack { entries } => entries.iter().any(|e| e.panel == anchor),
};
if !here {
return false;
}
match (zone, &mut *n) {
(Zone::Center, DockNode::Tabs { panels, active }) => {
let i = panels.iter().position(|x| *x == anchor).map_or(panels.len(), |i| i + 1);
panels.insert(i, p);
*active = i;
}
(Zone::Center, DockNode::Stack { entries }) => {
let i = entries.iter().position(|e| e.panel == anchor).map_or(entries.len(), |i| i + 1);
entries.insert(i, StackEntry { panel: p, open: true, height: None });
}
(z, node) => {
let new = tabs(&[p], 0);
let old = std::mem::replace(node, tabs(&[], 0));
let vertical = matches!(z, Zone::Top | Zone::Bottom);
let (a, b) = if matches!(z, Zone::Left | Zone::Top) { (new, old) } else { (old, new) };
*node = DockNode::Split { vertical, size: SplitSize::Ratio(0.5), a: Box::new(a), b: Box::new(b) };
}
}
true
}
if rec(self, p, anchor, zone) {
true
} else {
*self = before;
false
}
crate::dock_shared::move_in_tree(self, p, anchor, craft_ui::docking::Placement::Split(zone.shared()))
}
/// Move panel `p` into the group that holds `anchor` as a tab before `before` (one of that
/// group's panels) or last, and show it. False (tree unchanged) if `anchor` isn't docked.
pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option<PanelKind>) -> bool {
if p == anchor || !self.contains(anchor) {
return false;
}
let saved = self.clone();
self.close(p);
fn rec(n: &mut DockNode, p: PanelKind, anchor: PanelKind, before: Option<PanelKind>) -> bool {
match n {
DockNode::Split { a, b, .. } => rec(a, p, anchor, before) || rec(b, p, anchor, before),
DockNode::Tabs { panels, active } if panels.contains(&anchor) => {
let i = before.and_then(|b| panels.iter().position(|x| *x == b)).unwrap_or(panels.len());
panels.insert(i, p);
*active = i;
true
}
DockNode::Stack { entries } if entries.iter().any(|e| e.panel == anchor) => {
let i = before.and_then(|b| entries.iter().position(|e| e.panel == b)).unwrap_or(entries.len());
entries.insert(i, StackEntry { panel: p, open: true, height: None });
true
}
_ => false,
}
}
if rec(self, p, anchor, before) {
true
} else {
*self = saved;
false
}
crate::dock_shared::move_in_tree(self, p, anchor, craft_ui::docking::Placement::Tab { before })
}
/// Path of the group containing `p` (as in [`Group::path`]; stacks add `sN`).
@ -674,98 +572,42 @@ pub struct Layout {
}
impl Layout {
/// Undock `p` into a floating group at `rect` (no-op if it is the last docked panel).
/// Apply a shared transaction without replacing the persisted app schema.
pub fn apply(&mut self, action: craft_ui::docking::Action<PanelKind>) -> Result<(), String> {
crate::dock_shared::encode(&self.root, &self.floating)?.validate().map_err(|error| error.to_string())?;
let moving_to_float = match &action {
craft_ui::docking::Action::Float { panel, .. } => Some(*panel),
craft_ui::docking::Action::Move { panel, anchor, .. } if self.floating.iter().any(|group| group.panels.contains(anchor)) => Some(*panel),
_ => None,
};
if moving_to_float.is_some_and(|panel| self.root.contains(panel) && self.root.panel_count() <= 1) {
return Err("the last docked panel must remain in the workspace".into());
}
crate::dock_shared::apply(self, action)
}
/// Undock an existing panel; the final docked panel stays in the workspace.
pub fn float(&mut self, p: PanelKind, rect: [f32; 4]) -> bool {
if self.root.contains(p) {
if self.root.panel_count() <= 1 {
return false;
}
self.root.close(p);
} else if !self.unfloat(p) {
return false;
}
self.floating.push(Floating { panels: vec![p], active: 0, rect });
true
self.apply(craft_ui::docking::Action::Float { panel: p, rect }).is_ok()
}
/// Remove `p` from the floating groups (dropping emptied groups). True if it was floating.
/// Close an existing floating panel and collapse its empty group.
pub fn unfloat(&mut self, p: PanelKind) -> bool {
let mut found = false;
for f in &mut self.floating {
if let Some(i) = f.panels.iter().position(|x| *x == p) {
let shown = f.panels.get(f.active).copied();
f.panels.remove(i);
f.active = shown.and_then(|shown| f.panels.iter().position(|x| *x == shown)).unwrap_or(f.active.min(f.panels.len().saturating_sub(1)));
found = true;
}
}
self.floating.retain(|f| !f.panels.is_empty());
found
crate::dock_shared::unfloat(self, p)
}
/// Move `p` (docked or floating) into the group of `anchor` (docked or floating) as a tab
/// before `before` or last: where a tab dragged onto a tab strip lands.
/// Move or reorder a docked/floating tab through the shared transaction.
pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option<PanelKind>) -> bool {
if p == anchor || before == Some(p) {
return false;
}
if self.floating.iter().any(|f| f.panels.contains(&anchor)) {
if self.root.contains(p) {
if self.root.panel_count() <= 1 {
return false;
}
self.root.close(p);
} else {
self.unfloat(p);
}
// (Removing `p` may have dropped an emptied group: look the anchor up again.)
let Some(f) = self.floating.iter_mut().find(|f| f.panels.contains(&anchor)) else { return false };
let i = before.and_then(|b| f.panels.iter().position(|x| *x == b)).unwrap_or(f.panels.len());
f.panels.insert(i, p);
f.active = i;
return true;
}
if !self.root.contains(anchor) {
return false;
}
// A floating panel isn't in the tree, so insert_tab's removal is a no-op for it.
self.unfloat(p);
self.root.insert_tab(p, anchor, before)
self.apply(craft_ui::docking::Action::Move { panel: p, anchor, placement: craft_ui::docking::Placement::Tab { before } }).is_ok()
}
/// Dock `p` (docked or floating) next to `anchor`; `anchor` may be floating too (then Center
/// adds a tab to that floating group).
/// Dock a panel beside an anchor; floating destinations accept tabs only.
pub fn dock(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool {
if p == anchor {
return false;
}
if self.floating.iter().any(|f| f.panels.contains(&anchor)) {
if zone != Zone::Center {
return false;
}
if self.root.contains(p) {
if self.root.panel_count() <= 1 {
return false;
}
self.root.close(p);
} else {
self.unfloat(p);
}
let Some(f) = self.floating.iter_mut().find(|f| f.panels.contains(&anchor)) else { return false };
f.panels.push(p);
f.active = f.panels.len() - 1;
return true;
}
if !self.root.contains(anchor) {
return false;
}
// A floating panel isn't in the tree, so dock_panel's removal is a no-op for it.
self.unfloat(p);
self.root.dock_panel(p, anchor, zone)
self.apply(craft_ui::docking::Action::Move { panel: p, anchor, placement: craft_ui::docking::Placement::Split(zone.shared()) }).is_ok()
}
pub fn contains(&self, p: PanelKind) -> bool {
self.root.contains(p) || self.floating.iter().any(|f| f.panels.contains(&p))
self.root.contains(p) || self.floating.iter().any(|group| group.panels.contains(&p))
}
}
@ -838,6 +680,38 @@ pub struct TabDeco {
/// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging.
pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, path: &str, out: &mut Vec<Group>, reg: &mut crate::automation::Registry) {
let mut actions = Vec::new();
layout_with_actions(ui, node, rect, t, path, out, reg, &mut actions);
for action in actions {
crate::dock_shared::apply_tree(node, action);
}
}
/// Lay out chrome while returning mutations for the application's UI command route.
pub fn layout_with_actions(
ui: &mut egui::Ui,
node: &mut DockNode,
rect: Rect,
t: &Tokens,
path: &str,
out: &mut Vec<Group>,
reg: &mut crate::automation::Registry,
actions: &mut Vec<craft_ui::docking::Action<PanelKind>>,
) {
layout_inner(ui, node, rect, t, path, &[], out, reg, actions);
}
fn layout_inner(
ui: &mut egui::Ui,
node: &mut DockNode,
rect: Rect,
t: &Tokens,
path: &str,
tree_path: &[bool],
out: &mut Vec<Group>,
reg: &mut crate::automation::Registry,
actions: &mut Vec<craft_ui::docking::Action<PanelKind>>,
) {
match node {
DockNode::Tabs { panels, active } => {
let tab_h = if panels.len() == 1 && panels[0].compact() { 8.0 } else { t.tab_h };
@ -871,42 +745,31 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
y = r.max.y + g;
}
if let Some((above, below, d)) = drag {
resize_stacked(entries, &bodies, above, below, d);
let mut proposed = entries.clone();
resize_stacked(&mut proposed, &bodies, above, below, d);
for (before, after) in entries.iter().zip(proposed) {
if before.height != after.height {
actions.push(craft_ui::docking::Action::ResizeStack { panel: after.panel, height: after.height });
}
}
}
}
DockNode::Split { vertical, size, a, b } => {
let g = t.gap;
let total = if *vertical { rect.height() } else { rect.width() };
let avail = (total - g).max(0.0);
let first = match *size {
SplitSize::Ratio(r) => avail * r,
SplitSize::FixedA(px) => px.min(avail - 20.0),
SplitSize::FixedB(px) => avail - px.min(avail - 20.0),
let axis = if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal };
let Some(split) = split_rect(rect, axis, size.shared(), t.gap, 20.0) else { return };
let response = resize_handle(split.divider, *vertical, path, t).value(split.first_size(), 0.0..=split.available()).show(ui);
reg.add(&format!("dock.gutter.{path}"), response.response.rect, "gutter");
if response.delta != 0.0
&& let Some(resized) = split.resized(size.shared(), response.delta, 40.0)
{
actions.push(craft_ui::docking::Action::ResizeSplit { path: tree_path.to_vec(), size: resized });
}
.clamp(20.0_f32.min(avail), (avail - 20.0).max(0.0));
let (ra, gutter_rect, rb) = if *vertical {
(
Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + first)),
Rect::from_min_max(pos2(rect.min.x, rect.min.y + first), pos2(rect.max.x, rect.min.y + first + g)),
Rect::from_min_max(pos2(rect.min.x, rect.min.y + first + g), rect.max),
)
} else {
(
Rect::from_min_max(rect.min, pos2(rect.min.x + first, rect.max.y)),
Rect::from_min_max(pos2(rect.min.x + first, rect.min.y), pos2(rect.min.x + first + g, rect.max.y)),
Rect::from_min_max(pos2(rect.min.x + first + g, rect.min.y), rect.max),
)
};
if let Some(d) = gutter(ui, gutter_rect, *vertical, path, t, reg) {
let nf = (first + d).clamp(40.0, (avail - 40.0).max(40.0));
*size = match *size {
SplitSize::Ratio(_) => SplitSize::Ratio(nf / avail.max(1.0)),
SplitSize::FixedA(_) => SplitSize::FixedA(nf),
SplitSize::FixedB(_) => SplitSize::FixedB(avail - nf),
};
}
layout(ui, a, ra, t, &format!("{path}a"), out, reg);
layout(ui, b, rb, t, &format!("{path}b"), out, reg);
let mut first_path = tree_path.to_vec();
first_path.push(false);
let mut second_path = tree_path.to_vec();
second_path.push(true);
layout_inner(ui, a, split.first, t, &format!("{path}a"), &first_path, out, reg, actions);
layout_inner(ui, b, split.second, t, &format!("{path}b"), &second_path, out, reg, actions);
}
}
}
@ -914,14 +777,16 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
/// A gap between dock areas that drags to resize them (`vertical`: up and down), with a slightly
/// larger hit area than the gap. Returns the drag this frame.
fn gutter(ui: &mut egui::Ui, gap: Rect, vertical: bool, path: &str, t: &Tokens, reg: &mut crate::automation::Registry) -> Option<f32> {
let hit = gap.expand2(if vertical { vec2(0.0, 3.0) } else { vec2(3.0, 0.0) });
let resp = ui.interact(hit, egui::Id::new(("dock-gutter", path.to_string())), Sense::drag());
reg.add(&format!("dock.gutter.{path}"), hit, "gutter");
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal });
ui.painter().rect_filled(gap, 0.0, t.focus.gamma_multiply(if resp.dragged() { 0.9 } else { 0.4 }));
}
resp.dragged().then(|| if vertical { resp.drag_delta().y } else { resp.drag_delta().x })
let response = resize_handle(gap, vertical, path, t).show(ui);
reg.add(&format!("dock.gutter.{path}"), response.response.rect, "gutter");
(response.delta != 0.0).then_some(response.delta)
}
fn resize_handle(gap: Rect, vertical: bool, path: &str, t: &Tokens) -> ResizeHandle<'static> {
let axis = if vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal };
let style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus };
let label = if vertical { crate::i18n::tr("Height:") } else { crate::i18n::tr("Width:") };
ResizeHandle::new(egui::Id::new(("dock-gutter", path.to_string())), axis, gap, label, style)
}
/// The least body height of an open stacked panel (when the stack has room for it).
@ -1068,6 +933,8 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click());
icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, t.tab_text);
icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, t.tab_text);
crate::widgets::focus_outline(ui, &resp, t);
reg.add_response(&format!("panel.overflow.{}", g.path), &resp, "More tabs");
if resp.clicked()
&& let Some(next) = entries.get((ai + 1) % entries.len())
{
@ -1089,9 +956,9 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
Some(id) => format!("panel.tab.{}.{id}", p.id()),
None => format!("panel.tab.{}", p.id()),
};
reg.add(&auto_id, tab, &e.label);
reg.add_response(&auto_id, &resp, &e.label);
if e.shown_doc {
reg.add(&format!("panel.tab.{}", p.id()), tab, &e.label);
reg.add_response(&format!("panel.tab.{}", p.id()), &resp, &e.label);
}
let mut label_x = tab.min.x + 8.0;
if let Some(d) = e.deco {
@ -1103,7 +970,8 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
let kk = 3.5;
painter.line_segment([cr.center() + vec2(-kk, -kk), cr.center() + vec2(kk, kk)], Stroke::new(1.2, cc));
painter.line_segment([cr.center() + vec2(-kk, kk), cr.center() + vec2(kk, -kk)], Stroke::new(1.2, cc));
reg.add(&format!("panel.tab.{}.close", p.id()), cr, "Close");
reg.add_response(&format!("panel.tab.{}.close", p.id()), &cresp, "Close");
crate::widgets::focus_outline(ui, &cresp, t);
if cresp.clicked() {
out.actions.push(close(e));
}
@ -1124,7 +992,8 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
t.text_faint
};
icons::paint(&painter, lr, Icon::Lock, lc);
reg.add(&format!("panel.tab.{}.lock", p.id()), lr, if d.locked { "Unlock" } else { "Lock" });
reg.add_response(&format!("panel.tab.{}.lock", p.id()), &lresp, if d.locked { "Unlock" } else { "Lock" });
crate::widgets::focus_outline(ui, &lresp, t);
if lresp.clicked() {
out.actions.push(DockAction::ToggleLock(*p));
}
@ -1137,17 +1006,19 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
if is_active {
let mr = Rect::from_center_size(pos2(label_x + label_w + 12.0, text_y), vec2(12.0, 10.0));
let mresp = ui.interact(mr.expand(3.0), egui::Id::new(("tab-menu", g.path.clone())), Sense::click());
reg.add(&format!("panel.menu.{}", p.id()), mr, "panel menu");
reg.add_response(&format!("panel.menu.{}", p.id()), &mresp, "panel menu");
let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text };
for dy in [-3.5, 0.0, 3.5] {
painter.line_segment([pos2(mr.min.x, mr.center().y + dy), pos2(mr.max.x, mr.center().y + dy)], Stroke::new(1.5, mc));
}
let uy = strip.min.y + 23.0;
painter.line_segment([pos2(label_x, uy), pos2(mr.max.x, uy)], Stroke::new(1.0, t.tab_text_active));
crate::widgets::focus_outline(ui, &mresp, t);
if mresp.clicked() {
out.actions.push(DockAction::PanelMenu(*p, mr.left_bottom()));
}
}
crate::widgets::focus_outline(ui, &resp, t);
if resp.clicked() {
if g.stacked.is_some() {
out.actions.push(DockAction::ToggleStacked(*p));
@ -1181,6 +1052,32 @@ pub fn placeholder(ui: &mut egui::Ui, rect: Rect, t: &Tokens, text: &str) {
mod tests {
use super::*;
/// The original split clamp panicked whenever 0 < available < 40. Run the actual dock
/// layout without the app's last-resort panic guard so that regression cannot be hidden.
#[test]
fn tiny_split_layouts_stay_inside_their_parent() {
let ctx = egui::Context::default();
let tokens = Tokens::for_kind(crate::theme::ThemeKind::Dark);
for extent in [20.0, 0.0, 1.0, 5.0, 15.0, 35.0, 39.9, 40.0, 80.0] {
for vertical in [false, true] {
for size in [SplitSize::Ratio(0.5), SplitSize::FixedA(300.0), SplitSize::FixedB(300.0)] {
let rect = Rect::from_min_size(egui::Pos2::ZERO, egui::Vec2::splat(extent));
let mut node =
DockNode::Split { vertical, size, a: Box::new(tabs(&[PanelKind::Project], 0)), b: Box::new(tabs(&[PanelKind::Composition], 0)) };
let mut groups = vec![];
let mut registry = crate::automation::Registry::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| layout(ui, &mut node, rect, &tokens, "", &mut groups, &mut registry));
output.textures_delta.clear();
assert_eq!(groups.len(), 2);
for group in groups {
assert!(rect.contains_rect(group.rect), "{extent}, {vertical}: {:?}", group.rect);
assert!(group.rect.width() >= 0.0 && group.rect.height() >= 0.0);
}
}
}
}
}
#[test]
fn stacked_panels_toggle_open_close() {
use PanelKind::*;
@ -1461,8 +1358,10 @@ mod tests {
#[test]
fn removing_earlier_inactive_floating_tab_keeps_the_shown_panel() {
use PanelKind::*;
let mut layout =
Layout { root: tabs(&[Composition], 0), floating: vec![Floating { panels: vec![Properties, Align, EffectsPresets], active: 1, rect: [0.0; 4] }] };
let mut layout = Layout {
root: tabs(&[Composition], 0),
floating: vec![Floating { panels: vec![Properties, Align, EffectsPresets], active: 1, rect: [0.0, 0.0, 300.0, 200.0] }],
};
assert!(layout.unfloat(Properties));
let f = &layout.floating[0];
assert_eq!(f.panels.get(f.active), Some(&Align));
@ -1475,8 +1374,10 @@ mod tests {
for (active, closed, shown) in
[(0, Properties, Align), (1, Align, EffectsPresets), (2, EffectsPresets, Align), (usize::MAX, Properties, EffectsPresets)]
{
let mut layout =
Layout { root: tabs(&[Composition], 0), floating: vec![Floating { panels: vec![Properties, Align, EffectsPresets], active, rect: [0.0; 4] }] };
let mut layout = Layout {
root: tabs(&[Composition], 0),
floating: vec![Floating { panels: vec![Properties, Align, EffectsPresets], active, rect: [0.0, 0.0, 300.0, 200.0] }],
};
assert!(layout.unfloat(closed));
let f = &layout.floating[0];
assert_eq!(f.panels.get(f.active), Some(&shown));

View File

@ -0,0 +1,165 @@
//! Convert the persisted workspace schema to shared docking transactions.
use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Node, Placement, StackEntry as SharedStackEntry};
use craft_ui::layout::{SplitAxis, SplitSize as SharedSplitSize};
use crate::dock::{DockNode, Floating, Layout, PanelKind, SplitSize, StackEntry};
const MAX_DEPTH: usize = 32;
const MAX_NODES: usize = 4096;
const MAX_PANELS: usize = 128;
fn encode_node(node: &DockNode, depth: usize, count: &mut usize) -> Result<Node<PanelKind>, String> {
*count = count.saturating_add(1);
if depth > MAX_DEPTH || *count > MAX_NODES {
return Err("workspace dock tree exceeds supported limits".into());
}
Ok(match node {
DockNode::Tabs { panels, active } => {
if panels.len() > MAX_PANELS {
return Err("too many workspace tabs".into());
}
Node::Tabs { panels: panels.clone(), active: *active }
}
DockNode::Stack { entries } => {
if entries.len() > MAX_PANELS {
return Err("too many workspace stack entries".into());
}
Node::Stack { entries: entries.iter().map(|entry| SharedStackEntry { panel: entry.panel, open: entry.open, height: entry.height }).collect() }
}
DockNode::Split { vertical, size, a, b } => Node::Split {
axis: if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal },
size: match *size {
SplitSize::Ratio(value) => SharedSplitSize::Ratio(value),
SplitSize::FixedA(value) => SharedSplitSize::FixedFirst(value),
SplitSize::FixedB(value) => SharedSplitSize::FixedSecond(value),
},
first: Box::new(encode_node(a, depth.saturating_add(1), count)?),
second: Box::new(encode_node(b, depth.saturating_add(1), count)?),
},
})
}
fn decode_node(node: Node<PanelKind>) -> DockNode {
match node {
Node::Tabs { panels, active } => DockNode::Tabs { panels, active },
Node::Stack { entries } => {
DockNode::Stack { entries: entries.into_iter().map(|entry| StackEntry { panel: entry.panel, open: entry.open, height: entry.height }).collect() }
}
Node::Split { axis, size, first, second } => DockNode::Split {
vertical: axis == SplitAxis::Vertical,
size: match size {
SharedSplitSize::Ratio(value) => SplitSize::Ratio(value),
SharedSplitSize::FixedFirst(value) => SplitSize::FixedA(value),
SharedSplitSize::FixedSecond(value) => SplitSize::FixedB(value),
},
a: Box::new(decode_node(*first)),
b: Box::new(decode_node(*second)),
},
}
}
pub(crate) fn encode(root: &DockNode, floating: &[Floating]) -> Result<SharedLayout<PanelKind>, String> {
if floating.len() > MAX_PANELS || floating.iter().any(|group| group.panels.len() > MAX_PANELS) {
return Err("too many floating workspace panels".into());
}
let root = if matches!(root, DockNode::Tabs { panels, active: 0 } if panels.is_empty()) { None } else { Some(encode_node(root, 0, &mut 0)?) };
Ok(SharedLayout {
root,
floating: floating.iter().map(|group| SharedFloating { panels: group.panels.clone(), active: group.active, rect: group.rect }).collect(),
})
}
pub(crate) fn decode(layout: SharedLayout<PanelKind>) -> Layout {
Layout {
root: layout.root.map(decode_node).unwrap_or_else(|| DockNode::Tabs { panels: vec![], active: 0 }),
floating: layout.floating.into_iter().map(|group| Floating { panels: group.panels, active: group.active, rect: group.rect }).collect(),
}
}
pub(crate) fn apply(layout: &mut Layout, action: Action<PanelKind>) -> Result<(), String> {
let mut shared = encode(&layout.root, &layout.floating)?;
shared.apply(action).map_err(|error| error.to_string())?;
let proposed = decode(shared);
encode(&proposed.root, &proposed.floating)?;
*layout = proposed;
Ok(())
}
pub(crate) fn apply_tree(root: &mut DockNode, action: Action<PanelKind>) -> bool {
let Ok(mut shared) = encode(root, &[]) else { return false };
if shared.apply(action).is_err() {
return false;
}
let proposed = decode(shared).root;
if encode(&proposed, &[]).is_err() {
return false;
}
*root = proposed;
true
}
pub(crate) fn move_in_tree(root: &mut DockNode, panel: PanelKind, anchor: PanelKind, placement: Placement<PanelKind>) -> bool {
let Ok(mut shared) = encode(root, &[]) else { return false };
if panel == anchor || !root.contains(anchor) {
return false;
}
// This legacy entry point also opens new viewer/ScriptUI panels. Keep that explicit,
// separate from Move, and commit only once both shared transactions have succeeded.
if !root.contains(panel) && shared.apply(Action::Open { panel, anchor: Some(anchor) }).is_err() {
return false;
}
if shared.apply(Action::Move { panel, anchor, placement }).is_err() {
return false;
}
let proposed = decode(shared).root;
if encode(&proposed, &[]).is_err() {
return false;
}
*root = proposed;
true
}
// Legacy close methods repair selection indices. Do that on a bounded encoded copy,
// then retain shared validation for geometry, duplicates and all other invariants.
fn normalize_selection(node: &mut Node<PanelKind>) {
match node {
Node::Tabs { panels, active } => *active = (*active).min(panels.len().saturating_sub(1)),
Node::Split { first, second, .. } => {
normalize_selection(first);
normalize_selection(second);
}
Node::Stack { .. } => {}
}
}
pub(crate) fn close_tree(root: &mut DockNode, panel: PanelKind) {
let Ok(mut shared) = encode(root, &[]) else { return };
if let Some(node) = &mut shared.root {
normalize_selection(node);
}
if shared.validate().is_err() {
return;
}
// An absent close still repairs an invalid selection, as the legacy method did.
if root.contains(panel) && shared.apply(Action::Close { panel }).is_err() {
return;
}
*root = decode(shared).root;
}
pub(crate) fn unfloat(layout: &mut Layout, panel: PanelKind) -> bool {
let Ok(mut shared) = encode(&layout.root, &layout.floating) else { return false };
let mut found = false;
for group in &mut shared.floating {
if group.panels.contains(&panel) {
group.active = group.active.min(group.panels.len().saturating_sub(1));
found = true;
}
}
if !found || shared.apply(Action::Close { panel }).is_err() {
return false;
}
*layout = decode(shared);
true
}

View File

@ -6,14 +6,20 @@
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use crate::dock::{self, DockAction, DockNode, Group, Layout, PanelKind, Zone};
use crate::dock::{self, DockAction, DockNode, Group, Layout, PanelKind};
use crate::{EffectcraftApp, panels};
use craft_ui::docking::{Action, DropTarget, resolve_drop};
fn drag_id() -> egui::Id {
egui::Id::new("dock-tab-drag")
}
impl EffectcraftApp {
pub(crate) fn dock_command(&mut self, ctx: &egui::Context, action: Action<PanelKind>) -> Result<(), String> {
let value = serde_json::to_value(action).map_err(|error| error.to_string())?;
crate::menus::invoke(self, ctx, "window.dockAction", serde_json::json!({"action": value})).map(|_| ())
}
/// Tab labels that name what the panel shows, as After Effects does: "Composition Intro",
/// "Effect Controls Title", "Properties: Title" (the Timeline's tabs are its comps, see
/// [`Self::tab_docs`]).
@ -108,7 +114,11 @@ impl EffectcraftApp {
/// Edit the full layout (docked tree + floating groups) with a [`Layout`] operation.
pub fn edit_layout(&mut self, f: impl FnOnce(&mut Layout) -> bool) -> bool {
let mut l = Layout { root: self.ui.dock.clone(), floating: self.ui.floating.clone() };
let Ok(shared) = crate::dock_shared::encode(&self.ui.dock, &self.ui.floating) else { return false };
if shared.validate().is_err() {
return false;
}
let mut l = crate::dock_shared::decode(shared);
if !f(&mut l) {
return false;
}
@ -122,6 +132,10 @@ impl EffectcraftApp {
/// Close a panel wherever it is (docked or floating).
pub fn close_panel(&mut self, p: PanelKind) {
// Commit the layout first: rejected closes must not release viewer or script state.
if !self.edit_layout(|layout| layout.apply(Action::Close { panel: p }).is_ok()) {
return;
}
// Closing a ScriptUI panel closes its script window (its script's onClose runs).
if let PanelKind::ScriptPanel(id) = p
&& self.session.script_ui.window(id).is_some()
@ -129,8 +143,6 @@ impl EffectcraftApp {
{
self.ui.status = e.to_string();
}
self.ui.dock.close(p);
self.edit_layout(|l| l.unfloat(p));
panels::viewers::on_close(self, p);
if self.ui.maximized == Some(p) {
self.ui.maximized = None;
@ -186,7 +198,8 @@ impl EffectcraftApp {
None => std::mem::replace(&mut self.ui.dock, DockNode::Tabs { panels: vec![], active: 0 }),
};
let mut groups = Vec::new();
dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto);
let mut layout_actions = Vec::new();
dock::layout_with_actions(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto, &mut layout_actions);
let mut actions = Vec::new();
let titles = self.tab_titles();
let decos = self.tab_decos();
@ -219,23 +232,35 @@ impl EffectcraftApp {
}
// Floating groups over the dock.
let mut float_groups: Vec<Group> = vec![];
for i in 0..self.ui.floating.len() {
let layers = ctx.memory(|memory| memory.layer_ids().collect::<Vec<_>>());
let mut floating_order: Vec<_> = (0..self.ui.floating.len()).collect();
floating_order.sort_by_key(|index| {
self.ui
.floating
.get(*index)
.and_then(|group| group.panels.first())
.and_then(|panel| {
let id = egui::Id::new(("floating-panel", panel.id()));
layers.iter().position(|layer| layer.id == id)
})
.unwrap_or(usize::MAX)
});
for i in floating_order {
if let Some((g, tabs)) = self.floating_window(&ctx, i, &info, &mut actions) {
tab_rects.push((g.path.clone(), tabs));
float_groups.push(g);
}
}
for action in layout_actions {
let _ = self.dock_command(&ctx, action);
}
for a in actions {
match a {
DockAction::Activate(p) => {
if !self.ui.dock.activate(p)
&& let Some(f) = self.ui.floating.iter_mut().find(|f| f.panels.contains(&p))
{
f.active = f.panels.iter().position(|x| *x == p).unwrap_or(0);
}
let _ = self.dock_command(&ctx, Action::Activate { panel: p });
}
DockAction::ToggleStacked(p) => {
self.ui.dock.toggle_stacked(p);
let _ = self.dock_command(&ctx, Action::SetStackOpen { panel: p, open: !self.ui.dock.is_visible(p) });
}
DockAction::Focus(p) => {
self.ui.focused = p;
@ -244,7 +269,9 @@ impl EffectcraftApp {
panels::viewers::activate(self, &ctx, id);
}
}
DockAction::Close(p) => self.close_panel(p),
DockAction::Close(p) => {
let _ = self.dock_command(&ctx, Action::Close { panel: p });
}
DockAction::PanelMenu(p, pos) => {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("panel-menu"), (p, pos)));
}
@ -258,11 +285,7 @@ impl EffectcraftApp {
}
// The Timeline's comp tabs: show that comp / close its Timeline.
DockAction::ActivateDoc(p, id) => {
if !self.ui.dock.activate(p)
&& let Some(f) = self.ui.floating.iter_mut().find(|f| f.panels.contains(&p))
{
f.active = f.panels.iter().position(|x| *x == p).unwrap_or(0);
}
let _ = self.dock_command(&ctx, Action::Activate { panel: p });
if self.session.state.active_comp.map(|c| c.0) != Some(id) {
let _ = crate::menus::invoke(self, &ctx, "comp.open", serde_json::json!({"comp": id}));
}
@ -326,8 +349,9 @@ impl EffectcraftApp {
}
// Resize from the corner.
let grip = Rect::from_min_max(r.max - vec2(14.0, 14.0), r.max);
let rs = ui.interact(grip, id.with("resize"), Sense::drag());
self.auto.add(&format!("panel.float.{i}.resize"), grip, "Resize floating panel");
let rs = ui.interact(grip, id.with("resize"), Sense::click_and_drag());
rs.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, rs.enabled(), crate::i18n::tr("Resize floating panel")));
self.auto.add_response(&format!("panel.float.{i}.resize"), &rs, "Resize floating panel");
for k in 0..3 {
let o = 4.0 + k as f32 * 4.0;
ui.painter().line_segment([pos2(r.max.x - o, r.max.y - 2.0), pos2(r.max.x - 2.0, r.max.y - o)], Stroke::new(1.0, t.text_faint));
@ -335,13 +359,17 @@ impl EffectcraftApp {
if rs.hovered() || rs.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe);
}
if rs.dragged() {
nr = Rect::from_min_size(nr.min, (nr.size() + rs.drag_delta()).max(vec2(160.0, 100.0)));
let delta = floating_resize_delta(ui, &rs);
if rs.has_focus() {
ui.painter().rect_stroke(grip, 2.0, Stroke::new(1.5, t.focus), StrokeKind::Inside);
}
if delta != egui::Vec2::ZERO {
nr = Rect::from_min_size(nr.min, (nr.size() + delta).max(vec2(160.0, 100.0)));
}
if nr != r
&& let Some(fl) = self.ui.floating.get_mut(i)
&& let Some(panel) = f.panels.first().copied()
{
fl.rect = [nr.min.x, nr.min.y, nr.width(), nr.height()];
let _ = self.dock_command(ctx, Action::MoveFloating { panel, rect: [nr.min.x, nr.min.y, nr.width(), nr.height()] });
}
group = Some((g, c.tabs));
});
@ -355,64 +383,49 @@ impl EffectcraftApp {
fn tab_drag(&mut self, ctx: &egui::Context, groups: &[Group], floats: &[Group], tab_rects: &[(String, Vec<(PanelKind, Rect)>)]) {
let Some(p) = ctx.data(|d| d.get_temp::<PanelKind>(drag_id())) else { return };
let t = self.tokens;
let canceled =
ctx.input(|input| input.key_pressed(egui::Key::Escape) || !input.focused || (!input.pointer.primary_down() && !input.pointer.primary_released()));
if canceled || (!self.ui.dock.contains(p) && !self.ui.floating.iter().any(|group| group.panels.contains(&p))) {
ctx.data_mut(|data| data.remove::<PanelKind>(drag_id()));
return;
}
let Some(pos) = ctx.pointer_latest_pos() else { return };
// Floating windows are on top; then the docked groups.
let target = floats.iter().map(|g| (g, true)).chain(groups.iter().map(|g| (g, false))).find(|(g, _)| g.rect.contains(pos));
let float_drop = ctx.input(|i| i.modifiers.command);
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop")));
let plan: Option<DropPlan> = match target {
Some((g, is_float)) if !float_drop => {
let others: Vec<PanelKind> = g.panels.iter().copied().filter(|x| *x != p).collect();
let zone = if is_float { Zone::Center } else { dock::drop_zone(g.rect, t.tab_h, pos) };
match (others.first().copied(), zone) {
// The dragged tab alone in its group: nowhere to go here.
(None, _) => None,
(Some(first), Zone::Center) => {
let rects: Vec<(PanelKind, Rect)> = tab_rects
.iter()
.find(|(path, _)| *path == g.path)
.map(|(_, r)| r.iter().copied().filter(|(k, _)| *k != p).collect())
.unwrap_or_default();
let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h));
let before = if strip.contains(pos) {
// Before the first tab whose middle is right of the pointer.
rects.iter().find(|(_, r)| r.center().x > pos.x).map(|(k, _)| *k)
} else if g.panels.contains(&p) {
// The middle of its own group: it stays where it is.
Some(p)
} else {
// The middle of another group: right after its shown tab.
let shown = g.panels.get(g.active).and_then(|a| others.iter().position(|x| x == a)).unwrap_or(others.len());
others.get(shown + 1).copied()
};
if before == Some(p) {
None
} else {
let x = match before.and_then(|b| rects.iter().find(|(k, _)| *k == b)) {
Some((_, r)) => r.min.x - 4.0,
None => rects.last().map_or(strip.min.x + 8.0, |(_, r)| r.max.x + 4.0),
};
painter.rect_filled(strip.shrink2(vec2(2.0, 1.0)), t.radius, t.focus.gamma_multiply(0.12));
if !strip.contains(pos) {
painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside);
// The shared resolver receives groups in paint order; a floating group occludes the dock.
let targets: Vec<_> = groups
.iter()
.map(|g| (g, false))
.chain(floats.iter().map(|g| (g, true)))
.filter_map(|(g, floating)| {
Some(DropTarget {
anchor: *g.panels.get(g.active)?,
rect: g.rect,
tab_strip: Some(Rect::from_min_max(g.rect.min, pos2(g.rect.max.x, g.content.min.y))),
tabs: {
let mut tabs = tab_rects.iter().find(|(path, _)| *path == g.path).map(|(_, tabs)| tabs.clone()).unwrap_or_default();
for panel in &g.panels {
if !tabs.iter().any(|(candidate, _)| candidate == panel) {
tabs.push((*panel, Rect::NOTHING));
}
insertion_mark(&painter, x, strip, t.focus);
Some(DropPlan::Tab { anchor: first, before })
}
}
(Some(first), zone) => {
let shown = g.panels.get(g.active).copied().filter(|a| *a != p).unwrap_or(first);
let hr = dock::zone_rect(g.rect, zone).shrink(2.0);
painter.rect_filled(hr, t.radius, t.focus.gamma_multiply(0.25));
painter.rect_stroke(hr, t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside);
// Outline the whole group for the side zones, like AE's drop-zone frame.
painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside);
Some(DropPlan::Side { anchor: shown, zone })
}
}
tabs
},
allow_splits: !floating,
accepts_tabs: true,
})
})
.collect();
let over_group = targets.iter().any(|target| target.rect.contains(pos));
let float_drop = ctx.input(|i| i.modifiers.command);
let plan = (!float_drop).then(|| resolve_drop(&p, pos, &targets)).flatten();
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop")));
if let Some(proposal) = &plan {
let preview = proposal.preview.shrink(2.0);
painter.rect_filled(preview, t.radius, t.focus.gamma_multiply(0.18));
painter.rect_stroke(preview, t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside);
if let Some(mark) = proposal.insertion {
insertion_mark(&painter, mark.center().x, mark, t.focus);
}
_ => None,
};
}
// Ghost of the dragged tab.
let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE);
let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0));
@ -420,14 +433,13 @@ impl EffectcraftApp {
painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE);
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
ctx.request_repaint();
if ctx.input(|i| i.pointer.any_released()) {
if ctx.input(|i| i.pointer.primary_released()) {
ctx.data_mut(|d| d.remove::<PanelKind>(drag_id()));
let ok = match plan {
Some(DropPlan::Tab { anchor, before }) => self.edit_layout(|l| l.insert_tab(p, anchor, before)),
Some(DropPlan::Side { anchor, zone }) => self.edit_layout(|l| l.dock(p, anchor, zone)),
None if float_drop || target.is_none() => {
Some(proposal) => self.dock_command(ctx, Action::Move { panel: p, anchor: proposal.anchor, placement: proposal.placement }).is_ok(),
None if float_drop || !over_group => {
let r = [pos.x - 40.0, pos.y - 12.0, 420.0, 320.0];
self.edit_layout(|l| l.float(p, r))
self.dock_command(ctx, Action::Float { panel: p, rect: r }).is_ok()
}
None => false,
};
@ -439,14 +451,6 @@ impl EffectcraftApp {
}
}
/// Where a dragged tab lands.
enum DropPlan {
/// A tab in the group of `anchor`, before `before` (last when `None`).
Tab { anchor: PanelKind, before: Option<PanelKind> },
/// A new group split off one side of `anchor`'s group.
Side { anchor: PanelKind, zone: Zone },
}
/// The insertion mark of a tab drag: a bar between two tabs at `x`, with caps.
fn insertion_mark(painter: &egui::Painter, x: f32, strip: Rect, col: Color32) {
let (y0, y1) = (strip.min.y + 3.0, strip.max.y - 2.0);
@ -461,3 +465,29 @@ pub fn default_float_rect(screen: Rect) -> [f32; 4] {
let c = screen.center();
[c.x - 210.0, c.y - 160.0, 420.0, 320.0]
}
/// A corner is a two-axis resize control; focused arrows belong to it and Tab moves on.
fn floating_resize_delta(ui: &mut egui::Ui, response: &egui::Response) -> egui::Vec2 {
if response.drag_started() {
response.request_focus();
}
let mut delta = if response.dragged() { response.drag_delta() } else { egui::Vec2::ZERO };
if response.has_focus() {
ui.memory_mut(|memory| {
memory.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() })
});
ui.input_mut(|input| {
for (key, amount) in [
(egui::Key::ArrowLeft, vec2(-8.0, 0.0)),
(egui::Key::ArrowRight, vec2(8.0, 0.0)),
(egui::Key::ArrowUp, vec2(0.0, -8.0)),
(egui::Key::ArrowDown, vec2(0.0, 8.0)),
] {
if input.consume_key(egui::Modifiers::NONE, key) {
delta += amount;
}
}
});
}
if response.enabled() { delta } else { egui::Vec2::ZERO }
}

View File

@ -27,7 +27,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let brand = Rect::from_min_size(pos2(x, cy - 12.0), vec2(24.0, 24.0));
paint_logo(&p, brand);
let bresp = ui.interact(brand, egui::Id::new("brand"), Sense::click());
app.auto.add("header.about", brand, "About EffectCraft");
app.auto.add_response("header.about", &bresp, "About EffectCraft");
widgets::focus_outline(ui, &bresp, &t);
if bresp.on_hover_text(tr("About EffectCraft")).clicked() {
app.dialog = Some(Dialog::About);
}
@ -35,10 +36,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Home.
let home = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
if widgets::icon_button(ui, home, Icon::Home, app.ui.start_screen, &t, egui::Id::new("tool-home")).on_hover_text(tr("Home")).clicked() {
let home_response = widgets::icon_button(ui, home, Icon::Home, app.ui.start_screen, &t, egui::Id::new("tool-home")).on_hover_text(tr("Home"));
app.auto.add_response("header.home", &home_response, "Home");
if home_response.clicked() {
app.ui.start_screen = !app.ui.start_screen;
}
app.auto.add("header.home", home, "Home");
x += 30.0;
p.line_segment([pos2(x, cy - 10.0), pos2(x, cy + 10.0)], Stroke::new(1.0, t.separator));
x += 6.0;
@ -62,7 +64,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Stroke::NONE,
));
}
app.auto.add(&format!("tools.{cur:?}"), r, cur.label());
app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label());
let tip = match cur.shortcut() {
Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)),
None => cur.label().to_string(),

View File

@ -13,6 +13,7 @@ pub mod bench;
pub mod control;
pub mod credits;
pub mod dock;
mod dock_shared;
pub mod dock_ui;
pub mod frames;
pub mod gpu_failure;
@ -27,6 +28,7 @@ pub mod prefs_live;
pub mod state;
pub mod theme;
pub mod widgets;
mod workspace_state;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender};
@ -210,6 +212,7 @@ impl Playback {
pub struct EffectcraftApp {
pub session: Session,
pub ui: UiState,
workspace_store: workspace_state::WorkspaceStore,
pub tokens: Tokens,
pub auto: automation::Registry,
pub frames: Frames,
@ -297,9 +300,12 @@ pub struct EffectcraftApp {
impl EffectcraftApp {
pub fn new(session: Session) -> Self {
let mut ui = UiState::default();
let workspace_store = workspace_state::WorkspaceStore::load(session.config.as_deref(), &mut ui);
EffectcraftApp {
session,
ui: UiState::default(),
ui,
workspace_store,
tokens: Tokens::for_kind(Default::default()),
auto: Default::default(),
frames: Frames::default(),
@ -484,8 +490,8 @@ impl EffectcraftApp {
/// without moving the focus (a comp opening brings up its viewer, an effect applied brings up
/// Effect Controls).
pub fn raise_panel(&mut self, p: PanelKind) {
if let Some(f) = self.ui.floating.iter_mut().find(|f| f.panels.contains(&p)) {
f.active = f.panels.iter().position(|x| *x == p).unwrap_or(0);
if self.ui.floating.iter().any(|group| group.panels.contains(&p)) {
self.edit_layout(|layout| layout.apply(craft_ui::docking::Action::Activate { panel: p }).is_ok());
return;
}
if !self.ui.dock.contains(p) {
@ -1485,6 +1491,7 @@ impl EffectcraftApp {
}
self.command_inbox = Some(rx);
}
let tab_had_widget_focus = ctx.egui_wants_keyboard_input();
menus::handle_shortcuts(self, &ctx);
if !ctx.input(|i| i.pointer.primary_down()) {
self.ui.viewer.property_interacting = false;
@ -1510,6 +1517,9 @@ impl EffectcraftApp {
self.dock_area(ui, body);
#[cfg(not(target_arch = "wasm32"))]
prefs_live::dropped_files(self, ui, full);
if !tab_had_widget_focus {
menus::handle_tab_shortcut(self, &ctx);
}
panels::precomp::mini_flowchart(self, &ctx);
panels::home::capture_thumbnail(self);
panels::dialogs::show(self, &ctx);
@ -1518,6 +1528,24 @@ impl EffectcraftApp {
panels::learn::coach(self, &ctx);
let lift = panels::media_panels::import_card(self, ui, full);
self.draw_toast(ui, full, lift);
if self.session.config.is_some() && self.workspace_store.has_changes(&self.ui) && !ctx.input(|i| i.pointer.any_down()) {
if self.workspace_store.due(ctx.input(|i| i.time)) {
self.save_workspace_state();
}
if self.workspace_store.has_changes(&self.ui) {
ctx.request_repaint_after(std::time::Duration::from_secs(1));
}
}
}
fn save_workspace_state(&mut self) {
if let Err(error) = self.workspace_store.save(self.session.config.as_deref(), &self.ui) {
if self.workspace_store.error.as_ref() != Some(&error) {
log::warn!("{error}");
self.ui.status = error.clone();
}
self.workspace_store.error = Some(error);
}
}
/// The status message or the latest toast, bottom left (`lift` points higher while the
@ -1621,6 +1649,7 @@ impl EffectcraftApp {
impl eframe::App for EffectcraftApp {
fn on_exit(&mut self) {
self.save_workspace_state();
// Clean exit: no crash recovery next launch.
self.session.end_recovery();
}

View File

@ -27,6 +27,7 @@ macro_rules! uic {
/// UI-only commands (not in the AE menus; shortcuts and the command palette).
pub const UI_COMMANDS: &[UiCommand] = &[
uic!("window.dockAction", "Docking Action", [], None),
uic!("playback.ramPreview", "Preview (Numpad 0)", [], Some("Num0")),
uic!("playback.preview.shiftSpacebar", "Preview (Shift+Spacebar)", [], Some("Shift+Space")),
uic!("playback.preview.shiftNumpad0", "Preview (Shift+Numpad 0)", [], Some("Shift+Num0")),
@ -416,6 +417,30 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
_ => {}
}
}
if id == "window.dockAction" {
let value = params.get("action").cloned().ok_or("missing docking action")?;
let action: craft_ui::docking::Action<PanelKind> = serde_json::from_value(value).map_err(|error| format!("invalid docking action: {error}"))?;
if matches!(&action, craft_ui::docking::Action::Open { .. } | craft_ui::docking::Action::OpenAt { .. }) {
return Err("use the panel open commands to preserve viewer and ScriptUI lifecycle callbacks".into());
}
if let craft_ui::docking::Action::Close { panel } = &action {
let mut layout = crate::dock_shared::encode(&app.ui.dock, &app.ui.floating)?;
layout.apply(action.clone()).map_err(|error| error.to_string())?;
app.close_panel(*panel);
return Ok(Value::Null);
}
let mut error = None;
if !app.edit_layout(|layout| match layout.apply(action) {
Ok(()) => true,
Err(reason) => {
error = Some(reason);
false
}
}) {
return Err(error.unwrap_or_else(|| "invalid docking layout".into()));
}
return Ok(Value::Null);
}
// Docking: {panel, anchor, zone: center|left|right|top|bottom}, {panel, rect?}, {panel?}.
if let Some(op) = id.strip_prefix("window.").filter(|o| matches!(*o, "maximizePanel" | "dockPanel" | "floatPanel" | "closePanel")) {
let panel_of = |k: &str| params.get(k).and_then(Value::as_str).map(|s| PanelKind::from_name(s).ok_or(format!("unknown panel `{s}`")));
@ -435,11 +460,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
}
"floatPanel" => {
let p = panel_of("panel").ok_or("floatPanel: need `panel`")??;
let r = params.get("rect").and_then(Value::as_array).map(|a| {
let g = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
[g(0), g(1), g(2), g(3)]
});
let r = r.unwrap_or_else(|| crate::dock_ui::default_float_rect(ctx.content_rect()));
let r = match params.get("rect") {
Some(value) => serde_json::from_value::<[f32; 4]>(value.clone()).map_err(|_| "rect must contain four finite numbers")?,
None => crate::dock_ui::default_float_rect(ctx.content_rect()),
};
if !app.edit_layout(|l| l.float(p, r)) {
return Err(format!("can't undock {}", p.title()));
}
@ -1483,9 +1507,15 @@ fn modifiers_at_start(ctx: &egui::Context) -> Option<egui::Modifiers> {
let pass = ctx.cumulative_pass_nr();
let now = ctx.input(|i| i.modifiers);
ctx.data_mut(|d| {
let last = d.get_temp::<(u64, egui::Modifiers)>(id);
d.insert_temp(id, (pass, now));
last.and_then(|(p, m)| (p.checked_add(1) == Some(pass)).then_some(m))
let last = d.get_temp::<(u64, egui::Modifiers, Option<egui::Modifiers>)>(id);
if let Some((p, _, start)) = last
&& p == pass
{
return start;
}
let start = last.and_then(|(p, m, _)| (p.checked_add(1) == Some(pass)).then_some(m));
d.insert_temp(id, (pass, now, start));
start
})
}
@ -1496,9 +1526,21 @@ fn only_shift(m: egui::Modifiers) -> bool {
/// Dispatch keyboard shortcuts (skipped while typing in a text field).
pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
handle_shortcuts_impl(app, ctx, false);
}
/// Tab can acquire widget focus while the current frame is drawn. Dispatch its application
/// binding only afterward, and only when no widget owned focus before or after layout.
pub fn handle_tab_shortcut(app: &mut EffectcraftApp, ctx: &egui::Context) {
if ctx.input(|i| i.key_pressed(egui::Key::Tab)) {
handle_shortcuts_impl(app, ctx, true);
}
}
fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only: bool) {
let start = modifiers_at_start(ctx);
// Dialog cancellation owns Escape even when a text field has keyboard focus.
if app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) {
return;
}
@ -1534,7 +1576,7 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
// Text editing in the viewer takes the clipboard events itself.
let clipboard = app.session.state.text_edit.is_none();
let project = app.ui.focused == PanelKind::Project;
let events: Vec<(egui::Key, egui::Modifiers, bool)> = ctx.input(|i| {
let mut events: Vec<(egui::Key, egui::Modifiers, bool)> = ctx.input(|i| {
// The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of
// key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut,
// Paste and their variants (Ctrl+Alt+C…) run. Clipboard events carry no modifiers and
@ -1574,9 +1616,10 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
}
out
});
events.retain(|(key, _, _)| (*key == egui::Key::Tab) == tab_only);
// Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it
// comes as text.
if clipboard && app.dialog.is_none() && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) {
if !tab_only && clipboard && app.dialog.is_none() && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) {
let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone()));
if let Some((id, params)) = bound
&& let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params })
@ -1828,6 +1871,27 @@ fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> egui::R
mod tests {
use super::*;
#[test]
fn shortcut_passes_share_frame_start_modifiers() {
let ctx = egui::Context::default();
let mut starts = Vec::new();
let mut output = ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)], ..Default::default() }, |ui| {
let ctx = ui.ctx();
starts.push(modifiers_at_start(ctx));
starts.push(modifiers_at_start(ctx));
});
output.textures_delta.clear();
assert_eq!(starts, vec![None, None]);
starts.clear();
let mut output = ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)], ..Default::default() }, |ui| {
let ctx = ui.ctx();
starts.push(modifiers_at_start(ctx));
starts.push(modifiers_at_start(ctx));
});
output.textures_delta.clear();
assert_eq!(starts, vec![Some(egui::Modifiers::SHIFT), Some(egui::Modifiers::SHIFT)]);
}
#[test]
fn every_ui_shortcut_parses() {
for c in UI_COMMANDS {

View File

@ -152,13 +152,13 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
if ui.button(tr("Close Panel")).clicked() {
app.close_panel(panel);
let _ = app.dock_command(ui.ctx(), craft_ui::docking::Action::Close { panel });
close = true;
}
let floating = app.ui.floating.iter().any(|f| f.panels.contains(&panel));
if !floating && ui.button(tr("Undock Panel")).clicked() {
let r = crate::dock_ui::default_float_rect(ui.ctx().content_rect());
app.edit_layout(|l| l.float(panel, r));
let _ = app.dock_command(ui.ctx(), craft_ui::docking::Action::Float { panel, rect: r });
close = true;
}
if !floating && ui.button(if app.ui.maximized == Some(panel) { tr("Restore Panel Size") } else { tr("Maximize Panel") }).clicked() {

View File

@ -610,7 +610,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
r,
9.0,
if active {
Color32::from_rgb(0x34, 0x3c, 0x4e)
t.row_selected
} else if resp.hovered() {
t.hover
} else {
@ -626,7 +626,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// Bottom control bar.
let bar_h = 30.0;
let bar_h = 36.0;
let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - bar_h), rect.max);
// The expression error bar sits above the control bar while expressions fail.
let err_h = super::expr_bar::height(app, &ctx, cid);

View File

@ -157,8 +157,6 @@ pub fn hook(
resp: &egui::Response,
space_pan: bool,
) -> bool {
// The keyboard target: a focusable (non-clickable) widget, so egui and AccessKit know it.
ui.interact(Rect::from_min_size(resp.rect.min, vec2(1.0, 1.0)), focus_id(), egui::Sense::focusable_noninteractive());
let tool = app.ui.tool;
let type_tool = matches!(tool, Tool::Type | Tool::TypeVertical);
let last_tool_id = egui::Id::new("viewer-text-last-tool");
@ -328,6 +326,9 @@ pub fn hook(
// ---- keyboard
if app.session.state.text_edit.is_some() && app.dialog.is_none() && ui.is_enabled() {
// Register after pointer handling so new edits take focus in this frame. An inactive
// editor must not take Tab focus and immediately surrender it before viewer controls.
ui.interact(Rect::from_min_size(resp.rect.min, vec2(1.0, 1.0)), focus_id(), egui::Sense::focusable_noninteractive());
ui.memory_mut(|m| {
m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });
});

View File

@ -538,19 +538,45 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
let p = ui.painter().clone();
p.rect_filled(bar, 0.0, t.panel_bg);
p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
app.auto.add("viewer.controls", bar, "Viewer controls");
ui.scope_builder(egui::UiBuilder::new().max_rect(bar), |ui| {
let scroll = &mut ui.style_mut().spacing.scroll;
scroll.floating = false;
scroll.foreground_color = true;
scroll.bar_width = 4.0;
scroll.bar_inner_margin = 1.0;
scroll.bar_outer_margin = 1.0;
egui::ScrollArea::horizontal().id_salt("viewer-controls").auto_shrink([false, false]).show(ui, |ui| {
let row = Rect::from_min_size(ui.max_rect().min, vec2(bar.width(), 30.0));
let first_control = app.auto.elements.len();
let width = bottom_bar_contents(app, ui, row, zoom, fit, time, comp);
ui.allocate_rect(Rect::from_min_size(row.min, vec2(width, row.height())), Sense::hover());
if let Some(focused) = ui.ctx().memory(|memory| memory.focused())
&& app.auto.elements.get(first_control..).is_some_and(|controls| controls.iter().any(|control| control.response_id == Some(focused)))
&& let Some(response) = ui.ctx().read_response(focused)
&& response.gained_focus()
{
ui.scroll_to_rect(response.rect, None);
}
});
});
}
fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) -> f32 {
let t = app.tokens;
let p = ui.painter().clone();
let cy = bar.center().y;
let mut x = bar.min.x + 8.0;
let ppp = ui.ctx().pixels_per_point();
// Magnification.
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(80.0, 20.0));
if widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new("vw-mag"))
.on_hover_text(crate::i18n::tr("Magnification ratio popup"))
.clicked()
{
let response = widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new("vw-mag"))
.on_hover_text(crate::i18n::tr("Magnification ratio popup"));
if response.clicked() {
toggle_popup(ui, "vw-mag-pop");
}
app.auto.add("viewer.magnification", r, "Magnification");
app.auto.add_response("viewer.magnification", &response, "Magnification");
const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"];
let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0;
let items: Vec<(String, bool)> = MAGS
@ -576,13 +602,12 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
// Resolution / down-sample factor.
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0));
let scale = app.viewer_scale(zoom, ppp);
if widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res"))
.on_hover_text(crate::i18n::tr("Resolution/Down Sample Factor Popup"))
.clicked()
{
let response = widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res"))
.on_hover_text(crate::i18n::tr("Resolution/Down Sample Factor Popup"));
if response.clicked() {
toggle_popup(ui, "vw-res-pop");
}
app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor");
app.auto.add_response("viewer.resolution", &response, "Resolution/Down Sample Factor");
let res = app.ui.viewer.res;
let items: Vec<(String, bool)> = vec![
("Full".into(), res == Resolution::Full),
@ -607,7 +632,7 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
// The tip is the English source: the tooltip is looked up in the catalog, while the
// automation label stays English for scripts and tests.
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("vw-btn", id))).on_hover_text(crate::i18n::tr(tip));
auto.add(&format!("viewer.{id}"), r, tip);
auto.add_response(&format!("viewer.{id}"), &resp, tip);
*x += 24.0;
(resp.clicked(), r)
};
@ -686,7 +711,7 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
.custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
);
let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)"));
app.auto.add("viewer.exposure", er, "Adjust Exposure");
app.auto.add_response("viewer.exposure", &resp, "Adjust Exposure");
if resp.changed() {
let _ = app.session.execute("view.exposure", json!({"stops": ev}));
}
@ -702,7 +727,7 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
let showing = showing_snapshot(app, ui.ctx());
let (_, sr) = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, showing, "showSnapshot", "Show Snapshot (hold)");
// Show Snapshot works while the button is held, like F5.
let held = ui.input(|i| i.pointer.primary_down()) && ui.input(|i| i.pointer.press_origin()).is_some_and(|o| sr.contains(o));
let held = ui.input(|i| i.pointer.primary_down()) && ui.input(|i| i.pointer.press_origin()).is_some_and(|o| sr.intersect(ui.clip_rect()).contains(o));
if app.session.snapshot.is_some() && held != app.session.state.viewer.show_snapshot {
let _ = app.session.execute("view.showSnapshot", json!({"value": held}));
}
@ -720,16 +745,17 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
// 3D renderer + view.
if comp.has_3d() {
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0));
let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d"));
app.auto.add("viewer.renderer3d", r, "3D Renderer");
let response = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d"));
app.auto.add_response("viewer.renderer3d", &response, "3D Renderer");
x = r.max.x + 4.0;
let cid = app.session.active_comp_id();
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0));
if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() {
let response = widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam"));
if response.clicked() {
toggle_popup(ui, "vw-cam-pop");
}
app.auto.add("viewer.view3d", r, "3D View");
app.auto.add_response("viewer.view3d", &response, "3D View");
use effectcraft_engine::render::three_d::View3D;
let items: Vec<(String, bool)> = View3D::ALL.iter().map(|v| (v.label().to_string(), *v == cur)).collect();
if let Some(i) = popup(app, ui, "vw-cam-pop", r, &items, "view3dItem") {
@ -749,13 +775,18 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0));
p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode);
let resp = ui.interact(tr, egui::Id::new("vw-timecode"), Sense::click());
app.auto.add("viewer.timecode", tr, "Current time");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Current time"));
if resp.has_focus() {
p.rect_stroke(tr.shrink(1.0), 2.0, Stroke::new(1.0, t.focus), StrokeKind::Inside);
}
app.auto.add_response("viewer.timecode", &resp, "Current time");
crate::panels::toggle_time_display(app, &resp);
// Right: render time.
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
if tr.max.x + 50.0 < bar.max.x {
p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint);
}
(tr.right() + 8.0 - bar.left()).max(bar.width())
}
#[cfg(test)]

View File

@ -239,9 +239,17 @@ pub fn icon_button(ui: &mut Ui, rect: Rect, icon: Icon, active: bool, t: &Tokens
t.icon
};
icons::paint(ui.painter(), rect.shrink(rect.width() * 0.22), icon, col);
focus_outline(ui, &resp, t);
resp
}
/// Keep keyboard focus visible on custom-painted controls, including their active state.
pub fn focus_outline(ui: &Ui, response: &Response, t: &Tokens) {
if response.has_focus() {
ui.painter().rect_stroke(response.rect.shrink(1.0), 4.0, Stroke::new(2.0, t.text), StrokeKind::Inside);
}
}
/// One line of `text` at `pos` (placed by `align`), cut short with "…" past `max_w` points so it
/// never runs out of its panel. Returns where it was painted.
pub fn text_fit(p: &egui::Painter, pos: egui::Pos2, align: Align2, text: &str, font: egui::FontId, max_w: f32, color: Color32) -> Rect {

View File

@ -0,0 +1,391 @@
//! Persistent workspace layouts, separate from document-bound and transient UI state.
use std::collections::BTreeMap;
use effectcraft_engine::config::ConfigStore;
use serde::{Deserialize, Serialize};
use crate::dock::{DockNode, Floating, PanelKind, SplitSize};
use crate::state::UiState;
const FILE: &str = "workspaces.json";
const MAX_BYTES: usize = 1024 * 1024;
const MAX_WORKSPACES: usize = 64;
const MAX_NODES: usize = 4096;
const MAX_DEPTH: usize = 32;
const MAX_PANELS: usize = 128;
/// Leave room for new viewers without approaching the u32 allocator limit.
const MAX_VIEWER_ID: u32 = 65_535;
const MAX_COORD: f32 = 1_000_000.0;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct Snapshot {
version: u32,
workspace: String,
dock: DockNode,
floating: Vec<Floating>,
focused: PanelKind,
maximized: Option<PanelKind>,
saved_workspaces: BTreeMap<String, DockNode>,
saved_floating: BTreeMap<String, Vec<Floating>>,
}
impl Snapshot {
fn capture(ui: &UiState) -> Self {
Self {
version: 1,
workspace: ui.workspace.clone(),
dock: ui.dock.clone(),
floating: ui.floating.clone(),
focused: ui.focused,
maximized: ui.maximized,
saved_workspaces: ui.saved_workspaces.clone(),
saved_floating: ui.saved_floating.clone(),
}
}
fn apply(self, ui: &mut UiState) {
ui.workspace = self.workspace;
ui.dock = self.dock;
ui.floating = self.floating;
ui.focused = self.focused;
ui.maximized = self.maximized;
ui.saved_workspaces = self.saved_workspaces;
ui.saved_floating = self.saved_floating;
// Reserve restored viewer slots without restoring any project-specific composition
// links. New Viewer allocates from this map, including after switching named layouts.
let mut panels = Vec::new();
for root in std::iter::once(&ui.dock).chain(ui.saved_workspaces.values()) {
root.panels(&mut panels);
}
for groups in std::iter::once(&ui.floating).chain(ui.saved_floating.values()) {
for group in groups {
panels.extend(group.panels.iter().copied());
}
}
for panel in panels {
if let PanelKind::Viewer(id) = panel {
ui.viewers.entry(id).or_insert(None);
}
}
}
fn validate(&self) -> Result<(), String> {
if self.version != 1 {
return Err(format!("unsupported workspace version {}", self.version));
}
let name_ok = |name: &str| !name.trim().is_empty() && name.len() <= 256;
if !name_ok(&self.workspace)
|| self.saved_workspaces.len() > MAX_WORKSPACES
|| self.saved_floating.len() > MAX_WORKSPACES
|| self.saved_workspaces.keys().chain(self.saved_floating.keys()).any(|name| !name_ok(name))
{
return Err("workspace names or count exceed supported limits".into());
}
if !crate::dock::WORKSPACES.contains(&self.workspace.as_str()) && !self.saved_workspaces.contains_key(&self.workspace) {
return Err("current workspace has no saved definition".into());
}
let panel_ok = |panel: PanelKind| match panel {
PanelKind::Viewer(id) => (1..=MAX_VIEWER_ID).contains(&id),
_ => true,
};
if !panel_ok(self.focused) || self.maximized.is_some_and(|panel| !panel_ok(panel)) {
return Err("workspace viewer ID is outside the supported range 1–65535".into());
}
let mut nodes = 0usize;
for root in std::iter::once(&self.dock).chain(self.saved_workspaces.values()) {
let mut pending = vec![(root, 0usize)];
while let Some((node, depth)) = pending.pop() {
nodes = nodes.saturating_add(1);
if depth > MAX_DEPTH || nodes > MAX_NODES {
return Err("workspace dock tree exceeds supported limits".into());
}
match node {
DockNode::Split { size, a, b, .. } => {
let valid = match *size {
SplitSize::Ratio(v) => v.is_finite() && (0.0..=1.0).contains(&v),
SplitSize::FixedA(v) | SplitSize::FixedB(v) => v.is_finite() && (0.0..=MAX_COORD).contains(&v),
};
if !valid {
return Err("invalid workspace split size".into());
}
pending.push((a, depth.saturating_add(1)));
pending.push((b, depth.saturating_add(1)));
}
DockNode::Tabs { panels, active } => {
// Closing the last docked panel leaves an empty group with active=0.
if panels.len() > MAX_PANELS || *active >= panels.len().max(1) || panels.iter().any(|panel| !panel_ok(*panel)) {
return Err("invalid workspace tab group".into());
}
}
DockNode::Stack { entries } => {
if entries.len() > MAX_PANELS
|| entries.iter().any(|e| !panel_ok(e.panel) || e.height.is_some_and(|v| !v.is_finite() || !(0.0..=MAX_COORD).contains(&v)))
{
return Err("invalid workspace panel stack".into());
}
}
}
}
}
for groups in std::iter::once(&self.floating).chain(self.saved_floating.values()) {
if groups.len() > MAX_PANELS {
return Err("too many floating workspace groups".into());
}
for group in groups {
let [x, y, width, height] = group.rect;
if group.panels.is_empty()
|| group.panels.len() > MAX_PANELS
|| group.panels.iter().any(|panel| !panel_ok(*panel))
|| group.active >= group.panels.len()
|| !group.rect.iter().all(|v| v.is_finite())
|| x.abs() > MAX_COORD
|| y.abs() > MAX_COORD
|| !(1.0..=MAX_COORD).contains(&width)
|| !(1.0..=MAX_COORD).contains(&height)
{
return Err("invalid floating workspace group".into());
}
}
}
crate::dock_shared::encode(&self.dock, &self.floating)?.validate().map_err(|error| error.to_string())?;
for (name, root) in &self.saved_workspaces {
let floating = self.saved_floating.get(name).map(Vec::as_slice).unwrap_or(&[]);
crate::dock_shared::encode(root, floating)?.validate().map_err(|error| error.to_string())?;
}
Ok(())
}
}
pub(crate) struct WorkspaceStore {
saved: Snapshot,
last_attempt: f64,
writable: bool,
pub(crate) error: Option<String>,
}
impl WorkspaceStore {
pub(crate) fn load(config: Option<&dyn ConfigStore>, ui: &mut UiState) -> Self {
let loaded = config.map_or(Ok(None), |c| c.read_bounded(FILE, MAX_BYTES)).map_err(|e| e.to_string()).and_then(|text| {
text.map(|text| {
let snapshot = serde_json::from_str::<Snapshot>(&text).map_err(|e| e.to_string())?;
snapshot.validate()?;
Ok(snapshot)
})
.transpose()
});
let error = match loaded {
Ok(Some(snapshot)) => {
snapshot.apply(ui);
None
}
Ok(None) => None,
Err(e) => Some(format!("Could not load workspaces: {e}. The existing file will be preserved for this session.")),
};
if let Some(error) = &error {
log::warn!("{error}");
ui.status = error.clone();
}
// An unreadable or newer file is never replaced by this session, even after a later
// layout edit. Ordinary failed writes remain dirty and retryable.
Self { saved: Snapshot::capture(ui), last_attempt: f64::NEG_INFINITY, writable: error.is_none(), error }
}
pub(crate) fn due(&mut self, now: f64) -> bool {
if now - self.last_attempt < 1.0 {
return false;
}
self.last_attempt = now;
true
}
pub(crate) fn has_changes(&self, ui: &UiState) -> bool {
if !self.writable {
return false;
}
self.saved.workspace != ui.workspace
|| self.saved.dock != ui.dock
|| self.saved.floating != ui.floating
|| self.saved.focused != ui.focused
|| self.saved.maximized != ui.maximized
|| self.saved.saved_workspaces != ui.saved_workspaces
|| self.saved.saved_floating != ui.saved_floating
}
pub(crate) fn save(&mut self, config: Option<&dyn ConfigStore>, ui: &UiState) -> Result<bool, String> {
let Some(config) = config else { return Ok(false) };
if !self.has_changes(ui) {
return Ok(false);
}
let snapshot = Snapshot::capture(ui);
snapshot.validate()?;
let text = serde_json::to_string_pretty(&snapshot).map_err(|e| format!("serialize workspaces: {e}"))?;
if text.len() > MAX_BYTES {
return Err("workspace file exceeds 1 MiB".into());
}
config.write(FILE, &text).map_err(|e| format!("save workspaces: {e}"))?;
self.saved = snapshot;
self.error = None;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::config::MemoryConfig;
#[test]
fn corrupt_or_future_workspace_keeps_defaults_and_original_file() {
let valid = Snapshot::capture(&UiState::default());
for text in ["{".into(), serde_json::to_string(&Snapshot { version: 2, ..valid }).unwrap(), " ".repeat(MAX_BYTES + 1)] {
let config = MemoryConfig::default();
config.write(FILE, &text).unwrap();
let mut ui = UiState::default();
let mut store = WorkspaceStore::load(Some(&config), &mut ui);
assert!(store.error.is_some());
assert_eq!(ui.workspace, "Default");
ui.focused = PanelKind::Project;
ui.dock = crate::dock::workspace("Minimal");
assert!(!store.save(Some(&config), &ui).unwrap());
assert_eq!(config.read(FILE).as_deref(), Some(text.as_str()));
}
}
#[test]
fn invalid_tabs_split_and_deep_tree_are_rejected() {
let mut snapshot = Snapshot::capture(&UiState::default());
snapshot.dock = DockNode::Tabs { panels: vec![], active: usize::MAX };
assert!(snapshot.validate().is_err());
snapshot.dock = DockNode::Split {
vertical: false,
size: SplitSize::FixedA(f32::NAN),
a: Box::new(crate::dock::workspace("Minimal")),
b: Box::new(crate::dock::workspace("Minimal")),
};
assert!(snapshot.validate().is_err());
snapshot.dock = crate::dock::workspace("Minimal");
for _ in 0..=MAX_DEPTH {
snapshot.dock = DockNode::Split {
vertical: false,
size: SplitSize::Ratio(0.5),
a: Box::new(snapshot.dock),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }),
};
}
assert!(snapshot.validate().is_err());
}
#[test]
fn closing_all_docked_panels_remains_a_persistable_layout() {
let config = MemoryConfig::default();
let mut ui = UiState::default();
let mut store = WorkspaceStore::load(Some(&config), &mut ui);
let mut panels = Vec::new();
ui.dock.panels(&mut panels);
for panel in panels {
ui.dock.close(panel);
}
assert!(store.save(Some(&config), &ui).unwrap());
let mut restored = UiState::default();
let loaded = WorkspaceStore::load(Some(&config), &mut restored);
assert!(loaded.error.is_none());
assert_eq!(restored.dock, ui.dock);
}
#[test]
fn floating_groups_and_named_snapshots_roundtrip_without_document_state() {
let config = MemoryConfig::default();
let mut ui = UiState::default();
let mut store = WorkspaceStore::load(Some(&config), &mut ui);
ui.workspace = "Floating".into();
ui.dock.close(PanelKind::Preview);
ui.floating = vec![Floating { panels: vec![PanelKind::Preview], active: 0, rect: [50.0, 80.0, 200.0, 160.0] }];
ui.saved_workspaces.insert(ui.workspace.clone(), ui.dock.clone());
ui.saved_floating.insert(ui.workspace.clone(), ui.floating.clone());
ui.maximized = Some(PanelKind::Composition);
ui.project_search = "do not persist".into();
assert!(store.save(Some(&config), &ui).unwrap());
let mut restored = UiState::default();
WorkspaceStore::load(Some(&config), &mut restored);
assert_eq!(Snapshot::capture(&restored), Snapshot::capture(&ui));
assert!(restored.project_search.is_empty());
}
#[test]
fn restored_viewer_slots_do_not_reuse_ids_or_restore_document_links() {
let config = MemoryConfig::default();
let mut ui = UiState::default();
let mut store = WorkspaceStore::load(Some(&config), &mut ui);
ui.dock = DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(2)], active: 0 };
ui.saved_workspaces.insert("Two views".into(), DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(5)], active: 0 });
ui.viewers.insert(2, Some(42));
assert!(store.save(Some(&config), &ui).unwrap());
let mut app = crate::EffectcraftApp::new(effectcraft_engine::Session::default());
WorkspaceStore::load(Some(&config), &mut app.ui);
assert_eq!(app.ui.viewers.get(&2), Some(&None));
assert_eq!(app.ui.viewers.get(&5), Some(&None));
assert_eq!(crate::panels::viewers::new_viewer(&mut app), 6);
assert!(app.ui.dock.contains(PanelKind::Viewer(2)));
}
#[test]
fn invalid_viewer_ids_are_rejected_in_every_workspace_reference() {
for id in [0, MAX_VIEWER_ID + 1, u32::MAX] {
for location in 0..8 {
let mut snapshot = Snapshot::capture(&UiState::default());
let panel = PanelKind::Viewer(id);
let dock = DockNode::Tabs { panels: vec![panel], active: 0 };
let stack = DockNode::Stack { entries: vec![crate::dock::StackEntry { panel, open: true, height: None }] };
let floating = vec![Floating { panels: vec![panel], active: 0, rect: [0.0, 0.0, 200.0, 100.0] }];
match location {
0 => snapshot.focused = panel,
1 => snapshot.maximized = Some(panel),
2 => snapshot.dock = dock,
3 => snapshot.dock = stack,
4 => snapshot.floating = floating,
5 => {
snapshot.saved_workspaces.insert("Saved".into(), dock);
}
6 => {
snapshot.saved_workspaces.insert("Saved".into(), stack);
}
_ => {
snapshot.saved_floating.insert("Saved".into(), floating);
}
}
assert!(snapshot.validate().is_err(), "viewer {id} at location {location} must be rejected");
}
}
}
#[test]
fn failed_write_keeps_previous_snapshot_and_retries() {
struct Fails<'a>(&'a MemoryConfig);
impl ConfigStore for Fails<'_> {
fn read(&self, name: &str) -> Option<String> {
self.0.read(name)
}
fn write(&self, _name: &str, _data: &str) -> std::io::Result<()> {
Err(std::io::Error::other("synthetic full disk"))
}
fn remove(&self, _name: &str) -> std::io::Result<()> {
Err(std::io::Error::other("unused"))
}
}
let config = MemoryConfig::default();
let mut ui = UiState::default();
let mut store = WorkspaceStore::load(Some(&config), &mut ui);
ui.focused = PanelKind::Project;
assert!(store.save(Some(&config), &ui).unwrap());
let previous = config.read(FILE).unwrap();
ui.focused = PanelKind::Timeline;
assert!(store.save(Some(&Fails(&config)), &ui).unwrap_err().contains("synthetic full disk"));
assert_eq!(config.read(FILE).as_deref(), Some(previous.as_str()));
assert!(store.has_changes(&ui));
assert!(store.save(Some(&config), &ui).unwrap());
let mut restored = UiState::default();
WorkspaceStore::load(Some(&config), &mut restored);
assert_eq!(restored.focused, PanelKind::Timeline);
}
}

View File

@ -0,0 +1,400 @@
//! Existing EffectCraft workspaces use shared transactions without changing their saved schema.
use craft_ui::docking::{Action, Placement, Zone};
use effectcraft_engine::Session;
use effectcraft_ui_egui::{
EffectcraftApp,
dock::{self, DockNode, Layout, PanelKind, SplitSize, StackEntry},
};
use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
#[test]
fn shared_move_preserves_accordion_metadata_and_unrelated_active_tabs() {
let mut layout = Layout {
root: DockNode::Split {
vertical: false,
size: SplitSize::FixedB(300.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Composition, PanelKind::Layer], active: 1 }),
b: Box::new(DockNode::Stack {
entries: vec![
StackEntry { panel: PanelKind::Preview, open: false, height: Some(73.0) },
StackEntry { panel: PanelKind::Properties, open: true, height: Some(211.0) },
StackEntry { panel: PanelKind::Align, open: false, height: None },
],
}),
},
floating: vec![],
};
layout.apply(Action::Close { panel: PanelKind::Project }).unwrap();
assert!(layout.root.is_visible(PanelKind::Composition));
layout.apply(Action::Move { panel: PanelKind::Preview, anchor: PanelKind::Align, placement: Placement::Tab { before: None } }).unwrap();
let DockNode::Split { b, size, .. } = &layout.root else { panic!("split was lost") };
assert_eq!(*size, SplitSize::FixedB(300.0));
let DockNode::Stack { entries } = b.as_ref() else { panic!("stack was lost") };
assert_eq!(entries.iter().find(|entry| entry.panel == PanelKind::Preview).unwrap().height, Some(73.0));
assert_eq!(
entries.iter().find(|entry| entry.panel == PanelKind::Properties).unwrap(),
&StackEntry { panel: PanelKind::Properties, open: true, height: Some(211.0) }
);
let before = layout.clone();
assert!(layout.apply(Action::Move { panel: PanelKind::Preview, anchor: PanelKind::Audio, placement: Placement::Split(Zone::Left) }).is_err());
assert_eq!(layout, before);
assert!(layout.apply(Action::Float { panel: PanelKind::Preview, rect: [0.0, 0.0, f32::INFINITY, 200.0] }).is_err());
assert_eq!(layout, before);
}
#[test]
fn every_builtin_workspace_and_dynamic_identity_roundtrips() {
for name in dock::WORKSPACES {
let mut layout = Layout { root: dock::workspace(name), floating: dock::workspace_floating(name) };
let before = serde_json::to_value(&layout).unwrap();
// A structural no-op still crosses both adapters and shared validation. The complete
// persisted shape must remain byte-equivalent, including accordion state and heights.
let action = match &layout.root {
DockNode::Split { size, .. } => Action::ResizeSplit {
path: vec![],
size: match *size {
SplitSize::Ratio(v) => craft_ui::layout::SplitSize::Ratio(v),
SplitSize::FixedA(v) => craft_ui::layout::SplitSize::FixedFirst(v),
SplitSize::FixedB(v) => craft_ui::layout::SplitSize::FixedSecond(v),
},
},
DockNode::Tabs { panels, .. } => Action::Move { panel: panels[0], anchor: panels[1], placement: Placement::Tab { before: Some(panels[0]) } },
DockNode::Stack { entries } => Action::SetStackOpen { panel: entries[0].panel, open: entries[0].open },
};
layout.apply(action).unwrap();
assert_eq!(before, serde_json::to_value(&layout).unwrap());
let back: Layout = serde_json::from_value(before).unwrap();
assert_eq!(layout, back);
}
let mut layout = Layout {
root: DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(42), PanelKind::ScriptPanel(77)], active: 1 },
floating: vec![],
};
layout.apply(Action::Float { panel: PanelKind::ScriptPanel(77), rect: [20.0, 30.0, 400.0, 300.0] }).unwrap();
layout.apply(Action::Move { panel: PanelKind::ScriptPanel(77), anchor: PanelKind::Viewer(42), placement: Placement::Tab { before: None } }).unwrap();
assert!(layout.floating.is_empty());
assert!(layout.contains(PanelKind::Viewer(42)));
assert!(layout.root.is_visible(PanelKind::ScriptPanel(77)));
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let element = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}"));
Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3]))
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: egui::Pos2, to: egui::Pos2, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers });
h.step();
for fraction in [0.05, 0.25, 0.5, 0.75, 1.0] {
h.event(Event::PointerMoved(from.lerp(to, fraction)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers });
h.step();
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
#[test]
fn pointer_drop_merges_floats_and_redocks_without_project_changes() {
let mut session = Session::default();
session.execute("comp.new", json!({"name": "Docking composition", "width": 320, "height": 180, "duration": 4})).unwrap();
session.execute("layer.newSolid", json!({"name": "Teal plate", "width": 320, "height": 180, "color": "#245b68"})).unwrap();
session.execute("layer.newSolid", json!({"name": "Coral tile", "width": 110, "height": 70, "color": "#ee7859"})).unwrap();
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
app.ui.dock = DockNode::Split {
vertical: false,
size: SplitSize::FixedA(400.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info, PanelKind::Audio], active: 1 }),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Layer], active: 0 }),
};
app.ui.dock = DockNode::Split {
vertical: true,
size: SplitSize::Ratio(0.62),
a: Box::new(app.ui.dock),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }),
};
let snapshots = std::env::var_os("CRAFT_UI_DOCKING_SNAPSHOT_DIR").map(std::path::PathBuf::from);
let mut builder = Harness::builder().with_size(vec2(1200.0, 760.0));
if snapshots.is_some() {
builder = builder.wgpu();
}
let mut h = builder.build_eframe(move |_| app);
h.run_steps(4);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while h.state().frames.urgent_pending() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(!h.state().frames.urgent_pending());
h.run_steps(3);
let project = h.state().session.project.clone();
let times = h.state().session.state.times.clone();
let mut action_log = vec![action_state(&h, "start")];
let from = rect(&h, "panel.tab.Info").center();
let to = rect(&h, "panel.Composition").center();
drag(&mut h, from, to, Modifiers::NONE);
assert!(h.state().ui.dock.is_visible(PanelKind::Info));
assert!(same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::Composition));
action_log.push(action_state(&h, "merged"));
let from = rect(&h, "panel.tab.Info").center();
let before = rect(&h, "panel.tab.Composition");
drag(&mut h, from, pos2(before.left() + 2.0, before.center().y), Modifiers::NONE);
assert_eq!(tabs_containing(&h.state().ui.dock, PanelKind::Info).first(), Some(&PanelKind::Info));
action_log.push(action_state(&h, "reordered"));
let from = rect(&h, "panel.tab.Info").center();
drag(&mut h, from, pos2(700.0, 400.0), Modifiers::COMMAND);
assert_eq!(h.state().ui.floating.len(), 1);
assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Button);
action_log.push(action_state(&h, "floated"));
let original = h.state().ui.floating[0].rect;
let grip = rect(&h, "panel.float.0.resize").center();
drag(&mut h, grip, grip + vec2(36.0, 24.0), Modifiers::NONE);
assert!(h.state().ui.floating[0].rect[2] > original[2] + 25.0);
assert!(h.state().ui.floating[0].rect[3] > original[3] + 15.0);
action_log.push(action_state(&h, "resized"));
let grip_id = egui::Id::new(("floating-panel", PanelKind::Info.id())).with("resize");
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id));
let before_keys = h.state().ui.floating[0].rect;
for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] {
for pressed in [true, false] {
h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
}
assert_eq!(h.state().ui.floating[0].rect[2], before_keys[2] + 8.0);
assert_eq!(h.state().ui.floating[0].rect[3], before_keys[3] + 8.0);
h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert_ne!(h.ctx.memory(|memory| memory.focused()), Some(grip_id));
action_log.push(action_state(&h, "keyboard-resized"));
let resized = h.state().ui.floating[0].rect;
let header = rect(&h, "panel.float.0.move");
let from = pos2(header.right() - 24.0, header.center().y);
drag(&mut h, from, from - vec2(24.0, 18.0), Modifiers::NONE);
assert!(h.state().ui.floating[0].rect[0] < resized[0] - 15.0);
assert!(h.state().ui.floating[0].rect[1] < resized[1] - 10.0);
action_log.push(action_state(&h, "moved"));
if let Some(directory) = &snapshots {
std::fs::create_dir_all(directory).unwrap();
h.render().unwrap().save(directory.join("effectcraft-floating.png")).unwrap();
}
let from = rect(&h, "panel.tab.Info").center();
let to = rect(&h, "panel.Audio").center();
drag(&mut h, from, to, Modifiers::NONE);
assert!(h.state().ui.floating.is_empty());
action_log.push(action_state(&h, "redocked"));
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
assert_eq!(times, h.state().session.state.times);
assert!(h.state().auto.elements.iter().any(|element| element.id.starts_with("timeline.layer.")));
h.state_mut().session.execute("view.exposure", json!({"stops": 2.0})).unwrap();
h.run_steps(3);
let pos = rect(&h, "viewer.resetExposure").center();
h.event(Event::PointerMoved(pos));
h.step();
for pressed in [true, false] {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
assert_eq!(h.state().session.state.viewer.exposure, 0.0);
action_log.push(action_state(&h, "viewer-reset-exposure"));
if let Some(directory) = &snapshots {
std::fs::write(directory.join("effectcraft-actions.json"), serde_json::to_vec_pretty(&action_log).unwrap()).unwrap();
}
if let Some(directory) = &snapshots {
h.render().unwrap().save(directory.join("effectcraft-redocked.png")).unwrap();
}
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
}
fn same_group(root: &DockNode, first: PanelKind, second: PanelKind) -> bool {
match root {
DockNode::Tabs { panels, .. } => panels.contains(&first) && panels.contains(&second),
DockNode::Split { a, b, .. } => same_group(a, first, second) || same_group(b, first, second),
DockNode::Stack { .. } => false,
}
}
#[test]
fn current_and_named_floating_layouts_survive_restart() {
use effectcraft_engine::config::MemoryConfig;
use std::sync::Arc;
let store = Arc::new(MemoryConfig::default());
let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() });
assert!(app.edit_layout(|layout| layout.apply(Action::Float { panel: PanelKind::Project, rect: [45.0, 65.0, 430.0, 310.0] }).is_ok()));
let ctx = egui::Context::default();
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name": "Shared float"})).unwrap();
let saved = app.ui.floating.clone();
assert!(app.edit_layout(|layout| layout.apply(Action::MoveFloating { panel: PanelKind::Project, rect: [75.0, 95.0, 480.0, 360.0] }).is_ok()));
let current = app.ui.floating.clone();
eframe::App::on_exit(&mut app);
let mut restarted = EffectcraftApp::new(Session { config: Some(store), ..Default::default() });
assert_eq!(restarted.ui.floating, current);
restarted.set_workspace("Default");
restarted.set_workspace("Shared float");
assert_eq!(restarted.ui.floating, saved);
}
fn tabs_containing(root: &DockNode, panel: PanelKind) -> Vec<PanelKind> {
match root {
DockNode::Tabs { panels, .. } if panels.contains(&panel) => panels.clone(),
DockNode::Split { a, b, .. } => {
let first = tabs_containing(a, panel);
if first.is_empty() { tabs_containing(b, panel) } else { first }
}
_ => vec![],
}
}
#[test]
fn float_command_rejects_malformed_rectangles_atomically() {
let mut app = EffectcraftApp::new(Session::default());
let before = app.ui.dock.clone();
let ctx = egui::Context::default();
for rect in [json!([1, 2, 300]), json!([1, 2, 300, 200, 7]), json!([1, 2, 1e100, 200]), json!("rectangle")] {
assert!(effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Project", "rect": rect})).is_err());
assert_eq!(app.ui.dock, before);
assert!(app.ui.floating.is_empty());
}
app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 };
assert!(!app.edit_layout(|layout| layout.apply(Action::Float { panel: PanelKind::Project, rect: [1.0, 2.0, 300.0, 200.0] }).is_ok()));
}
fn action_state(h: &Harness<'_, EffectcraftApp>, action: &str) -> serde_json::Value {
json!({"action": action, "dock": h.state().ui.dock, "floating": h.state().ui.floating, "focused": h.state().ui.focused})
}
#[test]
fn catalog_docking_action_supports_resize_reorder_and_floating_geometry() {
let mut app = EffectcraftApp::new(Session::default());
app.ui.dock = DockNode::Split {
vertical: false,
size: SplitSize::FixedA(300.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info], active: 1 }),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition], active: 0 }),
};
let ctx = egui::Context::default();
for action in [
Action::ResizeSplit { path: vec![], size: craft_ui::layout::SplitSize::FixedFirst(360.0) },
Action::Move { panel: PanelKind::Info, anchor: PanelKind::Project, placement: Placement::Tab { before: Some(PanelKind::Project) } },
Action::Float { panel: PanelKind::Info, rect: [20.0, 40.0, 300.0, 200.0] },
Action::MoveFloating { panel: PanelKind::Info, rect: [45.0, 65.0, 330.0, 240.0] },
] {
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.dockAction", json!({"action": action})).unwrap();
}
assert_eq!(app.ui.floating[0].rect, [45.0, 65.0, 330.0, 240.0]);
let DockNode::Split { size, .. } = &app.ui.dock else { panic!("split lost") };
assert_eq!(*size, SplitSize::FixedA(360.0));
let before = json!({"dock": app.ui.dock, "floating": app.ui.floating});
assert!(
effectcraft_ui_egui::menus::invoke(
&mut app,
&ctx,
"window.dockAction",
json!({"action": {
"ResizeSplit": {"path": [true, true], "size": {"Ratio": 0.5}}
}})
)
.is_err()
);
assert_eq!(json!({"dock": app.ui.dock, "floating": app.ui.floating}), before);
}
#[test]
fn catalog_close_keeps_dynamic_panel_identity() {
let mut app = EffectcraftApp::new(Session::default());
app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(42), PanelKind::ScriptPanel(77)], active: 1 };
let ctx = egui::Context::default();
for panel in [PanelKind::Viewer(42), PanelKind::ScriptPanel(77)] {
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.dockAction", json!({"action": Action::Close { panel }})).unwrap();
assert!(!app.ui.dock.contains(panel));
}
assert!(app.ui.dock.contains(PanelKind::Composition));
}
#[test]
fn accordion_resize_routes_pointer_and_keyboard_without_changing_closed_entries() {
let mut session = Session::default();
session.execute("comp.new", json!({"width": 160, "height": 90, "duration": 1})).unwrap();
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
let mut h = Harness::builder().with_size(vec2(1000.0, 720.0)).build_eframe(move |_| app);
h.run_steps(4);
h.state_mut().ui.dock = DockNode::Stack {
entries: vec![
StackEntry { panel: PanelKind::Preview, open: true, height: Some(100.0) },
StackEntry { panel: PanelKind::Properties, open: true, height: None },
StackEntry { panel: PanelKind::Align, open: false, height: Some(77.0) },
],
};
h.run_steps(3);
let project = h.state().session.project.clone();
let id = egui::Id::new(("dock-gutter", "s0".to_string()));
h.ctx.memory_mut(|memory| memory.request_focus(id));
h.run_steps(2);
for pressed in [true, false] {
h.event(Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
let DockNode::Stack { entries } = &h.state().ui.dock else { panic!("stack lost") };
assert_eq!(entries[0].height, Some(108.0));
let from = rect(&h, "dock.gutter.s0").center();
drag(&mut h, from, from + vec2(0.0, 16.0), Modifiers::NONE);
let DockNode::Stack { entries } = &h.state().ui.dock else { panic!("stack lost") };
assert_eq!(entries[0].height, Some(124.0));
assert_eq!(entries[1].height, None);
assert_eq!(entries[2], StackEntry { panel: PanelKind::Align, open: false, height: Some(77.0) });
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
}
#[test]
fn legacy_close_repairs_selection_but_transactions_remain_strict() {
use effectcraft_ui_egui::dock::Floating;
let mut root = DockNode::Tabs { panels: vec![PanelKind::Properties, PanelKind::Align, PanelKind::EffectsPresets], active: usize::MAX };
root.close(PanelKind::History);
assert!(root.is_visible(PanelKind::EffectsPresets));
let mut layout = Layout {
root: DockNode::Tabs { panels: vec![PanelKind::Composition], active: 0 },
floating: vec![Floating {
panels: vec![PanelKind::Properties, PanelKind::Align, PanelKind::EffectsPresets],
active: usize::MAX,
rect: [0.0, 0.0, 300.0, 200.0],
}],
};
let before = layout.clone();
assert!(layout.apply(Action::Close { panel: PanelKind::Properties }).is_err());
assert_eq!(layout, before);
assert!(layout.unfloat(PanelKind::Properties));
assert_eq!(layout.floating[0].panels.get(layout.floating[0].active), Some(&PanelKind::EffectsPresets));
layout.floating[0].rect = [0.0; 4];
let before = layout.clone();
assert!(!layout.unfloat(PanelKind::Align));
assert_eq!(layout, before);
}
#[test]
fn failed_close_preserves_viewer_lifecycle_and_maximize_state() {
let mut app = EffectcraftApp::new(Session::default());
app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(42)], active: usize::MAX };
app.ui.maximized = Some(PanelKind::Viewer(42));
app.ui.viewers.insert(42, None);
app.ui.active_viewer = 42;
app.ui.locked_tabs.insert(PanelKind::Viewer(42).id());
let before = app.ui.clone();
app.close_panel(PanelKind::Viewer(42));
assert_eq!(app.ui.dock, before.dock);
assert_eq!(app.ui.maximized, before.maximized);
assert_eq!(app.ui.viewers, before.viewers);
assert_eq!(app.ui.active_viewer, before.active_viewer);
assert_eq!(app.ui.locked_tabs, before.locked_tabs);
}

View File

@ -0,0 +1,86 @@
//! Shared pane resizing in the full application, including application shortcut ownership.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::{DockNode, PanelKind, SplitSize};
use effectcraft_ui_egui::theme::ThemeKind;
use egui::{Event, Id, Key, Modifiers, PointerButton, Rect, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn snapshots(h: &mut Harness<'_, EffectcraftApp>, directory: Option<&std::path::Path>, phase: &str) {
let Some(directory) = directory else { return };
std::fs::create_dir_all(directory).unwrap();
for (name, theme) in [("dark", ThemeKind::Dark), ("darker", ThemeKind::Darker), ("light", ThemeKind::Light)] {
let ctx = h.ctx.clone();
h.state_mut().set_theme(&ctx, theme);
h.run_steps(3);
h.render().unwrap().save(directory.join(format!("effectcraft-{phase}-{name}.png"))).unwrap();
}
let ctx = h.ctx.clone();
h.state_mut().set_theme(&ctx, ThemeKind::Dark);
h.run_steps(3);
}
fn first_size(h: &Harness<'_, EffectcraftApp>) -> f32 {
match h.state().ui.dock {
DockNode::Split { size: SplitSize::FixedA(value), .. } => value,
ref other => panic!("fixed-size mode changed: {other:?}"),
}
}
#[test]
fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() {
let mut session = Session::default();
session.execute("comp.new", json!({"name": "Split test", "width": 160, "height": 90, "duration": 1})).unwrap();
session.execute("layer.newSolid", json!({"name": "Selected", "color": [1.0, 0.0, 0.0, 1.0]})).unwrap();
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
let directory = std::env::var_os("CRAFT_UI_PANES_SNAPSHOT_DIR").map(std::path::PathBuf::from);
let mut builder = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(10_000);
if directory.is_some() {
builder = builder.wgpu();
}
let mut h = builder.build_eframe(move |_| app);
h.run_steps(4);
snapshots(&mut h, directory.as_deref(), "workspace");
h.state_mut().ui.focused = PanelKind::Composition;
h.state_mut().ui.dock = DockNode::Split {
vertical: false,
size: SplitSize::FixedA(400.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition], active: 0 }),
};
h.run_steps(3);
let before_project = h.state().session.project.clone();
let before_times = h.state().session.state.times.clone();
let id = Id::new(("dock-gutter", String::new()));
h.ctx.memory_mut(|memory| memory.request_focus(id));
h.run_steps(2);
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(id));
snapshots(&mut h, directory.as_deref(), "focused-resize");
h.event(Event::Key { key: Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
h.event(Event::Key { key: Key::ArrowRight, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.step();
assert_eq!(first_size(&h), 408.0);
assert_eq!(h.state().session.state.times, before_times);
assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &before_project), "the viewer shortcut must not nudge a selected layer");
h.run_steps(2);
let element = h.state().auto.find("dock.gutter.").unwrap();
let from = Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3])).center();
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for offset in [5.0, 10.0, 15.0, 20.0] {
h.event(Event::PointerMoved(from + vec2(offset, 0.0)));
h.step();
}
h.event(Event::PointerButton { pos: from + vec2(20.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.step();
assert_eq!(first_size(&h), 428.0);
assert_eq!(h.state().session.state.times, before_times);
assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &before_project));
}

View File

@ -0,0 +1,311 @@
//! Workspaces survive a new frontend, and keyboard input follows real widget focus.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::config::{ConfigStore, DirConfig, MemoryConfig};
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::{DockNode, PanelKind, SplitSize};
use effectcraft_ui_egui::state::Tool;
use egui::{Event, Id, Key, Modifiers, vec2};
use egui_kittest::Harness;
use serde_json::json;
#[test]
fn named_and_current_workspaces_survive_frontend_restart() {
let store = Arc::new(MemoryConfig::default());
let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() });
app.ui.dock = DockNode::Split {
vertical: false,
size: SplitSize::FixedA(345.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Layer], active: 1 }),
};
let ctx = egui::Context::default();
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name": "Restart layout"})).unwrap();
let saved = app.ui.dock.clone();
if let DockNode::Split { size, .. } = &mut app.ui.dock {
*size = SplitSize::FixedA(390.0);
}
app.ui.focused = PanelKind::Layer;
let current = app.ui.dock.clone();
eframe::App::on_exit(&mut app);
assert!(store.read("workspaces.json").is_some(), "workspace data must reach the configured store");
let mut restarted = EffectcraftApp::new(Session { config: Some(store), ..Default::default() });
assert_eq!(restarted.ui.workspace, "Restart layout");
assert_eq!(restarted.ui.dock, current, "restore current unsaved divider changes independently of the named snapshot");
assert_eq!(restarted.ui.saved_workspaces.get("Restart layout"), Some(&saved));
assert_eq!(restarted.ui.focused, PanelKind::Layer);
restarted.set_workspace("Default");
restarted.set_workspace("Restart layout");
assert_eq!(restarted.ui.dock, saved, "the named snapshot is separately available after restart");
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
h.event(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
h.step();
h.event(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
h.step();
h.run_steps(2);
}
fn scratch_dir(label: &str) -> std::path::PathBuf {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("effectcraft-workspace-{label}-{}-{id}", std::process::id()));
std::fs::create_dir(&path).unwrap();
path
}
#[test]
fn native_config_read_is_byte_bounded_and_reports_errors() {
let dir = scratch_dir("bounded-read");
let config = DirConfig::new(&dir);
assert!(config.read_bounded("missing", 4).unwrap().is_none());
std::fs::write(dir.join("entry"), "é").unwrap();
assert_eq!(config.read_bounded("entry", 2).unwrap().as_deref(), Some("é"));
assert_eq!(config.read_bounded("entry", 1).unwrap_err().kind(), std::io::ErrorKind::InvalidData);
std::fs::write(dir.join("entry"), [0xff, 0xfe]).unwrap();
assert_eq!(config.read_bounded("entry", 4).unwrap_err().kind(), std::io::ErrorKind::InvalidData);
std::fs::create_dir(dir.join("folder")).unwrap();
assert!(config.read_bounded("folder", 4).is_err());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn unreadable_native_workspace_is_preserved_after_later_layout_changes() {
for bytes in [vec![0xff, 0xfe], vec![b' '; 1024 * 1024 + 1], b"{".to_vec()] {
let dir = scratch_dir("preserve");
let path = dir.join("workspaces.json");
std::fs::write(&path, &bytes).unwrap();
let mut app = EffectcraftApp::new(Session { config: Some(Arc::new(DirConfig::new(&dir))), ..Default::default() });
let ctx = egui::Context::default();
assert!(effectcraft_ui_egui::control::inspect(&app, &ctx)["workspaceStorageError"].is_string());
app.set_workspace("Minimal");
app.ui.focused = PanelKind::Project;
eframe::App::on_exit(&mut app);
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert!(effectcraft_ui_egui::control::inspect(&app, &ctx)["workspaceStorageError"].is_string());
std::fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn tab_and_shift_tab_traverse_toolbar_and_space_activates_only_focused_tool() {
let mut session = Session::default();
session.execute("comp.new", json!({"width": 160, "height": 90, "duration": 1})).unwrap();
session.execute("layer.newSolid", json!({"name": "Keep unchanged", "color": "#ff0000"})).unwrap();
let project = session.project.clone();
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
app.ui.show_menu_bar = false;
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app);
h.run_steps(3);
for _ in 0..8 {
key(&mut h, Key::Tab, Modifiers::NONE);
if h.ctx.memory(|m| m.focused()) == Some(Id::new(("tool-slot", 0usize))) {
break;
}
}
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::new(("tool-slot", 0usize))));
key(&mut h, Key::Tab, Modifiers::NONE);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::new(("tool-slot", 1usize))));
assert_eq!(h.state().ui.tool, Tool::Selection, "focus movement alone must not select a tool");
key(&mut h, Key::Tab, Modifiers::NONE);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::new(("tool-slot", 2usize))));
key(&mut h, Key::Tab, Modifiers::SHIFT);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::new(("tool-slot", 1usize))));
key(&mut h, Key::Space, Modifiers::NONE);
assert_eq!(h.state().ui.tool, Tool::Hand);
assert!(!h.state().playback.playing, "Space activates the focused widget, not preview playback");
assert!(Arc::ptr_eq(&h.state().session.project, &project), "keyboard focus and tool selection do not modify the project");
let response = h.ctx.read_response(Id::new(("tool-slot", 1usize))).unwrap();
assert!(
h.output().shapes.iter().any(|shape| {
matches!(&shape.shape, egui::Shape::Rect(r) if r.rect == response.rect.shrink(1.0) && r.stroke.color == h.state().tokens.text && r.stroke.width > 0.0)
}),
"the custom-painted toolbar must visibly identify keyboard focus"
);
let inspected = effectcraft_ui_egui::control::inspect(h.state(), &h.ctx);
assert_eq!(inspected["keyboardFocus"]["automationId"], "tools.Hand");
assert_eq!(inspected["keyboardFocus"]["rect"], json!([response.rect.min.x, response.rect.min.y, response.rect.width(), response.rect.height()]));
}
#[test]
fn tab_traversal_activates_an_inactive_dock_tab_without_editing_the_project() {
let mut session = Session::default();
session.execute("comp.new", json!({"width": 160, "height": 90, "duration": 1})).unwrap();
session.execute("layer.newSolid", json!({"name": "Keep unchanged", "color": "#ff0000"})).unwrap();
let project = session.project.clone();
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
app.ui.show_menu_bar = false;
// Leave the inactive, layer-named tab visible instead of behind the overflow control.
if let DockNode::Split { a, .. } = &mut app.ui.dock
&& let DockNode::Split { a, .. } = a.as_mut()
&& let DockNode::Split { size, .. } = a.as_mut()
{
*size = SplitSize::FixedA(420.0);
}
let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app);
h.run_steps(3);
assert!(h.state().auto.find("panel.tab.EffectControls").is_some(), "the inactive tab must be visible in this fixture");
let before = h.state().ui.dock.clone();
let target = Id::new(("tab", "aaa".to_string(), 1usize));
let mut tabs = 0;
for _ in 0..80 {
key(&mut h, Key::Tab, Modifiers::NONE);
tabs += 1;
if h.ctx.memory(|m| m.focused()) == Some(target) {
break;
}
}
assert_eq!(h.ctx.memory(|m| m.focused()), Some(target));
println!("Tab presses from no focus to Effect Controls: {tabs}");
assert_eq!(h.state().ui.dock, before, "traversal alone must not activate a tab");
key(&mut h, Key::Space, Modifiers::NONE);
let mut activated = before;
assert!(activated.activate(PanelKind::EffectControls));
assert_eq!(h.state().ui.dock, activated);
assert_eq!(h.state().ui.focused, PanelKind::EffectControls);
assert!(!h.state().playback.playing);
assert!(Arc::ptr_eq(&h.state().session.project, &project));
let response = h.ctx.read_response(target).unwrap();
assert!(
h.output().shapes.iter().any(|shape| {
matches!(&shape.shape, egui::Shape::Rect(r) if r.rect == response.rect.shrink(1.0) && r.stroke.color == h.state().tokens.text && r.stroke.width > 0.0)
}),
"the active dock tab must visibly retain keyboard focus"
);
let inspected = effectcraft_ui_egui::control::inspect(h.state(), &h.ctx);
assert_eq!(inspected["keyboardFocus"]["automationId"], "panel.tab.EffectControls");
key(&mut h, Key::Tab, Modifiers::SHIFT);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::new(("tab", "aaa".to_string(), 0usize))));
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.state().ui.focused, PanelKind::Project);
assert!(!h.state().playback.playing);
assert!(Arc::ptr_eq(&h.state().session.project, &project));
}
struct ControlDriver {
h: Harness<'static, EffectcraftApp>,
tx: std::sync::mpsc::Sender<effectcraft_ui_egui::ControlRequest>,
}
impl ControlDriver {
fn new() -> Self {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = EffectcraftApp::new(Session::default()).with_control(rx);
app.ui.start_screen = false;
app.ui.show_menu_bar = false;
let mut driver = Self { h: Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app), tx };
driver.frames(4, true);
driver.ok("engine.execute", json!({"command":"comp.new", "params":{"name":"Native Validation", "width":160,"height":90,"duration":1}}), true);
driver.ok("engine.execute", json!({"command":"layer.newSolid", "params":{"name":"Validation Red","color":"#ff0000"}}), true);
driver.ok("ui.set", json!({"focused":"Timeline"}), true);
driver.ok("ui.key", json!({"key":"Z","command":true}), true);
driver.ok("ui.key", json!({"key":"Z","command":true,"shift":true}), true);
driver
}
fn frames(&mut self, count: usize, native_focused: bool) {
for _ in 0..count {
let ctx = self.h.ctx.clone();
let mut raw = std::mem::take(self.h.input_mut());
raw.focused = native_focused;
if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) {
viewport.focused = Some(native_focused);
}
eframe::App::raw_input_hook(self.h.state_mut(), &ctx, &mut raw);
*self.h.input_mut() = raw;
self.h.step();
}
}
fn ok(&mut self, method: &str, params: serde_json::Value, native_focused: bool) -> serde_json::Value {
let (request, reply) = effectcraft_ui_egui::ControlRequest::new(method, params);
self.tx.send(request).unwrap();
for _ in 0..60 {
self.frames(1, native_focused);
if let Ok(result) = reply.try_recv() {
assert_eq!(result["ok"], true, "{result}");
self.frames(2, native_focused);
return result["result"].clone();
}
}
panic!("no control reply to {method}");
}
}
#[test]
fn control_root_divider_drag_after_toolbar_and_dock_tab_keyboard_activation() {
let mut d = ControlDriver::new();
let project = d.h.state().session.project.clone();
let times = d.h.state().session.state.times.clone();
assert_eq!(d.h.state().ui.focused, PanelKind::Timeline);
assert!(d.h.state().ui.mini_flowchart.is_none());
d.ok("ui.click", json!({"id":"tools.Selection"}), true);
for _ in 0..5 {
d.ok("ui.key", json!({"key":"Tab"}), true);
assert!(d.h.state().ui.mini_flowchart.is_none(), "Tab entering or traversing widgets must not also invoke the Timeline mini-flowchart shortcut");
}
d.ok("ui.key", json!({"key":"Tab","shift":true}), true);
d.ok("ui.key", json!({"key":"Space"}), true);
assert_eq!(d.h.state().ui.tool, Tool::Hand);
d.ok("ui.key", json!({"key":"Tab","shift":true}), true);
d.ok("ui.key", json!({"key":"Enter"}), true);
assert_eq!(d.h.state().ui.tool, Tool::Selection);
d.ok("ui.drag", json!({"from":{"id":"dock.gutter.aa","fx":0.5,"fy":0.5},"to":{"id":"dock.gutter.aa","fx":10.5,"fy":0.5},"steps":8}), true);
d.ok("ui.click", json!({"id":"tools.Selection"}), true);
// The toolbar can grow as upstream adds controls. Traverse to the actual response
// identity rather than assuming a fixed number of focus stops.
let target = Id::new(("tab", "aaa".to_string(), 1usize));
for tabs in 1..=100 {
d.ok("ui.key", json!({"key":"Tab"}), true);
assert!(d.h.state().ui.mini_flowchart.is_none(), "dock-tab traversal must not open a foreground popup");
if d.h.ctx.memory(|memory| memory.focused()) == Some(target) {
println!("control Tab presses to Effect Controls: {tabs}");
break;
}
}
assert_eq!(d.h.ctx.memory(|memory| memory.focused()), Some(target), "Effect Controls must remain reachable by control-channel Tab traversal");
d.ok("ui.key", json!({"key":"Space"}), true);
assert_eq!(d.h.state().ui.focused, PanelKind::EffectControls);
d.ok("ui.key", json!({"key":"Tab","shift":true}), true);
d.ok("ui.key", json!({"key":"Enter"}), true);
assert_eq!(d.h.state().ui.focused, PanelKind::Project);
let before = d.h.state().ui.dock.clone();
let gutter = d.h.state().auto.find("dock.gutter.").unwrap().rect;
println!("root gutter before control drag: {gutter:?}; mini-flowchart: {:?}", d.h.state().ui.mini_flowchart);
d.ok("ui.drag", json!({"from":{"id":"dock.gutter.","fx":0.5,"fy":0.5},"to":{"id":"dock.gutter.","fx":5.0,"fy":0.5},"steps":8}), true);
assert_ne!(d.h.state().ui.dock, before, "the foreground root divider must still accept a drag after dock-tab keyboard activation");
assert_eq!(d.h.state().session.state.times, times);
assert!(Arc::ptr_eq(&d.h.state().session.project, &project));
assert!(!d.h.state().playback.playing);
for _ in 0..160 {
d.ok("ui.key", json!({"key":"Tab"}), true);
assert!(d.h.state().ui.mini_flowchart.is_none(), "wrapping widget traversal must not dispatch the application Tab binding");
}
}
#[test]
fn unowned_tab_keeps_the_timeline_mini_flowchart_shortcut() {
let mut session = Session::default();
session.execute("comp.new", json!({"width":160,"height":90,"duration":1})).unwrap();
let mut app = EffectcraftApp::new(session);
app.ui.focused = PanelKind::Timeline;
let ctx = egui::Context::default();
let raw = egui::RawInput {
events: vec![egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }],
..Default::default()
};
let mut output = ctx.run_ui(raw, |ui| {
let ctx = ui.ctx();
effectcraft_ui_egui::menus::handle_shortcuts(&mut app, ctx);
assert!(app.ui.mini_flowchart.is_none(), "application Tab waits until widgets can claim focus");
assert!(!ctx.egui_wants_keyboard_input());
effectcraft_ui_egui::menus::handle_tab_shortcut(&mut app, ctx);
});
output.textures_delta.clear();
assert!(app.ui.mini_flowchart.is_some(), "an unowned Tab still invokes the application binding");
}

View File

@ -0,0 +1,277 @@
//! Inspect the actual viewer's painted colors and pointer-driven control geometry.
use effectcraft_engine::Session;
use effectcraft_ui_egui::{
EffectcraftApp,
panels::viewer,
theme::{self, ThemeKind},
};
use egui::{Color32, Event, Rect, Shape, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn harness(width: f32, kind: ThemeKind) -> Harness<'static, EffectcraftApp> {
harness_at_scale(width, kind, 1.0)
}
fn harness_at_scale(width: f32, kind: ThemeKind, scale: f32) -> Harness<'static, EffectcraftApp> {
let mut session = Session::default();
session.execute("comp.new", json!({"name":"Geometry validation", "width":640, "height":360, "duration":4})).unwrap();
let mut h = Harness::builder().with_size(vec2(width, 360.0)).with_pixels_per_point(scale).build_ui_state(
|ui, app: &mut EffectcraftApp| {
app.auto.begin_frame();
*app.frames.last_ms.lock().unwrap() = 0.0;
viewer::show(app, ui, ui.max_rect());
},
EffectcraftApp::new(session),
);
let ctx = h.ctx.clone();
h.state_mut().set_theme(&ctx, kind);
theme::install(&ctx, &h.state().tokens, "en");
h.run_steps(4);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while h.state().frames.urgent_pending() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(!h.state().frames.urgent_pending(), "synthetic viewer frame must finish rendering");
h.run_steps(3);
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.elements.iter().find(|e| e.id == id).unwrap_or_else(|| panic!("missing {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn luminance(color: Color32) -> f32 {
let linear = |v: u8| {
let v = f32::from(v) / 255.0;
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
};
0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b())
}
#[test]
fn active_breadcrumb_text_contrasts_with_its_actual_painted_background() {
let mut failures = Vec::new();
for kind in [ThemeKind::Light, ThemeKind::Dark, ThemeKind::Darker] {
for scale in [1.0, 2.0] {
let h = harness_at_scale(300.0, kind, scale);
let text = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
Shape::Text(text) if text.galley.text() == "Geometry validation" => Some(text),
_ => None,
})
.unwrap();
let fill = h
.output()
.shapes
.iter()
.rev()
.find_map(|s| match &s.shape {
Shape::Rect(r) if r.rect.height() == 18.0 && r.rect.contains(text.visual_bounding_rect().center()) => Some(r.fill),
_ => None,
})
.unwrap();
let color = text.override_text_color.unwrap_or(text.fallback_color);
let foreground = luminance(color);
let background = luminance(fill);
let contrast = (foreground.max(background) + 0.05) / (foreground.min(background) + 0.05);
eprintln!("viewer breadcrumb: {kind:?} {scale}x, text {color:?}, background {fill:?}, contrast {contrast:.2}");
if contrast < 4.5 {
failures.push(format!("{kind:?} {scale}x: actual breadcrumb contrast is {contrast:.2}"));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn compact_control_row_scrolls_and_preserves_interactions() {
let mut h = harness(300.0, ThemeKind::Light);
assert!(rect(&h, "viewer.timecode").right() > 300.0);
h.event(Event::PointerMoved(pos2(150.0, 345.0)));
h.event(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(-1500.0, 0.0), modifiers: Default::default(), phase: egui::TouchPhase::Move });
h.run_steps(30);
let timecode = rect(&h, "viewer.timecode");
let row = rect(&h, "viewer.controls");
assert!(timecode.left() >= row.left() && timecode.right() <= row.right(), "last viewer control must scroll into view: {timecode:?}");
let exposure = rect(&h, "viewer.resetExposure");
assert!(exposure.left() >= row.left() && exposure.right() <= row.right());
h.state_mut().session.execute("view.exposure", json!({"stops":2.0})).unwrap();
h.run_steps(2);
let point = rect(&h, "viewer.resetExposure").center();
h.event(Event::PointerMoved(point));
for pressed in [true, false] {
h.event(Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
h.run_steps(3);
assert_eq!(h.state().session.state.viewer.exposure, 0.0, "a scrolled control must still run its original command");
let exposure = rect(&h, "viewer.exposure").center();
h.event(Event::PointerMoved(exposure));
h.event(Event::PointerButton { pos: exposure, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for x in [10.0, 20.0, 30.0] {
h.event(Event::PointerMoved(exposure + vec2(x, 0.0)));
h.step();
}
h.event(Event::PointerButton { pos: exposure + vec2(30.0, 0.0), button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
assert!(h.state().session.state.viewer.exposure > 0.0, "the scrolled exposure control must retain dragging");
click_control(&mut h, "viewer.snapshot");
assert!(h.state().session.snapshot.is_some(), "the scrolled snapshot button must capture");
let snapshot = rect(&h, "viewer.showSnapshot").center();
h.event(Event::PointerMoved(snapshot));
h.event(Event::PointerButton { pos: snapshot, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.run_steps(3);
assert!(h.state().session.state.viewer.show_snapshot, "the scrolled snapshot control must retain holding");
h.event(Event::PointerButton { pos: snapshot, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
assert!(!h.state().session.state.viewer.show_snapshot, "releasing the snapshot control must return to the current frame");
click_control(&mut h, "viewer.fastPreviews");
click_control(&mut h, "viewer.fastPreviewsItem.1");
assert_eq!(h.state().session.state.viewer.fast_previews.id(), "adaptive", "a scrolled dropdown must retain its menu command");
}
fn click_control(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let control = rect(h, id);
let point = control.center();
let row = rect(h, "viewer.controls");
if !id.contains("Item.") {
assert!(control.left() >= row.left() && control.right() <= row.right(), "{id} must be fully visible before clicking");
}
h.event(Event::PointerMoved(point));
h.step();
for pressed in [true, false] {
h.event(Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
h.run_steps(3);
}
#[test]
fn tab_traversal_reveals_the_last_control() {
let mut h = harness(300.0, ThemeKind::Dark);
let last = egui::Id::new("vw-timecode");
for _ in 0..80 {
h.key_press(egui::Key::Tab);
h.run_steps(3);
if h.ctx.memory(|memory| memory.focused()) == Some(last) {
break;
}
}
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(last), "Tab must reach the actual timecode control");
h.run_steps(12);
let response = h.ctx.read_response(last).unwrap();
let row = rect(&h, "viewer.controls");
assert!(response.rect.left() >= row.left() && response.rect.right() <= row.right(), "focused control must be visible: {:?}", response.rect);
assert!(
h.output().shapes.iter().any(|shape| matches!(&shape.shape, Shape::Rect(rect)
if rect.rect == response.rect.shrink(1.0) && rect.stroke.color == h.state().tokens.focus)),
"focused control must have a visible outline"
);
}
#[test]
fn compact_scrollbar_thumb_is_distinct_and_tracks_the_scrolled_position() {
for kind in [ThemeKind::Dark, ThemeKind::Darker, ThemeKind::Light] {
for scale in [1.0, 2.0] {
let mut h = harness_at_scale(300.0, kind, scale);
let (initial_track, initial_thumb) = painted_scrollbar(&h, kind, scale, "initial");
scroll_controls_to_end(&mut h);
let (scrolled_track, scrolled_thumb) = painted_scrollbar(&h, kind, scale, "scrolled");
assert_eq!(initial_track, scrolled_track, "scrolling must keep the track fixed");
assert!(scrolled_thumb.left() > initial_thumb.left() + 1.0, "the painted thumb must move with horizontal scrolling");
}
}
}
fn painted_scrollbar(h: &Harness<'_, EffectcraftApp>, kind: ThemeKind, scale: f32, state: &str) -> (Rect, Rect) {
let scrollbar = h.get_by_role(egui::accesskit::Role::ScrollBar).rect();
let row = rect(h, "viewer.controls");
assert!(row.contains_rect(scrollbar), "scrollbar {scrollbar:?} must stay inside {row:?}");
let mut painted: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Rect(rect)
if rect.rect.width() > 20.0
&& rect.rect.height() <= 8.0
&& scrollbar.contains_rect(rect.rect)
&& rect.fill.a() > 0
&& shape.clip_rect.contains_rect(rect.rect) =>
{
Some(rect)
}
_ => None,
})
.collect();
painted.sort_by(|a, b| a.rect.width().total_cmp(&b.rect.width()));
assert_eq!(painted.len(), 2, "the track and thumb must each be painted without clipping");
let (thumb, track) = (painted[0], painted[1]);
assert!(thumb.rect.width() < track.rect.width() - 1.0 && track.rect.contains_rect(thumb.rect), "the overflow thumb must be narrower than its track");
let foreground = luminance(thumb.fill);
let background = luminance(track.fill);
let contrast = (foreground.max(background) + 0.05) / (foreground.min(background) + 0.05);
eprintln!(
"viewer scrollbar: {kind:?} {scale}x {state}, thumb {:?} {:?}, track {:?} {:?}, contrast {contrast:.2}",
thumb.rect, thumb.fill, track.rect, track.fill
);
assert!(contrast >= 3.0, "{kind:?} {scale}x {state}: actual thumb/track contrast {contrast:.2} is too low");
(track.rect, thumb.rect)
}
fn scroll_controls_to_end(h: &mut Harness<'_, EffectcraftApp>) {
h.event(Event::PointerMoved(pos2(150.0, 345.0)));
h.event(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(-1500.0, 0.0), modifiers: Default::default(), phase: egui::TouchPhase::Move });
h.run_steps(30);
h.event(Event::PointerMoved(pos2(150.0, 180.0)));
h.run_steps(3);
}
#[test]
fn viewer_theme_and_width_visual_matrix() {
let Ok(directory) = std::env::var("CRAFT_UI_VISUAL_DIR") else { return };
std::fs::create_dir_all(&directory).unwrap();
for kind in [ThemeKind::Dark, ThemeKind::Darker, ThemeKind::Light] {
for width in [900.0, 300.0] {
for scale in [1.0, 2.0] {
let mut h = harness_at_scale(width, kind, scale);
save_stable_snapshot(&mut h, &format!("{directory}/effect-viewer-{kind:?}-{width}-{scale}x.png"), width);
if width == 300.0 {
scroll_controls_to_end(&mut h);
save_stable_snapshot(&mut h, &format!("{directory}/effect-viewer-{kind:?}-{width}-{scale}x-scrolled.png"), width);
}
}
}
}
}
fn save_stable_snapshot(h: &mut Harness<'_, EffectcraftApp>, path: &str, width: f32) {
let image = h.render().unwrap();
image.save(path).unwrap();
h.run_steps(3);
let repeated = h.render().unwrap();
assert!(image == repeated, "settled synthetic viewer pixels must repeat exactly: {path}");
if width == 900.0 {
let times: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(text) if text.galley.text().ends_with(" ms") => Some(text.galley.text()),
_ => None,
})
.collect();
assert_eq!(times, ["0 ms"], "cached rendering must preserve the deterministic timing input");
}
}

View File

@ -51,6 +51,19 @@ Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `pdf →
`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine, script`,
`script → engine`.
The frontend uses `craft-ui::docking` for atomic layout transactions and pointer drop targets,
and `craft-ui::layout` for bounded split geometry and resize interactions. A small adapter
preserves EffectCraft's saved dock tree, stacked entries, floating rectangles and dynamic
viewer/ScriptUI identities. Tab moves, edge splits, reordering, float/redock, activation and
close operations use the shared model. The app retains composition-tab behavior, accordion
height allocation and ScriptUI close callbacks. Lower layers cannot depend on `craft-ui`.
Viewer controls retain their native widgets inside a horizontal scroll area, so narrow panels
keep dropdowns, exposure dragging and snapshot holds reachable. Keyboard focus scrolls the
focused control into view. Active composition breadcrumbs use the selected-row theme color.
The `viewer_geometry` tests inspect actual painted contrast, scroll geometry and pointer/keyboard
reachability across the three themes.
## 2. Time
All time is an integer `Tick`, 254 016 000 000 per second, the least common multiple of every

View File

@ -53,7 +53,7 @@ panel registers an element id; both are enforced by `crates/ui-egui/tests/ui_age
| Method | Params | Result |
|---|---|---|
| `ui.inspect` | `{}` | Window size, pixels per point, fps, serialized `UiState`, playback, time, active comp, selection, open dialog, last render ms. |
| `ui.inspect` | `{}` | Window size, pixels per point, fps, serialized `UiState`, playback, time, active comp, selection, open dialog, last render ms, `keyboardFocus`, `windowFocused`, `workspaceStorageError`. |
| `ui.elements` | `{prefix?}` | `[{id, label, rect: [x, y, w, h]}]`: the widgets drawn in the last frame, in points. |
| `ui.set` | `{tool?, workspace?, theme?, focused?, viewer?, timeline?, menuBar?, home?}` | `viewer: {zoom (null = fit), res: Full\|Half\|Third\|Quarter\|Auto, pan: [x,y], grid, rulers, safeMargins, transparencyGrid}`; `timeline: {pps (null = fit), start, graphEditor, showModes, openLayers: [ids], openGroups: [uids]}`. |
| `ui.panel.show` / `ui.panel.close` | `{panel}` | Panels: `Project`, `Composition`, `Timeline`, `EffectControls`, `EffectsPresets`, `Info`, `Preview`, `Character`, `Paragraph`, `Align` and others. |
@ -62,6 +62,26 @@ panel registers an element id; both are enforced by `crates/ui-egui/tests/ui_age
| `ui.focus` | `{}` | Activates the window and takes keyboard focus. Agents normally avoid this, because the app is started without stealing focus. |
| `app.quit` | `{force?}` | Closes the app. A modified project shows the Save / Don't Save / Cancel prompt first (automation ids `dialog.unsaved.save`, `dialog.unsaved.dontSave`, `dialog.unsaved.cancel`) unless `force: true`. |
`keyboardFocus` is `null` when no egui widget has keyboard focus. Otherwise it contains the
actual widget `id` (hexadecimal), its `rect: [x, y, w, h]` when available, an `automationId`
when the widget registered its response identity, and `registryCandidates` containing exact
rectangle matches. Candidates alone do not establish widget identity. Header, toolbar and
dock-tab controls register their response identity. `ui.focused` continues to identify the
focused panel; it is separate from keyboard widget focus. `windowFocused` reports the native
window focus received by egui, which can differ from the retained widget focus.
When a configuration store is available, current dock and floating geometry, focused and
maximized panels, and named workspace snapshots persist in `workspaces.json`. The current
layout and named snapshots are independent: changing a divider does not overwrite the named
snapshot until `window.saveWorkspace` runs. Changes save after pointer release, at most once
per second, with a final save on normal exit. Version 1 files are limited to 1 MiB and bounded
workspace trees; extra viewer IDs must be between 1 and 65,535. Native reads enforce the size
limit while reading and report I/O and UTF-8
errors. Unreadable, invalid or newer files leave the default workspace available and are
preserved for the entire session, including after later layout changes. Load and save errors
appear in the status message and the nullable
`workspaceStorageError` field; a failed write remains eligible for retry.
## Input
Points are logical window coordinates. A target is `{id}` (the element's centre; add `fx`/`fy` between
@ -141,3 +161,21 @@ including outside the layer's `[in, out)` range. Timing metadata carries outside
`keys.set` and `prop.get.keys[]` report both bases and warnings without an explicit base too.
`prop.get.time` is comp seconds. For these commands, `path` or `prop` identifies a property, not a group.
Set a layer's offset with `layer.timing {layer, start}` (comp seconds).
Panel moves, tab ordering, edge splits and floating geometry use shared docking transactions.
`window.floatPanel` requires exactly four finite numbers when `rect` is supplied. Invalid
geometry or missing source/anchor panels leaves the layout unchanged. The persisted workspace
shape is unchanged: accordion open states and explicit heights, floating groups, and dynamic
viewer/ScriptUI identities remain app data. Pointer drops use the same shared placement rules
as commands, with floating groups resolved in their actual window stacking order.
`window.dockAction` accepts `{action}` using the shared docking action's JSON form. It
covers tab moves and ordering, split and floating geometry, activation, and closing.
For example, `{"action":{"ResizeSplit":{"path":[],"size":{"FixedFirst":360}}}}`
resizes the root split; path entries use `false` for the first child and `true` for
the second. `{"action":{"MoveFloating":{"panel":"Info","rect":[45,65,420,320]}}}`
repositions and resizes the floating group containing Info. Invalid actions leave the
layout unchanged. Pointer gestures dispatch through this same catalog command.
EffectCraft also supports `SetStackOpen` and `ResizeStack`. Dynamic panel identities use
`{"Viewer":42}` or `{"ScriptPanel":77}` in the action. Closing runs the existing viewer
and ScriptUI lifecycle callbacks; opening uses the existing panel creation commands.

View File

@ -83,7 +83,7 @@ const SAME_LAYER: &[(&str, &str)] = &[
];
/// Crates that must not appear below L5 (UI toolkits, windowing, OS audio/menus).
const UI_ONLY: &[&str] = &["egui", "eframe", "egui-wgpu", "winit", "rfd", "cpal", "muda"];
const UI_ONLY: &[&str] = &["craft-ui", "egui", "eframe", "egui-wgpu", "winit", "rfd", "cpal", "muda"];
fn short(name: &str) -> &str {
name.strip_prefix("effectcraft-").unwrap_or(name)