Merge remote-tracking branch 'upstream/main' into gap-launch-paths

# Conflicts:
#	apps/filmcraft/src/args.rs
#	apps/filmcraft/src/main.rs
This commit is contained in:
vangel76 2026-10-11 08:48:28 +02:00
commit 33271cdd6c
35 changed files with 2643 additions and 363 deletions

10
Cargo.lock generated
View File

@ -1011,6 +1011,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"
@ -2264,6 +2273,7 @@ name = "filmcraft-ui-egui"
version = "0.6.0"
dependencies = [
"cpu-time",
"craft-ui",
"eframe",
"egui",
"egui_kittest",

View File

@ -67,6 +67,8 @@ proptest = "1"
roxmltree = "0.21"
# 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

@ -1,5 +1,8 @@
# FilmCraft Roadmap
- UI.6: Compact Source and Program monitors separate timecodes from view settings and retain Source drag choices. Transport controls use the shared toolbar sizing helper, with overflow actions in an operable More menu and a scrolling fallback at tiny widths.
- UI.3: Panel tabs, edge splits, tab reordering and floating groups use the shared docking model and drop resolver. Floating geometry is saved with named workspaces while legacy layout JSON remains readable. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tools and dock tabs expose actual widget focus, paint focus outlines, and support keyboard traversal/activation. Control input reaches focused widgets in background windows; optional input isolation supports unattended validation. Existing workspace trees and saved layout formats are retained.
**Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Premiere Pro 2026 26.5.2; scorecard, gaps, milestones and localization moved into docs/ per the craftrules progress-docs standard) · **Target:** Adobe Premiere Pro 2026 (26.5.2)

View File

@ -10,6 +10,8 @@ Usage: filmcraft [options] [project.fcproj | media files...]
Options:
--control <port> start the localhost JSON-lines control server
(also: FILMCRAFT_CONTROL_PORT)
--control-input-only accept widget input only from the control server
(requires --control or FILMCRAFT_CONTROL_PORT)
--demo open the demo project
--empty start with an empty project
--recover recover the newest unsaved changes without asking
@ -32,6 +34,7 @@ pub enum Cli {
#[derive(Debug, PartialEq)]
pub struct Launch {
pub control_port: Option<u16>,
pub control_input_only: bool,
/// A project and / or media files to open.
pub files: Vec<String>,
pub demo: bool,
@ -53,7 +56,7 @@ pub struct Launch {
/// in Latin-1), and `std::env::args` panics on one. A file name that is not is passed on lossily
/// (its import then reports it missing); `--data-dir` keeps the folder exactly.
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = OsString>) -> Result<Cli, String> {
let mut l = Launch { control_port: None, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None };
let mut l = Launch { control_port: None, control_input_only: false, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None };
let (mut help, mut version) = (false, false);
let mut args = args.into_iter();
while let Some(raw) = args.next() {
@ -67,6 +70,7 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = Os
l.control_port = Some(port.parse().map_err(|_| format!("--control needs a port number, got `{port}`"))?);
}
"--demo" => (l.demo, l.startup_flag) = (true, true),
"--control-input-only" => l.control_input_only = true,
"--empty" => (l.demo, l.startup_flag) = (false, true),
"--recover" => l.recover = Some(true),
"--no-recover" => l.recover = Some(false),
@ -90,6 +94,9 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = Os
{
l.control_port = Some(port.parse().map_err(|_| format!("FILMCRAFT_CONTROL_PORT needs a port number, got `{port}`"))?);
}
if l.control_input_only && l.control_port.is_none() {
return Err("--control-input-only requires --control or FILMCRAFT_CONTROL_PORT".into());
}
Ok(Cli::Run(l))
}
@ -188,7 +195,10 @@ mod tests {
#[test]
fn known_options_and_files_parse_as_before() {
let none = launch(&[]);
assert_eq!(none, Launch { control_port: None, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: None });
assert_eq!(
none,
Launch { control_port: None, control_input_only: false, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: None }
);
let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);
assert_eq!(l.control_port, Some(9876));
assert_eq!((l.demo, l.startup_flag, l.recover), (false, true, Some(false)));
@ -245,4 +255,14 @@ mod tests {
// nothing to resolve against: kept as given rather than dropped
assert_eq!(absolute(""), "");
}
#[test]
fn isolated_input_is_explicit_and_requires_a_control_server() {
assert!(!launch(&["--control", "9876"]).control_input_only);
assert!(parse_args(&["--control-input-only"]).is_err());
assert!(launch(&["--control-input-only", "--control", "9876"]).control_input_only);
assert!(matches!(parse_env(Some("9876"), &["--control-input-only"]), Ok(Cli::Run(Launch { control_input_only: true, control_port: Some(9876), .. }))));
assert_eq!(parse_args(&["--control-input-only", "--help"]), Ok(Cli::Help));
assert!(USAGE.contains("--control-input-only"));
}
}

View File

@ -80,7 +80,7 @@ fn main() -> eframe::Result {
let logger = logging::install();
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned());
let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args_os().skip(1)) {
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args_os().skip(1)) {
Ok(Cli::Run(l)) => l,
Ok(Cli::Help) => {
tell(args::USAGE, false);
@ -271,7 +271,7 @@ fn main() -> eframe::Result {
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) };
}
Ok(Box::new(app))
}),

View File

