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
@@ -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| {
crate::app::panels::show_plugins_ui(self.app, ui);
if !self.app.documents.is_empty() {
crate::app::panels::show_plugins_ui(self.app, ui);
}
});
}
HciePane::LayerStyles => {
@@ -563,8 +564,10 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
}
});
// Custom close button at the far right (replaces DockArea built-in)
if is_closeable {
// Custom close button at the far right (replaces DockArea built-in).
// Hidden by default; only appears when the tab is hovered so the
// title bar stays clean and the pin icon is always reachable.
if is_closeable && response.hovered() {
let close_size = 14.0;
let close_center = egui::pos2(response.rect.right() - 8.0, response.rect.center().y);
let close_rect =
@@ -624,15 +627,15 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
}
}
// Pin button for utility panels (to the left of the close button)
if !tab.is_document() && !matches!(tab, HciePane::Tools) {
// Always show the pin button when the tab is floating, otherwise only
// show on hover so the tab label keeps its full width.
if !is_floating && !response.hovered() {
return;
}
let pin_offset = if is_closeable { 24.0 } else { 8.0 };
// Pin / float toggle for docked utility panels only.
// LayerStyles, LayerDetails and Plugins are dialog-style panels: they
// cannot float and do not show a pin toggle.
let supports_float =
!tab.is_document() && !matches!(tab, HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins);
if supports_float {
// Pin icon is always visible on the right side of utility tabs so
// users can float/re-dock a panel without first hovering the tab.
let pin_offset = if is_closeable && response.hovered() { 24.0 } else { 8.0 };
let pin_center =
egui::pos2(response.rect.right() - pin_offset, response.rect.center().y);
let pin_rect = egui::Rect::from_center_size(pin_center, egui::vec2(14.0, 14.0));
@@ -677,7 +680,8 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
///
/// **Logic & Workflow:**
/// Actual documents are closeable, while the EmptyCenter placeholder is not.
/// Utility panels are closeable only if they are not pinned in `self.app.state.pinned_panels`.
/// Utility panels are always closeable regardless of pinned state. Pin only controls
/// startup visibility and the View menu; it does not prevent the user from closing a panel.
///
/// **Arguments:**
/// * `tab`: The active mutable reference to the `HciePane` tab.
@@ -687,8 +691,7 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
if tab.is_document() {
return tab.is_actual_document();
}
let title = tab.title(self.app);
!self.app.state.pinned_panels.contains(&title)
!matches!(tab, HciePane::Tools)
}
/// **Purpose:** Handles the tab close request when the user clicks the close button.
@@ -742,9 +745,13 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
}
/// **Purpose:** Controls whether a tab is allowed to be torn out into a separate OS window.
/// Documents and Tools cannot float.
/// Documents, Tools, and dialog-style panels (LayerStyles, LayerDetails, Plugins) cannot float.
fn allowed_in_windows(&self, tab: &mut Self::Tab) -> bool {
!tab.is_document() && !matches!(tab, HciePane::Tools)
!tab.is_document()
&& !matches!(
tab,
HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins
)
}
/// **Purpose:** Custom context menu for dock tabs (VS2022 style).
@@ -827,7 +834,11 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
ui.add_space(4.0);
}
if !is_doc && !matches!(tab, HciePane::Tools) {
// Float / close options intentionally disabled for LayerStyles, LayerDetails and Plugins
// because these panels render as popups / dialogs, not docked utility cards.
let is_floating_allowed = !is_doc
&& !matches!(tab, HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins);
if is_floating_allowed {
let mut float = false;
menu_item(ui, "Yeni Pencere", true, &mut float);
if float {
@@ -850,8 +861,11 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
"Paneli Kapat"
};
let close_enabled = self.is_closeable(tab);
menu_item(ui, close_label, close_enabled, &mut close);
if close && close_enabled {
// Dialog-style panels (LayerStyles/LayerDetails/Plugins) are closed by the
// dialog itself, not by the dock tab.
let close_in_context = close_enabled && !matches!(tab, HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins);
menu_item(ui, close_label, close_in_context, &mut close);
if close && close_in_context {
if let HciePane::Document(idx) = tab {
if self
.app
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;
+70 -262
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,13 +1708,8 @@ impl HcieApp {
/// **Arguments:**
/// * `doc_pane`: The `dock::HciePane` document pane enum to push.
pub fn push_document_to_center(&mut self, doc_pane: dock::HciePane) {
if self.use_grimdock {
self.push_document_to_grimdock_center(doc_pane);
return;
}
let mut target_node = None;
// 1. Prefer a leaf that already has document tabs
for (path, leaf) in self.dock_state.iter_leaves() {
if leaf.tabs.iter().any(|t| t.is_document()) {
@@ -1742,7 +1717,7 @@ impl HcieApp {
break;
}
}
if let Some(node_path) = target_node {
self.dock_state.set_focused_node_and_surface(node_path);
self.dock_state.push_to_focused_leaf(doc_pane.clone());
@@ -1751,65 +1726,24 @@ impl HcieApp {
// enforce_constraints will relocate it to the center if needed.
self.dock_state.push_to_focused_leaf(doc_pane.clone());
}
// Set the newly pushed tab as active to make it immediately visible
if let Some(tab_path) = self.dock_state.find_tab(&doc_pane) {
let _ = self.dock_state.set_active_tab(tab_path);
}
// Ensure dock layout settles and canvas texture uploads before the
// user sees the new document. Without this, the canvas appears blank
// on the first frame after opening a file.
self.dock_settle_repaints = self.dock_settle_repaints.max(5);
}
/// Push a newly opened/created document tab into the grimdock center document leaf.
fn push_document_to_grimdock_center(&mut self, doc_pane: dock::HciePane) {
if let Some(host) = self.grim_dock_host.as_mut() {
// Find or create the document leaf. The default layout reserves leaf 11.
let doc_leaf = if host.tree.pane_options(11).is_some() {
11
} else {
host.tree
.find_leaf_containing(&dock::HciePane::Document(0))
.unwrap_or(11)
};
// Remove any stale document tab with the same index to avoid duplicates.
if let dock::HciePane::Document(_idx) = doc_pane {
// grimdock tabs are compared by their tab data (HciePane). A stale tab
// with the same index matches equality.
if let Some((leaf, tab_pos)) = host.tree.find_tab(&doc_pane) {
if leaf == doc_leaf {
let _ = host.tree.remove_tab_at(leaf, tab_pos);
}
}
}
/// Push a newly opened/created document tab into the center document leaf.
fn push_document_to_center_placeholder() {}
/// Panic-safe tab removal: validates indices before calling egui_dock::remove_tab.
// Remove EmptyCenter placeholder if present — a real document is arriving.
if let Some((ec_leaf, ec_pos)) = host.tree.find_tab(&dock::HciePane::EmptyCenter) {
let _ = host.tree.remove_tab_at(ec_leaf, ec_pos);
}
let title = if let dock::HciePane::Document(idx) = doc_pane {
self.documents
.get(idx)
.map(|d| d.name.clone())
.unwrap_or_else(|| "Untitled".to_string())
} else {
"Untitled".to_string()
};
let mut tab = grimdock::Tab::new(title, doc_pane.clone());
if doc_pane.is_document() {
tab = tab.with_closable(true).with_draggable(false);
}
host.tree.insert_tab_into_leaf(doc_leaf, tab);
host.tree.set_collapsed(doc_leaf, false);
}
self.dock_settle_repaints = self.dock_settle_repaints.max(5);
}
/// Panic-safe tab removal: validates indices before calling egui_dock::remove_tab.
/// Returns `None` and logs a warning if indices are stale.
@@ -1986,110 +1920,44 @@ impl HcieApp {
continue;
}
let doc_count_before = self.documents.len();
self.documents.remove(idx);
if self.use_grimdock {
if let Some(host) = &mut self.grim_dock_host {
if doc_count_before <= 1 {
log::trace!("Last document closed in grimdock. Creating new Untitled document and replacing tab in-place to preserve sidebar layouts");
let new_idx = self.documents.len();
self.documents.push(AppDocument::new("Untitled", 800, 600));
self.documents[new_idx].engine.set_modified(false);
for leaf_idx in host.tree.leaf_indices().collect::<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()
.filter(|(_, t)| t.is_actual_document())
.count();
if doc_tab_count_before <= 1 {
log::trace!("Last document closed. Creating new Untitled document and replacing tab in-place to preserve sidebar layouts");
let new_idx = self.documents.len();
self.documents.push(AppDocument::new("Untitled", 800, 600));
self.documents[new_idx].engine.set_modified(false);
for (_, tab) in self.dock_state.iter_all_tabs_mut() {
if let dock::HciePane::Document(doc_idx) = tab {
if *doc_idx == idx {
*tab = dock::HciePane::Document(new_idx);
}
}
}
self.active_doc = new_idx;
} else {
log::trace!(
"Removing document tab natively from multi-document dock state"
);
let tabs_to_remove = self.dock_find_tabs(
|t| matches!(t, dock::HciePane::Document(d) if *d == idx),
);
for tab_pos in tabs_to_remove.into_iter().rev() {
self.dock_safe_remove_tab(tab_pos);
}
}
let doc_tab_count_before = self
.dock_state
.iter_all_tabs()
.filter(|(_, t)| t.is_actual_document())
.count();
if doc_tab_count_before <= 1 {
log::trace!("Last document closed. Creating new Untitled document and replacing tab in-place to preserve sidebar layouts");
let new_idx = self.documents.len();
self.documents.push(AppDocument::new("Untitled", 800, 600));
self.documents[new_idx].engine.set_modified(false);
for (_, tab) in self.dock_state.iter_all_tabs_mut() {
if let dock::HciePane::Document(doc_idx) = tab {
if *doc_idx > idx {
*doc_idx -= 1;
if *doc_idx == idx {
*tab = dock::HciePane::Document(new_idx);
}
}
}
self.active_doc = new_idx;
} else {
log::trace!(
"Removing document tab from egui_dock multi-document state"
);
let tabs_to_remove = self.dock_find_tabs(
|t| matches!(t, dock::HciePane::Document(d) if *d == idx),
);
for tab_pos in tabs_to_remove.into_iter().rev() {
self.dock_safe_remove_tab(tab_pos);
}
}
for (_, tab) in self.dock_state.iter_all_tabs_mut() {
if let dock::HciePane::Document(doc_idx) = tab {
if *doc_idx > idx {
*doc_idx -= 1;
}
}
}
if self.active_doc >= self.documents.len() && !self.documents.is_empty() {
@@ -4014,20 +3882,10 @@ impl HcieApp {
egui::UserData::default(),
));
}
if i.key_pressed(egui::Key::F11) && self.use_grimdock {
if let Some(host) = self.grim_dock_host.as_mut() {
let target = dock::HciePane::ColorBox;
if let Some((leaf_idx, tab_pos)) = host.tree.find_tab(&target) {
if let Some(tab) = host.tree.remove_tab_at(leaf_idx, tab_pos) {
self.grim_floating_panels.push((
tab.id,
egui::Rect::from_min_size(
egui::pos2(200.0, 200.0),
egui::vec2(320.0, 480.0),
),
));
}
}
if i.key_pressed(egui::Key::F11) {
let target = dock::HciePane::ColorBox;
if self.dock_state.find_tab(&target).is_some() {
self.panels_to_float.push(target);
}
}
if i.key_pressed(egui::Key::Escape) {
@@ -4117,14 +3975,6 @@ impl HcieApp {
}
let _dock_rect = ui.max_rect();
if self.use_grimdock {
let mut taken = self.grim_dock_host.take();
if let Some(host) = &mut taken {
host.render(ui, self);
}
self.grim_dock_host = taken;
return;
}
// Tema-duyarlı dock token'ları: açık/koyu temada tüm panel
// arka planları, tab renkleri ve ayırıcılar doğru rengi alır.
@@ -4371,16 +4221,6 @@ impl HcieApp {
self.sync_active_doc_from_dock();
});
// Render grimdock floating panels on top of the central dock area.
if self.use_grimdock {
let dock_rect = ctx.content_rect();
let mut taken = self.grim_dock_host.take();
if let Some(host) = &mut taken {
dock::grim_dock::render_floating_panels(ctx, self, host, dock_rect);
}
self.grim_dock_host = taken;
}
if let Some(name) = self.auto_hide_toggle.take() {
if self.auto_hide_active.as_deref() == Some(&name) {
self.auto_hide_active = None;
@@ -5046,49 +4886,23 @@ impl HcieApp {
};
let pane = pane.clone();
if self.panel_screenshot_frame == 0 {
// Pin the Color Palette so the layout solver gives it the intended
// 200px width instead of treating it as an auto-hide overlay.
if matches!(pane, dock::HciePane::ColorBox) {
self.state.pinned_panels.insert("Color Palette".to_string());
self.right_col1_w = 200.0;
self.last_window_width = 0.0;
// Rebuild the dock from the default so the persisted layout
// cannot keep a different panel width.
self.dock_state = default_dock_state();
// Rebuild the dock from the default so the persisted layout cannot
// keep a different panel width, and pin the target panel so the
// layout solver gives it the intended 200px width.
let pinned_name = pane.static_title();
self.state.pinned_panels.insert(pinned_name.to_string());
match pane {
dock::HciePane::ColorBox => self.right_col1_w = 200.0,
dock::HciePane::Brushes | dock::HciePane::Filters => self.left_col2_w = 200.0,
dock::HciePane::Layers | dock::HciePane::Properties => self.right_col2_w = 200.0,
_ => {}
}
// Ensure the panel exists in the dock. For grimdock, add tabs into
// the correct default leaf so that the target panel is actually
// rendered (some panels, e.g. Plugins, are not in the default tree).
if self.use_grimdock {
let host = self.grim_dock_host.as_mut().expect("grimdock host");
let leaf_idx = dock::grim_dock::default_leaf_for_pane(&pane);
if host.tree.find_leaf_containing(&pane).is_none() {
log::info!(
"Adding missing pane {:?} to grimdock leaf {} for screenshot",
pane,
leaf_idx
);
host.tree.insert_tab_into_leaf(
leaf_idx,
dock::grim_dock::GrimDockHost::new_dummy_tab(pane.clone()),
);
// Make sure the leaf is not collapsed so the new tab shows.
host.tree.set_collapsed(leaf_idx, false);
} else {
// The pane exists but may not be the active tab in its leaf;
// switch focus so the requested panel is rendered.
let tab_pos = host.tree.find_tab(&pane).map(|(_, pos)| pos);
let pane_id = dock::grim_dock::pane_id_for_pane(&pane);
if let Some(mut pane_ref) =
host.tree.pane_mut(grimdock::PaneId::from_raw(pane_id))
{
if let Some(pos) = tab_pos {
pane_ref.set_focused_index(pos);
}
pane_ref.set_collapsed(false);
}
}
} else if self.dock_state.find_tab(&pane).is_none() {
self.last_window_width = 0.0;
self.dock_state = default_dock_state();
// Ensure the panel exists in the dock. For panels not in the
// default tree (e.g. Plugins), add them to the currently focused
// leaf so the requested panel is actually rendered.
if self.dock_state.find_tab(&pane).is_none() {
self.dock_state.push_to_focused_leaf(pane.clone());
}
}
@@ -6132,12 +5946,6 @@ impl eframe::App for HcieApp {
if let Ok(json) = serde_json::to_string(&self.dock_state) {
storage.set_string("hcie_dock_state", json);
}
if let Some(grim) = &mut self.grim_dock_host {
let persisted = grim.tree.to_persisted();
if let Ok(json) = serde_json::to_string(&persisted) {
storage.set_string("hcie_grimdock_state", json);
}
}
let colors_data = (
&self.state.primary_color,
&self.state.secondary_color,
@@ -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"
);
}