Files
effectcraft/crates/ui-egui/src/dock_ui.rs
T

865 lines
43 KiB
Rust

//! EffectCraft's panels in craft-ui's shared docking. craft-ui draws the frames, tab strips,
//! stacked panels, floating windows, dividers and drop previews and owns the gestures (drag a tab
//! onto a group to tab it there or split it off to a side; hold Cmd/Ctrl at the release, or drop
//! outside every group, to float it). EffectCraft draws the panel bodies, the ≡ panel menu, the
//! Timeline's comp tabs and the viewer locks in each group header, and runs every layout change
//! through its command path (`window.dockAction`), so pointer, menu and control-channel changes
//! share one route.
use crate::state::UiState;
use craft_ui::docking::{
Action, Divider, DockArea, DockCommand, DockContent, DockError, DockEvent, DockStyle, FloatSpace, Floating, Hit, Node, ResizeEdge, UiDock,
};
use egui::{Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::{Value, json};
use crate::dock::{self, DocAction, DocTab, PanelKind, Workspace, WorkspaceExt as _};
use crate::icons::{self, Icon};
use crate::theme::Tokens;
use crate::{EffectcraftApp, panels};
/// The dock area's egui id; `DockArea::hit_rects` and `float_space` read by it.
pub fn area_id() -> egui::Id {
egui::Id::unique("effectcraft-docking")
}
/// Width of the ≡ panel menu button at the end of each tab strip.
const MENU_W: f32 = 26.0;
/// Width of the viewer lock before it.
const LOCK_W: f32 = 18.0;
/// The smallest and largest native panel window, in points.
pub const PANEL_WINDOW_MIN: [f32; 2] = [160.0, 100.0];
pub const PANEL_WINDOW_MAX: f32 = 16_384.0;
/// The farthest a native panel window may sit from the desktop origin, in points.
pub const PANEL_WINDOW_MAX_POS: f32 = 100_000.0;
/// A native panel window's desktop rect made safe to hand to the windowing system: finite, with
/// a size within [`PANEL_WINDOW_MIN`]..=[`PANEL_WINDOW_MAX`] and a position within
/// ±[`PANEL_WINDOW_MAX_POS`] (hostile workspace files and bad monitor reports included).
pub fn sanitized_window_rect(rect: [f32; 4]) -> [f32; 4] {
let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback };
let [x, y, w, h] = rect;
[
finite(x, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS),
finite(y, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS),
finite(w, 420.0).clamp(PANEL_WINDOW_MIN[0], PANEL_WINDOW_MAX),
finite(h, 320.0).clamp(PANEL_WINDOW_MIN[1], PANEL_WINDOW_MAX),
]
}
/// How long to wait for the main window to report its frame before migrated native windows
/// keep their places as they are, in seconds.
const FRAME_REPORT_WAIT: f64 = 2.0;
/// Moves the native rects of [`UiState::native_outer`]'s windows from the OS window's outer
/// position to its content position, by the main window's frame offset (`inner` less `outer`,
/// the main window's desktop rects): the earlier version's windows had the same OS title bar.
/// Without a report by [`FRAME_REPORT_WAIT`] (a backend without window positions), the places
/// stay as they are.
pub(crate) fn native_outer_to_content(ui: &mut UiState, outer: Option<Rect>, inner: Option<Rect>, now: f64) {
let offset = match (outer, inner) {
(Some(outer), Some(inner)) if outer.min.is_finite() && inner.min.is_finite() => inner.min - outer.min,
_ if now < FRAME_REPORT_WAIT => return,
_ => egui::Vec2::ZERO,
};
let ids = std::mem::take(&mut ui.native_outer);
for window in ui.dock.layout.floating.iter_mut().filter(|w| ids.contains(&w.id)) {
if let Some([x, y, w, h]) = window.native {
window.native = Some(sanitized_window_rect([x + offset.x, y + offset.y, w, h]));
}
}
}
/// A new floating group's default rect near the centre of `screen`.
pub fn default_float_rect(screen: Rect) -> [f32; 4] {
let c = screen.center();
[c.x - 210.0, c.y - 160.0, 420.0, 320.0]
}
/// A panel named by its title or id ("Effect Controls", "EffectControls") or its serialized
/// form (`{"Viewer": 2}`).
fn panel_param(value: &Value) -> Result<PanelKind, String> {
match value.as_str() {
Some(name) => PanelKind::from_name(name).ok_or(format!("unknown panel `{name}`")),
None => serde_json::from_value::<PanelKind>(value.clone()).map_err(|_| "unknown `panel`".to_string()),
}
}
fn check_raw_action(action: &Action<PanelKind>) -> Result<(), String> {
if matches!(action, Action::Open { .. } | Action::OpenAt { .. }) {
return Err("use the panel open commands to preserve viewer and ScriptUI lifecycle callbacks".into());
}
Ok(())
}
/// The docking commands: `window.dockAction {action}`, craft-ui's `window.panel.*` commands,
/// `ui.dock` and `ui.dock.reset`. `None` for any other id.
pub(crate) fn route(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option<Result<Value, String>> {
match id {
"window.dockAction" => Some((|| {
let value = params.get("action").cloned().ok_or("missing docking action")?;
let action: Action<PanelKind> = serde_json::from_value(value).map_err(|error| format!("invalid docking action: {error}"))?;
check_raw_action(&action)?;
app.apply_dock(action)?;
Ok(Value::Null)
})()),
"ui.dock.reset" => {
let name = app.ui.workspace.clone();
app.set_workspace(&name);
Some(Ok(json!({"docking": app.ui.dock})))
}
"ui.dock" => Some((|| {
let mut tagged = if params.is_object() { params.clone() } else { json!({}) };
if let Some(fields) = tagged.as_object_mut() {
fields.insert("command".into(), json!("ui.dock"));
}
let command: DockCommand<PanelKind> = serde_json::from_value(tagged).map_err(|error| format!("invalid ui.dock params: {error}"))?;
app.run_dock_command(ctx, command)?;
Ok(json!({"docking": app.ui.dock}))
})()),
_ if id.starts_with("window.panel.") && DockCommand::<PanelKind>::IDS.contains(&id) => Some((|| {
let mut tagged = if params.is_object() { params.clone() } else { json!({}) };
if let Some(fields) = tagged.as_object_mut() {
fields.insert("command".into(), json!(id));
// EffectCraft's panel names ("Effect Controls") as well as craft-ui's ids.
for key in ["panel", "anchor", "before"] {
if let Some(name) = fields.get(key).and_then(Value::as_str) {
let panel = PanelKind::from_name(name).ok_or(format!("unknown panel `{name}`"))?;
fields.insert(key.into(), json!(panel));
}
}
}
let command: DockCommand<PanelKind> = serde_json::from_value(tagged).map_err(|error| format!("invalid docking command: {error}"))?;
app.run_dock_command(ctx, command)?;
Ok(Value::Null)
})()),
"window.panelWindow" => Some((|| {
let panel = panel_param(params.get("panel").ok_or("panelWindow: need `panel`")?).map_err(|e| format!("panelWindow: {e}"))?;
let window = match params.get("window") {
None | Some(Value::Null) => None,
Some(value) => Some(value.as_bool().ok_or("panelWindow: `window` must be true or false")?),
};
let window = app.set_panel_window(ctx, panel, window)?;
Ok(json!({"window": window}))
})()),
_ => None,
}
}
impl EffectcraftApp {
/// A layout action from the dock area or a menu, through the command path the control
/// channel uses.
pub(crate) fn dock_command(&mut self, ctx: &egui::Context, action: Action<PanelKind>) -> Result<(), String> {
let value = serde_json::to_value(action).map_err(|error| error.to_string())?;
crate::menus::invoke(self, ctx, "window.dockAction", json!({"action": value})).map(|_| ())
}
/// Run a craft-ui docking command on the current workspace, all or nothing. Named
/// workspaces and Reset are EffectCraft's own (`window.workspace`, `window.resetWorkspace`).
pub(crate) fn run_dock_command(&mut self, ctx: &egui::Context, command: DockCommand<PanelKind>) -> Result<(), String> {
if let DockCommand::Layout { action } | DockCommand::UiDock(UiDock::Action { action }) = &command {
check_raw_action(action)?;
}
if command.is_app_owned() {
if matches!(command, DockCommand::ResetWorkspace {}) {
let name = self.ui.workspace.clone();
self.set_workspace(&name);
return Ok(());
}
return Err("use the window.workspace commands for named workspaces".into());
}
// A closed panel opens through EffectCraft's own path, which puts it in its usual place
// and runs the viewer and ScriptUI lifecycles.
let shown = match &command {
DockCommand::Activate { panel } | DockCommand::UiDock(UiDock::Operation(craft_ui::docking::DockOperation::Open { panel })) => Some(*panel),
DockCommand::UiDock(UiDock::Operation(craft_ui::docking::DockOperation::Activate { panel })) => Some(*panel),
_ => None,
};
if let Some(panel) = shown.filter(|panel| !self.ui.dock.is_open(*panel)) {
self.show_panel(panel);
return Ok(());
}
let space = craft_ui::docking::float_space(ctx, area_id()).unwrap_or_else(|| FloatSpace::new(ctx.content_rect()));
match command.to_actions(&self.ui.dock, false, &space) {
Ok(actions) => self.apply_dock_all(actions),
// Showing a hidden panel at an edge, and Lock, change more than the layout.
Err(DockError::NotAllowed) => {
let mut workspace = self.ui.dock.clone();
let closed = command.apply(&mut workspace, false, &space, dock::permissions).map_err(|error| error.to_string())?;
self.commit_workspace(workspace, &closed, true);
Ok(())
}
Err(error) => Err(error.to_string()),
}
}
pub fn apply_dock(&mut self, action: Action<PanelKind>) -> Result<(), String> {
self.apply_dock_all(vec![action])
}
/// Apply `actions` all or none. No move or float may empty the docked tree, so a floating
/// panel always has somewhere to dock back to; closing the last docked panel stays allowed.
pub(crate) fn apply_dock_all(&mut self, actions: Vec<Action<PanelKind>>) -> Result<(), String> {
let mut workspace = self.ui.dock.clone();
let mut relocating = false;
let mut closed = Vec::new();
for action in actions {
relocating |= matches!(
&action,
Action::Move { .. }
| Action::Float { .. }
| Action::MoveGroup { .. }
| Action::MoveWindow { .. }
| Action::MovePanel { .. }
| Action::MoveColumn { .. }
);
let closing = matches!(&action, Action::Close { .. } | Action::CloseGroup { .. } | Action::CloseWindow { .. } | Action::CloseOthers { .. });
let docked = workspace.layout.root.is_some();
closed.extend(workspace.apply(action, dock::permissions).map_err(|error| error.to_string())?);
if !closing && docked && workspace.layout.root.is_none() {
return Err("the last docked panel must remain in the workspace".into());
}
}
self.commit_workspace(workspace, &closed, relocating);
Ok(())
}
/// Take a changed workspace and release the state of the panels it closed: a ScriptUI
/// panel's script window closes (its onClose runs), a viewer forgets its comp.
fn commit_workspace(&mut self, workspace: Workspace, closed: &[PanelKind], relocating: bool) {
self.ui.dock = workspace;
for &p in closed {
if let PanelKind::ScriptPanel(id) = p
&& self.session.script_ui.window(id).is_some()
&& let Err(e) = self.session.execute("scriptui.close", json!({"window": id}))
{
self.ui.status = e.to_string();
}
panels::viewers::on_close(self, p);
}
if relocating || self.ui.maximized.is_some_and(|m| !self.ui.dock.is_docked(m)) {
self.ui.maximized = None;
}
}
/// Close a panel wherever it is (docked or floating). A rejected close changes nothing.
pub fn close_panel(&mut self, p: PanelKind) {
if self.ui.dock.is_open(p) {
let _ = self.apply_dock(Action::Close { panel: p });
}
}
/// Toggle the maximized panel (`p`, or none). Only a docked frame maximizes.
pub fn toggle_maximize(&mut self, p: PanelKind) {
self.ui.maximized = if self.ui.maximized == Some(p) || !self.ui.dock.is_docked(p) { None } else { Some(p) };
}
/// The panel whose body is under `pos` (from the last frame), else the focused one.
pub fn panel_at(&self, pos: Option<egui::Pos2>) -> PanelKind {
pos.and_then(|p| self.dock_rects.iter().rev().find(|(_, r)| r.contains(p)).map(|(k, _)| *k)).unwrap_or(self.ui.focused)
}
/// Whether panel `p` is in a floating window with a desktop place: its own OS window on
/// desktop (craft-ui `native-windows`); on the web and in embedded viewports the window
/// stays in the page and the place is kept for a desktop session.
pub fn in_panel_window(&self, p: PanelKind) -> bool {
self.ui.dock.layout.floating.iter().any(|w| w.contains(&p) && w.native.is_some())
}
/// Move panel `p` into a floating window of its own, which on desktop is an OS window that
/// can go on another monitor, or back into the main window (`window` false), where it joins
/// the first docked group. `None` toggles. A docked panel is undocked first, and its window
/// opens over the panel's place in the main window. Returns whether `p` is now in a window
/// of its own.
pub fn set_panel_window(&mut self, ctx: &egui::Context, p: PanelKind, window: Option<bool>) -> Result<bool, String> {
let current = self.in_panel_window(p);
let window = window.unwrap_or(!current);
if window == current {
return Ok(current);
}
if !window {
// On desktop every floating window is an OS window, so the way back is docking.
let anchor = self.ui.dock.layout.root.as_ref().and_then(|root| {
let mut panels = Vec::new();
dock::node_panels(root, &mut panels);
panels.into_iter().find(|panel| *panel != p)
});
let actions = match anchor {
Some(anchor) => vec![Action::Move { panel: p, anchor, placement: craft_ui::docking::Placement::Tab { before: None } }],
// Nothing is docked: the panel becomes the docked layout.
None => vec![Action::Close { panel: p }, Action::Open { panel: p, anchor: None }],
};
let mut workspace = self.ui.dock.clone();
// One transaction, so the panel is never reported closed and keeps its state.
let closed = workspace.apply_all(actions, dock::permissions).map_err(|_| format!("can't dock {}", p.title()))?;
self.commit_workspace(workspace, &closed, true);
return Ok(false);
}
if !self.ui.dock.is_floating(p) {
if !self.ui.dock.is_docked(p) {
return Err(format!("{} is not open", p.title()));
}
let rect = default_float_rect(ctx.input_for(egui::ViewportId::ROOT, |i| i.content_rect()));
self.apply_dock(Action::Float { panel: p, rect }).map_err(|_| format!("can't undock {}", p.title()))?;
}
let Some([x, y, w, h]) = self.ui.dock.layout.floating.iter().find(|f| f.contains(&p)).map(|f| f.rect) else {
return Err(format!("can't undock {}", p.title()));
};
let origin = ctx.input_for(egui::ViewportId::ROOT, |i| i.viewport().inner_rect.map(|r| r.min)).unwrap_or(pos2(0.0, 0.0));
let rect = sanitized_window_rect([origin.x + x, origin.y + y, w, h]);
self.apply_dock(Action::SetNative { panel: p, rect: Some(rect) })?;
if self.ui.maximized == Some(p) {
self.ui.maximized = None;
}
Ok(true)
}
/// Tab labels that name what the panel shows, as After Effects does: "Composition Intro",
/// "Effect Controls Title", "Properties: Title" (the Timeline's tabs are its comps, see
/// [`Self::tab_docs`]).
fn tab_titles(&self) -> Vec<(PanelKind, String)> {
let mut out = Vec::new();
// ScriptUI panels are named after their script.
for w in self.session.script_ui.windows.iter().filter(|w| w.kind == effectcraft_engine::scriptui::WindowKind::Panel) {
if let Some(t) = panels::scriptui_view::panel_title(self, w.id) {
out.push((PanelKind::ScriptPanel(w.id), t));
}
}
// Each Composition viewer is named after the comp it shows.
for id in std::iter::once(0).chain(self.ui.viewers.keys().copied().filter(|v| *v != 0)) {
if let Some(t) = panels::viewers::title(self, id) {
out.push((panels::viewers::panel(id), t));
}
}
let Some(comp) = self.session.active_comp() else { return out };
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
out.push((PanelKind::EffectControls, format!("{} {}", crate::i18n::command_label("window.panel", "Effect Controls"), l.name)));
out.push((PanelKind::Properties, format!("{}: {}", crate::i18n::command_label("window.panel", "Properties"), l.name)));
}
out
}
/// A comp's label colour as a tab swatch (faint without a label).
fn comp_swatch(&self, cid: effectcraft_engine::project::ItemId) -> egui::Color32 {
match self.session.project.item(cid).map(|i| i.label) {
Some(l) if l != effectcraft_engine::color::Label::None => self.tokens.label(l),
_ => self.tokens.text_faint,
}
}
/// The Timeline's comp tabs: one per open comp, as in After Effects (opening a precomp adds a
/// tab next to its parent's instead of replacing it).
fn tab_docs(&self) -> Vec<DocTab> {
let st = &self.session.state;
st.open_comps
.iter()
.filter_map(|id| {
let it = self.session.project.item(*id).filter(|i| i.as_comp().is_some())?;
Some(DocTab { id: id.0, title: it.name.clone(), swatch: self.comp_swatch(*id), active: st.active_comp == Some(*id) })
})
.collect()
}
/// The label-colour swatch before a tab's name: each Composition viewer's comp, and Effect
/// Controls' (and with Use Label Color for Related Tabs, Properties') selected layer.
fn tab_swatches(&self) -> Vec<(PanelKind, egui::Color32)> {
let mut v = Vec::new();
for id in std::iter::once(0).chain(self.ui.viewers.keys().copied().filter(|v| *v != 0)) {
let swatch = panels::viewers::comp_of(self, id).map_or(self.tokens.text_faint, |c| self.comp_swatch(c));
v.push((panels::viewers::panel(id), swatch));
}
if let Some(l) = self.session.active_comp().and_then(|c| self.session.state.selected_layers.first().and_then(|id| c.layer(*id)))
&& l.label != effectcraft_engine::color::Label::None
{
v.push((PanelKind::EffectControls, self.tokens.label(l.label)));
if self.session.prefs.appearance.use_label_color_for_tabs {
v.push((PanelKind::Properties, self.tokens.label(l.label)));
}
}
v
}
/// Draw panel `p`'s body in `rect`: register its automation region, put the Composition Navigator above an active
/// viewer when a nested-comp flow exists, then call the panel's `show` and (for a viewer) the sketch overlay.
fn panel_body(&mut self, ui: &mut egui::Ui, p: PanelKind, rect: Rect) {
self.auto.add(&format!("panel.{}", p.id()), rect, p.title());
let mut content = rect.shrink(1.0);
let viewer = p == panels::viewers::active_panel(self);
if viewer && !self.ui.start_screen && panels::precomp::has_flow(self) {
// Composition Navigator: the flow of nested comps above the viewer.
let nav = Rect::from_min_size(content.min, vec2(content.width(), panels::precomp::NAV_H));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(nav).id_salt("comp-navigator"));
child.set_clip_rect(nav.intersect(ui.clip_rect()));
panels::precomp::navigator(self, &mut child, nav);
content.min.y = nav.max.y;
}
let mut child = ui.new_child(
egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())).ui_stack_info(egui::UiStackInfo::default().with_tag(crate::widgets::PANEL_TAG)),
);
child.set_clip_rect(content.intersect(ui.clip_rect()));
panels::show(self, &mut child, p, content);
if viewer && !self.ui.start_screen {
panels::anim_tools::sketch_overlay(self, &mut child);
}
}
/// A press in a panel (its body, tab or group header) focuses it; another Composition viewer
/// also becomes the active one.
fn focus_panel(&mut self, ctx: &egui::Context, p: PanelKind) {
self.ui.focused = p;
panels::viewer_text::panel_clicked(self, ctx, p);
if let Some(id) = panels::viewers::id_of(p) {
panels::viewers::activate(self, ctx, id);
}
}
/// Whether floating panels' OS windows take no input: a dialog of the app's own or a
/// command's OS file dialog is up. egui's `Modal` blocks only the window it is in.
pub(crate) fn floats_blocked(&mut self, ctx: &egui::Context) -> bool {
let (focused, now) = ctx.input(|i| (i.focused || i.raw.viewports.values().any(|v| v.focused == Some(true)), i.time));
let file_dialog = self.file_dialogs.up(focused, now);
self.dialog.is_some() || panels::dialogs::modal_open(ctx) || file_dialog
}
/// Draw the Home screen or workspace in `body`. Shared craft-ui docking owns the dock tree,
/// group chrome, tab dragging and floating native windows; this adapter draws panel bodies,
/// applies the returned events/actions and shows the panel menu.
pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) {
let t = self.tokens;
let ctx = ui.ctx().clone();
if !self.ui.native_outer.is_empty() {
let (outer, inner, now) = ctx.input_for(egui::ViewportId::ROOT, |i| (i.viewport().outer_rect, i.viewport().inner_rect, i.time));
native_outer_to_content(&mut self.ui, outer, inner, now);
}
if self.ui.start_screen {
// Home covers the whole workspace, as in After Effects (the panels wait behind it).
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("home"));
child.set_clip_rect(body.intersect(ui.clip_rect()));
panels::home::show(self, &mut child, body);
return;
}
let style = dock_style(ui, &t);
let maximized = self.ui.maximized.filter(|m| self.ui.dock.is_docked(*m));
let mut area = DockArea::new(area_id()).float_bounds(body).maximized(maximized).locked(self.ui.dock.locked).input_blocked(self.floats_blocked(&ctx));
if let Some(panel) = self.pending_panel_raise.take() {
area = area.raise_panel(panel);
}
let layout = self.ui.dock.layout.clone();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("effectcraft-dock-area"));
child.set_clip_rect(body.intersect(ui.clip_rect()));
let docs = self.tab_docs();
let ime = NativeIme::new(&layout.floating, &self.unpublished_viewports);
let mut content = EffectContent {
titles: self.tab_titles(),
swatches: self.tab_swatches(),
docs,
ctx: ctx.clone(),
style: style.clone(),
app: self,
tokens: t,
doc_actions: Vec::new(),
locks: Vec::new(),
menu: None,
bodies: Vec::new(),
ime,
};
let mut output = area.show_content(&mut child, &layout, &style, &mut content);
let EffectContent { doc_actions, locks, menu, bodies, ime, .. } = content;
self.dock_rects = bodies;
self.unpublished_viewports = ime.unpublished();
// A window that closed or docked takes its composition with it.
let previous = std::mem::replace(&mut self.native_viewports, ime.begun());
let gone: Vec<_> = previous.into_iter().filter(|v| !self.native_viewports.contains(v)).collect();
for viewport in gone {
panels::viewer_text::end_viewport(self, &ctx, viewport);
}
self.forward_window_input(&ctx, std::mem::take(&mut output.forwarded_events), std::mem::take(&mut output.window_files));
if let Some(panel) = output.focused {
self.focus_panel(&ctx, panel);
}
if let Some(error) = output.error {
self.ui.status = error.to_string();
}
for event in output.events {
if let DockEvent::ToggleMaximize { panel } = event {
self.toggle_maximize(panel);
}
}
for action in output.actions {
if let Err(error) = self.dock_command(&ctx, action) {
self.ui.status = error;
}
}
for p in locks {
if !self.ui.locked_tabs.remove(&p.id()) {
self.ui.locked_tabs.insert(p.id().to_string());
}
}
for action in doc_actions {
let result = match action {
// The Timeline's comp tabs: show that comp / close its Timeline.
DocAction::Activate(id) => {
let _ = self.apply_dock(Action::Activate { panel: PanelKind::Timeline });
self.focus_panel(&ctx, PanelKind::Timeline);
if self.session.state.active_comp.map(|c| c.0) == Some(id) {
Ok(Value::Null)
} else {
crate::menus::invoke(self, &ctx, "comp.open", json!({"comp": id}))
}
}
DocAction::Close(id) => crate::menus::invoke(self, &ctx, "comp.close", json!({"comp": id})),
};
if let Err(error) = result {
self.ui.status = error;
}
}
if let Some((panel, pos)) = menu {
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("panel-menu"), (panel, pos)));
}
let hits = DockArea::<PanelKind>::hit_rects(&ctx, area_id());
self.register_dock_hits(&hits);
panels::panel_menu_popup(self, ui);
}
/// Keys and dropped files that reach a floating panel's own OS window go to the main
/// window's next frame ([`crate::EffectcraftApp::raw_input_hook`]), so its shortcuts and
/// file import handle them. Text is not forwarded, and nothing is while a text field in
/// the main window has the keyboard.
fn forward_window_input(&mut self, ctx: &egui::Context, events: Vec<egui::Event>, files: Vec<craft_ui::docking::WindowFiles<PanelKind>>) {
let typing = ctx.egui_wants_keyboard_input();
let events = events.into_iter().filter(|event| !typing && !matches!(event, egui::Event::Text(_) | egui::Event::Ime(_)));
self.forwarded.events.extend(events);
self.forwarded.files.extend(files.into_iter().flat_map(|window| window.dropped));
if !self.forwarded.events.is_empty() || !self.forwarded.files.is_empty() {
ctx.request_repaint();
}
}
/// Automation ids for craft-ui's gesture sources: `panel.tab.<Panel>`, `panel.drag.<Panel>`
/// (the group's drag bar), `panel.border.<Panel>` (the top border above its tabs),
/// `panel.tabs.more.<Panel>`, `panel.header.<Panel>` (a stacked panel's open/close button),
/// `panel.float.<n>.move`, `.close`, `.resize` (bottom-right
/// corner) and `.resize.<Edge>`, `dock.gutter.<path>` (`a`/`b` per split level; a stack's gap
/// below entry `i` adds `s<i>`), `dock.corner.<outer>.<inner>` where two dividers meet, and
/// the same inside floating window `n` as `panel.float.<n>.gutter.…` and `.corner.…`.
fn register_dock_hits(&mut self, hits: &[(Hit<PanelKind>, Rect)]) {
let window = |panel: &PanelKind| self.ui.dock.layout.floating.iter().position(|window| window.contains(panel));
// Tabs are labelled as drawn: "Composition Intro", not "Composition".
let titles = self.tab_titles();
let title = |panel: &PanelKind| titles.iter().find(|(p, _)| p == panel).map_or_else(|| panel.title().to_string(), |(_, t)| t.clone());
let mut found = Vec::new();
for (hit, rect) in hits {
let entry = match hit {
Hit::Tab(panel) => (format!("panel.tab.{}", panel.id()), title(panel)),
Hit::DragBar(panel) => (format!("panel.drag.{}", panel.id()), "Move panel group".into()),
Hit::Border(panel) => (format!("panel.border.{}", panel.id()), "Move panel group".into()),
Hit::Overflow(panel) => (format!("panel.tabs.more.{}", panel.id()), "More tabs".into()),
Hit::StackToggle(panel) => (format!("panel.header.{}", panel.id()), title(panel)),
Hit::Title(panel) => match window(panel) {
Some(index) => (format!("panel.float.{index}.move"), "Move floating panel".into()),
None => continue,
},
Hit::Close(panel) => match window(panel) {
Some(index) => (format!("panel.float.{index}.close"), "Close floating panel".into()),
None => continue,
},
Hit::Resize(panel, edge) => match window(panel) {
Some(index) if *edge == ResizeEdge::BottomRight => (format!("panel.float.{index}.resize"), "Resize floating panel".into()),
Some(index) => (format!("panel.float.{index}.resize.{edge:?}"), "Resize floating panel".into()),
None => continue,
},
Hit::Divider(Divider::Split(path)) => (format!("dock.gutter.{}", path_id(path)), "gutter".into()),
Hit::Divider(Divider::Stack(panel)) => match dock::stack_gutter_id(&self.ui.dock.layout, *panel) {
Some(id) => (id, "gutter".into()),
None => continue,
},
Hit::Divider(Divider::Intersection(outer, inner)) => (format!("dock.corner.{}.{}", path_id(outer), path_id(inner)), "gutter corner".into()),
Hit::Divider(Divider::WindowSplit(panel, path)) => match window(panel) {
Some(index) => (format!("panel.float.{index}.gutter.{}", path_id(path)), "gutter".into()),
None => continue,
},
Hit::Divider(Divider::WindowIntersection(panel, outer, inner)) => match window(panel) {
Some(index) => (format!("panel.float.{index}.corner.{}.{}", path_id(outer), path_id(inner)), "gutter corner".into()),
None => continue,
},
_ => continue,
};
found.push((entry, *rect, DockArea::hit_id(area_id(), hit)));
}
// The widget id lets keyboard focus on a divider or grip map to its element.
for ((id, label), rect, widget) in found {
self.auto.add(&id, rect, &label);
if let Some(element) = self.auto.elements.last_mut() {
element.response_id = Some(widget);
}
}
}
}
/// A split path as `a`/`b` per level (first/second child).
fn path_id(path: &[bool]) -> String {
path.iter().map(|second| if *second { 'b' } else { 'a' }).collect()
}
/// After Effects' window is all frames: drops on all four window edges, Ctrl/Cmd-drag undocks,
/// a release over no target floats, and a floating window's title bar only moves it.
fn dock_style(ui: &egui::Ui, t: &Tokens) -> DockStyle {
let mut style = DockStyle::from_ui(ui);
style.side_edge_zones = true;
style.new_dock_zones = false;
style.maximize_item = true;
style.close_others_item = true;
style.command_press_floats = true;
style.float_on_empty_drop = true;
style.title_bar_docks = false;
style.float_hint = true;
style.wheel_selects_tabs = true;
style.middle_click_closes_tabs = true;
style.float_label = "Undock Panel".into();
style.float_group_label = "Undock Frame".into();
style.close_group_label = "Close Frame".into();
style.tab_height = t.tab_h;
style.gap = t.gap;
style.background = t.panel_bg;
style.tab_background = t.panel_bg;
style.active_background = t.panel_bg;
style.floating_background = t.panel_bg;
style.text = t.tab_text_active;
style.inactive_text = t.tab_text;
style.accent = t.focus;
style.font = Tokens::ui(12.0);
style
}
/// Panels with a viewer lock: the Composition viewers and the Timeline keep their comp while
/// locked.
fn has_lock(panel: PanelKind) -> bool {
matches!(panel, PanelKind::Composition | PanelKind::Viewer(_) | PanelKind::Timeline)
}
/// EffectCraft's side of the dock area for one frame.
struct EffectContent<'a> {
app: &'a mut EffectcraftApp,
tokens: Tokens,
titles: Vec<(PanelKind, String)>,
swatches: Vec<(PanelKind, egui::Color32)>,
docs: Vec<DocTab>,
/// Measures the comp tabs for their width in the tab row.
ctx: egui::Context,
style: DockStyle,
doc_actions: Vec<DocAction>,
/// Viewer locks clicked.
locks: Vec<PanelKind>,
/// The ≡ button clicked: its panel and where its menu opens.
menu: Option<(PanelKind, egui::Pos2)>,
/// Where each panel body was drawn, in drawing order (floating windows last).
bodies: Vec<(PanelKind, Rect)>,
/// Where each native window's IME descriptor is set up and published.
ime: NativeIme,
}
/// Which body of a native floating window publishes its IME descriptor.
///
/// Each OS window is an egui pass of its own. `viewer_text::finish_ime` publishes what the pass's
/// text editor recorded, and with nothing recorded it clears the composition and the preedit. A
/// window with another group before the Viewer that published after every body would clear the
/// preedit before the Viewer drew, and the Viewer's publication would then be a new context that
/// interrupts the composition, every pass. So a window publishes after the body in which its
/// editor has recorded its geometry and after its last body.
pub(crate) struct NativeIme {
/// The bodies each native window draws this pass, as its layout says.
expected: Vec<(egui::ViewportId, usize)>,
/// The bodies each native window has drawn so far, in the order the windows began.
drawn: Vec<(egui::ViewportId, usize)>,
/// Windows that published this pass.
published: Vec<egui::ViewportId>,
}
impl NativeIme {
/// `unpublished` are the windows that drew last pass but never published: the layout's count
/// was too high for them, so they publish after every body this pass.
pub(crate) fn new(floating: &[Floating<PanelKind>], unpublished: &[egui::ViewportId]) -> Self {
let expected = floating
.iter()
.filter_map(|window| {
let viewport = window_viewport(window.id)?;
let count = if unpublished.contains(&viewport) { 0 } else { window_bodies(window) };
Some((viewport, count))
})
.collect();
Self { expected, drawn: Vec::new(), published: Vec::new() }
}
/// Count a body of `viewport` that starts drawing. True for the window's first body: its
/// pass sets up the IME before it.
pub(crate) fn begin_body(&mut self, viewport: egui::ViewportId) -> bool {
match self.drawn.iter_mut().find(|(v, _)| *v == viewport) {
Some((_, n)) => {
*n = n.saturating_add(1);
false
}
None => {
self.drawn.push((viewport, 1));
true
}
}
}
/// Whether the body of `viewport` that just drew publishes the IME descriptor: once the
/// window's editor has `recorded` its geometry this pass, and after the window's last body.
/// A window the layout does not name publishes after every body.
pub(crate) fn publishes(&mut self, viewport: egui::ViewportId, recorded: bool) -> bool {
let drawn = self.drawn.iter().find(|(v, _)| *v == viewport).map_or(0, |(_, n)| *n);
let expected = self.expected.iter().find(|(v, _)| *v == viewport).map_or(0, |(_, n)| *n);
let publishes = recorded || drawn >= expected;
if publishes && !self.published.contains(&viewport) {
self.published.push(viewport);
}
publishes
}
/// The native windows whose pass began.
pub(crate) fn begun(&self) -> Vec<egui::ViewportId> {
self.drawn.iter().map(|(v, _)| *v).collect()
}
/// The native windows that drew bodies but did not publish.
pub(crate) fn unpublished(&self) -> Vec<egui::ViewportId> {
self.drawn.iter().map(|(v, _)| *v).filter(|v| !self.published.contains(v)).collect()
}
}
/// The OS window craft-ui opens for the floating window `id`.
#[cfg(not(target_arch = "wasm32"))]
fn window_viewport(id: u64) -> Option<egui::ViewportId> {
Some(DockArea::<PanelKind>::window_viewport(area_id(), id))
}
/// The web keeps floating windows in the page.
#[cfg(target_arch = "wasm32")]
fn window_viewport(_id: u64) -> Option<egui::ViewportId> {
None
}
/// The panel bodies craft-ui draws in a floating window: the active panel of each group and each
/// open stack entry. A minimized window shows its bar only.
fn window_bodies(window: &Floating<PanelKind>) -> usize {
if window.minimized {
return 0;
}
let mut count = 0usize;
let mut nodes = vec![&window.root];
while let Some(node) = nodes.pop() {
match node {
Node::Split { first, second, .. } => nodes.extend([&**first, &**second]),
Node::Tabs { panels, active } => count = count.saturating_add(usize::from(panels.get(*active).is_some())),
Node::Stack { entries } => count = count.saturating_add(entries.iter().filter(|e| e.open).count()),
_ => {}
}
}
count
}
impl EffectContent<'_> {
fn header_width(&self, active: PanelKind, strip_width: f32) -> f32 {
let lock = if has_lock(active) { LOCK_W } else { 0.0 };
(lock + MENU_W).min(strip_width * 2.0 / 3.0).max(MENU_W)
}
/// Whether the group of `panels` shows comp tabs: it holds the Timeline and comps are open.
fn has_doc_tabs(&self, panels: &[PanelKind]) -> bool {
panels.contains(&PanelKind::Timeline) && !self.docs.is_empty()
}
}
impl DockContent<PanelKind> for EffectContent<'_> {
fn body(&mut self, ui: &mut egui::Ui, panel: &PanelKind) {
let t = self.tokens;
let rect = ui.max_rect();
self.bodies.push((*panel, rect));
// A floating panel's OS window is a pass of its own: its text IME descriptor is set up
// before its first body and published after its bodies (the main window does both in
// `draw`).
let viewport = ui.ctx().viewport_id();
let native = viewport != egui::ViewportId::ROOT && !ui.ctx().embed_viewports();
if native && self.ime.begin_body(viewport) {
panels::viewer_text::begin_ui(ui.ctx());
}
self.app.panel_body(ui, *panel, rect);
if native && self.ime.publishes(viewport, panels::viewer_text::ime_recorded(ui.ctx())) {
panels::viewer_text::finish_ime(self.app, ui);
}
// The body fills its frame; allocating it lets a floated panel keep its full size.
ui.allocate_rect(rect, Sense::hover());
// Painted after the body so child backgrounds cannot hide the focus outline.
if self.app.ui.focused == *panel {
ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside);
}
}
fn logical_label(&self, panel: &PanelKind) -> String {
let title = panel.title();
// The Window menu names most panels; `Markers` is only in the panel catalog, and the two
// panels in neither (`History`, `ScriptUI Panel`) keep their English names.
let listed = crate::i18n::command_label("window.panel", title);
if listed == title { crate::i18n::tr(title).to_string() } else { listed.to_string() }
}
/// The tab names what the panel shows ("Composition Intro"), after the label swatch of its
/// comp or layer. With comps open, the Timeline's own tab is just "Timeline"; each comp has a
/// tab of its own after the group's panel tabs.
fn label(&mut self, _ui: &egui::Ui, panel: &PanelKind, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText {
let title = self.titles.iter().find(|(p, _)| p == panel).map_or(logical, |(_, s)| s.as_str());
let Some((_, swatch)) = self.swatches.iter().find(|(p, _)| p == panel) else { return title.into() };
let mut job = egui::text::LayoutJob::default();
job.append("■ ", 0.0, egui::TextFormat::simple(font.clone(), *swatch));
job.append(title, 0.0, egui::TextFormat::simple(font.clone(), color));
job.into()
}
fn group_header_width(&self, _panels: &[PanelKind], active: &PanelKind, _floating: bool, strip_width: f32) -> f32 {
self.header_width(*active, strip_width)
}
/// The viewer lock and the ≡ panel menu of the shown panel.
fn group_header(&mut self, ui: &mut egui::Ui, _panels: &[PanelKind], active: &PanelKind, _floating: bool) {
let t = self.tokens;
let header = ui.max_rect();
let menu = Rect::from_min_max(pos2((header.max.x - MENU_W).max(header.min.x), header.min.y), header.max);
if has_lock(*active) {
let lock = Rect::from_center_size(pos2(menu.min.x - LOCK_W / 2.0, header.center().y), vec2(14.0, 14.0));
let locked = self.app.ui.locked_tabs.contains(&active.id());
if dock::lock_button(ui, lock, *active, locked, &t, &mut self.app.auto) {
self.locks.push(*active);
}
}
let rect = Rect::from_center_size(menu.center(), vec2(18.0, 18.0));
let response = ui.interact(rect, egui::Id::unique(("panel-menu-button", active.id())), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, response.enabled(), "panel menu"));
self.app.auto.add_response(&format!("panel.menu.{}", active.id()), &response, "panel menu");
let color = if response.hovered() { t.tab_text_active } else { t.tab_text };
icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::Hamburger, color);
crate::widgets::focus_outline(ui, &response, &t);
if response.clicked() {
self.app.ui.focused = *active;
self.menu = Some((*active, rect.left_bottom()));
}
}
fn group_extra_tabs_width(&self, panels: &[PanelKind], _active: &PanelKind, _floating: bool) -> f32 {
if !self.has_doc_tabs(panels) {
return 0.0;
}
dock::doc_widths(&self.ctx, &self.docs, &self.style.font).iter().sum()
}
/// The Timeline's comp tabs, after its group's panel tabs whichever panel is shown.
fn group_extra_tabs(&mut self, ui: &mut egui::Ui, panels: &[PanelKind], active: &PanelKind, _floating: bool) {
if self.has_doc_tabs(panels) {
let selected = *active == PanelKind::Timeline;
self.doc_actions.extend(dock::doc_tabs(ui, ui.max_rect(), &self.docs, selected, &self.style, &mut self.app.auto));
}
}
}