@ -556,7 +556,7 @@ impl VtEncoder {
VtRate::Cbr { kbps } => {
// true constant bitrate where the OS offers it (macOS 13+); otherwise an average
// with a one-second ceiling at the same rate
if !cbr_key.is_some_and(|key| set_value(session, key, CFNumber::new_i32(bps(kbps)).as_ref()) == 0) {
if cbr_key.is_none_or(|key| set_value(session, key, CFNumber::new_i32(bps(kbps)).as_ref()) != 0) {
require(set_value(session, avg_key, CFNumber::new_i32(bps(kbps)).as_ref()), "the bitrate")?;
let limits = CFArray::<CFType>::from_objects(&[CFNumber::new_i32(bps(kbps) / 8).as_ref(), CFNumber::new_f64(1.0).as_ref()]);
let _ = set_value(session, limits_key, limits.as_ref());

View File

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

View File

@ -59,3 +59,28 @@ range validation and undo; the UI owns the drag gesture. See `source_drag_ui` an
Source range handles use transient gesture previews and the engine's integer-frame
`source_monitor::adjust_range`; release dispatches one mark edit. Monitor command routing
keeps Source navigation, markers and marked-range playback separate from Program.
## Pane layout controls
Dock transactions and pointer targets use `craft-ui::docking`; bounded split geometry and
keyboard-accessible resize handles use `craft-ui::layout`. FilmCraft adapts its existing saved
dock tree and panel identities to the shared model. Panel tabs or their header grip can move
between tab groups, split at an edge, reorder, or float when released with Cmd/Ctrl. Floating
panels move by their empty header and resize from the bottom-right corner. Timeline sequence
tabs retain their sequence-opening, closing and horizontal reorder behavior; the header grip
moves the Timeline panel. Workspace saves retain floating groups alongside the legacy layout
JSON, and workspaces without floating data still load. Automation IDs and workspace commands
remain the integration boundary.
Tools and dock tabs expose their actual egui widget IDs, paint keyboard focus, and support
Tab/Shift-Tab traversal and Space/Enter activation. Control-injected keys can reach focused
widgets in a background window. The optional `--control-input-only` native launch mode isolates
automation from native widget/menu input; `ui.inspect` reports native and effective focus
separately. Focus and input-ownership regressions cover document/playhead preservation.
Compact monitor layouts separate the current and duration timecodes from zoom, resolution and
settings. Source video/audio drag choices retain their existing placement behavior. Transport
buttons use `craft-ui::layout::fit_toolbar`; commands beyond the available width remain available
through More, including their selected state and Source/Program routing. A scrolling fallback
retains all commands if even More cannot fit. `monitor_geometry` checks the painted readouts,
control bounds, overflow actions and Source drag payloads across monitor widths and themes.

View File

@ -10,6 +10,15 @@ pub struct Element {
pub id: String,
pub label: String,
pub rect: [f32; 4],
/// Present only when this entry represents a real egui widget, rather than a click alias.
#[serde(rename = "widgetId", skip_serializing_if = "Option::is_none")]
pub widget_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub focusable: Option<bool>,
#[serde(skip)]
pub(crate) response_id: Option<egui::Id>,
}
#[derive(Default)]
@ -21,7 +30,25 @@ 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()],
widget_id: None,
enabled: None,
focusable: None,
response_id: None,
});
}
/// Observe the widget without changing its accessibility role or input behavior.
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.widget_id = Some(format!("{:016x}", response.id.value()));
element.enabled = Some(response.enabled());
element.focusable = Some(response.sense.is_focusable());
element.response_id = Some(response.id);
}
}
pub fn begin_frame(&mut self) {
self.previous = std::mem::take(&mut self.elements);

View File

@ -308,7 +308,9 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
if req.method == "ui.panel.show" {
app.show_panel(panel);
} else {
app.ui.dock.close(panel);
if let Err(error) = app.apply_dock(craft_ui::docking::Action::Close { panel }) {
return err(&error);
}
}
ok(Value::Null)
}
@ -319,6 +321,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
Err(e) => return err(e),
};
let m = modifiers(p);
app.synthetic.push(egui::Event::ModifiersChanged(m));
app.synthetic.push(egui::Event::PointerMoved(pos));
if req.method == "ui.click" {
let button = match s("button") {
@ -332,6 +335,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
}
}
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
Outcome::AfterInput
}
"ui.drag" => {
@ -343,6 +347,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
};
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2);
let m = modifiers(p);
app.synthetic.push(egui::Event::ModifiersChanged(m));
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
for i in 1..=steps {
@ -350,6 +355,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f));
}
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
Outcome::AfterInput
}
"ui.scroll" => {
@ -359,6 +365,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
};
let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32;
let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32;
app.synthetic.push(egui::Event::ModifiersChanged(modifiers(p)));
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
@ -372,18 +379,22 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
pressed: false,
modifiers: Default::default(),
});
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
Outcome::AfterInput
}
"ui.key" => {
let Some(name) = s("key") else { return err("missing `key`") };
let Some((m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) };
let m = as_pressed(m | modifiers(p));
app.synthetic.push(egui::Event::ModifiersChanged(m));
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m });
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m });
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
Outcome::AfterInput
}
"ui.type" => {
let Some(text) = s("text") else { return err("missing `text`") };
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
app.synthetic.push(egui::Event::Text(text.to_string()));
app.synthetic.push(egui::Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
Outcome::AfterInput
@ -424,9 +435,30 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
ok(Value::Null)
}
"ui.focus" => {
// Explicit request: activate the app and take keyboard focus.
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
ok(Value::Null)
if p.is_null() || p.as_object().is_some_and(|object| object.is_empty()) {
// Existing explicit request: activate the app and take OS keyboard focus.
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
return ok(Value::Null);
}
let Some(object) = p.as_object().filter(|object| object.len() == 1 && object.contains_key("id")) else {
return err("ui.focus expects {} or {id: string}");
};
let Some(id) = object.get("id").and_then(Value::as_str) else { return err("ui.focus needs a string `id`") };
let Some(element) = app.auto.find(id) else { return err(format!("no element `{id}` (see ui.elements)")) };
let Some(widget_id) = element.response_id else { return err(format!("element `{id}` has no focusable widget")) };
let Some(response) = ctx.read_response(widget_id) else { return err(format!("widget `{id}` is not present")) };
if !response.enabled()
|| !response.sense.is_focusable()
|| !response.interact_rect.is_finite()
|| !response.interact_rect.is_positive()
|| !response.interact_rect.intersects(ctx.content_rect())
{
return err(format!("widget `{id}` is not enabled, visible and focusable"));
}
// A widget target never activates the native window. The next ui.inspect reports
// actual focus ownership separately from this request acknowledgement.
response.request_focus();
ok(json!({"focusRequested": id, "widgetId": format!("{:016x}", widget_id.value())}))
}
"app.quit" => {
// scripted quits don't wait on the save prompt; unsaved changes stay in the recovery journal
@ -457,10 +489,22 @@ fn merge(v: &mut Value, patch: &Value) {
pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value {
let size = ctx.content_rect().size();
let keyboard_focus = ctx.memory(|memory| memory.focused()).map(|id| {
let elements: Vec<_> = app.auto.previous.iter().filter(|element| element.response_id == Some(id)).collect();
json!({
"widgetId": format!("{:016x}", id.value()),
"elementIds": elements.iter().map(|element| element.id.as_str()).collect::<Vec<_>>(),
"label": elements.first().map(|element| element.label.as_str()),
"active": ctx.read_response(id).is_some_and(|response| response.has_focus()),
})
});
json!({
"window": [size.x, size.y],
"pixelsPerPoint": ctx.pixels_per_point(),
"fps": app.fps,
"keyboardFocus": keyboard_focus,
"input": {"mode": if app.control_input_only { "controlOnly" } else { "interactive" },
"nativeWindowFocused": app.native_window_focused, "effectiveWindowFocused": ctx.input(|input| input.focused)},
"ui": app.ui,
"sourcePlayback": {"playing": app.source_playback.clock.playing, "audioClock": app.source_playback.clock.audio_clock, "playhead": app.session.state.source_playhead.0},
"playback": {"playing": app.playback.playing, "speed": app.playback.speed, "loop": app.playback.looping, "audioClock": app.playback.audio_clock, "dropped": app.playback.meter.counts().1, "shown": app.playback.meter.counts().0, "preroll": app.playback.preroll.is_some()},

View File

@ -5,6 +5,8 @@
use egui::{Align2, Rect, Sense, Stroke, StrokeKind, 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;
@ -140,6 +142,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),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum DockNode {
/// `vertical`: children stacked top/bottom; else side by side.
@ -186,11 +198,24 @@ pub struct WorkspacePrefs {
pub struct SavedWorkspace {
pub name: String,
pub layout: serde_json::Value,
/// Kept raw for the same per-workspace failure isolation as the dock tree.
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub floating: serde_json::Value,
}
impl WorkspacePrefs {
pub fn load(path: &std::path::Path) -> Self {
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
use std::io::Read as _;
// Preferences are untrusted; bound reading before JSON can allocate from it.
const MAX_BYTES: u64 = 4 * 1024 * 1024;
let Some(bytes) = std::fs::File::open(path).ok().and_then(|file| {
let mut bytes = Vec::new();
file.take(MAX_BYTES + 1).read_to_end(&mut bytes).ok()?;
(bytes.len() as u64 <= MAX_BYTES).then_some(bytes)
}) else {
return Self::default();
};
serde_json::from_slice(&bytes).unwrap_or_default()
}
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
if let Some(d) = path.parent() {
@ -231,7 +256,55 @@ pub fn find(prefs: &WorkspacePrefs, query: &str) -> Option<String> {
/// The saved layout of a workspace: the user's saved version, else the built-in default.
pub fn saved_layout(prefs: &WorkspacePrefs, name: &str) -> DockNode {
prefs.saved.iter().find(|s| s.name == name).and_then(|s| serde_json::from_value(s.layout.clone()).ok()).unwrap_or_else(|| workspace(name))
saved_docking(prefs, name).root
}
pub fn saved_docking(prefs: &WorkspacePrefs, name: &str) -> Layout {
let saved = prefs.saved.iter().find(|saved| saved.name == name).and_then(|saved| {
let mut layout = Layout {
root: serde_json::from_value(saved.layout.clone()).ok()?,
floating: if saved.floating.is_null() { vec![] } else { serde_json::from_value(saved.floating.clone()).ok()? },
};
if layout.floating.is_empty() {
migrate_legacy_effects(&mut layout.root);
}
layout.validate().ok().map(|()| layout)
});
saved.unwrap_or_else(|| Layout { root: workspace(name), floating: vec![] })
}
/// Older Effects workspaces stored the same Effect Controls panel twice: selected beside
/// Source, and an inactive duplicate beside Essential Graphics. Remove only that known
/// duplicate shape; unrelated malformed or ambiguous saved layouts still fail validation.
fn migrate_legacy_effects(root: &mut DockNode) {
let Ok(shared) = crate::dock_shared::encode(root, &[]) else { return };
if shared.panels().iter().filter(|panel| ***panel == PanelKind::EffectControls).count() != 2 {
return;
}
fn selected_source(node: &DockNode) -> bool {
match node {
DockNode::Tabs { panels, active } => {
panels.contains(&PanelKind::Source) && panels.contains(&PanelKind::AudioClipMixer) && panels.get(*active) == Some(&PanelKind::EffectControls)
}
DockNode::Split { a, b, .. } => selected_source(a) || selected_source(b),
}
}
fn remove_inactive(node: &mut DockNode) {
match node {
DockNode::Tabs { panels, active } if panels.as_slice() == [PanelKind::EffectControls, PanelKind::EssentialGraphics] && *active == 1 => {
panels.remove(0);
*active = 0;
}
DockNode::Split { a, b, .. } => {
remove_inactive(a);
remove_inactive(b);
}
DockNode::Tabs { .. } => {}
}
}
if selected_source(root) {
remove_inactive(root);
}
}
/// The default layout of a named workspace.
@ -265,7 +338,7 @@ pub fn workspace(name: &str) -> DockNode {
hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer], 1), tabs(&[Program], 0)),
hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Info, Markers, History], 0), bottom_editing()),
),
vsplit(Ratio(0.5), tabs(&[Effects], 0), tabs(&[EffectControls, EssentialGraphics], 1)),
vsplit(Ratio(0.5), tabs(&[Effects], 0), tabs(&[EssentialGraphics], 0)),
),
"Audio" => hsplit(
FixedB(320.0),
@ -330,17 +403,7 @@ 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
}
}
}
crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Activate { panel: p })
}
pub fn is_visible(&self, p: PanelKind) -> bool {
match self {
@ -350,19 +413,7 @@ 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| matches!(n, DockNode::Tabs { panels, .. } if panels.is_empty());
if empty(a) {
*self = (**b).clone();
} else if empty(b) {
*self = (**a).clone();
}
} else if let DockNode::Tabs { panels, active } = self {
panels.retain(|x| *x != p);
*active = (*active).min(panels.len().saturating_sub(1));
}
crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Close { panel: p });
}
/// Put the Timeline panel back where the workspaces keep it (left of the audio meters) after
/// it was closed, so opening a sequence always has somewhere to show it.
@ -386,38 +437,44 @@ impl DockNode {
}
/// 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
}
}
}
}
/// The first group of the tree, even an empty one (closing the last panel leaves the
/// root as an empty group: a panel opened then must still have somewhere to go).
fn add_first(n: &mut DockNode, p: PanelKind) {
match n {
DockNode::Split { a, .. } => add_first(a, p),
DockNode::Tabs { panels, active } => {
panels.push(p);
*active = panels.len() - 1;
}
}
}
if !add(self, p, near) && !add(self, p, PanelKind::Project) && !add(self, p, PanelKind::Program) {
add_first(self, p);
}
let anchor = [near, PanelKind::Project, PanelKind::Program].into_iter().find(|panel| self.contains(*panel)).or_else(|| {
let mut panels = Vec::new();
self.panels(&mut panels);
panels.first().copied()
});
crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Open { panel: p, anchor });
}
}
/// A floating group retains the same panel identities as docked tabs.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Floating {
pub panels: Vec<PanelKind>,
pub active: usize,
/// [x, y, width, height] in logical points.
pub rect: [f32; 4],
}
/// App persistence shape; all mutations pass through the shared docking model.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Layout {
pub root: DockNode,
#[serde(default)]
pub floating: Vec<Floating>,
}
impl Layout {
pub fn apply(&mut self, action: craft_ui::docking::Action<PanelKind>) -> Result<(), String> {
crate::dock_shared::apply(self, action)
}
pub fn validate(&self) -> Result<(), String> {
crate::dock_shared::encode(&self.root, &self.floating)?.validate().map_err(|error| error.to_string())
}
pub fn contains(&self, panel: PanelKind) -> bool {
self.root.contains(panel) || self.floating.iter().any(|group| group.panels.contains(&panel))
}
}
@ -434,6 +491,7 @@ pub struct Group {
#[derive(Debug, Clone, PartialEq)]
pub enum DockAction {
Activate(PanelKind),
BeginDrag(PanelKind),
Focus(PanelKind),
Close(PanelKind),
PanelMenu(PanelKind, egui::Pos2),
@ -444,22 +502,40 @@ pub enum DockAction {
MoveSequence(u64, usize),
}
/// Size of a split's first child in `avail` points: the requested size, keeping both children at
/// least 20 points when there is room (and never more than `avail`). Must not panic for any size:
/// a tiny window (or browser canvas) gives splits less than 40 points.
fn split_first(size: SplitSize, avail: f32) -> f32 {
let want = match size {
SplitSize::Ratio(r) => avail * r,
SplitSize::FixedA(px) => px,
SplitSize::FixedB(px) => avail - px,
};
let lo = 20.0_f32.min(avail * 0.5);
let hi = (avail - 20.0).max(lo);
want.clamp(lo, hi)
}
/// 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 };
@ -467,45 +543,24 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
out.push(Group { path: path.to_string(), rect, content, panels: panels.clone(), active: *active });
}
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 = split_first(*size, avail);
let (ra, gutter, 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),
)
};
// gutter drag (a slightly larger hit area than the visible gap)
let hit = gutter.expand2(if *vertical { vec2(0.0, 3.0) } else { vec2(3.0, 0.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 id = egui::Id::new(("dock-gutter", path.to_string()));
let resp = ui.interact(hit, id, 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 });
let handle_style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus };
let label = if *vertical { tl!("Height:") } else { tl!("Width:") };
let response = ResizeHandle::new(id, axis, split.divider, label, handle_style).value(split.first_size(), 0.0..=split.available()).show(ui);
reg.add_response(&format!("dock.gutter.{path}"), &response.response, "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 });
}
if resp.dragged() {
let d = if *vertical { resp.drag_delta().y } else { resp.drag_delta().x };
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),
};
}
if resp.dragged() || resp.hovered() {
ui.painter().rect_filled(gutter, 0.0, t.focus.gamma_multiply(if resp.dragged() { 0.9 } else { 0.4 }));
}
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);
}
}
}
@ -526,11 +581,39 @@ pub fn draw_group_chrome(
t: &Tokens,
reg: &mut crate::automation::Registry,
) -> Vec<DockAction> {
draw_group_chrome_details(ui, g, focused, seqs, t, reg).actions
}
#[derive(Default)]
pub struct Chrome {
pub actions: Vec<DockAction>,
pub tabs: Vec<(PanelKind, Rect)>,
}
pub fn draw_group_chrome_details(
ui: &mut egui::Ui,
g: &Group,
focused: PanelKind,
seqs: &SeqTabs,
t: &Tokens,
reg: &mut crate::automation::Registry,
) -> Chrome {
let mut actions = Vec::new();
let mut tab_rects: Vec<(PanelKind, Rect)> = Vec::new();
let painter = ui.painter().clone();
painter.rect_filled(g.rect, t.radius, t.panel_bg);
let active_panel = g.panels.get(g.active).copied();
let compact = g.panels.len() == 1 && g.panels[0].compact();
// A narrow header grip moves the entire panel, including Timeline panels whose tabs
// separately reorder open sequences.
if let Some(panel) = active_panel {
let grip = Rect::from_min_size(g.rect.min, vec2(if compact { g.rect.width() } else { 10.0 }, if compact { 8.0 } else { t.tab_h }));
let response = ui.interact(grip, egui::Id::new(("panel-drag", g.path.clone())), Sense::drag());
reg.add_response(&format!("panel.drag.{}", panel.id()), &response, "Move panel");
if response.drag_started() {
actions.push(DockAction::BeginDrag(panel));
}
}
if compact {
// grip dots
let c = pos2(g.rect.center().x, g.rect.min.y + 4.0);
@ -592,10 +675,12 @@ pub fn draw_group_chrome(
if let Some(r) = more {
let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click());
let name = if g.panels.contains(&PanelKind::Timeline) { "timeline.tabs.more".to_string() } else { format!("panel.tabs.more.{}", g.path) };
reg.add(&name, r, "more tabs");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), "more tabs"));
reg.add_response(&name, &resp, "more tabs");
let c = if resp.hovered() { t.tab_text_active } else { t.tab_text };
icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, c);
icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, c);
paint_widget_focus(&painter, &resp, t);
if resp.clicked() {
let open = ui.ctx().data(|d| d.get_temp::<bool>(list_id)).unwrap_or(false);
ui.ctx().data_mut(|d| d.insert_temp(list_id, !open));
@ -616,8 +701,22 @@ pub fn draw_group_chrome(
// a sequence tab keeps its identity when the tabs are reordered under the pointer
let resp = match tab.seq {
Some(id) => ui.interact(tab_rect, egui::Id::new(("tab-seq", g.path.clone(), id)), Sense::click_and_drag()),
None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click()),
None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag()),
};
if tab.seq.is_none() && resp.drag_started() {
actions.push(DockAction::BeginDrag(*p));
}
if let Some((_, rect)) = tab_rects.iter_mut().find(|(panel, _)| panel == p) {
*rect = rect.union(tab_rect);
} else {
tab_rects.push((*p, tab_rect));
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), is_active, &tab.title));
ui.ctx().accesskit_node_builder(resp.id, |node| {
node.set_role(egui::accesskit::Role::Tab);
node.set_selected(is_active);
});
paint_widget_focus(&painter, &resp, t);
if let Some(id) = tab.seq {
seq_rects.push((id, tab_rect));
if resp.dragged() {
@ -625,10 +724,10 @@ pub fn draw_group_chrome(
}
}
if let Some(id) = tab.seq {
reg.add(&format!("timeline.tab.{id}"), tab_rect, &tab.title);
reg.add_response(&format!("timeline.tab.{id}"), &resp, &tab.title);
}
if tab.seq.is_none() || is_active {
reg.add(&format!("panel.tab.{}", p.id()), tab_rect, p.title());
reg.add_response(&format!("panel.tab.{}", p.id()), &resp, p.title());
}
let label_x = tab_rect.min.x + 8.0 + close_w;
let label_w = galley.size().x;
@ -639,10 +738,12 @@ pub fn draw_group_chrome(
// × closes the sequence's tab (the sequence stays in the project)
let cr = Rect::from_center_size(pos2(tab_rect.min.x + 10.0, text_y), vec2(8.0, 8.0));
let cresp = ui.interact(cr.expand(4.0), egui::Id::new(("tab-close", g.path.clone(), id)), Sense::click());
reg.add(&format!("timeline.tab.{id}.close"), cr.expand(4.0), "close sequence");
cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, cresp.enabled(), "close sequence"));
reg.add_response(&format!("timeline.tab.{id}.close"), &cresp, "close sequence");
let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text };
painter.line_segment([cr.left_top(), cr.right_bottom()], Stroke::new(1.2, cc));
painter.line_segment([cr.right_top(), cr.left_bottom()], Stroke::new(1.2, cc));
paint_widget_focus(&painter, &cresp, t);
if cresp.clicked() {
actions.push(DockAction::CloseSequence(id));
closed = true;
@ -651,12 +752,14 @@ pub fn draw_group_chrome(
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");
mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, mresp.enabled(), "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 };
icons::paint(&painter, Rect::from_center_size(mr.center(), vec2(16.0, 16.0)), Icon::Hamburger, mc);
// 1 pt underline spanning label + ≡, 23 pt below the frame top
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));
paint_widget_focus(&painter, &mresp, t);
if mresp.clicked() {
actions.push(DockAction::PanelMenu(*p, mr.left_bottom()));
}
@ -711,8 +814,8 @@ pub fn draw_group_chrome(
let label = if tab.active { format!("✓ {}", tab.title) } else { tab.title.clone() };
let resp = ui.button(label);
match tab.seq {
Some(id) => reg.add(&format!("timeline.tabs.list.{id}"), resp.rect, &tab.title),
None => reg.add(&format!("panel.tabs.list.{}", tab.panel.id()), resp.rect, &tab.title),
Some(id) => reg.add_response(&format!("timeline.tabs.list.{id}"), &resp, &tab.title),
None => reg.add_response(&format!("panel.tabs.list.{}", tab.panel.id()), &resp, &tab.title),
}
if resp.clicked() {
picked = Some(tab);
@ -744,7 +847,13 @@ pub fn draw_group_chrome(
{
actions.push(DockAction::Focus(p));
}
actions
Chrome { actions, tabs: tab_rects }
}
fn paint_widget_focus(painter: &egui::Painter, response: &egui::Response, tokens: &Tokens) {
if response.has_focus() {
painter.rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.5, tokens.focus), StrokeKind::Inside);
}
}
/// Placeholder body for panels that are not implemented yet.
@ -758,6 +867,9 @@ mod tests {
#[test]
fn split_first_never_panics_on_tiny_areas() {
let split_first = |size: SplitSize, avail: f32| {
split_rect(Rect::from_min_size(egui::Pos2::ZERO, vec2(avail, 100.0)), SplitAxis::Horizontal, size.shared(), 0.0, 20.0).unwrap().first_size()
};
for avail in [0.0, 1.0, 15.0, 20.0, 35.0, 39.9, 40.0, 100.0, 2000.0] {
for size in [
SplitSize::Ratio(0.0),

View File

@ -0,0 +1,113 @@
//! Convert the persisted workspace schema to shared docking transactions.
use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Node};
use craft_ui::layout::{SplitAxis, SplitSize as SharedSplitSize};
use crate::dock::{DockNode, Floating, Layout, PanelKind, SplitSize};
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::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>) -> Result<DockNode, String> {
Ok(match node {
Node::Tabs { panels, active } => DockNode::Tabs { panels, active },
Node::Stack { .. } => return Err("FilmCraft does not use stacked panels".into()),
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>) -> Result<Layout, String> {
Ok(Layout {
root: match layout.root {
Some(root) => decode_node(root)?,
None => 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.validate().map_err(|error| error.to_string())?;
check_last_docked(&shared, &action)?;
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 Ok(layout) = decode(shared) else { return false };
if encode(&layout.root, &[]).is_err() {
return false;
}
*root = layout.root;
true
}
/// Keep one docked destination available for dragging floating panels back into the workspace.
pub(crate) fn check_last_docked(layout: &SharedLayout<PanelKind>, action: &Action<PanelKind>) -> Result<(), String> {
let panel = match action {
Action::Float { panel, .. } => Some(panel),
Action::Move { panel, anchor, .. } if layout.floating.iter().any(|group| group.panels.contains(anchor)) => Some(panel),
_ => None,
};
if let Some(panel) = panel {
let docked = SharedLayout { root: layout.root.clone(), floating: vec![] };
if docked.contains(panel) && docked.panels().len() <= 1 {
return Err("the last docked panel must remain in the workspace".into());
}
}
Ok(())
}

View File

@ -0,0 +1,394 @@
//! FilmCraft's panel chrome and document tabs over the shared docking transactions.
use craft_ui::docking::{Action, DropTarget, Placement, Zone, resolve_drop};
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::{Value, json};
use crate::dock::{self, DockAction, DockNode, Group, PanelKind};
use crate::{FilmcraftApp, panels};
/// Single source for rendering and hit testing: saved offscreen positions remain
/// reachable, and every panel has enough room for its chrome.
fn floating_rect(group: &dock::Floating, screen: Rect) -> Rect {
let rect = Rect::from_min_size(pos2(group.rect[0], group.rect[1]), vec2(group.rect[2].max(160.0), group.rect[3].max(100.0)));
rect.translate(vec2(
(screen.min.x - rect.min.x).max(0.0) + (screen.max.x - 40.0 - rect.min.x).min(0.0),
(screen.min.y - rect.min.y).max(0.0) + (screen.max.y - 30.0 - rect.min.y).min(0.0),
))
}
fn drag_id() -> egui::Id {
egui::Id::new("dock-panel-drag")
}
pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option<Result<Value, String>> {
if id == "window.dockAction" {
return Some((|| {
let value = params.get("action").cloned().ok_or("missing docking action")?;
let action = serde_json::from_value(value).map_err(|error| format!("invalid docking action: {error}"))?;
app.apply_dock(action)?;
Ok(Value::Null)
})());
}
if !matches!(id, "window.dockPanel" | "window.floatPanel" | "window.closePanel") {
return None;
}
Some((|| {
let panel = match params.get("panel") {
None => app.ui.focused,
Some(value) => value.as_str().and_then(PanelKind::from_name).ok_or("unknown panel")?,
};
let action = match id {
"window.closePanel" => Action::Close { panel },
"window.floatPanel" => {
let rect = match params.get("rect") {
None => default_float_rect(ctx.content_rect()),
Some(value) => serde_json::from_value::<[f32; 4]>(value.clone()).map_err(|_| "rect must contain four finite numbers")?,
};
Action::Float { panel, rect }
}
_ => {
let anchor = params.get("anchor").and_then(Value::as_str).and_then(PanelKind::from_name).ok_or("a known anchor panel is required")?;
let zone_name = match params.get("zone") {
None => "center",
Some(value) => value.as_str().ok_or("zone must be a string")?,
};
let zone = match zone_name.to_ascii_lowercase().as_str() {
"left" => Zone::Left,
"right" => Zone::Right,
"top" => Zone::Top,
"bottom" => Zone::Bottom,
"center" | "tab" => Zone::Center,
_ => return Err("unknown docking zone".into()),
};
let placement = match params.get("before") {
Some(value) => Placement::Tab { before: Some(value.as_str().and_then(PanelKind::from_name).ok_or("unknown before panel")?) },
None => Placement::Split(zone),
};
Action::Move { panel, anchor, placement }
}
};
app.apply_dock(action)?;
Ok(json!({"panel": panel.id()}))
})())
}
pub(crate) fn default_float_rect(screen: Rect) -> [f32; 4] {
let center = screen.center();
[center.x - 210.0, center.y - 160.0, 420.0, 320.0]
}
impl FilmcraftApp {
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", json!({"action": value})).map(|_| ())
}
pub fn apply_dock(&mut self, action: Action<PanelKind>) -> Result<(), String> {
let relocating = matches!(&action, Action::Move { .. } | Action::Float { .. });
let reveal = match &action {
Action::Activate { panel } => Some(*panel),
_ => None,
};
let mut shared = crate::dock_shared::encode(&self.ui.dock, &self.ui.floating)?;
shared.validate().map_err(|error| error.to_string())?;
crate::dock_shared::check_last_docked(&shared, &action)?;
shared.apply(action).map_err(|error| error.to_string())?;
let layout = crate::dock_shared::decode(shared)?;
crate::dock_shared::encode(&layout.root, &layout.floating)?;
self.ui.dock = layout.root;
self.ui.floating = layout.floating;
if let Some(panel) = reveal {
self.pending_panel_raise = Some(panel);
}
if relocating
|| self.ui.keys.maximized.is_some_and(|panel| !self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel)))
{
self.ui.keys.maximized = None;
}
Ok(())
}
fn panel_body(&mut self, ui: &mut egui::Ui, panel: PanelKind, content: Rect) {
self.auto.add(&format!("panel.{}", panel.id()), content, panel.title());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", panel.id())));
child.set_clip_rect(content.intersect(ui.clip_rect()));
panels::show(self, &mut child, panel, content);
}
/// OS drag positions come from the host cursor hook, so egui pointer capture alone
/// cannot decide whether a Timeline is covered. Use the same geometry and layer
/// ordering as our renderer before any panel can dispatch an edit.
pub(crate) fn timeline_drop_target(&self, ctx: &egui::Context, pos: egui::Pos2) -> bool {
if !ctx.content_rect().contains(pos) {
return false;
}
if let Some(panel) =
self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel)))
{
return panel == PanelKind::Timeline;
}
for index in self.floating_order(ctx).into_iter().rev() {
if let Some(group) = self.ui.floating.get(index)
&& floating_rect(group, ctx.content_rect()).contains(pos)
{
return group.panels.get(group.active) == Some(&PanelKind::Timeline);
}
}
true
}
fn floating_order(&self, ctx: &egui::Context) -> Vec<usize> {
let layers = ctx.memory(|memory| memory.layer_ids().collect::<Vec<_>>());
let mut order: Vec<_> = (0..self.ui.floating.len()).collect();
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)
});
order
}
pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) {
let t = self.tokens;
let ctx = ui.ctx().clone();
if let Some(panel) = self.pending_panel_raise.take()
&& let Some(owner) = self.ui.floating.iter().find(|group| group.panels.contains(&panel)).and_then(|group| group.panels.first())
{
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", owner.id()))));
}
let maximized =
self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel)));
let mut dock = match maximized {
Some(panel) => DockNode::Tabs { panels: vec![panel], active: 0 },
None => std::mem::replace(&mut self.ui.dock, DockNode::Tabs { panels: vec![], active: 0 }),
};
let mut groups = vec![];
let mut layout_actions = Vec::new();
dock::layout_with_actions(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto, &mut layout_actions);
let seqs = dock::SeqTabs {
open: self
.session
.state
.open_sequences
.iter()
.filter_map(|id| {
self.session
.project
.item(*id)
.filter(|item| matches!(item.kind, filmcraft_project::ItemKind::Sequence(_)))
.map(|item| (id.0, item.name.clone()))
})
.collect(),
active: self.session.state.active_sequence.map(|id| id.0),
};
let mut actions = vec![];
let mut targets = vec![];
for group in &groups {
let chrome = dock::draw_group_chrome_details(ui, group, self.ui.focused, &seqs, &t, &mut self.auto);
if let Some(target) = target(group, chrome.tabs, true) {
targets.push(target);
}
actions.extend(chrome.actions);
}
if maximized.is_none() {
self.ui.dock = dock;
}
for group in &groups {
if let Some(panel) = group.panels.get(group.active).copied() {
self.panel_body(ui, panel, group.content);
}
}
let floating_order = self.floating_order(&ctx);
for index in floating_order.into_iter().filter(|_| maximized.is_none()) {
if let Some(target) = self.floating_group(&ctx, index, &seqs, &mut actions) {
targets.push(target);
}
}
for action in layout_actions {
let _ = self.dock_command(&ctx, action);
}
for action in actions {
match action {
DockAction::Activate(panel) => {
let _ = self.dock_command(&ctx, Action::Activate { panel });
}
DockAction::Close(panel) => {
let _ = self.dock_command(&ctx, Action::Close { panel });
}
DockAction::Focus(panel) => self.ui.focused = panel,
DockAction::BeginDrag(panel) => {
ctx.data_mut(|data| data.insert_temp(drag_id(), panel));
}
DockAction::OpenSequence(id) => {
let _ = self.dock_command(&ctx, Action::Activate { panel: PanelKind::Timeline });
if self.session.state.active_sequence.map(|item| item.0) != Some(id)
&& let Err(error) = self.session.execute("sequence.open", json!({"item": id}))
{
self.ui.status = error.to_string();
}
}
DockAction::CloseSequence(id) => {
if let Err(error) = self.session.execute("sequence.close", json!({"item": id})) {
self.ui.status = error.to_string();
}
}
DockAction::MoveSequence(id, index) => {
if let Err(error) = self.session.execute("sequence.moveTab", json!({"item": id, "index": index})) {
self.ui.status = error.to_string();
}
}
DockAction::PanelMenu(panel, pos) => {
let frame = ctx.cumulative_frame_nr();
ctx.data_mut(|data| {
data.insert_temp(egui::Id::new("panel-menu"), (panel, pos));
data.insert_temp(egui::Id::new("panel-menu-opened"), frame);
});
}
}
}
self.panel_drag(&ctx, &targets);
panels::panel_menu_popup(self, ui);
}
fn floating_group(&mut self, ctx: &egui::Context, index: usize, seqs: &dock::SeqTabs, actions: &mut Vec<DockAction>) -> Option<DropTarget<PanelKind>> {
let floating = self.ui.floating.get(index)?.clone();
let panel = *floating.panels.first()?;
let rect = floating_rect(&floating, ctx.content_rect());
let t = self.tokens;
let id = egui::Id::new(("floating-panel", panel.id()));
let mut output = None;
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).constrain(false).interactable(true).show(ctx, |ui| {
let (rect, _) = ui.allocate_exact_size(rect.size(), Sense::hover());
let compact = floating.panels.len() == 1 && panel.compact();
let header = if compact { 8.0 } else { t.tab_h };
let strip = Rect::from_min_size(rect.min, vec2(rect.width(), header));
let response = ui.interact(strip, id.with("move"), Sense::drag());
self.auto.add_response(&format!("panel.float.{index}.move"), &response, "Move floating panel");
let mut next = if response.dragged() { rect.translate(response.drag_delta()) } else { rect };
let group = Group {
path: format!("f{index}"),
rect,
content: Rect::from_min_max(pos2(rect.min.x, rect.min.y + header), rect.max),
panels: floating.panels.clone(),
active: floating.active,
};
let chrome = dock::draw_group_chrome_details(ui, &group, self.ui.focused, seqs, &t, &mut self.auto);
actions.extend(chrome.actions);
if let Some(active) = group.panels.get(group.active).copied() {
self.panel_body(ui, active, group.content);
}
ui.painter().rect_stroke(rect, t.radius, Stroke::new(1.0, t.focus), StrokeKind::Outside);
let grip = Rect::from_min_max(rect.max - vec2(14.0, 14.0), rect.max);
let response = ui.interact(grip, id.with("resize"), Sense::click_and_drag());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), tl!("Resize floating panel")));
self.auto.add_response(&format!("panel.float.{index}.resize"), &response, "Resize floating panel");
for step in 0..3 {
let offset = 4.0 + step as f32 * 4.0;
ui.painter()
.line_segment([pos2(rect.max.x - offset, rect.max.y - 2.0), pos2(rect.max.x - 2.0, rect.max.y - offset)], Stroke::new(1.0, t.text_faint));
}
if response.hovered() || response.dragged() {
ctx.set_cursor_icon(egui::CursorIcon::ResizeNwSe);
}
let delta = floating_resize_delta(ui, &response);
if response.has_focus() {
ui.painter().rect_stroke(grip, 2.0, Stroke::new(1.5, t.focus), StrokeKind::Inside);
}
if delta != egui::Vec2::ZERO {
next = Rect::from_min_size(next.min, (next.size() + delta).max(vec2(160.0, 100.0)));
}
if next != rect {
let _ = self.dock_command(ctx, Action::MoveFloating { panel, rect: [next.min.x, next.min.y, next.width(), next.height()] });
}
output = target(&group, chrome.tabs, false);
});
output
}
fn panel_drag(&mut self, ctx: &egui::Context, targets: &[DropTarget<PanelKind>]) {
let Some(panel) = ctx.data(|data| data.get_temp::<PanelKind>(drag_id())) else { return };
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(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel))) {
ctx.data_mut(|data| data.remove::<PanelKind>(drag_id()));
return;
}
let Some(pos) = ctx.pointer_latest_pos() else { return };
let force_float = ctx.input(|input| input.modifiers.command);
let proposal = (!force_float).then(|| resolve_drop(&panel, pos, targets)).flatten();
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop")));
let t = self.tokens;
if let Some(proposal) = &proposal {
painter.rect_filled(proposal.preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2));
painter.rect_stroke(proposal.preview.shrink(2.0), t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside);
if let Some(mark) = proposal.insertion {
painter.rect_filled(mark, 0.0, t.focus);
}
}
let title = painter.layout_no_wrap(panel.title().to_owned(), crate::theme::Tokens::ui(12.0), Color32::WHITE);
let ghost = Rect::from_min_size(pos + vec2(12.0, 10.0), title.size() + vec2(16.0, 8.0));
painter.rect_filled(ghost, 4.0, t.focus.gamma_multiply(0.85));
painter.galley(ghost.min + vec2(8.0, 4.0), title, Color32::WHITE);
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
ctx.request_repaint();
if ctx.input(|input| input.pointer.primary_released()) {
ctx.data_mut(|data| data.remove::<PanelKind>(drag_id()));
let action = match proposal {
Some(proposal) => Some(Action::Move { panel, anchor: proposal.anchor, placement: proposal.placement }),
None if force_float || !targets.iter().any(|target| target.rect.contains(pos)) => {
Some(Action::Float { panel, rect: [pos.x - 40.0, pos.y - 12.0, 420.0, 320.0] })
}
None => None,
};
if let Some(action) = action
&& self.dock_command(ctx, action).is_ok()
{
self.ui.focused = panel;
}
}
}
}
fn target(group: &Group, mut tabs: Vec<(PanelKind, Rect)>, allow_splits: bool) -> Option<DropTarget<PanelKind>> {
let strip = Rect::from_min_max(group.rect.min, pos2(group.rect.max.x, group.content.min.y));
// A compact grip still identifies the source group to the shared resolver.
for panel in &group.panels {
if !tabs.iter().any(|(candidate, _)| candidate == panel) {
tabs.push((*panel, Rect::NOTHING));
}
}
Some(DropTarget { anchor: *group.panels.get(group.active)?, rect: group.rect, tab_strip: Some(strip), tabs, allow_splits, accepts_tabs: true })
}
/// 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

