1use 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 Tools,
41 Nodes,
42 Mixer,
43 Markers,
44 Presets,
46 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 pub const ROUND3: [Pane; 4] = [Pane::Tools, Pane::Nodes, Pane::Mixer, Pane::Markers];
71 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#[derive(Clone, Serialize, Deserialize)]
116pub struct Layout {
117 pub tree: egui_tiles::Tree<Pane>,
118 #[serde(default)]
119 pub popped: Vec<Pane>,
120 #[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) };
142 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 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 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 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 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 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 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 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 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 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 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 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
295fn 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
303fn 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
317pub 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 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 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
406fn 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 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 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 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 #[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], 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 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 #[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 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 let mut popped = l.clone();
566 popped.popout(Pane::Markers);
567 assert!(Layout::from_json(&popped.to_json()).is_some());
568 }
569
570 #[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 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 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)); 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 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 #[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 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 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 #[test]
672 fn profile_name_stays_on_one_line() {
673 let ctx = egui::Context::default();
674 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 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 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}