Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-07-09 12:09:05 +03:00
parent 7ace048d94
commit e88a2be508
41 changed files with 130 additions and 10062 deletions
Generated
-9
View File
@@ -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",
-2
View File
@@ -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
-234
View File
@@ -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<TabId>,
/// 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));
}
}
});
}
@@ -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 }
@@ -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<TabId>,
palette: Palette,
frame_count: u64,
documents: Vec<String>,
}
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()))),
)
}
@@ -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<TabId>,
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),
);
}
});
}
@@ -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));
}
}
}
@@ -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| {
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
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,2 @@
pub use egui_dock::*;
pub mod egui_dock;
pub mod grim_dock;
+33 -225
View File
@@ -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<dock::HciePane, egui_dock::SurfaceIndex>,
/// 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<String>,
pub internal_clipboard: Option<SelectionTransform>,
/// 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<egui_dock::NodePath>,
/// 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<dock::grim_dock::GrimDockHost>,
/// 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<dock::HciePane> {
"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::<grimdock::PersistedPanelTree<dock::HciePane>>(&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::<egui_dock::DockState<dock::HciePane>>(&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,11 +1708,6 @@ 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
@@ -1763,53 +1738,12 @@ impl HcieApp {
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() {}
// 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);
}
/// Panic-safe tab removal: validates indices before calling egui_dock::remove_tab.
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,73 +1920,8 @@ 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::<Vec<_>>() {
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::<Vec<_>>() {
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::<Vec<_>>() {
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()
@@ -2073,7 +1942,7 @@ impl HcieApp {
self.active_doc = new_idx;
} else {
log::trace!(
"Removing document tab natively from multi-document dock state"
"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),
@@ -2090,7 +1959,6 @@ impl HcieApp {
}
}
}
}
if self.active_doc >= self.documents.len() && !self.documents.is_empty() {
self.active_doc = self.documents.len() - 1;
@@ -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() {
if i.key_pressed(egui::Key::F11) {
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 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;
// 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,
_ => {}
}
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();
}
// 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() {
// 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,
@@ -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.
@@ -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();
@@ -18,7 +18,6 @@ fn main() -> eframe::Result<()> {
let mut screenshot_panel: Option<String> = None;
let mut screenshot_out: Option<std::path::PathBuf> = 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))
}),
)
@@ -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));
}
@@ -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"
);
}
+20 -20
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
{"v":1}
-6
View File
@@ -1,6 +0,0 @@
{
"git": {
"sha1": "a49454b6028db991f498a181c2ebc1ac416ae9e6"
},
"path_in_vcs": ""
}
-59
View File
@@ -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<<EOF" >> "$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 }}
-22
View File
@@ -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
-84
View File
@@ -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
-27
View File
@@ -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
-49
View File
@@ -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.");
});
});
}
}
@@ -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<TabId>,
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.");
}
});
});
}
}
@@ -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<TabId>,
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::<Vec<_>>();
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));
}
});
}
}
-103
View File
@@ -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<PersistedPanelTree<&'static str>>,
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
}
@@ -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<TabId>,
style: PanelStyle,
_textures: Vec<egui::TextureHandle>,
}
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)
}
@@ -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<TabId>,
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<String>,
}
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<TabId>, 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)
}
@@ -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<TabId>,
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<egui::Color32>) {
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<TabId>, 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)
}
-232
View File
@@ -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<TabId>,
/// 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));
}
}
});
}
-50
View File
@@ -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<T: Clone + 'static>(
ui: &mut Ui,
tree: &PanelTree<T>,
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);
});
});
}
}
-746
View File
@@ -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<T: Clone + 'static>(
tab: &Tab<T>,
pane_id: PaneId,
role: Option<PaneRole>,
) -> bool {
tab.drop_policy.allows_target(pane_id, role)
}
fn header_tab_drop_index<T: Clone + 'static>(
tree: &PanelTree<T>,
leaf_idx: usize,
header_rect: Rect,
cursor_pos: Pos2,
) -> Option<usize> {
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<T: Clone + 'static>(
tree: &PanelTree<T>,
leaf_idx: usize,
header_rect: Rect,
tab_pos: usize,
) -> Option<Rect> {
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<DropZone> {
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<T: Clone + 'static>(
ui: &mut Ui,
tree: &mut PanelTree<T>,
style: &PanelStyle,
) {
let ctx = ui.ctx().clone();
// Is a drag live?
let Some(live_payload) = DragAndDrop::payload::<DragPayload>(&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::<DragPayload>(&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<T: Clone + 'static>(
tree: &mut PanelTree<T>,
src_leaf: usize,
tab_pos: usize,
target_leaf: usize,
tab: crate::tab::Tab<T>,
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::<T>::right_child(target_leaf),
ChildSide::Second => PanelTree::<T>::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<T: Clone + 'static>(
tree: &mut PanelTree<T>,
src_leaf: usize,
tab_pos: usize,
tab: crate::tab::Tab<T>,
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::<T>::right_child(0),
ChildSide::Second => PanelTree::<T>::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)));
}
}
File diff suppressed because it is too large Load Diff
-27
View File
@@ -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())
}
-272
View File
@@ -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<T: Clone + 'static>(
ui: &mut Ui,
tree: &mut PanelTree<T>,
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<T: Clone + 'static>(tree: &PanelTree<T>, idx: usize) -> bool {
match tree.node(idx) {
Node::Leaf { tabs, options, .. } => tabs.is_empty() && !options.persist_when_empty,
Node::Split { .. } => {
let left = PanelTree::<T>::left_child(idx);
let right = PanelTree::<T>::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<T: Clone + 'static>(
tree: &PanelTree<T>,
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<T: Clone + 'static>(
ui: &mut Ui,
tree: &mut PanelTree<T>,
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::<T>::left_child(idx);
let second_idx = PanelTree::<T>::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)
}
}
}
-243
View File
@@ -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<T: Clone + 'static> {
pub title: String,
pub tab: Tab<T>,
pub open_behavior: OpenBehavior,
}
impl<T: Clone + 'static> AddTabEntry<T> {
pub fn new(title: impl Into<String>, tab: Tab<T>) -> 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<String>, title: impl Into<String>) -> 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<T> {
/// Tabs closed from pane headers during this frame.
pub closed_tabs: Vec<T>,
/// Custom pane actions invoked from built-in pane menus during this frame.
pub pane_actions: Vec<PaneActionInvocation>,
}
impl<T> Default for PanelOutput<T> {
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<T>,
style: &'ui PanelStyle,
add_tab_entries: &'ui [AddTabEntry<T>],
add_tab_provider: Option<&'ui dyn Fn(PaneId, &PanelTree<T>) -> Vec<AddTabEntry<T>>>,
pane_menu_actions: &'ui [PaneMenuAction],
pane_menu_provider: Option<&'ui dyn Fn(PaneId, &PanelTree<T>) -> Vec<PaneMenuAction>>,
}
impl<'ui, T: Clone + 'static> PanelContext<'ui, T> {
pub fn new(
ui: &'ui mut Ui,
tree: &'ui mut PanelTree<T>,
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<T>]) -> 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<T>) -> Vec<AddTabEntry<T>>,
) -> 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<T>) -> Vec<PaneMenuAction>,
) -> 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<T>
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
}
}
-457
View File
@@ -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<T: Clone + 'static> {
pub version: u32,
pub next_pane_id: u64,
pub nodes: Vec<PersistedNode<T>>,
}
/// Persisted node payload for [`PersistedPanelTree`].
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PersistedNode<T: Clone + 'static> {
Empty,
Split {
dir: SplitDir,
ratio: f32,
},
Leaf {
pane: PaneId,
tabs: Vec<Tab<T>>,
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<T: Clone + 'static> {
pub nodes: Vec<LegacyPersistedNode<T>>,
pub next_pane_id: u64,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum LegacyPersistedNode<T: Clone + 'static> {
Empty,
Split {
dir: SplitDir,
ratio: f32,
},
Leaf {
pane: PaneId,
tabs: Vec<Tab<T>>,
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<T: Clone + 'static> {
Versioned(PersistedPanelTree<T>),
Legacy(LegacyPersistedPanelTree<T>),
}
#[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<T: Clone + 'static> PersistedPanelTree<T> {
/// Export a runtime tree into the current versioned persistence format.
pub fn current(tree: &PanelTree<T>) -> 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<PanelTree<T>, 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<T: Clone + 'static> From<&PanelTree<T>> for PersistedPanelTree<T> {
fn from(value: &PanelTree<T>) -> Self {
Self::current(value)
}
}
impl<T: Clone + 'static> From<&PanelTree<T>> for LegacyPersistedPanelTree<T> {
fn from(value: &PanelTree<T>) -> 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<T: Clone + 'static> From<LegacyPersistedPanelTree<T>> for PersistedPanelTree<T> {
fn from(value: LegacyPersistedPanelTree<T>) -> 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<T: Clone + 'static> PersistedPanelTreeFile<T> {
/// Migrate either the current versioned payload or the legacy payload into
/// the current versioned format.
pub fn migrate(self) -> Result<PersistedPanelTree<T>, 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<PanelTree<T>, PersistError> {
self.migrate()?.into_panel_tree()
}
}
impl<T: Clone + 'static> PanelTree<T> {
/// Export the current layout into the versioned persisted format.
pub fn to_persisted(&self) -> PersistedPanelTree<T> {
PersistedPanelTree::from(self)
}
/// Reconstruct a runtime tree from the versioned persisted format.
pub fn from_persisted(layout: PersistedPanelTree<T>) -> Result<Self, PersistError> {
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),
}
}
}
-376
View File
@@ -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<Color32>,
pub content_bg: Option<Color32>,
pub border_color: Option<Color32>,
pub accent_color: Option<Color32>,
}
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<Color32>,
pub inactive_bg: Option<Color32>,
pub hovered_bg: Option<Color32>,
pub text_color: Option<Color32>,
pub accent_color: Option<Color32>,
/// Override colour applied only to the leading icon, not the title text.
pub icon_color: Option<Color32>,
/// 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<f32>,
}
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<PaneStyleOverride>) -> Color32 {
pane.and_then(|pane| pane.header_bg).unwrap_or(self.header.bg)
}
pub fn pane_content_bg(&self, pane: Option<PaneStyleOverride>) -> Color32 {
pane.and_then(|pane| pane.content_bg).unwrap_or(self.content.bg)
}
pub fn pane_border_color(&self, pane: Option<PaneStyleOverride>) -> Color32 {
pane.and_then(|pane| pane.border_color)
.unwrap_or(self.content.border_color)
}
pub fn pane_accent_color(&self, pane: Option<PaneStyleOverride>) -> 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<PaneStyleOverride>,
tab: Option<TabStyleOverride>,
) -> 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));
}
}
-216
View File
@@ -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<PaneId>,
/// If set, this tab may only be dropped into panes with the given role.
pub locked_to_role: Option<PaneRole>,
/// If set, this tab may only be dropped into panes in this allow-list.
pub allowed_panes: Option<Vec<PaneId>>,
/// If set, this tab may only be dropped into panes with roles in this allow-list.
pub allowed_roles: Option<Vec<PaneRole>>,
/// Panes in this block-list reject this tab even if otherwise allowed.
pub blocked_panes: Vec<PaneId>,
/// Roles in this block-list reject this tab even if otherwise allowed.
pub blocked_roles: Vec<PaneRole>,
}
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<PaneRole>) -> 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<T: Clone + 'static> {
pub title: String,
pub id: T,
/// Optional leading icon rendered before the title in tab headers.
pub icon: Option<TabIcon>,
/// 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<TabStyleOverride>,
}
impl<T: Clone + 'static> Tab<T> {
/// Create a new tab with a title and caller-owned identifier.
pub fn new(title: impl Into<String>, 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<String>) -> 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<String>) -> 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<Item = PaneId>,
) -> 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<Item = PaneId>,
) -> 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<Item = PaneRole>,
) -> 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<Item = PaneRole>,
) -> 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
}
}
File diff suppressed because it is too large Load Diff