@ -677,6 +677,7 @@
Extract (') Extraer (')
Export Frame: use Export mode (M6) Exportar fotograma: usa el modo Exportar (M6)
Button Editor Editor de botones
More transport controls Más controles de reproducción
Multi-Camera Record on Grabación multicámara activada
Multi-Camera Record off Grabación multicámara desactivada
{first} - Merged {first} - Combinado
@ -3551,6 +3552,11 @@
Composite on the GPU when available Componer en la GPU cuando esté disponible
Text to Speech Texto a voz
Edit Narration Editar narración
Dock Panel Acoplar panel
Undock Panel Desacoplar panel
Docking Action Acción de acoplamiento
Resize floating panel Cambiar tamaño del panel flotante
Project Notes Notas del proyecto
Get Project Notes Obtener notas del proyecto
Edit Project Notes Editar notas del proyecto

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -3528,6 +3528,9 @@
Edit Narration 编辑旁白
Clear Transition 清除过渡
Set Transition Duration 设置过渡持续时间
Resize floating panel 调整浮动面板大小
More transport controls 更多播放控制
Undock Panel 取消面板停靠
Project Notes 项目备注
Get Project Notes 获取项目备注
Edit Project Notes 编辑项目备注

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -36,6 +36,8 @@ pub mod control;
pub mod crash;
pub mod credits;
pub mod dock;
mod dock_shared;
mod dock_ui;
pub mod frames;
pub mod header;
pub mod i18n;
@ -265,6 +267,14 @@ pub struct FilmcraftApp {
/// Named textures (monitors, thumbnails) with the key they show.
textures: HashMap<String, (FrameKey, TextureHandle)>,
control_rx: Option<Receiver<ControlRequest>>,
/// One explicit panel reveal; consumed by the custom renderer, never repeated per frame.
pub(crate) pending_panel_raise: Option<PanelKind>,
/// Explicit automation mode: native widget input and native menu commands are excluded.
pub(crate) control_input_only: bool,
/// Backend focus before synthetic input changes egui's effective focus for this frame.
pub(crate) native_window_focused: Option<bool>,
/// Modifiers owned by an in-flight control gesture, carried between its input frames.
synthetic_modifiers: egui::Modifiers,
/// Requests waiting for the UI to show an element: (request, give-up time).
deferred: Vec<(ControlRequest, f64)>,
last_ui_time: f64,
@ -502,6 +512,10 @@ impl FilmcraftApp {
auto: Default::default(),
textures: HashMap::new(),
control_rx: None,
pending_panel_raise: None,
control_input_only: false,
native_window_focused: None,
synthetic_modifiers: egui::Modifiers::NONE,
deferred: Vec::new(),
last_ui_time: 0.0,
synthetic: Vec::new(),
@ -550,6 +564,13 @@ impl FilmcraftApp {
self
}
/// Isolate an automation-owned app from native keyboard, pointer, drop and menu input.
/// Window close/quit and screenshot delivery remain available.
pub fn with_control_input_only(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_input_only = true;
self.with_control(rx)
}
pub fn services(&self) -> Arc<dyn Services> {
self.session.services.clone()
}
@ -760,9 +781,11 @@ impl FilmcraftApp {
pub fn set_workspace(&mut self, name: &str) {
self.ui.workspace = name.to_string();
// a workspace is shown whole: no frame stays maximized over it
let layout = dock::saved_docking(&self.workspaces, name);
self.ui.dock = layout.root;
self.ui.floating = layout.floating;
self.pending_panel_raise = None;
self.ui.keys.maximized = None;
self.ui.dock = dock::saved_layout(&self.workspaces, name);
if self.workspaces.current != name {
let mut next = self.workspaces.clone();
next.current = name.to_string();
@ -776,10 +799,10 @@ impl FilmcraftApp {
}
pub fn show_panel(&mut self, p: PanelKind) {
if p == PanelKind::Timeline {
if p == PanelKind::Timeline && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) {
self.ui.dock.restore_timeline();
}
if !self.ui.dock.contains(p) {
if !self.ui.dock.contains(p) && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) {
let near = match p {
// a side panel beside Properties (or Essential Sound), never over the Program monitor
PanelKind::TextToSpeech => [PanelKind::Properties, PanelKind::EssentialSound, PanelKind::EssentialGraphics]
@ -800,7 +823,7 @@ impl FilmcraftApp {
};
self.ui.dock.open_near(p, near);
}
self.ui.dock.activate(p);
let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: p });
self.ui.focused = p;
}
@ -1602,11 +1625,13 @@ impl FilmcraftApp {
filmcraft_engine::Event::OpenSequence(_) => {
// show the sequence with its own view (or fitted, the first time)
self.timeline_view_of = None;
self.ui.dock.restore_timeline();
self.ui.dock.activate(PanelKind::Timeline);
if !self.ui.floating.iter().any(|group| group.panels.contains(&PanelKind::Timeline)) {
self.ui.dock.restore_timeline();
}
let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Timeline });
}
filmcraft_engine::Event::OpenSource(_) => {
self.ui.dock.activate(PanelKind::Source);
let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Source });
}
filmcraft_engine::Event::RevealInProject(item) => self.reveal_in_project(item),
filmcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))),
@ -1617,6 +1642,9 @@ impl FilmcraftApp {
self.handle_drops(&ctx);
if let Some(rx) = self.command_inbox.take() {
while let Ok(id) = rx.try_recv() {
if self.control_input_only {
continue;
}
if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) {
self.ui.status = e;
}
@ -1760,79 +1788,6 @@ impl FilmcraftApp {
}
.to_string()
}
fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) {
let t = self.tokens;
// Maximize or Restore Frame (` / Shift+`): the maximized panel fills the dock area.
let maximized = self.ui.keys.maximized.filter(|p| self.ui.dock.contains(*p));
let mut dock = match maximized {
Some(p) => dock::DockNode::Tabs { panels: vec![p], active: 0 },
None => std::mem::replace(&mut self.ui.dock, 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 actions = Vec::new();
let seqs = dock::SeqTabs {
open: self
.session
.state
.open_sequences
.iter()
.filter_map(|id| {
self.session.project.item(*id).filter(|i| matches!(i.kind, filmcraft_project::ItemKind::Sequence(_))).map(|i| (id.0, i.name.clone()))
})
.collect(),
active: self.session.state.active_sequence.map(|i| i.0),
};
for g in &groups {
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &seqs, &t, &mut self.auto));
}
if maximized.is_none() {
self.ui.dock = dock;
}
for g in &groups {
let Some(p) = g.panels.get(g.active).copied() else { continue };
self.auto.add(&format!("panel.{}", p.id()), g.content, p.title());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(g.content).id_salt(("panel", p.id())));
child.set_clip_rect(g.content);
panels::show(self, &mut child, p, g.content);
}
for a in actions {
match a {
dock::DockAction::Activate(p) => {
self.ui.dock.activate(p);
}
dock::DockAction::Focus(p) => self.ui.focused = p,
dock::DockAction::Close(p) => self.ui.dock.close(p),
dock::DockAction::OpenSequence(id) => {
if self.session.state.active_sequence.map(|i| i.0) != Some(id)
&& let Err(e) = self.session.execute("sequence.open", json!({"item": id}))
{
self.ui.status = e.to_string();
}
}
dock::DockAction::CloseSequence(id) => {
if let Err(e) = self.session.execute("sequence.close", json!({"item": id})) {
self.ui.status = e.to_string();
}
}
dock::DockAction::MoveSequence(id, index) => {
if let Err(e) = self.session.execute("sequence.moveTab", json!({"item": id, "index": index})) {
self.ui.status = e.to_string();
}
}
dock::DockAction::PanelMenu(p, pos) => {
// (with the frame it opened in: the click that opens it is not a click elsewhere)
let frame = ui.ctx().cumulative_frame_nr();
ui.ctx().data_mut(|d| {
d.insert_temp(egui::Id::new("panel-menu"), (p, pos));
d.insert_temp(egui::Id::new("panel-menu-opened"), frame);
});
}
}
}
panels::panel_menu_popup(self, ui);
}
}
impl FilmcraftApp {
@ -1874,6 +1829,21 @@ impl FilmcraftApp {
impl eframe::App for FilmcraftApp {
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
self.native_window_focused = Some(raw_input.focused);
if self.control_input_only {
// Preserve screenshot responses and all viewport lifecycle/geometry metadata.
raw_input.events.retain(|event| matches!(event, egui::Event::Screenshot { .. }));
raw_input.hovered_files.clear();
raw_input.dropped_files.clear();
}
if self.control_input_only || !self.synthetic.is_empty() {
// Native modifiers cannot replace those requested by a control gesture. A queued
// reset runs in the frame after release, so clicked() still sees Shift/Alt.
raw_input.events.push(egui::Event::ModifiersChanged(self.synthetic_modifiers));
// Widget keyboard handling requires effective egui focus. Control input must work
// in a background window without activating it or taking the user's OS keyboard.
raw_input.focused = true;
}
if !self.synthetic.is_empty() {
// Pointer events go one per frame so egui sees press → moves → release as a real drag
// (all in one frame reads as a click); key/text runs go together up to a key release.
@ -1886,20 +1856,11 @@ impl eframe::App for FilmcraftApp {
.position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. }))
.map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 })
};
// A press's modifiers go down before it and up after its release, as real keys would,
// so a drag that egui recognises frames after the press still sees Shift / Ctrl.
for e in self.synthetic.drain(..n) {
match e {
egui::Event::PointerButton { pressed: true, modifiers, .. } if !modifiers.is_none() => {
raw_input.events.push(egui::Event::ModifiersChanged(modifiers));
raw_input.events.push(e);
}
egui::Event::PointerButton { pressed: false, modifiers, .. } if !modifiers.is_none() => {
raw_input.events.push(e);
raw_input.events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
}
_ => raw_input.events.push(e),
for event in self.synthetic.drain(..n) {
if let egui::Event::ModifiersChanged(modifiers) = event {
self.synthetic_modifiers = modifiers;
}
raw_input.events.push(event);
}
}
}

