filmcraft/crates/ui-egui/src/dock.rs

789 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Premiere-style docking: a tree of splits and tab groups. Panels are rounded frames separated by
//! thin gutters; each group has a tab strip (active tab bright, with a panel menu "≡"); the focused
//! panel gets a blue outline. Gutters drag to resize; workspaces are serialized trees.
use egui::{Align2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde::{Deserialize, Serialize};
use crate::icons::{self, Icon};
use crate::theme::Tokens;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PanelKind {
Project,
MediaBrowser,
Libraries,
Info,
Effects,
Markers,
History,
Source,
EffectControls,
AudioClipMixer,
Metadata,
Program,
Timeline,
Tools,
AudioMeters,
AudioTrackMixer,
LumetriColor,
LumetriScopes,
EssentialGraphics,
EssentialSound,
TextToSpeech,
Properties,
Text,
Events,
Progress,
ReferenceMonitor,
Timecode,
ProjectNotes,
}
impl PanelKind {
pub const ALL: [PanelKind; 28] = [
PanelKind::Project,
PanelKind::MediaBrowser,
PanelKind::Libraries,
PanelKind::Info,
PanelKind::Effects,
PanelKind::Markers,
PanelKind::History,
PanelKind::Source,
PanelKind::EffectControls,
PanelKind::AudioClipMixer,
PanelKind::Metadata,
PanelKind::Program,
PanelKind::Timeline,
PanelKind::Tools,
PanelKind::AudioMeters,
PanelKind::AudioTrackMixer,
PanelKind::LumetriColor,
PanelKind::LumetriScopes,
PanelKind::EssentialGraphics,
PanelKind::EssentialSound,
PanelKind::TextToSpeech,
PanelKind::Properties,
PanelKind::Text,
PanelKind::Events,
PanelKind::Progress,
PanelKind::ReferenceMonitor,
PanelKind::Timecode,
PanelKind::ProjectNotes,
];
pub fn title(self) -> &'static str {
match self {
PanelKind::Project => "Project",
PanelKind::MediaBrowser => "Media Browser",
PanelKind::Libraries => "Libraries",
PanelKind::Info => "Info",
PanelKind::Effects => "Effects",
PanelKind::Markers => "Markers",
PanelKind::History => "History",
PanelKind::Source => "Source",
PanelKind::EffectControls => "Effect Controls",
PanelKind::AudioClipMixer => "Audio Clip Mixer",
PanelKind::Metadata => "Metadata",
PanelKind::Program => "Program",
PanelKind::Timeline => "Timeline",
PanelKind::Tools => "Tools",
PanelKind::AudioMeters => "Audio Meters",
PanelKind::AudioTrackMixer => "Audio Track Mixer",
PanelKind::LumetriColor => "Lumetri Color",
PanelKind::LumetriScopes => "Lumetri Scopes",
PanelKind::EssentialGraphics => "Essential Graphics",
PanelKind::EssentialSound => "Essential Sound",
PanelKind::TextToSpeech => "Text to Speech",
PanelKind::Properties => "Properties",
PanelKind::Text => "Text",
PanelKind::Events => "Events",
PanelKind::Progress => "Progress",
PanelKind::ReferenceMonitor => "Reference Monitor",
PanelKind::Timecode => "Timecode",
PanelKind::ProjectNotes => "Project Notes",
}
}
pub fn id(self) -> String {
format!("{self:?}")
}
pub fn from_name(s: &str) -> Option<PanelKind> {
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
Self::ALL.iter().copied().find(|p| format!("{p:?}").to_ascii_lowercase() == n || p.title().to_ascii_lowercase().replace(' ', "") == n)
}
/// Window-menu shortcut (Premiere: Shift+1..9).
pub fn window_shortcut(self) -> Option<&'static str> {
match self {
PanelKind::Project => Some("Shift+1"),
PanelKind::Source => Some("Shift+2"),
PanelKind::Timeline => Some("Shift+3"),
PanelKind::Program => Some("Shift+4"),
PanelKind::EffectControls => Some("Shift+5"),
PanelKind::AudioClipMixer => Some("Shift+9"),
PanelKind::Effects => Some("Shift+7"),
PanelKind::MediaBrowser => Some("Shift+8"),
PanelKind::AudioTrackMixer => Some("Shift+6"),
_ => None,
}
}
/// Narrow chrome-less panels (no tab strip; a slim grip instead).
pub fn compact(self) -> bool {
matches!(self, PanelKind::Tools | PanelKind::AudioMeters)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum SplitSize {
Ratio(f32),
/// First child has a fixed size in points.
FixedA(f32),
/// Second child has a fixed size in points.
FixedB(f32),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum DockNode {
/// `vertical`: children stacked top/bottom; else side by side.
Split {
vertical: bool,
size: SplitSize,
a: Box<DockNode>,
b: Box<DockNode>,
},
Tabs {
panels: Vec<PanelKind>,
active: usize,
},
}
fn tabs(p: &[PanelKind], active: usize) -> DockNode {
DockNode::Tabs { panels: p.to_vec(), active }
}
fn hsplit(size: SplitSize, a: DockNode, b: DockNode) -> DockNode {
DockNode::Split { vertical: false, size, a: Box::new(a), b: Box::new(b) }
}
fn vsplit(size: SplitSize, a: DockNode, b: DockNode) -> DockNode {
DockNode::Split { vertical: true, size, a: Box::new(a), b: Box::new(b) }
}
pub const WORKSPACES: [&str; 9] = ["Editing", "Assembly", "Color", "Effects", "Audio", "Captions and Graphics", "Learning", "Review", "All Panels"];
/// The user's workspaces file, beside `preferences.json` in the data directory.
pub const WORKSPACES_FILE: &str = "workspaces.json";
/// Window ▸ Workspaces: the user's own workspaces and saved changes to the built-in ones, and the
/// workspace in use (reopened at launch).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct WorkspacePrefs {
pub saved: Vec<SavedWorkspace>,
pub current: String,
}
/// A named layout. `layout` is a [`DockNode`] kept as JSON, so one unreadable entry (a panel a
/// newer version added) doesn't lose the others.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct SavedWorkspace {
pub name: String,
pub layout: 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()
}
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
if let Some(d) = path.parent() {
std::fs::create_dir_all(d)?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)?;
std::fs::rename(tmp, path)
}
}
/// A workspace's command-id key: `Captions and Graphics` → `captionsandgraphics`.
pub fn slug(name: &str) -> String {
name.to_ascii_lowercase().replace(' ', "")
}
pub fn is_builtin(name: &str) -> bool {
WORKSPACES.contains(&name)
}
/// Every workspace, built-in and the user's own, in one alphabetical list (Premiere's Window ▸
/// Workspaces).
pub fn names(prefs: &WorkspacePrefs) -> Vec<String> {
let mut v: Vec<String> = WORKSPACES.iter().map(|s| s.to_string()).collect();
for s in &prefs.saved {
if !v.contains(&s.name) {
v.push(s.name.clone());
}
}
v.sort_by_key(|n| n.to_lowercase());
v
}
/// The workspace called `query` (any case, spaces optional).
pub fn find(prefs: &WorkspacePrefs, query: &str) -> Option<String> {
names(prefs).into_iter().find(|n| slug(n) == slug(query))
}
/// 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))
}
/// The default layout of a named workspace.
pub fn workspace(name: &str) -> DockNode {
use PanelKind::*;
use SplitSize::*;
let bottom_editing = || hsplit(FixedA(64.0), tabs(&[Tools], 0), hsplit(FixedB(116.0), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0)));
match name {
"Assembly" => vsplit(
Ratio(0.52),
hsplit(
Ratio(0.46),
tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0),
hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer, Metadata], 0), tabs(&[Program], 0)),
),
bottom_editing(),
),
"Color" => hsplit(
FixedB(340.0),
vsplit(
Ratio(0.58),
hsplit(Ratio(0.42), tabs(&[LumetriScopes, Source, EffectControls], 0), tabs(&[Program], 0)),
hsplit(Ratio(0.3), tabs(&[Project, Info, Markers, History], 0), bottom_editing()),
),
tabs(&[LumetriColor, Effects], 0),
),
"Effects" => hsplit(
FixedB(320.0),
vsplit(
Ratio(0.5),
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)),
),
"Audio" => hsplit(
FixedB(320.0),
vsplit(
Ratio(0.5),
hsplit(Ratio(0.5), tabs(&[AudioTrackMixer, Source, AudioClipMixer, EffectControls], 0), tabs(&[Program], 0)),
hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Effects, Markers, History], 0), bottom_editing()),
),
tabs(&[EssentialSound, TextToSpeech], 0),
),
"Captions and Graphics" => hsplit(
FixedB(330.0),
vsplit(
Ratio(0.5),
hsplit(Ratio(0.4), tabs(&[Text, Source, EffectControls], 0), tabs(&[Program], 0)),
hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Libraries], 0), bottom_editing()),
),
tabs(&[EssentialGraphics, Properties], 0),
),
"All Panels" => vsplit(
Ratio(0.5),
hsplit(
Ratio(0.5),
tabs(&[Source, EffectControls, AudioClipMixer, Metadata, LumetriScopes, Text], 0),
hsplit(Ratio(0.6), tabs(&[Program], 0), tabs(&[LumetriColor, EssentialGraphics, EssentialSound, TextToSpeech, Properties, AudioTrackMixer], 0)),
),
hsplit(Ratio(0.26), tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), bottom_editing()),
),
// Editing (default), Learning, Review — Premiere 26 factory layout:
// top: Source group | Program | Properties; bottom: Project group | Tools | Timeline | Meters.
_ => vsplit(
Ratio(0.57),
hsplit(
FixedB(450.0),
hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer, Metadata], 0), tabs(&[Program], 0)),
tabs(&[Properties, EssentialGraphics, Text], 0),
),
hsplit(
Ratio(0.29),
tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0),
hsplit(FixedA(64.0), tabs(&[Tools], 0), hsplit(FixedB(116.0), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))),
),
),
}
}
impl DockNode {
/// Every panel in the tree.
pub fn panels(&self, out: &mut Vec<PanelKind>) {
match self {
DockNode::Split { a, b, .. } => {
a.panels(out);
b.panels(out);
}
DockNode::Tabs { panels, .. } => out.extend(panels.iter().copied()),
}
}
pub fn contains(&self, p: PanelKind) -> bool {
let mut v = Vec::new();
self.panels(&mut v);
v.contains(&p)
}
/// 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
}
}
}
}
pub fn is_visible(&self, p: PanelKind) -> bool {
match self {
DockNode::Split { a, b, .. } => a.is_visible(p) || b.is_visible(p),
DockNode::Tabs { panels, active } => panels.get(*active) == Some(&p),
}
}
/// 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));
}
}
/// 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.
pub fn restore_timeline(&mut self) {
if self.contains(PanelKind::Timeline) {
return;
}
fn beside_meters(n: &mut DockNode) -> bool {
match n {
DockNode::Split { a, b, .. } => beside_meters(a) || beside_meters(b),
DockNode::Tabs { panels, .. } if panels.as_slice() == [PanelKind::AudioMeters] => {
*n = hsplit(SplitSize::FixedB(116.0), tabs(&[PanelKind::Timeline], 0), tabs(&[PanelKind::AudioMeters], 0));
true
}
DockNode::Tabs { .. } => false,
}
}
if !beside_meters(self) {
self.open_near(PanelKind::Timeline, PanelKind::Project);
}
}
/// 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);
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
}
}
}
}
if !add(self, p, near) && !add(self, p, PanelKind::Project) {
add(self, p, PanelKind::Program);
}
}
}
/// One laid-out tab group.
pub struct Group {
pub path: String,
pub rect: Rect,
pub content: Rect,
pub panels: Vec<PanelKind>,
pub active: usize,
}
/// Actions produced by interacting with the dock chrome.
#[derive(Debug, Clone, PartialEq)]
pub enum DockAction {
Activate(PanelKind),
Focus(PanelKind),
Close(PanelKind),
PanelMenu(PanelKind, egui::Pos2),
/// A sequence tab of the Timeline panel was clicked / closed (project item id).
OpenSequence(u64),
CloseSequence(u64),
/// A sequence tab was dragged past a neighbour: its new place among the sequence tabs.
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) {
match node {
DockNode::Tabs { panels, active } => {
let tab_h = if panels.len() == 1 && panels[0].compact() { 8.0 } else { t.tab_h };
let content = Rect::from_min_max(pos2(rect.min.x, rect.min.y + tab_h), rect.max);
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 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 });
}
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);
}
}
}
/// Draw a group's frame + tab strip. Returns actions (tab clicks, panel menu, focus).
/// The sequences open in the Timeline panel, in tab order: `(project item id, name)`.
#[derive(Debug, Clone, Default)]
pub struct SeqTabs {
pub open: Vec<(u64, String)>,
pub active: Option<u64>,
}
pub fn draw_group_chrome(
ui: &mut egui::Ui,
g: &Group,
focused: PanelKind,
seqs: &SeqTabs,
t: &Tokens,
reg: &mut crate::automation::Registry,
) -> Vec<DockAction> {
let mut actions = 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();
if compact {
// grip dots
let c = pos2(g.rect.center().x, g.rect.min.y + 4.0);
for dx in [-4.0, 0.0, 4.0] {
painter.circle_filled(c + vec2(dx, 0.0), 1.0, t.text_faint);
}
} else {
let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h));
// the Timeline panel shows one tab per open sequence (its own tab names the sequence)
struct Tab {
panel: PanelKind,
seq: Option<u64>,
title: String,
active: bool,
}
let mut tabs: Vec<Tab> = Vec::new();
for (i, p) in g.panels.iter().enumerate() {
let active = i == g.active;
if *p == PanelKind::Timeline && active && !seqs.open.is_empty() {
tabs.extend(seqs.open.iter().map(|(id, name)| Tab { panel: *p, seq: Some(*id), title: name.clone(), active: seqs.active == Some(*id) }));
} else if *p == PanelKind::Timeline {
let name = seqs.open.iter().find(|(id, _)| seqs.active == Some(*id)).map(|(_, n)| n.clone());
tabs.push(Tab { panel: *p, seq: None, title: name.unwrap_or_else(|| tl!("Timeline: (no sequences)").into()), active });
} else {
tabs.push(Tab { panel: *p, seq: None, title: crate::i18n::t(p.title()).to_string(), active });
}
}
let activate = |tab: &Tab, actions: &mut Vec<DockAction>| match tab.seq {
Some(id) => actions.push(DockAction::OpenSequence(id)),
None => actions.push(DockAction::Activate(tab.panel)),
};
let active_at = tabs.iter().position(|tab| tab.active).unwrap_or(0);
let text_y = strip.min.y + 16.0;
// Which tabs fit. The active tab is always shown: tabs before it are left out until it
// fits, then as many after it as there is room for. Tabs that are left out are reached
// through the » list at the right end of the strip.
let widths: Vec<f32> = tabs
.iter()
.map(|tab| {
let label = painter.layout_no_wrap(tab.title.clone(), Tokens::ui(12.0), t.tab_text).size().x;
label + 16.0 + if tab.active { 20.0 } else { 0.0 } + if tab.active && tab.seq.is_some() { 16.0 } else { 0.0 }
})
.collect();
let span = |from: usize, to: usize| widths.get(from..=to).map_or(0.0, |w| w.iter().sum::<f32>() + 8.0 * w.len() as f32);
let room = strip.width() - 12.0;
let all_fit = span(0, tabs.len().saturating_sub(1)) <= room;
let room = if all_fit { room } else { room - 24.0 };
let mut first = 0;
while first < active_at && span(first, active_at) > room {
first += 1;
}
let mut last = active_at.max(first);
while last + 1 < tabs.len() && span(first, last + 1) <= room {
last += 1;
}
let list_id = egui::Id::new(("tab-list", g.path.clone()));
let more = (!all_fit).then(|| Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0)));
let mut list_opened = false;
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");
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);
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));
list_opened = !open;
}
}
let mut x = strip.min.x + 12.0;
// sequence tabs as drawn (for dragging one past its neighbours), and the one being dragged
let mut seq_rects: Vec<(u64, Rect)> = Vec::new();
let mut dragged: Option<u64> = None;
for (i, tab) in tabs.iter().enumerate().take(last + 1).skip(first) {
let p = &tab.panel;
let is_active = tab.active;
let galley = painter.layout_no_wrap(tab.title.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
let close_w = if is_active && tab.seq.is_some() { 16.0 } else { 0.0 };
let w = widths.get(i).copied().unwrap_or(0.0);
let tab_rect = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h));
// 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()),
};
if let Some(id) = tab.seq {
seq_rects.push((id, tab_rect));
if resp.dragged() {
dragged = Some(id);
}
}
if let Some(id) = tab.seq {
reg.add(&format!("timeline.tab.{id}"), tab_rect, &tab.title);
}
if tab.seq.is_none() || is_active {
reg.add(&format!("panel.tab.{}", p.id()), tab_rect, p.title());
}
let label_x = tab_rect.min.x + 8.0 + close_w;
let label_w = galley.size().x;
let col = if is_active || resp.hovered() { t.tab_text_active } else { t.tab_text };
painter.galley_with_override_text_color(pos2(label_x, text_y - galley.size().y / 2.0), galley, col);
let mut closed = false;
if let Some(id) = tab.seq.filter(|_| is_active) {
// × 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");
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));
if cresp.clicked() {
actions.push(DockAction::CloseSequence(id));
closed = true;
}
}
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");
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));
if mresp.clicked() {
actions.push(DockAction::PanelMenu(*p, mr.left_bottom()));
}
}
if (resp.clicked() || resp.drag_started()) && !closed {
activate(tab, &mut actions);
actions.push(DockAction::Focus(*p));
}
// a right-click shows the tab and opens its panel menu (as in Premiere)
if resp.secondary_clicked() {
activate(tab, &mut actions);
actions.push(DockAction::Focus(*p));
let at = resp.interact_pointer_pos().unwrap_or(tab_rect.left_bottom());
actions.push(DockAction::PanelMenu(*p, at));
}
if resp.middle_clicked() {
// a sequence tab closes its sequence; the Timeline panel itself stays
match tab.seq.or(seqs.active.filter(|_| *p == PanelKind::Timeline)) {
Some(id) => actions.push(DockAction::CloseSequence(id)),
None => actions.push(DockAction::Close(*p)),
}
}
x += w + 8.0;
}
// Dragging a sequence tab sideways: it changes places with a neighbour when the pointer
// passes that neighbour's middle.
if let Some(id) = dragged
&& let Some(px) = ui.input(|i| i.pointer.latest_pos()).map(|p| p.x)
&& let Some(at) = seq_rects.iter().position(|(s, _)| *s == id)
&& let Some(index) = seqs.open.iter().position(|(s, _)| *s == id)
{
let before = at.checked_sub(1).and_then(|k| seq_rects.get(k));
let after = seq_rects.get(at + 1);
if before.is_some_and(|(_, r)| px < r.center().x) {
actions.push(DockAction::MoveSequence(id, index.saturating_sub(1)));
} else if after.is_some_and(|(_, r)| px > r.center().x) {
actions.push(DockAction::MoveSequence(id, index + 1));
}
}
// the » list: every tab of the group, the shown one ticked
let list_open = ui.ctx().data(|d| d.get_temp::<bool>(list_id)).unwrap_or(false);
if let Some(r) = more.filter(|_| list_open) {
let mut picked = None;
let area = egui::Area::new(list_id.with("area")).order(egui::Order::Foreground).pivot(Align2::RIGHT_TOP).fixed_pos(r.right_bottom()).show(
ui.ctx(),
|ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(160.0);
// many open sequences: the list scrolls instead of leaving the window
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for tab in &tabs {
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),
}
if resp.clicked() {
picked = Some(tab);
}
}
});
});
},
);
if let Some(tab) = picked {
activate(tab, &mut actions);
actions.push(DockAction::Focus(tab.panel));
}
if picked.is_some() || (!list_opened && area.response.clicked_elsewhere()) {
ui.ctx().data_mut(|d| d.remove::<bool>(list_id));
}
} else if list_open {
ui.ctx().data_mut(|d| d.remove::<bool>(list_id));
}
}
// focus outline
if active_panel == Some(focused) {
painter.rect_stroke(g.rect, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside);
}
// clicking anywhere in the panel focuses it
if let Some(p) = active_panel
&& ui.rect_contains_pointer(g.rect)
&& ui.input(|i| i.pointer.any_pressed())
{
actions.push(DockAction::Focus(p));
}
actions
}
/// Placeholder body for panels that are not implemented yet.
pub fn placeholder(ui: &mut egui::Ui, rect: Rect, t: &Tokens, text: &str) {
ui.painter().text(rect.center(), Align2::CENTER_CENTER, text, Tokens::ui(12.0), t.text_faint);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_first_never_panics_on_tiny_areas() {
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),
SplitSize::Ratio(0.5),
SplitSize::Ratio(1.0),
SplitSize::FixedA(300.0),
SplitSize::FixedB(300.0),
SplitSize::FixedA(0.0),
] {
let f = split_first(size, avail);
assert!((0.0..=avail).contains(&f), "{size:?} in {avail}: {f}");
}
}
assert_eq!(split_first(SplitSize::FixedA(300.0), 1000.0), 300.0);
assert_eq!(split_first(SplitSize::FixedB(300.0), 1000.0), 700.0);
assert_eq!(split_first(SplitSize::Ratio(0.0), 1000.0), 20.0);
}
#[test]
fn workspaces_contain_core_panels() {
for w in WORKSPACES {
let d = workspace(w);
assert!(d.contains(PanelKind::Timeline), "{w}");
assert!(d.contains(PanelKind::Program), "{w}");
}
}
#[test]
fn close_and_open() {
let mut d = workspace("Editing");
d.close(PanelKind::Metadata);
assert!(!d.contains(PanelKind::Metadata));
d.open_near(PanelKind::LumetriColor, PanelKind::Program);
assert!(d.is_visible(PanelKind::LumetriColor));
let s = serde_json::to_string(&d).unwrap();
let back: DockNode = serde_json::from_str(&s).unwrap();
assert_eq!(back, d);
}
}