simple_editor\ui/
layout.rs

1//! Dockable editor layout (egui_tiles): panes can be rearranged by dragging their tabs, split, tabbed,
2//! hidden/shown, popped out into their own OS windows (egui immediate viewports) and saved/loaded as
3//! profiles (JSON — also written to / read from `.sedit-layout` files so profiles can be shared).
4//!
5//! Default layout (DaVinci-like), two rows of four columns:
6//! top    = [Library | Preview with the Tools strip beneath it | Inspector | tabs(Curves, Nodes, Tracking)]
7//! bottom = [tabs(Effects, Transitions, Presets) | Timeline | tabs(Mixer, Auto-cut, Subtitles) |
8//!           tabs(Markers, Planner)].
9//! The Timeline is a column of its own rather than a tab, so nothing can ever hide it. `show` draws the
10//! tree in the given `ui` and each popped pane in its own viewport (closing that window docks the pane
11//! back); `draw(ui, pane)` renders a pane. Behaviour: tab titles = Pane::title, a pop-out button in the
12//! tab bar puts the active pane in its own window, a cross hides it. Hidden panes stay in the tree
13//! (egui_tiles visibility) so
14//! they come back exactly where they were.
15//!
16//! Dragging a tab paints nine drop squares over the tile under the cursor (centre = tabify, the eight
17//! around it = split), the dropped pane keeps the fraction of its parent it had instead of taking half of
18//! wherever it landed, and the move goes onto a small undo stack of its own so Ctrl+Z puts it back.
19//!
20//! Migration: a stored layout from an older version does not know the round-3 panes; `from_json` rejects
21//! it (None) so the app falls back to this default instead of an editor with no Tools/Mixer/Markers.
22
23use crate::ui::tools::{glyph_text_button, Glyph};
24use eframe::egui;
25use serde::{Deserialize, Serialize};
26
27#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
28pub enum Pane {
29    Preview,
30    Timeline,
31    Library,
32    Inspector,
33    Effects,
34    Transitions,
35    Curves,
36    Subtitles,
37    Planner,
38    AutoCut,
39    // ---- round 3 ----
40    Tools,
41    Nodes,
42    Mixer,
43    Markers,
44    /// Machine-local reusable things (effect chains, node graphs, adjustment layers, templates).
45    Presets,
46    /// Point / area tracking of a clip into a reusable project path.
47    Tracking,
48}
49
50impl Pane {
51    pub const ALL: [Pane; 16] = [
52        Pane::Preview,
53        Pane::Timeline,
54        Pane::Library,
55        Pane::Inspector,
56        Pane::Effects,
57        Pane::Transitions,
58        Pane::Curves,
59        Pane::Subtitles,
60        Pane::Planner,
61        Pane::AutoCut,
62        Pane::Tools,
63        Pane::Nodes,
64        Pane::Mixer,
65        Pane::Markers,
66        Pane::Presets,
67        Pane::Tracking,
68    ];
69    /// Panes added in round 3 — a stored layout without them is from an older version (see `from_json`).
70    pub const ROUND3: [Pane; 4] = [Pane::Tools, Pane::Nodes, Pane::Mixer, Pane::Markers];
71    /// Default icon for this pane (View menu, icon picker). Overridable in Settings → Appearance.
72    pub fn glyph(self) -> Glyph {
73        match self {
74            Pane::Preview => Glyph::Clapperboard,
75            Pane::Timeline => Glyph::FilmStrip,
76            Pane::Library => Glyph::Folder,
77            Pane::Inspector => Glyph::Sliders,
78            Pane::Effects => Glyph::Bolt,
79            Pane::Transitions => Glyph::Transition,
80            Pane::Curves => Glyph::CurveIcon,
81            Pane::Subtitles => Glyph::Subtitles,
82            Pane::Planner => Glyph::Notepad,
83            Pane::AutoCut => Glyph::Waveform,
84            Pane::Tools => Glyph::Wrench,
85            Pane::Nodes => Glyph::Nodes,
86            Pane::Mixer => Glyph::SpeakerOn,
87            Pane::Markers => Glyph::Flag,
88            Pane::Presets => Glyph::Bookmark,
89            Pane::Tracking => Glyph::Target,
90        }
91    }
92    pub fn title(self) -> &'static str {
93        match self {
94            Pane::Preview => "Preview",
95            Pane::Timeline => "Timeline",
96            Pane::Library => "Library",
97            Pane::Inspector => "Inspector",
98            Pane::Effects => "Effects",
99            Pane::Transitions => "Transitions",
100            Pane::Curves => "Curves",
101            Pane::Subtitles => "Subtitles",
102            Pane::Planner => "Planner",
103            Pane::AutoCut => "Auto-cut",
104            Pane::Tools => "Tools",
105            Pane::Nodes => "Nodes",
106            Pane::Mixer => "Mixer",
107            Pane::Markers => "Markers",
108            Pane::Presets => "Presets",
109            Pane::Tracking => "Tracking",
110        }
111    }
112}
113
114/// The whole layout: the docked tree plus panes currently popped out into their own windows.
115#[derive(Clone, Serialize, Deserialize)]
116pub struct Layout {
117    pub tree: egui_tiles::Tree<Pane>,
118    #[serde(default)]
119    pub popped: Vec<Pane>,
120    /// The layout's own undo history (serialised layouts): the layout is not the project, so it cannot
121    /// ride the project's snapshots. Never persisted, and 20 steps is plenty for "put that tab back".
122    #[serde(skip)]
123    pub undo: Vec<String>,
124    #[serde(skip)]
125    pub redo: Vec<String>,
126}
127
128impl Default for Layout {
129    fn default() -> Self {
130        Self::default_layout()
131    }
132}
133
134impl Layout {
135    pub fn default_layout() -> Self {
136        use egui_tiles::{Container, Linear, LinearDir, Tile};
137        let mut tiles = egui_tiles::Tiles::default();
138        let tabs = |tiles: &mut egui_tiles::Tiles<Pane>, panes: &[Pane]| {
139            let ids: Vec<_> = panes.iter().map(|&p| tiles.insert_pane(p)).collect();
140            tiles.insert_tab_tile(ids) // the first pane is the active tab
141        };
142        // a row of four columns with the given fractions; panes are listed explicitly so a variant this
143        // default does not know about is simply absent (the View menu still brings it in)
144        let row = |tiles: &mut egui_tiles::Tiles<Pane>, cols: [(egui_tiles::TileId, f32); 4]| {
145            let mut lin = Linear::new(LinearDir::Horizontal, cols.iter().map(|&(id, _)| id).collect());
146            for (id, share) in cols {
147                lin.shares.set_share(id, share);
148            }
149            tiles.insert_new(Tile::Container(Container::Linear(lin)))
150        };
151        // centre column: the viewport with the Tools strip directly beneath it
152        let preview = tiles.insert_pane(Pane::Preview);
153        let tools = tiles.insert_pane(Pane::Tools);
154        let mut centre_col = Linear::new(LinearDir::Vertical, vec![preview, tools]);
155        centre_col.shares.set_share(preview, 0.9);
156        centre_col.shares.set_share(tools, 0.1);
157        let centre = tiles.insert_new(Tile::Container(Container::Linear(centre_col)));
158        let library = tiles.insert_pane(Pane::Library);
159        let inspector = tiles.insert_pane(Pane::Inspector);
160        // ponytail: Presets / Tracking ride along as trailing tabs of the group they belong with — the
161        // requested arrangement does not place them, and a homeless pane is only reachable via the menu
162        let graphs = tabs(&mut tiles, &[Pane::Curves, Pane::Nodes, Pane::Tracking]);
163        let top = row(&mut tiles, [(library, 0.18), (centre, 0.44), (inspector, 0.2), (graphs, 0.18)]);
164        // the Timeline is a column of its own, not a tab: nothing can end up in front of it
165        let looks = tabs(&mut tiles, &[Pane::Effects, Pane::Transitions, Pane::Presets]);
166        let timeline = tiles.insert_pane(Pane::Timeline);
167        let mix = tabs(&mut tiles, &[Pane::Mixer, Pane::AutoCut, Pane::Subtitles]);
168        let notes = tabs(&mut tiles, &[Pane::Markers, Pane::Planner]);
169        let bottom = row(&mut tiles, [(looks, 0.18), (timeline, 0.44), (mix, 0.2), (notes, 0.18)]);
170        let mut rows = Linear::new(LinearDir::Vertical, vec![top, bottom]);
171        rows.shares.set_share(top, 0.62);
172        rows.shares.set_share(bottom, 0.38);
173        let root = tiles.insert_new(Tile::Container(Container::Linear(rows)));
174        Self::new(egui_tiles::Tree::new("layout", root, tiles))
175    }
176    /// A layout around a tree, with an empty history.
177    pub fn new(tree: egui_tiles::Tree<Pane>) -> Self {
178        Self { tree, popped: Vec::new(), undo: Vec::new(), redo: Vec::new() }
179    }
180    pub fn to_json(&self) -> String {
181        serde_json::to_string(self).unwrap_or_default()
182    }
183    /// None on malformed / incompatible JSON (caller falls back to the default layout). A layout saved
184    /// before round 3 knows nothing about Tools / Nodes / Mixer / Markers: rather than dropping four panes
185    /// into the root as loose tabs, it is rejected here and the caller resets to the new default.
186    pub fn from_json(s: &str) -> Option<Self> {
187        let l: Self = serde_json::from_str(s).ok()?;
188        l.tree.root()?;
189        let has_all = Pane::ROUND3.iter().all(|p| l.tree.tiles.find_pane(p).is_some() || l.popped.contains(p));
190        has_all.then_some(l)
191    }
192
193    /// Same, but an explicitly saved profile is migrated instead of rejected: panes it predates are added
194    /// to the root rather than making the whole profile unloadable.
195    pub fn from_json_migrating(s: &str) -> Option<Self> {
196        let mut l: Self = serde_json::from_str(s).ok()?;
197        l.tree.root()?;
198        for p in Pane::ALL {
199            if l.tree.tiles.find_pane(&p).is_none() && !l.popped.contains(&p) {
200                l.insert_into_root(p);
201            }
202        }
203        Some(l)
204    }
205    /// Visible = docked in the tree (and not hidden) or popped out.
206    pub fn is_visible(&self, pane: Pane) -> bool {
207        if self.popped.contains(&pane) {
208            return true;
209        }
210        self.tree.tiles.find_pane(&pane).map(|id| self.tree.tiles.is_visible(id)).unwrap_or(false)
211    }
212    /// Show (back where it was in the tree, or into the root when it is gone) or hide (invisible in the
213    /// tree / close the popout).
214    pub fn toggle(&mut self, pane: Pane) {
215        if let Some(i) = self.popped.iter().position(|&p| p == pane) {
216            self.popped.remove(i);
217            return;
218        }
219        match self.tree.tiles.find_pane(&pane) {
220            Some(id) if self.tree.tiles.is_visible(id) => self.tree.tiles.set_visible(id, false),
221            Some(id) => {
222                self.tree.tiles.set_visible(id, true);
223                self.tree.make_active(|tid, _| tid == id);
224            }
225            None => self.insert_into_root(pane),
226        }
227    }
228    /// Make the pane visible (docked or popped) and, if it is a tab, the active one.
229    pub fn reveal(&mut self, pane: Pane) {
230        if self.popped.contains(&pane) {
231            return;
232        }
233        match self.tree.tiles.find_pane(&pane) {
234            Some(id) => {
235                self.tree.tiles.set_visible(id, true);
236                self.tree.make_active(|tid, _| tid == id);
237            }
238            None => self.insert_into_root(pane),
239        }
240    }
241    pub fn popout(&mut self, pane: Pane) {
242        if self.popped.contains(&pane) {
243            return;
244        }
245        if let Some(id) = self.tree.tiles.find_pane(&pane) {
246            self.tree.tiles.set_visible(id, false);
247        }
248        self.popped.push(pane);
249    }
250    pub fn dock(&mut self, pane: Pane) {
251        self.popped.retain(|&p| p != pane);
252        self.reveal(pane);
253    }
254    pub fn reset(&mut self) {
255        let (undo, redo) = (std::mem::take(&mut self.undo), std::mem::take(&mut self.redo));
256        *self = Self::default_layout();
257        (self.undo, self.redo) = (undo, redo);
258    }
259    /// Remember the arrangement `snapshot` was taken from (before the move), so Ctrl+Z can go back to it.
260    pub fn push_undo(&mut self, snapshot: String) {
261        self.undo.push(snapshot);
262        if self.undo.len() > 20 {
263            self.undo.remove(0);
264        }
265        self.redo.clear();
266    }
267    pub fn undo(&mut self) -> bool {
268        self.step(true)
269    }
270    pub fn redo(&mut self) -> bool {
271        self.step(false)
272    }
273    fn step(&mut self, undoing: bool) -> bool {
274        let current = self.to_json();
275        let Some(json) = (if undoing { &mut self.undo } else { &mut self.redo }).pop() else { return false };
276        let Some(other) = Self::from_json(&json) else { return false };
277        (self.tree, self.popped) = (other.tree, other.popped);
278        if undoing { &mut self.redo } else { &mut self.undo }.push(current);
279        true
280    }
281    /// A pane that fell out of the tree entirely (e.g. an old profile): add it as a new tab in the root.
282    fn insert_into_root(&mut self, pane: Pane) {
283        let id = self.tree.tiles.insert_pane(pane);
284        match self.tree.root().and_then(|r| self.tree.tiles.get_mut(r)) {
285            Some(egui_tiles::Tile::Container(c)) => c.add_child(id),
286            _ => {
287                let root = self.tree.tiles.insert_tab_tile(vec![id]);
288                self.tree = egui_tiles::Tree::new("layout", root, std::mem::take(&mut self.tree.tiles));
289            }
290        }
291        self.tree.make_active(|tid, _| tid == id);
292    }
293}
294
295/// How much of its linear parent a tile takes up (None when the parent is a tab bar — a tab has no share).
296fn share_fraction(tree: &egui_tiles::Tree<Pane>, id: egui_tiles::TileId) -> Option<f32> {
297    let parent = tree.tiles.parent_of(id)?;
298    let egui_tiles::Container::Linear(lin) = tree.tiles.get_container(parent)? else { return None };
299    let total: f32 = lin.children.iter().map(|&c| lin.shares[c]).sum();
300    (total > 0.0).then(|| lin.shares[id] / total)
301}
302
303/// Give a just-dropped tile the same fraction of its new parent as it had in the old one: a fresh split
304/// hands out 1:1 shares, which silently halves whatever you dropped the pane onto.
305fn keep_share_fraction(tree: &mut egui_tiles::Tree<Pane>, id: egui_tiles::TileId, fraction: f32) {
306    let fraction = fraction.clamp(0.05, 0.95);
307    let Some(parent) = tree.tiles.parent_of(id) else { return };
308    let Some(egui_tiles::Tile::Container(egui_tiles::Container::Linear(lin))) = tree.tiles.get_mut(parent) else {
309        return;
310    };
311    let others: f32 = lin.children.iter().filter(|&&c| c != id).map(|&c| lin.shares[c]).sum();
312    if others > 0.0 {
313        lin.shares.set_share(id, others * fraction / (1.0 - fraction));
314    }
315}
316
317/// One entry of the "Load profile" menu. A menu sizes itself from the previous frame's content, so a
318/// name that wraps makes the menu narrower, which wraps it harder — after a few frames "Editor 1" has
319/// collapsed to "Edito / r 1". Measuring the name pins the width instead of letting it feed back, and
320/// anything past the cap truncates on one line with the whole name on hover.
321pub fn profile_button(ui: &mut egui::Ui, name: &str) -> egui::Response {
322    let font = egui::TextStyle::Button.resolve(ui.style());
323    let text = ui.painter().layout_no_wrap(name.to_owned(), font, egui::Color32::PLACEHOLDER).size().x;
324    let wanted = text + ui.spacing().button_padding.x * 2.0;
325    ui.scope(|ui| {
326        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
327        ui.set_min_width(wanted.min(240.0));
328        ui.button(name)
329    })
330    .inner
331    .on_hover_text(name)
332}
333
334struct Behaviour<'a> {
335    draw: &'a mut dyn FnMut(&mut egui::Ui, Pane),
336    hide: Vec<Pane>,
337    pop: Vec<Pane>,
338    edited: bool,
339    dropped: bool,
340}
341
342impl egui_tiles::Behavior<Pane> for Behaviour<'_> {
343    fn pane_ui(&mut self, ui: &mut egui::Ui, _tile_id: egui_tiles::TileId, pane: &mut Pane) -> egui_tiles::UiResponse {
344        (self.draw)(ui, *pane);
345        egui_tiles::UiResponse::None
346    }
347    fn tab_title_for_pane(&mut self, pane: &Pane) -> egui::WidgetText {
348        pane.title().into()
349    }
350    fn top_bar_right_ui(
351        &mut self,
352        tiles: &egui_tiles::Tiles<Pane>,
353        ui: &mut egui::Ui,
354        _tile_id: egui_tiles::TileId,
355        tabs: &egui_tiles::Tabs,
356        _scroll_offset: &mut f32,
357    ) {
358        let Some(&pane) = tabs.active.and_then(|id| tiles.get_pane(&id)) else { return };
359        if glyph_text_button(ui, Glyph::Cross, "").on_hover_text("Hide (View menu shows it again)").clicked() {
360            self.hide.push(pane);
361        }
362        if glyph_text_button(ui, Glyph::PopOut, "").on_hover_text("Pop out into its own window").clicked() {
363            self.pop.push(pane);
364        }
365    }
366    fn simplification_options(&self) -> egui_tiles::SimplificationOptions {
367        egui_tiles::SimplificationOptions { all_panes_must_have_tabs: true, ..Default::default() }
368    }
369    fn on_edit(&mut self, edit_action: egui_tiles::EditAction) {
370        self.edited = true;
371        self.dropped |= edit_action == egui_tiles::EditAction::TileDropped;
372    }
373    /// Nine drop squares over the hovered tile instead of egui_tiles' thin outline: the centre one tabs
374    /// the pane in, the eight around it split in that direction. egui_tiles still owns the hit test, so a
375    /// corner resolves to whichever of its two edges is nearer — the translucent fill is where it lands.
376    fn paint_drag_preview(
377        &self,
378        visuals: &egui::Visuals,
379        painter: &egui::Painter,
380        parent_rect: Option<egui::Rect>,
381        preview_rect: egui::Rect,
382    ) {
383        let area = parent_rect.unwrap_or(preview_rect);
384        let stroke = self.drag_preview_stroke(visuals);
385        let fill = self.drag_preview_color(visuals);
386        painter.rect_filled(preview_rect, 1.0, fill.gamma_multiply(0.35));
387        painter.rect_stroke(area, 1.0, stroke, egui::StrokeKind::Inside);
388        let side = (area.size().min_elem() * 0.14).clamp(14.0, 34.0);
389        let step = side * 1.18;
390        // which ninth the pointer is in, so the square under it can light up
391        let hot = painter.ctx().pointer_interact_pos().map(|p| {
392            let cell = |v: f32, min: f32, len: f32| (3.0 * (v - min) / len.max(1.0)).floor().clamp(0.0, 2.0) as i32;
393            (cell(p.x, area.left(), area.width()), cell(p.y, area.top(), area.height()))
394        });
395        for row in 0..3 {
396            for col in 0..3 {
397                let c = area.center() + egui::vec2((col - 1) as f32, (row - 1) as f32) * step;
398                let sq = egui::Rect::from_center_size(c, egui::Vec2::splat(side));
399                let bg = if hot == Some((col, row)) { stroke.color } else { fill.gamma_multiply(0.6) };
400                painter.rect(sq, 2.0, bg, stroke, egui::StrokeKind::Inside);
401            }
402        }
403    }
404}
405
406/// Draw the docked tree into `ui` and every popped pane in its own OS window; `draw(ui, pane)` renders
407/// a pane's content. A popped window that the user closes is docked back automatically.
408/// Returns (layout changed this frame — caller persists it, a pane was dropped somewhere new).
409/// Repair tab containers whose `active` no longer names one of their children — which is what leaves a
410/// lone tab drawn as inactive after a rearrange, so it has to be clicked before its pane comes back.
411fn activate_orphan_tabs(tree: &mut egui_tiles::Tree<Pane>) {
412    let mut fix: Vec<(egui_tiles::TileId, egui_tiles::TileId)> = Vec::new();
413    for (id, tile) in tree.tiles.iter() {
414        if let egui_tiles::Tile::Container(egui_tiles::Container::Tabs(tabs)) = tile {
415            let ok = tabs.active.is_some_and(|a| tabs.children.contains(&a));
416            if !ok {
417                if let Some(&first) = tabs.children.first() {
418                    fix.push((*id, first));
419                }
420            }
421        }
422    }
423    for (id, child) in fix {
424        if let Some(egui_tiles::Tile::Container(egui_tiles::Container::Tabs(tabs))) = tree.tiles.get_mut(id) {
425            tabs.set_active(child);
426        }
427    }
428}
429
430pub fn show(
431    ctx: &egui::Context,
432    ui: &mut egui::Ui,
433    layout: &mut Layout,
434    draw: &mut dyn FnMut(&mut egui::Ui, Pane),
435) -> (bool, bool) {
436    // the drop happens inside tree.ui(), so grab the "before" state while a drag is still in flight
437    let dragged = layout.tree.dragged_id(ctx).map(|id| (id, share_fraction(&layout.tree, id), layout.to_json()));
438    let mut beh = Behaviour { draw, hide: Vec::new(), pop: Vec::new(), edited: false, dropped: false };
439    layout.tree.ui(&mut beh, ui);
440    let mut moved = false;
441    if let (true, Some((id, fraction, before))) = (beh.dropped, dragged) {
442        layout.push_undo(before);
443        if let Some(f) = fraction {
444            keep_share_fraction(&mut layout.tree, id, f);
445        }
446        // the tab you just dropped is the one you want to look at; without this it lands behind
447        // whichever tab the container had active before
448        layout.tree.make_active(|tid, _| tid == id);
449        moved = true;
450    }
451    activate_orphan_tabs(&mut layout.tree);
452    let (hide, pop, mut changed) = (beh.hide, beh.pop, beh.edited);
453    for p in hide {
454        if layout.is_visible(p) {
455            layout.toggle(p);
456            changed = true;
457        }
458    }
459    for p in pop {
460        layout.popout(p);
461        changed = true;
462    }
463    let mut to_dock: Vec<Pane> = Vec::new();
464    for &pane in &layout.popped {
465        ctx.show_viewport_immediate(
466            egui::ViewportId::from_hash_of(("pane", pane)),
467            egui::ViewportBuilder::default().with_title(pane.title()).with_inner_size([800.0, 500.0]),
468            |ctx, _class| {
469                if ctx.input(|i| i.viewport().close_requested()) {
470                    to_dock.push(pane);
471                }
472                egui::CentralPanel::default().show(ctx, |ui| draw(ui, pane));
473            },
474        );
475    }
476    for p in to_dock {
477        layout.dock(p);
478        changed = true;
479    }
480    (changed, moved)
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn default_layout_contains_every_pane() {
489        let l = Layout::default_layout();
490        for p in Pane::ALL {
491            assert!(l.tree.tiles.find_pane(&p).is_some(), "{p:?} missing from the default layout");
492            assert!(l.is_visible(p), "{p:?} should be visible (a tab may be inactive but is not hidden)");
493        }
494        // the Timeline is a column of its own, so no tab can sit in front of it
495        let timeline = l.tree.tiles.find_pane(&Pane::Timeline).unwrap();
496        assert!(matches!(l.tree.tiles.get(timeline), Some(egui_tiles::Tile::Pane(Pane::Timeline))));
497    }
498
499    /// The requested default: two rows of four columns, none of them opening unusably small.
500    #[test]
501    fn default_layout_arrangement() {
502        let l = Layout::default_layout();
503        let panes = |id: egui_tiles::TileId| -> Vec<Pane> {
504            match l.tree.tiles.get(id) {
505                Some(egui_tiles::Tile::Pane(p)) => vec![*p],
506                Some(egui_tiles::Tile::Container(c)) => {
507                    c.children().filter_map(|k| l.tree.tiles.get_pane(k)).copied().collect()
508                }
509                None => Vec::new(),
510            }
511        };
512        let columns = |id: egui_tiles::TileId| -> Vec<Vec<Pane>> {
513            let Some(egui_tiles::Container::Linear(lin)) = l.tree.tiles.get_container(id) else {
514                panic!("{id:?} is not a row")
515            };
516            assert_eq!(lin.dir, egui_tiles::LinearDir::Horizontal);
517            for &c in &lin.children {
518                let f = share_fraction(&l.tree, c).unwrap();
519                assert!(f >= 0.15, "a column opens at {f} of the row");
520            }
521            lin.children.iter().map(|&c| panes(c)).collect()
522        };
523        let root = l.tree.root().unwrap();
524        let Some(egui_tiles::Container::Linear(rows)) = l.tree.tiles.get_container(root) else { panic!("no rows") };
525        assert_eq!(rows.dir, egui_tiles::LinearDir::Vertical);
526        let (top, bottom) = (rows.children[0], rows.children[1]);
527        assert_eq!(
528            columns(top),
529            vec![
530                vec![Pane::Library],
531                vec![Pane::Preview, Pane::Tools], // the Tools strip lives under the viewport
532                vec![Pane::Inspector],
533                vec![Pane::Curves, Pane::Nodes, Pane::Tracking],
534            ]
535        );
536        assert_eq!(
537            columns(bottom),
538            vec![
539                vec![Pane::Effects, Pane::Transitions, Pane::Presets],
540                vec![Pane::Timeline],
541                vec![Pane::Mixer, Pane::AutoCut, Pane::Subtitles],
542                vec![Pane::Markers, Pane::Planner],
543            ]
544        );
545        // the Preview column really is vertical (Tools beneath, not beside)
546        let col = l.tree.tiles.parent_of(l.tree.tiles.find_pane(&Pane::Preview).unwrap()).unwrap();
547        match l.tree.tiles.get_container(col).unwrap() {
548            egui_tiles::Container::Linear(lin) => assert_eq!(lin.dir, egui_tiles::LinearDir::Vertical),
549            c => panic!("Preview column is {c:?}, expected a vertical linear"),
550        }
551    }
552
553    /// A layout stored before round 3 must not survive: it has no Tools / Mixer / Nodes / Markers.
554    #[test]
555    fn old_layouts_are_rejected_so_the_app_resets() {
556        let l = Layout::default_layout();
557        let json = l.to_json();
558        assert!(Layout::from_json(&json).is_some());
559        // drop one round-3 pane from the tree: that is what an old profile looks like
560        let mut old = l.clone();
561        let id = old.tree.tiles.find_pane(&Pane::Mixer).unwrap();
562        old.tree.tiles.remove(id);
563        assert!(Layout::from_json(&old.to_json()).is_none());
564        // …unless it is popped out into its own window
565        let mut popped = l.clone();
566        popped.popout(Pane::Markers);
567        assert!(Layout::from_json(&popped.to_json()).is_some());
568    }
569
570    /// A named profile is worth migrating rather than reporting as corrupt: the panes it predates are
571    /// added back and everything it did lay out survives.
572    #[test]
573    fn old_profiles_are_migrated_not_rejected() {
574        let mut old = Layout::default_layout();
575        for p in [Pane::Mixer, Pane::Nodes] {
576            let id = old.tree.tiles.find_pane(&p).unwrap();
577            old.tree.tiles.remove(id);
578        }
579        let json = old.to_json();
580        assert!(Layout::from_json(&json).is_none(), "the auto-restored layout still resets");
581        let migrated = Layout::from_json_migrating(&json).expect("a saved profile still loads");
582        for p in Pane::ALL {
583            assert!(migrated.tree.tiles.find_pane(&p).is_some(), "{p:?} missing after the migration");
584        }
585        assert!(migrated.is_visible(Pane::Timeline), "the panes it did have are untouched");
586        // genuinely broken JSON is still refused
587        assert!(Layout::from_json_migrating("not json").is_none());
588    }
589
590    #[test]
591    fn toggle_hides_and_shows() {
592        let mut l = Layout::default_layout();
593        assert!(l.is_visible(Pane::Library));
594        l.toggle(Pane::Library);
595        assert!(!l.is_visible(Pane::Library));
596        l.toggle(Pane::Library);
597        assert!(l.is_visible(Pane::Library));
598        // reveal is idempotent and never hides
599        l.reveal(Pane::Curves);
600        l.reveal(Pane::Curves);
601        assert!(l.is_visible(Pane::Curves));
602    }
603
604    #[test]
605    fn popout_and_dock() {
606        let mut l = Layout::default_layout();
607        l.popout(Pane::Inspector);
608        assert!(l.popped.contains(&Pane::Inspector));
609        assert!(l.is_visible(Pane::Inspector)); // popped counts as visible
610        let id = l.tree.tiles.find_pane(&Pane::Inspector).unwrap();
611        assert!(!l.tree.tiles.is_visible(id), "popped pane must not draw in the tree too");
612        // toggling a popped pane closes the popout
613        l.toggle(Pane::Inspector);
614        assert!(!l.is_visible(Pane::Inspector) && l.popped.is_empty());
615        l.popout(Pane::Effects);
616        l.dock(Pane::Effects);
617        assert!(l.popped.is_empty());
618        assert!(l.is_visible(Pane::Effects));
619    }
620
621    #[test]
622    fn json_roundtrip() {
623        let mut l = Layout::default_layout();
624        l.toggle(Pane::Planner);
625        l.popout(Pane::Library);
626        let json = l.to_json();
627        let r = Layout::from_json(&json).expect("roundtrip");
628        assert_eq!(r.popped, vec![Pane::Library]);
629        assert!(!r.is_visible(Pane::Planner));
630        assert!(r.is_visible(Pane::Timeline));
631        assert!(Layout::from_json("{").is_none());
632        assert!(Layout::from_json("{}").is_none());
633    }
634
635    /// Moving a pane keeps the slice of its parent it had (not the even split a fresh container hands
636    /// out), and the move is undoable / redoable on the layout's own stack.
637    #[test]
638    fn move_keeps_its_share_and_is_undoable() {
639        let mut l = Layout::default_layout();
640        let tools = l.tree.tiles.find_pane(&Pane::Tools).unwrap();
641        let column = l.tree.tiles.parent_of(tools).unwrap();
642        let was = share_fraction(&l.tree, tools).unwrap();
643        assert!((was - 0.1).abs() < 1e-4, "Tools owns a tenth of the centre column, got {was}");
644
645        // move it into the root row, exactly what a drop on a horizontal/vertical edge ends up doing
646        let root = l.tree.root().unwrap();
647        let snapshot = l.to_json();
648        l.tree.move_tile_to_container(tools, root, 2, false);
649        assert!((share_fraction(&l.tree, tools).unwrap() - 0.5).abs() < 1e-4, "egui_tiles splits evenly");
650        keep_share_fraction(&mut l.tree, tools, was);
651        assert!((share_fraction(&l.tree, tools).unwrap() - was).abs() < 1e-4, "the recorded fraction is back");
652        // and the two rows it joined keep their proportions to each other (0.68 : 0.32)
653        let top = l.tree.tiles.parent_of(l.tree.tiles.find_pane(&Pane::Preview).unwrap()).unwrap();
654        let top = l.tree.tiles.parent_of(top).unwrap();
655        let bottom = l.tree.tiles.parent_of(l.tree.tiles.find_pane(&Pane::Timeline).unwrap()).unwrap();
656        let ratio = share_fraction(&l.tree, top).unwrap() / share_fraction(&l.tree, bottom).unwrap();
657        assert!((ratio - 0.62 / 0.38).abs() < 1e-3, "the rest of the row was redistributed: {ratio}");
658
659        l.push_undo(snapshot);
660        assert!(l.undo(), "the move undoes");
661        assert_eq!(l.tree.tiles.parent_of(l.tree.tiles.find_pane(&Pane::Tools).unwrap()), Some(column));
662        assert!(l.redo(), "and redoes");
663        let tools = l.tree.tiles.find_pane(&Pane::Tools).unwrap();
664        assert_eq!(l.tree.tiles.parent_of(tools), Some(root));
665        assert!((share_fraction(&l.tree, tools).unwrap() - was).abs() < 1e-4, "the fraction survives the trip");
666        assert!(!l.redo(), "nothing left to redo");
667    }
668
669    /// A menu is as wide as what it drew last frame, so feeding that width back in is what collapsed
670    /// "Editor 1" into two lines: the entry must keep its width and stay one row tall.
671    #[test]
672    fn profile_name_stays_on_one_line() {
673        let ctx = egui::Context::default();
674        // a menu ui: top-down justified, as wide as the last frame's content
675        let frame = |ctx: &egui::Context, w: f32, add: &mut dyn FnMut(&mut egui::Ui) -> f32| {
676            let (mut inner, mut width) = (0.0, w);
677            let _ = ctx.run(egui::RawInput::default(), |ctx| {
678                egui::CentralPanel::default().show(ctx, |ui| {
679                    let rect = egui::Rect::from_min_size(ui.max_rect().min, egui::vec2(w, 200.0));
680                    let menu = egui::Layout::top_down_justified(egui::Align::Min);
681                    let b = egui::UiBuilder::new().max_rect(rect).layout(menu);
682                    ui.scope_builder(b, |ui| {
683                        inner = add(ui);
684                        width = ui.min_rect().width();
685                    });
686                });
687            });
688            (inner, width)
689        };
690        let mut width = 400.0;
691        let mut height = 0.0;
692        for _ in 0..5 {
693            (height, width) = frame(&ctx, width, &mut |ui| profile_button(ui, "Editor 1").rect.height());
694        }
695        assert!(width > 40.0, "the menu collapsed to {width} px wide");
696        // in a pane too narrow for it the name truncates instead of wrapping onto a second line
697        let (wrapped, _) = frame(&ctx, 30.0, &mut |ui| ui.button("Editor 1").rect.height());
698        let (kept, _) = frame(&ctx, 30.0, &mut |ui| profile_button(ui, "Editor 1").rect.height());
699        assert!(kept < wrapped, "the name still wraps: {kept} px vs {wrapped} px for a plain button");
700        assert!((kept - height).abs() < 1.0, "one row either way: {kept} px vs {height} px");
701    }
702
703    #[test]
704    fn lost_pane_is_reinserted() {
705        let mut l = Layout::default_layout();
706        // simulate a profile that lost a pane entirely
707        let id = l.tree.tiles.find_pane(&Pane::Planner).unwrap();
708        l.tree.tiles.remove(id);
709        assert!(!l.is_visible(Pane::Planner));
710        l.toggle(Pane::Planner);
711        assert!(l.is_visible(Pane::Planner));
712    }
713}