View File

@ -22,6 +22,10 @@ macro_rules! uic {
}
pub const UI_COMMANDS: &[UiCommand] = &[
uic!("window.dockAction", "Docking Action", [], None),
uic!("window.dockPanel", "Dock Panel", [], None),
uic!("window.floatPanel", "Undock Panel", [], None),
uic!("window.closePanel", "Close Panel", [], None),
uic!("app.language.english", "English", ["Edit", "Language"], None),
uic!("app.language.japanese", "日本語", ["Edit", "Language"], None),
uic!("app.language.spanish", "Español", ["Edit", "Language"], None),
@ -234,6 +238,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
// the engine's counters plus playback, frame workers and UI timings
return Ok(crate::perf::stats(app));
}
if let Some(result) = crate::dock_ui::route(app, ctx, id, &params) {
return result;
}
if let Some(rest) = id.strip_prefix("window.panel.") {
let p = PanelKind::from_name(rest).ok_or_else(|| format!("unknown panel `{rest}`"))?;
app.show_panel(p);

View File

@ -235,10 +235,10 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val
/// Maximize `p`, or restore the layout when a panel is maximized (also the panel menu's Maximize /
/// Restore Frame). A maximized panel that has since been closed counts as restored.
pub(crate) fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value {
let current = app.ui.keys.maximized.filter(|q| app.ui.dock.contains(*q));
let current = app.ui.keys.maximized.filter(|q| app.ui.dock.contains(*q) || app.ui.floating.iter().any(|g| g.panels.contains(q)));
app.ui.keys.maximized = if current.is_some() { None } else { Some(p) };
if let Some(p) = app.ui.keys.maximized {
app.ui.dock.activate(p);
let _ = app.apply_dock(craft_ui::docking::Action::Activate { panel: p });
app.ui.focused = p;
}
json!({"maximized": app.ui.keys.maximized.map(|p| p.title())})
@ -247,7 +247,7 @@ pub(crate) fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value {
/// The visible panel under the pointer (from last frame's automation rects).
fn panel_under_pointer(app: &FilmcraftApp, ctx: &egui::Context) -> Option<PanelKind> {
let pos = ctx.input(|i| i.pointer.latest_pos())?;
app.auto.query("panel.").into_iter().find_map(|e| {
app.auto.query("panel.").into_iter().rev().find_map(|e| {
let r = egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]));
r.contains(pos).then(|| PanelKind::from_name(&e.id["panel.".len()..])).flatten()
})
@ -257,7 +257,9 @@ fn panel_under_pointer(app: &FilmcraftApp, ctx: &egui::Context) -> Option<PanelK
fn visible_panels(app: &FilmcraftApp) -> Vec<PanelKind> {
let mut all = Vec::new();
app.ui.dock.panels(&mut all);
all.into_iter().filter(|p| app.ui.dock.is_visible(*p) && !p.compact()).collect()
let mut visible: Vec<_> = all.into_iter().filter(|p| app.ui.dock.is_visible(*p) && !p.compact()).collect();
visible.extend(app.ui.floating.iter().filter_map(|group| group.panels.get(group.active).copied()).filter(|panel| !panel.compact()));
visible
}
fn cycle_panel(app: &mut FilmcraftApp, next: bool) -> Value {

View File

@ -246,62 +246,71 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
let mut close = false;
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(190.0);
// the Timeline's tabs are its open sequences: Close Panel closes the active one and
// keeps the panel (Premiere's wording and behaviour)
if p == PanelKind::Timeline && app.session.state.active_sequence.is_some() {
let r = ui.button(tl!("Close Panel"));
app.auto.add("panel.menu.Timeline.close", r.rect, "Close Panel");
if r.clicked() {
let _ = app.session.execute("sequence.close", serde_json::json!({}));
close = true;
}
let r = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new(tl!("Close Other Timeline Panels")));
app.auto.add("panel.menu.Timeline.closeOthers", r.rect, "Close Other Timeline Panels");
if r.clicked() {
let _ = app.session.execute("sequence.closeOthers", serde_json::json!({}));
close = true;
}
} else if ui.button(tl!("Close Panel")).clicked() {
app.ui.dock.close(p);
close = true;
}
// the same frame state as ` / Shift+`: the dock tree is kept, so Restore brings it back
let maximized = app.ui.keys.maximized.is_some_and(|q| app.ui.dock.contains(q));
let r = ui.button(if maximized { tl!("Restore Frame") } else { tl!("Maximize Frame") });
app.auto.add(&format!("panel.menu.{}.maximize", p.id()), r.rect, if maximized { "Restore Frame" } else { "Maximize Frame" });
if r.clicked() {
keyboard::toggle_maximize(app, p);
close = true;
}
if ui.button(tl!("Restore Workspace")).clicked() {
let w = app.ui.workspace.clone();
app.set_workspace(&w);
close = true;
}
if p == PanelKind::Timeline {
ui.separator();
let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new(tl!("Reveal Sequence in Project")));
app.auto.add("panel.menu.Timeline.revealSequence", r.rect, "Reveal Sequence in Project");
if r.clicked() {
let _ = app.session.execute("sequence.revealInProject", serde_json::json!({}));
close = true;
}
ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails"));
ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms"));
}
if p == PanelKind::Project {
ui.separator();
close |= project::panel_menu(app, ui);
}
if p == PanelKind::MediaBrowser {
ui.separator();
close |= media_browser::panel_menu(app, ui);
}
if p == PanelKind::Source {
ui.separator();
close |= monitor::source_panel_menu(app, ui);
}
let scroll =
egui::ScrollArea::vertical().id_salt("panel-menu-scroll").max_height((ui.ctx().content_rect().height() - 16.0).max(0.0)).show(ui, |ui| {
ui.set_min_width(190.0);
// the Timeline's tabs are its open sequences: Close Panel closes the active one and
// keeps the panel (Premiere's wording and behaviour)
if p == PanelKind::Timeline && app.session.state.active_sequence.is_some() {
let r = ui.button(tl!("Close Panel"));
app.auto.add("panel.menu.Timeline.close", r.rect, "Close Panel");
if r.clicked() {
let _ = app.session.execute("sequence.close", serde_json::json!({}));
close = true;
}
let r = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new(tl!("Close Other Timeline Panels")));
app.auto.add("panel.menu.Timeline.closeOthers", r.rect, "Close Other Timeline Panels");
if r.clicked() {
let _ = app.session.execute("sequence.closeOthers", serde_json::json!({}));
close = true;
}
} else if ui.button(tl!("Close Panel")).clicked() {
let _ = crate::menus::invoke(app, ui.ctx(), "window.closePanel", serde_json::json!({"panel": p.id()}));
close = true;
}
let undock = ui.button(tl!("Undock Panel"));
app.auto.add_response(&format!("panel.menu.{}.undock", p.id()), &undock, "Undock Panel");
if undock.clicked() {
let _ = crate::menus::invoke(app, ui.ctx(), "window.floatPanel", serde_json::json!({"panel": p.id()}));
close = true;
}
let maximized = app.ui.keys.maximized.is_some_and(|q| app.ui.dock.contains(q) || app.ui.floating.iter().any(|g| g.panels.contains(&q)));
let r = ui.button(if maximized { tl!("Restore Frame") } else { tl!("Maximize Frame") });
app.auto.add(&format!("panel.menu.{}.maximize", p.id()), r.rect, if maximized { "Restore Frame" } else { "Maximize Frame" });
if r.clicked() {
keyboard::toggle_maximize(app, p);
close = true;
}
if ui.button(tl!("Restore Workspace")).clicked() {
let w = app.ui.workspace.clone();
app.set_workspace(&w);
close = true;
}
if p == PanelKind::Timeline {
ui.separator();
let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new(tl!("Reveal Sequence in Project")));
app.auto.add("panel.menu.Timeline.revealSequence", r.rect, "Reveal Sequence in Project");
if r.clicked() {
let _ = app.session.execute("sequence.revealInProject", serde_json::json!({}));
close = true;
}
ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails"));
ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms"));
}
if p == PanelKind::Project {
ui.separator();
close |= project::panel_menu(app, ui);
}
if p == PanelKind::MediaBrowser {
ui.separator();
close |= media_browser::panel_menu(app, ui);
}
if p == PanelKind::Source {
ui.separator();
close |= monitor::source_panel_menu(app, ui);
}
});
app.auto.add("panel.menu.scroll", scroll.inner_rect, "Panel menu");
});
});
// A click elsewhere or Escape closes the menu. The click that opened it is over the tab, not

View File

