Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user