From e88a2be508b4b5d54dad02172d71f8b8c34ebc67 Mon Sep 17 00:00:00 2001 From: Halit Can Date: Thu, 9 Jul 2026 12:09:05 +0300 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- Cargo.lock | 9 - Cargo.toml | 2 - grimdemo.rs | 234 --- hcie-egui-app/crates/hcie-gui-egui/Cargo.toml | 9 - .../hcie-gui-egui/examples/dock_demo.rs | 502 ----- .../crates/hcie-gui-egui/examples/grimdemo.rs | 238 --- .../hcie-gui-egui/src/app/dialogs/dialogs.rs | 3 - .../hcie-gui-egui/src/app/dock/egui_dock.rs | 58 +- .../hcie-gui-egui/src/app/dock/grim_dock.rs | 1256 ------------ .../crates/hcie-gui-egui/src/app/dock/mod.rs | 1 - .../crates/hcie-gui-egui/src/app/mod.rs | 332 +-- .../src/app/shell/layout_solver.rs | 6 +- .../hcie-gui-egui/src/app/shell/menus.rs | 6 +- .../crates/hcie-gui-egui/src/main.rs | 9 - .../hcie-gui-egui/tests/dock_behavior.rs | 316 --- .../hcie-gui-egui/tests/invariant_guards.rs | 224 --- line_count_report.txt | 40 +- patches/grimdock/.cargo-ok | 1 - patches/grimdock/.cargo_vcs_info.json | 6 - .../grimdock/.github/workflows/publish.yml | 59 - patches/grimdock/.gitignore | 22 - patches/grimdock/Cargo.toml | 84 - patches/grimdock/README.md | 27 - patches/grimdock/examples/1_basic.rs | 49 - patches/grimdock/examples/2_split_layout.rs | 83 - .../grimdock/examples/3_header_features.rs | 152 -- patches/grimdock/examples/4_persistence.rs | 103 - patches/grimdock/examples/5_texture_icons.rs | 111 - .../grimdock/examples/6_policy_controls.rs | 332 --- patches/grimdock/examples/7_theme_options.rs | 323 --- patches/grimdock/examples/demo.rs | 232 --- patches/grimdock/src/content.rs | 50 - patches/grimdock/src/dnd.rs | 746 ------- patches/grimdock/src/header.rs | 1194 ----------- patches/grimdock/src/ids.rs | 27 - patches/grimdock/src/layout.rs | 272 --- patches/grimdock/src/lib.rs | 243 --- patches/grimdock/src/persistence.rs | 457 ----- patches/grimdock/src/style.rs | 376 ---- patches/grimdock/src/tab.rs | 216 -- patches/grimdock/src/tree.rs | 1782 ----------------- 41 files changed, 130 insertions(+), 10062 deletions(-) delete mode 100644 grimdemo.rs delete mode 100644 hcie-egui-app/crates/hcie-gui-egui/examples/dock_demo.rs delete mode 100644 hcie-egui-app/crates/hcie-gui-egui/examples/grimdemo.rs delete mode 100644 hcie-egui-app/crates/hcie-gui-egui/src/app/dock/grim_dock.rs delete mode 100644 hcie-egui-app/crates/hcie-gui-egui/tests/dock_behavior.rs delete mode 100644 hcie-egui-app/crates/hcie-gui-egui/tests/invariant_guards.rs delete mode 100644 patches/grimdock/.cargo-ok delete mode 100644 patches/grimdock/.cargo_vcs_info.json delete mode 100644 patches/grimdock/.github/workflows/publish.yml delete mode 100644 patches/grimdock/.gitignore delete mode 100644 patches/grimdock/Cargo.toml delete mode 100644 patches/grimdock/README.md delete mode 100644 patches/grimdock/examples/1_basic.rs delete mode 100644 patches/grimdock/examples/2_split_layout.rs delete mode 100644 patches/grimdock/examples/3_header_features.rs delete mode 100644 patches/grimdock/examples/4_persistence.rs delete mode 100644 patches/grimdock/examples/5_texture_icons.rs delete mode 100644 patches/grimdock/examples/6_policy_controls.rs delete mode 100644 patches/grimdock/examples/7_theme_options.rs delete mode 100644 patches/grimdock/examples/demo.rs delete mode 100644 patches/grimdock/src/content.rs delete mode 100644 patches/grimdock/src/dnd.rs delete mode 100644 patches/grimdock/src/header.rs delete mode 100644 patches/grimdock/src/ids.rs delete mode 100644 patches/grimdock/src/layout.rs delete mode 100644 patches/grimdock/src/lib.rs delete mode 100644 patches/grimdock/src/persistence.rs delete mode 100644 patches/grimdock/src/style.rs delete mode 100644 patches/grimdock/src/tab.rs delete mode 100644 patches/grimdock/src/tree.rs diff --git a/Cargo.lock b/Cargo.lock index 5124eb5..0d88c0f 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2050,14 +2050,6 @@ dependencies = [ "gl_generator", ] -[[package]] -name = "grimdock" -version = "0.2.0" -dependencies = [ - "egui", - "serde", -] - [[package]] name = "h2" version = "0.4.15" @@ -2310,7 +2302,6 @@ dependencies = [ "egui_extras", "env_logger", "evdev", - "grimdock", "hcie-engine-api", "image", "log", diff --git a/Cargo.toml b/Cargo.toml index 6a4261f..130e0e2 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,6 @@ egui = "0.34" eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow", "persistence", "wayland", "x11"] } egui_extras = { version = "0.34", features = ["svg", "image"] } egui_dock = { version = "0.19", features = ["serde"] } -grimdock = { version = "0.2.0", features = ["serde"] } rfd = "0.15" # Utilities @@ -131,7 +130,6 @@ opt-level = 2 [patch.crates-io] egui-winit = { path = "patches/egui-winit" } -grimdock = { path = "patches/grimdock" } [profile.release] opt-level = 3 diff --git a/grimdemo.rs b/grimdemo.rs deleted file mode 100644 index be5e81b..0000000 --- a/grimdemo.rs +++ /dev/null @@ -1,234 +0,0 @@ -//! Runnable demo: `cargo run --example demo` -//! -//! Opens a window with a pre-built split layout. Tabs can be dragged between -//! panes, panes can be resized by dragging the handle between them. - -use eframe::egui; -use grimdock::{ - AddTabEntry, ChildSide, DropPolicy, Node, PaneBuilder, PaneStyleOverride, PanelContext, - PanelStyle, PanelTree, SplitDir, Tab, TabStyleOverride, -}; - -fn main() -> eframe::Result<()> { - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("grimdock demo") - .with_inner_size([1024.0, 680.0]), - ..Default::default() - }; - eframe::run_native("grimdock demo", options, Box::new(|_cc| Ok(Box::new(App::new())))) -} - -/// A tab identifier. Using a simple enum so each variant renders unique content. -#[derive(Clone, PartialEq, Eq, Debug)] -enum TabId { - Editor(u32), - FileTree, - Terminal, - Search, - Problems, - Output, -} - -impl std::fmt::Display for TabId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TabId::Editor(n) => write!(f, "editor_{n}"), - TabId::FileTree => write!(f, "file_tree"), - TabId::Terminal => write!(f, "terminal"), - TabId::Search => write!(f, "search"), - TabId::Problems => write!(f, "problems"), - TabId::Output => write!(f, "output"), - } - } -} - -struct App { - tree: PanelTree, - /// Counter that ticks up each frame in the terminal pane. - tick: u64, -} - -impl App { - fn new() -> Self { - // Build an initial layout: - // - // ┌───────────┬──────────────────┐ - // │ File Tree │ Editor 1 │ - // │ ├──────────────────┤ - // │ │ Terminal │ Output│ - // └───────────┴──────────────────┘ - // - // We do this by starting with a single pane and splitting. - - let mut tree = PanelTree::new(vec![ - Tab::new("editor_1", TabId::Editor(1)) - .with_leading_visual(">") - .with_style_override(TabStyleOverride { - active_bg: Some(egui::Color32::from_rgb(62, 73, 50)), - inactive_bg: Some(egui::Color32::from_rgb(44, 52, 38)), - hovered_bg: Some(egui::Color32::from_rgb(55, 65, 45)), - text_color: Some(egui::Color32::from_rgb(221, 231, 200)), - accent_color: Some(egui::Color32::from_rgb(164, 196, 92)), - icon_color: None, - max_width: None, - }), - Tab::new("editor_2", TabId::Editor(2)).with_leading_visual("+"), - ]); - - // Split root (editors) horizontally: file tree on the left (First side). - let _file_tree_pane = tree.split_leaf_with( - 0, - SplitDir::Horizontal, - PaneBuilder::new(Tab::new("file_tree", TabId::FileTree).with_leading_visual("#")).with_options( - grimdock::PaneOptions { - style_override: Some(PaneStyleOverride { - header_bg: Some(egui::Color32::from_rgb(27, 47, 44)), - content_bg: Some(egui::Color32::from_rgb(21, 36, 34)), - border_color: Some(egui::Color32::from_rgb(52, 90, 84)), - accent_color: Some(egui::Color32::from_rgb(85, 188, 162)), - content_inset: None, - }), - drop_policy: DropPolicy::merge_only(), - ..Default::default() - }, - ), - ChildSide::First, - ); - // Node 0 is now Horizontal split. - // Node 1 (left/First): FileTree - // Node 2 (right/Second): editors - - // Split the right pane (editors, node 2) vertically: bottom gets terminal. - let editors_pane = tree - .find_pane_containing(&TabId::Editor(1)) - .expect("editor pane should exist after initial split"); - let bottom_pane = { - let mut pane = tree.pane_mut(editors_pane).expect("editor pane should exist"); - pane.split_with( - SplitDir::Vertical, - PaneBuilder::new(Tab::new("terminal", TabId::Terminal).with_leading_visual("$")), - ChildSide::Second, - ) - }; - // Node 2 is now Vertical split. - // Node 5 (top/First): editors - // Node 6 (bottom/Second): terminal - - // Add extra tabs to the bottom bar. - // Nodes are: 5 = editors top pane, 6 = bottom pane (terminal). - let mut bottom = tree.pane_mut(bottom_pane).expect("bottom pane should exist"); - bottom.push_tab(Tab::new("output", TabId::Output).with_leading_visual("!")); - bottom.push_tab(Tab::new("problems", TabId::Problems).with_leading_visual("x")); - bottom.push_tab(Tab::new("search", TabId::Search).with_leading_visual("?")); - - // Narrow the left (file tree) column — set the split ratio. - if let Node::Split { ratio, .. } = tree.node_mut(0) { - *ratio = 0.22; - } - - // Make the bottom strip shorter. - if let Node::Split { ratio, .. } = tree.node_mut(2) { - *ratio = 0.65; - } - - Self { - tree, - tick: 0, - } - } -} - -impl eframe::App for App { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.tick += 1; - - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - let tree = &mut self.tree; - let mut style = PanelStyle::from_egui_style(ui.style().as_ref()); - style.content_inset = 4.0; - style.tabs.rounding = egui::CornerRadius::same(5); - style.header.button.rounding = egui::CornerRadius::same(5); - let tick = self.tick; - let add_tab_entries = vec![ - AddTabEntry::new( - "Editor 3", - Tab::new("editor_3", TabId::Editor(3)) - .with_leading_visual("*") - .with_closable(true), - ), - AddTabEntry::new( - "Terminal", - Tab::new("terminal", TabId::Terminal) - .with_leading_visual("$") - .with_closable(true), - ), - AddTabEntry::new( - "Search", - Tab::new("search", TabId::Search) - .with_leading_visual("?") - .with_closable(true), - ), - AddTabEntry::new( - "Problems", - Tab::new("problems", TabId::Problems) - .with_leading_visual("x") - .with_closable(true), - ), - ]; - - PanelContext::new(ui, tree, &style) - .with_add_tab_entries(&add_tab_entries) - .show(|ui, tab_id| { - render_tab(ui, tab_id, tick); - }); - }); - - // Repaint continuously so the terminal counter updates. - ctx.request_repaint(); - } -} - -fn render_tab(ui: &mut egui::Ui, tab_id: &TabId, tick: u64) { - egui::ScrollArea::vertical().show(ui, |ui| { - match tab_id { - TabId::Editor(n) => { - ui.add_space(8.0); - ui.label( - egui::RichText::new(format!("// Editor {n}\n\nfn main() {{\n println!(\"Hello, world!\");\n}}")) - .monospace() - .size(13.0), - ); - ui.add_space(8.0); - ui.label("Drag a tab to a different pane to rearrange."); - ui.label("Drag the handle between panes to resize."); - } - TabId::FileTree => { - ui.add_space(6.0); - for item in &["src/", " lib.rs", " tree.rs", " layout.rs", " header.rs", " dnd.rs", "Cargo.toml"] { - ui.label(egui::RichText::new(*item).monospace().size(12.0)); - } - } - TabId::Terminal => { - ui.add_space(4.0); - ui.label(egui::RichText::new(format!("$ frame {tick}")).monospace().size(12.0)); - ui.label(egui::RichText::new("grimdock running ✓").monospace().color(egui::Color32::from_rgb(100, 200, 120)).size(12.0)); - } - TabId::Search => { - ui.add_space(8.0); - ui.label("Search"); - ui.text_edit_singleline(&mut String::new()); - } - TabId::Problems => { - ui.add_space(8.0); - ui.label(egui::RichText::new("No problems detected.").color(egui::Color32::from_rgb(100, 200, 120))); - } - TabId::Output => { - ui.add_space(4.0); - ui.label(egui::RichText::new("[INFO] Build complete.").monospace().size(12.0)); - } - } - }); -} \ No newline at end of file diff --git a/hcie-egui-app/crates/hcie-gui-egui/Cargo.toml b/hcie-egui-app/crates/hcie-gui-egui/Cargo.toml index 610e95e..34f17b4 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/Cargo.toml +++ b/hcie-egui-app/crates/hcie-gui-egui/Cargo.toml @@ -7,14 +7,6 @@ edition = "2021" name = "hcie-gui" path = "src/main.rs" -[[example]] -name = "grimdemo" -path = "examples/grimdemo.rs" - -[[example]] -name = "dock_demo" -path = "examples/dock_demo.rs" - [features] default = ["tablet-evdev"] tablet-evdev = ["dep:evdev"] @@ -31,7 +23,6 @@ egui = { workspace = true } eframe = { workspace = true } egui_extras = { workspace = true } egui_dock = { workspace = true } -grimdock = { workspace = true } image = { workspace = true } rfd = { workspace = true } serde = { workspace = true } diff --git a/hcie-egui-app/crates/hcie-gui-egui/examples/dock_demo.rs b/hcie-egui-app/crates/hcie-gui-egui/examples/dock_demo.rs deleted file mode 100644 index 5a59a25..0000000 --- a/hcie-egui-app/crates/hcie-gui-egui/examples/dock_demo.rs +++ /dev/null @@ -1,502 +0,0 @@ -//! Standalone demo: dockable side panels with a document center. -//! -//! ```text -//! cargo run -p hcie-gui-egui --example dock_demo -//! ``` -//! -//! Layout: -//! ```text -//! ┌──────────┬──────────────────────────┬────────────┐ -//! │ Explorer │ Document 1 | Document 2 │ Properties │ -//! │ Outline │ Document 3 │ Output │ -//! ├──────────┤ ├────────────┤ -//! │ (left) │ (center) │ (right) │ -//! └──────────┴──────────────────────────┴────────────┘ -//! ``` - -use eframe::egui; -use grimdock::{ - AddTabEntry, ChildSide, DropPolicy, Node, PaneAnchor, PaneBuilder, PaneOptions, PaneRole, - PaneStyleOverride, PanelContext, PanelStyle, PanelTree, SplitDir, Tab, TabStyleOverride, -}; - -#[derive(Clone, PartialEq, Eq, Debug)] -enum TabId { - Document(u32), - Explorer, - Outline, - Properties, - Output, -} - -impl std::fmt::Display for TabId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TabId::Document(n) => write!(f, "Document {n}"), - TabId::Explorer => write!(f, "Explorer"), - TabId::Outline => write!(f, "Outline"), - TabId::Properties => write!(f, "Properties"), - TabId::Output => write!(f, "Output"), - } - } -} - -struct Palette { - bg: egui::Color32, - panel_bg: egui::Color32, - accent: egui::Color32, - text: egui::Color32, - text_dim: egui::Color32, - border: egui::Color32, - hover: egui::Color32, -} - -impl Palette { - const DARK: Self = Self { - bg: egui::Color32::from_rgb(30, 30, 30), - panel_bg: egui::Color32::from_rgb(37, 37, 38), - accent: egui::Color32::from_rgb(0, 122, 204), - text: egui::Color32::from_rgb(212, 212, 212), - text_dim: egui::Color32::from_rgb(128, 128, 128), - border: egui::Color32::from_rgb(51, 51, 51), - hover: egui::Color32::from_rgb(45, 45, 48), - }; -} - -struct App { - tree: PanelTree, - palette: Palette, - frame_count: u64, - documents: Vec, -} - -impl App { - fn new() -> Self { - let pal = Palette::DARK; - - let doc1 = Tab::new("Document 1", TabId::Document(1)) - .with_leading_visual(">") - .with_closable(true) - .with_style_override(TabStyleOverride { - active_bg: Some(pal.panel_bg), - inactive_bg: Some(pal.bg), - hovered_bg: Some(pal.hover), - text_color: Some(pal.text), - accent_color: Some(pal.accent), - icon_color: None, - max_width: None, - }); - - let doc2 = Tab::new("Document 2", TabId::Document(2)) - .with_leading_visual("+") - .with_closable(true); - - let mut tree = PanelTree::new(vec![doc1, doc2]); - - let side_opts = |anchor: PaneAnchor, role: PaneRole| PaneOptions { - header_visibility: grimdock::HeaderVisibility::Always, - allow_collapse: true, - allow_tab_reorder: true, - allow_tab_drag_out: true, - allow_resize: true, - drop_policy: DropPolicy::all(), - lock_layout: false, - anchor: Some(anchor), - role: Some(role), - persist_when_empty: true, - paint_content_bg: true, - style_override: Some(PaneStyleOverride { - header_bg: Some(pal.bg), - content_bg: Some(pal.panel_bg), - border_color: Some(pal.border), - accent_color: Some(pal.accent), - }), - }; - - // Split left: Explorer + Outline - tree.split_leaf_with( - 0, - SplitDir::Horizontal, - PaneBuilder::new( - Tab::new("Explorer", TabId::Explorer).with_leading_visual("\u{1F4C2}"), - ) - .with_tabs(vec![ - Tab::new("Explorer", TabId::Explorer).with_leading_visual("\u{1F4C2}"), - Tab::new("Outline", TabId::Outline).with_leading_visual("\u{2630}"), - ]) - .with_options(side_opts(PaneAnchor::Left, PaneRole::Sidebar)), - ChildSide::First, - ); - - // Split right from the center (Document) leaf - let center_leaf = tree - .find_pane_containing(&TabId::Document(1)) - .expect("document pane must exist"); - - { - let mut center = tree.pane_mut(center_leaf).expect("center pane"); - center.split_with( - SplitDir::Horizontal, - PaneBuilder::new( - Tab::new("Properties", TabId::Properties).with_leading_visual("\u{2699}"), - ) - .with_options(side_opts(PaneAnchor::Right, PaneRole::Inspector)), - ChildSide::Second, - ); - } - - // Add third document tab to center - let center_leaf = tree - .find_pane_containing(&TabId::Document(1)) - .expect("center pane after right split"); - { - let mut center = tree.pane_mut(center_leaf).expect("center pane"); - center.push_tab(Tab::new("Document 3", TabId::Document(3)).with_closable(true)); - } - - // Add Output tab to right pane - let right_leaf = tree - .find_pane_containing(&TabId::Properties) - .expect("right pane must exist"); - { - let mut right = tree.pane_mut(right_leaf).expect("right pane"); - right.push_tab(Tab::new("Output", TabId::Output).with_leading_visual("!")); - } - - // Set split ratios - if let Node::Split { ratio, .. } = tree.node_mut(0) { - *ratio = 0.22; - } - - // Set the center-vs-right split ratio. After our splits the tree has - // nodes 0..7, and the second horizontal split is at index 2. - if let Node::Split { - dir: SplitDir::Horizontal, - ratio, - .. - } = tree.node_mut(2) - { - *ratio = 0.78; - } - - let documents = vec![ - "Welcome to Document 1\n\nThis is a demo showing\ngrimdock panel docking.\n\n\ - Panels can be:\n\ - - Dragged between panes\n\ - - Resized by handles\n\ - - Collapsed from headers" - .into(), - "Document 2 — A different file.\n\nDrag tabs between panes,\nresize by dragging handles." - .into(), - "Document 3 — Third tab.\n\nPanels can be collapsed\nand re-ordered." - .into(), - ]; - - Self { - tree, - palette: pal, - frame_count: 0, - documents, - } - } -} - -impl eframe::App for App { - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.frame_count += 1; - let ctx = ui.ctx().clone(); - - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show_inside(ui, |ui| { - let tree = &mut self.tree; - let pal = &self.palette; - - let mut style = PanelStyle::from_egui_style(ui.style().as_ref()); - style.content_inset = 4.0; - style.tabs.rounding = egui::CornerRadius::same(4); - style.header.button.rounding = egui::CornerRadius::same(4); - - let frame_count = self.frame_count; - let documents = &self.documents; - - let add_tab_entries = vec![AddTabEntry::new( - "New Document", - Tab::new("New Document", TabId::Document(99)).with_closable(true), - )]; - - PanelContext::new(ui, tree, &style) - .with_add_tab_entries(&add_tab_entries) - .show(|ui, tab_id| { - render_content(ui, tab_id, pal, frame_count, documents); - }); - }); - - ctx.request_repaint(); - } -} - -fn render_content( - ui: &mut egui::Ui, - tab_id: &TabId, - pal: &Palette, - frame_count: u64, - documents: &[String], -) { - match tab_id { - TabId::Document(n) => render_document(ui, *n, pal, documents), - TabId::Explorer => render_explorer(ui, pal), - TabId::Outline => render_outline(ui, pal), - TabId::Properties => render_properties(ui, pal), - TabId::Output => render_output(ui, pal, frame_count), - } -} - -fn render_document(ui: &mut egui::Ui, n: u32, pal: &Palette, documents: &[String]) { - ui.add_space(12.0); - - let text = documents - .get((n - 1) as usize) - .map(|s| s.as_str()) - .unwrap_or("(empty document)"); - - let available = ui.available_rect_before_wrap(); - ui.painter().rect_filled( - available, - egui::CornerRadius::same(4), - egui::Color32::from_rgb(30, 30, 30), - ); - - ui.vertical(|ui| { - ui.add_space(8.0); - ui.horizontal(|ui| { - ui.add_space(8.0); - ui.label( - egui::RichText::new(format!("Document {n}")) - .size(16.0) - .strong() - .color(pal.text), - ); - }); - ui.add_space(8.0); - ui.separator(); - ui.add_space(8.0); - - for (i, line) in text.lines().enumerate() { - ui.horizontal(|ui| { - ui.add_space(8.0); - ui.label( - egui::RichText::new(format!("{:>3} ", i + 1)) - .monospace() - .size(13.0) - .color(pal.text_dim), - ); - ui.label( - egui::RichText::new(line) - .monospace() - .size(13.0) - .color(pal.text), - ); - }); - } - - ui.add_space(16.0); - ui.horizontal(|ui| { - ui.add_space(8.0); - ui.label( - egui::RichText::new( - "Drag tabs between panes | Resize by dragging handles | Collapse panels", - ) - .size(11.0) - .color(pal.text_dim), - ); - }); - }); -} - -fn render_explorer(ui: &mut egui::Ui, pal: &Palette) { - ui.add_space(6.0); - let items = [ - ("src/", true, 0), - ("main.rs", false, 1), - ("lib.rs", false, 1), - ("render/", true, 1), - ("pipeline.rs", false, 2), - ("shader.wgsl", false, 2), - ("ui/", true, 1), - ("panels.rs", false, 2), - ("dock.rs", false, 2), - ("assets/", true, 0), - ("icon.png", false, 1), - ("font.ttf", false, 1), - ("Cargo.toml", false, 0), - ("README.md", false, 0), - ]; - - for (name, is_dir, depth) in items { - ui.horizontal(|ui| { - ui.add_space(depth as f32 * 14.0); - let prefix = if is_dir { "\u{1F4C1} " } else { " " }; - let color = if is_dir { pal.accent } else { pal.text }; - ui.label( - egui::RichText::new(format!("{prefix}{name}")) - .monospace() - .size(12.0) - .color(color), - ); - }); - } -} - -fn render_outline(ui: &mut egui::Ui, pal: &Palette) { - ui.add_space(6.0); - let items = [ - ("struct App", 0), - (" fn new()", 1), - (" fn update()", 1), - ("impl eframe::App", 0), - (" fn ui()", 1), - ("fn render_content()", 0), - ("fn render_document()", 0), - ("fn render_explorer()", 0), - ]; - - for (name, depth) in items { - ui.horizontal(|ui| { - ui.add_space(depth as f32 * 14.0); - ui.label( - egui::RichText::new(name) - .monospace() - .size(12.0) - .color(if depth == 0 { pal.accent } else { pal.text }), - ); - }); - } -} - -fn render_properties(ui: &mut egui::Ui, pal: &Palette) { - ui.add_space(6.0); - - ui.label( - egui::RichText::new("Properties") - .strong() - .size(13.0) - .color(pal.text), - ); - ui.separator(); - ui.add_space(4.0); - - let props = [ - ("Type", "Panel Demo"), - ("Version", "1.0.0"), - ("Author", "HCIE"), - ("License", "MIT"), - ("Panels", "Left, Center, Right"), - ("Docking", "grimdock"), - ("Framework", "egui + eframe"), - ]; - - for (key, val) in props { - ui.horizontal(|ui| { - ui.add_space(4.0); - ui.label( - egui::RichText::new(format!("{key}:")) - .size(12.0) - .color(pal.text_dim), - ); - ui.label(egui::RichText::new(val).size(12.0).color(pal.text)); - }); - } - - ui.add_space(8.0); - ui.separator(); - ui.add_space(4.0); - - ui.label( - egui::RichText::new("Appearance") - .strong() - .size(13.0) - .color(pal.text), - ); - ui.add_space(4.0); - - ui.horizontal(|ui| { - ui.add_space(4.0); - ui.label( - egui::RichText::new("BG Color:") - .size(12.0) - .color(pal.text_dim), - ); - ui.color_edit_button_srgba(&mut egui::Color32::from_rgb(37, 37, 38)); - }); - ui.horizontal(|ui| { - ui.add_space(4.0); - ui.label( - egui::RichText::new("Accent:") - .size(12.0) - .color(pal.text_dim), - ); - ui.color_edit_button_srgba(&mut egui::Color32::from_rgb(0, 122, 204)); - }); - ui.horizontal(|ui| { - ui.add_space(4.0); - ui.label( - egui::RichText::new("Font Size:") - .size(12.0) - .color(pal.text_dim), - ); - ui.add(egui::Slider::new(&mut 13.0f32, 8.0..=24.0).suffix("px")); - }); -} - -fn render_output(ui: &mut egui::Ui, pal: &Palette, frame: u64) { - ui.add_space(4.0); - - let messages = [ - ("[INFO] Application started", pal.text), - ("[INFO] grimdock layout initialized", pal.text), - ("[INFO] Left panel: Explorer + Outline", pal.accent), - ("[INFO] Right panel: Properties + Output", pal.accent), - ("[INFO] Center: 3 document tabs", pal.text), - ("[INFO] Waiting for user interaction...", pal.text_dim), - ( - "[OK] All panels responsive", - egui::Color32::from_rgb(100, 200, 120), - ), - ]; - - for (msg, color) in messages { - ui.horizontal(|ui| { - ui.add_space(4.0); - ui.label(egui::RichText::new(msg).monospace().size(11.0).color(color)); - }); - } - - ui.add_space(8.0); - ui.separator(); - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.add_space(4.0); - ui.label( - egui::RichText::new(format!("tick: {frame}")) - .monospace() - .size(11.0) - .color(pal.text_dim), - ); - }); -} - -fn main() -> eframe::Result<()> { - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("grimdock — dock panel demo") - .with_inner_size([1200.0, 720.0]) - .with_min_inner_size([800.0, 480.0]), - ..Default::default() - }; - eframe::run_native( - "grimdock dock demo", - options, - Box::new(|_cc| Ok(Box::new(App::new()))), - ) -} diff --git a/hcie-egui-app/crates/hcie-gui-egui/examples/grimdemo.rs b/hcie-egui-app/crates/hcie-gui-egui/examples/grimdemo.rs deleted file mode 100644 index 84f582f..0000000 --- a/hcie-egui-app/crates/hcie-gui-egui/examples/grimdemo.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Runnable demo: `cargo run --example grimdemo` -//! -//! Opens a window with a pre-built split layout. Tabs can be dragged between -//! panes, panes can be resized by dragging the handle between them. - -use eframe::egui; -use grimdock::{ - AddTabEntry, ChildSide, DropPolicy, Node, PaneBuilder, PaneStyleOverride, PanelContext, - PanelStyle, PanelTree, SplitDir, Tab, TabStyleOverride, -}; - -fn main() -> eframe::Result<()> { - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("grimdock demo") - .with_inner_size([1024.0, 680.0]), - ..Default::default() - }; - eframe::run_native( - "grimdock demo", - options, - Box::new(|_cc| Ok(Box::new(App::new()))), - ) -} - -#[derive(Clone, PartialEq, Eq, Debug)] -enum TabId { - Editor(u32), - FileTree, - Terminal, - Search, - Problems, - Output, -} - -impl std::fmt::Display for TabId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TabId::Editor(n) => write!(f, "editor_{n}"), - TabId::FileTree => write!(f, "file_tree"), - TabId::Terminal => write!(f, "terminal"), - TabId::Search => write!(f, "search"), - TabId::Problems => write!(f, "problems"), - TabId::Output => write!(f, "output"), - } - } -} - -struct App { - tree: PanelTree, - tick: u64, -} - -impl App { - fn new() -> Self { - let mut tree = PanelTree::new(vec![ - Tab::new("editor_1", TabId::Editor(1)) - .with_leading_visual(">") - .with_style_override(TabStyleOverride { - active_bg: Some(egui::Color32::from_rgb(62, 73, 50)), - inactive_bg: Some(egui::Color32::from_rgb(44, 52, 38)), - hovered_bg: Some(egui::Color32::from_rgb(55, 65, 45)), - text_color: Some(egui::Color32::from_rgb(221, 231, 200)), - accent_color: Some(egui::Color32::from_rgb(164, 196, 92)), - icon_color: None, - max_width: None, - }), - Tab::new("editor_2", TabId::Editor(2)).with_leading_visual("+"), - ]); - - let _file_tree_pane = tree.split_leaf_with( - 0, - SplitDir::Horizontal, - PaneBuilder::new(Tab::new("file_tree", TabId::FileTree).with_leading_visual("#")) - .with_options(grimdock::PaneOptions { - style_override: Some(PaneStyleOverride { - header_bg: Some(egui::Color32::from_rgb(27, 47, 44)), - content_bg: Some(egui::Color32::from_rgb(21, 36, 34)), - border_color: Some(egui::Color32::from_rgb(52, 90, 84)), - accent_color: Some(egui::Color32::from_rgb(85, 188, 162)), - }), - drop_policy: DropPolicy::merge_only(), - ..Default::default() - }), - ChildSide::First, - ); - - let editors_pane = tree - .find_pane_containing(&TabId::Editor(1)) - .expect("editor pane should exist after initial split"); - let bottom_pane = { - let mut pane = tree - .pane_mut(editors_pane) - .expect("editor pane should exist"); - pane.split_with( - SplitDir::Vertical, - PaneBuilder::new(Tab::new("terminal", TabId::Terminal).with_leading_visual("$")), - ChildSide::Second, - ) - }; - - let mut bottom = tree - .pane_mut(bottom_pane) - .expect("bottom pane should exist"); - bottom.push_tab(Tab::new("output", TabId::Output).with_leading_visual("!")); - bottom.push_tab(Tab::new("problems", TabId::Problems).with_leading_visual("x")); - bottom.push_tab(Tab::new("search", TabId::Search).with_leading_visual("?")); - - if let Node::Split { ratio, .. } = tree.node_mut(0) { - *ratio = 0.22; - } - - if let Node::Split { ratio, .. } = tree.node_mut(2) { - *ratio = 0.65; - } - - Self { tree, tick: 0 } - } -} - -impl eframe::App for App { - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.tick += 1; - - let ctx = ui.ctx().clone(); - - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show_inside(ui, |ui| { - let tree = &mut self.tree; - let mut style = PanelStyle::from_egui_style(ui.style().as_ref()); - style.content_inset = 4.0; - style.tabs.rounding = egui::CornerRadius::same(5); - style.header.button.rounding = egui::CornerRadius::same(5); - let tick = self.tick; - let add_tab_entries = vec![ - AddTabEntry::new( - "Editor 3", - Tab::new("editor_3", TabId::Editor(3)) - .with_leading_visual("*") - .with_closable(true), - ), - AddTabEntry::new( - "Terminal", - Tab::new("terminal", TabId::Terminal) - .with_leading_visual("$") - .with_closable(true), - ), - AddTabEntry::new( - "Search", - Tab::new("search", TabId::Search) - .with_leading_visual("?") - .with_closable(true), - ), - AddTabEntry::new( - "Problems", - Tab::new("problems", TabId::Problems) - .with_leading_visual("x") - .with_closable(true), - ), - ]; - - PanelContext::new(ui, tree, &style) - .with_add_tab_entries(&add_tab_entries) - .show(|ui, tab_id| { - render_tab(ui, tab_id, tick); - }); - }); - - ctx.request_repaint(); - } -} - -fn render_tab(ui: &mut egui::Ui, tab_id: &TabId, tick: u64) { - egui::ScrollArea::vertical().show(ui, |ui| match tab_id { - TabId::Editor(n) => { - ui.add_space(8.0); - ui.label( - egui::RichText::new(format!( - "// Editor {n}\n\nfn main() {{\n println!(\"Hello, world!\");\n}}" - )) - .monospace() - .size(13.0), - ); - ui.add_space(8.0); - ui.label("Drag a tab to a different pane to rearrange."); - ui.label("Drag the handle between panes to resize."); - } - TabId::FileTree => { - ui.add_space(6.0); - for item in &[ - "src/", - " lib.rs", - " tree.rs", - " layout.rs", - " header.rs", - " dnd.rs", - "Cargo.toml", - ] { - ui.label(egui::RichText::new(*item).monospace().size(12.0)); - } - } - TabId::Terminal => { - ui.add_space(4.0); - ui.label( - egui::RichText::new(format!("$ frame {tick}")) - .monospace() - .size(12.0), - ); - ui.label( - egui::RichText::new("grimdock running ✓") - .monospace() - .color(egui::Color32::from_rgb(100, 200, 120)) - .size(12.0), - ); - } - TabId::Search => { - ui.add_space(8.0); - ui.label("Search"); - ui.text_edit_singleline(&mut String::new()); - } - TabId::Problems => { - ui.add_space(8.0); - ui.label( - egui::RichText::new("No problems detected.") - .color(egui::Color32::from_rgb(100, 200, 120)), - ); - } - TabId::Output => { - ui.add_space(4.0); - ui.label( - egui::RichText::new("[INFO] Build complete.") - .monospace() - .size(12.0), - ); - } - }); -} diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/dialogs/dialogs.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/dialogs/dialogs.rs index 29fff10..79b6a54 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/dialogs/dialogs.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/dialogs/dialogs.rs @@ -666,9 +666,6 @@ pub fn show_close_confirm_dialog(app: &mut HcieApp, ctx: &egui::Context) { app.show_close_confirm = None; if discard { app.event_bus.push(AppEvent::DocClosed(idx)); - } else if app.use_grimdock { - // Restore the tab back to the grimdock center area if close was canceled - app.push_document_to_grimdock_center(crate::app::dock::HciePane::Document(idx)); } } } diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/egui_dock.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/egui_dock.rs index 281829e..92fee39 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/egui_dock.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/egui_dock.rs @@ -33,8 +33,7 @@ impl HciePane { } /// Stable title that does not require a full `HcieApp` reference. - /// Used by backends (e.g. `grimdock`) that build tabs before the app is - /// fully initialized. + /// Used when building tabs before the app is fully initialized. pub fn static_title(&self) -> &'static str { match self { HciePane::Tools => "Tools", @@ -312,7 +311,9 @@ impl<'a> HcieTabViewer<'a> { } HciePane::Plugins => { crate::app::shell::gui_layout::track_panel(ui, "panel.plugins", |ui| { - crate::app::panels::show_plugins_ui(self.app, ui); + if !self.app.documents.is_empty() { + crate::app::panels::show_plugins_ui(self.app, ui); + } }); } HciePane::LayerStyles => { @@ -563,8 +564,10 @@ impl<'a> TabViewer for HcieTabViewer<'a> { } }); - // Custom close button at the far right (replaces DockArea built-in) - if is_closeable { + // Custom close button at the far right (replaces DockArea built-in). + // Hidden by default; only appears when the tab is hovered so the + // title bar stays clean and the pin icon is always reachable. + if is_closeable && response.hovered() { let close_size = 14.0; let close_center = egui::pos2(response.rect.right() - 8.0, response.rect.center().y); let close_rect = @@ -624,15 +627,15 @@ impl<'a> TabViewer for HcieTabViewer<'a> { } } - // Pin button for utility panels (to the left of the close button) - if !tab.is_document() && !matches!(tab, HciePane::Tools) { - // Always show the pin button when the tab is floating, otherwise only - // show on hover so the tab label keeps its full width. - if !is_floating && !response.hovered() { - return; - } - - let pin_offset = if is_closeable { 24.0 } else { 8.0 }; + // Pin / float toggle for docked utility panels only. + // LayerStyles, LayerDetails and Plugins are dialog-style panels: they + // cannot float and do not show a pin toggle. + let supports_float = + !tab.is_document() && !matches!(tab, HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins); + if supports_float { + // Pin icon is always visible on the right side of utility tabs so + // users can float/re-dock a panel without first hovering the tab. + let pin_offset = if is_closeable && response.hovered() { 24.0 } else { 8.0 }; let pin_center = egui::pos2(response.rect.right() - pin_offset, response.rect.center().y); let pin_rect = egui::Rect::from_center_size(pin_center, egui::vec2(14.0, 14.0)); @@ -677,7 +680,8 @@ impl<'a> TabViewer for HcieTabViewer<'a> { /// /// **Logic & Workflow:** /// Actual documents are closeable, while the EmptyCenter placeholder is not. - /// Utility panels are closeable only if they are not pinned in `self.app.state.pinned_panels`. + /// Utility panels are always closeable regardless of pinned state. Pin only controls + /// startup visibility and the View menu; it does not prevent the user from closing a panel. /// /// **Arguments:** /// * `tab`: The active mutable reference to the `HciePane` tab. @@ -687,8 +691,7 @@ impl<'a> TabViewer for HcieTabViewer<'a> { if tab.is_document() { return tab.is_actual_document(); } - let title = tab.title(self.app); - !self.app.state.pinned_panels.contains(&title) + !matches!(tab, HciePane::Tools) } /// **Purpose:** Handles the tab close request when the user clicks the close button. @@ -742,9 +745,13 @@ impl<'a> TabViewer for HcieTabViewer<'a> { } /// **Purpose:** Controls whether a tab is allowed to be torn out into a separate OS window. - /// Documents and Tools cannot float. + /// Documents, Tools, and dialog-style panels (LayerStyles, LayerDetails, Plugins) cannot float. fn allowed_in_windows(&self, tab: &mut Self::Tab) -> bool { - !tab.is_document() && !matches!(tab, HciePane::Tools) + !tab.is_document() + && !matches!( + tab, + HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins + ) } /// **Purpose:** Custom context menu for dock tabs (VS2022 style). @@ -827,7 +834,11 @@ impl<'a> TabViewer for HcieTabViewer<'a> { ui.add_space(4.0); } - if !is_doc && !matches!(tab, HciePane::Tools) { + // Float / close options intentionally disabled for LayerStyles, LayerDetails and Plugins + // because these panels render as popups / dialogs, not docked utility cards. + let is_floating_allowed = !is_doc + && !matches!(tab, HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins); + if is_floating_allowed { let mut float = false; menu_item(ui, "Yeni Pencere", true, &mut float); if float { @@ -850,8 +861,11 @@ impl<'a> TabViewer for HcieTabViewer<'a> { "Paneli Kapat" }; let close_enabled = self.is_closeable(tab); - menu_item(ui, close_label, close_enabled, &mut close); - if close && close_enabled { + // Dialog-style panels (LayerStyles/LayerDetails/Plugins) are closed by the + // dialog itself, not by the dock tab. + let close_in_context = close_enabled && !matches!(tab, HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins); + menu_item(ui, close_label, close_in_context, &mut close); + if close && close_in_context { if let HciePane::Document(idx) = tab { if self .app diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/grim_dock.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/grim_dock.rs deleted file mode 100644 index db2acab..0000000 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/grim_dock.rs +++ /dev/null @@ -1,1256 +0,0 @@ -#![allow(deprecated)] -#![allow(dead_code)] -//! Prototype `grimdock` backend that mirrors the existing `egui_dock` layout. -//! -//! **Purpose:** -//! Provides a side-by-side experimental docking implementation using the -//! `grimdock` crate, without modifying the existing `egui_dock` code paths. -//! -//! **Logic & Workflow:** -//! 1. `HciePane` tab identifiers are reused unchanged. -//! 2. A `grimdock::PanelTree` is constructed once with the same -//! default layout as `default_dock_state()` (Tools left, document center, -//! utility panels right). -//! 3. Each frame, `render_grimdock()` builds a `PanelContext` and runs the -//! `show()` callback. Inside the callback, the active tab is dispatched to -//! the existing panel rendering helpers in `app::dock::HcieTabViewer`. -//! -//! **Arguments / API surface:** -//! * `GrimDockHost::new()` — creates the default tree. -//! * `GrimDockHost::render(ctx, app)` — runs one frame of grimdock layout. -//! -//! **Side Effects:** -//! Mutates the internal `PanelTree` to record drag/drop/resize changes. - -use crate::app::{dock, HcieApp}; -use eframe::egui; -use grimdock::{ - Node, PaneAnchor, PaneId, PaneOptions, PanelContext, PanelStyle, PanelTree, PersistedNode, - PersistedPanelTree, SplitDir, Tab, -}; - -/// Wrapper around the `grimdock` state for `HciePane` tabs. -pub struct GrimDockHost { - /// The persisted panel tree across frames. - pub tree: PanelTree, - /// Pane ids that requested to be floated during the last frame. - float_queue: Vec, - /// Maps a pane identifier to the leaf index it came from when it was last - /// floated. Used to return a panel to its previous dock location when the - /// user presses the dock button on a floating panel. If the previous leaf - /// no longer exists, the panel falls back to its default pane. - float_source_leaf: std::collections::HashMap, - /// The last synchronized active document index from the tree. - pub last_active_doc: Option, -} - -impl GrimDockHost { - /// Build a `Tab` for the given pane; public so the screenshot injector can - /// add missing panels into the grimdock tree without duplicating tab logic. - pub fn new_dummy_tab(pane: dock::HciePane) -> grimdock::Tab { - grimdock::Tab::new(pane.static_title(), pane) - } - - /// Construct a default layout matching the existing `egui_dock` default. - pub fn new() -> Self { - use dock::HciePane; - - // Pane id assignments for each leaf. - const PANE_TOOLS: u64 = 1; - const PANE_BRUSHES: u64 = 2; - const PANE_FILTERS: u64 = 3; - const PANE_LAYERS: u64 = 4; - const PANE_PROPERTIES: u64 = 5; - const PANE_DOCUMENT: u64 = 6; - const PANE_COLORBOX: u64 = 7; - const PANE_HISTORY: u64 = 8; - const PANE_AI: u64 = 9; - - fn leaf( - pane_id: u64, - tabs: Vec>, - options: PaneOptions, - ) -> PersistedNode { - PersistedNode::Leaf { - pane: PaneId::from_raw(pane_id), - tabs, - focused: 0, - options, - collapsed: false, - } - } - - fn make_tab(pane: HciePane) -> Tab { - Tab::new(pane.static_title(), pane) - } - - pub fn create_tab(pane: HciePane) -> Tab { - make_tab(pane) - } - - pub fn icon_only_tab(pane: HciePane) -> Tab { - let glyph = pane.icon_glyph(); - Tab::new("", pane).with_centered_glyph(glyph) - } - - fn utility_options(anchor: PaneAnchor, allow_resize: bool) -> PaneOptions { - PaneOptions { - header_visibility: grimdock::HeaderVisibility::Always, - allow_collapse: true, - allow_tab_reorder: true, - allow_tab_drag_out: true, - allow_resize, - drop_policy: grimdock::DropPolicy::all(), - lock_layout: false, - anchor: Some(anchor), - role: Some(grimdock::PaneRole::Sidebar), - persist_when_empty: true, - paint_content_bg: true, - style_override: None, - } - } - - fn tools_options() -> PaneOptions { - PaneOptions { - header_visibility: grimdock::HeaderVisibility::Hidden, - allow_collapse: false, - allow_tab_reorder: false, - allow_tab_drag_out: false, - allow_resize: false, - drop_policy: grimdock::DropPolicy::none(), - lock_layout: true, - anchor: Some(PaneAnchor::Left), - role: Some(grimdock::PaneRole::Sidebar), - persist_when_empty: true, - paint_content_bg: true, - style_override: None, - } - } - - fn document_options() -> PaneOptions { - PaneOptions { - header_visibility: grimdock::HeaderVisibility::Always, - allow_collapse: false, - allow_tab_reorder: true, - allow_tab_drag_out: false, - allow_resize: true, - drop_policy: grimdock::DropPolicy::all(), - lock_layout: false, - anchor: Some(PaneAnchor::Center), - role: Some(grimdock::PaneRole::Editor), - persist_when_empty: true, - paint_content_bg: true, - style_override: None, - } - } - // Heap-indexed binary tree: - // 0: H split (left_col2 | center_and_right) ratio = 0.1608 - // 1: V split (Brushes | Filters) ratio = 0.5 - // 2: H split (center | right_col2) ratio = 0.8084 - // 3: Leaf Brushes - // 4: Leaf Filters - // 5: H split (center_doc | right_col1) ratio = 0.763 - // 6: V split (ColorBox | [History,AI]) ratio = 0.5 - // 11: Leaf Document(0) - // 12: V split (Layers | Properties) ratio = 0.5 - // 13: Leaf ColorBox - // 14: Leaf [History, AIAssistant] - // 25: Leaf Layers - // 26: Leaf Properties - let nodes: Vec> = { - let mut nodes = vec![PersistedNode::Empty; 27]; - nodes[0] = PersistedNode::Split { - dir: SplitDir::Horizontal, - ratio: 0.1608, - }; - nodes[1] = PersistedNode::Split { - dir: SplitDir::Vertical, - ratio: 0.5, - }; - nodes[2] = PersistedNode::Split { - dir: SplitDir::Horizontal, - ratio: 0.8084, - }; - nodes[3] = leaf( - PANE_BRUSHES, - vec![make_tab(HciePane::Brushes)], - utility_options(PaneAnchor::Left, true), - ); - nodes[4] = leaf( - PANE_FILTERS, - vec![make_tab(HciePane::Filters)], - utility_options(PaneAnchor::Left, true), - ); - nodes[5] = PersistedNode::Split { - dir: SplitDir::Horizontal, - ratio: 0.763, - }; - nodes[6] = PersistedNode::Split { - dir: SplitDir::Vertical, - ratio: 0.5, - }; - nodes[11] = leaf( - PANE_DOCUMENT, - vec![Tab::new("Untitled", HciePane::Document(0)) - .with_closable(true) - .with_draggable(false)], - document_options(), - ); - nodes[12] = PersistedNode::Split { - dir: SplitDir::Vertical, - ratio: 0.5, - }; - nodes[13] = leaf( - PANE_COLORBOX, - vec![make_tab(HciePane::ColorBox)], - utility_options(PaneAnchor::Right, true), - ); - nodes[14] = leaf( - PANE_HISTORY, - vec![make_tab(HciePane::History), make_tab(HciePane::AiAssistant)], - utility_options(PaneAnchor::Right, true), - ); - nodes[25] = leaf( - PANE_LAYERS, - vec![make_tab(HciePane::Layers)], - utility_options(PaneAnchor::Right, true), - ); - nodes[26] = leaf( - PANE_PROPERTIES, - vec![make_tab(HciePane::Properties)], - utility_options(PaneAnchor::Right, true), - ); - nodes - }; - - let persisted = PersistedPanelTree { - version: grimdock::PANEL_TREE_FORMAT_VERSION, - next_pane_id: PANE_AI + 1, - nodes, - }; - - let tree = PanelTree::from_persisted(persisted).expect("default grimdock layout valid"); - Self { - tree, - float_queue: Vec::new(), - float_source_leaf: std::collections::HashMap::new(), - last_active_doc: None, - } - } - - /// Render one frame of the grimdock layout into the given UI. - pub fn render(&mut self, ui: &mut egui::Ui, app: &mut HcieApp) { - let mut style = PanelStyle::from_egui_style(ui.style().as_ref()); - let colors = crate::app::ThemeColors::get(app.state.settings.theme); - - // Process panels_to_close queued from external sources (e.g. from the menu toggle) - let to_close: Vec = app.panels_to_close.drain(..).collect(); - for pane in to_close { - if let dock::HciePane::Document(idx) = &pane { - if app - .documents - .get(*idx) - .map(|d| d.engine.is_modified()) - .unwrap_or(false) - { - app.show_close_confirm = Some(*idx); - continue; - } - } - if !pane.is_document() { - app.state.pinned_panels.remove(pane.static_title()); - } - // Remove from floating panels if floating - if let Some(pos) = app - .grim_floating_panels - .iter() - .position(|(p, _)| p == &pane) - { - app.grim_floating_panels.remove(pos); - } - // Remove from dock tree if docked - if let Some(leaf_idx) = self.tree.find_leaf_containing(&pane) { - if let Some(pane_id) = self.tree.pane_id_at(leaf_idx) { - if let Some(p) = self.tree.pane_mut(pane_id) { - if let Some(tab_pos) = p.tabs().iter().position(|t| t.id == pane) { - self.tree.remove_tab_at(leaf_idx, tab_pos); - log::info!( - "grimdock: closed/removed tab {:?} from leaf {}", - pane, - leaf_idx - ); - } - } - } - } - } - - // After processing all close requests, if no documents remain in the - // tree, insert the EmptyCenter placeholder into the document leaf so - // the center area is not blank. - let has_any_doc_tab = self.tree.leaf_indices().any(|idx| { - self.tree - .pane_id_at(idx) - .and_then(|pid| self.tree.pane(pid)) - .map(|p| { - p.tabs() - .iter() - .any(|t| matches!(t.id, dock::HciePane::Document(_))) - }) - .unwrap_or(false) - }); - let has_empty_center = self.tree.leaf_indices().any(|idx| { - self.tree - .pane_id_at(idx) - .and_then(|pid| self.tree.pane(pid)) - .map(|p| { - p.tabs() - .iter() - .any(|t| matches!(t.id, dock::HciePane::EmptyCenter)) - }) - .unwrap_or(false) - }); - if !has_any_doc_tab && !has_empty_center && app.documents.is_empty() { - // Find the document leaf (anchor = Center, role = Editor) - if let Some(doc_pane_id) = self.tree.find_pane_with_role(grimdock::PaneRole::Editor) { - if let Some(mut pane) = self.tree.pane_mut(doc_pane_id) { - pane.push_tab(grimdock::Tab::new( - "No Active Document", - dock::HciePane::EmptyCenter, - )); - log::info!("grimdock: inserted EmptyCenter placeholder into document leaf"); - } - } - } - - // Process panels_to_float queued from external sources (like context menus or tab viewer) - let to_float: Vec = app.panels_to_float.drain(..).collect(); - for pane in to_float { - if pane.is_document() || matches!(pane, dock::HciePane::Tools) { - continue; // Do not allow floating document or tools panes - } - if let Some(leaf_idx) = self.tree.find_leaf_containing(&pane) { - if let Some(pane_id) = self.tree.pane_id_at(leaf_idx) { - if let Some(p) = self.tree.pane(pane_id) { - if let Some(tab_pos) = p.tabs().iter().position(|t| t.id == pane) { - if let Some(removed) = self.tree.remove_tab_at(leaf_idx, tab_pos) { - self.float_source_leaf.insert(pane_id, leaf_idx); - app.grim_floating_panels - .push((removed.id.clone(), default_float_rect(ui.ctx()))); - log::info!( - "grimdock: floated tab {:?} from leaf {}", - pane, - leaf_idx - ); - } - } - } - } - } - } - - // Process panels_to_focus queued from external sources (e.g. to dock a panel back) - let to_focus: Vec = app.panels_to_focus.drain(..).collect(); - for pane in to_focus { - let is_floating = app.grim_floating_panels.iter().any(|(p, _)| p == &pane); - let in_tree = self.tree.find_leaf_containing(&pane).is_some(); - - if is_floating { - if let Some(pos) = app - .grim_floating_panels - .iter() - .position(|(p, _)| p == &pane) - { - app.grim_floating_panels.remove(pos); - // Return to dock - let pane_id = PaneId::from_raw(pane_id_for_pane(&pane)); - let target_leaf = self - .float_source_leaf - .get(&pane_id) - .copied() - .filter(|idx| self.tree.pane_options(*idx).is_some()) - .or_else(|| self.tree.find_leaf_containing(&pane)) - .or_else(|| { - let fallback = default_leaf_for_pane(&pane); - if self.tree.pane_options(fallback).is_some() { - Some(fallback) - } else { - None - } - }); - if let Some(idx) = target_leaf { - self.tree.set_collapsed(idx, false); - let mut tab = Tab::new(pane.static_title(), pane.clone()); - if pane.is_document() { - tab = tab.with_closable(true).with_draggable(false); - } - self.tree.insert_tab_into_leaf(idx, tab); - log::info!("grimdock: docked tab {:?} back to leaf {}", pane, idx); - } - } - } else if in_tree { - // If it's not floating, just ensure it's not collapsed and focus its leaf - if let Some(leaf_idx) = self.tree.find_leaf_containing(&pane) { - self.tree.set_collapsed(leaf_idx, false); - if let Some(pane_id) = self.tree.pane_id_at(leaf_idx) { - if let Some(mut p) = self.tree.pane_mut(pane_id) { - if let Some(tab_pos) = p.tabs().iter().position(|t| t.id == pane) { - p.set_focused_index(tab_pos); - } - } - } - } - } else { - // Not floating and not in tree (i.e. closed completely) -> insert back to fallback/default leaf! - let pane_id = PaneId::from_raw(pane_id_for_pane(&pane)); - let target_leaf = self - .float_source_leaf - .get(&pane_id) - .copied() - .filter(|idx| self.tree.pane_options(*idx).is_some()) - .unwrap_or_else(|| default_leaf_for_pane(&pane)); - if self.tree.pane_options(target_leaf).is_some() { - self.tree.set_collapsed(target_leaf, false); - let mut tab = Tab::new(pane.static_title(), pane.clone()); - if pane.is_document() { - tab = tab.with_closable(true).with_draggable(false); - } - self.tree.insert_tab_into_leaf(target_leaf, tab); - log::info!( - "grimdock: restored closed tab {:?} back to leaf {}", - pane, - target_leaf - ); - } - } - } - - // Customize grimdock visual style to match theme colors for a seamless look - style.header.bg = colors.title_bar_bg; - style.header.border_color = colors.accent; - style.header.button.bg = colors.title_bar_bg; - style.header.button.hover_bg = colors.bg_hover; - style.header.button.stroke_color = colors.border_low; - style.header.button.icon_color = colors.text_secondary; - style.header.button.hover_icon_color = colors.text_primary; - - style.content.bg = colors.bg_panel; - style.content.border_color = colors.border_high; - - style.tabs.active.bg = colors.bg_panel; - style.tabs.active.text_color = colors.text_primary; - style.tabs.active.accent_color = colors.accent; - - style.tabs.inactive.bg = colors.bg_dark_depth; - style.tabs.inactive.text_color = colors.text_secondary; - style.tabs.inactive.accent_color = colors.accent; - - style.tabs.hovered.bg = colors.bg_hover.gamma_multiply(0.6); - style.tabs.hovered.text_color = colors.text_primary; - style.tabs.hovered.accent_color = colors.accent; - - // Update last_window_width and stable_frames tracking for grimdock. - let avail_w = ui.available_rect_before_wrap().width().max(1.0); - if (app.last_window_width - avail_w).abs() < 0.1 { - app.stable_frames = app.stable_frames.saturating_add(1); - } else { - app.stable_frames = 0; - app.last_window_width = avail_w; - } - - // Dynamically update document tab titles in the tree so the dirty " ●" marker shows up. - for leaf_idx in self.tree.leaf_indices().collect::>() { - if let Some(pane_id) = self.tree.pane_id_at(leaf_idx) { - if let Some(mut pane) = self.tree.pane_mut(pane_id) { - for tab in pane.tabs_mut() { - if let dock::HciePane::Document(_) = &tab.id { - tab.title = tab.id.title(app); - } - } - } - } - } - - // Apply persisted panel widths during startup and window resize. - // (Tools is no longer in the grimdock tree — rendered as a fixed SidePanel.) - if app.stable_frames < 3 { - sync_panel_widths_from_app(&mut self.tree, app); - } - - // Enforce hard floor minimum width of 200.0px for resizable panels. - // New tree topology (Tools removed from grimdock): - // Node 0: H split (left_col2 | center_and_right) - // Node 2: H split (center | right_col2 = ColorBox/History) - // Node 5: H split (center_doc | right_col1 = Layers/Properties) - // Note: pane_fixed_width maps ColorBox|History -> right_col1_w, - // Layers|Properties -> right_col2_w, so the tree's "right_col2" - // (ColorBox/History) feeds app.right_col1_w and vice versa. - let full_w = avail_w; - - // Node 0: left_col2 (Brushes/Filters) vs center+right -> left_col2 min 200px - let min_ratio_0 = 200.0 / full_w; - if let Node::Split { ratio, .. } = self.tree.node_mut(0) { - if *ratio < min_ratio_0 { - *ratio = min_ratio_0.clamp(0.01, 0.95); - } - } - - // Measure actual left_col2_w after clamp - let left_col2_w = if let Node::Split { ratio, .. } = self.tree.node_mut(0) { - (*ratio * full_w).max(200.0) - } else { - 200.0 - }; - - // Node 2: center vs ColorBox/History -> ColorBox/History min 200px - let center_plus_right = (full_w - left_col2_w).max(1.0); - let min_ratio_2 = 200.0 / center_plus_right; - if let Node::Split { ratio, .. } = self.tree.node_mut(2) { - let max_ratio_2 = 1.0 - min_ratio_2; - if *ratio > max_ratio_2 { - *ratio = max_ratio_2.clamp(0.01, 0.95); - } - } - - // Measure ColorBox/History column width after clamp -> app.right_col1_w - let colorbox_history_w = if let Node::Split { ratio, .. } = self.tree.node_mut(2) { - ((1.0 - *ratio) * center_plus_right).max(200.0) - } else { - 200.0 - }; - - // Node 5: center_doc vs Layers/Properties -> Layers/Properties min 200px - let center_w = (center_plus_right - colorbox_history_w).max(1.0); - let min_ratio_5 = 200.0 / center_w; - if let Node::Split { ratio, .. } = self.tree.node_mut(5) { - let max_ratio_5 = 1.0 - min_ratio_5; - if *ratio > max_ratio_5 { - *ratio = max_ratio_5.clamp(0.01, 0.95); - } - } - - if app.stable_frames >= 30 { - // Measure Layers/Properties column width after clamp -> app.right_col2_w - let layers_props_w = if let Node::Split { ratio, .. } = self.tree.node_mut(5) { - ((1.0 - *ratio) * center_w).max(200.0) - } else { - 200.0 - }; - app.left_col2_w = left_col2_w; - app.right_col1_w = colorbox_history_w; - app.right_col2_w = layers_props_w; - } - - // Process queued float requests from previous frame before rendering. - // These are processed BEFORE the tree is rendered so the floated tabs - // are not rendered twice (once in the tree, once as egui::Window). - let mut floated: Vec<(dock::HciePane, egui::Rect)> = Vec::new(); - for pane_id in self.float_queue.drain(..) { - // Save the leaf index before removal so re-dock can restore there. - if let Some(idx) = self.tree.pane_index(pane_id) { - self.float_source_leaf.insert(pane_id, idx); - } - if let Some(removed) = self.tree.remove_pane(pane_id) { - // Collapse the now-empty leaf so it doesn't take visual space - // while still remembering the position for re-docking. - if let Some(idx) = self.tree.pane_index(pane_id) { - self.tree.set_collapsed(idx, true); - } - for pane in removed { - floated.push((pane, default_float_rect(ui.ctx()))); - } - } - } - app.grim_floating_panels.extend(floated); - - // Pre-layout collapse: shrink empty leaves that lost their last tab via - // panels_to_float or panels_to_focus. This must run BEFORE the layout - // pass so the resize-grip position reflects the collapsed state rather - // than leaving a one-frame ghost at the old boundary. Leaves with - // persist_when_empty=true are collapsed too — they stay in the tree for - // re-docking but take only collapsed_pane_thickness (32px) space. - for idx in self.tree.leaf_indices().collect::>() { - if idx == 1 { - continue; - } - if self.tree.leaf_tab_count(idx) == Some(0) && !self.tree.is_collapsed(idx) { - self.tree.set_collapsed(idx, true); - } - } - - let pane_menu_actions = vec![grimdock::PaneMenuAction::new("float", "Float Panel")]; - let provider = |pane: PaneId, tree: &PanelTree| { - if let Some(p) = tree.pane(pane) { - if p.tabs() - .iter() - .any(|t| t.id.is_document() || matches!(t.id, dock::HciePane::Tools)) - { - return vec![]; // Do not allow floating document or tools panes - } - } - pane_menu_actions.clone() - }; - let output = PanelContext::new(ui, &mut self.tree, &style) - .with_pane_menu_provider(&provider) - .show(|ui, tab| { - render_pane_content(ui, app, tab); - }); - - for closed in output.closed_tabs { - match closed { - dock::HciePane::Document(idx) => { - if app - .documents - .get(idx) - .map(|d| d.engine.is_modified()) - .unwrap_or(false) - { - app.show_close_confirm = Some(idx); - } else { - // Fire DocClosed so the document lifecycle handler in - // drain_events removes the doc from app.documents and - // (for the last document) creates a replacement Untitled. - // Do NOT push to panels_to_close here — the DocClosed - // handler already manages grimdock tree mutations. - app.event_bus - .push(crate::event_bus::AppEvent::DocClosed(idx)); - } - } - other => { - // Directly remove from pinned_panels so the statusbar - // shows the minimized icon on the same frame. - app.state.pinned_panels.remove(other.static_title()); - log::info!("grimdock: docked tab {:?} closed via tab button, removed from pinned_panels", other); - } - } - } - - // Post-layout safety net: collapse any leaf that DnD or other tree - // mutations emptied during the show() call. Without this the empty - // leaf stays at full size until the next frame. - for idx in self.tree.leaf_indices().collect::>() { - if idx == 1 { - continue; - } - if self.tree.leaf_tab_count(idx) == Some(0) && !self.tree.is_collapsed(idx) { - self.tree.set_collapsed(idx, true); - } - } - - for action in output.pane_actions { - if action.action_id == "float" { - // Defer the float to the next frame via panels_to_float. - // This is critical: if we remove the tab from the tree and add it - // to grim_floating_panels here, the tab will be rendered twice in - // the same frame — once inside the dock tree (Background layer) - // and once as an egui::Window (Middle layer). egui panics when - // the same widget ID appears in two different layers. - // By deferring, the tab is removed from the tree at the start of - // the next frame (before rendering), so it only appears once. - if let Some(pane) = self.tree.pane(action.pane_id) { - if let Some(tab) = pane.focused_tab() { - // Save the source leaf so re-dock can restore there. - if let Some(leaf_idx) = self.tree.pane_index(action.pane_id) { - self.float_source_leaf.insert(action.pane_id, leaf_idx); - } - app.panels_to_float.push(tab.id.clone()); - log::info!( - "grimdock: deferred float for tab {:?} from pane {:?}", - tab.id, - action.pane_id - ); - } - } - } else { - log::debug!( - "grimdock pane action: {:?} {}", - action.pane_id, - action.action_id - ); - } - } - - // Sync active_doc to the focused tab of the main docked editor pane. - if let Some(pane) = self.tree.pane(PaneId::from_raw(6)) { - if let Some(tab) = pane.focused_tab() { - if let dock::HciePane::Document(idx) = tab.id { - if self.last_active_doc != Some(idx) { - self.last_active_doc = Some(idx); - app.event_bus - .push(crate::event_bus::AppEvent::DocSwitched(idx)); - } - } - } - } - } -} - -/// Update the root horizontal split ratio so the left Tools column matches -/// the current single/two-column toolbox state and the stored column width. - -/// Update the split ratios in the grimdock tree so that the four resizable -/// utility columns match the pixel widths persisted in `HcieApp`. -/// Called once during startup after the persisted widths have been loaded. -/// -/// New tree topology (Tools removed from grimdock, rendered as SidePanel): -/// Node 0: H split (left_col2 | center_and_right) -/// Node 2: H split (center | ColorBox/History) -/// Node 5: H split (center_doc | Layers/Properties) -pub fn sync_panel_widths_from_app(tree: &mut PanelTree, app: &HcieApp) { - const DEFAULT_COL_W: f32 = 200.0; - - let full_width = if app.last_window_width > 1.0 { - app.last_window_width - } else { - 1280.0 - } - .max(1.0); - - let left_col2_w = app.left_col2_w.max(DEFAULT_COL_W); - // pane_fixed_width: ColorBox|History -> right_col1_w, Layers|Properties -> right_col2_w - let colorbox_history_w = app.right_col1_w.max(DEFAULT_COL_W); - let layers_props_w = app.right_col2_w.max(DEFAULT_COL_W); - - // Node 0: left_col2 vs center+right - let left_col2_ratio = (left_col2_w / full_width).clamp(0.12, 0.45); - // Node 2: center vs ColorBox/History - let center_plus_right = (full_width - left_col2_w).max(1.0); - let colorbox_ratio = (colorbox_history_w / center_plus_right).clamp(0.12, 0.55); - // Node 5: center_doc vs Layers/Properties - let center_w = (center_plus_right - colorbox_history_w).max(1.0); - let layers_ratio = (layers_props_w / center_w).clamp(0.15, 0.70); - - if let Node::Split { - ratio: ref mut r, .. - } = tree.node_mut(0) - { - *r = left_col2_ratio; - } - if let Node::Split { - ratio: ref mut r, .. - } = tree.node_mut(2) - { - *r = 1.0 - colorbox_ratio; - } - if let Node::Split { - ratio: ref mut r, .. - } = tree.node_mut(5) - { - *r = 1.0 - layers_ratio; - } -} - -fn sync_tools_width(tree: &mut PanelTree, ui: &egui::Ui, app: &mut HcieApp) -where - T: Clone + PartialEq + 'static, -{ - // Grimdock uses ui.available_rect_before_wrap() internally. Match that so the - // computed ratio corresponds to the actual space the tree will lay out in. - let full_width = ui.available_rect_before_wrap().width().max(1.0); - let desired = if app.state.toolbox_two_column { - crate::app::tools::toolbox::SLOT_W * 2.0 + crate::app::widgets::TOOL_BTN_GAP - } else { - // Keep the exact 36 px single-column width, ignoring stale persisted values. - crate::app::tools::toolbox::SLOT_W - }; - // Clamp to a sane range; the smallest side must still leave ~600 px for canvas. - let ratio = (desired / full_width).clamp(0.02, 0.25); - if let Node::Split { - ratio: ref mut r, .. - } = tree.node_mut(0) - { - *r = ratio; - } -} - -/// Default rectangle for a newly floated panel, centered on the current viewport. -fn default_float_rect(ctx: &egui::Context) -> egui::Rect { - let screen = ctx.content_rect(); - let size = egui::vec2(320.0, 480.0); - egui::Rect::from_min_size( - egui::pos2( - screen.center().x - size.x * 0.5, - screen.center().y - size.y * 0.5, - ), - size, - ) -} - -/// Render panels that were torn out of the dock into movable egui windows. -/// Must be called *after* `CentralPanel` so the windows draw on top. -pub fn render_floating_panels( - ctx: &egui::Context, - app: &mut HcieApp, - host: &mut GrimDockHost, - _dock_area: egui::Rect, -) { - let mut re_dock: Option = None; - let mut remove_indices: Vec = Vec::new(); - - let count = app.grim_floating_panels.len(); - for i in 0..count { - let pane = app.grim_floating_panels[i].0.clone(); - let mut close_window = false; - let rect = app.grim_floating_panels[i].1; - let title = pane.title(app); - // Tracks header drag delta for this frame. When nonzero, we update - // the window position so egui moves the window. When zero (no drag), - // we leave egui's area state untouched so resize works correctly. - let mut header_drag_delta = egui::Vec2::ZERO; - - // Use a stable pane-based ID so the window identity survives - // panel additions/removals that shift positional indices. - let stable_id = pane_id_for_pane(&pane); - let colors = crate::app::ThemeColors::get(app.state.settings.theme); - - // Use default_pos (NOT current_pos) so egui's own area state - // (which is updated by resize/drag) is NOT overridden every frame. - // current_pos sets new_pos which always overrides state.pivot_pos, - // breaking resize: when resizing from the bottom, egui keeps the top - // fixed via set_left_top_pos, but current_pos resets it to the old - // position, causing the top border to shrink/jump. - // default_pos only applies when no area state exists yet (first frame). - // Exception: when the custom header is being dragged, we must manually - // set current_pos because egui's built-in title-bar drag is disabled - // (title_bar(false)) and our custom header drag is not connected to - // egui's area state machinery. - let win_id = egui::Id::new("grim_float_").with(stable_id); - - let mut win = egui::Window::new(&title) - .id(win_id) - .title_bar(false) - .resizable(true) - .default_size(rect.size()) - .frame( - egui::Frame::NONE - .fill(colors.bg_panel) - .stroke(egui::Stroke::new(1.0, colors.border_high)) - .corner_radius(egui::CornerRadius::same(8)) - .inner_margin(egui::Margin::same(0)) - .shadow(egui::Shadow { - offset: [0, 8], - blur: 24, - spread: 0, - color: egui::Color32::from_black_alpha(if colors.is_light { - 20 - } else { - 50 - }), - }), - ); - - // Only force position when the custom header is being dragged. - // Otherwise, let egui manage the position via its own area state - // so that resize (especially from the bottom) works correctly. - if header_drag_delta != egui::Vec2::ZERO { - win = win.current_pos(rect.min + header_drag_delta); - } else { - // default_pos only applies on the first frame (no area state yet). - win = win.default_pos(rect.min); - } - - let response = win.show(ctx, |ui| { - let full_rect = ui.available_rect_before_wrap(); - // Fill the interior but stay 1px inside the frame stroke so the - // 1px border remains visible around the panel content. - let interior_rect = full_rect.shrink(1.0); - ui.painter() - .rect_filled(interior_rect, egui::CornerRadius::same(7), colors.bg_panel); - let header_height = 26.0; - let header_rect = egui::Rect::from_min_size( - full_rect.min + egui::vec2(1.0, 1.0), - egui::vec2(full_rect.width() - 2.0, header_height), - ); - - // Reserve header space so the rest of the content starts below it. - ui.allocate_rect(header_rect, egui::Sense::hover()); - - // Header background and bottom border. - let bg = colors.title_bar_bg; - ui.painter().rect_filled( - header_rect, - egui::CornerRadius { - nw: 7, - ne: 7, - sw: 0, - se: 0, - }, - bg, - ); - // Accent bottom border like Qt's 2px accent underline - ui.painter().rect_filled( - egui::Rect::from_min_max( - egui::pos2(header_rect.min.x, header_rect.max.y - 2.0), - egui::pos2(header_rect.max.x, header_rect.max.y), - ), - 0.0, - colors.accent, - ); - - // Drag anywhere on the header to move the window. - let drag_response = ui.interact( - header_rect, - egui::Id::new("grim_float_drag").with(stable_id), - egui::Sense::drag(), - ); - header_drag_delta = drag_response.drag_delta(); - - // Title + dock + close buttons. - let inset = header_rect.shrink(4.0); - ui.allocate_ui_at_rect(inset, |ui| { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(&title) - .strong() - .color(ui.visuals().strong_text_color()), - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let close_resp = ui.add( - egui::Button::new("✕") - .small() - .fill(egui::Color32::TRANSPARENT), - ); - if close_resp.clicked() { - close_window = true; - } - close_resp.on_hover_text("Close panel"); - let pin_size = egui::vec2(14.0, 14.0); - let (pin_rect, pin_resp) = - ui.allocate_exact_size(pin_size, egui::Sense::click()); - let pin_hovered = pin_resp.hovered(); - if pin_hovered { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - let pin_bg = if pin_hovered { - colors.accent.gamma_multiply(0.12) - } else { - egui::Color32::TRANSPARENT - }; - if pin_bg != egui::Color32::TRANSPARENT { - ui.painter().circle_filled(pin_rect.center(), 6.0, pin_bg); - } - let pin_col = if pin_hovered { - colors.accent - } else { - colors.text_primary.gamma_multiply(0.7) - }; - let scale = 0.75; - // Since it is currently floating, it is "unpinned" (pinned = false) - dock::draw_premium_pin( - ui.painter(), - pin_rect.center(), - pin_col, - false, - scale, - ); - if pin_resp.on_hover_text("Dock Panel").clicked() { - re_dock = Some(pane.clone()); - } - }); - }); - }); - - render_pane_content(ui, app, &pane); - }); - - if close_window { - remove_indices.push(i); - // Directly remove from pinned_panels so the statusbar shows - // the minimized icon on the same frame, without waiting for - // a deferred panels_to_close queue drain on the next frame. - if !pane.is_document() { - app.state.pinned_panels.remove(pane.static_title()); - log::info!( - "grimdock: floating panel {:?} closed, removed from pinned_panels", - pane - ); - } - } else if let Some(inner) = response { - // egui manages the window position/size via its own area state. - // We just sync our stored rect so re-docking and close still work. - // Do NOT add drag_delta here — egui already applied it internally. - let new_rect = inner.response.rect; - app.grim_floating_panels[i].1 = new_rect; - - // If this is a floating document pane, check if it has focus or hover. - if let dock::HciePane::Document(idx) = pane { - let hovered = ctx.input(|i| { - i.pointer - .hover_pos() - .map_or(false, |p| new_rect.contains(p)) - }); - if inner.response.has_focus() || hovered { - if host.last_active_doc != Some(idx) { - host.last_active_doc = Some(idx); - app.event_bus - .push(crate::event_bus::AppEvent::DocSwitched(idx)); - } - } - } - } - } - - for i in remove_indices.into_iter().rev() { - app.grim_floating_panels.remove(i); - } - - // Re-dock the first panel that requested it. - if let Some(pane) = re_dock { - if let Some(pos) = app - .grim_floating_panels - .iter() - .position(|(p, _)| p == &pane) - { - app.grim_floating_panels.remove(pos); - } - // Return the tab to the leaf it came from, if that leaf still exists. - // Otherwise fall back to the pane containing the same tab id, then to - // the default leaf for that panel type. Every candidate must be - // validated as an actual Leaf before inserting. - let pane_id = PaneId::from_raw(pane_id_for_pane(&pane)); - let target_leaf = host - .float_source_leaf - .get(&pane_id) - .copied() - .filter(|idx| host.tree.pane_options(*idx).is_some()) - .or_else(|| host.tree.find_leaf_containing(&pane)) - .or_else(|| { - let fallback = default_leaf_for_pane(&pane); - if host.tree.pane_options(fallback).is_some() { - Some(fallback) - } else { - None - } - }); - if let Some(idx) = target_leaf { - // Ensure the target leaf is not collapsed so the re-docked tab is visible. - host.tree.set_collapsed(idx, false); - let mut tab = Tab::new(pane.static_title(), pane.clone()); - if pane.is_document() { - tab = tab.with_closable(true).with_draggable(false); - } - host.tree.insert_tab_into_leaf(idx, tab); - } else { - log::warn!("re-dock failed: no leaf node available for {:?}", pane); - } - } -} - -/// Return the hard-coded default leaf index for a panel type. This is used when -/// a panel is re-docked but its original leaf no longer exists. -pub fn default_leaf_for_pane(pane: &dock::HciePane) -> usize { - match pane { - dock::HciePane::Brushes => 3, - dock::HciePane::Filters => 4, - dock::HciePane::Layers => 25, - dock::HciePane::Properties => 26, - dock::HciePane::ColorBox => 13, - dock::HciePane::History | dock::HciePane::AiAssistant => 14, - dock::HciePane::Document(_) => 11, - _ => 3, - } -} - -/// Return the stable pane identifier for an `HciePane` variant. Documents use -/// a fixed id because the grimdock tree only ever holds one document leaf. -pub fn pane_id_for_pane(pane: &dock::HciePane) -> u64 { - match pane { - dock::HciePane::Brushes => 2, - dock::HciePane::Filters => 3, - dock::HciePane::Layers => 4, - dock::HciePane::Properties => 5, - dock::HciePane::Document(_) => 6, - dock::HciePane::ColorBox => 7, - dock::HciePane::History => 8, - dock::HciePane::AiAssistant => 9, - _ => 0, - } -} - -/// Render the contents for one `HciePane` tab using the existing helpers. -fn render_pane_content(ui: &mut egui::Ui, app: &mut HcieApp, tab: &dock::HciePane) { - if let Some((target_pane, _)) = app.panel_screenshot_request.as_ref() { - if target_pane == tab { - app.panel_screenshot_rect = Some(ui.max_rect()); - } - } - - let active_doc_idx = app.active_doc; - if app.documents.is_empty() - && !matches!(tab, dock::HciePane::Tools | dock::HciePane::EmptyCenter) - { - ui.centered_and_justified(|ui| { - ui.label( - egui::RichText::new("No active document") - .size(12.0) - .color(egui::Color32::from_rgb(0x6B, 0x6B, 0x6B)), - ); - }); - return; - } - match tab { - dock::HciePane::EmptyCenter => { - ui.centered_and_justified(|ui| { - ui.vertical_centered(|ui| { - ui.label( - egui::RichText::new("Welcome to HCIE") - .size(32.0) - .color(ui.visuals().strong_text_color()) - .strong(), - ); - ui.add_space(8.0); - ui.label( - egui::RichText::new("Open or create a new document to start painting") - .size(18.0) - .color(ui.visuals().weak_text_color()), - ); - }); - }); - } - dock::HciePane::Tools => { - crate::app::shell::gui_layout::track_panel(ui, "panel.tools", |ui| { - crate::app::tools::toolbox::show_tool_panel_ui(app, ui); - }); - } - dock::HciePane::Filters => { - crate::app::shell::gui_layout::track_panel(ui, "panel.filters", |ui| { - crate::app::panels::show_filters_ui(app, ui); - }); - } - dock::HciePane::Brushes => { - crate::app::shell::gui_layout::track_panel(ui, "panel.brushes", |ui| { - crate::app::panels::show_brushes_ui(app, ui); - }); - } - dock::HciePane::AiAssistant => { - crate::app::shell::gui_layout::track_panel(ui, "panel.ai", |ui| { - ui.horizontal(|ui| { - if ui - .selectable_label(app.state.ai_panel_tab == 0, "ComfyUI") - .clicked() - { - app.state.ai_panel_tab = 0; - } - if ui - .selectable_label(app.state.ai_panel_tab == 1, "AI Chat") - .clicked() - { - app.state.ai_panel_tab = 1; - } - }); - ui.separator(); - match app.state.ai_panel_tab { - 0 => { - if !app.documents.is_empty() { - crate::app::panels::show_comfy_ai(app, ui); - } - } - _ => crate::app::panels::show_ai_chat(app, ui), - } - }); - } - dock::HciePane::ColorBox => { - crate::app::shell::gui_layout::track_panel(ui, "panel.color", |ui| { - if !app.documents.is_empty() { - if let Some(doc) = app.documents.get_mut(active_doc_idx) { - crate::app::panels::show_color_palette(doc, &mut app.state, ui); - } - } - }); - } - dock::HciePane::Layers => { - crate::app::shell::gui_layout::track_panel(ui, "panel.layers", |ui| { - if !app.documents.is_empty() { - if let Some(doc) = app.documents.get_mut(active_doc_idx) { - crate::app::panels::show_layers( - doc, - &mut app.state, - &mut app.event_bus, - ui, - ); - crate::app::tools::geometry_panel::show_geometry( - doc, - &mut app.state, - &mut app.event_bus, - ui, - ); - } - } - }); - } - dock::HciePane::History => { - crate::app::shell::gui_layout::track_panel(ui, "panel.history", |ui| { - if !app.documents.is_empty() { - if let Some(doc) = app.documents.get_mut(active_doc_idx) { - crate::app::panels::show_history(doc, ui); - } - } - }); - } - dock::HciePane::Properties => { - crate::app::shell::gui_layout::track_panel(ui, "panel.properties", |ui| { - if !app.documents.is_empty() { - if let Some(doc) = app.documents.get_mut(active_doc_idx) { - crate::app::panels::show_properties( - doc, - &mut app.state, - &mut app.event_bus, - ui, - ); - } - } - }); - } - dock::HciePane::Plugins => { - crate::app::shell::gui_layout::track_panel(ui, "panel.plugins", |ui| { - crate::app::panels::show_plugins_ui(app, ui); - }); - } - dock::HciePane::LayerStyles => { - crate::app::shell::gui_layout::track_panel(ui, "panel.layer_styles", |ui| { - if !app.documents.is_empty() { - if let Some(doc) = app.documents.get_mut(active_doc_idx) { - crate::app::tools::layer_styles_panel::show_layer_styles_panel( - doc, - &mut app.state, - ui, - ); - } - } - }); - } - dock::HciePane::LayerDetails => { - crate::app::shell::gui_layout::track_panel(ui, "panel.layer_details", |ui| { - if !app.documents.is_empty() { - if let Some(doc) = app.documents.get_mut(active_doc_idx) { - crate::app::tools::layer_details_panel::show_layer_details_panel( - doc, - &mut app.state, - ui, - ); - } - } - }); - } - dock::HciePane::Document(i) => { - if ui.rect_contains_pointer(ui.max_rect()) || ui.memory(|m| m.has_focus(ui.id())) { - if app.active_doc != *i { - app.event_bus - .push(crate::event_bus::AppEvent::DocSwitched(*i)); - } - } - if let Some(doc) = app.documents.get_mut(*i) { - let state = &mut app.state; - let event_bus = &mut app.event_bus; - ui.add(crate::canvas::CanvasWidget::new( - doc, - state, - event_bus, - &mut app.transform_texture, - *i, - app.active_doc, - )); - } - } - } -} diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/mod.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/mod.rs index 56bbd55..4bd5320 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/mod.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/mod.rs @@ -1,3 +1,2 @@ pub use egui_dock::*; pub mod egui_dock; -pub mod grim_dock; diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs index 9bb7602..632da65 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs @@ -1082,8 +1082,6 @@ pub struct HcieApp { /// Tracks which utility panels are currently floating, mapped to their /// window SurfaceIndex so they can be re-docked later. pub floating_panels: std::collections::HashMap, - /// grimdock-only: panels that have been torn off into free-floating egui windows. - pub grim_floating_panels: Vec<(dock::HciePane, egui::Rect)>, pub auto_hide_toggle: Option, pub internal_clipboard: Option, /// Raw paste data stored when paste is deferred due to canvas size mismatch. @@ -1112,12 +1110,7 @@ pub struct HcieApp { /// The canonical document node from the default dock layout. /// Used to keep documents in the center area and prevent swapping with utility panels. pub canonical_doc_node: Option, - /// When true, the experimental `grimdock` backend is used instead of - /// `egui_dock`. This is a runtime toggle for side-by-side comparison. - pub use_grimdock: bool, - /// Host state for the grimdock backend when enabled. - pub grim_dock_host: Option, - /// Auto-incrementing counter for paste layer names. + pub paste_counter: u64, /// Hash of last clipboard image to detect new clipboard content. pub clipboard_hash: u64, @@ -1211,19 +1204,20 @@ fn default_dock_state() -> egui_dock::DockState { "Küçültmek için Shift tuşuna basın veya sağ tıklayın.".to_string(); let tree = ds.main_surface_mut(); + // Default split fractions are chosen for a 1280px-wide window and are + // immediately overridden by the layout solver using stored column widths. + // They only determine the tree structure, not final pixel sizes. let [remaining, left_col2] = tree.split_left( egui_dock::NodeIndex::root(), - 0.1608, + 0.17, vec![HciePane::Brushes], ); let [_left_brushes, _left_filters] = tree.split_below(left_col2, 0.5, vec![HciePane::Filters]); - let [center, right_col2] = tree.split_right(remaining, 0.82, vec![HciePane::Layers]); + let [center, right_col2] = tree.split_right(remaining, 0.83, vec![HciePane::Layers]); let [_right_layers, _right_props] = tree.split_below(right_col2, 0.5, vec![HciePane::Properties]); - // Default split keeps ColorBox around 200px on a 1280px window. The layout - // solver will override this with the stored right_col1_w on startup. let [_center, right_col1] = tree.split_right(center, 0.80, vec![HciePane::ColorBox]); let [_right_color, _right_history] = tree.split_below( right_col1, @@ -1363,11 +1357,12 @@ impl HcieApp { if let Some(json) = storage.get_string("hcie_panel_widths") { if let Ok(tuple) = serde_json::from_str::<(f32, f32, f32, f32, f32)>(&json) { last_window_width = tuple.0; - // Tools column is ALWAYS exactly 36px — never allow resize + // Tools column is ALWAYS exactly 36px — never allow resize. left_col1_w = 36.0; - left_col2_w = tuple.2.max(200.0); - right_col1_w = tuple.3.max(200.0); - right_col2_w = tuple.4.max(200.0); + // Cap utility columns so they cannot take over the canvas. + left_col2_w = tuple.2.clamp(150.0, 220.0); + right_col1_w = tuple.3.clamp(150.0, 220.0); + right_col2_w = tuple.4.clamp(150.0, 220.0); log::trace!( "Loaded persisted panel widths: last_window_width={}, left_col1_w={}, left_col2_w={}, right_col1_w={}, right_col2_w={}", last_window_width, left_col1_w, left_col2_w, right_col1_w, right_col2_w @@ -1376,24 +1371,12 @@ impl HcieApp { } } - let mut grim_dock_host = Some(dock::grim_dock::GrimDockHost::new()); let mut dock_state = None; // When a panel screenshot is requested, ignore the persisted dock layout // so the panel always starts from the known default 200px Color Palette // width and the captured rectangle is deterministic. if panel_screenshot_request.is_none() { if let Some(storage) = cc.storage { - if let Some(json) = storage.get_string("hcie_grimdock_state") { - if let Ok(persisted) = - serde_json::from_str::>(&json) - { - if let Ok(tree) = grimdock::PanelTree::from_persisted(persisted) { - if let Some(host) = &mut grim_dock_host { - host.tree = tree; - } - } - } - } if let Some(json) = storage.get_string("hcie_dock_state") { if let Ok(mut ds) = serde_json::from_str::>(&json) @@ -1547,7 +1530,6 @@ impl HcieApp { panels_to_collapse: Vec::new(), panels_to_float: Vec::new(), floating_panels: std::collections::HashMap::new(), - grim_floating_panels: Vec::new(), auto_hide_toggle: None, internal_clipboard: None, status_message: None, @@ -1566,8 +1548,6 @@ impl HcieApp { dock_profile_rename_target: None, dock_profile_rename_buf: String::new(), canonical_doc_node, - use_grimdock: true, - grim_dock_host, paste_counter: 1, clipboard_hash: 0, clipboard_poll_counter: 0, @@ -1728,13 +1708,8 @@ impl HcieApp { /// **Arguments:** /// * `doc_pane`: The `dock::HciePane` document pane enum to push. pub fn push_document_to_center(&mut self, doc_pane: dock::HciePane) { - if self.use_grimdock { - self.push_document_to_grimdock_center(doc_pane); - return; - } - let mut target_node = None; - + // 1. Prefer a leaf that already has document tabs for (path, leaf) in self.dock_state.iter_leaves() { if leaf.tabs.iter().any(|t| t.is_document()) { @@ -1742,7 +1717,7 @@ impl HcieApp { break; } } - + if let Some(node_path) = target_node { self.dock_state.set_focused_node_and_surface(node_path); self.dock_state.push_to_focused_leaf(doc_pane.clone()); @@ -1751,65 +1726,24 @@ impl HcieApp { // enforce_constraints will relocate it to the center if needed. self.dock_state.push_to_focused_leaf(doc_pane.clone()); } - + // Set the newly pushed tab as active to make it immediately visible if let Some(tab_path) = self.dock_state.find_tab(&doc_pane) { let _ = self.dock_state.set_active_tab(tab_path); } - + // Ensure dock layout settles and canvas texture uploads before the // user sees the new document. Without this, the canvas appears blank // on the first frame after opening a file. self.dock_settle_repaints = self.dock_settle_repaints.max(5); } - /// Push a newly opened/created document tab into the grimdock center document leaf. - fn push_document_to_grimdock_center(&mut self, doc_pane: dock::HciePane) { - if let Some(host) = self.grim_dock_host.as_mut() { - // Find or create the document leaf. The default layout reserves leaf 11. - let doc_leaf = if host.tree.pane_options(11).is_some() { - 11 - } else { - host.tree - .find_leaf_containing(&dock::HciePane::Document(0)) - .unwrap_or(11) - }; - // Remove any stale document tab with the same index to avoid duplicates. - if let dock::HciePane::Document(_idx) = doc_pane { - // grimdock tabs are compared by their tab data (HciePane). A stale tab - // with the same index matches equality. - if let Some((leaf, tab_pos)) = host.tree.find_tab(&doc_pane) { - if leaf == doc_leaf { - let _ = host.tree.remove_tab_at(leaf, tab_pos); - } - } - } + /// Push a newly opened/created document tab into the center document leaf. + fn push_document_to_center_placeholder() {} + + /// Panic-safe tab removal: validates indices before calling egui_dock::remove_tab. - // Remove EmptyCenter placeholder if present — a real document is arriving. - if let Some((ec_leaf, ec_pos)) = host.tree.find_tab(&dock::HciePane::EmptyCenter) { - let _ = host.tree.remove_tab_at(ec_leaf, ec_pos); - } - - let title = if let dock::HciePane::Document(idx) = doc_pane { - self.documents - .get(idx) - .map(|d| d.name.clone()) - .unwrap_or_else(|| "Untitled".to_string()) - } else { - "Untitled".to_string() - }; - - let mut tab = grimdock::Tab::new(title, doc_pane.clone()); - if doc_pane.is_document() { - tab = tab.with_closable(true).with_draggable(false); - } - host.tree.insert_tab_into_leaf(doc_leaf, tab); - host.tree.set_collapsed(doc_leaf, false); - } - - self.dock_settle_repaints = self.dock_settle_repaints.max(5); - } /// Panic-safe tab removal: validates indices before calling egui_dock::remove_tab. /// Returns `None` and logs a warning if indices are stale. @@ -1986,110 +1920,44 @@ impl HcieApp { continue; } - let doc_count_before = self.documents.len(); self.documents.remove(idx); - if self.use_grimdock { - if let Some(host) = &mut self.grim_dock_host { - if doc_count_before <= 1 { - log::trace!("Last document closed in grimdock. Creating new Untitled document and replacing tab in-place to preserve sidebar layouts"); - let new_idx = self.documents.len(); - self.documents.push(AppDocument::new("Untitled", 800, 600)); - self.documents[new_idx].engine.set_modified(false); - for leaf_idx in host.tree.leaf_indices().collect::>() { - if let Some(pane_id) = host.tree.pane_id_at(leaf_idx) { - if let Some(mut pane) = host.tree.pane_mut(pane_id) { - for tab in pane.tabs_mut() { - if let dock::HciePane::Document(doc_idx) = - &mut tab.id - { - if *doc_idx == idx { - *doc_idx = new_idx; - tab.title = "Untitled".to_string(); - } - } - } - } - } - } - self.active_doc = new_idx; - } else { - log::trace!("Removing document tab from grimdock tree"); - let mut to_remove = None; - for leaf_idx in host.tree.leaf_indices().collect::>() { - if let Some(pane_id) = host.tree.pane_id_at(leaf_idx) { - if let Some(pane) = host.tree.pane(pane_id) { - for (pos, tab) in pane.tabs().iter().enumerate() { - if let dock::HciePane::Document(d) = tab.id { - if d == idx { - to_remove = Some((leaf_idx, pos)); - break; - } - } - } - } - } - } - if let Some((leaf_idx, pos)) = to_remove { - host.tree.remove_tab_at(leaf_idx, pos); - } - } - - // Shift document indices down in grimdock tree. - for leaf_idx in host.tree.leaf_indices().collect::>() { - if let Some(pane_id) = host.tree.pane_id_at(leaf_idx) { - if let Some(mut pane) = host.tree.pane_mut(pane_id) { - for tab in pane.tabs_mut() { - if let dock::HciePane::Document(doc_idx) = &mut tab.id { - if *doc_idx > idx { - *doc_idx -= 1; - } - } - } - } - } - } - // Reset last_active_doc to trigger immediate synchronization. - host.last_active_doc = None; - } - } else { - let doc_tab_count_before = self - .dock_state - .iter_all_tabs() - .filter(|(_, t)| t.is_actual_document()) - .count(); - if doc_tab_count_before <= 1 { - log::trace!("Last document closed. Creating new Untitled document and replacing tab in-place to preserve sidebar layouts"); - let new_idx = self.documents.len(); - self.documents.push(AppDocument::new("Untitled", 800, 600)); - self.documents[new_idx].engine.set_modified(false); - for (_, tab) in self.dock_state.iter_all_tabs_mut() { - if let dock::HciePane::Document(doc_idx) = tab { - if *doc_idx == idx { - *tab = dock::HciePane::Document(new_idx); - } - } - } - self.active_doc = new_idx; - } else { - log::trace!( - "Removing document tab natively from multi-document dock state" - ); - let tabs_to_remove = self.dock_find_tabs( - |t| matches!(t, dock::HciePane::Document(d) if *d == idx), - ); - for tab_pos in tabs_to_remove.into_iter().rev() { - self.dock_safe_remove_tab(tab_pos); - } - } - + let doc_tab_count_before = self + .dock_state + .iter_all_tabs() + .filter(|(_, t)| t.is_actual_document()) + .count(); + if doc_tab_count_before <= 1 { + log::trace!("Last document closed. Creating new Untitled document and replacing tab in-place to preserve sidebar layouts"); + let new_idx = self.documents.len(); + self.documents.push(AppDocument::new("Untitled", 800, 600)); + self.documents[new_idx].engine.set_modified(false); for (_, tab) in self.dock_state.iter_all_tabs_mut() { if let dock::HciePane::Document(doc_idx) = tab { - if *doc_idx > idx { - *doc_idx -= 1; + if *doc_idx == idx { + *tab = dock::HciePane::Document(new_idx); } } } + self.active_doc = new_idx; + } else { + log::trace!( + "Removing document tab from egui_dock multi-document state" + ); + let tabs_to_remove = self.dock_find_tabs( + |t| matches!(t, dock::HciePane::Document(d) if *d == idx), + ); + for tab_pos in tabs_to_remove.into_iter().rev() { + self.dock_safe_remove_tab(tab_pos); + } + } + + for (_, tab) in self.dock_state.iter_all_tabs_mut() { + if let dock::HciePane::Document(doc_idx) = tab { + if *doc_idx > idx { + *doc_idx -= 1; + } + } } if self.active_doc >= self.documents.len() && !self.documents.is_empty() { @@ -4014,20 +3882,10 @@ impl HcieApp { egui::UserData::default(), )); } - if i.key_pressed(egui::Key::F11) && self.use_grimdock { - if let Some(host) = self.grim_dock_host.as_mut() { - let target = dock::HciePane::ColorBox; - if let Some((leaf_idx, tab_pos)) = host.tree.find_tab(&target) { - if let Some(tab) = host.tree.remove_tab_at(leaf_idx, tab_pos) { - self.grim_floating_panels.push(( - tab.id, - egui::Rect::from_min_size( - egui::pos2(200.0, 200.0), - egui::vec2(320.0, 480.0), - ), - )); - } - } + if i.key_pressed(egui::Key::F11) { + let target = dock::HciePane::ColorBox; + if self.dock_state.find_tab(&target).is_some() { + self.panels_to_float.push(target); } } if i.key_pressed(egui::Key::Escape) { @@ -4117,14 +3975,6 @@ impl HcieApp { } let _dock_rect = ui.max_rect(); - if self.use_grimdock { - let mut taken = self.grim_dock_host.take(); - if let Some(host) = &mut taken { - host.render(ui, self); - } - self.grim_dock_host = taken; - return; - } // Tema-duyarlı dock token'ları: açık/koyu temada tüm panel // arka planları, tab renkleri ve ayırıcılar doğru rengi alır. @@ -4371,16 +4221,6 @@ impl HcieApp { self.sync_active_doc_from_dock(); }); - // Render grimdock floating panels on top of the central dock area. - if self.use_grimdock { - let dock_rect = ctx.content_rect(); - let mut taken = self.grim_dock_host.take(); - if let Some(host) = &mut taken { - dock::grim_dock::render_floating_panels(ctx, self, host, dock_rect); - } - self.grim_dock_host = taken; - } - if let Some(name) = self.auto_hide_toggle.take() { if self.auto_hide_active.as_deref() == Some(&name) { self.auto_hide_active = None; @@ -5046,49 +4886,23 @@ impl HcieApp { }; let pane = pane.clone(); if self.panel_screenshot_frame == 0 { - // Pin the Color Palette so the layout solver gives it the intended - // 200px width instead of treating it as an auto-hide overlay. - if matches!(pane, dock::HciePane::ColorBox) { - self.state.pinned_panels.insert("Color Palette".to_string()); - self.right_col1_w = 200.0; - self.last_window_width = 0.0; - // Rebuild the dock from the default so the persisted layout - // cannot keep a different panel width. - self.dock_state = default_dock_state(); + // Rebuild the dock from the default so the persisted layout cannot + // keep a different panel width, and pin the target panel so the + // layout solver gives it the intended 200px width. + let pinned_name = pane.static_title(); + self.state.pinned_panels.insert(pinned_name.to_string()); + match pane { + dock::HciePane::ColorBox => self.right_col1_w = 200.0, + dock::HciePane::Brushes | dock::HciePane::Filters => self.left_col2_w = 200.0, + dock::HciePane::Layers | dock::HciePane::Properties => self.right_col2_w = 200.0, + _ => {} } - // Ensure the panel exists in the dock. For grimdock, add tabs into - // the correct default leaf so that the target panel is actually - // rendered (some panels, e.g. Plugins, are not in the default tree). - if self.use_grimdock { - let host = self.grim_dock_host.as_mut().expect("grimdock host"); - let leaf_idx = dock::grim_dock::default_leaf_for_pane(&pane); - if host.tree.find_leaf_containing(&pane).is_none() { - log::info!( - "Adding missing pane {:?} to grimdock leaf {} for screenshot", - pane, - leaf_idx - ); - host.tree.insert_tab_into_leaf( - leaf_idx, - dock::grim_dock::GrimDockHost::new_dummy_tab(pane.clone()), - ); - // Make sure the leaf is not collapsed so the new tab shows. - host.tree.set_collapsed(leaf_idx, false); - } else { - // The pane exists but may not be the active tab in its leaf; - // switch focus so the requested panel is rendered. - let tab_pos = host.tree.find_tab(&pane).map(|(_, pos)| pos); - let pane_id = dock::grim_dock::pane_id_for_pane(&pane); - if let Some(mut pane_ref) = - host.tree.pane_mut(grimdock::PaneId::from_raw(pane_id)) - { - if let Some(pos) = tab_pos { - pane_ref.set_focused_index(pos); - } - pane_ref.set_collapsed(false); - } - } - } else if self.dock_state.find_tab(&pane).is_none() { + self.last_window_width = 0.0; + self.dock_state = default_dock_state(); + // Ensure the panel exists in the dock. For panels not in the + // default tree (e.g. Plugins), add them to the currently focused + // leaf so the requested panel is actually rendered. + if self.dock_state.find_tab(&pane).is_none() { self.dock_state.push_to_focused_leaf(pane.clone()); } } @@ -6132,12 +5946,6 @@ impl eframe::App for HcieApp { if let Ok(json) = serde_json::to_string(&self.dock_state) { storage.set_string("hcie_dock_state", json); } - if let Some(grim) = &mut self.grim_dock_host { - let persisted = grim.tree.to_persisted(); - if let Ok(json) = serde_json::to_string(&persisted) { - storage.set_string("hcie_grimdock_state", json); - } - } let colors_data = ( &self.state.primary_color, &self.state.secondary_color, diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/shell/layout_solver.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/shell/layout_solver.rs index 3854811..5926f9d 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/shell/layout_solver.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/shell/layout_solver.rs @@ -247,10 +247,8 @@ fn enforce_pixel_widths( /// Minimum pixel width any utility/document column may shrink to. Applied as /// a hard floor on every horizontal split so splitter drags can never collapse a /// panel below a usable size. Tools is exempt (it has its own fixed width). -/// The Layers column is allowed to go down to 160px because its content is -/// intentionally optimized for a 200px column; other panels keep 150px. -const MIN_PANE_WIDTH: f32 = 200.0; -const MIN_LAYERS_WIDTH: f32 = 200.0; +const MIN_PANE_WIDTH: f32 = 150.0; +const MIN_LAYERS_WIDTH: f32 = 150.0; /// Returns true if the subtree is a collapsed leaf (or contains one), in which /// case it should be exempt from the minimum-width guard. diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/shell/menus.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/shell/menus.rs index 4ec8da7..21668ea 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/shell/menus.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/shell/menus.rs @@ -795,12 +795,10 @@ fn ui_view_menu(app: &mut HcieApp, ui: &mut egui::Ui) { } else { app.state.pinned_panels.insert(label.to_string()); if pane != HciePane::Tools { - if app.use_grimdock { - app.panels_to_focus.push(pane.clone()); - } if app.dock_state.find_tab(&pane).is_none() { - app.dock_state.push_to_focused_leaf(pane); + app.dock_state.push_to_focused_leaf(pane.clone()); } + app.panels_to_focus.push(pane); } } ui.close(); diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/main.rs b/hcie-egui-app/crates/hcie-gui-egui/src/main.rs index 4a0351f..4a2327d 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/main.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/main.rs @@ -18,7 +18,6 @@ fn main() -> eframe::Result<()> { let mut screenshot_panel: Option = None; let mut screenshot_out: Option = None; let mut toolbox_two_column = false; - let mut use_grimdock = true; // grimdock is the new default { let mut i = 1; while i < args.len() { @@ -36,10 +35,6 @@ fn main() -> eframe::Result<()> { toolbox_two_column = true; i += 1; } - "--egui-dock" => { - use_grimdock = false; - i += 1; - } other => { load_path = Some(std::path::PathBuf::from(other)); i += 1; @@ -86,10 +81,6 @@ fn main() -> eframe::Result<()> { let mut app = app::HcieApp::new(cc, tablet_state, screenshot_request); app.pending_load = load_path; app.state.toolbox_two_column = toolbox_two_column; - app.use_grimdock = use_grimdock; - if use_grimdock { - app.grim_dock_host = Some(app::dock::grim_dock::GrimDockHost::new()); - } Ok(Box::new(app)) }), ) diff --git a/hcie-egui-app/crates/hcie-gui-egui/tests/dock_behavior.rs b/hcie-egui-app/crates/hcie-gui-egui/tests/dock_behavior.rs deleted file mode 100644 index e085722..0000000 --- a/hcie-egui-app/crates/hcie-gui-egui/tests/dock_behavior.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! Dock / float behavior tests (pure-Rust, no `egui::Context`). -//! -//! These tests exercise the regression-prone state-machine logic of -//! `DockController` and `dock_engine::PanelTree`: deferred-queue frame timing, -//! float source-leaf restore, EmptyCenter insertion, document/tools cannot -//! float, persistence round-trip, and document index shifting. They run in CI -//! without a GPU. Render / floating-window visual behavior is covered by the -//! `--screenshot-panel` baseline pipeline instead. -//! -//! See AGENTS.md "Dock / Float Regression Protection" for the protected -//! invariants each test guards. - -use hcie_gui_egui::app::dock::HciePane; -use hcie_gui_egui::app::dock_controller::DockController; -use hcie_gui_egui::app::dock_engine::{default_tree, PaneRole, PanelTree}; - -fn rect() -> [f32; 4] { - [100.0, 100.0, 320.0, 480.0] -} - -// ── Tree / default layout ────────────────────────────────────────────────── - -#[test] -fn tree_default_has_document_in_center_leaf() { - let tree = default_tree(); - let editor = tree - .find_pane_with_role(PaneRole::Editor) - .expect("default tree has an editor leaf"); - let (tabs, _, _) = tree.leaf(editor).expect("editor leaf exists"); - assert!( - tabs.iter().any(|t| matches!(t.id, HciePane::Document(0))), - "default editor leaf must hold Document(0)" - ); -} - -#[test] -fn tree_default_has_expected_panes() { - let tree = default_tree(); - for pane in [ - HciePane::Brushes, - HciePane::Filters, - HciePane::ColorBox, - HciePane::History, - HciePane::AiAssistant, - HciePane::Layers, - HciePane::Properties, - HciePane::Document(0), - ] { - assert!(tree.has_tab(&pane), "default tree missing {:?}", pane); - } -} - -#[test] -fn tree_persist_roundtrip_topology_identical() { - let tree = default_tree(); - let persisted = tree.persist(); - let restored = PanelTree::from_persisted(persisted).expect("round-trip"); - assert_eq!( - restored.root, tree.root, - "topology must be identical after round-trip" - ); - assert_eq!(restored.next_pane_id, tree.next_pane_id); -} - -#[test] -fn tree_from_persisted_rejects_wrong_version() { - let mut persisted = default_tree().persist(); - persisted.version = 999; - assert!(PanelTree::from_persisted(persisted).is_err()); -} - -// ── Controller: active tab / first-frame invariant ───────────────────────── - -#[test] -fn new_controller_active_tab_is_document_zero() { - let ctrl = DockController::new(); - assert_eq!(*ctrl.active_tab(), HciePane::Document(0)); - assert_eq!(ctrl.last_active_doc(), Some(0)); -} - -// ── Float / dock ─────────────────────────────────────────────────────────── - -#[test] -fn float_then_dock_restores_source_leaf() { - let mut ctrl = DockController::new(); - let pane = HciePane::Layers; - assert!(ctrl.is_pinned(&pane)); - assert!(!ctrl.is_floating(&pane)); - - assert!(ctrl.float_pane(&pane, rect())); - assert!(ctrl.is_floating(&pane)); - assert!(!ctrl.is_pinned(&pane)); - assert!(!ctrl.has_tab(&pane)); - - assert!(ctrl.dock_pane(&pane)); - assert!(!ctrl.is_floating(&pane)); - assert!(ctrl.is_pinned(&pane)); - assert!(ctrl.has_tab(&pane)); -} - -#[test] -fn float_twice_no_duplicate_floating_entry() { - let mut ctrl = DockController::new(); - let pane = HciePane::Layers; - assert!(ctrl.float_pane(&pane, rect())); - assert!(!ctrl.float_pane(&pane, rect())); - assert_eq!(ctrl.floating_count(), 1); -} - -#[test] -fn document_and_tools_cannot_float() { - let mut ctrl = DockController::new(); - assert!(!ctrl.float_pane(&HciePane::Document(0), rect())); - assert!(!ctrl.float_pane(&HciePane::Tools, rect())); - assert_eq!(ctrl.floating_count(), 0); -} - -#[test] -fn queue_float_rejects_document_and_tools() { - let mut ctrl = DockController::new(); - ctrl.queue_float(HciePane::Document(0)); - ctrl.queue_float(HciePane::Tools); - // Queue lengths: [close, float, focus, collapse] - let [_, float, _, _] = ctrl.queue_lengths(); - assert_eq!(float, 0, "Document/Tools must not be enqueued for float"); -} - -// ── Close ────────────────────────────────────────────────────────────────── - -#[test] -fn close_removes_from_pinned_and_floating() { - let mut ctrl = DockController::new(); - let pane = HciePane::Layers; - assert!(ctrl.float_pane(&pane, rect())); - assert!(ctrl.close_tab(&pane)); - assert!(!ctrl.is_floating(&pane)); - assert!(!ctrl.is_pinned(&pane)); - assert!(!ctrl.has_tab(&pane)); -} - -#[test] -fn close_docked_pane_removes_from_tree_and_pinned() { - let mut ctrl = DockController::new(); - let pane = HciePane::Layers; - assert!(ctrl.has_tab(&pane)); - assert!(ctrl.close_tab(&pane)); - assert!(!ctrl.has_tab(&pane)); - assert!(!ctrl.is_pinned(&pane)); -} - -// ── Document push / remove / shift ──────────────────────────────────────── - -#[test] -fn push_document_sets_active_tab_to_document() { - let mut ctrl = DockController::new(); - ctrl.push_document(1); - assert_eq!(*ctrl.active_tab(), HciePane::Document(1)); - assert_eq!(ctrl.last_active_doc(), Some(1)); - assert!(ctrl.has_tab(&HciePane::Document(1))); -} - -#[test] -fn push_document_removes_empty_center() { - let mut ctrl = DockController::new(); - // Simulate all documents closed -> EmptyCenter inserted. - ctrl.remove_document(0); - ctrl.drain_queues(); - assert!(ctrl.empty_center_present()); - ctrl.push_document(0); - assert!( - !ctrl.empty_center_present(), - "EmptyCenter must be removed once a document exists" - ); -} - -#[test] -fn remove_document_shifts_indices_in_tabs() { - let mut ctrl = DockController::new(); - ctrl.push_document(1); - ctrl.push_document(2); - assert!(ctrl.has_tab(&HciePane::Document(2))); - - // Remove doc index 1 -> doc 2 should become doc 1. - ctrl.remove_document(1); - assert!(ctrl.has_tab(&HciePane::Document(1))); - assert!(!ctrl.has_tab(&HciePane::Document(2))); -} - -#[test] -fn remove_last_document_updates_active_to_empty_center() { - let mut ctrl = DockController::new(); - ctrl.remove_document(0); - ctrl.drain_queues(); - assert_eq!(*ctrl.active_tab(), HciePane::EmptyCenter); - assert_eq!(ctrl.last_active_doc(), None); -} - -// ── EmptyCenter placeholder ──────────────────────────────────────────────── - -#[test] -fn emptycenter_inserted_when_no_documents() { - let mut ctrl = DockController::new(); - assert!(!ctrl.empty_center_present()); - ctrl.remove_document(0); - let outcome = ctrl.drain_queues(); - assert!(outcome.empty_center_inserted); - assert!(ctrl.empty_center_present()); -} - -// ── drain_queues frame-timing invariant ─────────────────────────────────── - -#[test] -fn queue_close_float_focus_drained_in_render_order() { - let mut ctrl = DockController::new(); - ctrl.queue_close(HciePane::Layers); - ctrl.queue_float(HciePane::Properties); - ctrl.queue_focus(HciePane::ColorBox); - ctrl.queue_collapse(HciePane::History); - - let outcome = ctrl.drain_queues(); - assert_eq!(outcome.closed, 1); - assert_eq!(outcome.floated, 1); - // Properties was floated then ColorBox focus-returned to dock. - // Note: queue_focus docks a currently-floating panel; ColorBox isn't - // floating, so focus is a no-op here (returns false). - assert_eq!(outcome.focused, 0); - assert_eq!(outcome.collapsed, 1); - - // All queues must be empty after drain. - assert_eq!(ctrl.queue_lengths(), [0, 0, 0, 0]); -} - -#[test] -fn drain_queues_is_idempotent_when_empty() { - let mut ctrl = DockController::new(); - let outcome = ctrl.drain_queues(); - assert_eq!(outcome, Default::default()); -} - -#[test] -fn queued_float_processed_before_focus_so_focus_can_dock() { - // Float then focus-dock the same panel in one drain: float first makes it - // floating, then focus docks it back. Net: not floating. - let mut ctrl = DockController::new(); - let pane = HciePane::Layers; - ctrl.queue_float(pane.clone()); - ctrl.queue_focus(pane.clone()); - let outcome = ctrl.drain_queues(); - assert_eq!(outcome.floated, 1); - assert_eq!(outcome.focused, 1); - assert!(!ctrl.is_floating(&pane)); - assert!(ctrl.has_tab(&pane)); -} - -// ── Persistence ─────────────────────────────────────────────────────────── - -#[test] -fn persistence_roundtrip_preserves_state() { - let mut ctrl = DockController::new(); - ctrl.push_document(1); - ctrl.float_pane(&HciePane::Layers, rect()); - ctrl.close_tab(&HciePane::Filters); - - let json = ctrl.persist(); - let mut restored = DockController::new(); - assert!(restored.restore(&json)); - assert!(restored.has_tab(&HciePane::Document(1))); - assert!(restored.is_floating(&HciePane::Layers)); - assert!(!restored.has_tab(&HciePane::Filters)); - assert_eq!(*restored.active_tab(), HciePane::Document(1)); -} - -#[test] -fn persistence_invalid_json_falls_back_to_default() { - let mut ctrl = DockController::new(); - ctrl.push_document(1); - assert!(!ctrl.restore("not valid json {")); - // Fallback resets to default. - assert_eq!(*ctrl.active_tab(), HciePane::Document(0)); - assert!(!ctrl.is_floating(&HciePane::Layers)); -} - -#[test] -fn persistence_legacy_version_falls_back_to_default() { - let mut ctrl = DockController::new(); - ctrl.push_document(2); - // Hand-craft a snapshot with a wrong version number. - let bad = serde_json::json!({ - "version": 999, - "tree": default_tree().persist(), - "floating": [], - "pinned": [], - "active_tab": HciePane::Document(0), - "last_active_doc": 0, - }) - .to_string(); - assert!(!ctrl.restore(&bad)); - assert_eq!(*ctrl.active_tab(), HciePane::Document(0)); -} - -// ── ensure_pane_present (screenshot injector) ───────────────────────────── - -#[test] -fn ensure_pane_present_inserts_missing_pane() { - let mut ctrl = DockController::new(); - ctrl.close_tab(&HciePane::Layers); - assert!(!ctrl.has_tab(&HciePane::Layers)); - assert!(ctrl.ensure_pane_present(&HciePane::Layers)); - assert!(ctrl.has_tab(&HciePane::Layers)); -} - -#[test] -fn ensure_pane_present_noop_when_already_present() { - let mut ctrl = DockController::new(); - assert!(!ctrl.ensure_pane_present(&HciePane::Layers)); -} diff --git a/hcie-egui-app/crates/hcie-gui-egui/tests/invariant_guards.rs b/hcie-egui-app/crates/hcie-gui-egui/tests/invariant_guards.rs deleted file mode 100644 index ebcfb06..0000000 --- a/hcie-egui-app/crates/hcie-gui-egui/tests/invariant_guards.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! Invariant guard tests (behavior + source-inspection). -//! -//! These tests protect the AGENTS.md "GUI First-Frame Visibility" and "Dock / -//! Float Regression Protection" invariants. They come in two flavors: -//! -//! * **Behavior tests** exercise the running state machine (no GPU) to prove -//! an invariant holds at runtime. -//! * **Source-inspection tests** embed the relevant source files via -//! `include_str!` and assert that the protected lines/patterns are present. -//! They catch the case where an agent edits the invariant file directly. -//! -//! These are deliberately run on every CI build (no `#[ignore]`). If any fails, -//! a protected mechanism was broken and the change is not ready to commit. - -use hcie_engine_api::Engine; -use hcie_gui_egui::app::dock::HciePane; -use hcie_gui_egui::app::dock_controller::DockController; - -// ── Source files embedded for source-inspection assertions ──────────────── - -const CANVAS_MOD: &str = include_str!("../src/canvas/mod.rs"); -const APP_MOD: &str = include_str!("../src/app/mod.rs"); -const DOCK_CONTROLLER: &str = include_str!("../src/app/dock_controller.rs"); -const DOCK_ENGINE: &str = include_str!("../src/app/dock_engine.rs"); - -// ── Behavior: DrawingFinished does NOT clear dirty flags ─────────────────── -// -// AGENTS.md: "DrawingFinished does NOT clear dirty flags ... Calling -// clear_dirty_flags() inside DrawingFinished races with the next render pass -// and makes newly drawn vector shapes disappear." -// -// We assert the behavior at the engine level: drawing marks composite dirty, -// and simply handling the DrawingFinished event path (which is a no-op for -// dirty flags) leaves the dirty flag set so the next render uploads. - -#[test] -fn drawing_finished_event_does_not_clear_dirty() { - let mut engine = Engine::new(10, 10); - // Drawing a stroke marks the composite dirty. - engine.draw_filled_rect_rgba(0, 0, 4, 4, [255, 0, 0, 255]); - assert!( - engine.is_composite_dirty(), - "after drawing, composite must be dirty so render_composition uploads" - ); - // The DrawingFinished event handler in mod.rs:1955-1962 is a documented - // no-op w.r.t. dirty flags; we simulate that contract here by NOT calling - // clear_dirty_flags and asserting the flag survives. - // (If an agent adds clear_dirty_flags() to the event handler, the source - // inspection test `protected_drawing_finished_noop` below catches it.) - assert!( - engine.is_composite_dirty(), - "DrawingFinished must not clear dirty flags before render_composition runs" - ); -} - -// ── Source-inspection: canvas multi-repaint on new texture ──────────────── -// -// AGENTS.md: "Extra repaint after new texture ... Requests multiple repaints -// after render_composition creates a brand-new texture." -// -// The pattern is: `if created_new_texture { request_repaint(); -// request_repaint_after(...) }`. We assert both calls appear within the -// `created_new_texture` block in canvas/mod.rs. - -#[test] -fn protected_canvas_multi_repaint_on_new_texture() { - // Locate the `created_new_texture` branch and assert both repaint calls - // appear shortly after it. This tolerates formatting changes but catches - // removal of one of the two repaint calls. - let created_idx = CANVAS_MOD - .find("if created_new_texture") - .or_else(|| CANVAS_MOD.find("created_new_texture {")) - .expect("canvas/mod.rs must retain the `created_new_texture` branch"); - let window = &CANVAS_MOD[created_idx..created_idx.saturating_add(800).min(CANVAS_MOD.len())]; - assert!( - window.contains("request_repaint()"), - "canvas/mod.rs: created_new_texture branch must call request_repaint() (first-frame visibility invariant)" - ); - assert!( - window.contains("request_repaint_after"), - "canvas/mod.rs: created_new_texture branch must call request_repaint_after(...) (second/third frame display)" - ); -} - -// ── Source-inspection: DrawingFinished no dirty clear ────────────────────── -// -// AGENTS.md: the `DrawingFinished` arm must NOT call `clear_dirty`. We assert -// that within the DrawingFinished match arm there is no `clear_dirty` call. - -#[test] -fn protected_drawing_finished_noop() { - let arm_idx = APP_MOD - .find("AppEvent::DrawingFinished =>") - .expect("app/mod.rs must retain the DrawingFinished match arm"); - // Take the slice until the next match arm or a reasonable window. - let end = APP_MOD[arm_idx..].find("AppEvent::Undo =>").unwrap_or(400); - let arm = &APP_MOD[arm_idx..arm_idx + end]; - assert!( - !arm.contains("clear_dirty"), - "app/mod.rs: DrawingFinished arm must NOT call clear_dirty_flags (AGENTS.md protected invariant)" - ); -} - -// ── Source-inspection: DockController private fields ─────────────────────── -// -// AGENTS.md: "All DockController fields are private; mutation only through -// methods." We assert the struct fields are declared without `pub`. - -#[test] -fn protected_dock_controller_fields_private() { - let struct_idx = DOCK_CONTROLLER - .find("pub struct DockController {") - .expect("dock_controller.rs must define pub struct DockController"); - let end = DOCK_CONTROLLER[struct_idx..].find('}').unwrap_or(800); - let body = &DOCK_CONTROLLER[struct_idx..struct_idx + end]; - assert!( - !body.contains("pub tree:") && !body.contains("pub floating:") && !body.contains("pub pinned:") && !body.contains("pub active_tab:"), - "dock_controller.rs: DockController fields must be private (no `pub`) so external mutation is forced through methods" - ); -} - -// ── Source-inspection: drain order fixed ─────────────────────────────────── -// -// AGENTS.md: "Drains to_close / to_focus / to_collapse / to_float queues in a -// fixed order." We assert the DRAIN_ORDER const lists Close before Float before -// Focus before Collapse. - -#[test] -fn protected_drain_order_fixed() { - let order_idx = DOCK_CONTROLLER - .find("const DRAIN_ORDER") - .expect("dock_controller.rs must define DRAIN_ORDER"); - let window = &DOCK_CONTROLLER[order_idx..order_idx + 200]; - let close = window.find("QueueKind::Close").unwrap(); - let float = window.find("QueueKind::Float").unwrap(); - let focus = window.find("QueueKind::Focus").unwrap(); - let collapse = window.find("QueueKind::Collapse").unwrap(); - assert!(close < float, "DRAIN_ORDER must be Close before Float"); - assert!(float < focus, "DRAIN_ORDER must be Float before Focus"); - assert!( - focus < collapse, - "DRAIN_ORDER must be Focus before Collapse" - ); -} - -// ── Behavior: initial active tab is Document(0) ──────────────────────────── -// -// AGENTS.md: "Initial document active tab ... Sets Document(0) as the active -// dock tab on startup so the canvas widget's ui() is called on the first frame." - -#[test] -fn protected_initial_active_tab_document_zero() { - let ctrl = DockController::new(); - assert_eq!(*ctrl.active_tab(), HciePane::Document(0)); -} - -// ── Source-inspection: document/tools cannot float guard present ────────── -// -// AGENTS.md: "Rejects float_pane calls for Document(_) and Tools panes." We -// assert the `float_pane` method contains the guard check. - -#[test] -fn protected_document_and_tools_cannot_float_guard() { - let method_idx = DOCK_CONTROLLER - .find("pub fn float_pane") - .expect("dock_controller.rs must define float_pane"); - let end = DOCK_CONTROLLER[method_idx..] - .find("pub fn dock_pane") - .unwrap_or(1200); - let body = &DOCK_CONTROLLER[method_idx..method_idx + end]; - assert!( - body.contains("HciePane::Tools"), - "float_pane must explicitly reject HciePane::Tools" - ); - assert!( - body.contains("is_document"), - "float_pane must reject documents via is_document()" - ); -} - -// ── Source-inspection: EmptyCenter insertion logic present ───────────────── -// -// AGENTS.md: "Inserts an EmptyCenter tab into the editor leaf when no document -// tabs remain." We assert drain_queues contains the EmptyCenter insert branch. - -#[test] -fn protected_emptycenter_insertion_present() { - let drain_idx = DOCK_CONTROLLER - .find("pub fn drain_queues") - .expect("dock_controller.rs must define drain_queues"); - let end = DOCK_CONTROLLER[drain_idx..] - .find("pub fn persist") - .unwrap_or(3000); - let body = &DOCK_CONTROLLER[drain_idx..drain_idx + end]; - assert!( - body.contains("EmptyCenter"), - "drain_queues must insert EmptyCenter when no documents remain" - ); - assert!( - body.contains("PaneRole::Editor"), - "EmptyCenter insertion must target the Editor role leaf" - ); -} - -// ── Source-inspection: persistence single-schema round-trip present ──────── -// -// AGENTS.md: "Single serde schema for tree + floating + pinned + active tab. -// Restore of an unrecognized (legacy) JSON falls back to the default layout." - -#[test] -fn protected_persistence_single_schema_and_fallback() { - assert!( - DOCK_CONTROLLER.contains("struct PersistedSnapshot"), - "dock_controller.rs must define a single PersistedSnapshot schema" - ); - assert!( - DOCK_CONTROLLER.contains("unrecognized JSON, falling back to default layout"), - "restore() must fall back to default on unrecognized JSON (legacy migration guard)" - ); - assert!( - DOCK_ENGINE.contains("PANEL_TREE_FORMAT_VERSION"), - "dock_engine.rs must define PANEL_TREE_FORMAT_VERSION for version-gated restore" - ); -} diff --git a/line_count_report.txt b/line_count_report.txt index 625ac9a..6cf2093 100644 --- a/line_count_report.txt +++ b/line_count_report.txt @@ -1,8 +1,8 @@ ========================================= SATIR SAYISI RAPORU Proje: HCIE-Rust v4 -Konum: /mnt/extra/00_PROJECTS/hcie-rust-v4 -Tarih: 2026-07-08 22:13:18 +Konum: /mnt/extra/00_PROJECTS/hcie-rust-v3.05 +Tarih: 2026-07-09 04:57:39 ========================================= ----------------------------------------- @@ -15,8 +15,8 @@ Tarih: 2026-07-08 22:13:18 hcie-composite 985 satır hcie-document 666 satır hcie-draw 502 satır - hcie-egui-app 31814 satır - hcie-engine-api 5366 satır + hcie-egui-app 35426 satır + hcie-engine-api 5401 satır hcie-engine-api-orig 3874 satır hcie-filter 3238 satır hcie-fx 4077 satır @@ -33,15 +33,15 @@ Tarih: 2026-07-08 22:13:18 hcie-vector 2298 satır hcie-vision 3131 satır - TOPLAM RUST: 86587 satır + TOPLAM RUST: 90234 satır ----------------------------------------- C++ / HEADER (.cpp, .h) ----------------------------------------- - hcie-qt-app/src/*.cpp 3963 satır - hcie-qt-app/src/*.h 1011 satır + hcie-qt-app/src/*.cpp 3991 satır + hcie-qt-app/src/*.h 1040 satır - TOPLAM C++ / HEADER: 4974 satır + TOPLAM C++ / HEADER: 5031 satır ----------------------------------------- JAVASCRIPT (.js) @@ -84,10 +84,10 @@ Tarih: 2026-07-08 22:13:18 ----------------------------------------- SHELL SCRIPT (.sh) ----------------------------------------- - (kök dizin) 3033 satır + (kök dizin) 3035 satır logs/ 220 satır - TOPLAM SHELL: 3253 satır + TOPLAM SHELL: 3255 satır ----------------------------------------- DOKÜMANTASYON / VERİ DOSYALARI @@ -95,34 +95,34 @@ Tarih: 2026-07-08 22:13:18 JSON (.json) -------------------------------- - (kök dizin) 14991 satır + (kök dizin) 6517 satır - TOPLAM JSON: 14991 satır + TOPLAM JSON: 6517 satır MARKDOWN (.md) -------------------------------- - (kök dizin) 11416 satır + (kök dizin) 11605 satır - TOPLAM MARKDOWN: 11416 satır + TOPLAM MARKDOWN: 11605 satır ========================================= KOD SATIRLARI TOPLAMI (GRAND TOTAL) ========================================= - Rust: 86587 satır - C++ / Header: 4974 satır + Rust: 90234 satır + C++ / Header: 5031 satır JavaScript: 0 satır Svelte: 0 satır TypeScript: 0 satır Python: 2267 satır HTML: 0 satır CSS: 0 satır - Shell Script: 3253 satır + Shell Script: 3255 satır ----------------------------------------- - KOD TOPLAMI: 97081 satır + KOD TOPLAMI: 100787 satır - RUST + C++/H TOPLAMI: 91561 satır + RUST + C++/H TOPLAMI: 95265 satır (JSON ve Markdown dosyaları yukarıda ayrı olarak gösterilmiştir) ========================================= -Rapor dosyaya kaydedildi: /mnt/extra/00_PROJECTS/hcie-rust-v4/line_count_report.txt +Rapor dosyaya kaydedildi: /mnt/extra/00_PROJECTS/hcie-rust-v3.05/line_count_report.txt diff --git a/patches/grimdock/.cargo-ok b/patches/grimdock/.cargo-ok deleted file mode 100644 index 5f8b795..0000000 --- a/patches/grimdock/.cargo-ok +++ /dev/null @@ -1 +0,0 @@ -{"v":1} \ No newline at end of file diff --git a/patches/grimdock/.cargo_vcs_info.json b/patches/grimdock/.cargo_vcs_info.json deleted file mode 100644 index 2b9bfca..0000000 --- a/patches/grimdock/.cargo_vcs_info.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "git": { - "sha1": "a49454b6028db991f498a181c2ebc1ac416ae9e6" - }, - "path_in_vcs": "" -} \ No newline at end of file diff --git a/patches/grimdock/.github/workflows/publish.yml b/patches/grimdock/.github/workflows/publish.yml deleted file mode 100644 index 4cf5b1f..0000000 --- a/patches/grimdock/.github/workflows/publish.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Publish Crate - -on: - push: - tags: - - "v*" - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Check tag matches Cargo.toml version - run: | - TAG_VERSION="${GITHUB_REF_NAME#v}" - CARGO_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') - test "$TAG_VERSION" = "$CARGO_VERSION" - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Dry run publish - run: cargo publish --dry-run - - - name: Publish to crates.io - run: cargo publish - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - - - name: Check for changelog - id: changelog_file - run: | - if [ -f CHANGELOG.md ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - fi - - - name: Get changelog entry - id: changelog - if: steps.changelog_file.outputs.present == 'true' - run: | - TAG_VERSION="${GITHUB_REF_NAME#v}" - echo "body<> "$GITHUB_OUTPUT" - awk "/^## \[${TAG_VERSION}\]/{found=1; next} found && /^## /{exit} found{print}" CHANGELOG.md >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - body: ${{ steps.changelog.outputs.body }} diff --git a/patches/grimdock/.gitignore b/patches/grimdock/.gitignore deleted file mode 100644 index 00c68c5..0000000 --- a/patches/grimdock/.gitignore +++ /dev/null @@ -1,22 +0,0 @@ -/target -**/target -book/build/ - -# Cargo lockfiles are usually omitted for library-style crates and examples here. -/Cargo.lock -/examples/qt_viewport/Cargo.lock - -# Local editor and OS noise. -.DS_Store -.idea/ -.vscode/ -.direnv/ -*.log -*.swp -*.swo -*.user -*.qtc.user - - -docs/ -settings.local.json diff --git a/patches/grimdock/Cargo.toml b/patches/grimdock/Cargo.toml deleted file mode 100644 index eb571d4..0000000 --- a/patches/grimdock/Cargo.toml +++ /dev/null @@ -1,84 +0,0 @@ -# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO -# -# When uploading crates to the registry Cargo will automatically -# "normalize" Cargo.toml files for maximal compatibility -# with all versions of Cargo and also rewrite `path` dependencies -# to registry (e.g., crates.io) dependencies. -# -# If you are reading this file be aware that the original Cargo.toml -# will likely look very different (and much more reasonable). -# See Cargo.toml.orig for the original contents. - -[package] -edition = "2021" -name = "grimdock" -version = "0.2.0" -build = false -autolib = false -autobins = false -autoexamples = false -autotests = false -autobenches = false -description = "Dockable panel layout system for egui" -readme = "README.md" -license = "MIT OR Apache-2.0" - -[features] -default = [] -serde = [ - "dep:serde", - "egui/serde", -] - -[lib] -name = "grimdock" -path = "src/lib.rs" - -[[example]] -name = "1_basic" -path = "examples/1_basic.rs" - -[[example]] -name = "2_split_layout" -path = "examples/2_split_layout.rs" - -[[example]] -name = "3_header_features" -path = "examples/3_header_features.rs" - -[[example]] -name = "4_persistence" -path = "examples/4_persistence.rs" - -[[example]] -name = "5_texture_icons" -path = "examples/5_texture_icons.rs" - -[[example]] -name = "6_policy_controls" -path = "examples/6_policy_controls.rs" - -[[example]] -name = "7_theme_options" -path = "examples/7_theme_options.rs" - -[[example]] -name = "demo" -path = "examples/demo.rs" - -[dependencies.egui] -version = "0.34" -default-features = false - -[dependencies.serde] -version = "1" -features = ["derive"] -optional = true - -[dev-dependencies.eframe] -version = "0.33.3" -features = [ - "default_fonts", - "glow", -] -default-features = false diff --git a/patches/grimdock/README.md b/patches/grimdock/README.md deleted file mode 100644 index e813efc..0000000 --- a/patches/grimdock/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# grimdock - -`grimdock` is an [egui](https://github.com/emilk/egui) docking crate I use for my own projects. - -It keeps to a single-surface IDE-style dock: split panes, draggable tabs, resizing, and rearranging without turning into a floating window system. - -## Features - -- split-pane dock layouts -- stable pane identity via `PaneId` -- pane-scoped policy and mutation APIs -- separate pane policy and tab drop policy -- built-in header interactions and pane actions -- pane-specific add/open menus -- pane anchors and semantic pane roles -- versioned persistence -- theme, typography, and icon support, including texture icons - -## Why this exists - -This crate has a few things I needed that other egui panel/docking options -didn't line up with: - -- explicit stable pane identity -- pane-level policy separate from tab-level drop rules -- anchors and semantic pane roles built into the dock model -- pane-scoped add/open actions and persistence behavior diff --git a/patches/grimdock/examples/1_basic.rs b/patches/grimdock/examples/1_basic.rs deleted file mode 100644 index 6f61cca..0000000 --- a/patches/grimdock/examples/1_basic.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Smallest runnable UI example: -//! `cargo run --example 1_basic` - -use eframe::egui; -use grimdock::{PanelContext, PanelStyle, PanelTree, Tab}; - -fn main() -> eframe::Result<()> { - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("grimdock 1_basic") - .with_inner_size([800.0, 480.0]), - ..Default::default() - }; - - eframe::run_native( - "grimdock 1_basic", - options, - Box::new(|_cc| Ok(Box::new(App::new()))), - ) -} - -struct App { - tree: PanelTree<&'static str>, - style: PanelStyle, -} - -impl App { - fn new() -> Self { - Self { - tree: PanelTree::new(vec![Tab::new("welcome", "welcome")]), - style: PanelStyle::default(), - } - } -} - -impl eframe::App for App { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - PanelContext::new(ui, &mut self.tree, &self.style).show(|ui, tab_id| { - ui.add_space(12.0); - ui.heading(*tab_id); - ui.label("This is the smallest useful grimdock setup."); - ui.label("There is one pane and one tab."); - }); - }); - } -} diff --git a/patches/grimdock/examples/2_split_layout.rs b/patches/grimdock/examples/2_split_layout.rs deleted file mode 100644 index 8dc407e..0000000 --- a/patches/grimdock/examples/2_split_layout.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Split layout and resize example: -//! `cargo run --example 2_split_layout` - -use eframe::egui; -use grimdock::{ChildSide, PanelContext, PanelStyle, PanelTree, SplitDir, Tab}; - -fn main() -> eframe::Result<()> { - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("grimdock 2_split_layout") - .with_inner_size([960.0, 600.0]), - ..Default::default() - }; - - eframe::run_native( - "grimdock 2_split_layout", - options, - Box::new(|_cc| Ok(Box::new(App::new()))), - ) -} - -#[derive(Clone, PartialEq, Eq, Debug)] -enum TabId { - Files, - Editor, - Terminal, -} - -struct App { - tree: PanelTree, - style: PanelStyle, -} - -impl App { - fn new() -> Self { - let mut tree = PanelTree::new(vec![Tab::new("editor", TabId::Editor)]); - tree.split_leaf( - 0, - SplitDir::Horizontal, - Tab::new("files", TabId::Files), - ChildSide::First, - ); - - let editor_pane = tree - .find_pane_containing(&TabId::Editor) - .expect("editor pane should exist"); - tree.pane_mut(editor_pane) - .expect("editor pane should exist") - .split( - SplitDir::Vertical, - Tab::new("terminal", TabId::Terminal), - ChildSide::Second, - ); - - Self { - tree, - style: PanelStyle::default(), - } - } -} - -impl eframe::App for App { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - PanelContext::new(ui, &mut self.tree, &self.style).show(|ui, tab_id| match tab_id { - TabId::Files => { - ui.heading("Files"); - ui.label("Try dragging the resize handles between panes."); - } - TabId::Editor => { - ui.heading("Editor"); - ui.label("This pane started as the root."); - } - TabId::Terminal => { - ui.heading("Terminal"); - ui.label("This pane was added by splitting the editor vertically."); - } - }); - }); - } -} diff --git a/patches/grimdock/examples/3_header_features.rs b/patches/grimdock/examples/3_header_features.rs deleted file mode 100644 index f8c2502..0000000 --- a/patches/grimdock/examples/3_header_features.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Header actions, add-tab menus, and tab markers: -//! `cargo run --example 3_header_features` - -use eframe::egui; -use grimdock::{ - AddTabEntry, HeaderVisibility, OpenBehavior, PanelContext, PanelStyle, PanelTree, - PersistedNode, Tab, -}; - -fn main() -> eframe::Result<()> { - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("grimdock 3_header_features") - .with_inner_size([960.0, 540.0]), - ..Default::default() - }; - - eframe::run_native( - "grimdock 3_header_features", - options, - Box::new(|_cc| Ok(Box::new(App::new()))), - ) -} - -#[derive(Clone, PartialEq, Eq, Debug)] -enum TabId { - Notes, - Search, - Problems, - Terminal, - Output, -} - -struct App { - tree: PanelTree, - style: PanelStyle, - hide_headers: bool, -} - -impl App { - fn new() -> Self { - Self { - tree: PanelTree::new(vec![ - Tab::new("notes", TabId::Notes) - .with_leading_visual(">") - .with_closable(true), - Tab::new("search", TabId::Search) - .with_leading_visual("?") - .with_closable(true), - Tab::new("problems", TabId::Problems) - .with_leading_visual("x") - .with_closable(true), - ]), - style: PanelStyle::default(), - hide_headers: false, - } - } - - fn sync_header_visibility(&mut self) { - let header_visibility = if self.hide_headers { - HeaderVisibility::Hidden - } else { - HeaderVisibility::Always - }; - - let pane_ids = self - .tree - .to_persisted() - .nodes - .into_iter() - .filter_map(|node| match node { - PersistedNode::Leaf { pane, .. } => Some(pane), - _ => None, - }) - .collect::>(); - - for pane_id in pane_ids { - if let Some(mut pane) = self.tree.pane_mut(pane_id) { - let mut options = pane.options(); - options.header_visibility = header_visibility; - pane.set_options(options); - } - } - } -} - -impl eframe::App for App { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.sync_header_visibility(); - - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - ui.checkbox(&mut self.hide_headers, "Hide pane headers"); - ui.separator(); - - let output = PanelContext::new(ui, &mut self.tree, &self.style) - .with_add_tab_provider(&|pane_id, tree| { - let mut entries = vec![ - AddTabEntry::new( - "Output", - Tab::new("output", TabId::Output) - .with_leading_visual("!") - .with_closable(true), - ), - ]; - - if tree.find_pane_containing(&TabId::Notes) == Some(pane_id) { - entries.push( - AddTabEntry::new( - "Terminal", - Tab::new("terminal", TabId::Terminal) - .with_leading_visual("$") - .with_closable(true), - ) - .with_open_behavior(OpenBehavior::FocusExisting), - ); - } - - entries - }) - .show(|ui, tab_id| match tab_id { - TabId::Notes => { - ui.heading("Notes"); - ui.label("This pane gets both Output and Terminal in its + menu."); - ui.label("Right-click a tab for Close / Close others / Split actions."); - } - TabId::Search => { - ui.heading("Search"); - ui.text_edit_singleline(&mut String::new()); - } - TabId::Problems => { - ui.heading("Problems"); - ui.label("No problems detected."); - } - TabId::Terminal => { - ui.heading("Terminal"); - ui.label("$ cargo run"); - } - TabId::Output => { - ui.heading("Output"); - ui.label("[INFO] pane-specific add menus are enabled"); - } - }); - - if !output.closed_tabs.is_empty() { - ui.separator(); - ui.label(format!("Closed this frame: {:?}", output.closed_tabs)); - } - }); - } -} diff --git a/patches/grimdock/examples/4_persistence.rs b/patches/grimdock/examples/4_persistence.rs deleted file mode 100644 index 2267f4e..0000000 --- a/patches/grimdock/examples/4_persistence.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Persistence example with a runnable UI: -//! `cargo run --example 4_persistence` - -use eframe::egui; -use grimdock::{ - ChildSide, DropPolicy, PaneBuilder, PaneOptions, PanelContext, PanelStyle, PanelTree, - PersistedPanelTree, SplitDir, Tab, -}; - -fn main() -> eframe::Result<()> { - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("grimdock 4_persistence") - .with_inner_size([960.0, 600.0]), - ..Default::default() - }; - - eframe::run_native( - "grimdock 4_persistence", - options, - Box::new(|_cc| Ok(Box::new(App::new()))), - ) -} - -struct App { - tree: PanelTree<&'static str>, - saved: Option>, - style: PanelStyle, -} - -impl App { - fn new() -> Self { - Self { - tree: make_tree(), - saved: None, - style: PanelStyle::default(), - } - } -} - -impl eframe::App for App { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::Panel::top("controls").show(ctx, |ui| { - ui.horizontal(|ui| { - if ui.button("Save layout").clicked() { - self.saved = Some(self.tree.to_persisted()); - } - - if ui.button("Restore saved layout").clicked() { - if let Some(saved) = self.saved.clone() { - self.tree = - PanelTree::from_persisted(saved).expect("saved layout should restore"); - } - } - - if ui.button("Reset demo layout").clicked() { - self.tree = make_tree(); - } - }); - - if let Some(saved) = &self.saved { - ui.label(format!( - "Saved version {} with {} persisted nodes", - saved.version, - saved.nodes.len() - )); - } else { - ui.label("No saved layout yet."); - } - }); - - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - PanelContext::new(ui, &mut self.tree, &self.style).show(|ui, tab_id| { - ui.heading(*tab_id); - ui.label("Drag tabs, resize panes, then press Save layout."); - ui.label("Restore saved layout to rebuild the tree from the persisted format."); - }); - }); - } -} - -fn make_tree() -> PanelTree<&'static str> { - let mut options = PaneOptions::default(); - options.drop_policy = DropPolicy::merge_only(); - options.allow_resize = false; - - let mut tree = PanelTree::from_pane( - PaneBuilder::new(Tab::new("editor", "editor").with_leading_visual(">")) - .push_tab(Tab::new("search", "search").with_leading_visual("?")) - .with_options(options), - ); - - tree.split_leaf( - 0, - SplitDir::Vertical, - Tab::new("terminal", "terminal").with_leading_visual("$"), - ChildSide::Second, - ); - - tree -} diff --git a/patches/grimdock/examples/5_texture_icons.rs b/patches/grimdock/examples/5_texture_icons.rs deleted file mode 100644 index 9b4c97d..0000000 --- a/patches/grimdock/examples/5_texture_icons.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! Texture-backed tab icon example: -//! `cargo run --example 5_texture_icons` - -use eframe::egui; -use grimdock::{PanelContext, PanelStyle, PanelTree, Tab}; - -fn main() -> eframe::Result<()> { - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("grimdock 5_texture_icons") - .with_inner_size([960.0, 560.0]), - ..Default::default() - }; - - eframe::run_native( - "grimdock 5_texture_icons", - options, - Box::new(|cc| Ok(Box::new(App::new(cc)))), - ) -} - -#[derive(Clone, PartialEq, Eq, Debug)] -enum TabId { - Rust, - Search, - Graph, -} - -struct App { - tree: PanelTree, - style: PanelStyle, - _textures: Vec, -} - -impl App { - fn new(cc: &eframe::CreationContext<'_>) -> Self { - let textures = vec![ - make_icon(&cc.egui_ctx, "rust_icon", egui::Color32::from_rgb(208, 116, 61)), - make_icon(&cc.egui_ctx, "search_icon", egui::Color32::from_rgb(84, 164, 218)), - make_icon(&cc.egui_ctx, "graph_icon", egui::Color32::from_rgb(108, 196, 119)), - ]; - - let icon_size = egui::vec2(14.0, 14.0); - let tree = PanelTree::new(vec![ - Tab::new("rust.rs", TabId::Rust) - .with_icon_texture(textures[0].id(), icon_size) - .with_closable(true), - Tab::new("search", TabId::Search) - .with_icon_texture(textures[1].id(), icon_size) - .with_closable(true), - Tab::new("graph", TabId::Graph) - .with_icon_texture(textures[2].id(), icon_size) - .with_closable(true), - ]); - - Self { - tree, - style: PanelStyle::default(), - _textures: textures, - } - } -} - -impl eframe::App for App { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - PanelContext::new(ui, &mut self.tree, &self.style).show(|ui, tab_id| match tab_id { - TabId::Rust => { - ui.heading("Texture-backed tab icons"); - ui.label("These tabs use TextureId + size instead of text markers."); - } - TabId::Search => { - ui.heading("Search"); - ui.label("Use this pattern when your app already has texture handles."); - } - TabId::Graph => { - ui.heading("Graph"); - ui.label("The example creates simple textures in memory at startup."); - } - }); - }); - } -} - -fn make_icon( - ctx: &egui::Context, - name: &str, - accent: egui::Color32, -) -> egui::TextureHandle { - let size = [16, 16]; - let mut image = egui::ColorImage::filled(size, egui::Color32::TRANSPARENT); - - for y in 2..14 { - for x in 2..14 { - let dx = x as i32 - 8; - let dy = y as i32 - 8; - if dx * dx + dy * dy <= 24 { - image[(x, y)] = accent; - } - } - } - - for i in 4..12 { - image[(i, 8)] = egui::Color32::WHITE; - image[(8, i)] = egui::Color32::WHITE; - } - - ctx.load_texture(name.to_owned(), image, egui::TextureOptions::LINEAR) -} diff --git a/patches/grimdock/examples/6_policy_controls.rs b/patches/grimdock/examples/6_policy_controls.rs deleted file mode 100644 index 06db379..0000000 --- a/patches/grimdock/examples/6_policy_controls.rs +++ /dev/null @@ -1,332 +0,0 @@ -//! Policy controls example: -//! `cargo run --example 6_policy_controls` - -use eframe::egui; -use grimdock::{ - DropPolicy, PaneAnchor, PaneBuilder, PaneId, PaneMenuAction, PaneOptions, PaneRole, - PanelContext, PanelStyle, PanelTree, Tab, TabDropPolicy, -}; - -fn main() -> eframe::Result<()> { - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("grimdock 6_policy_controls") - .with_inner_size([1180.0, 720.0]), - ..Default::default() - }; - - eframe::run_native( - "grimdock 6_policy_controls", - options, - Box::new(|_cc| Ok(Box::new(App::new()))), - ) -} - -#[derive(Clone, PartialEq, Eq, Debug)] -enum TabId { - Files, - Editor, - Search, - Terminal, -} - -struct App { - tree: PanelTree, - style: PanelStyle, - files_home_pane: PaneId, - editor_home_pane: PaneId, - terminal_pane: PaneId, - persist_files_pane: bool, - lock_files_layout: bool, - disable_files_drops: bool, - disable_terminal_resize: bool, - disable_editor_reorder: bool, - disable_editor_drag_out: bool, - freeze_search_tab: bool, - lock_search_to_files_pane: bool, - allow_search_only_files_and_editor: bool, - block_search_from_terminal: bool, - lock_search_to_sidebar_role: bool, - allow_search_only_sidebar_and_editor_roles: bool, - block_search_from_terminal_role: bool, - pane_action_log: Vec, -} - -impl App { - fn new() -> Self { - let (tree, files_home_pane, editor_home_pane, terminal_pane) = make_tree(); - Self { - tree, - style: PanelStyle::default(), - files_home_pane, - editor_home_pane, - terminal_pane, - persist_files_pane: true, - lock_files_layout: false, - disable_files_drops: false, - disable_terminal_resize: false, - disable_editor_reorder: false, - disable_editor_drag_out: false, - freeze_search_tab: false, - lock_search_to_files_pane: false, - allow_search_only_files_and_editor: false, - block_search_from_terminal: false, - lock_search_to_sidebar_role: false, - allow_search_only_sidebar_and_editor_roles: false, - block_search_from_terminal_role: false, - pane_action_log: Vec::new(), - } - } - - fn reset_layout(&mut self) { - let (tree, files_home_pane, editor_home_pane, terminal_pane) = make_tree(); - self.tree = tree; - self.files_home_pane = files_home_pane; - self.editor_home_pane = editor_home_pane; - self.terminal_pane = terminal_pane; - } - - fn update_pane_options( - &mut self, - pane_id: PaneId, - update: impl FnOnce(&mut PaneOptions), - ) { - if let Some(mut pane) = self.tree.pane_mut(pane_id) { - let mut options = pane.options(); - update(&mut options); - pane.set_options(options); - } - } - - fn apply_policy_toggles(&mut self) { - let persist_files_pane = self.persist_files_pane; - let lock_files_layout = self.lock_files_layout; - let disable_files_drops = self.disable_files_drops; - let disable_terminal_resize = self.disable_terminal_resize; - let disable_editor_reorder = self.disable_editor_reorder; - let disable_editor_drag_out = self.disable_editor_drag_out; - let freeze_search_tab = self.freeze_search_tab; - let lock_search_to_files_pane = self.lock_search_to_files_pane; - let allow_search_only_files_and_editor = self.allow_search_only_files_and_editor; - let block_search_from_terminal = self.block_search_from_terminal; - let lock_search_to_sidebar_role = self.lock_search_to_sidebar_role; - let allow_search_only_sidebar_and_editor_roles = - self.allow_search_only_sidebar_and_editor_roles; - let block_search_from_terminal_role = self.block_search_from_terminal_role; - let files_home_pane = self.files_home_pane; - let editor_home_pane = self.editor_home_pane; - let terminal_pane = self.terminal_pane; - - self.update_pane_options(files_home_pane, |options| { - options.persist_when_empty = persist_files_pane; - options.lock_layout = lock_files_layout; - options.drop_policy = if disable_files_drops { - DropPolicy::none() - } else { - DropPolicy::all() - }; - }); - - self.update_pane_options(editor_home_pane, |options| { - options.allow_tab_reorder = !disable_editor_reorder; - options.allow_tab_drag_out = !disable_editor_drag_out; - }); - - self.update_pane_options(terminal_pane, |options| { - options.allow_resize = !disable_terminal_resize; - }); - - if let Some(search_pane_id) = self.tree.find_pane_containing(&TabId::Search) { - if let Some(mut pane) = self.tree.pane_mut(search_pane_id) { - if let Some(tab) = pane.tabs_mut().iter_mut().find(|tab| tab.id == TabId::Search) { - tab.draggable = !freeze_search_tab; - - let mut drop_policy = TabDropPolicy::default(); - if lock_search_to_files_pane { - drop_policy.locked_to_pane = Some(files_home_pane); - } else if lock_search_to_sidebar_role { - drop_policy.locked_to_role = Some(PaneRole::Sidebar); - } else if allow_search_only_files_and_editor { - drop_policy.allowed_panes = Some(vec![files_home_pane, editor_home_pane]); - } else if allow_search_only_sidebar_and_editor_roles { - drop_policy.allowed_roles = - Some(vec![PaneRole::Sidebar, PaneRole::Editor]); - } - if block_search_from_terminal { - drop_policy.blocked_panes.push(terminal_pane); - } - if block_search_from_terminal_role { - drop_policy.blocked_roles.push(PaneRole::Terminal); - } - tab.drop_policy = drop_policy; - } - } - } - } - - fn pane_present(&self, pane_id: PaneId) -> bool { - self.tree.pane(pane_id).is_some() - } -} - -impl eframe::App for App { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.apply_policy_toggles(); - - egui::Panel::left("policy_controls") - .resizable(false) - .default_width(320.0) - .show(ctx, |ui| { - ui.heading("Policy controls"); - ui.label("This example separates pane policy from tab policy."); - ui.separator(); - - ui.label("Pane policy"); - ui.checkbox(&mut self.persist_files_pane, "Persist Files pane when empty"); - ui.checkbox(&mut self.lock_files_layout, "Lock Files pane layout"); - ui.checkbox(&mut self.disable_files_drops, "Reject drops into Files pane"); - ui.checkbox(&mut self.disable_terminal_resize, "Disable Terminal resize"); - ui.checkbox(&mut self.disable_editor_reorder, "Disable Editor pane reorder"); - ui.checkbox(&mut self.disable_editor_drag_out, "Disable Editor pane drag-out"); - - ui.separator(); - ui.label("Tab policy"); - ui.checkbox(&mut self.freeze_search_tab, "Make Search tab immovable"); - ui.checkbox(&mut self.lock_search_to_files_pane, "Lock Search tab to Files pane ID"); - ui.checkbox(&mut self.lock_search_to_sidebar_role, "Lock Search tab to Sidebar role"); - ui.checkbox( - &mut self.allow_search_only_files_and_editor, - "Allow Search only in Files and Editor pane IDs", - ); - ui.checkbox( - &mut self.allow_search_only_sidebar_and_editor_roles, - "Allow Search only in Sidebar and Editor roles", - ); - ui.checkbox(&mut self.block_search_from_terminal, "Block Search from Terminal pane ID"); - ui.checkbox( - &mut self.block_search_from_terminal_role, - "Block Search from Terminal role", - ); - - ui.separator(); - if ui.button("Reset layout").clicked() { - self.reset_layout(); - } - - ui.separator(); - ui.label(format!( - "Files home pane present: {}", - if self.pane_present(self.files_home_pane) { "yes" } else { "no" } - )); - ui.label(format!( - "Files anchor owner: {:?}", - self.tree.find_pane_with_anchor(PaneAnchor::Left) - )); - ui.label(format!( - "Terminal anchor owner: {:?}", - self.tree.find_pane_with_anchor(PaneAnchor::Bottom) - )); - ui.label(format!( - "Sidebar role owner: {:?}", - self.tree.find_pane_with_role(PaneRole::Sidebar) - )); - ui.label(format!( - "Terminal role owner: {:?}", - self.tree.find_pane_with_role(PaneRole::Terminal) - )); - ui.label(format!( - "Search tab currently in pane: {:?}", - self.tree.find_pane_containing(&TabId::Search) - )); - - ui.separator(); - ui.label("What to try:"); - ui.label("1. Move Files out of its home pane. With persistence on, the anchored left pane stays as an empty drop target."); - ui.label("2. Turn off Files persistence, empty that pane, and notice the pane can now disappear."); - ui.label("3. Lock Search to the Files pane, then try dropping it into Editor or Terminal."); - ui.label("4. Switch between pane-ID and pane-role targeting to compare semantic versus identity-based policy."); - if let Some(last) = self.pane_action_log.last() { - ui.separator(); - ui.label(format!("Last custom pane action: {last}")); - } - }); - - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - let output = PanelContext::new(ui, &mut self.tree, &self.style) - .with_pane_menu_provider(&|pane_id, tree| { - let mut actions = vec![PaneMenuAction::new("inspect", "Inspect pane")]; - if tree.find_pane_with_anchor(PaneAnchor::Left) == Some(pane_id) { - actions.push(PaneMenuAction::new("mark_left", "Mark left anchor")); - } - actions - }) - .show(|ui, tab_id| match tab_id { - TabId::Files => { - ui.heading("Files"); - ui.label("This pane demonstrates pane persistence, pane locking, and pane drop policy."); - } - TabId::Editor => { - ui.heading("Editor"); - ui.label("This pane demonstrates pane-level reorder and drag-out controls."); - } - TabId::Search => { - ui.heading("Search"); - ui.label("This tab demonstrates draggable state plus pane lock / allow-list / block-list targeting."); - } - TabId::Terminal => { - ui.heading("Terminal"); - ui.label("This pane demonstrates resize locking and can also be blocked as a tab destination."); - } - }); - - for action in output.pane_actions { - self.pane_action_log - .push(format!("{} on pane {:?}", action.action_id, action.pane_id)); - } - }); - } -} - -fn make_tree() -> (PanelTree, PaneId, PaneId, PaneId) { - let mut tree = PanelTree::new(vec![ - Tab::new("editor", TabId::Editor).with_leading_visual(">"), - Tab::new("search", TabId::Search).with_leading_visual("?"), - ]); - - let files_home_pane = tree.ensure_pane_at_anchor( - PaneAnchor::Left, - PaneBuilder::new(Tab::new("files", TabId::Files).with_leading_visual("#")), - ); - - let editor_home_pane = tree - .find_pane_containing(&TabId::Editor) - .expect("editor pane should exist after initial split"); - - let terminal_pane = tree.ensure_pane_at_anchor( - PaneAnchor::Bottom, - PaneBuilder::new(Tab::new("terminal", TabId::Terminal).with_leading_visual("$")), - ); - - if let Some(mut pane) = tree.pane_mut(files_home_pane) { - let mut options = pane.options(); - options.persist_when_empty = true; - options.role = Some(PaneRole::Sidebar); - pane.set_options(options); - } - - if let Some(mut pane) = tree.pane_mut(editor_home_pane) { - let mut options = pane.options(); - options.role = Some(PaneRole::Editor); - pane.set_options(options); - } - - if let Some(mut pane) = tree.pane_mut(terminal_pane) { - let mut options = pane.options(); - options.role = Some(PaneRole::Terminal); - pane.set_options(options); - } - - (tree, files_home_pane, editor_home_pane, terminal_pane) -} diff --git a/patches/grimdock/examples/7_theme_options.rs b/patches/grimdock/examples/7_theme_options.rs deleted file mode 100644 index 1929dd1..0000000 --- a/patches/grimdock/examples/7_theme_options.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! Theme and colour options example: -//! `cargo run --example 7_theme_options` - -use eframe::egui; -use grimdock::{ - ChildSide, PaneId, PaneStyleOverride, PanelContext, PanelStyle, PanelTree, SplitDir, Tab, - TabStyleOverride, -}; - -fn main() -> eframe::Result<()> { - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("grimdock 7_theme_options") - .with_inner_size([1260.0, 760.0]), - ..Default::default() - }; - - eframe::run_native( - "grimdock 7_theme_options", - options, - Box::new(|cc| Ok(Box::new(App::new(&cc.egui_ctx)))), - ) -} - -#[derive(Clone, PartialEq, Eq, Debug)] -enum TabId { - Files, - Editor, - Search, - Preview, - Terminal, -} - -struct App { - tree: PanelTree, - style: PanelStyle, - terminal_pane: PaneId, - terminal_override_enabled: bool, - terminal_override: PaneStyleOverride, - search_override_enabled: bool, - search_override: TabStyleOverride, -} - -impl App { - fn new(ctx: &egui::Context) -> Self { - let (tree, terminal_pane) = make_tree(); - Self { - tree, - style: PanelStyle::from_visuals(&ctx.style().visuals), - terminal_pane, - terminal_override_enabled: true, - terminal_override: PaneStyleOverride { - header_bg: Some(egui::Color32::from_rgb(45, 28, 20)), - content_bg: Some(egui::Color32::from_rgb(34, 22, 16)), - border_color: Some(egui::Color32::from_rgb(168, 99, 56)), - accent_color: Some(egui::Color32::from_rgb(230, 142, 78)), - }, - search_override_enabled: true, - search_override: TabStyleOverride { - active_bg: Some(egui::Color32::from_rgb(30, 74, 66)), - inactive_bg: Some(egui::Color32::from_rgb(24, 52, 48)), - hovered_bg: Some(egui::Color32::from_rgb(36, 90, 80)), - text_color: Some(egui::Color32::from_rgb(214, 245, 238)), - accent_color: Some(egui::Color32::from_rgb(52, 204, 168)), - icon_color: None, - }, - } - } - - fn sync_overrides(&mut self) { - if let Some(mut pane) = self.tree.pane_mut(self.terminal_pane) { - let mut options = pane.options(); - options.style_override = self.terminal_override_enabled.then_some(self.terminal_override); - pane.set_options(options); - } - - if let Some(search_pane_id) = self.tree.find_pane_containing(&TabId::Search) { - if let Some(mut pane) = self.tree.pane_mut(search_pane_id) { - if let Some(tab) = pane.tabs_mut().iter_mut().find(|tab| tab.id == TabId::Search) { - tab.style_override = self.search_override_enabled.then_some(self.search_override); - } - } - } - } -} - -impl eframe::App for App { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.sync_overrides(); - - egui::Panel::left("theme_controls") - .resizable(true) - .default_width(360.0) - .show(ctx, |ui| { - ui.heading("Theme workbench"); - ui.label("This example exposes the global PanelStyle plus pane and tab color overrides."); - - ui.horizontal(|ui| { - if ui.button("Use egui visuals").clicked() { - self.style = PanelStyle::from_visuals(&ctx.style().visuals); - } - if ui.button("Use crate defaults").clicked() { - self.style = PanelStyle::default(); - } - }); - - ui.separator(); - - ui.collapsing("Layout metrics", |ui| { - ui.add(egui::Slider::new(&mut self.style.header_height, 20.0..=40.0).text("Header height")); - ui.add( - egui::Slider::new(&mut self.style.collapsed_pane_thickness, 20.0..=60.0) - .text("Collapsed thickness"), - ); - ui.add(egui::Slider::new(&mut self.style.handle_width, 2.0..=12.0).text("Handle width")); - ui.add(egui::Slider::new(&mut self.style.min_pane_size, 40.0..=160.0).text("Min pane size")); - ui.add(egui::Slider::new(&mut self.style.content_inset, 0.0..=20.0).text("Content inset")); - corner_radius_slider(ui, "Pane rounding", &mut self.style.pane_rounding); - corner_radius_slider(ui, "Tab rounding", &mut self.style.tabs.rounding); - corner_radius_slider(ui, "Header button rounding", &mut self.style.header.button.rounding); - }); - - ui.collapsing("Typography", |ui| { - ui.label("Dock chrome text is painted directly by grimdock."); - ui.label("Font weight comes from whichever font family your app has loaded."); - font_controls(ui, "Tab title", &mut self.style.typography.tab_title_font); - font_controls( - ui, - "Leading text icon", - &mut self.style.typography.tab_icon_text_font, - ); - }); - - ui.collapsing("Header", |ui| { - color_row(ui, "Header bg", &mut self.style.header.bg); - color_row(ui, "Header border", &mut self.style.header.border_color); - color_row(ui, "Button bg", &mut self.style.header.button.bg); - color_row(ui, "Button hover bg", &mut self.style.header.button.hover_bg); - color_row(ui, "Button stroke", &mut self.style.header.button.stroke_color); - color_row(ui, "Button icon", &mut self.style.header.button.icon_color); - color_row(ui, "Button hover icon", &mut self.style.header.button.hover_icon_color); - }); - - ui.collapsing("Content", |ui| { - color_row(ui, "Content bg", &mut self.style.content.bg); - color_row(ui, "Content border", &mut self.style.content.border_color); - }); - - ui.collapsing("Tabs", |ui| { - ui.label("Active"); - color_row(ui, "Active bg", &mut self.style.tabs.active.bg); - color_row(ui, "Active text", &mut self.style.tabs.active.text_color); - color_row(ui, "Active accent", &mut self.style.tabs.active.accent_color); - - ui.separator(); - ui.label("Inactive"); - color_row(ui, "Inactive bg", &mut self.style.tabs.inactive.bg); - color_row(ui, "Inactive text", &mut self.style.tabs.inactive.text_color); - color_row(ui, "Inactive accent", &mut self.style.tabs.inactive.accent_color); - - ui.separator(); - ui.label("Hovered"); - color_row(ui, "Hovered bg", &mut self.style.tabs.hovered.bg); - color_row(ui, "Hovered text", &mut self.style.tabs.hovered.text_color); - color_row(ui, "Hovered accent", &mut self.style.tabs.hovered.accent_color); - }); - - ui.collapsing("Handles and overlay", |ui| { - color_row(ui, "Handle", &mut self.style.handle.color); - color_row(ui, "Handle hover", &mut self.style.handle.hover_color); - color_row(ui, "Handle locked", &mut self.style.handle.locked_color); - color_row(ui, "Overlay fill", &mut self.style.overlay.fill); - color_row(ui, "Overlay stroke", &mut self.style.overlay.stroke.color); - ui.add( - egui::Slider::new(&mut self.style.overlay.stroke.width, 0.5..=4.0) - .text("Overlay stroke width"), - ); - }); - - ui.collapsing("Pane override", |ui| { - ui.checkbox(&mut self.terminal_override_enabled, "Enable Terminal pane override"); - color_option_row(ui, "Header bg", &mut self.terminal_override.header_bg); - color_option_row(ui, "Content bg", &mut self.terminal_override.content_bg); - color_option_row(ui, "Border", &mut self.terminal_override.border_color); - color_option_row(ui, "Accent", &mut self.terminal_override.accent_color); - }); - - ui.collapsing("Tab override", |ui| { - ui.checkbox(&mut self.search_override_enabled, "Enable Search tab override"); - color_option_row(ui, "Active bg", &mut self.search_override.active_bg); - color_option_row(ui, "Inactive bg", &mut self.search_override.inactive_bg); - color_option_row(ui, "Hovered bg", &mut self.search_override.hovered_bg); - color_option_row(ui, "Text", &mut self.search_override.text_color); - color_option_row(ui, "Accent", &mut self.search_override.accent_color); - }); - }); - - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - PanelContext::new(ui, &mut self.tree, &self.style).show(|ui, tab_id| match tab_id { - TabId::Files => { - ui.heading("Files"); - ui.label("Use this workbench to tune every main color and size in PanelStyle."); - } - TabId::Editor => { - ui.heading("Editor"); - ui.label("Drag tabs and resize panes while adjusting the theme controls."); - } - TabId::Search => { - ui.heading("Search"); - ui.label("This tab can demonstrate per-tab color overrides."); - } - TabId::Preview => { - ui.heading("Preview"); - ui.label("The dock updates live as you change colors, rounding, and handle styles."); - } - TabId::Terminal => { - ui.heading("Terminal"); - ui.label("This pane can demonstrate per-pane header/content/accent overrides."); - } - }); - }); - } -} - -fn color_row(ui: &mut egui::Ui, label: &str, color: &mut egui::Color32) { - ui.horizontal(|ui| { - ui.label(label); - ui.color_edit_button_srgba(color); - }); -} - -fn color_option_row(ui: &mut egui::Ui, label: &str, color: &mut Option) { - let mut enabled = color.is_some(); - ui.horizontal(|ui| { - ui.checkbox(&mut enabled, label); - if !enabled { - *color = None; - return; - } - - let value = color.get_or_insert(egui::Color32::WHITE); - ui.color_edit_button_srgba(value); - }); -} - -fn corner_radius_slider(ui: &mut egui::Ui, label: &str, radius: &mut egui::CornerRadius) { - let mut value = radius.nw; - ui.add(egui::Slider::new(&mut value, 0..=16).text(label)); - *radius = egui::CornerRadius::same(value); -} - -fn font_controls(ui: &mut egui::Ui, label: &str, font: &mut egui::FontId) { - ui.group(|ui| { - ui.label(label); - ui.add(egui::Slider::new(&mut font.size, 8.0..=24.0).text("Size")); - - let mut family = match &font.family { - egui::FontFamily::Proportional => 0, - egui::FontFamily::Monospace => 1, - egui::FontFamily::Name(_) => 2, - }; - egui::ComboBox::from_label("Family") - .selected_text(match family { - 0 => "Proportional", - 1 => "Monospace", - _ => "Custom", - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut family, 0, "Proportional"); - ui.selectable_value(&mut family, 1, "Monospace"); - ui.selectable_value(&mut family, 2, "Custom"); - }); - - if family == 2 { - let mut name = match &font.family { - egui::FontFamily::Name(name) => name.to_string(), - _ => String::from("bold"), - }; - ui.text_edit_singleline(&mut name); - font.family = egui::FontFamily::Name(name.into()); - } else { - font.family = if family == 0 { - egui::FontFamily::Proportional - } else { - egui::FontFamily::Monospace - }; - } - }); -} - -fn make_tree() -> (PanelTree, PaneId) { - let mut tree = PanelTree::new(vec![ - Tab::new("editor", TabId::Editor).with_leading_visual(">"), - Tab::new("search", TabId::Search).with_leading_visual("?"), - ]); - - tree.split_leaf( - 0, - SplitDir::Horizontal, - Tab::new("files", TabId::Files).with_leading_visual("#"), - ChildSide::First, - ); - - let editor_pane = tree - .find_pane_containing(&TabId::Editor) - .expect("editor pane should exist"); - let terminal_pane = tree - .pane_mut(editor_pane) - .expect("editor pane should exist") - .split( - SplitDir::Vertical, - Tab::new("terminal", TabId::Terminal).with_leading_visual("$"), - ChildSide::Second, - ); - - if let Some(mut pane) = tree.pane_mut(editor_pane) { - pane.push_tab(Tab::new("preview", TabId::Preview).with_leading_visual("*")); - } - - (tree, terminal_pane) -} diff --git a/patches/grimdock/examples/demo.rs b/patches/grimdock/examples/demo.rs deleted file mode 100644 index 6fb9a65..0000000 --- a/patches/grimdock/examples/demo.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! Runnable demo: `cargo run --example demo` -//! -//! Opens a window with a pre-built split layout. Tabs can be dragged between -//! panes, panes can be resized by dragging the handle between them. - -use eframe::egui; -use grimdock::{ - AddTabEntry, ChildSide, DropPolicy, Node, PaneBuilder, PaneStyleOverride, PanelContext, - PanelStyle, PanelTree, SplitDir, Tab, TabStyleOverride, -}; - -fn main() -> eframe::Result<()> { - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("grimdock demo") - .with_inner_size([1024.0, 680.0]), - ..Default::default() - }; - eframe::run_native("grimdock demo", options, Box::new(|_cc| Ok(Box::new(App::new())))) -} - -/// A tab identifier. Using a simple enum so each variant renders unique content. -#[derive(Clone, PartialEq, Eq, Debug)] -enum TabId { - Editor(u32), - FileTree, - Terminal, - Search, - Problems, - Output, -} - -impl std::fmt::Display for TabId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TabId::Editor(n) => write!(f, "editor_{n}"), - TabId::FileTree => write!(f, "file_tree"), - TabId::Terminal => write!(f, "terminal"), - TabId::Search => write!(f, "search"), - TabId::Problems => write!(f, "problems"), - TabId::Output => write!(f, "output"), - } - } -} - -struct App { - tree: PanelTree, - /// Counter that ticks up each frame in the terminal pane. - tick: u64, -} - -impl App { - fn new() -> Self { - // Build an initial layout: - // - // ┌───────────┬──────────────────┐ - // │ File Tree │ Editor 1 │ - // │ ├──────────────────┤ - // │ │ Terminal │ Output│ - // └───────────┴──────────────────┘ - // - // We do this by starting with a single pane and splitting. - - let mut tree = PanelTree::new(vec![ - Tab::new("editor_1", TabId::Editor(1)) - .with_leading_visual(">") - .with_style_override(TabStyleOverride { - active_bg: Some(egui::Color32::from_rgb(62, 73, 50)), - inactive_bg: Some(egui::Color32::from_rgb(44, 52, 38)), - hovered_bg: Some(egui::Color32::from_rgb(55, 65, 45)), - text_color: Some(egui::Color32::from_rgb(221, 231, 200)), - accent_color: Some(egui::Color32::from_rgb(164, 196, 92)), - icon_color: None, - }), - Tab::new("editor_2", TabId::Editor(2)).with_leading_visual("+"), - ]); - - // Split root (editors) horizontally: file tree on the left (First side). - let _file_tree_pane = tree.split_leaf_with( - 0, - SplitDir::Horizontal, - PaneBuilder::new(Tab::new("file_tree", TabId::FileTree).with_leading_visual("#")).with_options( - grimdock::PaneOptions { - style_override: Some(PaneStyleOverride { - header_bg: Some(egui::Color32::from_rgb(27, 47, 44)), - content_bg: Some(egui::Color32::from_rgb(21, 36, 34)), - border_color: Some(egui::Color32::from_rgb(52, 90, 84)), - accent_color: Some(egui::Color32::from_rgb(85, 188, 162)), - }), - drop_policy: DropPolicy::merge_only(), - ..Default::default() - }, - ), - ChildSide::First, - ); - // Node 0 is now Horizontal split. - // Node 1 (left/First): FileTree - // Node 2 (right/Second): editors - - // Split the right pane (editors, node 2) vertically: bottom gets terminal. - let editors_pane = tree - .find_pane_containing(&TabId::Editor(1)) - .expect("editor pane should exist after initial split"); - let bottom_pane = { - let mut pane = tree.pane_mut(editors_pane).expect("editor pane should exist"); - pane.split_with( - SplitDir::Vertical, - PaneBuilder::new(Tab::new("terminal", TabId::Terminal).with_leading_visual("$")), - ChildSide::Second, - ) - }; - // Node 2 is now Vertical split. - // Node 5 (top/First): editors - // Node 6 (bottom/Second): terminal - - // Add extra tabs to the bottom bar. - // Nodes are: 5 = editors top pane, 6 = bottom pane (terminal). - let mut bottom = tree.pane_mut(bottom_pane).expect("bottom pane should exist"); - bottom.push_tab(Tab::new("output", TabId::Output).with_leading_visual("!")); - bottom.push_tab(Tab::new("problems", TabId::Problems).with_leading_visual("x")); - bottom.push_tab(Tab::new("search", TabId::Search).with_leading_visual("?")); - - // Narrow the left (file tree) column — set the split ratio. - if let Node::Split { ratio, .. } = tree.node_mut(0) { - *ratio = 0.22; - } - - // Make the bottom strip shorter. - if let Node::Split { ratio, .. } = tree.node_mut(2) { - *ratio = 0.65; - } - - Self { - tree, - tick: 0, - } - } -} - -impl eframe::App for App { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.tick += 1; - - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ctx, |ui| { - let tree = &mut self.tree; - let mut style = PanelStyle::from_egui_style(ui.style().as_ref()); - style.content_inset = 4.0; - style.tabs.rounding = egui::CornerRadius::same(5); - style.header.button.rounding = egui::CornerRadius::same(5); - let tick = self.tick; - let add_tab_entries = vec![ - AddTabEntry::new( - "Editor 3", - Tab::new("editor_3", TabId::Editor(3)) - .with_leading_visual("*") - .with_closable(true), - ), - AddTabEntry::new( - "Terminal", - Tab::new("terminal", TabId::Terminal) - .with_leading_visual("$") - .with_closable(true), - ), - AddTabEntry::new( - "Search", - Tab::new("search", TabId::Search) - .with_leading_visual("?") - .with_closable(true), - ), - AddTabEntry::new( - "Problems", - Tab::new("problems", TabId::Problems) - .with_leading_visual("x") - .with_closable(true), - ), - ]; - - PanelContext::new(ui, tree, &style) - .with_add_tab_entries(&add_tab_entries) - .show(|ui, tab_id| { - render_tab(ui, tab_id, tick); - }); - }); - - // Repaint continuously so the terminal counter updates. - ctx.request_repaint(); - } -} - -fn render_tab(ui: &mut egui::Ui, tab_id: &TabId, tick: u64) { - egui::ScrollArea::vertical().show(ui, |ui| { - match tab_id { - TabId::Editor(n) => { - ui.add_space(8.0); - ui.label( - egui::RichText::new(format!("// Editor {n}\n\nfn main() {{\n println!(\"Hello, world!\");\n}}")) - .monospace() - .size(13.0), - ); - ui.add_space(8.0); - ui.label("Drag a tab to a different pane to rearrange."); - ui.label("Drag the handle between panes to resize."); - } - TabId::FileTree => { - ui.add_space(6.0); - for item in &["src/", " lib.rs", " tree.rs", " layout.rs", " header.rs", " dnd.rs", "Cargo.toml"] { - ui.label(egui::RichText::new(*item).monospace().size(12.0)); - } - } - TabId::Terminal => { - ui.add_space(4.0); - ui.label(egui::RichText::new(format!("$ frame {tick}")).monospace().size(12.0)); - ui.label(egui::RichText::new("grimdock running ✓").monospace().color(egui::Color32::from_rgb(100, 200, 120)).size(12.0)); - } - TabId::Search => { - ui.add_space(8.0); - ui.label("Search"); - ui.text_edit_singleline(&mut String::new()); - } - TabId::Problems => { - ui.add_space(8.0); - ui.label(egui::RichText::new("No problems detected.").color(egui::Color32::from_rgb(100, 200, 120))); - } - TabId::Output => { - ui.add_space(4.0); - ui.label(egui::RichText::new("[INFO] Build complete.").monospace().size(12.0)); - } - } - }); -} diff --git a/patches/grimdock/src/content.rs b/patches/grimdock/src/content.rs deleted file mode 100644 index e940f94..0000000 --- a/patches/grimdock/src/content.rs +++ /dev/null @@ -1,50 +0,0 @@ -use egui::{Rect, Ui}; - -use crate::{ - ids::pane_content_id, - style::PanelStyle, - tree::{Node, PanelTree}, -}; - -/// Run the content pass: invoke the caller's render callback for each leaf's -/// focused tab inside a clipped child UI. -pub(crate) fn content_pass( - ui: &mut Ui, - tree: &PanelTree, - leaf_rects: &[(usize, Rect)], - style: &PanelStyle, - mut render: impl FnMut(&mut Ui, &T), -) { - for &(leaf_idx, content_rect) in leaf_rects { - if tree.is_collapsed(leaf_idx) { - continue; - } - let (pane_id, tab_id, focused_idx) = match tree.node(leaf_idx) { - Node::Leaf { - pane, - tabs, - focused, - .. - } => { - if let Some(tab) = tabs.get(*focused) { - (*pane, tab.id.clone(), *focused) - } else { - continue; - } - } - _ => continue, - }; - - // Use a stable ID per leaf + focused tab so widget state (scroll - // position, text input, etc.) survives tab switches in other panes. - let content_id = pane_content_id(pane_id).with(focused_idx); - let inset_rect = content_rect.shrink(style.content_inset); - - ui.push_id(content_id, |ui| { - ui.scope_builder(egui::UiBuilder::new().max_rect(inset_rect), |ui| { - ui.set_clip_rect(inset_rect); - render(ui, &tab_id); - }); - }); - } -} diff --git a/patches/grimdock/src/dnd.rs b/patches/grimdock/src/dnd.rs deleted file mode 100644 index d91c743..0000000 --- a/patches/grimdock/src/dnd.rs +++ /dev/null @@ -1,746 +0,0 @@ -use egui::{DragAndDrop, LayerId, Order, Pos2, Rect, Ui}; - -use crate::{ - style::PanelStyle, - tab::Tab, - tree::{ChildSide, Node, PaneId, PaneOptions, PaneRole, PanelTree, SplitDir}, -}; - -/// Payload carried through egui's drag-and-drop system. -#[derive(Clone, Debug)] -pub(crate) struct DragPayload { - /// Stable pane identifier of the leaf that owns the dragged tab. - pub src_pane: PaneId, - /// Position of the dragged tab within the source leaf's tab list. - pub tab_pos: usize, -} - -/// Determines where relative to a pane's centre the cursor is sitting. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum DropZone { - Center, - Left, - Right, - Top, - Bottom, -} - -fn drop_zone_allowed(options: PaneOptions, zone: DropZone) -> bool { - if options.lock_layout { - return false; - } - - match zone { - DropZone::Center => options.drop_policy.allow_merge, - DropZone::Left => options.drop_policy.allows_split(SplitDir::Horizontal, ChildSide::First), - DropZone::Right => { - options - .drop_policy - .allows_split(SplitDir::Horizontal, ChildSide::Second) - } - DropZone::Top => options.drop_policy.allows_split(SplitDir::Vertical, ChildSide::First), - DropZone::Bottom => { - options - .drop_policy - .allows_split(SplitDir::Vertical, ChildSide::Second) - } - } -} - -fn tab_allows_target_pane( - tab: &Tab, - pane_id: PaneId, - role: Option, -) -> bool { - tab.drop_policy.allows_target(pane_id, role) -} - -fn header_tab_drop_index( - tree: &PanelTree, - leaf_idx: usize, - header_rect: Rect, - cursor_pos: Pos2, -) -> Option { - let tab_count = match tree.node(leaf_idx) { - Node::Leaf { tabs, .. } => tabs.len(), - _ => return None, - }; - if tab_count == 0 { - return None; - } - let tab_width = (header_rect.width() / tab_count.max(1) as f32) - .min(120.0) - .max(40.0); - let relative_x = (cursor_pos.x - header_rect.min.x).clamp(0.0, header_rect.width()); - let raw_index = (relative_x / tab_width).floor() as usize; - Some(raw_index.min(tab_count.saturating_sub(1))) -} - -fn header_tab_rect( - tree: &PanelTree, - leaf_idx: usize, - header_rect: Rect, - tab_pos: usize, -) -> Option { - let tab_count = match tree.node(leaf_idx) { - Node::Leaf { tabs, .. } => tabs.len(), - _ => return None, - }; - if tab_pos >= tab_count { - return None; - } - let tab_width = (header_rect.width() / tab_count.max(1) as f32) - .min(120.0) - .max(40.0); - Some(Rect::from_min_size( - egui::pos2( - header_rect.min.x + tab_pos as f32 * tab_width, - header_rect.min.y, - ), - egui::vec2(tab_width, header_rect.height()), - )) -} - -fn root_edge_drop_zone(cursor: Pos2, root_rect: Rect) -> Option { - if !root_rect.contains(cursor) { - return None; - } - let edge_band = 20.0_f32.min(root_rect.width() * 0.15).min(root_rect.height() * 0.15); - let left = cursor.x - root_rect.min.x; - let right = root_rect.max.x - cursor.x; - let top = cursor.y - root_rect.min.y; - let bottom = root_rect.max.y - cursor.y; - - let mut best: Option<(f32, DropZone)> = None; - for (dist, zone) in [ - (left, DropZone::Left), - (right, DropZone::Right), - (top, DropZone::Top), - (bottom, DropZone::Bottom), - ] { - if dist <= edge_band { - match best { - Some((best_dist, _)) if dist >= best_dist => {} - _ => best = Some((dist, zone)), - } - } - } - best.map(|(_, zone)| zone) -} - -impl DropZone { - /// Classify `cursor` against the five zones of `rect`. - /// - /// The central 20 % of each axis forms the `Center` zone. Outside that, - /// the remaining area is divided into four triangular quadrants by the two - /// diagonals of the rectangle. - pub(crate) fn classify(cursor: Pos2, rect: Rect) -> Self { - let center = rect.center(); - let dx = (cursor.x - center.x) / rect.width().max(1.0); - let dy = (cursor.y - center.y) / rect.height().max(1.0); - - // Central region: both axes within ±10 % of the centre. - if dx.abs() < 0.1 && dy.abs() < 0.1 { - return DropZone::Center; - } - - // Outside the centre: pick the dominant axis. - if dx.abs() > dy.abs() { - if dx > 0.0 { - DropZone::Right - } else { - DropZone::Left - } - } else if dy > 0.0 { - DropZone::Bottom - } else { - DropZone::Top - } - } - - /// Return the preview rectangle for this drop zone within `pane_rect`. - pub(crate) fn preview_rect(self, pane_rect: Rect) -> Rect { - match self { - DropZone::Center => pane_rect, - DropZone::Left => Rect::from_min_max( - pane_rect.min, - egui::pos2(pane_rect.min.x + pane_rect.width() * 0.25, pane_rect.max.y), - ), - DropZone::Right => Rect::from_min_max( - egui::pos2(pane_rect.max.x - pane_rect.width() * 0.25, pane_rect.min.y), - pane_rect.max, - ), - DropZone::Top => Rect::from_min_max( - pane_rect.min, - egui::pos2(pane_rect.max.x, pane_rect.min.y + pane_rect.height() * 0.25), - ), - DropZone::Bottom => Rect::from_min_max( - egui::pos2(pane_rect.min.x, pane_rect.max.y - pane_rect.height() * 0.25), - pane_rect.max, - ), - } - } -} - -/// Run the drag-and-drop resolution pass. -/// -/// Draws the drop-target overlay while a drag is live, and mutates the tree -/// on pointer release. -pub(crate) fn dnd_pass( - ui: &mut Ui, - tree: &mut PanelTree, - style: &PanelStyle, -) { - let ctx = ui.ctx().clone(); - - // Is a drag live? - let Some(live_payload) = DragAndDrop::payload::(&ctx) else { - return; - }; - - let cursor_pos = match ctx.pointer_hover_pos() { - Some(p) => p, - None => return, - }; - - // Find which leaf the cursor is over. - let target_leaf = tree.leaf_indices().find(|&idx| { - if let Node::Leaf { rect, .. } = tree.node(idx) { - rect.contains(cursor_pos) - } else { - false - } - }); - - if let Some(target_leaf) = target_leaf { - let root_rect = match tree.node(0) { - Node::Split { rect, .. } => *rect, - Node::Leaf { rect, .. } => *rect, - Node::Empty => return, - }; - let pane_rect = match tree.node(target_leaf) { - Node::Leaf { rect, .. } => *rect, - _ => return, - }; - let Some(options) = tree.pane_options(target_leaf) else { - return; - }; - - // Cursor in the header bar → always merge (Center). Without this, - // a cursor sitting on a tab button lands near the top of the pane - // rect and gets classified as DropZone::Top, causing an unintended - // split when the user just wanted to drop onto that pane's tab bar. - let header_visible = tree.header_visible(target_leaf); - let header_rect = egui::Rect::from_min_size( - pane_rect.min, - egui::vec2(pane_rect.width(), style.header_height), - ); - let zone = if header_visible && header_rect.contains(cursor_pos) { - DropZone::Center - } else { - // Classify relative to the content area only (below the header). - let header_height = if header_visible { - style.header_height - } else { - 0.0 - }; - let content_rect = egui::Rect::from_min_max( - egui::pos2(pane_rect.min.x, pane_rect.min.y + header_height), - pane_rect.max, - ); - DropZone::classify(cursor_pos, content_rect) - }; - let Some(target_pane) = tree.pane_id_at(target_leaf) else { - return; - }; - let target_role = match tree.node(target_leaf) { - Node::Leaf { options, .. } => options.role, - _ => None, - }; - let dragged_tab = tree - .pane_index(live_payload.src_pane) - .and_then(|src_leaf| match &tree.nodes[src_leaf] { - Node::Leaf { tabs, .. } => tabs.get(live_payload.tab_pos), - _ => None, - }); - if !dragged_tab - .map(|tab| tab_allows_target_pane(tab, target_pane, target_role)) - .unwrap_or(false) - { - return; - } - - let mut zone_allowed = drop_zone_allowed(options, zone); - let root_zone = root_edge_drop_zone(cursor_pos, root_rect) - .filter(|&root_zone| drop_zone_allowed(options, root_zone)); - let effective_zone = root_zone.unwrap_or(zone); - zone_allowed = root_zone.is_some() || zone_allowed; - if !zone_allowed { - return; - } - let reorder_preview = if target_pane == live_payload.src_pane - && effective_zone == DropZone::Center - && header_visible - && header_rect.contains(cursor_pos) - && !options.lock_layout - && options.allow_tab_reorder - { - header_tab_drop_index(tree, target_leaf, header_rect, cursor_pos) - .and_then(|target_pos| header_tab_rect(tree, target_leaf, header_rect, target_pos)) - } else { - None - }; - let preview = if let Some(reorder_rect) = reorder_preview { - reorder_rect - } else if root_zone.is_some() { - effective_zone.preview_rect(root_rect) - } else { - effective_zone.preview_rect(pane_rect) - }; - - // Draw translucent overlay on a top-level layer. - let overlay_painter = ctx.layer_painter(LayerId::new( - Order::Tooltip, - egui::Id::new("grimdock_dnd_overlay"), - )); - overlay_painter.rect_filled(preview, style.pane_rounding, style.overlay.fill); - overlay_painter.rect_stroke( - preview, - style.pane_rounding, - style.overlay.stroke, - egui::StrokeKind::Inside, - ); - - // On release, apply the drop. - let released = ctx.input(|i| i.pointer.any_released()); - if released { - // Take the payload — this clears the drag state. - if let Some(payload) = DragAndDrop::take_payload::(&ctx) { - let Some(src_leaf) = tree.pane_index(payload.src_pane) else { - return; - }; - let tab_pos = payload.tab_pos; - - let draggable = match &tree.nodes[src_leaf] { - Node::Leaf { tabs, options, .. } => tabs - .get(tab_pos) - .map(|tab| { - tab.draggable - && tab_allows_target_pane(tab, target_pane, target_role) - && !options.lock_layout - && (options.allow_tab_drag_out || options.allow_tab_reorder) - }), - _ => None, - }; - if draggable != Some(true) { - return; - } - - // Retrieve the tab before mutating the tree. - let tab = match &tree.nodes[src_leaf] { - Node::Leaf { tabs, .. } => tabs.get(tab_pos).cloned(), - _ => None, - }; - - if let Some(tab) = tab { - match effective_zone { - DropZone::Center => { - if src_leaf == target_leaf { - if header_visible && header_rect.contains(cursor_pos) && options.allow_tab_reorder { - if let Some(target_pos) = - header_tab_drop_index(tree, target_leaf, header_rect, cursor_pos) - { - let _ = tree.move_tab_within_leaf(target_leaf, tab_pos, target_pos); - } - } - } else { - tree.merge_tab_into(src_leaf, tab_pos, target_leaf); - tree.collapse_empty_leaf(src_leaf); - } - } - DropZone::Left => { - if root_zone.is_some() { - apply_root_directional_drop( - tree, - src_leaf, - tab_pos, - tab, - SplitDir::Horizontal, - ChildSide::First, - ); - } else { - apply_directional_drop( - tree, - src_leaf, - tab_pos, - target_leaf, - tab, - SplitDir::Horizontal, - ChildSide::First, - ); - } - } - DropZone::Right => { - if root_zone.is_some() { - apply_root_directional_drop( - tree, - src_leaf, - tab_pos, - tab, - SplitDir::Horizontal, - ChildSide::Second, - ); - } else { - apply_directional_drop( - tree, - src_leaf, - tab_pos, - target_leaf, - tab, - SplitDir::Horizontal, - ChildSide::Second, - ); - } - } - DropZone::Top => { - if root_zone.is_some() { - apply_root_directional_drop( - tree, - src_leaf, - tab_pos, - tab, - SplitDir::Vertical, - ChildSide::First, - ); - } else { - apply_directional_drop( - tree, - src_leaf, - tab_pos, - target_leaf, - tab, - SplitDir::Vertical, - ChildSide::First, - ); - } - } - DropZone::Bottom => { - if root_zone.is_some() { - apply_root_directional_drop( - tree, - src_leaf, - tab_pos, - tab, - SplitDir::Vertical, - ChildSide::Second, - ); - } else { - apply_directional_drop( - tree, - src_leaf, - tab_pos, - target_leaf, - tab, - SplitDir::Vertical, - ChildSide::Second, - ); - } - } - } - } - } - } - } -} - -/// Remove the tab from its source and split the target pane to host it. -/// -/// **Operation order**: split target first (no indices change), then remove -/// from source, then collapse if source is empty. Doing it in this order -/// keeps `src_leaf` at a stable index throughout — collapsing before -/// splitting would shift the target's index in any layout where target is -/// inside the subtree that gets promoted. -fn apply_directional_drop( - tree: &mut PanelTree, - src_leaf: usize, - tab_pos: usize, - target_leaf: usize, - tab: crate::tab::Tab, - dir: SplitDir, - side: ChildSide, -) { - if src_leaf == target_leaf { - // Dragging a tab onto the edge of its own pane. - // split_leaf keeps the existing tabs in one child and places `tab` in - // the other. We need to remove the original occurrence of the tab - // from the "existing-tabs" child afterwards. - tree.split_leaf(target_leaf, dir, tab, side); - - // After the split, src_leaf is now a Split node. The child that - // received the original tabs is the opposite of `side`. - let old_child = match side { - ChildSide::First => PanelTree::::right_child(target_leaf), - ChildSide::Second => PanelTree::::left_child(target_leaf), - }; - if let Node::Leaf { tabs, focused, .. } = &mut tree.nodes[old_child] { - if tab_pos < tabs.len() { - tabs.remove(tab_pos); - if !tabs.is_empty() && *focused >= tabs.len() { - *focused = tabs.len() - 1; - } - } - } - // If the old child is now empty (sole-tab pane split onto its own - // edge), collapse it so the layout stays consistent. - tree.collapse_empty_leaf(old_child); - return; - } - - // Step 1: split the target. `split_leaf` only adds child nodes — it - // never moves any existing nodes — so `src_leaf` keeps its index. - tree.split_leaf(target_leaf, dir, tab, side); - - // Step 2: remove the tab from the source. - match &mut tree.nodes[src_leaf] { - Node::Leaf { tabs, focused, .. } => { - if tab_pos < tabs.len() { - tabs.remove(tab_pos); - if !tabs.is_empty() && *focused >= tabs.len() { - *focused = tabs.len() - 1; - } - } - } - _ => return, - } - - // Step 3: collapse the source if it is now empty. - let src_empty = matches!(&tree.nodes[src_leaf], Node::Leaf { tabs, .. } if tabs.is_empty()); - if src_empty { - tree.collapse_empty_leaf(src_leaf); - } -} - -fn apply_root_directional_drop( - tree: &mut PanelTree, - src_leaf: usize, - tab_pos: usize, - tab: crate::tab::Tab, - dir: SplitDir, - side: ChildSide, -) { - tree.wrap_root_with_split(dir, tab, side); - - // After wrap_root_with_split the old subtree (rooted at old index 0) is - // placed at `existing_child` in the new tree. copy_subtree_with_offset - // maps old index `i` → `existing_child * 2^depth(i) + i`, where - // `depth(i) = floor(log2(i + 1))`. The previously used formulas - // `2*src_leaf+2` / `2*src_leaf+1` were only correct for the shallowest - // nodes and caused an out-of-bounds panic for deeper layouts. - let existing_child = match side { - ChildSide::First => PanelTree::::right_child(0), - ChildSide::Second => PanelTree::::left_child(0), - }; - let depth = usize::BITS - (src_leaf + 1).leading_zeros() - 1; - let shifted_src_leaf = existing_child * (1 << depth) + src_leaf; - - match &mut tree.nodes[shifted_src_leaf] { - Node::Leaf { tabs, focused, .. } => { - if tab_pos < tabs.len() { - tabs.remove(tab_pos); - if !tabs.is_empty() && *focused >= tabs.len() { - *focused = tabs.len() - 1; - } - } - } - _ => return, - } - - let src_empty = - matches!(&tree.nodes[shifted_src_leaf], Node::Leaf { tabs, .. } if tabs.is_empty()); - if src_empty { - tree.collapse_empty_leaf(shifted_src_leaf); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{DropPolicy, HeaderVisibility, PanelTree, Tab, TabDropPolicy}; - - #[test] - fn root_edge_drop_zone_prefers_nearest_edge() { - let root = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(200.0, 100.0)); - - assert_eq!( - root_edge_drop_zone(egui::pos2(4.0, 50.0), root), - Some(DropZone::Left) - ); - assert_eq!( - root_edge_drop_zone(egui::pos2(196.0, 50.0), root), - Some(DropZone::Right) - ); - assert_eq!( - root_edge_drop_zone(egui::pos2(100.0, 4.0), root), - Some(DropZone::Top) - ); - assert_eq!( - root_edge_drop_zone(egui::pos2(100.0, 96.0), root), - Some(DropZone::Bottom) - ); - assert_eq!(root_edge_drop_zone(egui::pos2(100.0, 50.0), root), None); - } - - #[test] - fn drop_zone_allowed_respects_non_droppable_and_locked_panes() { - let mut options = crate::tree::PaneOptions::default(); - options.drop_policy = DropPolicy::none(); - assert!(!drop_zone_allowed(options, DropZone::Center)); - assert!(!drop_zone_allowed(options, DropZone::Left)); - - options.drop_policy = DropPolicy::all(); - options.lock_layout = true; - assert!(!drop_zone_allowed(options, DropZone::Center)); - assert!(!drop_zone_allowed(options, DropZone::Right)); - } - - #[test] - fn apply_root_directional_drop_wraps_root_without_losing_tabs() { - let mut tree = PanelTree::new(vec![Tab::new("A", "a")]); - tree.insert_tab_into_leaf(0, Tab::new("B", "b")); - - let tab = match tree.node(0) { - Node::Leaf { tabs, .. } => tabs[0].clone(), - _ => panic!("expected root leaf"), - }; - - apply_root_directional_drop( - &mut tree, - 0, - 0, - tab, - SplitDir::Horizontal, - ChildSide::First, - ); - - assert!(matches!(tree.node(0), Node::Split { .. })); - assert!(tree.find_tab(&"a").is_some()); - assert!(tree.find_tab(&"b").is_some()); - } - - /// Regression: dragging a tab from a deep leaf (depth ≥ 2) onto the root - /// edge used to compute the shifted_src_leaf index as `right_child(src)` - /// / `left_child(src)`, which gives the wrong node and panics with - /// "index out of bounds" once the tree is tall enough. - #[test] - fn apply_root_directional_drop_deep_layout_no_panic() { - // Build a tree: root → [A | [B | C]] - // After two split_leaf calls the layout is: - // 0 = Split - // 1 = Leaf [A] (left child of root) - // 2 = Split - // 5 = Leaf [B] (left child of node 2) - // 6 = Leaf [C] (right child of node 2) ← src_leaf = 6 - let mut tree = PanelTree::new(vec![Tab::new("A", "a")]); - tree.split_leaf(0, SplitDir::Horizontal, Tab::new("B", "b"), ChildSide::Second); - // node 0 = Split, node 1 = Leaf [A], node 2 = Leaf [B] - tree.split_leaf(2, SplitDir::Horizontal, Tab::new("C", "c"), ChildSide::Second); - // node 0 = Split, node 1 = Leaf [A], node 2 = Split, node 5 = Leaf [B], node 6 = Leaf [C] - - let src_leaf = 6; - let tab = match tree.node(src_leaf) { - Node::Leaf { tabs, .. } => tabs[0].clone(), - _ => panic!("expected leaf at index {src_leaf}"), - }; - - // This used to panic with "index out of bounds" because shifted_src_leaf - // was computed as left_child(6) = 13 but the tree only had 11 nodes. - apply_root_directional_drop( - &mut tree, - src_leaf, - 0, - tab, - SplitDir::Horizontal, - ChildSide::Second, - ); - - assert!(matches!(tree.node(0), Node::Split { .. })); - assert!(tree.find_tab(&"a").is_some()); - assert!(tree.find_tab(&"b").is_some()); - // "c" was the dragged tab so it should be in the new root-edge pane - assert!(tree.find_tab(&"c").is_some()); - } - - #[test] - fn header_drop_index_handles_single_hidden_header_layouts() { - let mut tree = PanelTree::new(vec![Tab::new("A", "a"), Tab::new("B", "b")]); - let mut options = tree.pane_options(0).unwrap(); - options.header_visibility = HeaderVisibility::WhenMultipleTabs; - tree.set_pane_options(0, options); - - let rect = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(100.0, 20.0)); - assert_eq!( - header_tab_drop_index(&tree, 0, rect, egui::pos2(5.0, 10.0)), - Some(0) - ); - assert_eq!( - header_tab_drop_index(&tree, 0, rect, egui::pos2(95.0, 10.0)), - Some(1) - ); - } - - #[test] - fn tab_drop_policy_blocks_non_whitelisted_target_panes() { - let mut tree = PanelTree::new(vec![Tab::new("A", "a")]); - let right_pane = tree.split_leaf( - 0, - SplitDir::Horizontal, - Tab::new("B", "b").with_drop_policy(TabDropPolicy { - locked_to_pane: None, - locked_to_role: None, - allowed_panes: Some(vec![PaneId::from_raw(1)]), - allowed_roles: None, - blocked_panes: Vec::new(), - blocked_roles: Vec::new(), - }), - ChildSide::Second, - ); - - let right_idx = tree.pane_index(right_pane).expect("right pane should exist"); - let tab = match tree.node(right_idx) { - Node::Leaf { tabs, .. } => tabs.first().expect("right pane should hold a tab"), - other => panic!("expected right leaf, got {:?}", other), - }; - - assert!(!tab_allows_target_pane(tab, right_pane, None)); - } - - #[test] - fn tab_drop_policy_honors_blocked_pane_list() { - let policy = TabDropPolicy { - locked_to_pane: None, - locked_to_role: None, - allowed_panes: None, - allowed_roles: None, - blocked_panes: vec![PaneId::from_raw(7)], - blocked_roles: Vec::new(), - }; - - assert!(policy.allows_target(PaneId::from_raw(3), None)); - assert!(!policy.allows_target(PaneId::from_raw(7), None)); - } - - #[test] - fn tab_drop_policy_honors_role_filters() { - let policy = TabDropPolicy { - locked_to_pane: None, - locked_to_role: Some(PaneRole::Terminal), - allowed_panes: None, - allowed_roles: None, - blocked_panes: Vec::new(), - blocked_roles: Vec::new(), - }; - - assert!(policy.allows_target(PaneId::from_raw(1), Some(PaneRole::Terminal))); - assert!(!policy.allows_target(PaneId::from_raw(1), Some(PaneRole::Editor))); - } -} diff --git a/patches/grimdock/src/header.rs b/patches/grimdock/src/header.rs deleted file mode 100644 index 953c8cc..0000000 --- a/patches/grimdock/src/header.rs +++ /dev/null @@ -1,1194 +0,0 @@ -use egui::{Align2, Popup, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, vec2}; - -use crate::{ - AddTabEntry, OpenBehavior, PaneActionInvocation, PaneAnchor, PaneMenuAction, - dnd::DragPayload, - ids::{tab_button_id, tab_close_button_id}, - style::PanelStyle, - tab::TabIcon, - tree::{ChildSide, Node, PaneId, PanelTree, SplitDir}, -}; - -#[derive(Clone)] -enum PendingAction { - Close(usize), - CloseOthers(usize), - CloseAll, - RemovePane, - MovePane(PaneAnchor), - AddTab(AddTabEntry), - SplitWith(AddTabEntry, SplitDir, ChildSide), - EmitPaneAction(PaneActionInvocation), -} - -#[derive(Clone, Copy)] -enum HeaderButtonIcon { - Add, - Overflow, - PaneMenu, - Close, - Collapse { collapsed: bool }, - DockBack, -} - -/// Run the header pass over all leaf nodes. -pub(crate) fn header_pass( - ui: &mut Ui, - tree: &mut PanelTree, - leaf_rects: &[(usize, Rect)], - style: &PanelStyle, - add_tab_entries: &[AddTabEntry], - add_tab_provider: Option<&dyn Fn(PaneId, &PanelTree) -> Vec>>, - pane_menu_actions: &[PaneMenuAction], - pane_menu_provider: Option<&dyn Fn(PaneId, &PanelTree) -> Vec>, - closed_tabs: &mut Vec, - pane_actions: &mut Vec, -) where - T: PartialEq, -{ - for &(leaf_idx, content_rect) in leaf_rects { - let full_rect = match tree.node(leaf_idx) { - Node::Leaf { rect, .. } => *rect, - _ => continue, - }; - if full_rect.width() <= 0.0 || full_rect.height() <= 0.0 { - continue; - } - - let pane_style_override = match tree.node(leaf_idx) { - Node::Leaf { options, .. } => options.style_override, - _ => None, - }; - let pane_border = style.pane_border_color(pane_style_override); - let pane_content_bg = style.pane_content_bg(pane_style_override); - let pane_header_bg = style.pane_header_bg(pane_style_override); - - if !tree.header_visible(leaf_idx) { - if matches!(tree.node(leaf_idx), Node::Leaf { options, .. } if options.paint_content_bg) { - ui.painter() - .rect_filled(content_rect, style.pane_rounding, pane_content_bg); - } - ui.painter().rect_stroke( - content_rect, - style.pane_rounding, - Stroke::new(1.0, pane_border), - StrokeKind::Inside, - ); - continue; - } - - let full_rect = match tree.node(leaf_idx) { - Node::Leaf { rect, .. } => *rect, - _ => continue, - }; - - let header_rect = Rect::from_min_size(full_rect.min, vec2(full_rect.width(), style.header_height)); - - ui.painter() - .rect_filled(header_rect, style.pane_rounding, pane_header_bg); - - if matches!(tree.node(leaf_idx), Node::Leaf { options, .. } if options.paint_content_bg) { - ui.painter() - .rect_filled(content_rect, style.pane_rounding, pane_content_bg); - } - - ui.painter().rect_stroke( - content_rect, - style.pane_rounding, - Stroke::new(1.0, pane_border), - StrokeKind::Inside, - ); - - let pane_entries = if let Some(Node::Leaf { pane, .. }) = Some(tree.node(leaf_idx)) { - if let Some(provider) = add_tab_provider { - provider(*pane, tree) - } else { - add_tab_entries.to_vec() - } - } else { - add_tab_entries.to_vec() - }; - - let pane_menu_entries = if let Some(Node::Leaf { pane, .. }) = Some(tree.node(leaf_idx)) { - if let Some(provider) = pane_menu_provider { - provider(*pane, tree) - } else { - pane_menu_actions.to_vec() - } - } else { - pane_menu_actions.to_vec() - }; - - render_tabs( - ui, - tree, - leaf_idx, - header_rect, - style, - &pane_entries, - &pane_menu_entries, - closed_tabs, - pane_actions, - ); - } -} - -fn render_tabs( - ui: &mut Ui, - tree: &mut PanelTree, - leaf_idx: usize, - header_rect: Rect, - style: &PanelStyle, - add_tab_entries: &[AddTabEntry], - pane_menu_actions: &[PaneMenuAction], - closed_tabs: &mut Vec, - pane_actions: &mut Vec, -) where - T: PartialEq, -{ - let (pane_id, tab_count, focused, collapse_allowed, collapsed, pane_style_override, lock_layout) = - match tree.node(leaf_idx) { - Node::Leaf { - pane, - tabs, - focused, - options, - collapsed, - .. - } => ( - *pane, - tabs.len(), - *focused, - options.allow_collapse, - *collapsed, - options.style_override, - options.lock_layout, - ), - _ => return, - }; - - let action_button_width = 18.0; - let action_button_padding = 4.0; - let mut right_edge = header_rect.max.x; - - // Add a small dock/return icon at the far right so floated panels can be - // re-docked without using the window body. Hidden for the locked Tools pane - // and when there is no float action. - let dock_rect = if pane_id != PaneId::from_raw(1) - && pane_menu_actions.iter().any(|a| a.id == "float") - { - let rect = Rect::from_center_size( - egui::pos2( - right_edge - action_button_padding - action_button_width * 0.5, - header_rect.center().y, - ), - vec2(action_button_width, action_button_width), - ); - right_edge = rect.min.x - action_button_padding; - Some(rect) - } else { - None - }; - - let collapse_rect = if collapse_allowed { - let rect = Rect::from_center_size( - egui::pos2( - right_edge - action_button_padding - action_button_width * 0.5, - header_rect.center().y, - ), - vec2(action_button_width, action_button_width), - ); - right_edge = rect.min.x - action_button_padding; - Some(rect) - } else { - None - }; - - let pane_menu_rect = { - let rect = Rect::from_center_size( - egui::pos2( - right_edge - action_button_padding - action_button_width * 0.5, - header_rect.center().y, - ), - vec2(action_button_width, action_button_width), - ); - right_edge = rect.min.x - action_button_padding; - rect - }; - - let add_rect = if add_tab_entries.is_empty() { - None - } else { - let rect = Rect::from_center_size( - egui::pos2( - right_edge - action_button_padding - action_button_width * 0.5, - header_rect.center().y, - ), - vec2(action_button_width, action_button_width), - ); - right_edge = rect.min.x - action_button_padding; - Some(rect) - }; - - let padding_x = 8.0; - let leading_gap = 6.0; - let mut tabs_rect = Rect::from_min_max(header_rect.min, egui::pos2(right_edge, header_rect.max.y)); - - if collapsed { - // In collapsed mode the pane is reduced to a narrow strip. Render the - // focused tab's title or icon so the panel stays identifiable, with a - // tooltip if the text is truncated. - let focused_title = match tree.node(leaf_idx) { - Node::Leaf { tabs, focused, .. } => tabs.get(*focused).map(|t| (t.title.clone(), t.icon.clone())), - _ => None, - }; - - let mut title_left = tabs_rect.min.x + padding_x; - let text_color = style.tabs.inactive.text_color; - let mut tooltip: Option = None; - if let Some((title, icon)) = focused_title { - if let Some(icon) = icon { - let icon_tint = pane_style_override - .and_then(|o| o.accent_color) - .unwrap_or(text_color); - let icon_width = paint_tab_leading_icon( - ui, - &icon, - title_left, - header_rect, - icon_tint, - style, - ); - title_left += icon_width + leading_gap; - } - let title_text = title.clone(); - let text_max_x = collapse_rect.map_or(header_rect.max.x, |r| r.min.x) - padding_x; - let available = (text_max_x - title_left).max(0.0); - ui.painter().text( - egui::pos2(title_left, header_rect.center().y), - Align2::LEFT_CENTER, - &title_text, - style.typography.tab_title_font.clone(), - text_color, - ); - if title_is_truncated(ui, &title_text, available, style) { - tooltip = Some(title_text); - } - } - - if let Some(collapse_rect) = collapse_rect { - let collapse_response = ui.interact( - collapse_rect, - egui::Id::new("grimdock::tab_collapse").with(pane_id.into_raw()), - Sense::click(), - ); - paint_header_button( - ui, - collapse_rect, - &collapse_response, - style, - HeaderButtonIcon::Collapse { collapsed: true }, - ); - let collapse_response = if let Some(tip) = tooltip { - collapse_response.on_hover_text(tip) - } else { - collapse_response - }; - if collapse_response.clicked() { - let _ = tree.toggle_collapsed(leaf_idx); - } - } - return; - } - - let min_tab_width = 72.0; - let overflow_button_width = action_button_width + action_button_padding * 2.0; - let close_button_width = 16.0; - let close_button_padding = 4.0; - let tab_max_widths = tab_max_widths(tree, leaf_idx); - let (visible_indices, hidden_indices) = compute_visible_tabs( - tab_count, - focused, - tabs_rect.width(), - min_tab_width, - overflow_button_width, - ); - - let overflow_rect = if hidden_indices.is_empty() { - None - } else { - let rect = Rect::from_min_max( - egui::pos2(tabs_rect.max.x - overflow_button_width, tabs_rect.min.y), - tabs_rect.max, - ); - tabs_rect.max.x -= overflow_button_width; - Some(rect) - }; - - let visible_width = - (tabs_rect.width() - if hidden_indices.is_empty() { 0.0 } else { overflow_button_width }) - .max(0.0); - let tab_widths = compute_tab_widths(&visible_indices, visible_width, &tab_max_widths, min_tab_width); - - let mut pending_action: Option> = None; - let mut new_focused = focused; - let mut current_x = tabs_rect.min.x; - - for (slot, &tab_index) in visible_indices.iter().enumerate() { - let Some((title, icon, draggable, closable, tab_style_override)) = (match tree.node(leaf_idx) { - Node::Leaf { tabs, options, .. } => tabs.get(tab_index).map(|t| { - ( - t.title.clone(), - t.icon.clone(), - t.draggable - && !options.lock_layout - && (options.allow_tab_drag_out || options.allow_tab_reorder), - t.closable, - t.style_override, - ) - }), - _ => None, - }) else { - continue; - }; - - let tab_rect = Rect::from_min_size( - egui::pos2(current_x, tabs_rect.min.y), - vec2(tab_widths.get(slot).copied().unwrap_or(min_tab_width), style.header_height), - ); - current_x = tab_rect.max.x; - let painted_tab_rect = paint_tab_rect(tab_rect, slot, visible_indices.len()); - let is_focused = tab_index == focused; - let hovered = ui.rect_contains_pointer(tab_rect); - let tab_state = style.tab_state(is_focused, hovered, pane_style_override, tab_style_override); - - ui.painter() - .rect_filled(painted_tab_rect, tab_rounding(style), tab_state.bg); - - let close_rect = Rect::from_center_size( - egui::pos2( - tab_rect.max.x - close_button_padding - close_button_width * 0.5, - tab_rect.center().y, - ), - vec2(close_button_width, close_button_width), - ); - - if is_focused { - let accent_rect = Rect::from_min_max( - egui::pos2(painted_tab_rect.min.x + 2.0, painted_tab_rect.max.y - 2.0), - egui::pos2(painted_tab_rect.max.x - 2.0, painted_tab_rect.max.y), - ); - ui.painter().rect_filled(accent_rect, 0.0, tab_state.accent_color); - } - - let mut text_x = tab_rect.min.x + padding_x; - if let Some(icon) = &icon { - let icon_tint = tab_style_override - .and_then(|o| o.icon_color) - .unwrap_or(tab_state.text_color); - let icon_width = paint_tab_leading_icon( - ui, - icon, - text_x, - tab_rect, - icon_tint, - style, - ); - text_x += icon_width + leading_gap; - } - - // When a leading icon is present and the title is intentionally empty, - // render the icon centered in the tab and use the title as tooltip only. - let icon_only = icon.is_some() && title.trim().is_empty(); - let text_max_x = if closable { - close_rect.min.x - padding_x - } else { - tab_rect.max.x - padding_x - }; - if !icon_only { - let text_pos = egui::pos2(text_x, tab_rect.center().y); - ui.painter().text( - text_pos, - Align2::LEFT_CENTER, - &title, - style.typography.tab_title_font.clone(), - tab_state.text_color, - ); - } - - let mut response = ui.interact(tab_rect, tab_button_id(pane_id, tab_index), Sense::click_and_drag()); - if icon_only { - response = response.on_hover_text(title.clone()); - } else if title_is_truncated(ui, &title, (text_max_x - text_x).max(0.0), style) { - response = response.on_hover_text(title.clone()); - } - - if response.clicked() { - new_focused = tab_index; - } - if draggable && response.drag_started() { - response.dnd_set_drag_payload(DragPayload { - src_pane: pane_id, - tab_pos: tab_index, - }); - } - - response.context_menu(|ui| { - populate_tab_context_menu(ui, add_tab_entries, closable, tab_index, &mut pending_action); - - // Right-click on the tab background opens a group tab switcher. - if tab_count > 1 { - ui.label(egui::RichText::new("Switch tab").small().weak()); - ui.separator(); - let tab_titles: Vec<(usize, String)> = match tree.node(leaf_idx) { - Node::Leaf { tabs, .. } => tabs - .iter() - .enumerate() - .map(|(i, t)| (i, overflow_tab_label(t))) - .collect(), - _ => Vec::new(), - }; - for (pos, label) in &tab_titles { - let selected = *pos == focused; - if ui.selectable_label(selected, label).clicked() { - new_focused = *pos; - ui.close(); - } - } - } - }); - - if closable { - let close_response = - ui.interact(close_rect, tab_close_button_id(pane_id, tab_index), Sense::click()); - let close_color = if close_response.hovered() { - tab_state.accent_color - } else { - tab_state.text_color - }; - paint_icon(ui, close_rect, HeaderButtonIcon::Close, close_color); - if close_response.clicked() { - pending_action = Some(PendingAction::Close(tab_index)); - } - } - } - - if let Some(overflow_rect) = overflow_rect { - let overflow_response = header_menu_button( - ui, - overflow_rect, - egui::Id::new("grimdock::overflow").with(pane_id.into_raw()), - style, - HeaderButtonIcon::Overflow, - ); - Popup::menu(&overflow_response).show(|ui| { - for &tab_index in &hidden_indices { - let Some(label) = (match tree.node(leaf_idx) { - Node::Leaf { tabs, .. } => tabs.get(tab_index).map(|tab| { - overflow_tab_label(tab) - }), - _ => None, - }) else { - continue; - }; - if ui.selectable_label(tab_index == focused, label).clicked() { - new_focused = tab_index; - ui.close(); - } - } - }); - } - - if let Some(add_rect) = add_rect { - let add_response = header_menu_button( - ui, - add_rect, - egui::Id::new("grimdock::add_tab").with(pane_id.into_raw()), - style, - HeaderButtonIcon::Add, - ); - Popup::menu(&add_response).show(|ui| { - for entry in add_tab_entries { - if ui.button(&entry.title).clicked() { - pending_action = Some(PendingAction::AddTab(entry.clone())); - ui.close(); - } - } - }); - } - - if let Some(dock_rect) = dock_rect { - let dock_response = ui.interact( - dock_rect, - egui::Id::new("grimdock::dock_back").with(pane_id.into_raw()), - Sense::click(), - ); - // Use a high-contrast fill for the float/dock toggle so it is visible - // in light and dark title bars. - let fg = if dock_response.hovered() { - style.header.button.hover_icon_color - } else { - style.tabs.active.accent_color - }; - if dock_response.hovered() { - ui.painter() - .rect_filled(dock_rect, style.header.button.rounding, style.header.button.hover_bg); - } - let scale = 0.75; - draw_premium_pin(ui.painter(), dock_rect.center(), fg, true, scale); - let dock_response = dock_response.on_hover_text("Float / Dock panel"); - if dock_response.clicked() { - pending_action = Some(PendingAction::EmitPaneAction(PaneActionInvocation { - pane_id, - action_id: "float".to_string(), - })); - } - } - - { - let pane_response = header_menu_button( - ui, - pane_menu_rect, - egui::Id::new("grimdock::pane_menu").with(pane_id.into_raw()), - style, - HeaderButtonIcon::PaneMenu, - ); - Popup::menu(&pane_response).show(|ui| { - if ui.button("Close all").clicked() { - pending_action = Some(PendingAction::CloseAll); - ui.close(); - } - if ui - .add_enabled(tree.can_remove_pane(pane_id), egui::Button::new("Remove pane")) - .clicked() - { - pending_action = Some(PendingAction::RemovePane); - ui.close(); - } - if !lock_layout { - ui.separator(); - populate_move_pane_menu(ui, &mut pending_action); - } - if !pane_menu_actions.is_empty() { - ui.separator(); - for action in pane_menu_actions { - if ui.button(&action.title).clicked() { - pending_action = Some(PendingAction::EmitPaneAction(PaneActionInvocation { - pane_id, - action_id: action.id.clone(), - })); - ui.close(); - } - } - } - if !add_tab_entries.is_empty() { - ui.separator(); - populate_split_menu(ui, add_tab_entries, &mut pending_action); - } - }); - } - - if let Some(collapse_rect) = collapse_rect { - let collapse_response = ui.interact( - collapse_rect, - egui::Id::new("grimdock::tab_collapse").with(pane_id.into_raw()), - Sense::click(), - ); - paint_header_button( - ui, - collapse_rect, - &collapse_response, - style, - HeaderButtonIcon::Collapse { collapsed: false }, - ); - if collapse_response.clicked() { - let _ = tree.toggle_collapsed(leaf_idx); - } - } - - if new_focused != focused { - if let Node::Leaf { focused: f, .. } = tree.node_mut(leaf_idx) { - *f = new_focused; - } - } - - if let Some(action) = pending_action { - apply_pending_action(tree, leaf_idx, action, closed_tabs, pane_actions); - } -} - -fn populate_tab_context_menu( - ui: &mut Ui, - add_tab_entries: &[AddTabEntry], - closable: bool, - tab_index: usize, - pending_action: &mut Option>, -) where - T: PartialEq, -{ - if closable && ui.button("Close").clicked() { - *pending_action = Some(PendingAction::Close(tab_index)); - ui.close(); - } - if ui.button("Close others").clicked() { - *pending_action = Some(PendingAction::CloseOthers(tab_index)); - ui.close(); - } - if ui.button("Close all").clicked() { - *pending_action = Some(PendingAction::CloseAll); - ui.close(); - } - if !add_tab_entries.is_empty() { - ui.separator(); - populate_split_menu(ui, add_tab_entries, pending_action); - } -} - -fn populate_split_menu( - ui: &mut Ui, - add_tab_entries: &[AddTabEntry], - pending_action: &mut Option>, -) where - T: PartialEq, -{ - for (label, dir, side) in [ - ("Split left", SplitDir::Horizontal, ChildSide::First), - ("Split right", SplitDir::Horizontal, ChildSide::Second), - ("Split top", SplitDir::Vertical, ChildSide::First), - ("Split bottom", SplitDir::Vertical, ChildSide::Second), - ] { - ui.menu_button(label, |ui| { - for entry in add_tab_entries { - if ui.button(&entry.title).clicked() { - *pending_action = Some(PendingAction::SplitWith(entry.clone(), dir, side)); - ui.close(); - } - } - }); - } -} - -fn populate_move_pane_menu( - ui: &mut Ui, - pending_action: &mut Option>, -) { - for (label, anchor) in [ - ("Move pane left", PaneAnchor::Left), - ("Move pane right", PaneAnchor::Right), - ("Move pane top", PaneAnchor::Top), - ("Move pane bottom", PaneAnchor::Bottom), - ("Move pane center", PaneAnchor::Center), - ] { - if ui.button(label).clicked() { - *pending_action = Some(PendingAction::MovePane(anchor)); - ui.close(); - } - } -} - -fn apply_open_entry_to_leaf( - tree: &mut PanelTree, - leaf_idx: usize, - entry: AddTabEntry, -) where - T: PartialEq, -{ - let AddTabEntry { - tab, - open_behavior, - .. - } = entry; - - match open_behavior { - OpenBehavior::AllowDuplicate => tree.insert_tab_into_leaf(leaf_idx, tab), - OpenBehavior::FocusExisting => { - if !tree.focus_tab(&tab.id) { - tree.insert_tab_into_leaf(leaf_idx, tab); - } - } - OpenBehavior::FocusExistingInPane => { - let exists_in_target = tree - .find_tab(&tab.id) - .map(|(existing_leaf, tab_pos)| { - if existing_leaf == leaf_idx { - if let Node::Leaf { focused, .. } = tree.node_mut(leaf_idx) { - *focused = tab_pos; - } - true - } else { - false - } - }) - .unwrap_or(false); - if !exists_in_target { - tree.insert_tab_into_leaf(leaf_idx, tab); - } - } - } -} - -fn apply_pending_action( - tree: &mut PanelTree, - leaf_idx: usize, - action: PendingAction, - closed_tabs: &mut Vec, - pane_actions: &mut Vec, -) where - T: PartialEq, -{ - match action { - PendingAction::Close(tab_pos) => { - if let Some(tab) = tree.remove_tab_at(leaf_idx, tab_pos) { - closed_tabs.push(tab.id); - } - } - PendingAction::CloseOthers(keep_pos) => { - closed_tabs.extend(tree.close_other_tabs_in_leaf(leaf_idx, keep_pos)); - } - PendingAction::CloseAll => { - closed_tabs.extend(tree.close_all_tabs_in_leaf(leaf_idx)); - } - PendingAction::RemovePane => { - if let Some(pane_id) = tree.pane_id_at(leaf_idx) { - if let Some(removed) = tree.remove_pane(pane_id) { - closed_tabs.extend(removed); - } - } - } - PendingAction::MovePane(anchor) => { - if let Some(pane_id) = tree.pane_id_at(leaf_idx) { - let _ = tree.move_pane_to_anchor(pane_id, anchor); - } - } - PendingAction::AddTab(entry) => { - apply_open_entry_to_leaf(tree, leaf_idx, entry); - } - PendingAction::SplitWith(entry, dir, side) => { - let AddTabEntry { - title: _, - tab, - open_behavior, - } = entry; - - match open_behavior { - OpenBehavior::AllowDuplicate => { - tree.split_leaf(leaf_idx, dir, tab, side); - } - OpenBehavior::FocusExisting => { - if !tree.focus_tab(&tab.id) { - tree.split_leaf(leaf_idx, dir, tab, side); - } - } - OpenBehavior::FocusExistingInPane => { - let exists_in_target = tree - .find_tab(&tab.id) - .map(|(existing_leaf, tab_pos)| { - if existing_leaf == leaf_idx { - if let Node::Leaf { focused, .. } = tree.node_mut(leaf_idx) { - *focused = tab_pos; - } - true - } else { - false - } - }) - .unwrap_or(false); - if !exists_in_target { - tree.split_leaf(leaf_idx, dir, tab, side); - } - } - } - } - PendingAction::EmitPaneAction(action) => { - pane_actions.push(action); - } - } -} - -fn compute_visible_tabs( - tab_count: usize, - focused: usize, - available_width: f32, - min_tab_width: f32, - overflow_width: f32, -) -> (Vec, Vec) { - if tab_count == 0 { - return (Vec::new(), Vec::new()); - } - - let mut needs_overflow = false; - let mut visible_count; - loop { - let width = (available_width - if needs_overflow { overflow_width } else { 0.0 }).max(0.0); - visible_count = ((width / min_tab_width).floor() as usize).max(1).min(tab_count); - let next = visible_count < tab_count; - if next == needs_overflow { - break; - } - needs_overflow = next; - } - - let mut visible: Vec = (0..visible_count).collect(); - if focused >= visible_count && !visible.is_empty() { - let last = visible.len() - 1; - visible[last] = focused; - visible.sort_unstable(); - visible.dedup(); - while visible.len() < visible_count { - let candidate = (0..tab_count).find(|idx| !visible.contains(idx)).unwrap_or(0); - visible.push(candidate); - visible.sort_unstable(); - } - } - - let hidden: Vec = (0..tab_count).filter(|idx| !visible.contains(idx)).collect(); - (visible, hidden) -} - -fn compute_tab_widths( - visible_indices: &[usize], - visible_width: f32, - tab_max_widths: &[Option], - min_tab_width: f32, -) -> Vec { - if visible_indices.is_empty() { - return Vec::new(); - } - - let mut widths = vec![visible_width / visible_indices.len() as f32; visible_indices.len()]; - let mut flexible = vec![true; visible_indices.len()]; - - loop { - let mut changed = false; - let mut leftover = 0.0; - let mut flexible_count = 0usize; - - for (slot, width) in widths.iter_mut().enumerate() { - if !flexible[slot] { - continue; - } - let max_width = tab_max_widths - .get(visible_indices[slot]) - .copied() - .flatten() - .unwrap_or(f32::INFINITY); - if *width > max_width { - leftover += *width - max_width; - *width = max_width; - flexible[slot] = false; - changed = true; - } else { - flexible_count += 1; - } - } - - if !changed || leftover <= 0.0 || flexible_count == 0 { - break; - } - - let extra = leftover / flexible_count as f32; - for (slot, width) in widths.iter_mut().enumerate() { - if flexible[slot] { - *width += extra; - } - } - } - - for width in &mut widths { - *width = width.max(min_tab_width); - } - widths -} - -fn tab_max_widths( - tree: &crate::PanelTree, - leaf_idx: usize, -) -> Vec> { - match tree.node(leaf_idx) { - Node::Leaf { tabs, .. } => tabs - .iter() - .map(|tab| tab.style_override.and_then(|style| style.max_width)) - .collect(), - _ => Vec::new(), - } -} - -fn header_menu_button( - ui: &mut Ui, - rect: Rect, - id: egui::Id, - style: &PanelStyle, - icon: HeaderButtonIcon, -) -> egui::Response { - let response = ui.interact(rect, id, Sense::click()); - paint_header_button(ui, rect, &response, style, icon); - response -} - -fn paint_header_button( - ui: &Ui, - rect: Rect, - response: &egui::Response, - style: &PanelStyle, - icon: HeaderButtonIcon, -) { - let fg = if response.hovered() { - style.header.button.hover_icon_color - } else { - style.header.button.icon_color - }; - // Visual Studio-style toolbar buttons: no visible background or border - // until hovered, then only a subtle rounded fill. - if response.hovered() { - ui.painter() - .rect_filled(rect, style.header.button.rounding, style.header.button.hover_bg); - } - paint_icon(ui, rect, icon, fg); -} - -fn paint_icon(ui: &Ui, rect: Rect, icon: HeaderButtonIcon, color: egui::Color32) { - match icon { - HeaderButtonIcon::Add => { - let c = rect.center(); - let r = rect.width().min(rect.height()) * 0.22; - let stroke = Stroke::new(1.5, color); - ui.painter().line_segment([egui::pos2(c.x - r, c.y), egui::pos2(c.x + r, c.y)], stroke); - ui.painter().line_segment([egui::pos2(c.x, c.y - r), egui::pos2(c.x, c.y + r)], stroke); - } - HeaderButtonIcon::Overflow | HeaderButtonIcon::PaneMenu => { - let c = rect.center(); - let spacing = rect.width().min(rect.height()) * 0.18; - let radius = 1.5; - for offset in [-spacing, 0.0, spacing] { - ui.painter() - .circle_filled(egui::pos2(c.x + offset, c.y), radius, color); - } - } - HeaderButtonIcon::Collapse { collapsed } => { - // Visual Studio-style chevron: three horizontal bars for the - // collapsed (pin/preview) state, and a single downward chevron when - // the pane is expanded. - let c = rect.center(); - let s = rect.width().min(rect.height()) * 0.24; - let stroke = Stroke::new(1.5, color); - if collapsed { - let bar_w = s * 1.4; - let bar_h = 1.5; - let spacing = s * 0.32; - for dy in [-spacing, 0.0, spacing] { - let y = c.y + dy; - ui.painter().line_segment( - [egui::pos2(c.x - bar_w * 0.5, y), egui::pos2(c.x + bar_w * 0.5, y)], - stroke, - ); - } - } else { - let points = [ - egui::pos2(c.x - s, c.y - s * 0.3), - egui::pos2(c.x, c.y + s * 0.5), - egui::pos2(c.x + s, c.y - s * 0.3), - ]; - ui.painter().line_segment([points[0], points[1]], stroke); - ui.painter().line_segment([points[1], points[2]], stroke); - } - } - HeaderButtonIcon::Close => { - let c = rect.center(); - let s = rect.width().min(rect.height()) * 0.22; - let stroke = Stroke::new(1.5, color); - ui.painter().line_segment( - [egui::pos2(c.x - s, c.y - s), egui::pos2(c.x + s, c.y + s)], - stroke, - ); - ui.painter().line_segment( - [egui::pos2(c.x - s, c.y + s), egui::pos2(c.x + s, c.y - s)], - stroke, - ); - } - HeaderButtonIcon::DockBack => { - let scale = 0.75; - draw_premium_pin(ui.painter(), rect.center(), color, true, scale); - } - } -} - -fn paint_tab_leading_icon( - ui: &Ui, - icon: &TabIcon, - min_x: f32, - tab_rect: Rect, - tint: egui::Color32, - style: &PanelStyle, -) -> f32 { - match icon { - TabIcon::Text(text) => { - ui.painter().text( - egui::pos2(min_x, tab_rect.center().y), - Align2::LEFT_CENTER, - text, - style.typography.tab_icon_text_font.clone(), - tint, - ); - ui.painter() - .layout_no_wrap( - text.clone(), - style.typography.tab_icon_text_font.clone(), - tint, - ) - .size() - .x - } - TabIcon::CenteredGlyph(glyph) => { - let galley = ui.painter().layout_no_wrap( - glyph.clone(), - style.typography.tab_icon_text_font.clone(), - tint, - ); - let size = galley.size(); - let pos = tab_rect.center() - size * 0.5; - ui.painter().galley(pos, galley, tint); - size.x - } - TabIcon::Texture { texture_id, size } => { - let max_side = (tab_rect.height() - 8.0).max(8.0); - let scale = (max_side / size.y.max(1.0)).min(max_side / size.x.max(1.0)); - let draw_size = egui::vec2(size.x * scale, size.y * scale); - let icon_rect = Rect::from_min_size( - egui::pos2(min_x, tab_rect.center().y - draw_size.y * 0.5), - draw_size, - ); - ui.painter().image( - *texture_id, - icon_rect, - Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - tint, - ); - draw_size.x - } - } -} - -fn overflow_tab_label(tab: &crate::tab::Tab) -> String { - match &tab.icon { - Some(TabIcon::Text(text)) => format!("{text} {}", tab.title), - Some(TabIcon::CenteredGlyph(glyph)) => format!("{glyph} {}", tab.title), - Some(TabIcon::Texture { .. }) => format!("[img] {}", tab.title), - None => tab.title.clone(), - } -} - -fn title_is_truncated(ui: &Ui, title: &str, available_width: f32, style: &PanelStyle) -> bool { - if available_width <= 0.0 { - return true; - } - let galley = ui.painter().layout_no_wrap( - title.to_owned(), - style.typography.tab_title_font.clone(), - style.tabs.inactive.text_color, - ); - galley.size().x > available_width -} - -fn paint_tab_rect(tab_rect: Rect, index: usize, tab_count: usize) -> Rect { - let overlap = 1.0; - let min_x = if index == 0 { tab_rect.min.x } else { tab_rect.min.x - overlap }; - let max_x = if index + 1 == tab_count { - tab_rect.max.x - } else { - tab_rect.max.x + overlap - }; - Rect::from_min_max(egui::pos2(min_x, tab_rect.min.y), egui::pos2(max_x, tab_rect.max.y)) -} - -fn tab_rounding(style: &PanelStyle) -> egui::CornerRadius { - egui::CornerRadius { - nw: style.tabs.rounding.nw, - ne: style.tabs.rounding.ne, - sw: 0, - se: 0, - } -} - -fn draw_premium_pin(painter: &egui::Painter, center: egui::Pos2, color: egui::Color32, pinned: bool, scale: f32) { - if pinned { - let s = scale; - let cap_rect = egui::Rect::from_center_size(center - egui::vec2(0.0, 4.0 * s), egui::vec2(8.0 * s, 1.5 * s)); - painter.rect_filled(cap_rect, 0.5 * s, color); - - let body_rect = egui::Rect::from_center_size(center - egui::vec2(0.0, 1.0 * s), egui::vec2(5.0 * s, 4.5 * s)); - painter.rect_filled(body_rect, 0.5 * s, color); - - let ridge_rect = egui::Rect::from_center_size(center + egui::vec2(0.0, 1.75 * s), egui::vec2(7.0 * s, 1.0 * s)); - painter.rect_filled(ridge_rect, 0.0, color); - - let needle_rect = egui::Rect::from_center_size(center + egui::vec2(0.0, 4.75 * s), egui::vec2(1.2 * s, 5.0 * s)); - painter.rect_filled(needle_rect, 0.0, color); - } else { - let s = scale; - let dir = egui::vec2(-1.0, 1.0).normalized(); - let ortho = egui::vec2(1.0, 1.0).normalized(); - - painter.line_segment( - [center + dir * 0.5 * s, center + dir * 5.5 * s], - egui::Stroke::new(1.3 * s, color) - ); - - let ridge_c = center - dir * 0.75 * s; - painter.line_segment( - [ridge_c - ortho * 3.0 * s, ridge_c + ortho * 3.0 * s], - egui::Stroke::new(1.2 * s, color) - ); - - let body_c = ridge_c - dir * 2.0 * s; - let p1 = body_c - ortho * 2.0 * s - dir * 1.5 * s; - let p2 = body_c + ortho * 2.0 * s - dir * 1.5 * s; - let p3 = body_c + ortho * 2.0 * s + dir * 1.5 * s; - let p4 = body_c - ortho * 2.0 * s + dir * 1.5 * s; - painter.add(egui::Shape::convex_polygon(vec![p1, p2, p3, p4], color, egui::Stroke::NONE)); - - let cap_c = ridge_c - dir * 4.25 * s; - painter.line_segment( - [cap_c - ortho * 3.5 * s, cap_c + ortho * 3.5 * s], - egui::Stroke::new(1.5 * s, color) - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Tab; - - fn count_tab(tree: &PanelTree, id: &T) -> usize { - tree.leaf_indices() - .map(|leaf_idx| match tree.node(leaf_idx) { - Node::Leaf { tabs, .. } => tabs.iter().filter(|tab| &tab.id == id).count(), - _ => 0, - }) - .sum() - } - - #[test] - fn focus_existing_open_behavior_prevents_duplicates() { - let mut tree = PanelTree::new(vec![Tab::new("A", "a")]); - let entry = AddTabEntry::new("A", Tab::new("A", "a")) - .with_open_behavior(OpenBehavior::FocusExisting); - - apply_open_entry_to_leaf(&mut tree, 0, entry); - - assert_eq!(count_tab(&tree, &"a"), 1); - } - - #[test] - fn focus_existing_in_pane_allows_duplicate_in_other_pane() { - let mut tree = PanelTree::new(vec![Tab::new("A", "a")]); - tree.split_leaf(0, SplitDir::Horizontal, Tab::new("B", "b"), ChildSide::Second); - - let entry = AddTabEntry::new("A", Tab::new("A", "a")) - .with_open_behavior(OpenBehavior::FocusExistingInPane); - - apply_open_entry_to_leaf(&mut tree, 2, entry); - - assert_eq!(count_tab(&tree, &"a"), 2); - } -} diff --git a/patches/grimdock/src/ids.rs b/patches/grimdock/src/ids.rs deleted file mode 100644 index aceee8b..0000000 --- a/patches/grimdock/src/ids.rs +++ /dev/null @@ -1,27 +0,0 @@ -use egui::Id; - -use crate::tree::PaneId; - -/// Stable [`Id`] for the resize handle between the two children of a split node. -pub(crate) fn resize_handle_id(tree_node_idx: usize) -> Id { - Id::new("grimdock::resize").with(tree_node_idx) -} - -/// Stable [`Id`] for a tab button inside a leaf node. -pub(crate) fn tab_button_id(pane_id: PaneId, tab_pos: usize) -> Id { - Id::new("grimdock::tab") - .with(pane_id.into_raw()) - .with(tab_pos) -} - -/// Stable [`Id`] for a tab close button inside a leaf node. -pub(crate) fn tab_close_button_id(pane_id: PaneId, tab_pos: usize) -> Id { - Id::new("grimdock::tab_close") - .with(pane_id.into_raw()) - .with(tab_pos) -} - -/// Stable [`Id`] for the content child-UI of a leaf node. -pub(crate) fn pane_content_id(pane_id: PaneId) -> Id { - Id::new("grimdock::content").with(pane_id.into_raw()) -} diff --git a/patches/grimdock/src/layout.rs b/patches/grimdock/src/layout.rs deleted file mode 100644 index 5a043a1..0000000 --- a/patches/grimdock/src/layout.rs +++ /dev/null @@ -1,272 +0,0 @@ -use egui::{Rect, Sense, Ui, vec2}; - -use crate::{ - ids::resize_handle_id, - style::PanelStyle, - tree::{Node, PanelTree, SplitDir}, -}; - -/// Run the layout pass over the entire tree. -/// -/// Assigns a rectangle to every node starting from the root, renders resize -/// handles for split nodes, and handles ratio updates from handle drags. -/// -/// Returns a list of `(leaf_index, content_rect)` pairs for consumption by -/// the header and content passes. -pub(crate) fn layout_pass( - ui: &mut Ui, - tree: &mut PanelTree, - style: &PanelStyle, -) -> Vec<(usize, Rect)> { - let root_rect = ui.available_rect_before_wrap(); - let mut leaf_rects: Vec<(usize, Rect)> = Vec::new(); - layout_node(ui, tree, 0, root_rect, style, &mut leaf_rects); - leaf_rects -} - -/// Compute whether a node has vanished from the layout. A leaf with no tabs -/// is considered vanished UNLESS its `persist_when_empty` option is set, which -/// keeps the pane alive so that split ratios and sibling panes are preserved. -fn is_node_vanished(tree: &PanelTree, idx: usize) -> bool { - match tree.node(idx) { - Node::Leaf { tabs, options, .. } => tabs.is_empty() && !options.persist_when_empty, - Node::Split { .. } => { - let left = PanelTree::::left_child(idx); - let right = PanelTree::::right_child(idx); - is_node_vanished(tree, left) && is_node_vanished(tree, right) - } - Node::Empty => true, - } -} - -/// Compute the effective collapsed thickness for a child node. Collapsed -/// leaves that have no tabs (i.e. floated panels) are assigned zero space so -/// they vanish from the layout entirely. -fn effective_collapsed_thickness( - tree: &PanelTree, - child_idx: usize, - collapsed: bool, - default: f32, -) -> f32 { - if !collapsed { - return 0.0; - } - if is_node_vanished(tree, child_idx) { - 0.0 - } else { - default - } -} - -fn layout_node( - ui: &mut Ui, - tree: &mut PanelTree, - idx: usize, - rect: Rect, - style: &PanelStyle, - leaf_rects: &mut Vec<(usize, Rect)>, -) { - // Store the rect in the node for DnD hit-testing later. - match tree.node_mut(idx) { - Node::Split { rect: r, .. } => *r = rect, - Node::Leaf { rect: r, .. } => *r = rect, - Node::Empty => return, - } - - match tree.node(idx).clone() { - Node::Split { dir, mut ratio, .. } => { - let handle_w = style.handle_width; - let first_idx = PanelTree::::left_child(idx); - let second_idx = PanelTree::::right_child(idx); - let first_collapsed = tree.is_collapsed(first_idx) || is_node_vanished(tree, first_idx); - let second_collapsed = tree.is_collapsed(second_idx) || is_node_vanished(tree, second_idx); - let resize_locked = tree.split_resize_locked(idx); - - let first_ct = effective_collapsed_thickness( - tree, first_idx, first_collapsed, style.collapsed_pane_thickness, - ); - let second_ct = effective_collapsed_thickness( - tree, second_idx, second_collapsed, style.collapsed_pane_thickness, - ); - - let (_first_rect, handle_rect, _second_rect) = split_rect( - rect, dir, ratio, handle_w, - first_collapsed, second_collapsed, - first_ct, second_ct, - style.min_pane_size, !resize_locked, - ); - - // Draw and interact with the resize handle. - let handle_id = resize_handle_id(idx); - let handle_response = ui.interact( - handle_rect, - handle_id, - if resize_locked { - Sense::hover() - } else { - Sense::drag() - }, - ); - - let is_active = handle_response.dragged() || handle_response.hovered(); - let color = if resize_locked { - style.handle.locked_color - } else if is_active { - style.handle.hover_color - } else { - style.handle.color - }; - ui.painter().rect_filled(handle_rect, 0.0, color); - - if !resize_locked && handle_response.hovered() { - let cursor = match dir { - SplitDir::Horizontal => egui::CursorIcon::ResizeHorizontal, - SplitDir::Vertical => egui::CursorIcon::ResizeVertical, - }; - ui.ctx().set_cursor_icon(cursor); - } - - if handle_response.dragged() - && !resize_locked - && !first_collapsed - && !second_collapsed - { - let delta = handle_response.drag_delta(); - let delta_ratio = match dir { - SplitDir::Horizontal => delta.x / rect.width().max(1.0), - SplitDir::Vertical => delta.y / rect.height().max(1.0), - }; - - let min_ratio = style.min_pane_size - / match dir { - SplitDir::Horizontal => rect.width().max(1.0), - SplitDir::Vertical => rect.height().max(1.0), - }; - let max_ratio = 1.0 - min_ratio; - ratio = (ratio + delta_ratio).clamp(min_ratio.max(0.0), max_ratio.min(1.0)); - - if let Node::Split { ratio: r, .. } = tree.node_mut(idx) { - *r = ratio; - } - } - - let (first_rect, _, second_rect) = split_rect( - rect, dir, ratio, handle_w, - first_collapsed, second_collapsed, - first_ct, second_ct, - style.min_pane_size, !resize_locked, - ); - - layout_node(ui, tree, first_idx, first_rect, style, leaf_rects); - layout_node(ui, tree, second_idx, second_rect, style, leaf_rects); - } - Node::Leaf { tabs, collapsed, .. } => { - let header_height = if tree.header_visible(idx) { - if collapsed && tabs.is_empty() { 0.0 } else { style.header_height } - } else { - 0.0 - }; - let content_rect = Rect::from_min_size( - rect.min + vec2(0.0, header_height), - vec2(rect.width(), (rect.height() - header_height).max(0.0)), - ); - leaf_rects.push((idx, content_rect)); - } - Node::Empty => {} - } -} - -/// Split `rect` into two sub-rects with a handle strip between them. -/// -/// `first_ct` / `second_ct` are the collapsed thickness for each side when -/// that side is collapsed. A value of `0.0` makes the collapsed side vanish. -/// -/// When `enforce_min` is `true`, each non-collapsed side is guaranteed at least -/// `min_pane_size` pixels of extent. Splits whose resize handle is locked -/// (e.g. the fixed-width Tools column) pass `enforce_min = false` so their -/// ratio-driven (possibly narrow) width is preserved. -fn split_rect( - rect: Rect, - dir: SplitDir, - ratio: f32, - handle_w: f32, - first_collapsed: bool, - second_collapsed: bool, - first_ct: f32, - second_ct: f32, - min_pane_size: f32, - enforce_min: bool, -) -> (Rect, Rect, Rect) { - let total_extent = match dir { - SplitDir::Horizontal => rect.width(), - SplitDir::Vertical => rect.height(), - } - .max(0.0); - - let first_vanished = first_collapsed && first_ct == 0.0; - let second_vanished = second_collapsed && second_ct == 0.0; - - if first_vanished && second_vanished { - return (Rect::NOTHING, Rect::NOTHING, Rect::NOTHING); - } else if first_vanished { - match dir { - SplitDir::Horizontal => { - let empty = Rect::from_min_max(rect.min, rect.min); - return (empty, empty, rect); - } - SplitDir::Vertical => { - let empty = Rect::from_min_max(rect.min, rect.min); - return (empty, empty, rect); - } - } - } else if second_vanished { - match dir { - SplitDir::Horizontal => { - let empty = Rect::from_min_max(rect.max, rect.max); - return (rect, empty, empty); - } - SplitDir::Vertical => { - let empty = Rect::from_min_max(rect.max, rect.max); - return (rect, empty, empty); - } - } - } - - let first_extent = if first_collapsed && !second_collapsed { - first_ct.min((total_extent - handle_w).max(0.0)) - } else if second_collapsed && !first_collapsed { - (total_extent - handle_w - second_ct).max(0.0) - } else { - let raw = total_extent * ratio; - if enforce_min && min_pane_size > 0.0 { - let usable = (total_extent - handle_w).max(0.0); - let min = min_pane_size.min(usable * 0.5); - raw.clamp(min, (usable - min).max(min)) - } else { - raw - } - }; - - match dir { - SplitDir::Horizontal => { - let split_x = rect.min.x + first_extent; - let first = Rect::from_min_max(rect.min, egui::pos2(split_x - handle_w / 2.0, rect.max.y)); - let handle = Rect::from_min_max( - egui::pos2(split_x - handle_w / 2.0, rect.min.y), - egui::pos2(split_x + handle_w / 2.0, rect.max.y), - ); - let second = Rect::from_min_max(egui::pos2(split_x + handle_w / 2.0, rect.min.y), rect.max); - (first, handle, second) - } - SplitDir::Vertical => { - let split_y = rect.min.y + first_extent; - let first = Rect::from_min_max(rect.min, egui::pos2(rect.max.x, split_y - handle_w / 2.0)); - let handle = Rect::from_min_max( - egui::pos2(rect.min.x, split_y - handle_w / 2.0), - egui::pos2(rect.max.x, split_y + handle_w / 2.0), - ); - let second = Rect::from_min_max(egui::pos2(rect.min.x, split_y + handle_w / 2.0), rect.max); - (first, handle, second) - } - } -} diff --git a/patches/grimdock/src/lib.rs b/patches/grimdock/src/lib.rs deleted file mode 100644 index e6a85bc..0000000 --- a/patches/grimdock/src/lib.rs +++ /dev/null @@ -1,243 +0,0 @@ -#![allow(dead_code, unused_imports, unused_variables)] -//! # grimdock -//! -//! A dockable panel layout system for [egui](https://github.com/emilk/egui). -//! -//! Provides an IDE-style workspace where panels can be split, resized, and -//! rearranged by dragging tabs — all within egui's immediate-mode layer. -//! -//! ## Quick start -//! -//! ```rust,no_run -//! # use grimdock::{PanelTree, PanelStyle, PanelContext, Tab}; -//! # fn show(ui: &mut egui::Ui, tree: &mut PanelTree<&'static str>) { -//! let style = PanelStyle::default(); -//! PanelContext::new(ui, tree, &style).show(|ui, tab_id| { -//! ui.label(*tab_id); -//! }); -//! # } -//! ``` - -mod content; -mod dnd; -mod header; -mod ids; -mod layout; -mod persistence; -mod style; -mod tab; -mod tree; - -pub use persistence::{ - LegacyPersistedNode, LegacyPersistedPanelTree, PersistError, PersistedNode, PersistedPanelTree, - PersistedPanelTreeFile, PANEL_TREE_FORMAT_VERSION, -}; -pub use style::{ - ContentStyle, HandleStyle, HeaderButtonStyle, HeaderStyle, OverlayStyle, PaneStyleOverride, - PanelStyle, TabStateStyle, TabStyle, TabStyleOverride, TypographyStyle, -}; -pub use tab::{Tab, TabDropPolicy, TabIcon}; -pub use tree::{ - ChildSide, DropPolicy, HeaderVisibility, Node, Pane, PaneAnchor, PaneBuilder, PaneId, PaneMut, - PaneOptions, PaneRole, PanelTree, SplitDir, -}; - -use egui::Ui; - -/// Built-in behavior for resolving add/open actions when a tab with the same -/// identifier may already exist. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum OpenBehavior { - /// Always insert a fresh tab, even if the same identifier already exists. - AllowDuplicate, - /// Focus the existing tab anywhere in the dock instead of creating another. - FocusExisting, - /// Focus the existing tab only if it is already in the target pane. - /// Otherwise insert a duplicate into the target pane. - FocusExistingInPane, -} - -/// A caller-provided entry shown in built-in add-tab and split-here menus. -#[derive(Clone, Debug)] -pub struct AddTabEntry { - pub title: String, - pub tab: Tab, - pub open_behavior: OpenBehavior, -} - -impl AddTabEntry { - pub fn new(title: impl Into, tab: Tab) -> Self { - Self { - title: title.into(), - tab, - open_behavior: OpenBehavior::FocusExisting, - } - } - - pub fn with_open_behavior(mut self, open_behavior: OpenBehavior) -> Self { - self.open_behavior = open_behavior; - self - } -} - -/// A caller-provided pane action shown in the built-in pane menu. -#[derive(Clone, Debug)] -pub struct PaneMenuAction { - pub id: String, - pub title: String, -} - -impl PaneMenuAction { - pub fn new(id: impl Into, title: impl Into) -> Self { - Self { - id: id.into(), - title: title.into(), - } - } -} - -/// A custom pane action invoked from the built-in pane menu during this frame. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PaneActionInvocation { - pub pane_id: PaneId, - pub action_id: String, -} - -/// Mutations emitted by a single [`PanelContext::show`] pass. -#[derive(Debug)] -pub struct PanelOutput { - /// Tabs closed from pane headers during this frame. - pub closed_tabs: Vec, - /// Custom pane actions invoked from built-in pane menus during this frame. - pub pane_actions: Vec, -} - -impl Default for PanelOutput { - fn default() -> Self { - Self { - closed_tabs: Vec::new(), - pane_actions: Vec::new(), - } - } -} - -/// Entry point for rendering the panel layout each frame. -/// -/// The caller owns and persists the [`PanelTree`] across frames. Each frame, -/// construct a `PanelContext` and call [`PanelContext::show`] with a render -/// callback. -/// -/// # Example -/// -/// ```rust,no_run -/// # use grimdock::{PanelTree, PanelStyle, PanelContext, Tab}; -/// # fn show(ui: &mut egui::Ui, tree: &mut PanelTree<&'static str>) { -/// PanelContext::new(ui, tree, &PanelStyle::default()).show(|ui, id| { -/// ui.heading(*id); -/// }); -/// # } -/// ``` -pub struct PanelContext<'ui, T: Clone + 'static> { - ui: &'ui mut Ui, - tree: &'ui mut PanelTree, - style: &'ui PanelStyle, - add_tab_entries: &'ui [AddTabEntry], - add_tab_provider: Option<&'ui dyn Fn(PaneId, &PanelTree) -> Vec>>, - pane_menu_actions: &'ui [PaneMenuAction], - pane_menu_provider: Option<&'ui dyn Fn(PaneId, &PanelTree) -> Vec>, -} - -impl<'ui, T: Clone + 'static> PanelContext<'ui, T> { - pub fn new( - ui: &'ui mut Ui, - tree: &'ui mut PanelTree, - style: &'ui PanelStyle, - ) -> Self { - Self { - ui, - tree, - style, - add_tab_entries: &[], - add_tab_provider: None, - pane_menu_actions: &[], - pane_menu_provider: None, - } - } - - /// Provide the same entries for built-in add-tab, overflow, and split-here - /// menus in every pane. - pub fn with_add_tab_entries(mut self, add_tab_entries: &'ui [AddTabEntry]) -> Self { - self.add_tab_entries = add_tab_entries; - self - } - - /// Provide pane-scoped entries for built-in add-tab and split-here menus. - pub fn with_add_tab_provider( - mut self, - add_tab_provider: &'ui dyn Fn(PaneId, &PanelTree) -> Vec>, - ) -> Self { - self.add_tab_provider = Some(add_tab_provider); - self - } - - /// Provide the same custom pane menu actions for every pane. - pub fn with_pane_menu_actions(mut self, pane_menu_actions: &'ui [PaneMenuAction]) -> Self { - self.pane_menu_actions = pane_menu_actions; - self - } - - /// Provide pane-scoped custom actions for the built-in pane menu. - pub fn with_pane_menu_provider( - mut self, - pane_menu_provider: &'ui dyn Fn(PaneId, &PanelTree) -> Vec, - ) -> Self { - self.pane_menu_provider = Some(pane_menu_provider); - self - } - - /// Run all three rendering passes and resolve any drag-and-drop that - /// completed this frame. - /// - /// `render` is called once per visible leaf with the egui [`Ui`] for that - /// pane's content area and a reference to the focused tab's identifier. - pub fn show(self, render: impl FnMut(&mut Ui, &T)) -> PanelOutput - where - T: PartialEq, - { - let Self { - ui, - tree, - style, - add_tab_entries, - add_tab_provider, - pane_menu_actions, - pane_menu_provider, - } = self; - let mut output = PanelOutput::default(); - - // Pass 1: layout — assign rects, draw resize handles. - let leaf_rects = layout::layout_pass(ui, tree, style); - - // Pass 2: headers — draw tab buttons, initiate drags. - header::header_pass( - ui, - tree, - &leaf_rects, - style, - add_tab_entries, - add_tab_provider, - pane_menu_actions, - pane_menu_provider, - &mut output.closed_tabs, - &mut output.pane_actions, - ); - - // Pass 3: content — invoke caller callback for each focused tab. - content::content_pass(ui, tree, &leaf_rects, style, render); - - // Pass 4: drag-and-drop — draw overlay, resolve drops. - dnd::dnd_pass(ui, tree, style); - - output - } -} diff --git a/patches/grimdock/src/persistence.rs b/patches/grimdock/src/persistence.rs deleted file mode 100644 index b78ff73..0000000 --- a/patches/grimdock/src/persistence.rs +++ /dev/null @@ -1,457 +0,0 @@ -use std::collections::HashSet; - -use crate::{ - tab::Tab, - tree::{Node, PaneId, PaneOptions, PanelTree, SplitDir}, -}; - -/// Current persisted layout format version. -pub const PANEL_TREE_FORMAT_VERSION: u32 = 1; - -/// Versioned, runtime-independent layout payload for persistence. -/// -/// This format is the intended stable save/load contract for `grimdock`. -/// It avoids runtime-only fields such as layout rects and carries an explicit -/// version number so future structural changes can be migrated intentionally. -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct PersistedPanelTree { - pub version: u32, - pub next_pane_id: u64, - pub nodes: Vec>, -} - -/// Persisted node payload for [`PersistedPanelTree`]. -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum PersistedNode { - Empty, - Split { - dir: SplitDir, - ratio: f32, - }, - Leaf { - pane: PaneId, - tabs: Vec>, - focused: usize, - options: PaneOptions, - collapsed: bool, - }, -} - -/// Legacy unversioned persistence shape that mirrors direct serde of -/// [`PanelTree`]. This is only used for migration into the explicit versioned -/// format. -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct LegacyPersistedPanelTree { - pub nodes: Vec>, - pub next_pane_id: u64, -} - -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum LegacyPersistedNode { - Empty, - Split { - dir: SplitDir, - ratio: f32, - }, - Leaf { - pane: PaneId, - tabs: Vec>, - focused: usize, - options: PaneOptions, - collapsed: bool, - }, -} - -/// Deserialization entry point that accepts both the current versioned format -/// and the legacy unversioned format. -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(untagged))] -pub enum PersistedPanelTreeFile { - Versioned(PersistedPanelTree), - Legacy(LegacyPersistedPanelTree), -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PersistError { - UnsupportedVersion(u32), - EmptyTree, - EmptyLeaf { pane: PaneId }, - FocusOutOfRange { - pane: PaneId, - focused: usize, - tab_count: usize, - }, - DuplicatePaneId(PaneId), - NextPaneIdTooLow { - next_pane_id: u64, - required_minimum: u64, - }, -} - -impl PersistedPanelTree { - /// Export a runtime tree into the current versioned persistence format. - pub fn current(tree: &PanelTree) -> Self { - Self { - version: PANEL_TREE_FORMAT_VERSION, - next_pane_id: tree.next_pane_id, - nodes: tree - .nodes - .iter() - .map(|node| match node { - Node::Empty => PersistedNode::Empty, - Node::Split { dir, ratio, .. } => PersistedNode::Split { - dir: *dir, - ratio: *ratio, - }, - Node::Leaf { - pane, - tabs, - focused, - options, - collapsed, - .. - } => PersistedNode::Leaf { - pane: *pane, - tabs: tabs.clone(), - focused: *focused, - options: *options, - collapsed: *collapsed, - }, - }) - .collect(), - } - } - - /// Validate the persisted payload before attempting to build a runtime tree. - pub fn validate(&self) -> Result<(), PersistError> { - if self.nodes.is_empty() { - return Err(PersistError::EmptyTree); - } - - let mut panes = HashSet::new(); - let mut max_pane = 0_u64; - - for node in &self.nodes { - if let PersistedNode::Leaf { - pane, - tabs, - focused, - options, - .. - } = node - { - if !panes.insert(*pane) { - return Err(PersistError::DuplicatePaneId(*pane)); - } - max_pane = max_pane.max(pane.into_raw()); - if tabs.is_empty() { - if options.persist_when_empty { - if *focused != 0 { - return Err(PersistError::FocusOutOfRange { - pane: *pane, - focused: *focused, - tab_count: 0, - }); - } - continue; - } - return Err(PersistError::EmptyLeaf { pane: *pane }); - } - if *focused >= tabs.len() { - return Err(PersistError::FocusOutOfRange { - pane: *pane, - focused: *focused, - tab_count: tabs.len(), - }); - } - } - } - - let required_minimum = max_pane.saturating_add(1).max(1); - if self.next_pane_id < required_minimum { - return Err(PersistError::NextPaneIdTooLow { - next_pane_id: self.next_pane_id, - required_minimum, - }); - } - - Ok(()) - } - - /// Convert the versioned persisted payload into a runtime tree. - pub fn into_panel_tree(self) -> Result, PersistError> { - if self.version != PANEL_TREE_FORMAT_VERSION { - return Err(PersistError::UnsupportedVersion(self.version)); - } - self.validate()?; - - Ok(PanelTree { - nodes: self - .nodes - .into_iter() - .map(|node| match node { - PersistedNode::Empty => Node::Empty, - PersistedNode::Split { dir, ratio } => Node::Split { - dir, - ratio, - rect: egui::Rect::NOTHING, - }, - PersistedNode::Leaf { - pane, - tabs, - focused, - options, - collapsed, - } => Node::Leaf { - pane, - tabs, - focused, - options, - collapsed, - rect: egui::Rect::NOTHING, - }, - }) - .collect(), - next_pane_id: self.next_pane_id, - }) - } -} - -impl From<&PanelTree> for PersistedPanelTree { - fn from(value: &PanelTree) -> Self { - Self::current(value) - } -} - -impl From<&PanelTree> for LegacyPersistedPanelTree { - fn from(value: &PanelTree) -> Self { - Self { - next_pane_id: value.next_pane_id, - nodes: value - .nodes - .iter() - .map(|node| match node { - Node::Empty => LegacyPersistedNode::Empty, - Node::Split { dir, ratio, .. } => LegacyPersistedNode::Split { - dir: *dir, - ratio: *ratio, - }, - Node::Leaf { - pane, - tabs, - focused, - options, - collapsed, - .. - } => LegacyPersistedNode::Leaf { - pane: *pane, - tabs: tabs.clone(), - focused: *focused, - options: *options, - collapsed: *collapsed, - }, - }) - .collect(), - } - } -} - -impl From> for PersistedPanelTree { - fn from(value: LegacyPersistedPanelTree) -> Self { - Self { - version: PANEL_TREE_FORMAT_VERSION, - next_pane_id: value.next_pane_id, - nodes: value - .nodes - .into_iter() - .map(|node| match node { - LegacyPersistedNode::Empty => PersistedNode::Empty, - LegacyPersistedNode::Split { dir, ratio } => PersistedNode::Split { dir, ratio }, - LegacyPersistedNode::Leaf { - pane, - tabs, - focused, - options, - collapsed, - } => PersistedNode::Leaf { - pane, - tabs, - focused, - options, - collapsed, - }, - }) - .collect(), - } - } -} - -impl PersistedPanelTreeFile { - /// Migrate either the current versioned payload or the legacy payload into - /// the current versioned format. - pub fn migrate(self) -> Result, PersistError> { - match self { - PersistedPanelTreeFile::Versioned(layout) => { - if layout.version != PANEL_TREE_FORMAT_VERSION { - return Err(PersistError::UnsupportedVersion(layout.version)); - } - layout.validate()?; - Ok(layout) - } - PersistedPanelTreeFile::Legacy(layout) => { - let layout = PersistedPanelTree::from(layout); - layout.validate()?; - Ok(layout) - } - } - } - - /// Convert either the current or legacy persisted payload into a runtime tree. - pub fn into_panel_tree(self) -> Result, PersistError> { - self.migrate()?.into_panel_tree() - } -} - -impl PanelTree { - /// Export the current layout into the versioned persisted format. - pub fn to_persisted(&self) -> PersistedPanelTree { - PersistedPanelTree::from(self) - } - - /// Reconstruct a runtime tree from the versioned persisted format. - pub fn from_persisted(layout: PersistedPanelTree) -> Result { - layout.into_panel_tree() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{DropPolicy, HeaderVisibility, PaneBuilder, PaneRole, TabDropPolicy, TabStyleOverride}; - - fn sample_tree() -> PanelTree<&'static str> { - let mut options = PaneOptions::default(); - options.header_visibility = HeaderVisibility::WhenMultipleTabs; - options.drop_policy = DropPolicy::merge_only(); - options.style_override = Some(crate::style::PaneStyleOverride { - header_bg: Some(egui::Color32::from_rgb(1, 2, 3)), - content_bg: None, - border_color: None, - accent_color: None, - }); - - let mut tree = PanelTree::from_pane( - PaneBuilder::new( - Tab::new("A", "a").with_style_override(TabStyleOverride { - active_bg: Some(egui::Color32::from_rgb(4, 5, 6)), - inactive_bg: None, - hovered_bg: None, - text_color: None, - accent_color: None, - icon_color: None, - max_width: Some(120.0), - }), - ) - .push_tab(Tab::new("B", "b")) - .with_options(options), - ); - tree.split_leaf(0, SplitDir::Horizontal, Tab::new("C", "c"), crate::ChildSide::Second); - tree - } - - #[test] - fn persisted_round_trip_restores_tree() { - let tree = sample_tree(); - let persisted = tree.to_persisted(); - let restored = PanelTree::from_persisted(persisted).expect("persistence should round-trip"); - - assert_eq!(restored.next_pane_id, tree.next_pane_id); - assert_eq!(restored.nodes.len(), tree.nodes.len()); - assert_eq!(restored.find_pane_containing(&"a"), tree.find_pane_containing(&"a")); - assert_eq!(restored.find_pane_containing(&"c"), tree.find_pane_containing(&"c")); - } - - #[test] - fn persisted_validation_rejects_invalid_focus() { - let mut layout = sample_tree().to_persisted(); - layout.nodes[1] = PersistedNode::Leaf { - pane: PaneId::from_raw(1), - tabs: vec![Tab::new("A", "a")], - focused: 3, - options: PaneOptions::default(), - collapsed: false, - }; - - let err = layout.validate().expect_err("layout should be invalid"); - assert!(matches!(err, PersistError::FocusOutOfRange { .. })); - } - - #[test] - fn legacy_layout_migrates_to_current_format() { - let legacy = LegacyPersistedPanelTree::from(&sample_tree()); - let migrated = PersistedPanelTreeFile::Legacy(legacy) - .migrate() - .expect("legacy layout should migrate"); - - assert_eq!(migrated.version, PANEL_TREE_FORMAT_VERSION); - assert!(migrated.validate().is_ok()); - } - - #[test] - fn persisted_validation_allows_persistent_empty_leaf() { - let mut options = PaneOptions::default(); - options.persist_when_empty = true; - let layout: PersistedPanelTree<&'static str> = PersistedPanelTree { - version: PANEL_TREE_FORMAT_VERSION, - next_pane_id: 2, - nodes: vec![PersistedNode::Leaf { - pane: PaneId::from_raw(1), - tabs: Vec::new(), - focused: 0, - options, - collapsed: false, - }], - }; - - assert!(layout.validate().is_ok()); - } - - #[test] - fn persisted_round_trip_preserves_tab_drop_policy() { - let tree = PanelTree::new(vec![ - Tab::new("A", "a").with_drop_policy(TabDropPolicy { - locked_to_pane: Some(PaneId::from_raw(9)), - locked_to_role: Some(PaneRole::Terminal), - allowed_panes: Some(vec![PaneId::from_raw(9), PaneId::from_raw(10)]), - allowed_roles: Some(vec![PaneRole::Editor, PaneRole::Sidebar]), - blocked_panes: vec![PaneId::from_raw(10)], - blocked_roles: vec![PaneRole::Inspector], - }), - ]); - let persisted = tree.to_persisted(); - let restored = PanelTree::from_persisted(persisted).expect("persistence should round-trip"); - - match restored.node(0) { - Node::Leaf { tabs, .. } => { - assert_eq!(tabs[0].drop_policy.locked_to_pane, Some(PaneId::from_raw(9))); - assert_eq!(tabs[0].drop_policy.locked_to_role, Some(PaneRole::Terminal)); - assert_eq!( - tabs[0].drop_policy.allowed_panes, - Some(vec![PaneId::from_raw(9), PaneId::from_raw(10)]) - ); - assert_eq!( - tabs[0].drop_policy.allowed_roles, - Some(vec![PaneRole::Editor, PaneRole::Sidebar]) - ); - assert_eq!(tabs[0].drop_policy.blocked_panes, vec![PaneId::from_raw(10)]); - assert_eq!(tabs[0].drop_policy.blocked_roles, vec![PaneRole::Inspector]); - } - other => panic!("expected restored root leaf, got {:?}", other), - } - } -} diff --git a/patches/grimdock/src/style.rs b/patches/grimdock/src/style.rs deleted file mode 100644 index 0548366..0000000 --- a/patches/grimdock/src/style.rs +++ /dev/null @@ -1,376 +0,0 @@ -use egui::{Color32, CornerRadius, FontFamily, FontId, Stroke, Style, TextStyle, Visuals}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct HeaderButtonStyle { - pub bg: Color32, - pub hover_bg: Color32, - pub stroke_color: Color32, - pub icon_color: Color32, - pub hover_icon_color: Color32, - pub rounding: CornerRadius, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct HeaderStyle { - pub bg: Color32, - pub border_color: Color32, - pub button: HeaderButtonStyle, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ContentStyle { - pub bg: Color32, - pub border_color: Color32, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct HandleStyle { - pub color: Color32, - pub hover_color: Color32, - pub locked_color: Color32, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct OverlayStyle { - pub fill: Color32, - pub stroke: Stroke, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct TabStateStyle { - pub bg: Color32, - pub text_color: Color32, - pub accent_color: Color32, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct TabStyle { - pub active: TabStateStyle, - pub inactive: TabStateStyle, - pub hovered: TabStateStyle, - pub rounding: CornerRadius, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct TypographyStyle { - pub tab_title_font: FontId, - pub tab_icon_text_font: FontId, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct PaneStyleOverride { - pub header_bg: Option, - pub content_bg: Option, - pub border_color: Option, - pub accent_color: Option, -} - -impl PaneStyleOverride { - pub const fn none() -> Self { - Self { - header_bg: None, - content_bg: None, - border_color: None, - accent_color: None, - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct TabStyleOverride { - pub active_bg: Option, - pub inactive_bg: Option, - pub hovered_bg: Option, - pub text_color: Option, - pub accent_color: Option, - /// Override colour applied only to the leading icon, not the title text. - pub icon_color: Option, - /// Maximum header width for this tab when there is spare room. - /// `None` means the tab may stretch to fill the available width. - pub max_width: Option, -} - -impl TabStyleOverride { - pub const fn none() -> Self { - Self { - active_bg: None, - inactive_bg: None, - hovered_bg: None, - text_color: None, - accent_color: None, - icon_color: None, - max_width: None, - } - } -} - -/// All visual parameters for the panel layout system. -#[derive(Clone, Debug)] -pub struct PanelStyle { - pub header_height: f32, - pub collapsed_pane_thickness: f32, - pub handle_width: f32, - pub min_pane_size: f32, - pub content_inset: f32, - pub pane_rounding: CornerRadius, - pub typography: TypographyStyle, - pub header: HeaderStyle, - pub content: ContentStyle, - pub tabs: TabStyle, - pub handle: HandleStyle, - pub overlay: OverlayStyle, -} - -impl PanelStyle { - pub fn from_egui_style(style: &Style) -> Self { - let mut panel_style = Self::from_visuals(&style.visuals); - let button_font = style - .text_styles - .get(&TextStyle::Button) - .cloned() - .unwrap_or_else(|| FontId::proportional(12.0)); - let small_font = style - .text_styles - .get(&TextStyle::Small) - .cloned() - .unwrap_or_else(|| FontId::monospace(11.0)); - panel_style.typography = TypographyStyle { - tab_title_font: button_font, - tab_icon_text_font: small_font, - }; - panel_style - } - - pub fn from_visuals(visuals: &Visuals) -> Self { - let widgets = &visuals.widgets; - let inactive_fill = widgets.inactive.weak_bg_fill; - let hovered_fill = widgets.hovered.weak_bg_fill; - let active_fill = widgets.active.weak_bg_fill; - let accent = widgets.active.bg_fill; - let border = widgets.noninteractive.bg_stroke.color; - - Self { - header_height: 26.0, - collapsed_pane_thickness: 32.0, - handle_width: 5.0, - min_pane_size: 200.0, - content_inset: 0.0, - pane_rounding: visuals.window_corner_radius, - typography: TypographyStyle { - tab_title_font: FontId::new(12.0, FontFamily::Proportional), - tab_icon_text_font: FontId::new(11.0, FontFamily::Monospace), - }, - header: HeaderStyle { - bg: widgets.noninteractive.bg_fill, - border_color: border, - button: HeaderButtonStyle { - bg: inactive_fill, - hover_bg: hovered_fill, - stroke_color: border, - icon_color: widgets.inactive.fg_stroke.color, - hover_icon_color: widgets.hovered.fg_stroke.color, - rounding: CornerRadius::same(3), - }, - }, - content: ContentStyle { - bg: visuals.panel_fill, - border_color: border, - }, - tabs: TabStyle { - active: TabStateStyle { - bg: active_fill, - text_color: widgets.active.fg_stroke.color, - accent_color: accent, - }, - inactive: TabStateStyle { - bg: inactive_fill, - text_color: widgets.inactive.fg_stroke.color, - accent_color: accent, - }, - hovered: TabStateStyle { - bg: hovered_fill, - text_color: widgets.hovered.fg_stroke.color, - accent_color: accent, - }, - rounding: CornerRadius::same(3), - }, - handle: HandleStyle { - color: widgets.noninteractive.bg_fill, - hover_color: widgets.hovered.bg_fill, - locked_color: widgets.noninteractive.bg_fill.gamma_multiply(0.6), - }, - overlay: OverlayStyle { - fill: accent.gamma_multiply(0.35), - stroke: Stroke::new(1.0, accent), - }, - } - } - - pub fn pane_header_bg(&self, pane: Option) -> Color32 { - pane.and_then(|pane| pane.header_bg).unwrap_or(self.header.bg) - } - - pub fn pane_content_bg(&self, pane: Option) -> Color32 { - pane.and_then(|pane| pane.content_bg).unwrap_or(self.content.bg) - } - - pub fn pane_border_color(&self, pane: Option) -> Color32 { - pane.and_then(|pane| pane.border_color) - .unwrap_or(self.content.border_color) - } - - pub fn pane_accent_color(&self, pane: Option) -> Color32 { - pane.and_then(|pane| pane.accent_color) - .unwrap_or(self.tabs.active.accent_color) - } - - pub fn tab_state( - &self, - active: bool, - hovered: bool, - pane: Option, - tab: Option, - ) -> TabStateStyle { - let mut state = if active { - self.tabs.active - } else if hovered { - self.tabs.hovered - } else { - self.tabs.inactive - }; - - if let Some(tab) = tab { - state.bg = if active { - tab.active_bg.unwrap_or(state.bg) - } else if hovered { - tab.hovered_bg.unwrap_or(state.bg) - } else { - tab.inactive_bg.unwrap_or(state.bg) - }; - state.text_color = tab.text_color.unwrap_or(state.text_color); - state.accent_color = tab.accent_color.unwrap_or(state.accent_color); - } - - if let Some(pane) = pane { - state.accent_color = pane.accent_color.unwrap_or(state.accent_color); - } - - state - } -} - -impl Default for PanelStyle { - fn default() -> Self { - Self { - header_height: 26.0, - collapsed_pane_thickness: 32.0, - handle_width: 5.0, - min_pane_size: 200.0, - content_inset: 0.0, - pane_rounding: CornerRadius::same(2), - typography: TypographyStyle { - tab_title_font: FontId::new(12.0, FontFamily::Proportional), - tab_icon_text_font: FontId::new(11.0, FontFamily::Monospace), - }, - header: HeaderStyle { - bg: Color32::from_rgb(30, 30, 35), - border_color: Color32::from_rgb(45, 45, 55), - button: HeaderButtonStyle { - bg: Color32::from_rgb(35, 35, 42), - hover_bg: Color32::from_rgb(50, 50, 60), - stroke_color: Color32::from_rgb(45, 45, 55), - icon_color: Color32::from_rgb(210, 210, 220), - hover_icon_color: Color32::from_rgb(80, 130, 220), - rounding: CornerRadius::same(3), - }, - }, - content: ContentStyle { - bg: Color32::from_rgb(27, 27, 31), - border_color: Color32::from_rgb(45, 45, 55), - }, - tabs: TabStyle { - active: TabStateStyle { - bg: Color32::from_rgb(50, 50, 60), - text_color: Color32::from_rgb(210, 210, 220), - accent_color: Color32::from_rgb(80, 130, 220), - }, - inactive: TabStateStyle { - bg: Color32::from_rgb(35, 35, 42), - text_color: Color32::from_rgb(210, 210, 220), - accent_color: Color32::from_rgb(80, 130, 220), - }, - hovered: TabStateStyle { - bg: Color32::from_rgb(42, 42, 50), - text_color: Color32::from_rgb(220, 220, 228), - accent_color: Color32::from_rgb(80, 130, 220), - }, - rounding: CornerRadius::same(3), - }, - handle: HandleStyle { - color: Color32::from_rgb(45, 45, 55), - hover_color: Color32::from_rgb(80, 110, 180), - locked_color: Color32::from_rgb(34, 34, 40), - }, - overlay: OverlayStyle { - fill: Color32::from_rgba_premultiplied(80, 110, 180, 60), - stroke: Stroke::new(1.0, Color32::from_rgb(80, 130, 220)), - }, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tab_state_prefers_overrides() { - let style = PanelStyle::default(); - let pane = PaneStyleOverride { - accent_color: Some(Color32::from_rgb(1, 2, 3)), - ..PaneStyleOverride::none() - }; - let tab = TabStyleOverride { - active_bg: Some(Color32::from_rgb(4, 5, 6)), - text_color: Some(Color32::from_rgb(7, 8, 9)), - max_width: Some(120.0), - ..TabStyleOverride::none() - }; - - let state = style.tab_state(true, false, Some(pane), Some(tab)); - assert_eq!(state.bg, Color32::from_rgb(4, 5, 6)); - assert_eq!(state.text_color, Color32::from_rgb(7, 8, 9)); - assert_eq!(state.accent_color, Color32::from_rgb(1, 2, 3)); - } - - #[test] - fn from_visuals_uses_widget_palette() { - let visuals = Visuals::dark(); - let style = PanelStyle::from_visuals(&visuals); - assert_eq!(style.content.bg, visuals.panel_fill); - assert_eq!( - style.header.button.icon_color, - visuals.widgets.inactive.fg_stroke.color - ); - assert_eq!(style.overlay.stroke.color, visuals.widgets.active.bg_fill); - assert_eq!(style.typography.tab_title_font, FontId::proportional(12.0)); - assert_eq!(style.typography.tab_icon_text_font, FontId::monospace(11.0)); - } - - #[test] - fn from_egui_style_uses_button_and_small_text_styles() { - let mut egui_style = Style::default(); - egui_style - .text_styles - .insert(TextStyle::Button, FontId::proportional(16.0)); - egui_style - .text_styles - .insert(TextStyle::Small, FontId::monospace(13.0)); - - let style = PanelStyle::from_egui_style(&egui_style); - assert_eq!(style.typography.tab_title_font, FontId::proportional(16.0)); - assert_eq!(style.typography.tab_icon_text_font, FontId::monospace(13.0)); - } -} diff --git a/patches/grimdock/src/tab.rs b/patches/grimdock/src/tab.rs deleted file mode 100644 index 56cf74a..0000000 --- a/patches/grimdock/src/tab.rs +++ /dev/null @@ -1,216 +0,0 @@ -use crate::{ - style::TabStyleOverride, - tree::{PaneId, PaneRole}, -}; - -/// Per-tab drop constraints evaluated against the destination pane. -#[derive(Clone, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct TabDropPolicy { - /// If set, this tab may only be dropped into the given pane. - pub locked_to_pane: Option, - /// If set, this tab may only be dropped into panes with the given role. - pub locked_to_role: Option, - /// If set, this tab may only be dropped into panes in this allow-list. - pub allowed_panes: Option>, - /// If set, this tab may only be dropped into panes with roles in this allow-list. - pub allowed_roles: Option>, - /// Panes in this block-list reject this tab even if otherwise allowed. - pub blocked_panes: Vec, - /// Roles in this block-list reject this tab even if otherwise allowed. - pub blocked_roles: Vec, -} - -impl TabDropPolicy { - /// Return whether this tab may be dropped into the given pane target. - /// - /// Pane identity and semantic role are evaluated together. This lets - /// applications express either "only this pane instance" or "any pane with - /// this role" style rules. - pub fn allows_target(&self, pane_id: PaneId, role: Option) -> bool { - if let Some(locked_to_pane) = self.locked_to_pane { - return locked_to_pane == pane_id; - } - - if let Some(locked_to_role) = self.locked_to_role { - return role == Some(locked_to_role); - } - - if let Some(allowed_panes) = &self.allowed_panes { - if !allowed_panes.contains(&pane_id) { - return false; - } - } - - if let Some(allowed_roles) = &self.allowed_roles { - if !role.is_some_and(|role| allowed_roles.contains(&role)) { - return false; - } - } - - if self.blocked_panes.contains(&pane_id) { - return false; - } - - !role.is_some_and(|role| self.blocked_roles.contains(&role)) - } -} - -impl Default for TabDropPolicy { - fn default() -> Self { - Self { - locked_to_pane: None, - locked_to_role: None, - allowed_panes: None, - allowed_roles: None, - blocked_panes: Vec::new(), - blocked_roles: Vec::new(), - } - } -} - -/// Leading tab icon content rendered before the tab title. -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum TabIcon { - /// Short text marker such as ASCII tokens or compact glyphs. - Text(String), - /// Texture-backed image icon with an explicit display size. - Texture { - texture_id: egui::TextureId, - size: egui::Vec2, - }, - /// Single Unicode glyph rendered centred, with no trailing title space. - CenteredGlyph(String), -} - -/// A single tab that lives inside a pane. -/// -/// `T` is the caller-supplied identifier — must be `Clone + 'static`. -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Tab { - pub title: String, - pub id: T, - /// Optional leading icon rendered before the title in tab headers. - pub icon: Option, - /// Whether the tab may be dragged to another pane. - pub draggable: bool, - /// Per-tab destination constraints evaluated during drag-and-drop. - pub drop_policy: TabDropPolicy, - /// Whether the tab may be closed from the header UI. - pub closable: bool, - /// Optional visual overrides for this tab. - pub style_override: Option, -} - -impl Tab { - /// Create a new tab with a title and caller-owned identifier. - pub fn new(title: impl Into, id: T) -> Self { - Self { - title: title.into(), - id, - icon: None, - draggable: true, - drop_policy: TabDropPolicy::default(), - closable: false, - style_override: None, - } - } - - /// Set a short text marker as the leading tab visual. - pub fn with_leading_visual(mut self, leading_visual: impl Into) -> Self { - self.icon = Some(TabIcon::Text(leading_visual.into())); - self - } - - /// Set a single centred glyph as the only tab visual. - pub fn with_centered_glyph(mut self, glyph: impl Into) -> Self { - self.icon = Some(TabIcon::CenteredGlyph(glyph.into())); - self - } - - /// Set the leading icon payload directly. - pub fn with_icon(mut self, icon: TabIcon) -> Self { - self.icon = Some(icon); - self - } - - /// Set a texture-backed leading icon. - pub fn with_icon_texture(mut self, texture_id: egui::TextureId, size: egui::Vec2) -> Self { - self.icon = Some(TabIcon::Texture { texture_id, size }); - self - } - - /// Control whether this tab may be dragged at all. - pub fn with_draggable(mut self, draggable: bool) -> Self { - self.draggable = draggable; - self - } - - /// Replace the full tab drop-policy payload. - pub fn with_drop_policy(mut self, drop_policy: TabDropPolicy) -> Self { - self.drop_policy = drop_policy; - self - } - - /// Lock the tab to a specific pane instance. - pub fn with_locked_pane(mut self, pane_id: PaneId) -> Self { - self.drop_policy.locked_to_pane = Some(pane_id); - self - } - - /// Lock the tab to panes with a specific semantic role. - pub fn with_locked_role(mut self, role: PaneRole) -> Self { - self.drop_policy.locked_to_role = Some(role); - self - } - - /// Restrict the tab to a set of pane identifiers. - pub fn with_allowed_drop_panes( - mut self, - allowed_panes: impl IntoIterator, - ) -> Self { - self.drop_policy.allowed_panes = Some(allowed_panes.into_iter().collect()); - self - } - - /// Reject drops into a set of pane identifiers. - pub fn with_blocked_drop_panes( - mut self, - blocked_panes: impl IntoIterator, - ) -> Self { - self.drop_policy.blocked_panes = blocked_panes.into_iter().collect(); - self - } - - /// Restrict the tab to a set of pane roles. - pub fn with_allowed_drop_roles( - mut self, - allowed_roles: impl IntoIterator, - ) -> Self { - self.drop_policy.allowed_roles = Some(allowed_roles.into_iter().collect()); - self - } - - /// Reject drops into panes with a set of semantic roles. - pub fn with_blocked_drop_roles( - mut self, - blocked_roles: impl IntoIterator, - ) -> Self { - self.drop_policy.blocked_roles = blocked_roles.into_iter().collect(); - self - } - - /// Control whether the tab may be closed from the built-in header UI. - pub fn with_closable(mut self, closable: bool) -> Self { - self.closable = closable; - self - } - - /// Apply a per-tab style override used by the built-in header renderer. - pub fn with_style_override(mut self, style_override: TabStyleOverride) -> Self { - self.style_override = Some(style_override); - self - } -} diff --git a/patches/grimdock/src/tree.rs b/patches/grimdock/src/tree.rs deleted file mode 100644 index 0fc418e..0000000 --- a/patches/grimdock/src/tree.rs +++ /dev/null @@ -1,1782 +0,0 @@ -use egui::Rect; - -use crate::{ - style::PaneStyleOverride, - tab::Tab, -}; - -#[cfg(feature = "serde")] -fn serde_rect_nothing() -> Rect { - Rect::NOTHING -} - -/// Stable identifier for a pane leaf. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct PaneId(u64); - -impl PaneId { - /// Create a pane identifier from its raw integer value. - pub const fn from_raw(raw: u64) -> Self { - Self(raw) - } - - /// Return the raw integer value of this identifier. - pub const fn into_raw(self) -> u64 { - self.0 - } -} - -/// Builder for constructing a pane with explicit tabs, focus, and options. -#[derive(Clone, Debug)] -pub struct PaneBuilder { - tabs: Vec>, - focused: usize, - options: PaneOptions, - collapsed: bool, -} - -impl PaneBuilder { - /// Start a pane builder with a single initial tab. - pub fn new(tab: Tab) -> Self { - Self { - tabs: vec![tab], - focused: 0, - options: PaneOptions::default(), - collapsed: false, - } - } - - /// Replace the builder's tab list. - pub fn with_tabs(mut self, tabs: Vec>) -> Self { - assert!(!tabs.is_empty(), "PaneBuilder requires at least one tab"); - self.tabs = tabs; - self.focused = self.focused.min(self.tabs.len().saturating_sub(1)); - self - } - - /// Append a tab to the pane being built. - pub fn push_tab(mut self, tab: Tab) -> Self { - self.tabs.push(tab); - self - } - - /// Set the initially focused tab index. - pub fn with_focused(mut self, focused: usize) -> Self { - assert!( - focused < self.tabs.len(), - "focused tab index must be in bounds for PaneBuilder", - ); - self.focused = focused; - self - } - - /// Replace the full options payload for the pane being built. - pub fn with_options(mut self, options: PaneOptions) -> Self { - self.options = options; - self - } - - /// Assign a root-edge anchor to the pane being built. - pub fn with_anchor(mut self, anchor: PaneAnchor) -> Self { - self.options.anchor = Some(anchor); - self - } - - /// Assign a semantic role to the pane being built. - pub fn with_role(mut self, role: PaneRole) -> Self { - self.options.role = Some(role); - self - } - - /// Set the initial collapsed state for the pane being built. - pub fn with_collapsed(mut self, collapsed: bool) -> Self { - self.collapsed = collapsed; - self - } - - fn build(self, pane: PaneId) -> Node { - assert!(!self.tabs.is_empty(), "PaneBuilder requires at least one tab"); - assert!( - self.focused < self.tabs.len(), - "focused tab index must be in bounds for PaneBuilder", - ); - Node::Leaf { - pane, - tabs: self.tabs, - focused: self.focused, - options: self.options, - collapsed: self.collapsed, - rect: Rect::NOTHING, - } - } -} - -/// Controls when a pane's header/tab bar is shown. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum HeaderVisibility { - /// Always show the header, even for a single tab. - Always, - /// Show the header only when the pane holds multiple tabs. - WhenMultipleTabs, - /// Never show the header. - Hidden, -} - -/// Fine-grained permissions for tab merge and directional split drops. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct DropPolicy { - /// Whether tabs may be merged into this pane. - pub allow_merge: bool, - /// Whether a drop may create a left split relative to this pane. - pub allow_split_left: bool, - /// Whether a drop may create a right split relative to this pane. - pub allow_split_right: bool, - /// Whether a drop may create a top split relative to this pane. - pub allow_split_top: bool, - /// Whether a drop may create a bottom split relative to this pane. - pub allow_split_bottom: bool, -} - -impl DropPolicy { - pub const fn all() -> Self { - Self { - allow_merge: true, - allow_split_left: true, - allow_split_right: true, - allow_split_top: true, - allow_split_bottom: true, - } - } - - pub const fn merge_only() -> Self { - Self { - allow_merge: true, - allow_split_left: false, - allow_split_right: false, - allow_split_top: false, - allow_split_bottom: false, - } - } - - pub const fn none() -> Self { - Self { - allow_merge: false, - allow_split_left: false, - allow_split_right: false, - allow_split_top: false, - allow_split_bottom: false, - } - } - - pub const fn allows_split(self, dir: SplitDir, side: ChildSide) -> bool { - match (dir, side) { - (SplitDir::Horizontal, ChildSide::First) => self.allow_split_left, - (SplitDir::Horizontal, ChildSide::Second) => self.allow_split_right, - (SplitDir::Vertical, ChildSide::First) => self.allow_split_top, - (SplitDir::Vertical, ChildSide::Second) => self.allow_split_bottom, - } - } -} - -impl Default for DropPolicy { - fn default() -> Self { - Self::all() - } -} - -/// Stable root-edge placement for panes that should keep a semantic location. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum PaneAnchor { - Left, - Right, - Top, - Bottom, - Center, -} - -impl PaneAnchor { - fn split_dir_and_side(self) -> Option<(SplitDir, ChildSide)> { - match self { - PaneAnchor::Left => Some((SplitDir::Horizontal, ChildSide::First)), - PaneAnchor::Right => Some((SplitDir::Horizontal, ChildSide::Second)), - PaneAnchor::Top => Some((SplitDir::Vertical, ChildSide::First)), - PaneAnchor::Bottom => Some((SplitDir::Vertical, ChildSide::Second)), - PaneAnchor::Center => None, - } - } -} - -/// Semantic pane role for higher-level policy targeting. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum PaneRole { - Sidebar, - Editor, - Inspector, - Terminal, - BottomPanel, - Custom(u64), -} - -/// Behaviour flags for a pane leaf. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct PaneOptions { - /// Header/tab bar visibility policy. - pub header_visibility: HeaderVisibility, - /// Whether the pane may be collapsed/expanded from the header UI. - pub allow_collapse: bool, - /// Whether tabs may be reordered within this pane. - pub allow_tab_reorder: bool, - /// Whether tabs in this pane may be dragged out of it. - pub allow_tab_drag_out: bool, - /// Whether resize-handle drags that affect this pane are allowed. - pub allow_resize: bool, - /// Whether drag/drop layout mutations are allowed for this pane. - pub drop_policy: DropPolicy, - /// Whether structural layout mutations should be frozen for this pane. - pub lock_layout: bool, - /// Optional semantic root-edge placement for this pane. - pub anchor: Option, - /// Optional semantic role for higher-level app policy and role-based tab targeting. - pub role: Option, - /// Whether an empty pane should stay in the layout instead of collapsing. - pub persist_when_empty: bool, - /// Whether the pane content background should be painted by the dock system. - pub paint_content_bg: bool, - /// Optional visual overrides for this pane. - pub style_override: Option, -} - -impl Default for PaneOptions { - fn default() -> Self { - Self { - header_visibility: HeaderVisibility::Always, - allow_collapse: true, - allow_tab_reorder: true, - allow_tab_drag_out: true, - allow_resize: true, - drop_policy: DropPolicy::all(), - lock_layout: false, - anchor: None, - role: None, - persist_when_empty: false, - paint_content_bg: true, - style_override: None, - } - } -} - -/// Direction of a split between two child panes. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum SplitDir { - /// The two children are placed side-by-side (left | right). - Horizontal, - /// The two children are stacked (top / bottom). - Vertical, -} - -/// Which child slot a newly created pane should land in when calling -/// [`PanelTree::split_leaf`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ChildSide { - /// Left child (horizontal) or top child (vertical). - First, - /// Right child (horizontal) or bottom child (vertical). - Second, -} - -/// A single node in the flat-array binary tree. -#[derive(Clone, Debug)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum Node { - /// Padding slot — exists only to keep the array a complete binary tree shape. - Empty, - /// An internal node that divides its rectangle between two children. - Split { - dir: SplitDir, - /// Fraction `[0, 1]` of the rectangle given to the *first* child - /// (left for horizontal, top for vertical). - ratio: f32, - /// Rectangle assigned during the last layout pass. - #[cfg_attr(feature = "serde", serde(skip, default = "serde_rect_nothing"))] - rect: Rect, - }, - /// A leaf node containing one or more tabs. - Leaf { - /// Stable identifier for this pane. - pane: PaneId, - tabs: Vec>, - /// Index into `tabs` of the currently focused tab. - focused: usize, - /// Behaviour flags for this pane. - options: PaneOptions, - /// Whether the pane is collapsed to a strip along its split axis. - collapsed: bool, - /// Full pane rectangle (including header) assigned during last layout pass. - #[cfg_attr(feature = "serde", serde(skip, default = "serde_rect_nothing"))] - rect: Rect, - }, -} - - -/// The panel layout tree — a binary tree stored as a flat heap-indexed array. -/// -/// Index arithmetic (same as a binary heap): -/// - Root: `0` -/// - Left child of `i`: `2*i + 1` -/// - Right child of `i`: `2*i + 2` -/// - Parent of `i` (for `i > 0`): `(i - 1) / 2` -#[derive(Clone, Debug)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct PanelTree { - pub(crate) nodes: Vec>, - pub(crate) next_pane_id: u64, -} - -/// Shared read-only access to a pane. -pub struct Pane<'a, T: Clone + 'static> { - tree: &'a PanelTree, - leaf_idx: usize, -} - -impl<'a, T: Clone + 'static> Pane<'a, T> { - pub fn id(&self) -> PaneId { - self.tree - .pane_id_at(self.leaf_idx) - .expect("Pane handle must point at a leaf") - } - - pub fn index(&self) -> usize { - self.leaf_idx - } - - pub fn options(&self) -> PaneOptions { - self.tree - .pane_options(self.leaf_idx) - .expect("Pane handle must point at a leaf") - } - - pub fn is_collapsed(&self) -> bool { - self.tree.is_collapsed(self.leaf_idx) - } - - pub fn tabs(&self) -> &[Tab] { - match self.tree.node(self.leaf_idx) { - Node::Leaf { tabs, .. } => tabs, - _ => unreachable!("Pane handle must point at a leaf"), - } - } - - pub fn focused_index(&self) -> usize { - match self.tree.node(self.leaf_idx) { - Node::Leaf { focused, .. } => *focused, - _ => unreachable!("Pane handle must point at a leaf"), - } - } - - pub fn focused_tab(&self) -> Option<&Tab> { - let focused = self.focused_index(); - self.tabs().get(focused) - } -} - -/// Mutable access to a pane. -pub struct PaneMut<'a, T: Clone + 'static> { - tree: &'a mut PanelTree, - leaf_idx: usize, -} - -impl<'a, T: Clone + 'static> PaneMut<'a, T> { - pub fn id(&self) -> PaneId { - self.tree - .pane_id_at(self.leaf_idx) - .expect("Pane handle must point at a leaf") - } - - pub fn index(&self) -> usize { - self.leaf_idx - } - - pub fn options(&self) -> PaneOptions { - self.tree - .pane_options(self.leaf_idx) - .expect("Pane handle must point at a leaf") - } - - pub fn set_options(&mut self, options: PaneOptions) { - let changed = self.tree.set_pane_options(self.leaf_idx, options); - debug_assert!(changed, "Pane handle must point at a leaf"); - } - - pub fn is_collapsed(&self) -> bool { - self.tree.is_collapsed(self.leaf_idx) - } - - pub fn set_collapsed(&mut self, collapsed: bool) -> bool { - self.tree.set_collapsed(self.leaf_idx, collapsed) - } - - pub fn toggle_collapsed(&mut self) -> bool { - self.tree.toggle_collapsed(self.leaf_idx) - } - - pub fn tabs(&self) -> &[Tab] { - match self.tree.node(self.leaf_idx) { - Node::Leaf { tabs, .. } => tabs, - _ => unreachable!("Pane handle must point at a leaf"), - } - } - - pub fn tabs_mut(&mut self) -> &mut Vec> { - match self.tree.node_mut(self.leaf_idx) { - Node::Leaf { tabs, .. } => tabs, - _ => unreachable!("Pane handle must point at a leaf"), - } - } - - pub fn focused_index(&self) -> usize { - match self.tree.node(self.leaf_idx) { - Node::Leaf { focused, .. } => *focused, - _ => unreachable!("Pane handle must point at a leaf"), - } - } - - pub fn focused_tab(&self) -> Option<&Tab> { - let focused = self.focused_index(); - self.tabs().get(focused) - } - - pub fn set_focused_index(&mut self, focused: usize) -> bool { - match self.tree.node_mut(self.leaf_idx) { - Node::Leaf { - tabs, - focused: current, - .. - } if focused < tabs.len() => { - *current = focused; - true - } - Node::Leaf { .. } => false, - _ => unreachable!("Pane handle must point at a leaf"), - } - } - - pub fn push_tab(&mut self, tab: Tab) { - self.tree.insert_tab_into_leaf(self.leaf_idx, tab); - } - - pub fn close_all_tabs(&mut self) -> Vec { - self.tree.close_all_tabs_in_leaf(self.leaf_idx) - } - - pub fn close_other_tabs(&mut self, keep_pos: usize) -> Vec { - self.tree.close_other_tabs_in_leaf(self.leaf_idx, keep_pos) - } - - pub fn remove_tab_at(&mut self, tab_pos: usize) -> Option> { - self.tree.remove_tab_at(self.leaf_idx, tab_pos) - } - - pub fn split(&mut self, dir: SplitDir, new_tab: Tab, side: ChildSide) -> PaneId { - self.tree.split_leaf(self.leaf_idx, dir, new_tab, side) - } - - pub fn split_with( - &mut self, - dir: SplitDir, - new_pane: PaneBuilder, - side: ChildSide, - ) -> PaneId { - self.tree.split_leaf_with(self.leaf_idx, dir, new_pane, side) - } -} - -impl PanelTree { - /// Create a tree containing a single pane from a builder. - pub fn from_pane(root: PaneBuilder) -> Self { - Self { - nodes: vec![root.build(PaneId(1))], - next_pane_id: 2, - } - } - - /// Create a tree containing a single pane with the given tabs. - pub fn new(tabs: Vec>) -> Self { - assert!(!tabs.is_empty(), "PanelTree requires at least one tab"); - Self::from_pane(PaneBuilder { - tabs, - focused: 0, - options: PaneOptions::default(), - collapsed: false, - }) - } - - fn alloc_pane_id(&mut self) -> PaneId { - let pane = PaneId(self.next_pane_id); - self.next_pane_id += 1; - pane - } - - // ── Index helpers ──────────────────────────────────────────────────────── - - pub(crate) fn left_child(i: usize) -> usize { - 2 * i + 1 - } - - pub(crate) fn right_child(i: usize) -> usize { - 2 * i + 2 - } - - pub(crate) fn parent(i: usize) -> Option { - if i == 0 { - None - } else { - Some((i - 1) / 2) - } - } - - pub(crate) fn sibling(i: usize) -> Option { - let parent = Self::parent(i)?; - let left = Self::left_child(parent); - let right = Self::right_child(parent); - if i == left { - Some(right) - } else { - Some(left) - } - } - - // ── Node access ───────────────────────────────────────────────────────── - - pub(crate) fn node(&self, idx: usize) -> &Node { - self.nodes.get(idx).unwrap_or(&Node::Empty) - } - - pub fn node_mut(&mut self, idx: usize) -> &mut Node { - self.ensure_capacity(idx); - &mut self.nodes[idx] - } - - /// Return the stable pane identifier for a leaf index. - pub fn pane_id_at(&self, leaf_idx: usize) -> Option { - match self.node(leaf_idx) { - Node::Leaf { pane, .. } => Some(*pane), - _ => None, - } - } - - /// Return the current leaf index for a pane identifier. - pub fn pane_index(&self, pane_id: PaneId) -> Option { - self.leaf_indices() - .find(|&leaf_idx| self.pane_id_at(leaf_idx) == Some(pane_id)) - } - - /// Return the pane currently assigned to the given anchor, if any. - pub fn find_pane_with_anchor(&self, anchor: PaneAnchor) -> Option { - self.leaf_indices().find_map(|leaf_idx| match self.node(leaf_idx) { - Node::Leaf { pane, options, .. } if options.anchor == Some(anchor) => Some(*pane), - _ => None, - }) - } - - /// Return the pane currently assigned to the given role, if any. - pub fn find_pane_with_role(&self, role: PaneRole) -> Option { - self.leaf_indices().find_map(|leaf_idx| match self.node(leaf_idx) { - Node::Leaf { pane, options, .. } if options.role == Some(role) => Some(*pane), - _ => None, - }) - } - - /// Borrow a pane by stable pane identifier. - pub fn pane(&self, pane_id: PaneId) -> Option> { - let leaf_idx = self.pane_index(pane_id)?; - Some(Pane { tree: self, leaf_idx }) - } - - /// Mutably borrow a pane by stable pane identifier. - pub fn pane_mut(&mut self, pane_id: PaneId) -> Option> { - let leaf_idx = self.pane_index(pane_id)?; - Some(PaneMut { - tree: self, - leaf_idx, - }) - } - - /// Return the current pane options for a leaf. - /// - /// This is the low-level leaf-indexed access path. Prefer `pane(...)` or - /// `pane_mut(...)` when you already have a `PaneId`. - pub fn pane_options(&self, leaf_idx: usize) -> Option { - match self.node(leaf_idx) { - Node::Leaf { options, .. } => Some(*options), - _ => None, - } - } - - /// Mutate the pane options for a leaf node. - /// - /// This is the low-level leaf-indexed access path. Prefer `pane_mut(...)` - /// for typical pane-centric code. - pub fn set_pane_options(&mut self, leaf_idx: usize, options: PaneOptions) -> bool { - match self.node_mut(leaf_idx) { - Node::Leaf { options: current, .. } => { - *current = options; - true - } - _ => false, - } - } - - fn clear_anchor_owner(&mut self, anchor: PaneAnchor, except: Option) { - let pane_ids = self - .leaf_indices() - .filter_map(|leaf_idx| match self.node(leaf_idx) { - Node::Leaf { pane, options, .. } - if options.anchor == Some(anchor) && Some(*pane) != except => - { - Some(*pane) - } - _ => None, - }) - .collect::>(); - - for pane_id in pane_ids { - if let Some(mut pane) = self.pane_mut(pane_id) { - let mut options = pane.options(); - options.anchor = None; - pane.set_options(options); - } - } - } - - /// Return whether the given pane may currently be removed from the layout. - pub fn can_remove_pane(&self, pane_id: PaneId) -> bool { - let Some(leaf_idx) = self.pane_index(pane_id) else { - return false; - }; - match self.node(leaf_idx) { - Node::Leaf { options, .. } if options.lock_layout => false, - Node::Leaf { options, .. } if leaf_idx == 0 && !options.persist_when_empty => false, - Node::Leaf { .. } => true, - _ => false, - } - } - - /// Return whether the header should be visible for the given leaf. - pub fn header_visible(&self, leaf_idx: usize) -> bool { - match self.node(leaf_idx) { - Node::Leaf { - tabs, - options, - collapsed, - .. - } => { - if *collapsed { - true - } else if tabs.is_empty() && options.persist_when_empty { - true - } else { - match options.header_visibility { - HeaderVisibility::Always => true, - HeaderVisibility::WhenMultipleTabs => tabs.len() > 1, - HeaderVisibility::Hidden => false, - } - } - } - _ => false, - } - } - - /// Return whether a leaf is currently collapsed. - pub fn is_collapsed(&self, leaf_idx: usize) -> bool { - matches!(self.node(leaf_idx), Node::Leaf { collapsed: true, .. }) - } - - /// Update the collapsed state for a leaf. - pub fn set_collapsed(&mut self, leaf_idx: usize, collapsed: bool) -> bool { - match self.node_mut(leaf_idx) { - Node::Leaf { - collapsed: current, - options, - .. - } => { - if options.lock_layout { - return false; - } - if collapsed && !options.allow_collapse { - return false; - } - *current = collapsed; - true - } - _ => false, - } - } - - /// Toggle the collapsed state for a leaf. - pub fn toggle_collapsed(&mut self, leaf_idx: usize) -> bool { - let collapsed = self.is_collapsed(leaf_idx); - self.set_collapsed(leaf_idx, !collapsed) - } - - fn ensure_capacity(&mut self, idx: usize) { - if idx >= self.nodes.len() { - self.nodes.resize_with(idx + 1, || Node::Empty); - } - } - - // ── Iteration ──────────────────────────────────────────────────────────── - - /// Iterate over all indices that hold a `Leaf` node. - pub fn leaf_indices(&self) -> impl Iterator + '_ { - self.nodes.iter().enumerate().filter_map(|(i, n)| { - if matches!(n, Node::Leaf { .. }) { - Some(i) - } else { - None - } - }) - } - - /// Return the number of tabs in a leaf node, or `None` if `idx` is not a leaf. - pub fn leaf_tab_count(&self, idx: usize) -> Option { - match self.node(idx) { - Node::Leaf { tabs, .. } => Some(tabs.len()), - _ => None, - } - } - - pub(crate) fn subtree_resize_locked(&self, idx: usize) -> bool { - match self.node(idx) { - Node::Empty => false, - Node::Split { .. } => { - self.subtree_resize_locked(Self::left_child(idx)) - || self.subtree_resize_locked(Self::right_child(idx)) - } - Node::Leaf { options, .. } => options.lock_layout || !options.allow_resize, - } - } - - /// Return whether the resize handle for a split node is locked. - /// - /// The ownership model is subtree-based: a split handle is locked if - /// either child subtree contains a pane that is layout-locked or marked - /// non-resizable. - pub fn split_resize_locked(&self, split_idx: usize) -> bool { - match self.node(split_idx) { - Node::Split { .. } => { - self.subtree_resize_locked(Self::left_child(split_idx)) - || self.subtree_resize_locked(Self::right_child(split_idx)) - } - _ => false, - } - } - - /// Find the leaf and tab position for a tab identifier. - pub fn find_tab(&self, id: &T) -> Option<(usize, usize)> - where - T: PartialEq, - { - self.leaf_indices().find_map(|leaf_idx| match self.node(leaf_idx) { - Node::Leaf { tabs, .. } => tabs - .iter() - .position(|tab| &tab.id == id) - .map(|tab_pos| (leaf_idx, tab_pos)), - _ => None, - }) - } - - /// Return the leaf index that contains the given tab identifier. - pub fn find_leaf_containing(&self, id: &T) -> Option - where - T: PartialEq, - { - self.find_tab(id).map(|(leaf_idx, _)| leaf_idx) - } - - /// Return the stable pane identifier that contains the given tab. - pub fn find_pane_containing(&self, id: &T) -> Option - where - T: PartialEq, - { - let leaf_idx = self.find_leaf_containing(id)?; - self.pane_id_at(leaf_idx) - } - - /// Focus the tab with the given identifier if it exists. - pub fn focus_tab(&mut self, id: &T) -> bool - where - T: PartialEq, - { - let Some((leaf_idx, tab_pos)) = self.find_tab(id) else { - return false; - }; - if let Node::Leaf { focused, .. } = self.node_mut(leaf_idx) { - *focused = tab_pos; - true - } else { - false - } - } - - /// Insert a tab into an existing leaf, focusing the inserted tab. - pub fn insert_tab_into_leaf(&mut self, leaf_idx: usize, tab: Tab) { - match self.node_mut(leaf_idx) { - Node::Leaf { tabs, focused, .. } => { - tabs.push(tab); - *focused = tabs.len() - 1; - } - _ => panic!("insert_tab_into_leaf called on non-Leaf node"), - } - } - - /// Ensure a tab exists in the tree, inserting it into `leaf_idx` if missing. - pub fn ensure_tab_in_leaf(&mut self, leaf_idx: usize, tab: Tab) -> bool - where - T: PartialEq, - { - if self.find_tab(&tab.id).is_some() { - return false; - } - self.insert_tab_into_leaf(leaf_idx, tab); - true - } - - /// Remove a tab by leaf index and position, returning the removed tab. - pub fn remove_tab_at(&mut self, leaf_idx: usize, tab_pos: usize) -> Option> { - let removed = self.extract_tab_at(leaf_idx, tab_pos)?; - self.collapse_empty_leaf(leaf_idx); - Some(removed) - } - - fn extract_tab_at(&mut self, leaf_idx: usize, tab_pos: usize) -> Option> { - match self.node_mut(leaf_idx) { - Node::Leaf { tabs, focused, .. } => { - if tab_pos >= tabs.len() { - return None; - } - let removed = tabs.remove(tab_pos); - if !tabs.is_empty() && *focused >= tabs.len() { - *focused = tabs.len() - 1; - } - Some(removed) - } - _ => return None, - } - } - - /// Remove a tab by identifier, returning the removed tab. - pub fn remove_tab(&mut self, id: &T) -> Option> - where - T: PartialEq, - { - let (leaf_idx, tab_pos) = self.find_tab(id)?; - self.remove_tab_at(leaf_idx, tab_pos) - } - - /// Close all closable tabs in a leaf and return their identifiers. - pub fn close_all_tabs_in_leaf(&mut self, leaf_idx: usize) -> Vec { - self.close_tabs_in_leaf_where(leaf_idx, |_, tab| tab.closable) - } - - /// Close all closable tabs except the kept position and return their identifiers. - pub fn close_other_tabs_in_leaf(&mut self, leaf_idx: usize, keep_pos: usize) -> Vec { - self.close_tabs_in_leaf_where(leaf_idx, |idx, tab| idx != keep_pos && tab.closable) - } - - fn close_tabs_in_leaf_where( - &mut self, - leaf_idx: usize, - mut should_close: impl FnMut(usize, &Tab) -> bool, - ) -> Vec { - let removed = match self.node_mut(leaf_idx) { - Node::Leaf { tabs, focused, .. } => { - let mut kept = Vec::with_capacity(tabs.len()); - let mut removed = Vec::new(); - for (idx, tab) in std::mem::take(tabs).into_iter().enumerate() { - if should_close(idx, &tab) { - removed.push(tab.id); - } else { - kept.push(tab); - } - } - *tabs = kept; - if !tabs.is_empty() { - *focused = (*focused).min(tabs.len() - 1); - } - removed - } - _ => Vec::new(), - }; - - self.collapse_empty_leaf(leaf_idx); - removed - } - - /// Move a tab to a new position within the same leaf, focusing it. - pub fn move_tab_within_leaf( - &mut self, - leaf_idx: usize, - from_pos: usize, - to_pos: usize, - ) -> bool { - match self.node_mut(leaf_idx) { - Node::Leaf { tabs, focused, .. } => { - if from_pos >= tabs.len() || to_pos >= tabs.len() { - return false; - } - if from_pos == to_pos { - *focused = to_pos; - return true; - } - let tab = tabs.remove(from_pos); - tabs.insert(to_pos, tab); - *focused = to_pos; - true - } - _ => false, - } - } - - // ── Mutation operations ────────────────────────────────────────────────── - - /// Convert the leaf at `leaf_idx` into a `Split` node, placing the - /// existing leaf content on one side and a new single-tab leaf on the - /// other. - pub fn split_leaf( - &mut self, - leaf_idx: usize, - dir: SplitDir, - new_tab: Tab, - side: ChildSide, - ) -> PaneId { - self.split_leaf_with(leaf_idx, dir, PaneBuilder::new(new_tab), side) - } - - /// Convert the leaf at `leaf_idx` into a `Split` node, placing the - /// existing leaf content on one side and a caller-configured pane on the - /// other. - pub fn split_leaf_with( - &mut self, - leaf_idx: usize, - dir: SplitDir, - new_pane: PaneBuilder, - side: ChildSide, - ) -> PaneId { - let left = Self::left_child(leaf_idx); - let right = Self::right_child(leaf_idx); - - // Ensure space for both children. - self.ensure_capacity(right); - - // Extract the existing leaf's content before mutating. - let existing = match self.nodes[leaf_idx].clone() { - Node::Leaf { - pane, - tabs, - focused, - options, - .. - } => (pane, tabs, focused, options), - _ => panic!("split_leaf called on non-Leaf node"), - }; - - // Build the two child leaves. - let existing_leaf = Node::Leaf { - pane: existing.0, - tabs: existing.1, - focused: existing.2, - options: existing.3, - collapsed: false, - rect: Rect::NOTHING, - }; - let new_pane_id = self.alloc_pane_id(); - let new_leaf = new_pane.build(new_pane_id); - - let (first_leaf, second_leaf) = match side { - ChildSide::First => (new_leaf, existing_leaf), - ChildSide::Second => (existing_leaf, new_leaf), - }; - - // Promote current node to a Split, then place children. - self.nodes[leaf_idx] = Node::Split { - dir, - ratio: 0.5, - rect: Rect::NOTHING, - }; - // Recursively copy any existing subtrees at left/right out of the way - // (they should be Empty for a former leaf, but be safe). - self.nodes[left] = first_leaf; - self.nodes[right] = second_leaf; - new_pane_id - } - - /// Wrap the current root in a new split, placing `new_tab` on one side - /// and the existing layout subtree on the other. - pub fn wrap_root_with_split( - &mut self, - dir: SplitDir, - new_tab: Tab, - side: ChildSide, - ) -> PaneId { - self.wrap_root_with_split_with(dir, PaneBuilder::new(new_tab), side) - } - - /// Wrap the current root in a new split, placing a caller-configured pane - /// on one side and the existing layout subtree on the other. - pub fn wrap_root_with_split_with( - &mut self, - dir: SplitDir, - new_pane: PaneBuilder, - side: ChildSide, - ) -> PaneId { - let old_nodes = std::mem::take(&mut self.nodes); - let existing_child = match side { - ChildSide::First => Self::right_child(0), - ChildSide::Second => Self::left_child(0), - }; - let new_tab_child = match side { - ChildSide::First => Self::left_child(0), - ChildSide::Second => Self::right_child(0), - }; - - self.nodes = vec![Node::Split { - dir, - ratio: 0.5, - rect: Rect::NOTHING, - }]; - - self.ensure_capacity(new_tab_child); - let new_pane_id = self.alloc_pane_id(); - self.nodes[new_tab_child] = new_pane.build(new_pane_id); - - copy_subtree_with_offset(&old_nodes, 0, &mut self.nodes, existing_child); - new_pane_id - } - - fn wrap_root_with_existing_leaf( - &mut self, - dir: SplitDir, - new_leaf: Node, - side: ChildSide, - ) -> PaneId { - let old_nodes = std::mem::take(&mut self.nodes); - let existing_child = match side { - ChildSide::First => Self::right_child(0), - ChildSide::Second => Self::left_child(0), - }; - let new_leaf_child = match side { - ChildSide::First => Self::left_child(0), - ChildSide::Second => Self::right_child(0), - }; - - let pane_id = match &new_leaf { - Node::Leaf { pane, .. } => *pane, - _ => panic!("wrap_root_with_existing_leaf requires a leaf"), - }; - - self.nodes = vec![Node::Split { - dir, - ratio: 0.5, - rect: Rect::NOTHING, - }]; - - self.ensure_capacity(new_leaf_child); - self.nodes[new_leaf_child] = new_leaf; - - copy_subtree_with_offset(&old_nodes, 0, &mut self.nodes, existing_child); - pane_id - } - - /// Ensure a pane exists at the requested root-edge anchor. - /// - /// If a pane already owns the anchor, it is returned unchanged. Otherwise - /// the current root is wrapped and the new pane is placed on the requested - /// edge. Non-center anchors default to `persist_when_empty = true` so the - /// anchored location remains available after its tabs are moved away. - pub fn ensure_pane_at_anchor( - &mut self, - anchor: PaneAnchor, - mut pane: PaneBuilder, - ) -> PaneId { - if let Some(existing) = self.find_pane_with_anchor(anchor) { - return existing; - } - - let mut options = pane.options; - options.anchor = Some(anchor); - if anchor != PaneAnchor::Center { - options.persist_when_empty = true; - } - pane.options = options; - - match anchor.split_dir_and_side() { - Some((dir, side)) => self.wrap_root_with_split_with(dir, pane, side), - None => { - if let Node::Leaf { pane, options, .. } = self.node_mut(0) { - options.anchor = Some(PaneAnchor::Center); - *pane - } else { - self.wrap_root_with_split_with(SplitDir::Horizontal, pane, ChildSide::Second) - } - } - } - } - - /// Remove a pane from the layout, returning the tab identifiers it held. - /// - /// If the pane is persistent it is cleared and left in place as an empty - /// placeholder. If it is the sole root pane and not persistent, removal is - /// rejected. - pub fn remove_pane(&mut self, pane_id: PaneId) -> Option> { - if !self.can_remove_pane(pane_id) { - return None; - } - - let leaf_idx = self.pane_index(pane_id)?; - let mut removed = Vec::new(); - let persist_when_empty = match self.node_mut(leaf_idx) { - Node::Leaf { - tabs, - focused, - options, - .. - } => { - removed.extend(tabs.drain(..).map(|tab| tab.id)); - *focused = 0; - options.persist_when_empty - } - _ => return None, - }; - - if !persist_when_empty { - self.collapse_empty_leaf(leaf_idx); - } - - Some(removed) - } - - /// Move an existing pane to a stable root-edge anchor, preserving its - /// pane identifier and contents. - /// - /// This is the semantic location-oriented path. It complements - /// `PaneId`-based identity and `PaneRole`-based policy targeting. - pub fn move_pane_to_anchor(&mut self, pane_id: PaneId, anchor: PaneAnchor) -> Option { - let leaf_idx = self.pane_index(pane_id)?; - if matches!(self.node(leaf_idx), Node::Leaf { options, .. } if options.lock_layout) { - return None; - } - - self.clear_anchor_owner(anchor, Some(pane_id)); - - if leaf_idx == 0 { - if let Node::Leaf { options, .. } = self.node_mut(0) { - options.anchor = Some(anchor); - } - return Some(pane_id); - } - - let mut relocated_leaf = match self.node(leaf_idx).clone() { - Node::Leaf { pane, tabs, focused, mut options, .. } => { - options.anchor = Some(anchor); - if anchor != PaneAnchor::Center { - options.persist_when_empty = true; - } - Node::Leaf { - pane, - tabs, - focused, - options, - collapsed: false, - rect: Rect::NOTHING, - } - } - _ => return None, - }; - - if let Node::Leaf { options, .. } = self.node_mut(leaf_idx) { - options.persist_when_empty = false; - } - if let Node::Leaf { tabs, focused, .. } = self.node_mut(leaf_idx) { - tabs.clear(); - *focused = 0; - } - self.collapse_empty_leaf(leaf_idx); - - match anchor.split_dir_and_side() { - Some((dir, side)) => Some(self.wrap_root_with_existing_leaf(dir, relocated_leaf, side)), - None => { - if let Node::Leaf { options, .. } = &mut relocated_leaf { - options.anchor = Some(PaneAnchor::Center); - } - Some(self.wrap_root_with_existing_leaf( - SplitDir::Horizontal, - relocated_leaf, - ChildSide::Second, - )) - } - } - } - - /// Move the tab at position `tab_pos` in `src_leaf` into `dst_leaf`. - /// - /// The tab is appended at the end of the destination's tab list and focused. - pub(crate) fn merge_tab_into(&mut self, src_leaf: usize, tab_pos: usize, dst_leaf: usize) { - let tab = self - .extract_tab_at(src_leaf, tab_pos) - .expect("merge_tab_into: src is not a Leaf or tab index is invalid"); - - // Insert into destination. - match &mut self.nodes[dst_leaf] { - Node::Leaf { tabs, focused, .. } => { - tabs.push(tab); - *focused = tabs.len() - 1; - } - _ => panic!("merge_tab_into: dst is not a Leaf"), - } - } - - /// Collapse an empty leaf node, promoting its sibling subtree one level up. - /// - /// If the leaf is not empty this is a no-op. If the leaf is the root, the - /// tree is left in its current state (cannot collapse sole root). - pub(crate) fn collapse_empty_leaf(&mut self, leaf_idx: usize) { - // Check that the leaf is actually empty. - let (is_empty, persist_when_empty) = match &self.nodes[leaf_idx] { - Node::Leaf { tabs, options, .. } => (tabs.is_empty(), options.persist_when_empty), - _ => return, - }; - if !is_empty { - return; - } - if persist_when_empty { - return; - } - - let parent_idx = match Self::parent(leaf_idx) { - Some(p) => p, - None => return, // root — cannot collapse - }; - - let sibling_idx = Self::sibling(leaf_idx).unwrap(); - - // Copy the sibling's entire subtree into the parent's position. - copy_subtree(&mut self.nodes, sibling_idx, parent_idx); - - // Clear the former empty leaf — but only if copy_subtree didn't - // already write new content there. When the promoted sibling was a - // Split, copy_subtree places the sibling's children at the parent's - // child slots (left_child(parent) and right_child(parent)). leaf_idx - // IS one of those slots, so clearing it would destroy the - // newly-promoted content. - // - // After copy_subtree the node at parent_idx holds what was in sibling. - // If that is a Split, leaf_idx was overwritten — skip the clear. - let sibling_became_split = matches!(&self.nodes[parent_idx], Node::Split { .. }); - if !sibling_became_split && leaf_idx < self.nodes.len() { - self.nodes[leaf_idx] = Node::Empty; - } - } -} - -/// Copy the subtree rooted at `from` into the slot at `to`, then clear the -/// original source slots. -/// -/// Uses a two-phase approach to avoid aliasing: first collect all -/// `(src_idx, cloned_node, dst_idx)` triples, then write them all, then -/// clear any source slot that was not itself used as a destination. The -/// naive recursive approach has a subtle bug when the source subtree and -/// destination subtree overlap (which always happens when `from` is a child -/// of `to`), because clearing `nodes[from]` at the end of the recursion -/// overwrites a node that one of the recursive writes just placed there. -fn copy_subtree(nodes: &mut Vec>, from: usize, to: usize) { - // Phase 1: collect all (src, cloned_node, dst) triples. - let mut moves: Vec<(usize, Node, usize)> = Vec::new(); - collect_moves(nodes, from, to, &mut moves); - - if moves.is_empty() { - return; - } - - // Ensure the vec is large enough for all destination indices. - let max_dst = moves.iter().map(|m| m.2).max().unwrap_or(0); - if max_dst >= nodes.len() { - nodes.resize_with(max_dst + 1, || Node::Empty); - } - - let dst_set: std::collections::HashSet = moves.iter().map(|m| m.2).collect(); - - // Phase 2: write all nodes to their destinations. - for (_, node, dst) in moves.iter().cloned() { - nodes[dst] = node; - } - - // Phase 3: clear source slots that were not also used as destinations. - for (src, _, _) in &moves { - if !dst_set.contains(src) && *src < nodes.len() { - nodes[*src] = Node::Empty; - } - } -} - -/// Recursively collect `(src_idx, cloned_node, dst_idx)` triples for an -/// entire subtree, preserving the heap-index mapping. -fn collect_moves( - nodes: &[Node], - from: usize, - to: usize, - moves: &mut Vec<(usize, Node, usize)>, -) { - if from >= nodes.len() || matches!(nodes[from], Node::Empty) { - return; - } - let node = nodes[from].clone(); - let is_split = matches!(node, Node::Split { .. }); - moves.push((from, node, to)); - if is_split { - collect_moves(nodes, 2 * from + 1, 2 * to + 1, moves); - collect_moves(nodes, 2 * from + 2, 2 * to + 2, moves); - } -} - -fn copy_subtree_with_offset( - src: &[Node], - from: usize, - dst: &mut Vec>, - to: usize, -) { - if from >= src.len() || matches!(src[from], Node::Empty) { - return; - } - if to >= dst.len() { - dst.resize_with(to + 1, || Node::Empty); - } - let node = src[from].clone(); - let is_split = matches!(node, Node::Split { .. }); - dst[to] = node; - if is_split { - copy_subtree_with_offset(src, 2 * from + 1, dst, 2 * to + 1); - copy_subtree_with_offset(src, 2 * from + 2, dst, 2 * to + 2); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::tab::Tab; - - fn make_tree() -> PanelTree<&'static str> { - PanelTree::new(vec![ - Tab::new("A", "a"), - Tab::new("B", "b"), - ]) - } - - #[test] - fn index_arithmetic() { - assert_eq!(PanelTree::<()>::left_child(0), 1); - assert_eq!(PanelTree::<()>::right_child(0), 2); - assert_eq!(PanelTree::<()>::left_child(1), 3); - assert_eq!(PanelTree::<()>::right_child(1), 4); - assert_eq!(PanelTree::<()>::parent(1), Some(0)); - assert_eq!(PanelTree::<()>::parent(2), Some(0)); - assert_eq!(PanelTree::<()>::parent(0), None); - } - - #[test] - fn sibling() { - assert_eq!(PanelTree::<()>::sibling(1), Some(2)); - assert_eq!(PanelTree::<()>::sibling(2), Some(1)); - assert_eq!(PanelTree::<()>::sibling(3), Some(4)); - } - - #[test] - fn split_creates_children() { - let mut tree = make_tree(); - tree.split_leaf(0, SplitDir::Horizontal, Tab::new("C", "c"), ChildSide::Second); - - assert!(matches!(tree.node(0), Node::Split { dir: SplitDir::Horizontal, .. })); - assert!(matches!(tree.node(1), Node::Leaf { .. })); - assert!(matches!(tree.node(2), Node::Leaf { .. })); - - // The existing tabs should be in the first child (ChildSide::Second puts new in right). - if let Node::Leaf { tabs, .. } = tree.node(1) { - assert_eq!(tabs.len(), 2); - } else { - panic!("expected Leaf at index 1"); - } - if let Node::Leaf { tabs, .. } = tree.node(2) { - assert_eq!(tabs[0].id, "c"); - } else { - panic!("expected Leaf at index 2"); - } - } - - #[test] - fn merge_tab_then_collapse() { - // Single-tab tree: root has only "a". - let mut tree = PanelTree::new(vec![Tab::new("A", "a")]); - - // Split root: new tab "b" on the right (Second), existing "a" stays on left (First). - tree.split_leaf(0, SplitDir::Horizontal, Tab::new("B", "b"), ChildSide::Second); - // node 1 (left) = ["a"] - // node 2 (right) = ["b"] - - // Merge "b" (node 2, pos 0) into node 1. - tree.merge_tab_into(2, 0, 1); - - // Node 2 must now be empty. - if let Node::Leaf { tabs, .. } = tree.node(2) { - assert!(tabs.is_empty(), "node 2 should be empty after merge"); - } else { - panic!("expected Leaf at index 2"); - } - - // Node 1 should now hold both tabs. - if let Node::Leaf { tabs, .. } = tree.node(1) { - assert_eq!(tabs.len(), 2); - } - - // Collapse empty node 2: node 1 (sibling) gets promoted to root (node 0). - tree.collapse_empty_leaf(2); - - if let Node::Leaf { tabs, .. } = tree.node(0) { - assert_eq!(tabs.len(), 2, "root should hold both tabs after collapse"); - } else { - panic!("expected root to be a Leaf after collapse"); - } - - // Nodes 1 and 2 should be Empty after promotion. - assert!(matches!(tree.node(1), Node::Empty)); - assert!(matches!(tree.node(2), Node::Empty)); - } - - /// Regression: collapse a leaf whose sibling is a Split. - /// - /// Before the fix, copy_subtree would place the sibling's children at - /// left_child(parent) and right_child(parent). leaf_idx coincides with - /// one of those slots (it IS a child of parent), so the subsequent - /// `nodes[leaf_idx] = Empty` erased that newly-promoted content, causing - /// a tab to silently disappear. - #[test] - fn collapse_leaf_with_split_sibling_preserves_content() { - // Build: - // node 0: Split - // node 1: Leaf ["solo"] ← will be emptied and collapsed - // node 2: Split ← sibling, its children must survive - // node 5: Leaf ["left"] - // node 6: Leaf ["right"] - - let mut tree = PanelTree::new(vec![Tab::new("Solo", "solo")]); - - // Split root horizontally: solo stays left (First side), new tab right. - tree.split_leaf(0, SplitDir::Horizontal, Tab::new("Right", "right"), ChildSide::Second); - // node 1 = ["solo"], node 2 = ["right"] - - // Split node 2 vertically so it becomes a Split with two leaf children. - tree.split_leaf(2, SplitDir::Vertical, Tab::new("Left", "left"), ChildSide::First); - // node 2 = Split, node 5 = ["left"], node 6 = ["right"] - - // Empty node 1 by merging its tab into node 5. - tree.merge_tab_into(1, 0, 5); - // node 1 = Leaf[], node 5 = ["left", "solo"] - - assert!(matches!(tree.node(1), Node::Leaf { tabs, .. } if tabs.is_empty())); - - // Collapse empty node 1. Sibling is node 2 (a Split). - // After collapse: - // node 0 = Split (was node 2) - // node 1 = ["left", "solo"] (promoted from node 5) - // node 2 = ["right"] (promoted from node 6) - tree.collapse_empty_leaf(1); - - // node 0 must be a Split. - assert!(matches!(tree.node(0), Node::Split { .. }), "root should be Split"); - - // node 1 must have the two tabs from old node 5 — not be Empty. - if let Node::Leaf { tabs, .. } = tree.node(1) { - assert_eq!(tabs.len(), 2, "promoted left child should have 2 tabs, got {:?}", tabs.iter().map(|t| t.id).collect::>()); - } else { - panic!("node 1 should be a Leaf after collapse, got {:?}", tree.node(1)); - } - - // node 2 must have the one tab from old node 6. - if let Node::Leaf { tabs, .. } = tree.node(2) { - assert_eq!(tabs.len(), 1, "promoted right child should have 1 tab"); - assert_eq!(tabs[0].id, "right"); - } else { - panic!("node 2 should be a Leaf after collapse"); - } - } - - #[test] - fn pane_ids_stay_stable_across_split_and_collapse() { - let mut tree = PanelTree::new(vec![Tab::new("Solo", "solo")]); - let solo_pane = tree.find_pane_containing(&"solo").expect("solo pane should exist"); - - let right_pane = - tree.split_leaf(0, SplitDir::Horizontal, Tab::new("Right", "right"), ChildSide::Second); - - assert_eq!(tree.find_pane_containing(&"solo"), Some(solo_pane)); - assert_eq!(tree.find_pane_containing(&"right"), Some(right_pane)); - assert_ne!(solo_pane, right_pane); - - tree.merge_tab_into(tree.pane_index(right_pane).unwrap(), 0, tree.pane_index(solo_pane).unwrap()); - tree.collapse_empty_leaf(tree.pane_index(right_pane).unwrap()); - - assert_eq!(tree.find_pane_containing(&"solo"), Some(solo_pane)); - assert_eq!(tree.find_pane_containing(&"right"), Some(solo_pane)); - assert!(tree.pane_index(right_pane).is_none()); - } - - #[test] - fn pane_mut_supports_option_and_tab_updates() { - let mut tree = PanelTree::new(vec![Tab::new("A", "a")]); - let pane_id = tree.find_pane_containing(&"a").expect("root pane should exist"); - - { - let mut pane = tree.pane_mut(pane_id).expect("pane should exist"); - let mut options = pane.options(); - options.allow_collapse = false; - pane.set_options(options); - pane.push_tab(Tab::new("B", "b")); - assert!(pane.set_focused_index(1)); - assert_eq!(pane.focused_tab().map(|tab| tab.id), Some("b")); - } - - let pane = tree.pane(pane_id).expect("pane should still exist"); - assert!(!pane.options().allow_collapse); - assert_eq!(pane.tabs().len(), 2); - assert_eq!(pane.focused_tab().map(|tab| tab.id), Some("b")); - } - - #[test] - fn pane_builder_preserves_options_focus_and_tabs() { - let mut options = PaneOptions::default(); - options.drop_policy = DropPolicy::merge_only(); - options.allow_resize = false; - - let tree = PanelTree::from_pane( - PaneBuilder::new(Tab::new("A", "a")) - .push_tab(Tab::new("B", "b")) - .with_focused(1) - .with_options(options), - ); - - let pane_id = tree.find_pane_containing(&"b").expect("pane should exist"); - let pane = tree.pane(pane_id).expect("pane should exist"); - assert_eq!(pane.tabs().len(), 2); - assert_eq!(pane.focused_tab().map(|tab| tab.id), Some("b")); - assert_eq!(pane.options().drop_policy, DropPolicy::merge_only()); - assert!(!pane.options().allow_resize); - } - - #[test] - fn header_visibility_respects_hidden_and_collapsed_flows() { - let mut tree = PanelTree::new(vec![Tab::new("A", "a")]); - let mut options = tree.pane_options(0).unwrap(); - options.header_visibility = HeaderVisibility::Hidden; - assert!(tree.set_pane_options(0, options)); - - assert!(!tree.header_visible(0), "hidden header should stay hidden"); - - assert!(tree.set_collapsed(0, true)); - assert!( - tree.header_visible(0), - "collapsed panes should still show a header affordance" - ); - } - - #[test] - fn header_visibility_when_multiple_tabs_only() { - let mut tree = PanelTree::new(vec![Tab::new("A", "a")]); - let mut options = tree.pane_options(0).unwrap(); - options.header_visibility = HeaderVisibility::WhenMultipleTabs; - assert!(tree.set_pane_options(0, options)); - - assert!(!tree.header_visible(0)); - - tree.insert_tab_into_leaf(0, Tab::new("B", "b")); - assert!(tree.header_visible(0)); - } - - #[test] - fn lock_layout_blocks_collapse() { - let mut tree = PanelTree::new(vec![Tab::new("A", "a")]); - let pane_id = tree.find_pane_containing(&"a").expect("root pane should exist"); - - let mut options = tree.pane(pane_id).unwrap().options(); - options.lock_layout = true; - assert!(tree.set_pane_options(0, options)); - - assert!(!tree.set_collapsed(0, true)); - assert!(!tree.is_collapsed(0)); - } - - #[test] - fn move_tab_within_leaf_reorders_and_refocuses() { - let mut tree = PanelTree::new(vec![ - Tab::new("A", "a"), - Tab::new("B", "b"), - Tab::new("C", "c"), - ]); - - assert!(tree.move_tab_within_leaf(0, 0, 2)); - - if let Node::Leaf { tabs, focused, .. } = tree.node(0) { - assert_eq!(tabs.iter().map(|tab| tab.id).collect::>(), vec!["b", "c", "a"]); - assert_eq!(*focused, 2); - } else { - panic!("expected root to remain a leaf"); - } - } - - #[test] - fn split_resize_locked_tracks_descendant_pane_policy() { - let mut tree = PanelTree::new(vec![Tab::new("A", "a")]); - let right_pane = - tree.split_leaf(0, SplitDir::Horizontal, Tab::new("B", "b"), ChildSide::Second); - - assert!(!tree.split_resize_locked(0)); - - let right_idx = tree.pane_index(right_pane).expect("right pane should exist"); - let mut options = tree.pane_options(right_idx).unwrap(); - options.allow_resize = false; - assert!(tree.set_pane_options(right_idx, options)); - - assert!(tree.split_resize_locked(0)); - assert!(tree.subtree_resize_locked(right_idx)); - } - - #[test] - fn split_with_builder_creates_configured_pane() { - let mut tree = PanelTree::new(vec![Tab::new("A", "a")]); - let mut options = PaneOptions::default(); - options.lock_layout = true; - options.drop_policy = DropPolicy::none(); - - let new_pane = tree.split_leaf_with( - 0, - SplitDir::Horizontal, - PaneBuilder::new(Tab::new("B", "b")) - .push_tab(Tab::new("C", "c")) - .with_focused(1) - .with_options(options), - ChildSide::Second, - ); - - let pane = tree.pane(new_pane).expect("split pane should exist"); - assert_eq!(pane.tabs().len(), 2); - assert_eq!(pane.focused_tab().map(|tab| tab.id), Some("c")); - assert!(pane.options().lock_layout); - assert_eq!(pane.options().drop_policy, DropPolicy::none()); - } - - #[test] - fn persist_when_empty_keeps_leaf_in_layout() { - let mut tree = PanelTree::new(vec![Tab::new("solo", "solo")]); - let mut options = tree.pane_options(0).expect("root pane should exist"); - options.persist_when_empty = true; - assert!(tree.set_pane_options(0, options)); - - let removed = tree.remove_tab_at(0, 0).expect("tab should be removed"); - assert_eq!(removed.id, "solo"); - - match tree.node(0) { - Node::Leaf { tabs, .. } => assert!(tabs.is_empty(), "persistent pane should remain empty"), - other => panic!("expected persistent empty leaf at root, got {:?}", other), - } - } - - #[test] - fn persistent_empty_pane_keeps_header_visible() { - let mut tree = PanelTree::new(vec![Tab::new("solo", "solo")]); - let mut options = tree.pane_options(0).expect("root pane should exist"); - options.header_visibility = HeaderVisibility::Hidden; - options.persist_when_empty = true; - assert!(tree.set_pane_options(0, options)); - - assert!(tree.remove_tab_at(0, 0).is_some()); - assert!(tree.header_visible(0)); - } - - #[test] - fn ensure_pane_at_anchor_reuses_existing_anchor_owner() { - let mut tree = PanelTree::new(vec![Tab::new("center", "center")]); - let left = tree.ensure_pane_at_anchor( - PaneAnchor::Left, - PaneBuilder::new(Tab::new("files", "files")), - ); - let left_again = tree.ensure_pane_at_anchor( - PaneAnchor::Left, - PaneBuilder::new(Tab::new("other", "other")), - ); - - assert_eq!(left, left_again); - assert_eq!(tree.find_pane_with_anchor(PaneAnchor::Left), Some(left)); - assert_eq!(tree.find_tab(&"other"), None); - } - - #[test] - fn ensure_pane_at_anchor_marks_edge_panes_persistent() { - let mut tree = PanelTree::new(vec![Tab::new("center", "center")]); - let bottom = tree.ensure_pane_at_anchor( - PaneAnchor::Bottom, - PaneBuilder::new(Tab::new("terminal", "terminal")), - ); - - let pane = tree.pane(bottom).expect("bottom anchor pane should exist"); - assert_eq!(pane.options().anchor, Some(PaneAnchor::Bottom)); - assert!(pane.options().persist_when_empty); - } - - #[test] - fn remove_pane_clears_persistent_root_and_returns_tabs() { - let mut tree = PanelTree::new(vec![Tab::new("solo", "solo")]); - let pane_id = tree.find_pane_containing(&"solo").expect("root pane should exist"); - let mut options = tree.pane(pane_id).unwrap().options(); - options.persist_when_empty = true; - assert!(tree.set_pane_options(0, options)); - - let removed = tree.remove_pane(pane_id).expect("persistent root pane should be removable"); - assert_eq!(removed, vec!["solo"]); - match tree.node(0) { - Node::Leaf { tabs, .. } => assert!(tabs.is_empty()), - other => panic!("expected empty root leaf, got {:?}", other), - } - } - - #[test] - fn move_pane_to_anchor_preserves_identity_and_content() { - let mut tree = PanelTree::new(vec![Tab::new("editor", "editor")]); - let files = tree.ensure_pane_at_anchor( - PaneAnchor::Left, - PaneBuilder::new(Tab::new("files", "files")), - ); - - let moved = tree - .move_pane_to_anchor(files, PaneAnchor::Bottom) - .expect("pane should move to bottom"); - assert_eq!(moved, files); - assert_eq!(tree.find_pane_with_anchor(PaneAnchor::Bottom), Some(files)); - assert_eq!(tree.find_tab(&"files").map(|(leaf, _)| tree.pane_id_at(leaf)), Some(Some(files))); - } - - #[test] - fn find_pane_with_role_tracks_semantic_owner() { - let tree = PanelTree::from_pane( - PaneBuilder::new(Tab::new("files", "files")).with_role(PaneRole::Sidebar), - ); - - assert_eq!( - tree.find_pane_with_role(PaneRole::Sidebar), - tree.find_pane_containing(&"files") - ); - } -}