@ -28,7 +28,10 @@ pub enum Which {
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let controls_h = 28.0 + 24.0 + 36.0;
let stacked_controls = rect.width() < 540.0;
let separate_source_drag = which == Which::Source && stacked_controls && rect.width() < 310.0;
let source_drag_h = if separate_source_drag { 26.0 } else { 0.0 };
let controls_h = 28.0 + 24.0 + 36.0 + if stacked_controls { 26.0 } else { 0.0 } + source_drag_h;
let video_area = Rect::from_min_max(rect.min + vec2(4.0, 4.0), pos2(rect.max.x - 4.0, rect.max.y - controls_h));
// the Source monitor's time ruler starts at `origin` (a subclip restricted to its range)
let mut origin = Tick::ZERO;
@ -329,8 +332,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
app.auto.add(&format!("{prefix}.timecode"), tc_rect, &tc);
let dur_tc = format_time(mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration) - range_start, rate, drop_frame, TimeDisplay::Timecode, 48000);
ui.painter().text(pos2(row1.max.x, row1.center().y), Align2::RIGHT_CENTER, &dur_tc, Tokens::timecode(), t.text_dim);
// zoom + resolution dropdowns centred-ish
let zr = Rect::from_min_size(pos2(row1.min.x + 116.0, row1.min.y), vec2(70.0, 24.0));
// At compact widths, timecodes and view settings need separate rows.
let settings_y = row1.min.y + if stacked_controls { 26.0 } else { 0.0 };
let zoom_x = if stacked_controls && which == Which::Source && !separate_source_drag {
row1.min.x
} else if stacked_controls {
row1.center().x - 83.0
} else {
row1.min.x + 116.0
};
let zr = Rect::from_min_size(pos2(zoom_x, settings_y), vec2(70.0, 24.0));
let zoom_label = match mv.zoom {
None => tl!("Fit").to_string(),
Some(z) => format!("{}%", (z * 100.0).round() as i32),
@ -339,9 +350,19 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
app.auto.add(&format!("{prefix}.zoom"), zr, "Select Zoom Level");
monitor_view::zoom_menu(app, &zresp, which);
let res = mv.res;
let rr = Rect::from_min_size(pos2(row1.max.x - 196.0, row1.min.y), vec2(62.0, 24.0));
let resolution_x = if stacked_controls && which == Which::Source && !separate_source_drag {
row1.max.x - 90.0
} else if stacked_controls {
zr.max.x + 6.0
} else {
row1.max.x - 196.0
};
let rr = Rect::from_min_size(pos2(resolution_x, settings_y), vec2(62.0, 24.0));
if which == Which::Source && rr.min.x > zr.max.x + 12.0 {
crate::panels::source_drag::controls(app, ui, Rect::from_min_max(pos2(zr.max.x + 6.0, row1.min.y), pos2(rr.min.x - 6.0, row1.min.y + 24.0)));
crate::panels::source_drag::controls(app, ui, Rect::from_min_max(pos2(zr.max.x + 6.0, settings_y), pos2(rr.min.x - 6.0, settings_y + 24.0)));
}
if separate_source_drag {
crate::panels::source_drag::controls(app, ui, Rect::from_min_size(pos2(row1.center().x - 52.0, settings_y + 26.0), vec2(104.0, 24.0)));
}
let rresp = crate::widgets::dropdown_text(ui, rr, crate::i18n::t(res.label()), &t, egui::Id::new((prefix, "res")));
app.auto.add(&format!("{prefix}.resolution"), rr, "Select Playback Resolution");
@ -352,7 +373,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
}
}
});
let wr = Rect::from_min_size(pos2(rr.max.x + 6.0, row1.min.y + 1.0), vec2(22.0, 22.0));
let wr = Rect::from_min_size(pos2(rr.max.x + 6.0, settings_y + 1.0), vec2(22.0, 22.0));
let wresp = ui.interact(wr, egui::Id::new((prefix, "wrench")), Sense::click());
icons::paint(ui.painter(), wr.shrink(4.0), Icon::Wrench, if wresp.hovered() { t.tab_text_active } else { t.icon });
app.auto.add(&format!("{prefix}.settings"), wr, "Settings");
@ -408,7 +429,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
}
});
// ---- mini timeline / scrub bar
let bar = Rect::from_min_size(pos2(rect.min.x + 14.0, row1.max.y + 2.0), vec2(rect.width() - 28.0, 22.0));
let bar = Rect::from_min_size(pos2(rect.min.x + 14.0, settings_y + 28.0 + source_drag_h), vec2(rect.width() - 28.0, 22.0));
mini_timeline(app, ui, bar, which, origin, time, duration, rate, mark_in, mark_out);
// ---- transport buttons
@ -747,79 +768,141 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which)
};
let mut buttons = buttons;
if !src {
// Optional control, discoverable through the editor without changing existing layouts.
buttons.push((Icon::Loop, "playback.loop", tl!("Loop")));
}
let hidden = monitor_view::view(app, which).transport_hidden.clone();
let bw = 30.0;
let total = buttons.iter().filter(|(_, cmd, _)| transport_visible(&hidden, cmd)).count() as f32 * bw;
let mut x = row.center().x - total / 2.0;
let ctx = ui.ctx().clone();
let all_buttons = buttons.clone();
buttons.retain(|(_, command, _)| transport_visible(&hidden, command));
let prefix = if src { "source" } else { "program" };
for &(icon, cmd, tip) in &buttons {
if !transport_visible(&hidden, cmd) {
continue;
}
let r = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(bw - 2.0, 26.0));
let resp = ui.interact(r, egui::Id::new((prefix, cmd)), Sense::click()).on_hover_text(tip);
app.auto.add(&format!("{prefix}.transport.{cmd}"), r, tip);
let is_play = cmd.ends_with("play") || cmd == "playback.toggle";
if resp.hovered() {
ui.painter().rect_filled(r, 4.0, t.hover);
}
let sz = if is_play { 16.0 } else { 14.0 };
let on = cmd == "media.toggleProxies" && app.session.media.use_proxies();
let col = if on {
t.accent
} else if resp.hovered() {
t.tab_text_active
} else {
t.icon
};
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(sz, sz)), icon, col);
if resp.clicked() {
let r = match cmd {
"src.markIn" => app.session.execute("markers.markIn", json!({"target": "source"})).map_err(|e| e.to_string()),
"src.markOut" => app.session.execute("markers.markOut", json!({"target": "source"})).map_err(|e| e.to_string()),
"src.goIn" | "src.goOut" | "src.stepBack" | "src.stepFwd" => {
source_nav(app, cmd);
Ok(serde_json::Value::Null)
if row.width() >= 30.0 {
// Keep the existing button-editor affordance only when it has its own space.
// Customize this monitor's transport separately from the other monitor.
let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 4.0), vec2(22.0, 22.0));
let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor"));
app.auto.add(&format!("{prefix}.transport.editor"), r, "Button Editor");
icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim });
let mut edited_hidden = hidden.clone();
let editor_width = (ui.ctx().content_rect().width() - 10.0).clamp(1.0, 225.0);
let editor_height = (ui.ctx().content_rect().height() - 16.0).max(1.0);
egui::Popup::menu(&resp).width(editor_width).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.set_width(editor_width);
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
let scroll = egui::ScrollArea::vertical().id_salt((prefix, "editor-scroll")).max_height(editor_height).show(ui, |ui| {
for &(_, cmd, tip) in &all_buttons {
let mut visible = transport_visible(&edited_hidden, cmd);
let checkbox = ui.checkbox(&mut visible, tip);
app.auto.add(&format!("{prefix}.transport.editor.{cmd}"), checkbox.rect, tip);
if checkbox.changed() {
set_transport_visible(&mut edited_hidden, cmd, visible);
}
}
"exportFrame" => crate::menus::invoke(app, &ctx, "file.exportFrame", json!({"monitor": if src { "source" } else { "program" }})),
c => crate::menus::invoke(app, &ctx, c, json!({"monitor": if src { "source" } else { "program" }})),
};
if let Err(e) = r {
app.ui.status = e;
}
ui.separator();
let reset = ui.button(tl!("Reset"));
app.auto.add(&format!("{prefix}.transport.editor.reset"), reset.rect, "Reset");
if reset.clicked() {
edited_hidden = crate::state::MonitorView::default().transport_hidden;
}
});
app.auto.add(&format!("{prefix}.transport.editor.scroll"), scroll.inner_rect, "Button editor");
});
if edited_hidden != hidden {
monitor_view::view_mut(app, which).transport_hidden = edited_hidden;
}
x += bw;
}
// Customize this monitor's transport separately from the other monitor.
let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 4.0), vec2(22.0, 22.0));
let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor"));
app.auto.add(&format!("{prefix}.transport.editor"), r, "Button Editor");
icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim });
let mut edited_hidden = hidden.clone();
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.set_min_width(225.0);
for &(_, cmd, tip) in &buttons {
let mut visible = transport_visible(&edited_hidden, cmd);
let checkbox = ui.checkbox(&mut visible, tip);
app.auto.add(&format!("{prefix}.transport.editor.{cmd}"), checkbox.rect, tip);
if checkbox.changed() {
set_transport_visible(&mut edited_hidden, cmd, visible);
let available = (row.width() - 46.0).max(0.0);
let widths = vec![28.0; buttons.len()];
let Some(fit) = craft_ui::layout::fit_toolbar(&widths, available, 2.0, 28.0) else {
ui.scope_builder(egui::UiBuilder::new().max_rect(Rect::from_min_max(row.min, pos2((row.right() - 30.0).max(row.left()), row.bottom()))), |ui| {
egui::ScrollArea::horizontal().id_salt((prefix, "transport-scroll")).show(ui, |ui| {
ui.horizontal(|ui| {
for (icon, command, label) in &buttons {
let (rect, _) = ui.allocate_exact_size(vec2(28.0, 26.0), Sense::hover());
if transport_button(app, ui, rect, prefix, *icon, command, label) {
invoke_transport(app, ui.ctx(), which, command);
}
}
});
});
});
return;
};
let total = fit.shown.len() as f32 * 30.0 - 2.0 + if fit.overflow.is_empty() { 0.0 } else { 30.0 };
let mut x = row.center().x - 15.0 - total.max(28.0) / 2.0;
for (index, _) in &fit.shown {
let Some((icon, command, label)) = buttons.get(*index) else { continue };
let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(28.0, 26.0));
if transport_button(app, ui, rect, prefix, *icon, command, label) {
invoke_transport(app, ui.ctx(), which, command);
}
x += 30.0;
}
if !fit.overflow.is_empty() {
let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(28.0, 26.0));
let response = ui.interact(rect, egui::Id::new((prefix, "transport-more")), Sense::click()).on_hover_text(tl!("More transport controls"));
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("More transport controls")));
if response.hovered() {
ui.painter().rect_filled(rect, 4.0, t.hover);
}
for offset in [-5.0, 0.0, 5.0] {
ui.painter().circle_filled(rect.center() + vec2(offset, 0.0), 1.5, t.icon);
}
if response.has_focus() {
ui.painter().rect_stroke(rect.shrink(2.0), 2.0, Stroke::new(1.0, t.focus), StrokeKind::Inside);
}
app.auto.add_response(&format!("{prefix}.transport.more"), &response, "More transport controls");
egui::Popup::menu(&response).show(|ui| {
for index in &fit.overflow {
let Some((_, command, label)) = buttons.get(*index) else { continue };
let selected = match *command {
"media.toggleProxies" => Some(app.session.media.use_proxies()),
"playback.toggle" => Some(app.playback.playing),
"source.playback.toggle" => Some(app.source_playback.clock.playing),
_ => None,
};
let response = if let Some(selected) = selected { ui.selectable_label(selected, *label) } else { ui.button(*label) };
app.auto.add_response(&format!("{prefix}.transport.{command}"), &response, label);
if response.clicked() {
invoke_transport(app, ui.ctx(), which, command);
ui.close();
}
}
});
}
}
fn transport_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, prefix: &str, icon: Icon, command: &str, label: &str) -> bool {
let t = app.tokens;
let response = ui.interact(rect, egui::Id::new((prefix, command)), Sense::click()).on_hover_text(label);
app.auto.add_response(&format!("{prefix}.transport.{command}"), &response, label);
if response.hovered() {
ui.painter().rect_filled(rect, 4.0, t.hover);
}
let size = if matches!(command, "playback.toggle" | "source.playback.toggle") { 16.0 } else { 14.0 };
let color = if command == "media.toggleProxies" && app.session.media.use_proxies() {
t.accent
} else if response.hovered() {
t.tab_text_active
} else {
t.icon
};
icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(size, size)), icon, color);
response.clicked()
}
fn invoke_transport(app: &mut FilmcraftApp, ctx: &egui::Context, which: Which, command: &str) {
let source = which == Which::Source;
let result = match command {
"src.markIn" => app.session.execute("markers.markIn", json!({"target": "source"})).map_err(|error| error.to_string()),
"src.markOut" => app.session.execute("markers.markOut", json!({"target": "source"})).map_err(|error| error.to_string()),
"src.goIn" | "src.goOut" | "src.stepBack" | "src.stepFwd" => {
source_nav(app, command);
Ok(serde_json::Value::Null)
}
ui.separator();
let reset = ui.button(tl!("Reset"));
app.auto.add(&format!("{prefix}.transport.editor.reset"), reset.rect, "Reset");
if reset.clicked() {
edited_hidden = crate::state::MonitorView::default().transport_hidden;
}
});
if edited_hidden != hidden {
monitor_view::view_mut(app, which).transport_hidden = edited_hidden;
"exportFrame" => crate::menus::invoke(app, ctx, "file.exportFrame", json!({"monitor": if source { "source" } else { "program" }})),
command => crate::menus::invoke(app, ctx, command, json!({"monitor": if source { "source" } else { "program" }})),
};
if let Err(error) = result {
app.ui.status = error;
}
}

View File

@ -623,6 +623,7 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
// accept drops from the project panel
if let Some(item) = crate::panels::dragged_project_item(ui)
&& ui.rect_contains_pointer(rect)
&& ui.input(|i| i.pointer.hover_pos()).is_some_and(|pos| app.timeline_drop_target(ui.ctx(), pos))
&& ui.input(|i| i.pointer.any_released())
{
let _ = app.session.execute("file.newSequence", json!({"fromItem": item.0}));
@ -2514,6 +2515,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
if let Some(item) = crate::panels::dragged_project_item(ui)
&& let Some(p) = ctx.pointer_hover_pos()
&& layout.content.contains(p)
&& app.timeline_drop_target(ui.ctx(), p)
{
let source = crate::panels::dragged_source(ui);
let row = layout.row_at(p.y).cloned().filter(|r| {
@ -2574,6 +2576,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
if let Some(template) = crate::panels::dragged_template(ui)
&& let Some(p) = ctx.pointer_hover_pos()
&& layout.content.contains(p)
&& app.timeline_drop_target(ui.ctx(), p)
{
let t = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)), &[]);
let row = layout.row_at(p.y).cloned().filter(|r| r.kind == TrackKind::Video);
@ -2595,6 +2598,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
if let Some(effect) = crate::panels::dragged_effect(ui)
&& let Some(p) = ctx.pointer_hover_pos()
&& layout.content.contains(p)
&& app.timeline_drop_target(ui.ctx(), p)
&& let Hit::Clip { clip, .. } = hit(seq, layout, p)
&& let Some((tid, it)) = seq.find_item(clip)
&& let Some(row) = layout.rows.iter().find(|r| r.track == tid)
@ -2651,6 +2655,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
// ---- OS file drops imported with "drop imports to the timeline": placed at the drop point, one after another
if let Some((items, p)) = app.pending_timeline_drop.clone()
&& layout.content.contains(p)
&& app.timeline_drop_target(ui.ctx(), p)
{
app.pending_timeline_drop = None;
let row = layout.row_at(p.y).cloned().or_else(|| layout.rows.iter().find(|r| r.kind == TrackKind::Video).cloned());
@ -2679,6 +2684,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
// preview of OS files being dragged over the Timeline: a placeholder where the drop would start
if let Some((count, p)) = app.file_drag_hover
&& layout.content.contains(p)
&& app.timeline_drop_target(ui.ctx(), p)
{
let row = layout.row_at(p.y).cloned().or_else(|| layout.rows.iter().find(|r| r.kind == TrackKind::Video).cloned());
if let Some(row) = row {
@ -2687,6 +2693,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
let r = Rect::from_min_max(pos2(x, row.rect.min.y + 1.0), pos2((x + 120.0).min(layout.content.max.x), row.rect.max.y - 1.0));
ui.painter().rect_filled(r, 3.0, Color32::from_white_alpha(40));
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside);
app.auto.add("timeline.fileDropPreview", r, "tracks");
let label = if count == 1 { tl!("1 file").to_string() } else { tlf!("{n} files", n = count) };
ui.painter().text(r.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, label, Tokens::ui(10.0), Color32::WHITE);
}

View File

@ -24,8 +24,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
format!("{} ({})", app.ui.language.tr(current.label()), current.shortcut())
};
let resp = ui.interact(r, egui::Id::new(("tool", format!("{:?}", group[0]))), Sense::click()).on_hover_text(tip);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), app.ui.tool == current, current.label()));
for tl in &group {
app.auto.add(&format!("tools.{tl:?}"), r, tl.label());
if *tl == current {
app.auto.add_response(&format!("tools.{tl:?}"), &resp, tl.label());
} else {
// Hidden group alternatives remain click aliases, not separate focus targets.
app.auto.add(&format!("tools.{tl:?}"), r, tl.label());
}
}
let active = group.contains(&app.ui.tool);
if active {
@ -34,6 +40,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.painter().rect_filled(r, 4.0, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(17.0, 17.0)), current.icon(), if active { egui::Color32::WHITE } else { t.icon });
if resp.has_focus() {
ui.painter().rect_stroke(r.shrink(2.0), 4.0, egui::Stroke::new(1.5, t.focus), egui::StrokeKind::Inside);
}
if group.len() > 1 {
let c = r.right_bottom() - vec2(3.0, 3.0);
ui.painter().add(egui::Shape::convex_polygon(vec![c, c - vec2(5.0, 0.0), c - vec2(0.0, 5.0)], t.text_dim, egui::Stroke::NONE));
@ -64,7 +73,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
egui::Popup::menu(&resp).open_memory(command).close_behavior(close_behavior).show(|ui| {
for tl in &group {
let row = ui.selectable_label(app.ui.tool == *tl, format!("{} {}", app.ui.language.tr(tl.label()), tl.shortcut()));
app.auto.add(&format!("tools.select.{tl:?}"), row.rect, tl.label());
app.auto.add_response(&format!("tools.select.{tl:?}"), &row, tl.label());
if row.clicked() {
app.ui.tool = *tl;
ui.close();

View File

@ -56,12 +56,17 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option<Result<
/// Save the current layout as the saved layout of `name`.
fn save(app: &mut FilmcraftApp, name: &str) -> Result<(), String> {
crate::dock::Layout { root: app.ui.dock.clone(), floating: app.ui.floating.clone() }.validate()?;
let layout = serde_json::to_value(&app.ui.dock).map_err(|e| e.to_string())?;
let floating = if app.ui.floating.is_empty() { serde_json::Value::Null } else { serde_json::to_value(&app.ui.floating).map_err(|e| e.to_string())? };
let mut next = app.workspaces.clone();
let saved = &mut next.saved;
match saved.iter_mut().find(|s| s.name == name) {
Some(s) => s.layout = layout,
None => saved.push(dock::SavedWorkspace { name: name.to_string(), layout }),
Some(s) => {
s.layout = layout;
s.floating = floating;
}
None => saved.push(dock::SavedWorkspace { name: name.to_string(), layout, floating }),
}
next.current = name.to_string();
app.set_workspaces(next)

View File

@ -426,6 +426,8 @@ pub struct UiState {
pub mode: Mode,
pub workspace: String,
pub dock: DockNode,
#[serde(default)]
pub floating: Vec<crate::dock::Floating>,
/// Focused panel (blue outline, receives shortcuts).
pub focused: PanelKind,
pub timeline: TimelineView,
@ -945,6 +947,7 @@ impl Default for UiState {
tool: Tool::Selection,
mode: Mode::Edit,
workspace: "Editing".into(),
floating: vec![],
dock: crate::dock::workspace("Editing"),
focused: PanelKind::Timeline,
timeline: TimelineView::default(),

View File

@ -126,12 +126,18 @@ impl Driver {
}
fn drag(&mut self, from: (f64, f64), by: (f64, f64)) {
self.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": from.0 + by.0, "y": from.1 + by.1}, "steps": 6}));
// Control gestures use explicit modifiers and isolate ambient native keyboard state.
let m = self.harness.ctx.input(|input| input.modifiers);
self.ok(
"ui.drag",
json!({"from": {"x": from.0, "y": from.1}, "to": {"x": from.0 + by.0, "y": from.1 + by.1}, "steps": 6,
"modifiers": {"shift":m.shift,"alt":m.alt,"ctrl":m.ctrl,"command":m.command}}),
);
self.frames(4);
}
/// Press or let go of Shift, as a keyboard reports it (the control channel's drags carry
/// modifiers only on their press and release).
/// Press or let go of Shift as a keyboard reports it. The drag fixture then explicitly
/// forwards the held modifiers to the control gesture.
fn hold_shift(&mut self, on: bool) {
let m = if on { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE };
self.harness.input_mut().events.push(egui::Event::ModifiersChanged(m));

View File

@ -0,0 +1,230 @@
//! Geometry and reachability checks against the production monitor renderer.
use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use filmcraft_engine::Session;
use filmcraft_ui_egui::{
FilmcraftApp,
dock::{self, DockNode, PanelKind, SeqTabs},
panels::monitor::{self, Which},
theme::{self, ThemeKind},
};
use serde_json::json;
fn harness(width: f32, kind: ThemeKind, which: Which) -> Harness<'static, FilmcraftApp> {
harness_at_scale(width, kind, which, 1.0)
}
fn harness_at_scale(width: f32, kind: ThemeKind, which: Which, scale: f32) -> Harness<'static, FilmcraftApp> {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
session.execute("source.open", json!({"item":5})).unwrap();
let mut h = Harness::builder().with_size(vec2(width, 340.0)).with_pixels_per_point(scale).build_ui_state(
move |ui, app: &mut FilmcraftApp| {
app.set_theme(ui.ctx(), kind);
app.auto.begin_frame();
let panel = if which == Which::Source { PanelKind::Source } else { PanelKind::Program };
let mut node = DockNode::Tabs { panels: vec![panel], active: 0 };
let mut groups = Vec::new();
dock::layout(ui, &mut node, ui.max_rect(), &app.tokens, "monitor", &mut groups, &mut app.auto);
for group in groups {
// Exercise the actual parent chrome, including its themed panel background.
dock::draw_group_chrome(ui, &group, panel, &SeqTabs::default(), &app.tokens, &mut app.auto);
monitor::show(app, ui, group.content, which);
}
let source = filmcraft_ui_egui::panels::dragged_source(ui);
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("observed-source-drag"), source));
},
FilmcraftApp::new(session),
);
theme::install(&h.ctx, &h.state().tokens);
h.run_steps(4);
h
}
fn element(h: &Harness<'_, FilmcraftApp>, id: &str) -> Rect {
let element = h.state().auto.elements.iter().find(|element| element.id == id).unwrap_or_else(|| panic!("missing {id}"));
let [x, y, w, h] = element.rect;
Rect::from_min_size(pos2(x, y), vec2(w, h))
}
fn click(h: &mut Harness<'_, FilmcraftApp>, id: &str) {
let pos = element(h, id).center();
h.event(Event::PointerMoved(pos));
for pressed in [true, false] {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
}
fn overlaps(first: Rect, second: Rect) -> bool {
let intersection = first.intersect(second);
intersection.width() > 0.0 && intersection.height() > 0.0
}
#[test]
fn monitor_readouts_and_dropdowns_do_not_overlap() {
let mut failures = Vec::new();
for (which, prefix) in [(Which::Source, "source"), (Which::Program, "program")] {
for kind in [ThemeKind::Dark, ThemeKind::Medium, ThemeKind::Light] {
for width in [720.0, 500.0, 300.0, 250.0] {
for scale in [1.0, 2.0] {
let mut h = harness_at_scale(width, kind, which, scale);
if let Ok(directory) = std::env::var("CRAFT_UI_VISUAL_DIR") {
std::fs::create_dir_all(&directory).unwrap();
h.render().unwrap().save(format!("{directory}/film-{prefix}-{kind:?}-{width}-{scale}x.png")).unwrap();
}
let controls = ["timecode", "zoom", "resolution", "settings"].map(|name| element(&h, &format!("{prefix}.{name}")));
let timecodes: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.text().matches(':').count() == 3 => {
Some(text.visual_bounding_rect().intersect(shape.clip_rect))
}
_ => None,
})
.collect();
assert_eq!(timecodes.len(), 2, "both actual timecode galleys must be painted");
if overlaps(timecodes[0], timecodes[1]) {
failures.push(format!("{prefix} {kind:?} {width} {scale}x: actual timecode text overlaps: {timecodes:?}"));
}
for (i, first) in controls.iter().enumerate() {
if first.left() < 0.0 || first.right() > width {
failures.push(format!("{prefix} {kind:?} {width} {scale}x: outside monitor: {first:?}"));
}
for second in controls.iter().skip(i + 1) {
if overlaps(*first, *second) {
failures.push(format!("{prefix} {kind:?} {width} {scale}x: {first:?} overlaps {second:?}"));
}
}
}
}
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn compact_transport_buttons_stay_inside_monitor() {
for width in [720.0, 500.0, 300.0, 250.0] {
let h = harness(width, ThemeKind::Dark, Which::Program);
for e in &h.state().auto.elements {
if e.id.starts_with("program.transport.") {
let r = element(&h, &e.id);
assert!(r.left() >= 0.0 && r.right() <= width, "{width}: {} {r:?}", e.id);
}
}
}
}
#[test]
fn transport_editor_keeps_its_own_space_and_customizes_compact_controls() {
for width in [720.0, 500.0, 300.0, 250.0, 90.0] {
let mut h = harness(width, ThemeKind::Light, Which::Program);
let editor = element(&h, "program.transport.editor");
for e in &h.state().auto.elements {
if e.id.starts_with("program.transport.") && e.id != "program.transport.editor" {
assert!(!overlaps(editor, element(&h, &e.id)), "{width}: editor overlaps {}", e.id);
}
}
click(&mut h, "program.transport.editor");
let id = "program.transport.editor.playback.toggle";
let target = element(&h, id);
if width == 90.0
&& let Ok(directory) = std::env::var("CRAFT_UI_VISUAL_DIR")
{
std::fs::create_dir_all(&directory).unwrap();
h.render().unwrap().save(format!("{directory}/film-program-editor-90.png")).unwrap();
}
click(&mut h, id);
assert!(
h.state().ui.program.transport_hidden.iter().any(|command| command == "playback.toggle"),
"width={width} editor={editor:?} checkbox={target:?} hidden={:?}",
h.state().ui.program.transport_hidden
);
if width == 90.0 {
let scroll = element(&h, "program.transport.editor.scroll");
let reset_before = element(&h, "program.transport.editor.reset");
h.event(Event::PointerMoved(scroll.center()));
h.event(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -1200.0),
modifiers: Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.run_steps(5);
let reset = element(&h, "program.transport.editor.reset");
assert!(reset.top() < reset_before.top(), "real wheel moves clipped editor controls");
assert!(scroll.contains(reset.center()), "Reset is visible after scrolling: {reset:?}, {scroll:?}");
click(&mut h, "program.transport.editor.reset");
assert_eq!(h.state().ui.program.transport_hidden, vec!["playback.loop".to_string()]);
}
}
}
#[test]
fn compact_transport_more_runs_the_existing_proxy_command() {
let mut h = harness(250.0, ThemeKind::Light, Which::Program);
let before = h.state().session.media.use_proxies();
click(&mut h, "program.transport.more");
assert_eq!(
h.get_by_label("Toggle Proxies").accesskit_node().toggled(),
Some(if before { egui::accesskit::Toggled::True } else { egui::accesskit::Toggled::False })
);
click(&mut h, "program.transport.media.toggleProxies");
assert_eq!(h.state().session.media.use_proxies(), !before);
click(&mut h, "program.transport.more");
assert_eq!(
h.get_by_label("Toggle Proxies").accesskit_node().toggled(),
Some(if before { egui::accesskit::Toggled::False } else { egui::accesskit::Toggled::True })
);
}
#[test]
fn compact_source_overflow_keeps_source_navigation_routing() {
let mut h = harness(250.0, ThemeKind::Dark, Which::Source);
let before = h.state().session.state.source_playhead;
let program = h.state().session.playhead();
click(&mut h, "source.transport.more");
click(&mut h, "source.transport.src.goOut");
assert!(h.state().session.state.source_playhead > before);
assert_eq!(h.state().session.playhead(), program);
}
fn source_drag_choices(width: f32) {
for (control, video, audio) in [("video", true, false), ("audio", false, true), ("both", true, true)] {
let mut h = harness(width, ThemeKind::Dark, Which::Source);
let rect = element(&h, &format!("source.drag.{control}"));
assert!(rect.left() >= 0.0 && rect.right() <= width);
let point = rect.center();
h.event(Event::PointerMoved(point));
h.step();
h.event(Event::PointerButton { pos: point, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.event(Event::PointerMoved(point + vec2(0.0, 10.0)));
h.run_steps(2);
let source = h
.ctx
.data(|data| data.get_temp::<Option<filmcraft_ui_egui::panels::source_drag::SourceDrag>>(egui::Id::new("observed-source-drag")))
.flatten()
.expect("drag must create the production Source payload");
assert_eq!((source.video, source.audio), (video, audio));
assert_eq!(source.item.0, 5);
assert!(source.range.duration.0 > 0);
}
}
#[test]
fn source_drag_choices_remain_reachable_at_500_pixels() {
source_drag_choices(500.0);
}
#[test]
fn source_drag_choices_remain_reachable_at_250_pixels() {
source_drag_choices(250.0);
}

View File

@ -322,8 +322,21 @@ fn freeform_view_places_and_stacks_cards() {
assert!(d.has(&format!("project.stack.{}", items[0])), "×2 badge");
// Freeform View Options… from the panel menu
d.click("panel.menu.Project");
d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200}));
d.frames(4);
let item_rect = d.rect("project.menu.freeformOptions");
let ctx = d.harness.ctx.clone();
let at = egui::pos2(item_rect[0] + item_rect[2] / 2.0, item_rect[1] + item_rect[3] / 2.0);
assert!(ctx.content_rect().contains(at), "bottom menu action must be reachable: {item_rect:?}");
d.snapshot("project-freeform-menu", None);
d.click("project.menu.freeformOptions");
assert!(d.has("projectDialog.snap"));
let app = d.app();
let diagnostic = format!(
"dialog={:?}, controls={:?}",
app.ui.project_panel.dialog,
app.auto.elements.iter().filter(|element| element.id.starts_with("projectDialog.")).map(|element| (&element.id, element.rect)).collect::<Vec<_>>()
);
assert!(d.has("projectDialog.snap"), "{diagnostic}");
d.click("projectDialog.snap");
d.click("projectDialog.ok");
assert!(d.app().session.prefs.project_panel.freeform.snap);
@ -358,6 +371,8 @@ fn metadata_display_dialog_and_view_presets() {
] {
assert!(d.has(id), "{id}");
}
d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200}));
d.frames(4);
d.click("project.menu.metadataDisplay");
d.frames(4);
assert!(matches!(d.app().ui.project_panel.dialog, Some(filmcraft_ui_egui::panels::project::ProjectDialog::MetadataDisplay { .. })));
@ -689,6 +704,8 @@ fn media_browser_navigation_import_and_columns() {
assert!(d.label("mediaBrowser.count").starts_with("2 of 2"));
d.snapshot("media-browser-list", Some("panel.MediaBrowser"));
d.click("panel.menu.MediaBrowser");
d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200}));
d.frames(4);
d.click("mediaBrowser.menu.import");
assert_eq!(d.app().session.project.items.len(), 2);
// Shift+O opens the selection in the Source monitor (already imported: no duplicate)

View File

@ -0,0 +1,515 @@
//! Shared docking preserves FilmCraft document state and legacy workspace files.
use craft_ui::docking::{Action, Placement, Zone};
use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use filmcraft_engine::Session;
use filmcraft_ui_egui::{
FilmcraftApp,
dock::{self, DockNode, Layout, PanelKind, SplitSize},
menus,
};
use serde_json::json;
fn layout() -> Layout {
Layout {
root: DockNode::Split {
vertical: false,
size: SplitSize::FixedA(400.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info, PanelKind::History], active: 1 }),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Source, PanelKind::Program], active: 1 }),
},
floating: vec![],
}
}
#[test]
fn shared_transactions_keep_selection_and_reject_invalid_moves_atomically() {
let mut layout = layout();
layout.apply(Action::Close { panel: PanelKind::Project }).unwrap();
assert!(layout.root.is_visible(PanelKind::Info));
let before = layout.clone();
for action in [
Action::Move { panel: PanelKind::Info, anchor: PanelKind::Tools, placement: Placement::Split(Zone::Left) },
Action::Float { panel: PanelKind::Info, rect: [0.0, 0.0, f32::NAN, 100.0] },
Action::Move { panel: PanelKind::Info, anchor: PanelKind::Program, placement: Placement::Tab { before: Some(PanelKind::Tools) } },
] {
assert!(layout.apply(action).is_err());
assert_eq!(layout, before);
}
layout.apply(Action::Float { panel: PanelKind::Info, rect: [50.0, 50.0, 400.0, 300.0] }).unwrap();
layout.apply(Action::Move { panel: PanelKind::Info, anchor: PanelKind::Program, placement: Placement::Split(Zone::Bottom) }).unwrap();
assert!(layout.floating.is_empty());
assert!(layout.root.is_visible(PanelKind::Info));
}
#[test]
fn builtin_and_legacy_workspace_shapes_roundtrip_and_save_floating() {
for name in dock::WORKSPACES {
let root = dock::workspace(name);
let before = serde_json::to_value(&root).unwrap();
let prefs: dock::WorkspacePrefs = serde_json::from_value(json!({"saved": [{"name": name, "layout": before}], "current": name})).unwrap();
let loaded = dock::saved_docking(&prefs, name);
loaded.validate().unwrap();
assert_eq!(loaded.root, root);
assert!(loaded.floating.is_empty());
assert!(serde_json::to_value(&prefs).unwrap()["saved"][0].get("floating").is_none());
}
let dir = std::env::temp_dir().join(format!("filmcraft-shared-dock-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut session = Session::default();
session.prefs_path = Some(dir.join("preferences.json"));
let mut app = FilmcraftApp::new(session);
let ctx = egui::Context::default();
app.ui.dock = layout().root;
menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap();
menus::invoke(&mut app, &ctx, "window.workspace.saveAs", json!({"name": "Floating test"})).unwrap();
let prefs = dock::WorkspacePrefs::load(&dir.join(dock::WORKSPACES_FILE));
let restored = dock::saved_docking(&prefs, "Floating test");
assert_eq!(restored.floating, app.ui.floating);
assert_eq!(restored.root, app.ui.dock);
let mut restarted_session = Session::default();
restarted_session.prefs_path = Some(dir.join("preferences.json"));
let restarted = FilmcraftApp::new(restarted_session);
let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| restarted);
for _ in 0..4 {
step(&mut h);
}
assert_eq!(h.state().ui.dock, restored.root);
assert_eq!(h.state().ui.floating, restored.floating);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn legacy_effects_workspace_drops_only_the_known_inactive_duplicate() {
let expected = dock::workspace("Effects");
let mut legacy = expected.clone();
fn duplicate(node: &mut DockNode) {
match node {
DockNode::Tabs { panels, active } if panels.as_slice() == [PanelKind::EssentialGraphics] => {
panels.insert(0, PanelKind::EffectControls);
*active = 1;
}
DockNode::Split { a, b, .. } => {
duplicate(a);
duplicate(b);
}
DockNode::Tabs { .. } => {}
}
}
duplicate(&mut legacy);
let prefs: dock::WorkspacePrefs =
serde_json::from_value(json!({"saved": [{"name": "Historical Effects", "layout": legacy}], "current": "Historical Effects"})).unwrap();
assert_eq!(dock::saved_docking(&prefs, "Historical Effects").root, expected);
}
fn step(h: &mut Harness<'_, FilmcraftApp>) {
let ctx = h.ctx.clone();
let mut input = std::mem::take(h.input_mut());
eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut input);
*h.input_mut() = input;
h.step();
}
fn rect(h: &Harness<'_, FilmcraftApp>, 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 click(h: &mut Harness<'_, FilmcraftApp>, id: &str) {
let pos = rect(h, id).center();
h.event(Event::PointerMoved(pos));
step(h);
for pressed in [true, false] {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
step(h);
}
for _ in 0..3 {
step(h);
}
}
fn drag(h: &mut Harness<'_, FilmcraftApp>, from: egui::Pos2, to: egui::Pos2, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.event(Event::PointerMoved(from));
step(h);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers });
step(h);
for fraction in [0.05, 0.25, 0.5, 0.75, 1.0] {
h.event(Event::PointerMoved(from.lerp(to, fraction)));
step(h);
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers });
step(h);
h.event(Event::ModifiersChanged(Modifiers::NONE));
for _ in 0..3 {
step(h);
}
}
#[test]
fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let app = FilmcraftApp::new(session);
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);
for _ in 0..4 {
step(&mut h);
}
h.state_mut().ui.dock = DockNode::Split {
vertical: true,
size: SplitSize::Ratio(0.62),
a: Box::new(layout().root),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }),
};
for _ in 0..3 {
step(&mut h);
}
let project = h.state().session.project.clone();
let mut action_log = vec![action_state(&h, "start")];
let playheads = h.state().session.state.playheads.clone();
let from = rect(&h, "panel.tab.Info").center();
let to = rect(&h, "panel.Program").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::Program));
action_log.push(action_state(&h, "merged"));
let from = rect(&h, "panel.tab.Info").center();
let before = rect(&h, "panel.tab.Source");
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();
let left = rect(&h, "panel.History");
// Activate a visible sibling after the source left its original group.
let to = pos2(left.left() + 8.0, left.center().y);
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::History));
assert!(!same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::Program));
action_log.push(action_state(&h, "split"));
click(&mut h, "panel.tab.Program");
assert!(h.state().auto.elements.iter().any(|element| element.id.starts_with("timeline.clip.")));
assert!(h.state().auto.find("program.transport.playhead.stepForward").is_some());
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 });
step(&mut h);
}
}
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 });
step(&mut h);
h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
for _ in 0..3 {
step(&mut h);
}
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"));
let saved_root = h.state().ui.dock.clone();
let saved_floating = h.state().ui.floating.clone();
let ctx = h.ctx.clone();
menus::invoke(h.state_mut(), &ctx, "window.maximizeFrame", json!({})).unwrap();
for _ in 0..3 {
step(&mut h);
}
assert!(rect(&h, "panel.Info").width() > 1000.0);
action_log.push(action_state(&h, "maximized"));
assert_eq!(h.state().ui.dock, saved_root);
assert_eq!(h.state().ui.floating, saved_floating);
menus::invoke(h.state_mut(), &ctx, "window.maximizeFrame", json!({})).unwrap();
for _ in 0..3 {
step(&mut h);
}
assert_eq!(h.state().ui.floating, saved_floating);
if let Some(directory) = &snapshots {
std::fs::create_dir_all(directory).unwrap();
h.render().unwrap().save(directory.join("filmcraft-floating.png")).unwrap();
}
let from = rect(&h, "panel.tab.Info").center();
let to = rect(&h, "panel.History").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!(playheads, h.state().session.state.playheads);
let playhead = h.state().session.playhead();
click(&mut h, "program.transport.playhead.stepForward");
assert!(h.state().session.playhead() > playhead);
assert!(h.state().auto.elements.iter().any(|element| element.id.starts_with("timeline.clip.")));
action_log.push(action_state(&h, "program-step-forward"));
if let Some(directory) = &snapshots {
std::fs::write(directory.join("filmcraft-actions.json"), serde_json::to_vec_pretty(&action_log).unwrap()).unwrap();
}
if let Some(directory) = &snapshots {
h.render().unwrap().save(directory.join("filmcraft-redocked.png")).unwrap();
}
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
assert!(h.state().ui_error.is_none());
}
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),
}
}
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 docking_commands_reject_bad_geometry_and_targets_without_partial_changes() {
let mut app = FilmcraftApp::new(Session::default());
app.ui.dock = layout().root;
let ctx = egui::Context::default();
let before = app.ui.dock.clone();
for (id, params) in [
("window.floatPanel", json!({"panel": "Info", "rect": [1, 2, 300]})),
("window.floatPanel", json!({"panel": "Info", "rect": [1, 2, 300, 200, 7]})),
("window.floatPanel", json!({"panel": "Info", "rect": [1, 2, 1e100, 200]})),
("window.dockPanel", json!({"panel": "Info", "anchor": "Tools"})),
("window.dockPanel", json!({"panel": "Info", "anchor": "Program", "zone": 42})),
("window.dockPanel", json!({"panel": "Info", "anchor": "Program", "before": "Tools"})),
] {
assert!(menus::invoke(&mut app, &ctx, id, params.clone()).is_err(), "{id} accepted {params}");
assert_eq!(app.ui.dock, before);
assert!(app.ui.floating.is_empty());
}
app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 };
assert!(menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Project", "rect": [1, 2, 300, 200]})).is_err());
}
fn action_state(h: &Harness<'_, FilmcraftApp>, 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 = FilmcraftApp::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::Program], 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] },
] {
filmcraft_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!(
filmcraft_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 unreadable_floating_workspace_does_not_erase_valid_workspaces_or_current_name() {
let root = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 };
let valid = json!({"name": "Valid", "layout": root, "floating": [{
"panels": ["TextToSpeech", "Timeline"], "active": 0, "rect": [80, 90, 420, 320]
}]});
for (index, bad) in [
json!([{"panels": ["FuturePanel"], "active": 0, "rect": [80, 90, 420, 320]}]),
json!([{"panels": ["Info"], "active": "invalid", "rect": [80, 90, 420, 320]}]),
json!({"malformed": true}),
]
.into_iter()
.enumerate()
{
let path = std::env::temp_dir().join(format!("filmcraft-floating-isolation-{}-{index}.json", std::process::id()));
let prefs = json!({"saved": [valid.clone(), {"name": "Broken", "layout": root, "floating": bad}], "current": "Valid"});
std::fs::write(&path, serde_json::to_vec(&prefs).unwrap()).unwrap();
let loaded = dock::WorkspacePrefs::load(&path);
assert_eq!(loaded.current, "Valid");
assert_eq!(loaded.saved.len(), 2);
let restored = dock::saved_docking(&loaded, "Valid");
restored.validate().unwrap();
assert_eq!(restored.root, root);
assert_eq!(restored.floating[0].panels, vec![PanelKind::TextToSpeech, PanelKind::Timeline]);
assert_eq!(dock::saved_docking(&loaded, "Broken").root, dock::workspace("Broken"));
loaded.save(&path).unwrap();
assert_eq!(dock::WorkspacePrefs::load(&path), loaded, "saving preserves the unreadable entry for a future version");
std::fs::remove_file(path).unwrap();
}
}
#[test]
fn floating_tts_and_timeline_reveal_keeps_one_owner_and_draft_without_project_edits() {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let mut app = FilmcraftApp::new(session);
app.ui.tts.text = "Synthetic retained narration".into();
app.show_panel(PanelKind::TextToSpeech);
let project = app.session.project.clone();
let state = serde_json::to_value(&app.session.state).unwrap();
let draft = serde_json::to_value(&app.ui.tts).unwrap();
let ctx = egui::Context::default();
menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "TextToSpeech", "rect": [80, 90, 420, 320]})).unwrap();
menus::invoke(
&mut app,
&ctx,
"window.dockAction",
json!({"action": Action::Move {
panel: PanelKind::Timeline, anchor: PanelKind::TextToSpeech, placement: Placement::Tab { before: None }
}}),
)
.unwrap();
app.show_panel(PanelKind::TextToSpeech);
app.show_panel(PanelKind::Timeline);
assert!(!app.ui.dock.contains(PanelKind::TextToSpeech));
assert!(!app.ui.dock.contains(PanelKind::Timeline));
assert_eq!(app.ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1);
assert_eq!(app.ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::Timeline).count(), 1);
assert_eq!(serde_json::to_value(&app.ui.tts).unwrap(), draft);
assert!(std::sync::Arc::ptr_eq(&project, &app.session.project));
assert_eq!(serde_json::to_value(&app.session.state).unwrap(), state);
}
#[test]
fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_project() {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let clip = session
.active_sequence()
.unwrap()
.audio_tracks
.iter()
.flat_map(|track| &track.items)
.find(|clip| {
session.project.item(clip.item).and_then(|item| item.as_media()).is_some_and(|media| media.info.kind == filmcraft_media::MediaKind::AudioOnly)
})
.unwrap()
.clone();
let narration = filmcraft_project::Narration {
text: "Synthetic narration behind an overlapping pane".into(),
language: "en-US".into(),
voice: filmcraft_tts::default_voice_id().into(),
pitch: filmcraft_project::VocalPitch::Default,
pace: 1.2,
speech_duration: filmcraft_time::Tick::ZERO,
};
std::sync::Arc::make_mut(&mut session.project).narrations.insert(clip.item, narration.clone());
let project = session.project.clone();
let mut app = FilmcraftApp::new(session);
app.show_panel(PanelKind::TextToSpeech);
let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app);
for _ in 0..4 {
step(&mut h);
}
let ctx = h.ctx.clone();
for panel in ["TextToSpeech", "Info"] {
menus::invoke(h.state_mut(), &ctx, "window.floatPanel", json!({"panel": panel, "rect": [400, 220, 450, 360]})).unwrap();
for _ in 0..4 {
step(&mut h);
}
}
let layer = |panel: PanelKind| egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", panel.id())));
let covered = rect(&h, "panel.TextToSpeech").center();
assert!(rect(&h, "panel.Info").contains(covered));
ctx.move_to_top(layer(PanelKind::Info));
for _ in 0..3 {
step(&mut h);
}
assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info)), "precondition: Info really covers TTS");
h.state_mut().session.state.selection = vec![clip.id];
for _ in 0..4 {
step(&mut h);
}
let draft = h.state().ui.tts.clone();
assert_eq!(draft.text, narration.text);
assert_eq!(draft.pace, narration.pace);
assert_eq!(draft.loaded_from, Some((clip.id.0, clip.item.0)));
assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::TextToSpeech)), "narration selection must bring its floating editor forward");
assert_eq!(h.state().ui.focused, PanelKind::TextToSpeech);
assert!(!h.state().ui.dock.contains(PanelKind::TextToSpeech));
assert_eq!(h.state().ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1);
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
// A reveal is one-shot: drawing the selected narration must not steal stacking
// from another pane that the user subsequently brings forward.
ctx.move_to_top(layer(PanelKind::Info));
for _ in 0..4 {
step(&mut h);
}
assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info)));
assert_eq!(h.state().ui.tts, draft);
menus::invoke(h.state_mut(), &ctx, "window.panel.TextToSpeech", json!({})).unwrap();
for _ in 0..4 {
step(&mut h);
}
assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::TextToSpeech)));
assert_eq!(h.state().ui.tts, draft);
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
// Replacing the workspace cancels a queued reveal before the next render,
// even when the destination happens to contain the same floating panel IDs.
ctx.move_to_top(layer(PanelKind::Info));
for _ in 0..3 {
step(&mut h);
}
let saved = dock::SavedWorkspace {
name: "Reveal cancellation".into(),
layout: serde_json::to_value(&h.state().ui.dock).unwrap(),
floating: serde_json::to_value(&h.state().ui.floating).unwrap(),
};
h.state_mut().workspaces.saved.push(saved);
h.state_mut().show_panel(PanelKind::TextToSpeech);
h.state_mut().set_workspace("Reveal cancellation");
for _ in 0..4 {
step(&mut h);
}
assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info)));
assert_eq!(h.state().ui.tts, draft);
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
}

View File

@ -174,3 +174,37 @@ fn the_program_and_source_timecodes_move_their_own_playheads() {
assert_eq!(d.source_frame(), parse_timecode("100", d.source_rate(), false, 0).unwrap(), "1 s at the clip's own rate");
assert_eq!(d.harness.state().session.playhead(), program);
}
#[test]
fn compact_floating_monitor_timecodes_keep_drag_typing_escape_and_caret_routing() {
for scale in [1.0, 2.0] {
let mut d = Driver::demo();
d.harness.ctx.set_pixels_per_point(scale);
d.frames(4);
for (panel, x) in [("Source", 70), ("Program", 360)] {
d.ok("engine.execute", json!({"command": "window.floatPanel", "params": {"panel": panel, "rect": [x, 100, 250, 360]}}));
}
let project = d.harness.state().session.project.clone();
d.set_frame(30);
d.drag_by("program.timecode", 20.0);
assert_eq!(d.frame(), 50);
let program = d.harness.state().session.playhead();
let source = d.source_frame();
d.drag_by("source.timecode", 20.0);
assert_eq!(d.source_frame(), source + 20);
assert_eq!(d.harness.state().session.playhead(), program);
d.type_into("program.timecode", "1000", "Escape");
assert_eq!(d.harness.state().session.playhead(), program);
d.type_into("source.timecode", "100", "Enter");
assert_eq!(d.source_frame(), parse_timecode("100", d.source_rate(), false, 0).unwrap());
assert_eq!(d.harness.state().session.playhead(), program);
d.ok("ui.click", json!({"id": "program.timecode"}));
d.ok("ui.key", json!({"key": "Left"}));
assert_eq!(d.harness.state().session.playhead(), program, "caret arrows must not seek");
d.ok("ui.key", json!({"key": "Escape"}));
d.type_into("program.timecode", "200", "Enter");
let s = &d.harness.state().session;
assert_eq!(d.frame(), parse_timecode("200", s.sequence_rate(), s.active_sequence().unwrap().settings.drop_frame, 0).unwrap());
assert!(std::sync::Arc::ptr_eq(&project, &s.project));
}
}

View File

@ -152,3 +152,117 @@ fn with_drop_import_off_the_file_is_only_imported() {
assert_eq!(h.state().session.project.items.len(), items_before + 1, "the still is imported to the bin");
assert_eq!(clip_counts(&h), (0, 0), "status: {}", h.state().ui.status);
}
fn float_panel(h: &mut Harness<'static, FilmcraftApp>, panel: &str, rect: [f32; 4]) {
let ctx = h.ctx.clone();
filmcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.floatPanel", json!({"panel": panel, "rect": rect})).unwrap();
for _ in 0..4 {
step(h);
}
}
fn drop_at(h: &mut Harness<'static, FilmcraftApp>, pos: egui::Pos2, path: &Path) {
pointer_to(h, pos);
h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(path.to_path_buf())));
step(h);
h.input_mut().dropped_files.clear();
step(h);
}
fn hover_at(h: &mut Harness<'static, FilmcraftApp>, pos: egui::Pos2) -> bool {
pointer_to(h, pos);
h.input_mut().hovered_files.push(Default::default());
step(h);
let preview = h.state().auto.elements.iter().any(|element| element.id == "timeline.fileDropPreview");
h.input_mut().hovered_files.clear();
step(h);
preview
}
#[test]
fn floating_panel_blocks_os_drop_and_hover_but_uncovered_timeline_still_accepts() {
for scale in [1.0, 2.0] {
let mut h = harness();
h.ctx.set_pixels_per_point(scale);
for _ in 0..4 {
step(&mut h);
}
let at = point_on_row(&h, "timeline.track.V1.target");
float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]);
let project = h.state().session.project.clone();
let covered_preview = hover_at(&mut h, at);
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project), "hover does not edit the project");
let items_before = h.state().session.project.items.len();
drop_at(&mut h, at, &still(&format!("drop_covered_{scale}.png")));
assert_eq!(h.state().session.project.items.len(), items_before + 1);
assert_eq!(clip_counts(&h), (0, 0), "a covered Timeline must not be edited");
assert!(!covered_preview, "a covered Timeline must not preview a placement");
float_panel(&mut h, "Info", [5.0, 5.0, 320.0, 200.0]);
let at = point_on_row(&h, "timeline.track.V1.target");
assert!(hover_at(&mut h, at));
drop_at(&mut h, at, &still(&format!("drop_uncovered_{scale}.png")));
assert_eq!(clip_counts(&h), (1, 0));
}
}
#[test]
fn floating_timeline_accepts_once_and_another_float_can_occlude_it() {
let mut h = harness();
float_panel(&mut h, "Timeline", [300.0, 180.0, 950.0, 640.0]);
let at = point_on_row(&h, "timeline.track.V1.target");
assert!(hover_at(&mut h, at));
drop_at(&mut h, at, &still("drop_floating_timeline.png"));
assert_eq!(clip_counts(&h), (1, 0));
float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]);
assert!(!hover_at(&mut h, at));
drop_at(&mut h, at, &still("drop_occluded_floating_timeline.png"));
assert_eq!(clip_counts(&h), (1, 0));
}
#[test]
fn offscreen_float_uses_rendered_clamp_and_maximized_timeline_ignores_hidden_floats() {
let mut h = harness();
let at = point_on_row(&h, "timeline.track.V1.target");
// The very wide offscreen float is clamped back to x=0, over this row.
float_panel(&mut h, "Info", [-5000.0, at.y - 60.0, 1500.0, 200.0]);
let visible = control_rect(&h, "panel.Info");
assert!(visible.contains(at), "rendered={visible:?}, drop={at:?}, floats={:?}", h.state().ui.floating);
assert!(!hover_at(&mut h, at));
drop_at(&mut h, at, &still("drop_clamped_occlusion.png"));
assert_eq!(clip_counts(&h), (0, 0));
h.state_mut().ui.keys.maximized = Some(filmcraft_ui_egui::dock::PanelKind::Timeline);
for _ in 0..4 {
step(&mut h);
}
let at = point_on_row(&h, "timeline.track.V1.target");
assert!(hover_at(&mut h, at));
drop_at(&mut h, at, &still("drop_maximized_timeline.png"));
assert_eq!(clip_counts(&h), (1, 0));
}
#[test]
fn os_cursor_hook_selects_destination_when_egui_pointer_has_no_motion() {
let mut h = harness();
let at = point_on_row(&h, "timeline.track.V1.target");
float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]);
// Physical pixels at 1x, with the synthetic viewport origin fixed at zero.
h.state_mut().hooks.cursor_screen_position = Some(Box::new(move || Some((at.x.round() as i32, at.y.round() as i32))));
if let Some(viewport) = h.input_mut().viewports.get_mut(&egui::ViewportId::ROOT) {
viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 980.0)));
}
h.input_mut().hovered_files.push(Default::default());
step(&mut h);
assert!(!h.state().auto.elements.iter().any(|element| element.id == "timeline.fileDropPreview"));
h.input_mut().hovered_files.clear();
h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(still("drop_os_hook_covered.png"))));
step(&mut h);
h.input_mut().dropped_files.clear();
step(&mut h);
assert_eq!(clip_counts(&h), (0, 0));
h.state_mut().ui.floating.clear();
h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(still("drop_os_hook_visible.png"))));
step(&mut h);
h.input_mut().dropped_files.clear();
step(&mut h);
assert_eq!(clip_counts(&h), (1, 0));
}

View File

@ -0,0 +1,277 @@
//! Control-only validation must not share keyboard ownership with native computer use.
use std::sync::{
Arc,
mpsc::{Sender, channel},
};
use egui::{Event, Id, Key, Modifiers, vec2};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::{
FilmcraftApp,
control::ControlRequest,
dock::{DockNode, PanelKind, SplitSize},
};
use serde_json::{Value, json};
struct Driver {
h: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
}
impl Driver {
fn new(isolated: bool) -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let (tx, rx) = channel();
let app = FilmcraftApp::new(session);
let app = if isolated { app.with_control_input_only(rx) } else { app.with_control(rx) };
let mut driver = Self { h: Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app), tx };
driver.frames(4, 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();
assert!(self.h.state().ui_error.is_none());
}
}
fn ok(&mut self, method: &str, params: Value, native_focused: bool) -> Value {
let (request, reply) = 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}");
}
}
fn native_key(h: &mut Harness<'_, FilmcraftApp>, key: Key) {
for pressed in [true, false] {
// The backend supplies native events before raw_input_hook. Harness::event would
// instead append them after that hook and bypass the input boundary under test.
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
}
}
#[test]
fn native_j_cannot_start_reverse_playback_during_control_only_editing() {
let mut d = Driver::new(true);
d.ok("engine.execute", json!({"command":"playhead.set","params":{"seconds":1}}), true);
let before = d.ok("engine.execute", json!({"command":"sequence.inspect","params":{}}), true);
let (request, reply) = ControlRequest::new("ui.key", json!({"key":"K","command":true}));
d.tx.send(request).unwrap();
d.frames(1, true); // queue the requested command; the native key arrives in its input frame
native_key(&mut d.h, Key::J);
d.frames(4, true);
assert_eq!(reply.try_recv().unwrap()["ok"], true);
assert!(!d.h.state().playback.playing, "native J must not contaminate the automated edit");
let after = d.ok("engine.execute", json!({"command":"sequence.inspect","params":{}}), true);
assert_eq!(after["playhead"], before["playhead"]);
assert_eq!(after["video"][0]["items"].as_array().unwrap().len(), before["video"][0]["items"].as_array().unwrap().len() + 1);
}
#[test]
fn background_control_input_resizes_the_focused_divider_without_seeking() {
background_divider(true);
}
#[test]
fn ordinary_control_input_also_resizes_a_background_divider() {
background_divider(false);
}
fn background_divider(isolated: bool) {
let mut d = Driver::new(isolated);
d.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::Timeline], active: 0 }),
};
d.frames(3, true);
let before_project = d.h.state().session.project.clone();
let before_playheads = d.h.state().session.state.playheads.clone();
let divider = Id::new(("dock-gutter", String::new()));
d.h.ctx.memory_mut(|memory| memory.request_focus(divider));
d.frames(2, true);
d.h.input_mut().events.push(Event::WindowFocused(false));
d.ok("ui.key", json!({"key":"ArrowRight"}), false);
assert!(matches!(d.h.state().ui.dock, DockNode::Split { size: SplitSize::FixedA(408.0), .. }), "control input must work without native activation");
assert_eq!(d.h.state().session.state.playheads, before_playheads);
assert!(Arc::ptr_eq(&d.h.state().session.project, &before_project));
assert!(!d.h.state().playback.playing);
}
#[test]
fn interactive_control_keeps_native_keyboard_and_menu_input() {
let mut d = Driver::new(false);
native_key(&mut d.h, Key::J);
d.frames(2, true);
assert!(d.h.state().playback.playing);
assert_eq!(d.h.state().playback.speed, -1.0);
d.ok("ui.playback", json!({"action":"stop"}), true);
let (tx, rx) = channel();
d.h.state_mut().command_inbox = Some(rx);
tx.send("playback.reverse".into()).unwrap();
d.frames(2, true);
assert!(d.h.state().playback.playing);
assert_eq!(d.h.state().playback.speed, -1.0);
}
#[test]
fn native_menu_commands_cannot_contaminate_control_only_validation() {
let mut d = Driver::new(true);
let (tx, rx) = channel();
d.h.state_mut().command_inbox = Some(rx);
tx.send("playback.reverse".into()).unwrap();
d.frames(2, true);
assert!(!d.h.state().playback.playing);
// Intentional playback remains reachable through the control command path.
d.ok("engine.execute", json!({"command":"playback.reverse","params":{}}), false);
assert!(d.h.state().playback.playing);
assert_eq!(d.h.state().playback.speed, -1.0);
}
#[test]
fn isolation_preserves_close_and_screenshot_delivery_without_claiming_os_focus() {
let mut d = Driver::new(true);
let mut raw = egui::RawInput { focused: false, ..Default::default() };
let viewport = raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap();
viewport.focused = Some(false);
viewport.events.push(egui::ViewportEvent::Close);
raw.events = vec![
Event::Text("unrelated typing".into()),
Event::WindowFocused(false),
Event::Screenshot {
viewport_id: egui::ViewportId::ROOT,
user_data: Default::default(),
image: Arc::new(egui::ColorImage::new([1, 1], vec![egui::Color32::WHITE])),
},
];
raw.hovered_files.push(Default::default());
#[derive(Debug)]
struct UnrelatedFile;
impl egui::DroppedFile for UnrelatedFile {
fn path(&self) -> &std::path::Path {
std::path::Path::new("/unrelated-input.txt")
}
#[cfg(not(target_arch = "wasm32"))]
fn bytes(&self) -> Result<Vec<u8>, String> {
Err("must not read unrelated native input".into())
}
#[cfg(target_arch = "wasm32")]
fn bytes_async(&self) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<u8>, String>> + '_>> {
Box::pin(async { Err("must not read unrelated native input".into()) })
}
}
raw.dropped_files.push(Arc::new(UnrelatedFile));
let ctx = d.h.ctx.clone();
eframe::App::raw_input_hook(d.h.state_mut(), &ctx, &mut raw);
assert!(raw.focused);
assert_eq!(raw.viewports[&egui::ViewportId::ROOT].focused, Some(false));
assert_eq!(raw.viewports[&egui::ViewportId::ROOT].events, vec![egui::ViewportEvent::Close]);
assert!(matches!(raw.events.as_slice(), [Event::Screenshot { .. }, Event::ModifiersChanged(Modifiers::NONE)]));
assert!(raw.hovered_files.is_empty() && raw.dropped_files.is_empty());
let state = filmcraft_ui_egui::control::inspect(d.h.state(), &ctx);
assert_eq!(state["input"]["nativeWindowFocused"], false);
}
#[test]
fn focus_reports_real_widget_ownership_and_rejects_bad_targets_without_changing_it() {
let mut d = Driver::new(true);
let focus = d.ok("ui.focus", json!({"id":"tools.Selection"}), false);
assert_eq!(focus["focusRequested"], "tools.Selection");
let state = d.ok("ui.inspect", json!({}), false);
assert_eq!(state["keyboardFocus"]["widgetId"], focus["widgetId"]);
assert_eq!(state["keyboardFocus"]["elementIds"], json!(["tools.Selection"]));
assert_eq!(state["keyboardFocus"]["active"], true);
assert_eq!(state["input"]["nativeWindowFocused"], false);
assert_eq!(state["input"]["effectiveWindowFocused"], true);
let focused = d.h.ctx.memory(|memory| memory.focused());
for params in [
json!({"id":7}),
json!({"id":"missing"}),
json!({"id":"panel.Tools"}),
json!({"id":"tools.Rolling"}),
json!({"id":"tools.Selection","activate":true}),
json!([]),
] {
let (request, _) = ControlRequest::new("ui.focus", params);
let ctx = d.h.ctx.clone();
let filmcraft_ui_egui::control::Outcome::Done(result) = filmcraft_ui_egui::control::handle(d.h.state_mut(), &ctx, &request) else {
panic!("bad focus target must fail immediately");
};
assert_eq!(result["ok"], false, "{result}");
assert_eq!(d.h.ctx.memory(|memory| memory.focused()), focused);
}
d.ok("ui.key", json!({"key":"Tab"}), false);
let state = d.ok("ui.inspect", json!({}), false);
assert_eq!(state["keyboardFocus"]["elementIds"], json!(["tools.TrackSelectForward"]));
assert_ne!(state["keyboardFocus"]["widgetId"], focus["widgetId"]);
assert!(!d.h.state().playback.playing);
}
#[test]
fn modified_control_click_adds_to_timeline_selection() {
for isolated in [true, false] {
let mut d = Driver::new(isolated);
d.ok("ui.set", json!({"timeline":{"fit":true}}), false);
d.frames(30, false);
let seq = d.h.state().session.active_sequence().unwrap();
let first = seq.video_tracks[0].items[0].id;
let second = seq.video_tracks[0].items[1].id;
let project = d.h.state().session.project.clone();
d.ok("engine.execute", json!({"command":"timeline.select","params":{"clips":[first.0]}}), false);
let position = d.ok("ui.timeline.locate", json!({"clip":second.0}), false);
d.ok("ui.click", json!({"x":position["x"],"y":position["y"],"shift":true}), false);
assert!(d.h.state().session.state.selection.contains(&first), "Shift-click must retain the first selected clip (isolated={isolated})");
assert!(d.h.state().session.state.selection.contains(&second));
assert!(Arc::ptr_eq(&d.h.state().session.project, &project));
assert!(!d.h.state().playback.playing);
assert_eq!(d.h.ctx.input(|input| input.modifiers), Modifiers::NONE);
}
}
#[test]
fn modified_control_drag_keeps_shift_until_release_and_clears_it_afterward() {
let mut d = Driver::new(true);
let (request, reply) = ControlRequest::new(
"ui.drag",
json!({
"from":{"id":"dock.gutter."},"to":{"id":"dock.gutter.","fy":3.0},"steps":4,"shift":true,
}),
);
d.tx.send(request).unwrap();
let mut held_frames = 0;
for _ in 0..60 {
d.frames(1, false);
if d.h.ctx.input(|input| input.pointer.any_down() || input.pointer.any_released()) {
held_frames += 1;
assert!(d.h.ctx.input(|input| input.modifiers.shift), "the synthetic gesture owns Shift throughout press, movement and release");
}
if let Ok(result) = reply.try_recv() {
assert_eq!(result["ok"], true);
break;
}
}
assert!(held_frames >= 4);
d.frames(3, false);
assert_eq!(d.h.ctx.input(|input| input.modifiers), Modifiers::NONE);
}

View File

@ -0,0 +1,95 @@
//! Shared pane resizing in the full application, including application shortcut ownership.
use egui::{Event, Id, Key, Modifiers, PointerButton, Rect, pos2, vec2};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::dock::{DockNode, PanelKind, SplitSize};
use filmcraft_ui_egui::theme::ThemeKind;
use serde_json::json;
fn step(h: &mut Harness<'_, FilmcraftApp>) {
h.step();
assert!(h.state().ui_error.is_none(), "the app caught a UI panic: {:?}", h.state().ui_error);
}
fn frames(h: &mut Harness<'_, FilmcraftApp>, count: usize) {
for _ in 0..count {
step(h);
}
}
fn snapshots(h: &mut Harness<'_, FilmcraftApp>, 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), ("medium", ThemeKind::Medium), ("light", ThemeKind::Light)] {
let ctx = h.ctx.clone();
h.state_mut().set_theme(&ctx, theme);
frames(h, 3);
h.render().unwrap().save(directory.join(format!("filmcraft-{phase}-{name}.png"))).unwrap();
}
let ctx = h.ctx.clone();
h.state_mut().set_theme(&ctx, ThemeKind::Dark);
frames(h, 3);
}
fn first_size(h: &Harness<'_, FilmcraftApp>) -> 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("file.openDemoProject", json!({})).unwrap();
let app = FilmcraftApp::new(session);
// Opt in to GPU snapshots while the ordinary test remains headless and renderer-independent.
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);
frames(&mut h, 4);
snapshots(&mut h, directory.as_deref(), "workspace");
h.state_mut().ui.focused = PanelKind::Timeline;
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::Timeline], active: 0 }),
};
frames(&mut h, 3);
let before_project = h.state().session.project.clone();
let before_playheads = h.state().session.state.playheads.clone();
let id = Id::new(("dock-gutter", String::new()));
h.ctx.memory_mut(|memory| memory.request_focus(id));
frames(&mut h, 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 });
step(&mut h);
h.event(Event::Key { key: Key::ArrowRight, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
step(&mut h);
assert_eq!(first_size(&h), 408.0);
assert_eq!(h.state().session.state.playheads, before_playheads, "the Timeline shortcut must not seek");
assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &before_project));
frames(&mut h, 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));
step(&mut h);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
step(&mut h);
for offset in [5.0, 10.0, 15.0, 20.0] {
h.event(Event::PointerMoved(from + vec2(offset, 0.0)));
step(&mut h);
}
h.event(Event::PointerButton { pos: from + vec2(20.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
step(&mut h);
assert_eq!(first_size(&h), 428.0);
assert_eq!(h.state().session.state.playheads, before_playheads);
}

View File

@ -0,0 +1,107 @@
//! Keyboard traversal reaches actual controls and preserves document/playback state.
use std::sync::Arc;
use egui::{Event, Id, Key, Modifiers, vec2};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::{
FilmcraftApp,
dock::{DockNode, PanelKind},
state::Tool,
};
use serde_json::json;
fn key(h: &mut Harness<'_, FilmcraftApp>, key: Key, modifiers: Modifiers) {
for pressed in [true, false] {
h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
h.step();
}
h.run_steps(2);
assert!(h.state().ui_error.is_none());
}
#[test]
fn tab_traversal_and_space_select_only_the_focused_tool_with_visible_focus() {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let project = session.project.clone();
let playheads = session.state.playheads.clone();
let app = FilmcraftApp::new(session);
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(move |_| app);
h.run_steps(4);
h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Tools], active: 0 };
h.run_steps(3);
let first = Id::new(("tool", "Selection".to_string()));
let second = Id::new(("tool", "TrackSelectForward".to_string()));
let third = Id::new(("tool", "Ripple".to_string()));
for _ in 0..128 {
key(&mut h, Key::Tab, Modifiers::NONE);
if h.ctx.memory(|m| m.focused()) == Some(first) {
break;
}
}
assert_eq!(h.ctx.memory(|m| m.focused()), Some(first), "Tab must reach the first tool");
key(&mut h, Key::Tab, Modifiers::NONE);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(second));
key(&mut h, Key::Tab, Modifiers::NONE);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(third));
key(&mut h, Key::Tab, Modifiers::SHIFT);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(second));
assert_eq!(h.state().ui.tool, Tool::Selection, "traversal does not activate controls");
key(&mut h, Key::Space, Modifiers::NONE);
assert_eq!(h.state().ui.tool, Tool::TrackSelectForward);
assert!(!h.state().playback.playing, "Space belongs to the focused tool");
assert_eq!(h.state().session.state.playheads, playheads);
assert!(Arc::ptr_eq(&h.state().session.project, &project));
let response = h.ctx.read_response(second).unwrap();
assert!(
h.output().shapes.iter().any(|shape| matches!(&shape.shape,
egui::Shape::Rect(r) if r.rect == response.rect.shrink(2.0)
&& r.stroke.color == h.state().tokens.focus && r.stroke.width > 0.0)),
"the focused custom-painted tool needs a visible focus outline"
);
}
#[test]
fn dock_tab_traversal_and_enter_change_the_visible_panel_without_editing() {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let project = session.project.clone();
let playheads = session.state.playheads.clone();
let app = FilmcraftApp::new(session);
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(move |_| app);
h.run_steps(4);
h.state_mut().ui.show_header = false;
h.state_mut().ui.show_status_bar = false;
h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Effects], active: 0 };
h.run_steps(3);
let first = Id::new(("tab", String::new(), 0usize));
let second = Id::new(("tab", String::new(), 1usize));
for _ in 0..128 {
key(&mut h, Key::Tab, Modifiers::NONE);
if h.ctx.memory(|m| m.focused()) == Some(second) {
break;
}
}
assert_eq!(h.ctx.memory(|m| m.focused()), Some(second));
assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 0, .. }));
let response = h.ctx.read_response(second).unwrap();
assert!(
h.output().shapes.iter().any(|shape| matches!(&shape.shape,
egui::Shape::Rect(r) if r.rect == response.rect.shrink(2.0)
&& r.stroke.color == h.state().tokens.focus && r.stroke.width > 0.0)),
"the focused dock tab needs a visible focus outline"
);
key(&mut h, Key::Space, Modifiers::NONE);
assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 1, .. }));
assert_eq!(h.state().ui.focused, PanelKind::Effects);
key(&mut h, Key::Tab, Modifiers::SHIFT);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(first));
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 0, .. }));
assert_eq!(h.state().ui.focused, PanelKind::Project);
assert!(Arc::ptr_eq(&h.state().session.project, &project));
assert_eq!(h.state().session.state.playheads, playheads);
assert!(!h.state().playback.playing);
}

View File

@ -38,7 +38,8 @@ Methods (handlers in `crates/ui-egui/src/control.rs`):
| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null |
| `ui.playback` | `{action: play|stop|toggle, speed?}` | |
| `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) |
| `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus. `app.quit` quits without the save prompt (unsaved changes stay in the recovery journal); the command `app.quit` (Quit, Cmd+Q) asks first |
| `ui.resize`, `app.quit` | `{width,height}` / `{}` | resize or close the native window; `app.quit` quits without the save prompt (unsaved changes stay in the recovery journal), while the command `app.quit` asks first |
| `ui.focus` | `{}` / `{id}` | empty parameters activate the native window; a widget ID requests egui focus without OS activation |
| `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle buffers of the frame pool, the planes of evicted frames and the compositor's float images, which a standalone export frees when it ends; and buffers taken from it instead of allocated), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding), `audio` (desktop: sound is mixed 200 ms ahead of the device; device `callbacks`, `underruns` and `missingFrames` played as silence because the mixer fell behind, `minLeadMs` the least sound left buffered); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `monitor`: the `frame` and `revision` of the picture the Program monitor shows against `playheadFrame` and `projectRevision` (how far a drag or a scrub is ahead of the picture); `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) |
**Timeline thumbnails.** `ui.set {timeline: {thumbnails}}` sets the Timeline wrench menu's Show Video
@ -53,7 +54,22 @@ still apply. `engine.commands` and the menus always show the selection's enablem
**Focus.** Driving the app never steals the user's keyboard. Started with `--control`, the app opens
without activating itself. UI requests and screenshots that need a rendered frame bring the window
forward without making it key (macOS `orderFrontRegardless`), so the user's typing keeps going to
the app they are in. Only an explicit `ui.focus` activates FilmCraft.
the app they are in. Only an explicit `ui.focus {}` activates FilmCraft. Injected keys work on
focused widgets even when the native window is in the background.
`ui.focus {id}` targets an enabled, visible widget registered with a real egui response. It rejects
unknown IDs, non-widget regions and hidden tool aliases. The reply acknowledges the request;
`ui.inspect.keyboardFocus` reports the actual widget ID, matching element IDs, label and active
focus. This is separate from `ui.focused`, which identifies the active panel. Tools, dock tabs,
tab menus and divider handles expose widget IDs through `ui.elements`. Tab/Shift-Tab move widget
focus; Space/Enter activate focused buttons and tabs, with visible focus outlines.
For unattended validation, start with `--control <port> --control-input-only`. This opt-in mode
accepts widget input through control requests and excludes native keyboard, pointer, file-drop
and menu commands. Window close/quit and screenshots remain available. Ordinary launches and
`--control` alone keep native input enabled. `ui.inspect.input` distinguishes `mode`, the observed
`nativeWindowFocused`, and egui's `effectiveWindowFocused`; effective focus does not activate the
OS window or prove physical-keyboard delivery.
Element ids are stable, e.g. `timeline.clip.<id>`, `timeline.track.V1.lock`, `tools.Razor`,
`project.item.<id>`, `effects.item.gaussian_blur`, `panel.tab.Timeline`, `program.transport.playback.toggle`.
@ -153,3 +169,26 @@ Annotations, argument checking, errors and export progress / cancellation:
[agents.md § Conventions](agents.md#conventions).
Time is in ticks (254 016 000 000 per second); commands also accept `seconds`, `frame` or `timecode`.
## Panel docking commands
`ui.menu.invoke` and `engine.execute` route these frontend commands:
| Command | Parameters | Result |
| --- | --- | --- |
| `window.floatPanel` | `{panel?, rect?: [x,y,width,height]}` | Move an existing panel into a floating group. Omitted panel uses the focused panel; omitted rect uses a centered 420×320 group. |
| `window.dockPanel` | `{panel?, anchor, zone?: "center"\|"left"\|"right"\|"top"\|"bottom", before?}` | Move into the anchor's tab group or a side split; `before` names an existing destination tab for precise ordering. Floating destinations accept tabs only. |
| `window.closePanel` | `{panel?}` | Close a docked or floating panel without closing its project documents. |
Invalid source/target identities and non-finite or out-of-range geometry return an error with
the layout unchanged. `ui.inspect.ui.floating` reports floating groups and their logical-point
rectangles. `panel.drag.<Panel>` locates each panel's header grip; `panel.float.<index>.move`
and `.resize` locate floating geometry handles. Named workspace saves include these groups.
`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.

View File

@ -103,7 +103,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("filmcraft-").unwrap_or(name)