Refactor code structure for improved readability and maintainability
This commit is contained in:
Generated
-9
@@ -2050,14 +2050,6 @@ dependencies = [
|
|||||||
"gl_generator",
|
"gl_generator",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "grimdock"
|
|
||||||
version = "0.2.0"
|
|
||||||
dependencies = [
|
|
||||||
"egui",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.15"
|
version = "0.4.15"
|
||||||
@@ -2310,7 +2302,6 @@ dependencies = [
|
|||||||
"egui_extras",
|
"egui_extras",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"evdev",
|
"evdev",
|
||||||
"grimdock",
|
|
||||||
"hcie-engine-api",
|
"hcie-engine-api",
|
||||||
"image",
|
"image",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ egui = "0.34"
|
|||||||
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow", "persistence", "wayland", "x11"] }
|
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow", "persistence", "wayland", "x11"] }
|
||||||
egui_extras = { version = "0.34", features = ["svg", "image"] }
|
egui_extras = { version = "0.34", features = ["svg", "image"] }
|
||||||
egui_dock = { version = "0.19", features = ["serde"] }
|
egui_dock = { version = "0.19", features = ["serde"] }
|
||||||
grimdock = { version = "0.2.0", features = ["serde"] }
|
|
||||||
rfd = "0.15"
|
rfd = "0.15"
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
@@ -131,7 +130,6 @@ opt-level = 2
|
|||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
egui-winit = { path = "patches/egui-winit" }
|
egui-winit = { path = "patches/egui-winit" }
|
||||||
grimdock = { path = "patches/grimdock" }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
-234
@@ -1,234 +0,0 @@
|
|||||||
//! Runnable demo: `cargo run --example demo`
|
|
||||||
//!
|
|
||||||
//! Opens a window with a pre-built split layout. Tabs can be dragged between
|
|
||||||
//! panes, panes can be resized by dragging the handle between them.
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
use grimdock::{
|
|
||||||
AddTabEntry, ChildSide, DropPolicy, Node, PaneBuilder, PaneStyleOverride, PanelContext,
|
|
||||||
PanelStyle, PanelTree, SplitDir, Tab, TabStyleOverride,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
|
||||||
let options = eframe::NativeOptions {
|
|
||||||
viewport: egui::ViewportBuilder::default()
|
|
||||||
.with_title("grimdock demo")
|
|
||||||
.with_inner_size([1024.0, 680.0]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
eframe::run_native("grimdock demo", options, Box::new(|_cc| Ok(Box::new(App::new()))))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A tab identifier. Using a simple enum so each variant renders unique content.
|
|
||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
||||||
enum TabId {
|
|
||||||
Editor(u32),
|
|
||||||
FileTree,
|
|
||||||
Terminal,
|
|
||||||
Search,
|
|
||||||
Problems,
|
|
||||||
Output,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for TabId {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
TabId::Editor(n) => write!(f, "editor_{n}"),
|
|
||||||
TabId::FileTree => write!(f, "file_tree"),
|
|
||||||
TabId::Terminal => write!(f, "terminal"),
|
|
||||||
TabId::Search => write!(f, "search"),
|
|
||||||
TabId::Problems => write!(f, "problems"),
|
|
||||||
TabId::Output => write!(f, "output"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
tree: PanelTree<TabId>,
|
|
||||||
/// Counter that ticks up each frame in the terminal pane.
|
|
||||||
tick: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn new() -> Self {
|
|
||||||
// Build an initial layout:
|
|
||||||
//
|
|
||||||
// ┌───────────┬──────────────────┐
|
|
||||||
// │ File Tree │ Editor 1 │
|
|
||||||
// │ ├──────────────────┤
|
|
||||||
// │ │ Terminal │ Output│
|
|
||||||
// └───────────┴──────────────────┘
|
|
||||||
//
|
|
||||||
// We do this by starting with a single pane and splitting.
|
|
||||||
|
|
||||||
let mut tree = PanelTree::new(vec![
|
|
||||||
Tab::new("editor_1", TabId::Editor(1))
|
|
||||||
.with_leading_visual(">")
|
|
||||||
.with_style_override(TabStyleOverride {
|
|
||||||
active_bg: Some(egui::Color32::from_rgb(62, 73, 50)),
|
|
||||||
inactive_bg: Some(egui::Color32::from_rgb(44, 52, 38)),
|
|
||||||
hovered_bg: Some(egui::Color32::from_rgb(55, 65, 45)),
|
|
||||||
text_color: Some(egui::Color32::from_rgb(221, 231, 200)),
|
|
||||||
accent_color: Some(egui::Color32::from_rgb(164, 196, 92)),
|
|
||||||
icon_color: None,
|
|
||||||
max_width: None,
|
|
||||||
}),
|
|
||||||
Tab::new("editor_2", TabId::Editor(2)).with_leading_visual("+"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Split root (editors) horizontally: file tree on the left (First side).
|
|
||||||
let _file_tree_pane = tree.split_leaf_with(
|
|
||||||
0,
|
|
||||||
SplitDir::Horizontal,
|
|
||||||
PaneBuilder::new(Tab::new("file_tree", TabId::FileTree).with_leading_visual("#")).with_options(
|
|
||||||
grimdock::PaneOptions {
|
|
||||||
style_override: Some(PaneStyleOverride {
|
|
||||||
header_bg: Some(egui::Color32::from_rgb(27, 47, 44)),
|
|
||||||
content_bg: Some(egui::Color32::from_rgb(21, 36, 34)),
|
|
||||||
border_color: Some(egui::Color32::from_rgb(52, 90, 84)),
|
|
||||||
accent_color: Some(egui::Color32::from_rgb(85, 188, 162)),
|
|
||||||
content_inset: None,
|
|
||||||
}),
|
|
||||||
drop_policy: DropPolicy::merge_only(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ChildSide::First,
|
|
||||||
);
|
|
||||||
// Node 0 is now Horizontal split.
|
|
||||||
// Node 1 (left/First): FileTree
|
|
||||||
// Node 2 (right/Second): editors
|
|
||||||
|
|
||||||
// Split the right pane (editors, node 2) vertically: bottom gets terminal.
|
|
||||||
let editors_pane = tree
|
|
||||||
.find_pane_containing(&TabId::Editor(1))
|
|
||||||
.expect("editor pane should exist after initial split");
|
|
||||||
let bottom_pane = {
|
|
||||||
let mut pane = tree.pane_mut(editors_pane).expect("editor pane should exist");
|
|
||||||
pane.split_with(
|
|
||||||
SplitDir::Vertical,
|
|
||||||
PaneBuilder::new(Tab::new("terminal", TabId::Terminal).with_leading_visual("$")),
|
|
||||||
ChildSide::Second,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
// Node 2 is now Vertical split.
|
|
||||||
// Node 5 (top/First): editors
|
|
||||||
// Node 6 (bottom/Second): terminal
|
|
||||||
|
|
||||||
// Add extra tabs to the bottom bar.
|
|
||||||
// Nodes are: 5 = editors top pane, 6 = bottom pane (terminal).
|
|
||||||
let mut bottom = tree.pane_mut(bottom_pane).expect("bottom pane should exist");
|
|
||||||
bottom.push_tab(Tab::new("output", TabId::Output).with_leading_visual("!"));
|
|
||||||
bottom.push_tab(Tab::new("problems", TabId::Problems).with_leading_visual("x"));
|
|
||||||
bottom.push_tab(Tab::new("search", TabId::Search).with_leading_visual("?"));
|
|
||||||
|
|
||||||
// Narrow the left (file tree) column — set the split ratio.
|
|
||||||
if let Node::Split { ratio, .. } = tree.node_mut(0) {
|
|
||||||
*ratio = 0.22;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the bottom strip shorter.
|
|
||||||
if let Node::Split { ratio, .. } = tree.node_mut(2) {
|
|
||||||
*ratio = 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
|
||||||
tree,
|
|
||||||
tick: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for App {
|
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
||||||
self.tick += 1;
|
|
||||||
|
|
||||||
egui::CentralPanel::default()
|
|
||||||
.frame(egui::Frame::NONE)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
let tree = &mut self.tree;
|
|
||||||
let mut style = PanelStyle::from_egui_style(ui.style().as_ref());
|
|
||||||
style.content_inset = 4.0;
|
|
||||||
style.tabs.rounding = egui::CornerRadius::same(5);
|
|
||||||
style.header.button.rounding = egui::CornerRadius::same(5);
|
|
||||||
let tick = self.tick;
|
|
||||||
let add_tab_entries = vec![
|
|
||||||
AddTabEntry::new(
|
|
||||||
"Editor 3",
|
|
||||||
Tab::new("editor_3", TabId::Editor(3))
|
|
||||||
.with_leading_visual("*")
|
|
||||||
.with_closable(true),
|
|
||||||
),
|
|
||||||
AddTabEntry::new(
|
|
||||||
"Terminal",
|
|
||||||
Tab::new("terminal", TabId::Terminal)
|
|
||||||
.with_leading_visual("$")
|
|
||||||
.with_closable(true),
|
|
||||||
),
|
|
||||||
AddTabEntry::new(
|
|
||||||
"Search",
|
|
||||||
Tab::new("search", TabId::Search)
|
|
||||||
.with_leading_visual("?")
|
|
||||||
.with_closable(true),
|
|
||||||
),
|
|
||||||
AddTabEntry::new(
|
|
||||||
"Problems",
|
|
||||||
Tab::new("problems", TabId::Problems)
|
|
||||||
.with_leading_visual("x")
|
|
||||||
.with_closable(true),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
PanelContext::new(ui, tree, &style)
|
|
||||||
.with_add_tab_entries(&add_tab_entries)
|
|
||||||
.show(|ui, tab_id| {
|
|
||||||
render_tab(ui, tab_id, tick);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Repaint continuously so the terminal counter updates.
|
|
||||||
ctx.request_repaint();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_tab(ui: &mut egui::Ui, tab_id: &TabId, tick: u64) {
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
||||||
match tab_id {
|
|
||||||
TabId::Editor(n) => {
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.label(
|
|
||||||
egui::RichText::new(format!("// Editor {n}\n\nfn main() {{\n println!(\"Hello, world!\");\n}}"))
|
|
||||||
.monospace()
|
|
||||||
.size(13.0),
|
|
||||||
);
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.label("Drag a tab to a different pane to rearrange.");
|
|
||||||
ui.label("Drag the handle between panes to resize.");
|
|
||||||
}
|
|
||||||
TabId::FileTree => {
|
|
||||||
ui.add_space(6.0);
|
|
||||||
for item in &["src/", " lib.rs", " tree.rs", " layout.rs", " header.rs", " dnd.rs", "Cargo.toml"] {
|
|
||||||
ui.label(egui::RichText::new(*item).monospace().size(12.0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TabId::Terminal => {
|
|
||||||
ui.add_space(4.0);
|
|
||||||
ui.label(egui::RichText::new(format!("$ frame {tick}")).monospace().size(12.0));
|
|
||||||
ui.label(egui::RichText::new("grimdock running ✓").monospace().color(egui::Color32::from_rgb(100, 200, 120)).size(12.0));
|
|
||||||
}
|
|
||||||
TabId::Search => {
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.label("Search");
|
|
||||||
ui.text_edit_singleline(&mut String::new());
|
|
||||||
}
|
|
||||||
TabId::Problems => {
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.label(egui::RichText::new("No problems detected.").color(egui::Color32::from_rgb(100, 200, 120)));
|
|
||||||
}
|
|
||||||
TabId::Output => {
|
|
||||||
ui.add_space(4.0);
|
|
||||||
ui.label(egui::RichText::new("[INFO] Build complete.").monospace().size(12.0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -7,14 +7,6 @@ edition = "2021"
|
|||||||
name = "hcie-gui"
|
name = "hcie-gui"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "grimdemo"
|
|
||||||
path = "examples/grimdemo.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "dock_demo"
|
|
||||||
path = "examples/dock_demo.rs"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["tablet-evdev"]
|
default = ["tablet-evdev"]
|
||||||
tablet-evdev = ["dep:evdev"]
|
tablet-evdev = ["dep:evdev"]
|
||||||
@@ -31,7 +23,6 @@ egui = { workspace = true }
|
|||||||
eframe = { workspace = true }
|
eframe = { workspace = true }
|
||||||
egui_extras = { workspace = true }
|
egui_extras = { workspace = true }
|
||||||
egui_dock = { workspace = true }
|
egui_dock = { workspace = true }
|
||||||
grimdock = { workspace = true }
|
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
rfd = { workspace = true }
|
rfd = { workspace = true }
|
||||||
serde = { 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;
|
app.show_close_confirm = None;
|
||||||
if discard {
|
if discard {
|
||||||
app.event_bus.push(AppEvent::DocClosed(idx));
|
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.
|
/// Stable title that does not require a full `HcieApp` reference.
|
||||||
/// Used by backends (e.g. `grimdock`) that build tabs before the app is
|
/// Used when building tabs before the app is fully initialized.
|
||||||
/// fully initialized.
|
|
||||||
pub fn static_title(&self) -> &'static str {
|
pub fn static_title(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
HciePane::Tools => "Tools",
|
HciePane::Tools => "Tools",
|
||||||
@@ -312,7 +311,9 @@ impl<'a> HcieTabViewer<'a> {
|
|||||||
}
|
}
|
||||||
HciePane::Plugins => {
|
HciePane::Plugins => {
|
||||||
crate::app::shell::gui_layout::track_panel(ui, "panel.plugins", |ui| {
|
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 => {
|
HciePane::LayerStyles => {
|
||||||
@@ -563,8 +564,10 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom close button at the far right (replaces DockArea built-in)
|
// Custom close button at the far right (replaces DockArea built-in).
|
||||||
if is_closeable {
|
// 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_size = 14.0;
|
||||||
let close_center = egui::pos2(response.rect.right() - 8.0, response.rect.center().y);
|
let close_center = egui::pos2(response.rect.right() - 8.0, response.rect.center().y);
|
||||||
let close_rect =
|
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)
|
// Pin / float toggle for docked utility panels only.
|
||||||
if !tab.is_document() && !matches!(tab, HciePane::Tools) {
|
// LayerStyles, LayerDetails and Plugins are dialog-style panels: they
|
||||||
// Always show the pin button when the tab is floating, otherwise only
|
// cannot float and do not show a pin toggle.
|
||||||
// show on hover so the tab label keeps its full width.
|
let supports_float =
|
||||||
if !is_floating && !response.hovered() {
|
!tab.is_document() && !matches!(tab, HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins);
|
||||||
return;
|
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 { 24.0 } else { 8.0 };
|
let pin_offset = if is_closeable && response.hovered() { 24.0 } else { 8.0 };
|
||||||
let pin_center =
|
let pin_center =
|
||||||
egui::pos2(response.rect.right() - pin_offset, response.rect.center().y);
|
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));
|
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:**
|
/// **Logic & Workflow:**
|
||||||
/// Actual documents are closeable, while the EmptyCenter placeholder is not.
|
/// 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:**
|
/// **Arguments:**
|
||||||
/// * `tab`: The active mutable reference to the `HciePane` tab.
|
/// * `tab`: The active mutable reference to the `HciePane` tab.
|
||||||
@@ -687,8 +691,7 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
|
|||||||
if tab.is_document() {
|
if tab.is_document() {
|
||||||
return tab.is_actual_document();
|
return tab.is_actual_document();
|
||||||
}
|
}
|
||||||
let title = tab.title(self.app);
|
!matches!(tab, HciePane::Tools)
|
||||||
!self.app.state.pinned_panels.contains(&title)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **Purpose:** Handles the tab close request when the user clicks the close button.
|
/// **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.
|
/// **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 {
|
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).
|
/// **Purpose:** Custom context menu for dock tabs (VS2022 style).
|
||||||
@@ -827,7 +834,11 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
|
|||||||
ui.add_space(4.0);
|
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;
|
let mut float = false;
|
||||||
menu_item(ui, "Yeni Pencere", true, &mut float);
|
menu_item(ui, "Yeni Pencere", true, &mut float);
|
||||||
if float {
|
if float {
|
||||||
@@ -850,8 +861,11 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
|
|||||||
"Paneli Kapat"
|
"Paneli Kapat"
|
||||||
};
|
};
|
||||||
let close_enabled = self.is_closeable(tab);
|
let close_enabled = self.is_closeable(tab);
|
||||||
menu_item(ui, close_label, close_enabled, &mut close);
|
// Dialog-style panels (LayerStyles/LayerDetails/Plugins) are closed by the
|
||||||
if close && close_enabled {
|
// 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 let HciePane::Document(idx) = tab {
|
||||||
if self
|
if self
|
||||||
.app
|
.app
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,2 @@
|
|||||||
pub use egui_dock::*;
|
pub use egui_dock::*;
|
||||||
pub mod 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
|
/// Tracks which utility panels are currently floating, mapped to their
|
||||||
/// window SurfaceIndex so they can be re-docked later.
|
/// window SurfaceIndex so they can be re-docked later.
|
||||||
pub floating_panels: std::collections::HashMap<dock::HciePane, egui_dock::SurfaceIndex>,
|
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 auto_hide_toggle: Option<String>,
|
||||||
pub internal_clipboard: Option<SelectionTransform>,
|
pub internal_clipboard: Option<SelectionTransform>,
|
||||||
/// Raw paste data stored when paste is deferred due to canvas size mismatch.
|
/// 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.
|
/// The canonical document node from the default dock layout.
|
||||||
/// Used to keep documents in the center area and prevent swapping with utility panels.
|
/// Used to keep documents in the center area and prevent swapping with utility panels.
|
||||||
pub canonical_doc_node: Option<egui_dock::NodePath>,
|
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,
|
pub paste_counter: u64,
|
||||||
/// Hash of last clipboard image to detect new clipboard content.
|
/// Hash of last clipboard image to detect new clipboard content.
|
||||||
pub clipboard_hash: u64,
|
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();
|
"Küçültmek için Shift tuşuna basın veya sağ tıklayın.".to_string();
|
||||||
let tree = ds.main_surface_mut();
|
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(
|
let [remaining, left_col2] = tree.split_left(
|
||||||
egui_dock::NodeIndex::root(),
|
egui_dock::NodeIndex::root(),
|
||||||
0.1608,
|
0.17,
|
||||||
vec![HciePane::Brushes],
|
vec![HciePane::Brushes],
|
||||||
);
|
);
|
||||||
let [_left_brushes, _left_filters] = tree.split_below(left_col2, 0.5, vec![HciePane::Filters]);
|
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] =
|
let [_right_layers, _right_props] =
|
||||||
tree.split_below(right_col2, 0.5, vec![HciePane::Properties]);
|
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 [_center, right_col1] = tree.split_right(center, 0.80, vec![HciePane::ColorBox]);
|
||||||
let [_right_color, _right_history] = tree.split_below(
|
let [_right_color, _right_history] = tree.split_below(
|
||||||
right_col1,
|
right_col1,
|
||||||
@@ -1363,11 +1357,12 @@ impl HcieApp {
|
|||||||
if let Some(json) = storage.get_string("hcie_panel_widths") {
|
if let Some(json) = storage.get_string("hcie_panel_widths") {
|
||||||
if let Ok(tuple) = serde_json::from_str::<(f32, f32, f32, f32, f32)>(&json) {
|
if let Ok(tuple) = serde_json::from_str::<(f32, f32, f32, f32, f32)>(&json) {
|
||||||
last_window_width = tuple.0;
|
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_col1_w = 36.0;
|
||||||
left_col2_w = tuple.2.max(200.0);
|
// Cap utility columns so they cannot take over the canvas.
|
||||||
right_col1_w = tuple.3.max(200.0);
|
left_col2_w = tuple.2.clamp(150.0, 220.0);
|
||||||
right_col2_w = tuple.4.max(200.0);
|
right_col1_w = tuple.3.clamp(150.0, 220.0);
|
||||||
|
right_col2_w = tuple.4.clamp(150.0, 220.0);
|
||||||
log::trace!(
|
log::trace!(
|
||||||
"Loaded persisted panel widths: last_window_width={}, left_col1_w={}, left_col2_w={}, right_col1_w={}, right_col2_w={}",
|
"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
|
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;
|
let mut dock_state = None;
|
||||||
// When a panel screenshot is requested, ignore the persisted dock layout
|
// When a panel screenshot is requested, ignore the persisted dock layout
|
||||||
// so the panel always starts from the known default 200px Color Palette
|
// so the panel always starts from the known default 200px Color Palette
|
||||||
// width and the captured rectangle is deterministic.
|
// width and the captured rectangle is deterministic.
|
||||||
if panel_screenshot_request.is_none() {
|
if panel_screenshot_request.is_none() {
|
||||||
if let Some(storage) = cc.storage {
|
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 Some(json) = storage.get_string("hcie_dock_state") {
|
||||||
if let Ok(mut ds) =
|
if let Ok(mut ds) =
|
||||||
serde_json::from_str::<egui_dock::DockState<dock::HciePane>>(&json)
|
serde_json::from_str::<egui_dock::DockState<dock::HciePane>>(&json)
|
||||||
@@ -1547,7 +1530,6 @@ impl HcieApp {
|
|||||||
panels_to_collapse: Vec::new(),
|
panels_to_collapse: Vec::new(),
|
||||||
panels_to_float: Vec::new(),
|
panels_to_float: Vec::new(),
|
||||||
floating_panels: std::collections::HashMap::new(),
|
floating_panels: std::collections::HashMap::new(),
|
||||||
grim_floating_panels: Vec::new(),
|
|
||||||
auto_hide_toggle: None,
|
auto_hide_toggle: None,
|
||||||
internal_clipboard: None,
|
internal_clipboard: None,
|
||||||
status_message: None,
|
status_message: None,
|
||||||
@@ -1566,8 +1548,6 @@ impl HcieApp {
|
|||||||
dock_profile_rename_target: None,
|
dock_profile_rename_target: None,
|
||||||
dock_profile_rename_buf: String::new(),
|
dock_profile_rename_buf: String::new(),
|
||||||
canonical_doc_node,
|
canonical_doc_node,
|
||||||
use_grimdock: true,
|
|
||||||
grim_dock_host,
|
|
||||||
paste_counter: 1,
|
paste_counter: 1,
|
||||||
clipboard_hash: 0,
|
clipboard_hash: 0,
|
||||||
clipboard_poll_counter: 0,
|
clipboard_poll_counter: 0,
|
||||||
@@ -1728,11 +1708,6 @@ impl HcieApp {
|
|||||||
/// **Arguments:**
|
/// **Arguments:**
|
||||||
/// * `doc_pane`: The `dock::HciePane` document pane enum to push.
|
/// * `doc_pane`: The `dock::HciePane` document pane enum to push.
|
||||||
pub fn push_document_to_center(&mut self, doc_pane: dock::HciePane) {
|
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;
|
let mut target_node = None;
|
||||||
|
|
||||||
// 1. Prefer a leaf that already has document tabs
|
// 1. Prefer a leaf that already has document tabs
|
||||||
@@ -1763,53 +1738,12 @@ impl HcieApp {
|
|||||||
self.dock_settle_repaints = self.dock_settle_repaints.max(5);
|
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.
|
/// Push a newly opened/created document tab into the center document leaf.
|
||||||
if let dock::HciePane::Document(_idx) = doc_pane {
|
fn push_document_to_center_placeholder() {}
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove EmptyCenter placeholder if present — a real document is arriving.
|
/// Panic-safe tab removal: validates indices before calling egui_dock::remove_tab.
|
||||||
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.
|
/// Panic-safe tab removal: validates indices before calling egui_dock::remove_tab.
|
||||||
/// Returns `None` and logs a warning if indices are stale.
|
/// Returns `None` and logs a warning if indices are stale.
|
||||||
@@ -1986,110 +1920,44 @@ impl HcieApp {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let doc_count_before = self.documents.len();
|
|
||||||
self.documents.remove(idx);
|
self.documents.remove(idx);
|
||||||
|
|
||||||
if self.use_grimdock {
|
let doc_tab_count_before = self
|
||||||
if let Some(host) = &mut self.grim_dock_host {
|
.dock_state
|
||||||
if doc_count_before <= 1 {
|
.iter_all_tabs()
|
||||||
log::trace!("Last document closed in grimdock. Creating new Untitled document and replacing tab in-place to preserve sidebar layouts");
|
.filter(|(_, t)| t.is_actual_document())
|
||||||
let new_idx = self.documents.len();
|
.count();
|
||||||
self.documents.push(AppDocument::new("Untitled", 800, 600));
|
if doc_tab_count_before <= 1 {
|
||||||
self.documents[new_idx].engine.set_modified(false);
|
log::trace!("Last document closed. Creating new Untitled document and replacing tab in-place to preserve sidebar layouts");
|
||||||
for leaf_idx in host.tree.leaf_indices().collect::<Vec<_>>() {
|
let new_idx = self.documents.len();
|
||||||
if let Some(pane_id) = host.tree.pane_id_at(leaf_idx) {
|
self.documents.push(AppDocument::new("Untitled", 800, 600));
|
||||||
if let Some(mut pane) = host.tree.pane_mut(pane_id) {
|
self.documents[new_idx].engine.set_modified(false);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (_, tab) in self.dock_state.iter_all_tabs_mut() {
|
for (_, tab) in self.dock_state.iter_all_tabs_mut() {
|
||||||
if let dock::HciePane::Document(doc_idx) = tab {
|
if let dock::HciePane::Document(doc_idx) = tab {
|
||||||
if *doc_idx > idx {
|
if *doc_idx == idx {
|
||||||
*doc_idx -= 1;
|
*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() {
|
if self.active_doc >= self.documents.len() && !self.documents.is_empty() {
|
||||||
@@ -4014,20 +3882,10 @@ impl HcieApp {
|
|||||||
egui::UserData::default(),
|
egui::UserData::default(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if i.key_pressed(egui::Key::F11) && self.use_grimdock {
|
if i.key_pressed(egui::Key::F11) {
|
||||||
if let Some(host) = self.grim_dock_host.as_mut() {
|
let target = dock::HciePane::ColorBox;
|
||||||
let target = dock::HciePane::ColorBox;
|
if self.dock_state.find_tab(&target).is_some() {
|
||||||
if let Some((leaf_idx, tab_pos)) = host.tree.find_tab(&target) {
|
self.panels_to_float.push(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::Escape) {
|
if i.key_pressed(egui::Key::Escape) {
|
||||||
@@ -4117,14 +3975,6 @@ impl HcieApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _dock_rect = ui.max_rect();
|
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
|
// 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.
|
// 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();
|
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 let Some(name) = self.auto_hide_toggle.take() {
|
||||||
if self.auto_hide_active.as_deref() == Some(&name) {
|
if self.auto_hide_active.as_deref() == Some(&name) {
|
||||||
self.auto_hide_active = None;
|
self.auto_hide_active = None;
|
||||||
@@ -5046,49 +4886,23 @@ impl HcieApp {
|
|||||||
};
|
};
|
||||||
let pane = pane.clone();
|
let pane = pane.clone();
|
||||||
if self.panel_screenshot_frame == 0 {
|
if self.panel_screenshot_frame == 0 {
|
||||||
// Pin the Color Palette so the layout solver gives it the intended
|
// Rebuild the dock from the default so the persisted layout cannot
|
||||||
// 200px width instead of treating it as an auto-hide overlay.
|
// keep a different panel width, and pin the target panel so the
|
||||||
if matches!(pane, dock::HciePane::ColorBox) {
|
// layout solver gives it the intended 200px width.
|
||||||
self.state.pinned_panels.insert("Color Palette".to_string());
|
let pinned_name = pane.static_title();
|
||||||
self.right_col1_w = 200.0;
|
self.state.pinned_panels.insert(pinned_name.to_string());
|
||||||
self.last_window_width = 0.0;
|
match pane {
|
||||||
// Rebuild the dock from the default so the persisted layout
|
dock::HciePane::ColorBox => self.right_col1_w = 200.0,
|
||||||
// cannot keep a different panel width.
|
dock::HciePane::Brushes | dock::HciePane::Filters => self.left_col2_w = 200.0,
|
||||||
self.dock_state = default_dock_state();
|
dock::HciePane::Layers | dock::HciePane::Properties => self.right_col2_w = 200.0,
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
// Ensure the panel exists in the dock. For grimdock, add tabs into
|
self.last_window_width = 0.0;
|
||||||
// the correct default leaf so that the target panel is actually
|
self.dock_state = default_dock_state();
|
||||||
// rendered (some panels, e.g. Plugins, are not in the default tree).
|
// Ensure the panel exists in the dock. For panels not in the
|
||||||
if self.use_grimdock {
|
// default tree (e.g. Plugins), add them to the currently focused
|
||||||
let host = self.grim_dock_host.as_mut().expect("grimdock host");
|
// leaf so the requested panel is actually rendered.
|
||||||
let leaf_idx = dock::grim_dock::default_leaf_for_pane(&pane);
|
if self.dock_state.find_tab(&pane).is_none() {
|
||||||
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.dock_state.push_to_focused_leaf(pane.clone());
|
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) {
|
if let Ok(json) = serde_json::to_string(&self.dock_state) {
|
||||||
storage.set_string("hcie_dock_state", json);
|
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 = (
|
let colors_data = (
|
||||||
&self.state.primary_color,
|
&self.state.primary_color,
|
||||||
&self.state.secondary_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
|
/// 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
|
/// 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).
|
/// 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
|
const MIN_PANE_WIDTH: f32 = 150.0;
|
||||||
/// intentionally optimized for a 200px column; other panels keep 150px.
|
const MIN_LAYERS_WIDTH: f32 = 150.0;
|
||||||
const MIN_PANE_WIDTH: f32 = 200.0;
|
|
||||||
const MIN_LAYERS_WIDTH: f32 = 200.0;
|
|
||||||
|
|
||||||
/// Returns true if the subtree is a collapsed leaf (or contains one), in which
|
/// Returns true if the subtree is a collapsed leaf (or contains one), in which
|
||||||
/// case it should be exempt from the minimum-width guard.
|
/// 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 {
|
} else {
|
||||||
app.state.pinned_panels.insert(label.to_string());
|
app.state.pinned_panels.insert(label.to_string());
|
||||||
if pane != HciePane::Tools {
|
if pane != HciePane::Tools {
|
||||||
if app.use_grimdock {
|
|
||||||
app.panels_to_focus.push(pane.clone());
|
|
||||||
}
|
|
||||||
if app.dock_state.find_tab(&pane).is_none() {
|
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();
|
ui.close();
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ fn main() -> eframe::Result<()> {
|
|||||||
let mut screenshot_panel: Option<String> = None;
|
let mut screenshot_panel: Option<String> = None;
|
||||||
let mut screenshot_out: Option<std::path::PathBuf> = None;
|
let mut screenshot_out: Option<std::path::PathBuf> = None;
|
||||||
let mut toolbox_two_column = false;
|
let mut toolbox_two_column = false;
|
||||||
let mut use_grimdock = true; // grimdock is the new default
|
|
||||||
{
|
{
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
@@ -36,10 +35,6 @@ fn main() -> eframe::Result<()> {
|
|||||||
toolbox_two_column = true;
|
toolbox_two_column = true;
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
"--egui-dock" => {
|
|
||||||
use_grimdock = false;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
other => {
|
other => {
|
||||||
load_path = Some(std::path::PathBuf::from(other));
|
load_path = Some(std::path::PathBuf::from(other));
|
||||||
i += 1;
|
i += 1;
|
||||||
@@ -86,10 +81,6 @@ fn main() -> eframe::Result<()> {
|
|||||||
let mut app = app::HcieApp::new(cc, tablet_state, screenshot_request);
|
let mut app = app::HcieApp::new(cc, tablet_state, screenshot_request);
|
||||||
app.pending_load = load_path;
|
app.pending_load = load_path;
|
||||||
app.state.toolbox_two_column = toolbox_two_column;
|
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))
|
Ok(Box::new(app))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,316 +0,0 @@
|
|||||||
//! Dock / float behavior tests (pure-Rust, no `egui::Context`).
|
|
||||||
//!
|
|
||||||
//! These tests exercise the regression-prone state-machine logic of
|
|
||||||
//! `DockController` and `dock_engine::PanelTree`: deferred-queue frame timing,
|
|
||||||
//! float source-leaf restore, EmptyCenter insertion, document/tools cannot
|
|
||||||
//! float, persistence round-trip, and document index shifting. They run in CI
|
|
||||||
//! without a GPU. Render / floating-window visual behavior is covered by the
|
|
||||||
//! `--screenshot-panel` baseline pipeline instead.
|
|
||||||
//!
|
|
||||||
//! See AGENTS.md "Dock / Float Regression Protection" for the protected
|
|
||||||
//! invariants each test guards.
|
|
||||||
|
|
||||||
use hcie_gui_egui::app::dock::HciePane;
|
|
||||||
use hcie_gui_egui::app::dock_controller::DockController;
|
|
||||||
use hcie_gui_egui::app::dock_engine::{default_tree, PaneRole, PanelTree};
|
|
||||||
|
|
||||||
fn rect() -> [f32; 4] {
|
|
||||||
[100.0, 100.0, 320.0, 480.0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tree / default layout ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tree_default_has_document_in_center_leaf() {
|
|
||||||
let tree = default_tree();
|
|
||||||
let editor = tree
|
|
||||||
.find_pane_with_role(PaneRole::Editor)
|
|
||||||
.expect("default tree has an editor leaf");
|
|
||||||
let (tabs, _, _) = tree.leaf(editor).expect("editor leaf exists");
|
|
||||||
assert!(
|
|
||||||
tabs.iter().any(|t| matches!(t.id, HciePane::Document(0))),
|
|
||||||
"default editor leaf must hold Document(0)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tree_default_has_expected_panes() {
|
|
||||||
let tree = default_tree();
|
|
||||||
for pane in [
|
|
||||||
HciePane::Brushes,
|
|
||||||
HciePane::Filters,
|
|
||||||
HciePane::ColorBox,
|
|
||||||
HciePane::History,
|
|
||||||
HciePane::AiAssistant,
|
|
||||||
HciePane::Layers,
|
|
||||||
HciePane::Properties,
|
|
||||||
HciePane::Document(0),
|
|
||||||
] {
|
|
||||||
assert!(tree.has_tab(&pane), "default tree missing {:?}", pane);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tree_persist_roundtrip_topology_identical() {
|
|
||||||
let tree = default_tree();
|
|
||||||
let persisted = tree.persist();
|
|
||||||
let restored = PanelTree::from_persisted(persisted).expect("round-trip");
|
|
||||||
assert_eq!(
|
|
||||||
restored.root, tree.root,
|
|
||||||
"topology must be identical after round-trip"
|
|
||||||
);
|
|
||||||
assert_eq!(restored.next_pane_id, tree.next_pane_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tree_from_persisted_rejects_wrong_version() {
|
|
||||||
let mut persisted = default_tree().persist();
|
|
||||||
persisted.version = 999;
|
|
||||||
assert!(PanelTree::from_persisted(persisted).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Controller: active tab / first-frame invariant ─────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_controller_active_tab_is_document_zero() {
|
|
||||||
let ctrl = DockController::new();
|
|
||||||
assert_eq!(*ctrl.active_tab(), HciePane::Document(0));
|
|
||||||
assert_eq!(ctrl.last_active_doc(), Some(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Float / dock ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn float_then_dock_restores_source_leaf() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
let pane = HciePane::Layers;
|
|
||||||
assert!(ctrl.is_pinned(&pane));
|
|
||||||
assert!(!ctrl.is_floating(&pane));
|
|
||||||
|
|
||||||
assert!(ctrl.float_pane(&pane, rect()));
|
|
||||||
assert!(ctrl.is_floating(&pane));
|
|
||||||
assert!(!ctrl.is_pinned(&pane));
|
|
||||||
assert!(!ctrl.has_tab(&pane));
|
|
||||||
|
|
||||||
assert!(ctrl.dock_pane(&pane));
|
|
||||||
assert!(!ctrl.is_floating(&pane));
|
|
||||||
assert!(ctrl.is_pinned(&pane));
|
|
||||||
assert!(ctrl.has_tab(&pane));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn float_twice_no_duplicate_floating_entry() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
let pane = HciePane::Layers;
|
|
||||||
assert!(ctrl.float_pane(&pane, rect()));
|
|
||||||
assert!(!ctrl.float_pane(&pane, rect()));
|
|
||||||
assert_eq!(ctrl.floating_count(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn document_and_tools_cannot_float() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
assert!(!ctrl.float_pane(&HciePane::Document(0), rect()));
|
|
||||||
assert!(!ctrl.float_pane(&HciePane::Tools, rect()));
|
|
||||||
assert_eq!(ctrl.floating_count(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn queue_float_rejects_document_and_tools() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
ctrl.queue_float(HciePane::Document(0));
|
|
||||||
ctrl.queue_float(HciePane::Tools);
|
|
||||||
// Queue lengths: [close, float, focus, collapse]
|
|
||||||
let [_, float, _, _] = ctrl.queue_lengths();
|
|
||||||
assert_eq!(float, 0, "Document/Tools must not be enqueued for float");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Close ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_removes_from_pinned_and_floating() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
let pane = HciePane::Layers;
|
|
||||||
assert!(ctrl.float_pane(&pane, rect()));
|
|
||||||
assert!(ctrl.close_tab(&pane));
|
|
||||||
assert!(!ctrl.is_floating(&pane));
|
|
||||||
assert!(!ctrl.is_pinned(&pane));
|
|
||||||
assert!(!ctrl.has_tab(&pane));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_docked_pane_removes_from_tree_and_pinned() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
let pane = HciePane::Layers;
|
|
||||||
assert!(ctrl.has_tab(&pane));
|
|
||||||
assert!(ctrl.close_tab(&pane));
|
|
||||||
assert!(!ctrl.has_tab(&pane));
|
|
||||||
assert!(!ctrl.is_pinned(&pane));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Document push / remove / shift ────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_document_sets_active_tab_to_document() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
ctrl.push_document(1);
|
|
||||||
assert_eq!(*ctrl.active_tab(), HciePane::Document(1));
|
|
||||||
assert_eq!(ctrl.last_active_doc(), Some(1));
|
|
||||||
assert!(ctrl.has_tab(&HciePane::Document(1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_document_removes_empty_center() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
// Simulate all documents closed -> EmptyCenter inserted.
|
|
||||||
ctrl.remove_document(0);
|
|
||||||
ctrl.drain_queues();
|
|
||||||
assert!(ctrl.empty_center_present());
|
|
||||||
ctrl.push_document(0);
|
|
||||||
assert!(
|
|
||||||
!ctrl.empty_center_present(),
|
|
||||||
"EmptyCenter must be removed once a document exists"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_document_shifts_indices_in_tabs() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
ctrl.push_document(1);
|
|
||||||
ctrl.push_document(2);
|
|
||||||
assert!(ctrl.has_tab(&HciePane::Document(2)));
|
|
||||||
|
|
||||||
// Remove doc index 1 -> doc 2 should become doc 1.
|
|
||||||
ctrl.remove_document(1);
|
|
||||||
assert!(ctrl.has_tab(&HciePane::Document(1)));
|
|
||||||
assert!(!ctrl.has_tab(&HciePane::Document(2)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_last_document_updates_active_to_empty_center() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
ctrl.remove_document(0);
|
|
||||||
ctrl.drain_queues();
|
|
||||||
assert_eq!(*ctrl.active_tab(), HciePane::EmptyCenter);
|
|
||||||
assert_eq!(ctrl.last_active_doc(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── EmptyCenter placeholder ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn emptycenter_inserted_when_no_documents() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
assert!(!ctrl.empty_center_present());
|
|
||||||
ctrl.remove_document(0);
|
|
||||||
let outcome = ctrl.drain_queues();
|
|
||||||
assert!(outcome.empty_center_inserted);
|
|
||||||
assert!(ctrl.empty_center_present());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── drain_queues frame-timing invariant ───────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn queue_close_float_focus_drained_in_render_order() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
ctrl.queue_close(HciePane::Layers);
|
|
||||||
ctrl.queue_float(HciePane::Properties);
|
|
||||||
ctrl.queue_focus(HciePane::ColorBox);
|
|
||||||
ctrl.queue_collapse(HciePane::History);
|
|
||||||
|
|
||||||
let outcome = ctrl.drain_queues();
|
|
||||||
assert_eq!(outcome.closed, 1);
|
|
||||||
assert_eq!(outcome.floated, 1);
|
|
||||||
// Properties was floated then ColorBox focus-returned to dock.
|
|
||||||
// Note: queue_focus docks a currently-floating panel; ColorBox isn't
|
|
||||||
// floating, so focus is a no-op here (returns false).
|
|
||||||
assert_eq!(outcome.focused, 0);
|
|
||||||
assert_eq!(outcome.collapsed, 1);
|
|
||||||
|
|
||||||
// All queues must be empty after drain.
|
|
||||||
assert_eq!(ctrl.queue_lengths(), [0, 0, 0, 0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn drain_queues_is_idempotent_when_empty() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
let outcome = ctrl.drain_queues();
|
|
||||||
assert_eq!(outcome, Default::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn queued_float_processed_before_focus_so_focus_can_dock() {
|
|
||||||
// Float then focus-dock the same panel in one drain: float first makes it
|
|
||||||
// floating, then focus docks it back. Net: not floating.
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
let pane = HciePane::Layers;
|
|
||||||
ctrl.queue_float(pane.clone());
|
|
||||||
ctrl.queue_focus(pane.clone());
|
|
||||||
let outcome = ctrl.drain_queues();
|
|
||||||
assert_eq!(outcome.floated, 1);
|
|
||||||
assert_eq!(outcome.focused, 1);
|
|
||||||
assert!(!ctrl.is_floating(&pane));
|
|
||||||
assert!(ctrl.has_tab(&pane));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Persistence ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persistence_roundtrip_preserves_state() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
ctrl.push_document(1);
|
|
||||||
ctrl.float_pane(&HciePane::Layers, rect());
|
|
||||||
ctrl.close_tab(&HciePane::Filters);
|
|
||||||
|
|
||||||
let json = ctrl.persist();
|
|
||||||
let mut restored = DockController::new();
|
|
||||||
assert!(restored.restore(&json));
|
|
||||||
assert!(restored.has_tab(&HciePane::Document(1)));
|
|
||||||
assert!(restored.is_floating(&HciePane::Layers));
|
|
||||||
assert!(!restored.has_tab(&HciePane::Filters));
|
|
||||||
assert_eq!(*restored.active_tab(), HciePane::Document(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persistence_invalid_json_falls_back_to_default() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
ctrl.push_document(1);
|
|
||||||
assert!(!ctrl.restore("not valid json {"));
|
|
||||||
// Fallback resets to default.
|
|
||||||
assert_eq!(*ctrl.active_tab(), HciePane::Document(0));
|
|
||||||
assert!(!ctrl.is_floating(&HciePane::Layers));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persistence_legacy_version_falls_back_to_default() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
ctrl.push_document(2);
|
|
||||||
// Hand-craft a snapshot with a wrong version number.
|
|
||||||
let bad = serde_json::json!({
|
|
||||||
"version": 999,
|
|
||||||
"tree": default_tree().persist(),
|
|
||||||
"floating": [],
|
|
||||||
"pinned": [],
|
|
||||||
"active_tab": HciePane::Document(0),
|
|
||||||
"last_active_doc": 0,
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
assert!(!ctrl.restore(&bad));
|
|
||||||
assert_eq!(*ctrl.active_tab(), HciePane::Document(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ensure_pane_present (screenshot injector) ─────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ensure_pane_present_inserts_missing_pane() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
ctrl.close_tab(&HciePane::Layers);
|
|
||||||
assert!(!ctrl.has_tab(&HciePane::Layers));
|
|
||||||
assert!(ctrl.ensure_pane_present(&HciePane::Layers));
|
|
||||||
assert!(ctrl.has_tab(&HciePane::Layers));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ensure_pane_present_noop_when_already_present() {
|
|
||||||
let mut ctrl = DockController::new();
|
|
||||||
assert!(!ctrl.ensure_pane_present(&HciePane::Layers));
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
//! Invariant guard tests (behavior + source-inspection).
|
|
||||||
//!
|
|
||||||
//! These tests protect the AGENTS.md "GUI First-Frame Visibility" and "Dock /
|
|
||||||
//! Float Regression Protection" invariants. They come in two flavors:
|
|
||||||
//!
|
|
||||||
//! * **Behavior tests** exercise the running state machine (no GPU) to prove
|
|
||||||
//! an invariant holds at runtime.
|
|
||||||
//! * **Source-inspection tests** embed the relevant source files via
|
|
||||||
//! `include_str!` and assert that the protected lines/patterns are present.
|
|
||||||
//! They catch the case where an agent edits the invariant file directly.
|
|
||||||
//!
|
|
||||||
//! These are deliberately run on every CI build (no `#[ignore]`). If any fails,
|
|
||||||
//! a protected mechanism was broken and the change is not ready to commit.
|
|
||||||
|
|
||||||
use hcie_engine_api::Engine;
|
|
||||||
use hcie_gui_egui::app::dock::HciePane;
|
|
||||||
use hcie_gui_egui::app::dock_controller::DockController;
|
|
||||||
|
|
||||||
// ── Source files embedded for source-inspection assertions ────────────────
|
|
||||||
|
|
||||||
const CANVAS_MOD: &str = include_str!("../src/canvas/mod.rs");
|
|
||||||
const APP_MOD: &str = include_str!("../src/app/mod.rs");
|
|
||||||
const DOCK_CONTROLLER: &str = include_str!("../src/app/dock_controller.rs");
|
|
||||||
const DOCK_ENGINE: &str = include_str!("../src/app/dock_engine.rs");
|
|
||||||
|
|
||||||
// ── Behavior: DrawingFinished does NOT clear dirty flags ───────────────────
|
|
||||||
//
|
|
||||||
// AGENTS.md: "DrawingFinished does NOT clear dirty flags ... Calling
|
|
||||||
// clear_dirty_flags() inside DrawingFinished races with the next render pass
|
|
||||||
// and makes newly drawn vector shapes disappear."
|
|
||||||
//
|
|
||||||
// We assert the behavior at the engine level: drawing marks composite dirty,
|
|
||||||
// and simply handling the DrawingFinished event path (which is a no-op for
|
|
||||||
// dirty flags) leaves the dirty flag set so the next render uploads.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn drawing_finished_event_does_not_clear_dirty() {
|
|
||||||
let mut engine = Engine::new(10, 10);
|
|
||||||
// Drawing a stroke marks the composite dirty.
|
|
||||||
engine.draw_filled_rect_rgba(0, 0, 4, 4, [255, 0, 0, 255]);
|
|
||||||
assert!(
|
|
||||||
engine.is_composite_dirty(),
|
|
||||||
"after drawing, composite must be dirty so render_composition uploads"
|
|
||||||
);
|
|
||||||
// The DrawingFinished event handler in mod.rs:1955-1962 is a documented
|
|
||||||
// no-op w.r.t. dirty flags; we simulate that contract here by NOT calling
|
|
||||||
// clear_dirty_flags and asserting the flag survives.
|
|
||||||
// (If an agent adds clear_dirty_flags() to the event handler, the source
|
|
||||||
// inspection test `protected_drawing_finished_noop` below catches it.)
|
|
||||||
assert!(
|
|
||||||
engine.is_composite_dirty(),
|
|
||||||
"DrawingFinished must not clear dirty flags before render_composition runs"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Source-inspection: canvas multi-repaint on new texture ────────────────
|
|
||||||
//
|
|
||||||
// AGENTS.md: "Extra repaint after new texture ... Requests multiple repaints
|
|
||||||
// after render_composition creates a brand-new texture."
|
|
||||||
//
|
|
||||||
// The pattern is: `if created_new_texture { request_repaint();
|
|
||||||
// request_repaint_after(...) }`. We assert both calls appear within the
|
|
||||||
// `created_new_texture` block in canvas/mod.rs.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protected_canvas_multi_repaint_on_new_texture() {
|
|
||||||
// Locate the `created_new_texture` branch and assert both repaint calls
|
|
||||||
// appear shortly after it. This tolerates formatting changes but catches
|
|
||||||
// removal of one of the two repaint calls.
|
|
||||||
let created_idx = CANVAS_MOD
|
|
||||||
.find("if created_new_texture")
|
|
||||||
.or_else(|| CANVAS_MOD.find("created_new_texture {"))
|
|
||||||
.expect("canvas/mod.rs must retain the `created_new_texture` branch");
|
|
||||||
let window = &CANVAS_MOD[created_idx..created_idx.saturating_add(800).min(CANVAS_MOD.len())];
|
|
||||||
assert!(
|
|
||||||
window.contains("request_repaint()"),
|
|
||||||
"canvas/mod.rs: created_new_texture branch must call request_repaint() (first-frame visibility invariant)"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
window.contains("request_repaint_after"),
|
|
||||||
"canvas/mod.rs: created_new_texture branch must call request_repaint_after(...) (second/third frame display)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Source-inspection: DrawingFinished no dirty clear ──────────────────────
|
|
||||||
//
|
|
||||||
// AGENTS.md: the `DrawingFinished` arm must NOT call `clear_dirty`. We assert
|
|
||||||
// that within the DrawingFinished match arm there is no `clear_dirty` call.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protected_drawing_finished_noop() {
|
|
||||||
let arm_idx = APP_MOD
|
|
||||||
.find("AppEvent::DrawingFinished =>")
|
|
||||||
.expect("app/mod.rs must retain the DrawingFinished match arm");
|
|
||||||
// Take the slice until the next match arm or a reasonable window.
|
|
||||||
let end = APP_MOD[arm_idx..].find("AppEvent::Undo =>").unwrap_or(400);
|
|
||||||
let arm = &APP_MOD[arm_idx..arm_idx + end];
|
|
||||||
assert!(
|
|
||||||
!arm.contains("clear_dirty"),
|
|
||||||
"app/mod.rs: DrawingFinished arm must NOT call clear_dirty_flags (AGENTS.md protected invariant)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Source-inspection: DockController private fields ───────────────────────
|
|
||||||
//
|
|
||||||
// AGENTS.md: "All DockController fields are private; mutation only through
|
|
||||||
// methods." We assert the struct fields are declared without `pub`.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protected_dock_controller_fields_private() {
|
|
||||||
let struct_idx = DOCK_CONTROLLER
|
|
||||||
.find("pub struct DockController {")
|
|
||||||
.expect("dock_controller.rs must define pub struct DockController");
|
|
||||||
let end = DOCK_CONTROLLER[struct_idx..].find('}').unwrap_or(800);
|
|
||||||
let body = &DOCK_CONTROLLER[struct_idx..struct_idx + end];
|
|
||||||
assert!(
|
|
||||||
!body.contains("pub tree:") && !body.contains("pub floating:") && !body.contains("pub pinned:") && !body.contains("pub active_tab:"),
|
|
||||||
"dock_controller.rs: DockController fields must be private (no `pub`) so external mutation is forced through methods"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Source-inspection: drain order fixed ───────────────────────────────────
|
|
||||||
//
|
|
||||||
// AGENTS.md: "Drains to_close / to_focus / to_collapse / to_float queues in a
|
|
||||||
// fixed order." We assert the DRAIN_ORDER const lists Close before Float before
|
|
||||||
// Focus before Collapse.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protected_drain_order_fixed() {
|
|
||||||
let order_idx = DOCK_CONTROLLER
|
|
||||||
.find("const DRAIN_ORDER")
|
|
||||||
.expect("dock_controller.rs must define DRAIN_ORDER");
|
|
||||||
let window = &DOCK_CONTROLLER[order_idx..order_idx + 200];
|
|
||||||
let close = window.find("QueueKind::Close").unwrap();
|
|
||||||
let float = window.find("QueueKind::Float").unwrap();
|
|
||||||
let focus = window.find("QueueKind::Focus").unwrap();
|
|
||||||
let collapse = window.find("QueueKind::Collapse").unwrap();
|
|
||||||
assert!(close < float, "DRAIN_ORDER must be Close before Float");
|
|
||||||
assert!(float < focus, "DRAIN_ORDER must be Float before Focus");
|
|
||||||
assert!(
|
|
||||||
focus < collapse,
|
|
||||||
"DRAIN_ORDER must be Focus before Collapse"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Behavior: initial active tab is Document(0) ────────────────────────────
|
|
||||||
//
|
|
||||||
// AGENTS.md: "Initial document active tab ... Sets Document(0) as the active
|
|
||||||
// dock tab on startup so the canvas widget's ui() is called on the first frame."
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protected_initial_active_tab_document_zero() {
|
|
||||||
let ctrl = DockController::new();
|
|
||||||
assert_eq!(*ctrl.active_tab(), HciePane::Document(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Source-inspection: document/tools cannot float guard present ──────────
|
|
||||||
//
|
|
||||||
// AGENTS.md: "Rejects float_pane calls for Document(_) and Tools panes." We
|
|
||||||
// assert the `float_pane` method contains the guard check.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protected_document_and_tools_cannot_float_guard() {
|
|
||||||
let method_idx = DOCK_CONTROLLER
|
|
||||||
.find("pub fn float_pane")
|
|
||||||
.expect("dock_controller.rs must define float_pane");
|
|
||||||
let end = DOCK_CONTROLLER[method_idx..]
|
|
||||||
.find("pub fn dock_pane")
|
|
||||||
.unwrap_or(1200);
|
|
||||||
let body = &DOCK_CONTROLLER[method_idx..method_idx + end];
|
|
||||||
assert!(
|
|
||||||
body.contains("HciePane::Tools"),
|
|
||||||
"float_pane must explicitly reject HciePane::Tools"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
body.contains("is_document"),
|
|
||||||
"float_pane must reject documents via is_document()"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Source-inspection: EmptyCenter insertion logic present ─────────────────
|
|
||||||
//
|
|
||||||
// AGENTS.md: "Inserts an EmptyCenter tab into the editor leaf when no document
|
|
||||||
// tabs remain." We assert drain_queues contains the EmptyCenter insert branch.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protected_emptycenter_insertion_present() {
|
|
||||||
let drain_idx = DOCK_CONTROLLER
|
|
||||||
.find("pub fn drain_queues")
|
|
||||||
.expect("dock_controller.rs must define drain_queues");
|
|
||||||
let end = DOCK_CONTROLLER[drain_idx..]
|
|
||||||
.find("pub fn persist")
|
|
||||||
.unwrap_or(3000);
|
|
||||||
let body = &DOCK_CONTROLLER[drain_idx..drain_idx + end];
|
|
||||||
assert!(
|
|
||||||
body.contains("EmptyCenter"),
|
|
||||||
"drain_queues must insert EmptyCenter when no documents remain"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
body.contains("PaneRole::Editor"),
|
|
||||||
"EmptyCenter insertion must target the Editor role leaf"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Source-inspection: persistence single-schema round-trip present ────────
|
|
||||||
//
|
|
||||||
// AGENTS.md: "Single serde schema for tree + floating + pinned + active tab.
|
|
||||||
// Restore of an unrecognized (legacy) JSON falls back to the default layout."
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protected_persistence_single_schema_and_fallback() {
|
|
||||||
assert!(
|
|
||||||
DOCK_CONTROLLER.contains("struct PersistedSnapshot"),
|
|
||||||
"dock_controller.rs must define a single PersistedSnapshot schema"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
DOCK_CONTROLLER.contains("unrecognized JSON, falling back to default layout"),
|
|
||||||
"restore() must fall back to default on unrecognized JSON (legacy migration guard)"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
DOCK_ENGINE.contains("PANEL_TREE_FORMAT_VERSION"),
|
|
||||||
"dock_engine.rs must define PANEL_TREE_FORMAT_VERSION for version-gated restore"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+20
-20
@@ -1,8 +1,8 @@
|
|||||||
=========================================
|
=========================================
|
||||||
SATIR SAYISI RAPORU
|
SATIR SAYISI RAPORU
|
||||||
Proje: HCIE-Rust v4
|
Proje: HCIE-Rust v4
|
||||||
Konum: /mnt/extra/00_PROJECTS/hcie-rust-v4
|
Konum: /mnt/extra/00_PROJECTS/hcie-rust-v3.05
|
||||||
Tarih: 2026-07-08 22:13:18
|
Tarih: 2026-07-09 04:57:39
|
||||||
=========================================
|
=========================================
|
||||||
|
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
@@ -15,8 +15,8 @@ Tarih: 2026-07-08 22:13:18
|
|||||||
hcie-composite 985 satır
|
hcie-composite 985 satır
|
||||||
hcie-document 666 satır
|
hcie-document 666 satır
|
||||||
hcie-draw 502 satır
|
hcie-draw 502 satır
|
||||||
hcie-egui-app 31814 satır
|
hcie-egui-app 35426 satır
|
||||||
hcie-engine-api 5366 satır
|
hcie-engine-api 5401 satır
|
||||||
hcie-engine-api-orig 3874 satır
|
hcie-engine-api-orig 3874 satır
|
||||||
hcie-filter 3238 satır
|
hcie-filter 3238 satır
|
||||||
hcie-fx 4077 satır
|
hcie-fx 4077 satır
|
||||||
@@ -33,15 +33,15 @@ Tarih: 2026-07-08 22:13:18
|
|||||||
hcie-vector 2298 satır
|
hcie-vector 2298 satır
|
||||||
hcie-vision 3131 satır
|
hcie-vision 3131 satır
|
||||||
|
|
||||||
TOPLAM RUST: 86587 satır
|
TOPLAM RUST: 90234 satır
|
||||||
|
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
C++ / HEADER (.cpp, .h)
|
C++ / HEADER (.cpp, .h)
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
hcie-qt-app/src/*.cpp 3963 satır
|
hcie-qt-app/src/*.cpp 3991 satır
|
||||||
hcie-qt-app/src/*.h 1011 satır
|
hcie-qt-app/src/*.h 1040 satır
|
||||||
|
|
||||||
TOPLAM C++ / HEADER: 4974 satır
|
TOPLAM C++ / HEADER: 5031 satır
|
||||||
|
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
JAVASCRIPT (.js)
|
JAVASCRIPT (.js)
|
||||||
@@ -84,10 +84,10 @@ Tarih: 2026-07-08 22:13:18
|
|||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
SHELL SCRIPT (.sh)
|
SHELL SCRIPT (.sh)
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
(kök dizin) 3033 satır
|
(kök dizin) 3035 satır
|
||||||
logs/ 220 satır
|
logs/ 220 satır
|
||||||
|
|
||||||
TOPLAM SHELL: 3253 satır
|
TOPLAM SHELL: 3255 satır
|
||||||
|
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
DOKÜMANTASYON / VERİ DOSYALARI
|
DOKÜMANTASYON / VERİ DOSYALARI
|
||||||
@@ -95,34 +95,34 @@ Tarih: 2026-07-08 22:13:18
|
|||||||
|
|
||||||
JSON (.json)
|
JSON (.json)
|
||||||
--------------------------------
|
--------------------------------
|
||||||
(kök dizin) 14991 satır
|
(kök dizin) 6517 satır
|
||||||
|
|
||||||
TOPLAM JSON: 14991 satır
|
TOPLAM JSON: 6517 satır
|
||||||
|
|
||||||
MARKDOWN (.md)
|
MARKDOWN (.md)
|
||||||
--------------------------------
|
--------------------------------
|
||||||
(kök dizin) 11416 satır
|
(kök dizin) 11605 satır
|
||||||
|
|
||||||
TOPLAM MARKDOWN: 11416 satır
|
TOPLAM MARKDOWN: 11605 satır
|
||||||
|
|
||||||
=========================================
|
=========================================
|
||||||
KOD SATIRLARI TOPLAMI (GRAND TOTAL)
|
KOD SATIRLARI TOPLAMI (GRAND TOTAL)
|
||||||
=========================================
|
=========================================
|
||||||
Rust: 86587 satır
|
Rust: 90234 satır
|
||||||
C++ / Header: 4974 satır
|
C++ / Header: 5031 satır
|
||||||
JavaScript: 0 satır
|
JavaScript: 0 satır
|
||||||
Svelte: 0 satır
|
Svelte: 0 satır
|
||||||
TypeScript: 0 satır
|
TypeScript: 0 satır
|
||||||
Python: 2267 satır
|
Python: 2267 satır
|
||||||
HTML: 0 satır
|
HTML: 0 satır
|
||||||
CSS: 0 satır
|
CSS: 0 satır
|
||||||
Shell Script: 3253 satır
|
Shell Script: 3255 satır
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
KOD TOPLAMI: 97081 satır
|
KOD TOPLAMI: 100787 satır
|
||||||
|
|
||||||
RUST + C++/H TOPLAMI: 91561 satır
|
RUST + C++/H TOPLAMI: 95265 satır
|
||||||
|
|
||||||
(JSON ve Markdown dosyaları yukarıda ayrı olarak gösterilmiştir)
|
(JSON ve Markdown dosyaları yukarıda ayrı olarak gösterilmiştir)
|
||||||
=========================================
|
=========================================
|
||||||
|
|
||||||
Rapor dosyaya kaydedildi: /mnt/extra/00_PROJECTS/hcie-rust-v4/line_count_report.txt
|
Rapor dosyaya kaydedildi: /mnt/extra/00_PROJECTS/hcie-rust-v3.05/line_count_report.txt
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"v":1}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"git": {
|
|
||||||
"sha1": "a49454b6028db991f498a181c2ebc1ac416ae9e6"
|
|
||||||
},
|
|
||||||
"path_in_vcs": ""
|
|
||||||
}
|
|
||||||
-59
@@ -1,59 +0,0 @@
|
|||||||
name: Publish Crate
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Check tag matches Cargo.toml version
|
|
||||||
run: |
|
|
||||||
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
CARGO_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
|
|
||||||
test "$TAG_VERSION" = "$CARGO_VERSION"
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Cache cargo
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
|
|
||||||
- name: Dry run publish
|
|
||||||
run: cargo publish --dry-run
|
|
||||||
|
|
||||||
- name: Publish to crates.io
|
|
||||||
run: cargo publish
|
|
||||||
env:
|
|
||||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Check for changelog
|
|
||||||
id: changelog_file
|
|
||||||
run: |
|
|
||||||
if [ -f CHANGELOG.md ]; then
|
|
||||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Get changelog entry
|
|
||||||
id: changelog
|
|
||||||
if: steps.changelog_file.outputs.present == 'true'
|
|
||||||
run: |
|
|
||||||
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
echo "body<<EOF" >> "$GITHUB_OUTPUT"
|
|
||||||
awk "/^## \[${TAG_VERSION}\]/{found=1; next} found && /^## /{exit} found{print}" CHANGELOG.md >> "$GITHUB_OUTPUT"
|
|
||||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
body: ${{ steps.changelog.outputs.body }}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/target
|
|
||||||
**/target
|
|
||||||
book/build/
|
|
||||||
|
|
||||||
# Cargo lockfiles are usually omitted for library-style crates and examples here.
|
|
||||||
/Cargo.lock
|
|
||||||
/examples/qt_viewport/Cargo.lock
|
|
||||||
|
|
||||||
# Local editor and OS noise.
|
|
||||||
.DS_Store
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
.direnv/
|
|
||||||
*.log
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*.user
|
|
||||||
*.qtc.user
|
|
||||||
|
|
||||||
|
|
||||||
docs/
|
|
||||||
settings.local.json
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
|
||||||
#
|
|
||||||
# When uploading crates to the registry Cargo will automatically
|
|
||||||
# "normalize" Cargo.toml files for maximal compatibility
|
|
||||||
# with all versions of Cargo and also rewrite `path` dependencies
|
|
||||||
# to registry (e.g., crates.io) dependencies.
|
|
||||||
#
|
|
||||||
# If you are reading this file be aware that the original Cargo.toml
|
|
||||||
# will likely look very different (and much more reasonable).
|
|
||||||
# See Cargo.toml.orig for the original contents.
|
|
||||||
|
|
||||||
[package]
|
|
||||||
edition = "2021"
|
|
||||||
name = "grimdock"
|
|
||||||
version = "0.2.0"
|
|
||||||
build = false
|
|
||||||
autolib = false
|
|
||||||
autobins = false
|
|
||||||
autoexamples = false
|
|
||||||
autotests = false
|
|
||||||
autobenches = false
|
|
||||||
description = "Dockable panel layout system for egui"
|
|
||||||
readme = "README.md"
|
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
serde = [
|
|
||||||
"dep:serde",
|
|
||||||
"egui/serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "grimdock"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "1_basic"
|
|
||||||
path = "examples/1_basic.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "2_split_layout"
|
|
||||||
path = "examples/2_split_layout.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "3_header_features"
|
|
||||||
path = "examples/3_header_features.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "4_persistence"
|
|
||||||
path = "examples/4_persistence.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "5_texture_icons"
|
|
||||||
path = "examples/5_texture_icons.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "6_policy_controls"
|
|
||||||
path = "examples/6_policy_controls.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "7_theme_options"
|
|
||||||
path = "examples/7_theme_options.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "demo"
|
|
||||||
path = "examples/demo.rs"
|
|
||||||
|
|
||||||
[dependencies.egui]
|
|
||||||
version = "0.34"
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[dependencies.serde]
|
|
||||||
version = "1"
|
|
||||||
features = ["derive"]
|
|
||||||
optional = true
|
|
||||||
|
|
||||||
[dev-dependencies.eframe]
|
|
||||||
version = "0.33.3"
|
|
||||||
features = [
|
|
||||||
"default_fonts",
|
|
||||||
"glow",
|
|
||||||
]
|
|
||||||
default-features = false
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# grimdock
|
|
||||||
|
|
||||||
`grimdock` is an [egui](https://github.com/emilk/egui) docking crate I use for my own projects.
|
|
||||||
|
|
||||||
It keeps to a single-surface IDE-style dock: split panes, draggable tabs, resizing, and rearranging without turning into a floating window system.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- split-pane dock layouts
|
|
||||||
- stable pane identity via `PaneId`
|
|
||||||
- pane-scoped policy and mutation APIs
|
|
||||||
- separate pane policy and tab drop policy
|
|
||||||
- built-in header interactions and pane actions
|
|
||||||
- pane-specific add/open menus
|
|
||||||
- pane anchors and semantic pane roles
|
|
||||||
- versioned persistence
|
|
||||||
- theme, typography, and icon support, including texture icons
|
|
||||||
|
|
||||||
## Why this exists
|
|
||||||
|
|
||||||
This crate has a few things I needed that other egui panel/docking options
|
|
||||||
didn't line up with:
|
|
||||||
|
|
||||||
- explicit stable pane identity
|
|
||||||
- pane-level policy separate from tab-level drop rules
|
|
||||||
- anchors and semantic pane roles built into the dock model
|
|
||||||
- pane-scoped add/open actions and persistence behavior
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
//! Smallest runnable UI example:
|
|
||||||
//! `cargo run --example 1_basic`
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
use grimdock::{PanelContext, PanelStyle, PanelTree, Tab};
|
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
|
||||||
let options = eframe::NativeOptions {
|
|
||||||
viewport: egui::ViewportBuilder::default()
|
|
||||||
.with_title("grimdock 1_basic")
|
|
||||||
.with_inner_size([800.0, 480.0]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
eframe::run_native(
|
|
||||||
"grimdock 1_basic",
|
|
||||||
options,
|
|
||||||
Box::new(|_cc| Ok(Box::new(App::new()))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
tree: PanelTree<&'static str>,
|
|
||||||
style: PanelStyle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
tree: PanelTree::new(vec![Tab::new("welcome", "welcome")]),
|
|
||||||
style: PanelStyle::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for App {
|
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
||||||
egui::CentralPanel::default()
|
|
||||||
.frame(egui::Frame::NONE)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
PanelContext::new(ui, &mut self.tree, &self.style).show(|ui, tab_id| {
|
|
||||||
ui.add_space(12.0);
|
|
||||||
ui.heading(*tab_id);
|
|
||||||
ui.label("This is the smallest useful grimdock setup.");
|
|
||||||
ui.label("There is one pane and one tab.");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
//! Split layout and resize example:
|
|
||||||
//! `cargo run --example 2_split_layout`
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
use grimdock::{ChildSide, PanelContext, PanelStyle, PanelTree, SplitDir, Tab};
|
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
|
||||||
let options = eframe::NativeOptions {
|
|
||||||
viewport: egui::ViewportBuilder::default()
|
|
||||||
.with_title("grimdock 2_split_layout")
|
|
||||||
.with_inner_size([960.0, 600.0]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
eframe::run_native(
|
|
||||||
"grimdock 2_split_layout",
|
|
||||||
options,
|
|
||||||
Box::new(|_cc| Ok(Box::new(App::new()))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
||||||
enum TabId {
|
|
||||||
Files,
|
|
||||||
Editor,
|
|
||||||
Terminal,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
tree: PanelTree<TabId>,
|
|
||||||
style: PanelStyle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn new() -> Self {
|
|
||||||
let mut tree = PanelTree::new(vec![Tab::new("editor", TabId::Editor)]);
|
|
||||||
tree.split_leaf(
|
|
||||||
0,
|
|
||||||
SplitDir::Horizontal,
|
|
||||||
Tab::new("files", TabId::Files),
|
|
||||||
ChildSide::First,
|
|
||||||
);
|
|
||||||
|
|
||||||
let editor_pane = tree
|
|
||||||
.find_pane_containing(&TabId::Editor)
|
|
||||||
.expect("editor pane should exist");
|
|
||||||
tree.pane_mut(editor_pane)
|
|
||||||
.expect("editor pane should exist")
|
|
||||||
.split(
|
|
||||||
SplitDir::Vertical,
|
|
||||||
Tab::new("terminal", TabId::Terminal),
|
|
||||||
ChildSide::Second,
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
tree,
|
|
||||||
style: PanelStyle::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for App {
|
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
||||||
egui::CentralPanel::default()
|
|
||||||
.frame(egui::Frame::NONE)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
PanelContext::new(ui, &mut self.tree, &self.style).show(|ui, tab_id| match tab_id {
|
|
||||||
TabId::Files => {
|
|
||||||
ui.heading("Files");
|
|
||||||
ui.label("Try dragging the resize handles between panes.");
|
|
||||||
}
|
|
||||||
TabId::Editor => {
|
|
||||||
ui.heading("Editor");
|
|
||||||
ui.label("This pane started as the root.");
|
|
||||||
}
|
|
||||||
TabId::Terminal => {
|
|
||||||
ui.heading("Terminal");
|
|
||||||
ui.label("This pane was added by splitting the editor vertically.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
//! Header actions, add-tab menus, and tab markers:
|
|
||||||
//! `cargo run --example 3_header_features`
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
use grimdock::{
|
|
||||||
AddTabEntry, HeaderVisibility, OpenBehavior, PanelContext, PanelStyle, PanelTree,
|
|
||||||
PersistedNode, Tab,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
|
||||||
let options = eframe::NativeOptions {
|
|
||||||
viewport: egui::ViewportBuilder::default()
|
|
||||||
.with_title("grimdock 3_header_features")
|
|
||||||
.with_inner_size([960.0, 540.0]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
eframe::run_native(
|
|
||||||
"grimdock 3_header_features",
|
|
||||||
options,
|
|
||||||
Box::new(|_cc| Ok(Box::new(App::new()))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
||||||
enum TabId {
|
|
||||||
Notes,
|
|
||||||
Search,
|
|
||||||
Problems,
|
|
||||||
Terminal,
|
|
||||||
Output,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
tree: PanelTree<TabId>,
|
|
||||||
style: PanelStyle,
|
|
||||||
hide_headers: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
tree: PanelTree::new(vec![
|
|
||||||
Tab::new("notes", TabId::Notes)
|
|
||||||
.with_leading_visual(">")
|
|
||||||
.with_closable(true),
|
|
||||||
Tab::new("search", TabId::Search)
|
|
||||||
.with_leading_visual("?")
|
|
||||||
.with_closable(true),
|
|
||||||
Tab::new("problems", TabId::Problems)
|
|
||||||
.with_leading_visual("x")
|
|
||||||
.with_closable(true),
|
|
||||||
]),
|
|
||||||
style: PanelStyle::default(),
|
|
||||||
hide_headers: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_header_visibility(&mut self) {
|
|
||||||
let header_visibility = if self.hide_headers {
|
|
||||||
HeaderVisibility::Hidden
|
|
||||||
} else {
|
|
||||||
HeaderVisibility::Always
|
|
||||||
};
|
|
||||||
|
|
||||||
let pane_ids = self
|
|
||||||
.tree
|
|
||||||
.to_persisted()
|
|
||||||
.nodes
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|node| match node {
|
|
||||||
PersistedNode::Leaf { pane, .. } => Some(pane),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
for pane_id in pane_ids {
|
|
||||||
if let Some(mut pane) = self.tree.pane_mut(pane_id) {
|
|
||||||
let mut options = pane.options();
|
|
||||||
options.header_visibility = header_visibility;
|
|
||||||
pane.set_options(options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for App {
|
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
||||||
self.sync_header_visibility();
|
|
||||||
|
|
||||||
egui::CentralPanel::default()
|
|
||||||
.frame(egui::Frame::NONE)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
ui.checkbox(&mut self.hide_headers, "Hide pane headers");
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
let output = PanelContext::new(ui, &mut self.tree, &self.style)
|
|
||||||
.with_add_tab_provider(&|pane_id, tree| {
|
|
||||||
let mut entries = vec![
|
|
||||||
AddTabEntry::new(
|
|
||||||
"Output",
|
|
||||||
Tab::new("output", TabId::Output)
|
|
||||||
.with_leading_visual("!")
|
|
||||||
.with_closable(true),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
if tree.find_pane_containing(&TabId::Notes) == Some(pane_id) {
|
|
||||||
entries.push(
|
|
||||||
AddTabEntry::new(
|
|
||||||
"Terminal",
|
|
||||||
Tab::new("terminal", TabId::Terminal)
|
|
||||||
.with_leading_visual("$")
|
|
||||||
.with_closable(true),
|
|
||||||
)
|
|
||||||
.with_open_behavior(OpenBehavior::FocusExisting),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
entries
|
|
||||||
})
|
|
||||||
.show(|ui, tab_id| match tab_id {
|
|
||||||
TabId::Notes => {
|
|
||||||
ui.heading("Notes");
|
|
||||||
ui.label("This pane gets both Output and Terminal in its + menu.");
|
|
||||||
ui.label("Right-click a tab for Close / Close others / Split actions.");
|
|
||||||
}
|
|
||||||
TabId::Search => {
|
|
||||||
ui.heading("Search");
|
|
||||||
ui.text_edit_singleline(&mut String::new());
|
|
||||||
}
|
|
||||||
TabId::Problems => {
|
|
||||||
ui.heading("Problems");
|
|
||||||
ui.label("No problems detected.");
|
|
||||||
}
|
|
||||||
TabId::Terminal => {
|
|
||||||
ui.heading("Terminal");
|
|
||||||
ui.label("$ cargo run");
|
|
||||||
}
|
|
||||||
TabId::Output => {
|
|
||||||
ui.heading("Output");
|
|
||||||
ui.label("[INFO] pane-specific add menus are enabled");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if !output.closed_tabs.is_empty() {
|
|
||||||
ui.separator();
|
|
||||||
ui.label(format!("Closed this frame: {:?}", output.closed_tabs));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
//! Persistence example with a runnable UI:
|
|
||||||
//! `cargo run --example 4_persistence`
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
use grimdock::{
|
|
||||||
ChildSide, DropPolicy, PaneBuilder, PaneOptions, PanelContext, PanelStyle, PanelTree,
|
|
||||||
PersistedPanelTree, SplitDir, Tab,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
|
||||||
let options = eframe::NativeOptions {
|
|
||||||
viewport: egui::ViewportBuilder::default()
|
|
||||||
.with_title("grimdock 4_persistence")
|
|
||||||
.with_inner_size([960.0, 600.0]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
eframe::run_native(
|
|
||||||
"grimdock 4_persistence",
|
|
||||||
options,
|
|
||||||
Box::new(|_cc| Ok(Box::new(App::new()))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
tree: PanelTree<&'static str>,
|
|
||||||
saved: Option<PersistedPanelTree<&'static str>>,
|
|
||||||
style: PanelStyle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
tree: make_tree(),
|
|
||||||
saved: None,
|
|
||||||
style: PanelStyle::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for App {
|
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
||||||
egui::Panel::top("controls").show(ctx, |ui| {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui.button("Save layout").clicked() {
|
|
||||||
self.saved = Some(self.tree.to_persisted());
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui.button("Restore saved layout").clicked() {
|
|
||||||
if let Some(saved) = self.saved.clone() {
|
|
||||||
self.tree =
|
|
||||||
PanelTree::from_persisted(saved).expect("saved layout should restore");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui.button("Reset demo layout").clicked() {
|
|
||||||
self.tree = make_tree();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(saved) = &self.saved {
|
|
||||||
ui.label(format!(
|
|
||||||
"Saved version {} with {} persisted nodes",
|
|
||||||
saved.version,
|
|
||||||
saved.nodes.len()
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
ui.label("No saved layout yet.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
egui::CentralPanel::default()
|
|
||||||
.frame(egui::Frame::NONE)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
PanelContext::new(ui, &mut self.tree, &self.style).show(|ui, tab_id| {
|
|
||||||
ui.heading(*tab_id);
|
|
||||||
ui.label("Drag tabs, resize panes, then press Save layout.");
|
|
||||||
ui.label("Restore saved layout to rebuild the tree from the persisted format.");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_tree() -> PanelTree<&'static str> {
|
|
||||||
let mut options = PaneOptions::default();
|
|
||||||
options.drop_policy = DropPolicy::merge_only();
|
|
||||||
options.allow_resize = false;
|
|
||||||
|
|
||||||
let mut tree = PanelTree::from_pane(
|
|
||||||
PaneBuilder::new(Tab::new("editor", "editor").with_leading_visual(">"))
|
|
||||||
.push_tab(Tab::new("search", "search").with_leading_visual("?"))
|
|
||||||
.with_options(options),
|
|
||||||
);
|
|
||||||
|
|
||||||
tree.split_leaf(
|
|
||||||
0,
|
|
||||||
SplitDir::Vertical,
|
|
||||||
Tab::new("terminal", "terminal").with_leading_visual("$"),
|
|
||||||
ChildSide::Second,
|
|
||||||
);
|
|
||||||
|
|
||||||
tree
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
//! Texture-backed tab icon example:
|
|
||||||
//! `cargo run --example 5_texture_icons`
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
use grimdock::{PanelContext, PanelStyle, PanelTree, Tab};
|
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
|
||||||
let options = eframe::NativeOptions {
|
|
||||||
viewport: egui::ViewportBuilder::default()
|
|
||||||
.with_title("grimdock 5_texture_icons")
|
|
||||||
.with_inner_size([960.0, 560.0]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
eframe::run_native(
|
|
||||||
"grimdock 5_texture_icons",
|
|
||||||
options,
|
|
||||||
Box::new(|cc| Ok(Box::new(App::new(cc)))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
||||||
enum TabId {
|
|
||||||
Rust,
|
|
||||||
Search,
|
|
||||||
Graph,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
tree: PanelTree<TabId>,
|
|
||||||
style: PanelStyle,
|
|
||||||
_textures: Vec<egui::TextureHandle>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
|
||||||
let textures = vec![
|
|
||||||
make_icon(&cc.egui_ctx, "rust_icon", egui::Color32::from_rgb(208, 116, 61)),
|
|
||||||
make_icon(&cc.egui_ctx, "search_icon", egui::Color32::from_rgb(84, 164, 218)),
|
|
||||||
make_icon(&cc.egui_ctx, "graph_icon", egui::Color32::from_rgb(108, 196, 119)),
|
|
||||||
];
|
|
||||||
|
|
||||||
let icon_size = egui::vec2(14.0, 14.0);
|
|
||||||
let tree = PanelTree::new(vec![
|
|
||||||
Tab::new("rust.rs", TabId::Rust)
|
|
||||||
.with_icon_texture(textures[0].id(), icon_size)
|
|
||||||
.with_closable(true),
|
|
||||||
Tab::new("search", TabId::Search)
|
|
||||||
.with_icon_texture(textures[1].id(), icon_size)
|
|
||||||
.with_closable(true),
|
|
||||||
Tab::new("graph", TabId::Graph)
|
|
||||||
.with_icon_texture(textures[2].id(), icon_size)
|
|
||||||
.with_closable(true),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
tree,
|
|
||||||
style: PanelStyle::default(),
|
|
||||||
_textures: textures,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for App {
|
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
||||||
egui::CentralPanel::default()
|
|
||||||
.frame(egui::Frame::NONE)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
PanelContext::new(ui, &mut self.tree, &self.style).show(|ui, tab_id| match tab_id {
|
|
||||||
TabId::Rust => {
|
|
||||||
ui.heading("Texture-backed tab icons");
|
|
||||||
ui.label("These tabs use TextureId + size instead of text markers.");
|
|
||||||
}
|
|
||||||
TabId::Search => {
|
|
||||||
ui.heading("Search");
|
|
||||||
ui.label("Use this pattern when your app already has texture handles.");
|
|
||||||
}
|
|
||||||
TabId::Graph => {
|
|
||||||
ui.heading("Graph");
|
|
||||||
ui.label("The example creates simple textures in memory at startup.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_icon(
|
|
||||||
ctx: &egui::Context,
|
|
||||||
name: &str,
|
|
||||||
accent: egui::Color32,
|
|
||||||
) -> egui::TextureHandle {
|
|
||||||
let size = [16, 16];
|
|
||||||
let mut image = egui::ColorImage::filled(size, egui::Color32::TRANSPARENT);
|
|
||||||
|
|
||||||
for y in 2..14 {
|
|
||||||
for x in 2..14 {
|
|
||||||
let dx = x as i32 - 8;
|
|
||||||
let dy = y as i32 - 8;
|
|
||||||
if dx * dx + dy * dy <= 24 {
|
|
||||||
image[(x, y)] = accent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i in 4..12 {
|
|
||||||
image[(i, 8)] = egui::Color32::WHITE;
|
|
||||||
image[(8, i)] = egui::Color32::WHITE;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.load_texture(name.to_owned(), image, egui::TextureOptions::LINEAR)
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
//! Policy controls example:
|
|
||||||
//! `cargo run --example 6_policy_controls`
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
use grimdock::{
|
|
||||||
DropPolicy, PaneAnchor, PaneBuilder, PaneId, PaneMenuAction, PaneOptions, PaneRole,
|
|
||||||
PanelContext, PanelStyle, PanelTree, Tab, TabDropPolicy,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
|
||||||
let options = eframe::NativeOptions {
|
|
||||||
viewport: egui::ViewportBuilder::default()
|
|
||||||
.with_title("grimdock 6_policy_controls")
|
|
||||||
.with_inner_size([1180.0, 720.0]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
eframe::run_native(
|
|
||||||
"grimdock 6_policy_controls",
|
|
||||||
options,
|
|
||||||
Box::new(|_cc| Ok(Box::new(App::new()))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
||||||
enum TabId {
|
|
||||||
Files,
|
|
||||||
Editor,
|
|
||||||
Search,
|
|
||||||
Terminal,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
tree: PanelTree<TabId>,
|
|
||||||
style: PanelStyle,
|
|
||||||
files_home_pane: PaneId,
|
|
||||||
editor_home_pane: PaneId,
|
|
||||||
terminal_pane: PaneId,
|
|
||||||
persist_files_pane: bool,
|
|
||||||
lock_files_layout: bool,
|
|
||||||
disable_files_drops: bool,
|
|
||||||
disable_terminal_resize: bool,
|
|
||||||
disable_editor_reorder: bool,
|
|
||||||
disable_editor_drag_out: bool,
|
|
||||||
freeze_search_tab: bool,
|
|
||||||
lock_search_to_files_pane: bool,
|
|
||||||
allow_search_only_files_and_editor: bool,
|
|
||||||
block_search_from_terminal: bool,
|
|
||||||
lock_search_to_sidebar_role: bool,
|
|
||||||
allow_search_only_sidebar_and_editor_roles: bool,
|
|
||||||
block_search_from_terminal_role: bool,
|
|
||||||
pane_action_log: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn new() -> Self {
|
|
||||||
let (tree, files_home_pane, editor_home_pane, terminal_pane) = make_tree();
|
|
||||||
Self {
|
|
||||||
tree,
|
|
||||||
style: PanelStyle::default(),
|
|
||||||
files_home_pane,
|
|
||||||
editor_home_pane,
|
|
||||||
terminal_pane,
|
|
||||||
persist_files_pane: true,
|
|
||||||
lock_files_layout: false,
|
|
||||||
disable_files_drops: false,
|
|
||||||
disable_terminal_resize: false,
|
|
||||||
disable_editor_reorder: false,
|
|
||||||
disable_editor_drag_out: false,
|
|
||||||
freeze_search_tab: false,
|
|
||||||
lock_search_to_files_pane: false,
|
|
||||||
allow_search_only_files_and_editor: false,
|
|
||||||
block_search_from_terminal: false,
|
|
||||||
lock_search_to_sidebar_role: false,
|
|
||||||
allow_search_only_sidebar_and_editor_roles: false,
|
|
||||||
block_search_from_terminal_role: false,
|
|
||||||
pane_action_log: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reset_layout(&mut self) {
|
|
||||||
let (tree, files_home_pane, editor_home_pane, terminal_pane) = make_tree();
|
|
||||||
self.tree = tree;
|
|
||||||
self.files_home_pane = files_home_pane;
|
|
||||||
self.editor_home_pane = editor_home_pane;
|
|
||||||
self.terminal_pane = terminal_pane;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_pane_options(
|
|
||||||
&mut self,
|
|
||||||
pane_id: PaneId,
|
|
||||||
update: impl FnOnce(&mut PaneOptions),
|
|
||||||
) {
|
|
||||||
if let Some(mut pane) = self.tree.pane_mut(pane_id) {
|
|
||||||
let mut options = pane.options();
|
|
||||||
update(&mut options);
|
|
||||||
pane.set_options(options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_policy_toggles(&mut self) {
|
|
||||||
let persist_files_pane = self.persist_files_pane;
|
|
||||||
let lock_files_layout = self.lock_files_layout;
|
|
||||||
let disable_files_drops = self.disable_files_drops;
|
|
||||||
let disable_terminal_resize = self.disable_terminal_resize;
|
|
||||||
let disable_editor_reorder = self.disable_editor_reorder;
|
|
||||||
let disable_editor_drag_out = self.disable_editor_drag_out;
|
|
||||||
let freeze_search_tab = self.freeze_search_tab;
|
|
||||||
let lock_search_to_files_pane = self.lock_search_to_files_pane;
|
|
||||||
let allow_search_only_files_and_editor = self.allow_search_only_files_and_editor;
|
|
||||||
let block_search_from_terminal = self.block_search_from_terminal;
|
|
||||||
let lock_search_to_sidebar_role = self.lock_search_to_sidebar_role;
|
|
||||||
let allow_search_only_sidebar_and_editor_roles =
|
|
||||||
self.allow_search_only_sidebar_and_editor_roles;
|
|
||||||
let block_search_from_terminal_role = self.block_search_from_terminal_role;
|
|
||||||
let files_home_pane = self.files_home_pane;
|
|
||||||
let editor_home_pane = self.editor_home_pane;
|
|
||||||
let terminal_pane = self.terminal_pane;
|
|
||||||
|
|
||||||
self.update_pane_options(files_home_pane, |options| {
|
|
||||||
options.persist_when_empty = persist_files_pane;
|
|
||||||
options.lock_layout = lock_files_layout;
|
|
||||||
options.drop_policy = if disable_files_drops {
|
|
||||||
DropPolicy::none()
|
|
||||||
} else {
|
|
||||||
DropPolicy::all()
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
self.update_pane_options(editor_home_pane, |options| {
|
|
||||||
options.allow_tab_reorder = !disable_editor_reorder;
|
|
||||||
options.allow_tab_drag_out = !disable_editor_drag_out;
|
|
||||||
});
|
|
||||||
|
|
||||||
self.update_pane_options(terminal_pane, |options| {
|
|
||||||
options.allow_resize = !disable_terminal_resize;
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(search_pane_id) = self.tree.find_pane_containing(&TabId::Search) {
|
|
||||||
if let Some(mut pane) = self.tree.pane_mut(search_pane_id) {
|
|
||||||
if let Some(tab) = pane.tabs_mut().iter_mut().find(|tab| tab.id == TabId::Search) {
|
|
||||||
tab.draggable = !freeze_search_tab;
|
|
||||||
|
|
||||||
let mut drop_policy = TabDropPolicy::default();
|
|
||||||
if lock_search_to_files_pane {
|
|
||||||
drop_policy.locked_to_pane = Some(files_home_pane);
|
|
||||||
} else if lock_search_to_sidebar_role {
|
|
||||||
drop_policy.locked_to_role = Some(PaneRole::Sidebar);
|
|
||||||
} else if allow_search_only_files_and_editor {
|
|
||||||
drop_policy.allowed_panes = Some(vec![files_home_pane, editor_home_pane]);
|
|
||||||
} else if allow_search_only_sidebar_and_editor_roles {
|
|
||||||
drop_policy.allowed_roles =
|
|
||||||
Some(vec![PaneRole::Sidebar, PaneRole::Editor]);
|
|
||||||
}
|
|
||||||
if block_search_from_terminal {
|
|
||||||
drop_policy.blocked_panes.push(terminal_pane);
|
|
||||||
}
|
|
||||||
if block_search_from_terminal_role {
|
|
||||||
drop_policy.blocked_roles.push(PaneRole::Terminal);
|
|
||||||
}
|
|
||||||
tab.drop_policy = drop_policy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pane_present(&self, pane_id: PaneId) -> bool {
|
|
||||||
self.tree.pane(pane_id).is_some()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for App {
|
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
||||||
self.apply_policy_toggles();
|
|
||||||
|
|
||||||
egui::Panel::left("policy_controls")
|
|
||||||
.resizable(false)
|
|
||||||
.default_width(320.0)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
ui.heading("Policy controls");
|
|
||||||
ui.label("This example separates pane policy from tab policy.");
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
ui.label("Pane policy");
|
|
||||||
ui.checkbox(&mut self.persist_files_pane, "Persist Files pane when empty");
|
|
||||||
ui.checkbox(&mut self.lock_files_layout, "Lock Files pane layout");
|
|
||||||
ui.checkbox(&mut self.disable_files_drops, "Reject drops into Files pane");
|
|
||||||
ui.checkbox(&mut self.disable_terminal_resize, "Disable Terminal resize");
|
|
||||||
ui.checkbox(&mut self.disable_editor_reorder, "Disable Editor pane reorder");
|
|
||||||
ui.checkbox(&mut self.disable_editor_drag_out, "Disable Editor pane drag-out");
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
ui.label("Tab policy");
|
|
||||||
ui.checkbox(&mut self.freeze_search_tab, "Make Search tab immovable");
|
|
||||||
ui.checkbox(&mut self.lock_search_to_files_pane, "Lock Search tab to Files pane ID");
|
|
||||||
ui.checkbox(&mut self.lock_search_to_sidebar_role, "Lock Search tab to Sidebar role");
|
|
||||||
ui.checkbox(
|
|
||||||
&mut self.allow_search_only_files_and_editor,
|
|
||||||
"Allow Search only in Files and Editor pane IDs",
|
|
||||||
);
|
|
||||||
ui.checkbox(
|
|
||||||
&mut self.allow_search_only_sidebar_and_editor_roles,
|
|
||||||
"Allow Search only in Sidebar and Editor roles",
|
|
||||||
);
|
|
||||||
ui.checkbox(&mut self.block_search_from_terminal, "Block Search from Terminal pane ID");
|
|
||||||
ui.checkbox(
|
|
||||||
&mut self.block_search_from_terminal_role,
|
|
||||||
"Block Search from Terminal role",
|
|
||||||
);
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
if ui.button("Reset layout").clicked() {
|
|
||||||
self.reset_layout();
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
ui.label(format!(
|
|
||||||
"Files home pane present: {}",
|
|
||||||
if self.pane_present(self.files_home_pane) { "yes" } else { "no" }
|
|
||||||
));
|
|
||||||
ui.label(format!(
|
|
||||||
"Files anchor owner: {:?}",
|
|
||||||
self.tree.find_pane_with_anchor(PaneAnchor::Left)
|
|
||||||
));
|
|
||||||
ui.label(format!(
|
|
||||||
"Terminal anchor owner: {:?}",
|
|
||||||
self.tree.find_pane_with_anchor(PaneAnchor::Bottom)
|
|
||||||
));
|
|
||||||
ui.label(format!(
|
|
||||||
"Sidebar role owner: {:?}",
|
|
||||||
self.tree.find_pane_with_role(PaneRole::Sidebar)
|
|
||||||
));
|
|
||||||
ui.label(format!(
|
|
||||||
"Terminal role owner: {:?}",
|
|
||||||
self.tree.find_pane_with_role(PaneRole::Terminal)
|
|
||||||
));
|
|
||||||
ui.label(format!(
|
|
||||||
"Search tab currently in pane: {:?}",
|
|
||||||
self.tree.find_pane_containing(&TabId::Search)
|
|
||||||
));
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
ui.label("What to try:");
|
|
||||||
ui.label("1. Move Files out of its home pane. With persistence on, the anchored left pane stays as an empty drop target.");
|
|
||||||
ui.label("2. Turn off Files persistence, empty that pane, and notice the pane can now disappear.");
|
|
||||||
ui.label("3. Lock Search to the Files pane, then try dropping it into Editor or Terminal.");
|
|
||||||
ui.label("4. Switch between pane-ID and pane-role targeting to compare semantic versus identity-based policy.");
|
|
||||||
if let Some(last) = self.pane_action_log.last() {
|
|
||||||
ui.separator();
|
|
||||||
ui.label(format!("Last custom pane action: {last}"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
egui::CentralPanel::default()
|
|
||||||
.frame(egui::Frame::NONE)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
let output = PanelContext::new(ui, &mut self.tree, &self.style)
|
|
||||||
.with_pane_menu_provider(&|pane_id, tree| {
|
|
||||||
let mut actions = vec![PaneMenuAction::new("inspect", "Inspect pane")];
|
|
||||||
if tree.find_pane_with_anchor(PaneAnchor::Left) == Some(pane_id) {
|
|
||||||
actions.push(PaneMenuAction::new("mark_left", "Mark left anchor"));
|
|
||||||
}
|
|
||||||
actions
|
|
||||||
})
|
|
||||||
.show(|ui, tab_id| match tab_id {
|
|
||||||
TabId::Files => {
|
|
||||||
ui.heading("Files");
|
|
||||||
ui.label("This pane demonstrates pane persistence, pane locking, and pane drop policy.");
|
|
||||||
}
|
|
||||||
TabId::Editor => {
|
|
||||||
ui.heading("Editor");
|
|
||||||
ui.label("This pane demonstrates pane-level reorder and drag-out controls.");
|
|
||||||
}
|
|
||||||
TabId::Search => {
|
|
||||||
ui.heading("Search");
|
|
||||||
ui.label("This tab demonstrates draggable state plus pane lock / allow-list / block-list targeting.");
|
|
||||||
}
|
|
||||||
TabId::Terminal => {
|
|
||||||
ui.heading("Terminal");
|
|
||||||
ui.label("This pane demonstrates resize locking and can also be blocked as a tab destination.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for action in output.pane_actions {
|
|
||||||
self.pane_action_log
|
|
||||||
.push(format!("{} on pane {:?}", action.action_id, action.pane_id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_tree() -> (PanelTree<TabId>, PaneId, PaneId, PaneId) {
|
|
||||||
let mut tree = PanelTree::new(vec![
|
|
||||||
Tab::new("editor", TabId::Editor).with_leading_visual(">"),
|
|
||||||
Tab::new("search", TabId::Search).with_leading_visual("?"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let files_home_pane = tree.ensure_pane_at_anchor(
|
|
||||||
PaneAnchor::Left,
|
|
||||||
PaneBuilder::new(Tab::new("files", TabId::Files).with_leading_visual("#")),
|
|
||||||
);
|
|
||||||
|
|
||||||
let editor_home_pane = tree
|
|
||||||
.find_pane_containing(&TabId::Editor)
|
|
||||||
.expect("editor pane should exist after initial split");
|
|
||||||
|
|
||||||
let terminal_pane = tree.ensure_pane_at_anchor(
|
|
||||||
PaneAnchor::Bottom,
|
|
||||||
PaneBuilder::new(Tab::new("terminal", TabId::Terminal).with_leading_visual("$")),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(mut pane) = tree.pane_mut(files_home_pane) {
|
|
||||||
let mut options = pane.options();
|
|
||||||
options.persist_when_empty = true;
|
|
||||||
options.role = Some(PaneRole::Sidebar);
|
|
||||||
pane.set_options(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(mut pane) = tree.pane_mut(editor_home_pane) {
|
|
||||||
let mut options = pane.options();
|
|
||||||
options.role = Some(PaneRole::Editor);
|
|
||||||
pane.set_options(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(mut pane) = tree.pane_mut(terminal_pane) {
|
|
||||||
let mut options = pane.options();
|
|
||||||
options.role = Some(PaneRole::Terminal);
|
|
||||||
pane.set_options(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
(tree, files_home_pane, editor_home_pane, terminal_pane)
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
//! Theme and colour options example:
|
|
||||||
//! `cargo run --example 7_theme_options`
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
use grimdock::{
|
|
||||||
ChildSide, PaneId, PaneStyleOverride, PanelContext, PanelStyle, PanelTree, SplitDir, Tab,
|
|
||||||
TabStyleOverride,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
|
||||||
let options = eframe::NativeOptions {
|
|
||||||
viewport: egui::ViewportBuilder::default()
|
|
||||||
.with_title("grimdock 7_theme_options")
|
|
||||||
.with_inner_size([1260.0, 760.0]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
eframe::run_native(
|
|
||||||
"grimdock 7_theme_options",
|
|
||||||
options,
|
|
||||||
Box::new(|cc| Ok(Box::new(App::new(&cc.egui_ctx)))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
||||||
enum TabId {
|
|
||||||
Files,
|
|
||||||
Editor,
|
|
||||||
Search,
|
|
||||||
Preview,
|
|
||||||
Terminal,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
tree: PanelTree<TabId>,
|
|
||||||
style: PanelStyle,
|
|
||||||
terminal_pane: PaneId,
|
|
||||||
terminal_override_enabled: bool,
|
|
||||||
terminal_override: PaneStyleOverride,
|
|
||||||
search_override_enabled: bool,
|
|
||||||
search_override: TabStyleOverride,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn new(ctx: &egui::Context) -> Self {
|
|
||||||
let (tree, terminal_pane) = make_tree();
|
|
||||||
Self {
|
|
||||||
tree,
|
|
||||||
style: PanelStyle::from_visuals(&ctx.style().visuals),
|
|
||||||
terminal_pane,
|
|
||||||
terminal_override_enabled: true,
|
|
||||||
terminal_override: PaneStyleOverride {
|
|
||||||
header_bg: Some(egui::Color32::from_rgb(45, 28, 20)),
|
|
||||||
content_bg: Some(egui::Color32::from_rgb(34, 22, 16)),
|
|
||||||
border_color: Some(egui::Color32::from_rgb(168, 99, 56)),
|
|
||||||
accent_color: Some(egui::Color32::from_rgb(230, 142, 78)),
|
|
||||||
},
|
|
||||||
search_override_enabled: true,
|
|
||||||
search_override: TabStyleOverride {
|
|
||||||
active_bg: Some(egui::Color32::from_rgb(30, 74, 66)),
|
|
||||||
inactive_bg: Some(egui::Color32::from_rgb(24, 52, 48)),
|
|
||||||
hovered_bg: Some(egui::Color32::from_rgb(36, 90, 80)),
|
|
||||||
text_color: Some(egui::Color32::from_rgb(214, 245, 238)),
|
|
||||||
accent_color: Some(egui::Color32::from_rgb(52, 204, 168)),
|
|
||||||
icon_color: None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_overrides(&mut self) {
|
|
||||||
if let Some(mut pane) = self.tree.pane_mut(self.terminal_pane) {
|
|
||||||
let mut options = pane.options();
|
|
||||||
options.style_override = self.terminal_override_enabled.then_some(self.terminal_override);
|
|
||||||
pane.set_options(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(search_pane_id) = self.tree.find_pane_containing(&TabId::Search) {
|
|
||||||
if let Some(mut pane) = self.tree.pane_mut(search_pane_id) {
|
|
||||||
if let Some(tab) = pane.tabs_mut().iter_mut().find(|tab| tab.id == TabId::Search) {
|
|
||||||
tab.style_override = self.search_override_enabled.then_some(self.search_override);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for App {
|
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
||||||
self.sync_overrides();
|
|
||||||
|
|
||||||
egui::Panel::left("theme_controls")
|
|
||||||
.resizable(true)
|
|
||||||
.default_width(360.0)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
ui.heading("Theme workbench");
|
|
||||||
ui.label("This example exposes the global PanelStyle plus pane and tab color overrides.");
|
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui.button("Use egui visuals").clicked() {
|
|
||||||
self.style = PanelStyle::from_visuals(&ctx.style().visuals);
|
|
||||||
}
|
|
||||||
if ui.button("Use crate defaults").clicked() {
|
|
||||||
self.style = PanelStyle::default();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
ui.collapsing("Layout metrics", |ui| {
|
|
||||||
ui.add(egui::Slider::new(&mut self.style.header_height, 20.0..=40.0).text("Header height"));
|
|
||||||
ui.add(
|
|
||||||
egui::Slider::new(&mut self.style.collapsed_pane_thickness, 20.0..=60.0)
|
|
||||||
.text("Collapsed thickness"),
|
|
||||||
);
|
|
||||||
ui.add(egui::Slider::new(&mut self.style.handle_width, 2.0..=12.0).text("Handle width"));
|
|
||||||
ui.add(egui::Slider::new(&mut self.style.min_pane_size, 40.0..=160.0).text("Min pane size"));
|
|
||||||
ui.add(egui::Slider::new(&mut self.style.content_inset, 0.0..=20.0).text("Content inset"));
|
|
||||||
corner_radius_slider(ui, "Pane rounding", &mut self.style.pane_rounding);
|
|
||||||
corner_radius_slider(ui, "Tab rounding", &mut self.style.tabs.rounding);
|
|
||||||
corner_radius_slider(ui, "Header button rounding", &mut self.style.header.button.rounding);
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.collapsing("Typography", |ui| {
|
|
||||||
ui.label("Dock chrome text is painted directly by grimdock.");
|
|
||||||
ui.label("Font weight comes from whichever font family your app has loaded.");
|
|
||||||
font_controls(ui, "Tab title", &mut self.style.typography.tab_title_font);
|
|
||||||
font_controls(
|
|
||||||
ui,
|
|
||||||
"Leading text icon",
|
|
||||||
&mut self.style.typography.tab_icon_text_font,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.collapsing("Header", |ui| {
|
|
||||||
color_row(ui, "Header bg", &mut self.style.header.bg);
|
|
||||||
color_row(ui, "Header border", &mut self.style.header.border_color);
|
|
||||||
color_row(ui, "Button bg", &mut self.style.header.button.bg);
|
|
||||||
color_row(ui, "Button hover bg", &mut self.style.header.button.hover_bg);
|
|
||||||
color_row(ui, "Button stroke", &mut self.style.header.button.stroke_color);
|
|
||||||
color_row(ui, "Button icon", &mut self.style.header.button.icon_color);
|
|
||||||
color_row(ui, "Button hover icon", &mut self.style.header.button.hover_icon_color);
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.collapsing("Content", |ui| {
|
|
||||||
color_row(ui, "Content bg", &mut self.style.content.bg);
|
|
||||||
color_row(ui, "Content border", &mut self.style.content.border_color);
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.collapsing("Tabs", |ui| {
|
|
||||||
ui.label("Active");
|
|
||||||
color_row(ui, "Active bg", &mut self.style.tabs.active.bg);
|
|
||||||
color_row(ui, "Active text", &mut self.style.tabs.active.text_color);
|
|
||||||
color_row(ui, "Active accent", &mut self.style.tabs.active.accent_color);
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
ui.label("Inactive");
|
|
||||||
color_row(ui, "Inactive bg", &mut self.style.tabs.inactive.bg);
|
|
||||||
color_row(ui, "Inactive text", &mut self.style.tabs.inactive.text_color);
|
|
||||||
color_row(ui, "Inactive accent", &mut self.style.tabs.inactive.accent_color);
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
ui.label("Hovered");
|
|
||||||
color_row(ui, "Hovered bg", &mut self.style.tabs.hovered.bg);
|
|
||||||
color_row(ui, "Hovered text", &mut self.style.tabs.hovered.text_color);
|
|
||||||
color_row(ui, "Hovered accent", &mut self.style.tabs.hovered.accent_color);
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.collapsing("Handles and overlay", |ui| {
|
|
||||||
color_row(ui, "Handle", &mut self.style.handle.color);
|
|
||||||
color_row(ui, "Handle hover", &mut self.style.handle.hover_color);
|
|
||||||
color_row(ui, "Handle locked", &mut self.style.handle.locked_color);
|
|
||||||
color_row(ui, "Overlay fill", &mut self.style.overlay.fill);
|
|
||||||
color_row(ui, "Overlay stroke", &mut self.style.overlay.stroke.color);
|
|
||||||
ui.add(
|
|
||||||
egui::Slider::new(&mut self.style.overlay.stroke.width, 0.5..=4.0)
|
|
||||||
.text("Overlay stroke width"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.collapsing("Pane override", |ui| {
|
|
||||||
ui.checkbox(&mut self.terminal_override_enabled, "Enable Terminal pane override");
|
|
||||||
color_option_row(ui, "Header bg", &mut self.terminal_override.header_bg);
|
|
||||||
color_option_row(ui, "Content bg", &mut self.terminal_override.content_bg);
|
|
||||||
color_option_row(ui, "Border", &mut self.terminal_override.border_color);
|
|
||||||
color_option_row(ui, "Accent", &mut self.terminal_override.accent_color);
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.collapsing("Tab override", |ui| {
|
|
||||||
ui.checkbox(&mut self.search_override_enabled, "Enable Search tab override");
|
|
||||||
color_option_row(ui, "Active bg", &mut self.search_override.active_bg);
|
|
||||||
color_option_row(ui, "Inactive bg", &mut self.search_override.inactive_bg);
|
|
||||||
color_option_row(ui, "Hovered bg", &mut self.search_override.hovered_bg);
|
|
||||||
color_option_row(ui, "Text", &mut self.search_override.text_color);
|
|
||||||
color_option_row(ui, "Accent", &mut self.search_override.accent_color);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
egui::CentralPanel::default()
|
|
||||||
.frame(egui::Frame::NONE)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
PanelContext::new(ui, &mut self.tree, &self.style).show(|ui, tab_id| match tab_id {
|
|
||||||
TabId::Files => {
|
|
||||||
ui.heading("Files");
|
|
||||||
ui.label("Use this workbench to tune every main color and size in PanelStyle.");
|
|
||||||
}
|
|
||||||
TabId::Editor => {
|
|
||||||
ui.heading("Editor");
|
|
||||||
ui.label("Drag tabs and resize panes while adjusting the theme controls.");
|
|
||||||
}
|
|
||||||
TabId::Search => {
|
|
||||||
ui.heading("Search");
|
|
||||||
ui.label("This tab can demonstrate per-tab color overrides.");
|
|
||||||
}
|
|
||||||
TabId::Preview => {
|
|
||||||
ui.heading("Preview");
|
|
||||||
ui.label("The dock updates live as you change colors, rounding, and handle styles.");
|
|
||||||
}
|
|
||||||
TabId::Terminal => {
|
|
||||||
ui.heading("Terminal");
|
|
||||||
ui.label("This pane can demonstrate per-pane header/content/accent overrides.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn color_row(ui: &mut egui::Ui, label: &str, color: &mut egui::Color32) {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label(label);
|
|
||||||
ui.color_edit_button_srgba(color);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn color_option_row(ui: &mut egui::Ui, label: &str, color: &mut Option<egui::Color32>) {
|
|
||||||
let mut enabled = color.is_some();
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.checkbox(&mut enabled, label);
|
|
||||||
if !enabled {
|
|
||||||
*color = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let value = color.get_or_insert(egui::Color32::WHITE);
|
|
||||||
ui.color_edit_button_srgba(value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn corner_radius_slider(ui: &mut egui::Ui, label: &str, radius: &mut egui::CornerRadius) {
|
|
||||||
let mut value = radius.nw;
|
|
||||||
ui.add(egui::Slider::new(&mut value, 0..=16).text(label));
|
|
||||||
*radius = egui::CornerRadius::same(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn font_controls(ui: &mut egui::Ui, label: &str, font: &mut egui::FontId) {
|
|
||||||
ui.group(|ui| {
|
|
||||||
ui.label(label);
|
|
||||||
ui.add(egui::Slider::new(&mut font.size, 8.0..=24.0).text("Size"));
|
|
||||||
|
|
||||||
let mut family = match &font.family {
|
|
||||||
egui::FontFamily::Proportional => 0,
|
|
||||||
egui::FontFamily::Monospace => 1,
|
|
||||||
egui::FontFamily::Name(_) => 2,
|
|
||||||
};
|
|
||||||
egui::ComboBox::from_label("Family")
|
|
||||||
.selected_text(match family {
|
|
||||||
0 => "Proportional",
|
|
||||||
1 => "Monospace",
|
|
||||||
_ => "Custom",
|
|
||||||
})
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
ui.selectable_value(&mut family, 0, "Proportional");
|
|
||||||
ui.selectable_value(&mut family, 1, "Monospace");
|
|
||||||
ui.selectable_value(&mut family, 2, "Custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
if family == 2 {
|
|
||||||
let mut name = match &font.family {
|
|
||||||
egui::FontFamily::Name(name) => name.to_string(),
|
|
||||||
_ => String::from("bold"),
|
|
||||||
};
|
|
||||||
ui.text_edit_singleline(&mut name);
|
|
||||||
font.family = egui::FontFamily::Name(name.into());
|
|
||||||
} else {
|
|
||||||
font.family = if family == 0 {
|
|
||||||
egui::FontFamily::Proportional
|
|
||||||
} else {
|
|
||||||
egui::FontFamily::Monospace
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_tree() -> (PanelTree<TabId>, PaneId) {
|
|
||||||
let mut tree = PanelTree::new(vec![
|
|
||||||
Tab::new("editor", TabId::Editor).with_leading_visual(">"),
|
|
||||||
Tab::new("search", TabId::Search).with_leading_visual("?"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
tree.split_leaf(
|
|
||||||
0,
|
|
||||||
SplitDir::Horizontal,
|
|
||||||
Tab::new("files", TabId::Files).with_leading_visual("#"),
|
|
||||||
ChildSide::First,
|
|
||||||
);
|
|
||||||
|
|
||||||
let editor_pane = tree
|
|
||||||
.find_pane_containing(&TabId::Editor)
|
|
||||||
.expect("editor pane should exist");
|
|
||||||
let terminal_pane = tree
|
|
||||||
.pane_mut(editor_pane)
|
|
||||||
.expect("editor pane should exist")
|
|
||||||
.split(
|
|
||||||
SplitDir::Vertical,
|
|
||||||
Tab::new("terminal", TabId::Terminal).with_leading_visual("$"),
|
|
||||||
ChildSide::Second,
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(mut pane) = tree.pane_mut(editor_pane) {
|
|
||||||
pane.push_tab(Tab::new("preview", TabId::Preview).with_leading_visual("*"));
|
|
||||||
}
|
|
||||||
|
|
||||||
(tree, terminal_pane)
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
//! Runnable demo: `cargo run --example demo`
|
|
||||||
//!
|
|
||||||
//! Opens a window with a pre-built split layout. Tabs can be dragged between
|
|
||||||
//! panes, panes can be resized by dragging the handle between them.
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
use grimdock::{
|
|
||||||
AddTabEntry, ChildSide, DropPolicy, Node, PaneBuilder, PaneStyleOverride, PanelContext,
|
|
||||||
PanelStyle, PanelTree, SplitDir, Tab, TabStyleOverride,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
|
||||||
let options = eframe::NativeOptions {
|
|
||||||
viewport: egui::ViewportBuilder::default()
|
|
||||||
.with_title("grimdock demo")
|
|
||||||
.with_inner_size([1024.0, 680.0]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
eframe::run_native("grimdock demo", options, Box::new(|_cc| Ok(Box::new(App::new()))))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A tab identifier. Using a simple enum so each variant renders unique content.
|
|
||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
||||||
enum TabId {
|
|
||||||
Editor(u32),
|
|
||||||
FileTree,
|
|
||||||
Terminal,
|
|
||||||
Search,
|
|
||||||
Problems,
|
|
||||||
Output,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for TabId {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
TabId::Editor(n) => write!(f, "editor_{n}"),
|
|
||||||
TabId::FileTree => write!(f, "file_tree"),
|
|
||||||
TabId::Terminal => write!(f, "terminal"),
|
|
||||||
TabId::Search => write!(f, "search"),
|
|
||||||
TabId::Problems => write!(f, "problems"),
|
|
||||||
TabId::Output => write!(f, "output"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
tree: PanelTree<TabId>,
|
|
||||||
/// Counter that ticks up each frame in the terminal pane.
|
|
||||||
tick: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn new() -> Self {
|
|
||||||
// Build an initial layout:
|
|
||||||
//
|
|
||||||
// ┌───────────┬──────────────────┐
|
|
||||||
// │ File Tree │ Editor 1 │
|
|
||||||
// │ ├──────────────────┤
|
|
||||||
// │ │ Terminal │ Output│
|
|
||||||
// └───────────┴──────────────────┘
|
|
||||||
//
|
|
||||||
// We do this by starting with a single pane and splitting.
|
|
||||||
|
|
||||||
let mut tree = PanelTree::new(vec![
|
|
||||||
Tab::new("editor_1", TabId::Editor(1))
|
|
||||||
.with_leading_visual(">")
|
|
||||||
.with_style_override(TabStyleOverride {
|
|
||||||
active_bg: Some(egui::Color32::from_rgb(62, 73, 50)),
|
|
||||||
inactive_bg: Some(egui::Color32::from_rgb(44, 52, 38)),
|
|
||||||
hovered_bg: Some(egui::Color32::from_rgb(55, 65, 45)),
|
|
||||||
text_color: Some(egui::Color32::from_rgb(221, 231, 200)),
|
|
||||||
accent_color: Some(egui::Color32::from_rgb(164, 196, 92)),
|
|
||||||
icon_color: None,
|
|
||||||
}),
|
|
||||||
Tab::new("editor_2", TabId::Editor(2)).with_leading_visual("+"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Split root (editors) horizontally: file tree on the left (First side).
|
|
||||||
let _file_tree_pane = tree.split_leaf_with(
|
|
||||||
0,
|
|
||||||
SplitDir::Horizontal,
|
|
||||||
PaneBuilder::new(Tab::new("file_tree", TabId::FileTree).with_leading_visual("#")).with_options(
|
|
||||||
grimdock::PaneOptions {
|
|
||||||
style_override: Some(PaneStyleOverride {
|
|
||||||
header_bg: Some(egui::Color32::from_rgb(27, 47, 44)),
|
|
||||||
content_bg: Some(egui::Color32::from_rgb(21, 36, 34)),
|
|
||||||
border_color: Some(egui::Color32::from_rgb(52, 90, 84)),
|
|
||||||
accent_color: Some(egui::Color32::from_rgb(85, 188, 162)),
|
|
||||||
}),
|
|
||||||
drop_policy: DropPolicy::merge_only(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ChildSide::First,
|
|
||||||
);
|
|
||||||
// Node 0 is now Horizontal split.
|
|
||||||
// Node 1 (left/First): FileTree
|
|
||||||
// Node 2 (right/Second): editors
|
|
||||||
|
|
||||||
// Split the right pane (editors, node 2) vertically: bottom gets terminal.
|
|
||||||
let editors_pane = tree
|
|
||||||
.find_pane_containing(&TabId::Editor(1))
|
|
||||||
.expect("editor pane should exist after initial split");
|
|
||||||
let bottom_pane = {
|
|
||||||
let mut pane = tree.pane_mut(editors_pane).expect("editor pane should exist");
|
|
||||||
pane.split_with(
|
|
||||||
SplitDir::Vertical,
|
|
||||||
PaneBuilder::new(Tab::new("terminal", TabId::Terminal).with_leading_visual("$")),
|
|
||||||
ChildSide::Second,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
// Node 2 is now Vertical split.
|
|
||||||
// Node 5 (top/First): editors
|
|
||||||
// Node 6 (bottom/Second): terminal
|
|
||||||
|
|
||||||
// Add extra tabs to the bottom bar.
|
|
||||||
// Nodes are: 5 = editors top pane, 6 = bottom pane (terminal).
|
|
||||||
let mut bottom = tree.pane_mut(bottom_pane).expect("bottom pane should exist");
|
|
||||||
bottom.push_tab(Tab::new("output", TabId::Output).with_leading_visual("!"));
|
|
||||||
bottom.push_tab(Tab::new("problems", TabId::Problems).with_leading_visual("x"));
|
|
||||||
bottom.push_tab(Tab::new("search", TabId::Search).with_leading_visual("?"));
|
|
||||||
|
|
||||||
// Narrow the left (file tree) column — set the split ratio.
|
|
||||||
if let Node::Split { ratio, .. } = tree.node_mut(0) {
|
|
||||||
*ratio = 0.22;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the bottom strip shorter.
|
|
||||||
if let Node::Split { ratio, .. } = tree.node_mut(2) {
|
|
||||||
*ratio = 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
|
||||||
tree,
|
|
||||||
tick: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for App {
|
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
||||||
self.tick += 1;
|
|
||||||
|
|
||||||
egui::CentralPanel::default()
|
|
||||||
.frame(egui::Frame::NONE)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
let tree = &mut self.tree;
|
|
||||||
let mut style = PanelStyle::from_egui_style(ui.style().as_ref());
|
|
||||||
style.content_inset = 4.0;
|
|
||||||
style.tabs.rounding = egui::CornerRadius::same(5);
|
|
||||||
style.header.button.rounding = egui::CornerRadius::same(5);
|
|
||||||
let tick = self.tick;
|
|
||||||
let add_tab_entries = vec![
|
|
||||||
AddTabEntry::new(
|
|
||||||
"Editor 3",
|
|
||||||
Tab::new("editor_3", TabId::Editor(3))
|
|
||||||
.with_leading_visual("*")
|
|
||||||
.with_closable(true),
|
|
||||||
),
|
|
||||||
AddTabEntry::new(
|
|
||||||
"Terminal",
|
|
||||||
Tab::new("terminal", TabId::Terminal)
|
|
||||||
.with_leading_visual("$")
|
|
||||||
.with_closable(true),
|
|
||||||
),
|
|
||||||
AddTabEntry::new(
|
|
||||||
"Search",
|
|
||||||
Tab::new("search", TabId::Search)
|
|
||||||
.with_leading_visual("?")
|
|
||||||
.with_closable(true),
|
|
||||||
),
|
|
||||||
AddTabEntry::new(
|
|
||||||
"Problems",
|
|
||||||
Tab::new("problems", TabId::Problems)
|
|
||||||
.with_leading_visual("x")
|
|
||||||
.with_closable(true),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
PanelContext::new(ui, tree, &style)
|
|
||||||
.with_add_tab_entries(&add_tab_entries)
|
|
||||||
.show(|ui, tab_id| {
|
|
||||||
render_tab(ui, tab_id, tick);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Repaint continuously so the terminal counter updates.
|
|
||||||
ctx.request_repaint();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_tab(ui: &mut egui::Ui, tab_id: &TabId, tick: u64) {
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
||||||
match tab_id {
|
|
||||||
TabId::Editor(n) => {
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.label(
|
|
||||||
egui::RichText::new(format!("// Editor {n}\n\nfn main() {{\n println!(\"Hello, world!\");\n}}"))
|
|
||||||
.monospace()
|
|
||||||
.size(13.0),
|
|
||||||
);
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.label("Drag a tab to a different pane to rearrange.");
|
|
||||||
ui.label("Drag the handle between panes to resize.");
|
|
||||||
}
|
|
||||||
TabId::FileTree => {
|
|
||||||
ui.add_space(6.0);
|
|
||||||
for item in &["src/", " lib.rs", " tree.rs", " layout.rs", " header.rs", " dnd.rs", "Cargo.toml"] {
|
|
||||||
ui.label(egui::RichText::new(*item).monospace().size(12.0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TabId::Terminal => {
|
|
||||||
ui.add_space(4.0);
|
|
||||||
ui.label(egui::RichText::new(format!("$ frame {tick}")).monospace().size(12.0));
|
|
||||||
ui.label(egui::RichText::new("grimdock running ✓").monospace().color(egui::Color32::from_rgb(100, 200, 120)).size(12.0));
|
|
||||||
}
|
|
||||||
TabId::Search => {
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.label("Search");
|
|
||||||
ui.text_edit_singleline(&mut String::new());
|
|
||||||
}
|
|
||||||
TabId::Problems => {
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.label(egui::RichText::new("No problems detected.").color(egui::Color32::from_rgb(100, 200, 120)));
|
|
||||||
}
|
|
||||||
TabId::Output => {
|
|
||||||
ui.add_space(4.0);
|
|
||||||
ui.label(egui::RichText::new("[INFO] Build complete.").monospace().size(12.0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
use egui::{Rect, Ui};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
ids::pane_content_id,
|
|
||||||
style::PanelStyle,
|
|
||||||
tree::{Node, PanelTree},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Run the content pass: invoke the caller's render callback for each leaf's
|
|
||||||
/// focused tab inside a clipped child UI.
|
|
||||||
pub(crate) fn content_pass<T: Clone + 'static>(
|
|
||||||
ui: &mut Ui,
|
|
||||||
tree: &PanelTree<T>,
|
|
||||||
leaf_rects: &[(usize, Rect)],
|
|
||||||
style: &PanelStyle,
|
|
||||||
mut render: impl FnMut(&mut Ui, &T),
|
|
||||||
) {
|
|
||||||
for &(leaf_idx, content_rect) in leaf_rects {
|
|
||||||
if tree.is_collapsed(leaf_idx) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (pane_id, tab_id, focused_idx) = match tree.node(leaf_idx) {
|
|
||||||
Node::Leaf {
|
|
||||||
pane,
|
|
||||||
tabs,
|
|
||||||
focused,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if let Some(tab) = tabs.get(*focused) {
|
|
||||||
(*pane, tab.id.clone(), *focused)
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use a stable ID per leaf + focused tab so widget state (scroll
|
|
||||||
// position, text input, etc.) survives tab switches in other panes.
|
|
||||||
let content_id = pane_content_id(pane_id).with(focused_idx);
|
|
||||||
let inset_rect = content_rect.shrink(style.content_inset);
|
|
||||||
|
|
||||||
ui.push_id(content_id, |ui| {
|
|
||||||
ui.scope_builder(egui::UiBuilder::new().max_rect(inset_rect), |ui| {
|
|
||||||
ui.set_clip_rect(inset_rect);
|
|
||||||
render(ui, &tab_id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
use egui::{DragAndDrop, LayerId, Order, Pos2, Rect, Ui};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
style::PanelStyle,
|
|
||||||
tab::Tab,
|
|
||||||
tree::{ChildSide, Node, PaneId, PaneOptions, PaneRole, PanelTree, SplitDir},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Payload carried through egui's drag-and-drop system.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub(crate) struct DragPayload {
|
|
||||||
/// Stable pane identifier of the leaf that owns the dragged tab.
|
|
||||||
pub src_pane: PaneId,
|
|
||||||
/// Position of the dragged tab within the source leaf's tab list.
|
|
||||||
pub tab_pos: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Determines where relative to a pane's centre the cursor is sitting.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub(crate) enum DropZone {
|
|
||||||
Center,
|
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
Top,
|
|
||||||
Bottom,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drop_zone_allowed(options: PaneOptions, zone: DropZone) -> bool {
|
|
||||||
if options.lock_layout {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
match zone {
|
|
||||||
DropZone::Center => options.drop_policy.allow_merge,
|
|
||||||
DropZone::Left => options.drop_policy.allows_split(SplitDir::Horizontal, ChildSide::First),
|
|
||||||
DropZone::Right => {
|
|
||||||
options
|
|
||||||
.drop_policy
|
|
||||||
.allows_split(SplitDir::Horizontal, ChildSide::Second)
|
|
||||||
}
|
|
||||||
DropZone::Top => options.drop_policy.allows_split(SplitDir::Vertical, ChildSide::First),
|
|
||||||
DropZone::Bottom => {
|
|
||||||
options
|
|
||||||
.drop_policy
|
|
||||||
.allows_split(SplitDir::Vertical, ChildSide::Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tab_allows_target_pane<T: Clone + 'static>(
|
|
||||||
tab: &Tab<T>,
|
|
||||||
pane_id: PaneId,
|
|
||||||
role: Option<PaneRole>,
|
|
||||||
) -> bool {
|
|
||||||
tab.drop_policy.allows_target(pane_id, role)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn header_tab_drop_index<T: Clone + 'static>(
|
|
||||||
tree: &PanelTree<T>,
|
|
||||||
leaf_idx: usize,
|
|
||||||
header_rect: Rect,
|
|
||||||
cursor_pos: Pos2,
|
|
||||||
) -> Option<usize> {
|
|
||||||
let tab_count = match tree.node(leaf_idx) {
|
|
||||||
Node::Leaf { tabs, .. } => tabs.len(),
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
if tab_count == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let tab_width = (header_rect.width() / tab_count.max(1) as f32)
|
|
||||||
.min(120.0)
|
|
||||||
.max(40.0);
|
|
||||||
let relative_x = (cursor_pos.x - header_rect.min.x).clamp(0.0, header_rect.width());
|
|
||||||
let raw_index = (relative_x / tab_width).floor() as usize;
|
|
||||||
Some(raw_index.min(tab_count.saturating_sub(1)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn header_tab_rect<T: Clone + 'static>(
|
|
||||||
tree: &PanelTree<T>,
|
|
||||||
leaf_idx: usize,
|
|
||||||
header_rect: Rect,
|
|
||||||
tab_pos: usize,
|
|
||||||
) -> Option<Rect> {
|
|
||||||
let tab_count = match tree.node(leaf_idx) {
|
|
||||||
Node::Leaf { tabs, .. } => tabs.len(),
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
if tab_pos >= tab_count {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let tab_width = (header_rect.width() / tab_count.max(1) as f32)
|
|
||||||
.min(120.0)
|
|
||||||
.max(40.0);
|
|
||||||
Some(Rect::from_min_size(
|
|
||||||
egui::pos2(
|
|
||||||
header_rect.min.x + tab_pos as f32 * tab_width,
|
|
||||||
header_rect.min.y,
|
|
||||||
),
|
|
||||||
egui::vec2(tab_width, header_rect.height()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn root_edge_drop_zone(cursor: Pos2, root_rect: Rect) -> Option<DropZone> {
|
|
||||||
if !root_rect.contains(cursor) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let edge_band = 20.0_f32.min(root_rect.width() * 0.15).min(root_rect.height() * 0.15);
|
|
||||||
let left = cursor.x - root_rect.min.x;
|
|
||||||
let right = root_rect.max.x - cursor.x;
|
|
||||||
let top = cursor.y - root_rect.min.y;
|
|
||||||
let bottom = root_rect.max.y - cursor.y;
|
|
||||||
|
|
||||||
let mut best: Option<(f32, DropZone)> = None;
|
|
||||||
for (dist, zone) in [
|
|
||||||
(left, DropZone::Left),
|
|
||||||
(right, DropZone::Right),
|
|
||||||
(top, DropZone::Top),
|
|
||||||
(bottom, DropZone::Bottom),
|
|
||||||
] {
|
|
||||||
if dist <= edge_band {
|
|
||||||
match best {
|
|
||||||
Some((best_dist, _)) if dist >= best_dist => {}
|
|
||||||
_ => best = Some((dist, zone)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
best.map(|(_, zone)| zone)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DropZone {
|
|
||||||
/// Classify `cursor` against the five zones of `rect`.
|
|
||||||
///
|
|
||||||
/// The central 20 % of each axis forms the `Center` zone. Outside that,
|
|
||||||
/// the remaining area is divided into four triangular quadrants by the two
|
|
||||||
/// diagonals of the rectangle.
|
|
||||||
pub(crate) fn classify(cursor: Pos2, rect: Rect) -> Self {
|
|
||||||
let center = rect.center();
|
|
||||||
let dx = (cursor.x - center.x) / rect.width().max(1.0);
|
|
||||||
let dy = (cursor.y - center.y) / rect.height().max(1.0);
|
|
||||||
|
|
||||||
// Central region: both axes within ±10 % of the centre.
|
|
||||||
if dx.abs() < 0.1 && dy.abs() < 0.1 {
|
|
||||||
return DropZone::Center;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outside the centre: pick the dominant axis.
|
|
||||||
if dx.abs() > dy.abs() {
|
|
||||||
if dx > 0.0 {
|
|
||||||
DropZone::Right
|
|
||||||
} else {
|
|
||||||
DropZone::Left
|
|
||||||
}
|
|
||||||
} else if dy > 0.0 {
|
|
||||||
DropZone::Bottom
|
|
||||||
} else {
|
|
||||||
DropZone::Top
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the preview rectangle for this drop zone within `pane_rect`.
|
|
||||||
pub(crate) fn preview_rect(self, pane_rect: Rect) -> Rect {
|
|
||||||
match self {
|
|
||||||
DropZone::Center => pane_rect,
|
|
||||||
DropZone::Left => Rect::from_min_max(
|
|
||||||
pane_rect.min,
|
|
||||||
egui::pos2(pane_rect.min.x + pane_rect.width() * 0.25, pane_rect.max.y),
|
|
||||||
),
|
|
||||||
DropZone::Right => Rect::from_min_max(
|
|
||||||
egui::pos2(pane_rect.max.x - pane_rect.width() * 0.25, pane_rect.min.y),
|
|
||||||
pane_rect.max,
|
|
||||||
),
|
|
||||||
DropZone::Top => Rect::from_min_max(
|
|
||||||
pane_rect.min,
|
|
||||||
egui::pos2(pane_rect.max.x, pane_rect.min.y + pane_rect.height() * 0.25),
|
|
||||||
),
|
|
||||||
DropZone::Bottom => Rect::from_min_max(
|
|
||||||
egui::pos2(pane_rect.min.x, pane_rect.max.y - pane_rect.height() * 0.25),
|
|
||||||
pane_rect.max,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run the drag-and-drop resolution pass.
|
|
||||||
///
|
|
||||||
/// Draws the drop-target overlay while a drag is live, and mutates the tree
|
|
||||||
/// on pointer release.
|
|
||||||
pub(crate) fn dnd_pass<T: Clone + 'static>(
|
|
||||||
ui: &mut Ui,
|
|
||||||
tree: &mut PanelTree<T>,
|
|
||||||
style: &PanelStyle,
|
|
||||||
) {
|
|
||||||
let ctx = ui.ctx().clone();
|
|
||||||
|
|
||||||
// Is a drag live?
|
|
||||||
let Some(live_payload) = DragAndDrop::payload::<DragPayload>(&ctx) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let cursor_pos = match ctx.pointer_hover_pos() {
|
|
||||||
Some(p) => p,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find which leaf the cursor is over.
|
|
||||||
let target_leaf = tree.leaf_indices().find(|&idx| {
|
|
||||||
if let Node::Leaf { rect, .. } = tree.node(idx) {
|
|
||||||
rect.contains(cursor_pos)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(target_leaf) = target_leaf {
|
|
||||||
let root_rect = match tree.node(0) {
|
|
||||||
Node::Split { rect, .. } => *rect,
|
|
||||||
Node::Leaf { rect, .. } => *rect,
|
|
||||||
Node::Empty => return,
|
|
||||||
};
|
|
||||||
let pane_rect = match tree.node(target_leaf) {
|
|
||||||
Node::Leaf { rect, .. } => *rect,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
let Some(options) = tree.pane_options(target_leaf) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cursor in the header bar → always merge (Center). Without this,
|
|
||||||
// a cursor sitting on a tab button lands near the top of the pane
|
|
||||||
// rect and gets classified as DropZone::Top, causing an unintended
|
|
||||||
// split when the user just wanted to drop onto that pane's tab bar.
|
|
||||||
let header_visible = tree.header_visible(target_leaf);
|
|
||||||
let header_rect = egui::Rect::from_min_size(
|
|
||||||
pane_rect.min,
|
|
||||||
egui::vec2(pane_rect.width(), style.header_height),
|
|
||||||
);
|
|
||||||
let zone = if header_visible && header_rect.contains(cursor_pos) {
|
|
||||||
DropZone::Center
|
|
||||||
} else {
|
|
||||||
// Classify relative to the content area only (below the header).
|
|
||||||
let header_height = if header_visible {
|
|
||||||
style.header_height
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let content_rect = egui::Rect::from_min_max(
|
|
||||||
egui::pos2(pane_rect.min.x, pane_rect.min.y + header_height),
|
|
||||||
pane_rect.max,
|
|
||||||
);
|
|
||||||
DropZone::classify(cursor_pos, content_rect)
|
|
||||||
};
|
|
||||||
let Some(target_pane) = tree.pane_id_at(target_leaf) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let target_role = match tree.node(target_leaf) {
|
|
||||||
Node::Leaf { options, .. } => options.role,
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let dragged_tab = tree
|
|
||||||
.pane_index(live_payload.src_pane)
|
|
||||||
.and_then(|src_leaf| match &tree.nodes[src_leaf] {
|
|
||||||
Node::Leaf { tabs, .. } => tabs.get(live_payload.tab_pos),
|
|
||||||
_ => None,
|
|
||||||
});
|
|
||||||
if !dragged_tab
|
|
||||||
.map(|tab| tab_allows_target_pane(tab, target_pane, target_role))
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut zone_allowed = drop_zone_allowed(options, zone);
|
|
||||||
let root_zone = root_edge_drop_zone(cursor_pos, root_rect)
|
|
||||||
.filter(|&root_zone| drop_zone_allowed(options, root_zone));
|
|
||||||
let effective_zone = root_zone.unwrap_or(zone);
|
|
||||||
zone_allowed = root_zone.is_some() || zone_allowed;
|
|
||||||
if !zone_allowed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let reorder_preview = if target_pane == live_payload.src_pane
|
|
||||||
&& effective_zone == DropZone::Center
|
|
||||||
&& header_visible
|
|
||||||
&& header_rect.contains(cursor_pos)
|
|
||||||
&& !options.lock_layout
|
|
||||||
&& options.allow_tab_reorder
|
|
||||||
{
|
|
||||||
header_tab_drop_index(tree, target_leaf, header_rect, cursor_pos)
|
|
||||||
.and_then(|target_pos| header_tab_rect(tree, target_leaf, header_rect, target_pos))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let preview = if let Some(reorder_rect) = reorder_preview {
|
|
||||||
reorder_rect
|
|
||||||
} else if root_zone.is_some() {
|
|
||||||
effective_zone.preview_rect(root_rect)
|
|
||||||
} else {
|
|
||||||
effective_zone.preview_rect(pane_rect)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Draw translucent overlay on a top-level layer.
|
|
||||||
let overlay_painter = ctx.layer_painter(LayerId::new(
|
|
||||||
Order::Tooltip,
|
|
||||||
egui::Id::new("grimdock_dnd_overlay"),
|
|
||||||
));
|
|
||||||
overlay_painter.rect_filled(preview, style.pane_rounding, style.overlay.fill);
|
|
||||||
overlay_painter.rect_stroke(
|
|
||||||
preview,
|
|
||||||
style.pane_rounding,
|
|
||||||
style.overlay.stroke,
|
|
||||||
egui::StrokeKind::Inside,
|
|
||||||
);
|
|
||||||
|
|
||||||
// On release, apply the drop.
|
|
||||||
let released = ctx.input(|i| i.pointer.any_released());
|
|
||||||
if released {
|
|
||||||
// Take the payload — this clears the drag state.
|
|
||||||
if let Some(payload) = DragAndDrop::take_payload::<DragPayload>(&ctx) {
|
|
||||||
let Some(src_leaf) = tree.pane_index(payload.src_pane) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let tab_pos = payload.tab_pos;
|
|
||||||
|
|
||||||
let draggable = match &tree.nodes[src_leaf] {
|
|
||||||
Node::Leaf { tabs, options, .. } => tabs
|
|
||||||
.get(tab_pos)
|
|
||||||
.map(|tab| {
|
|
||||||
tab.draggable
|
|
||||||
&& tab_allows_target_pane(tab, target_pane, target_role)
|
|
||||||
&& !options.lock_layout
|
|
||||||
&& (options.allow_tab_drag_out || options.allow_tab_reorder)
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if draggable != Some(true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve the tab before mutating the tree.
|
|
||||||
let tab = match &tree.nodes[src_leaf] {
|
|
||||||
Node::Leaf { tabs, .. } => tabs.get(tab_pos).cloned(),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(tab) = tab {
|
|
||||||
match effective_zone {
|
|
||||||
DropZone::Center => {
|
|
||||||
if src_leaf == target_leaf {
|
|
||||||
if header_visible && header_rect.contains(cursor_pos) && options.allow_tab_reorder {
|
|
||||||
if let Some(target_pos) =
|
|
||||||
header_tab_drop_index(tree, target_leaf, header_rect, cursor_pos)
|
|
||||||
{
|
|
||||||
let _ = tree.move_tab_within_leaf(target_leaf, tab_pos, target_pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tree.merge_tab_into(src_leaf, tab_pos, target_leaf);
|
|
||||||
tree.collapse_empty_leaf(src_leaf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DropZone::Left => {
|
|
||||||
if root_zone.is_some() {
|
|
||||||
apply_root_directional_drop(
|
|
||||||
tree,
|
|
||||||
src_leaf,
|
|
||||||
tab_pos,
|
|
||||||
tab,
|
|
||||||
SplitDir::Horizontal,
|
|
||||||
ChildSide::First,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
apply_directional_drop(
|
|
||||||
tree,
|
|
||||||
src_leaf,
|
|
||||||
tab_pos,
|
|
||||||
target_leaf,
|
|
||||||
tab,
|
|
||||||
SplitDir::Horizontal,
|
|
||||||
ChildSide::First,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DropZone::Right => {
|
|
||||||
if root_zone.is_some() {
|
|
||||||
apply_root_directional_drop(
|
|
||||||
tree,
|
|
||||||
src_leaf,
|
|
||||||
tab_pos,
|
|
||||||
tab,
|
|
||||||
SplitDir::Horizontal,
|
|
||||||
ChildSide::Second,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
apply_directional_drop(
|
|
||||||
tree,
|
|
||||||
src_leaf,
|
|
||||||
tab_pos,
|
|
||||||
target_leaf,
|
|
||||||
tab,
|
|
||||||
SplitDir::Horizontal,
|
|
||||||
ChildSide::Second,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DropZone::Top => {
|
|
||||||
if root_zone.is_some() {
|
|
||||||
apply_root_directional_drop(
|
|
||||||
tree,
|
|
||||||
src_leaf,
|
|
||||||
tab_pos,
|
|
||||||
tab,
|
|
||||||
SplitDir::Vertical,
|
|
||||||
ChildSide::First,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
apply_directional_drop(
|
|
||||||
tree,
|
|
||||||
src_leaf,
|
|
||||||
tab_pos,
|
|
||||||
target_leaf,
|
|
||||||
tab,
|
|
||||||
SplitDir::Vertical,
|
|
||||||
ChildSide::First,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DropZone::Bottom => {
|
|
||||||
if root_zone.is_some() {
|
|
||||||
apply_root_directional_drop(
|
|
||||||
tree,
|
|
||||||
src_leaf,
|
|
||||||
tab_pos,
|
|
||||||
tab,
|
|
||||||
SplitDir::Vertical,
|
|
||||||
ChildSide::Second,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
apply_directional_drop(
|
|
||||||
tree,
|
|
||||||
src_leaf,
|
|
||||||
tab_pos,
|
|
||||||
target_leaf,
|
|
||||||
tab,
|
|
||||||
SplitDir::Vertical,
|
|
||||||
ChildSide::Second,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove the tab from its source and split the target pane to host it.
|
|
||||||
///
|
|
||||||
/// **Operation order**: split target first (no indices change), then remove
|
|
||||||
/// from source, then collapse if source is empty. Doing it in this order
|
|
||||||
/// keeps `src_leaf` at a stable index throughout — collapsing before
|
|
||||||
/// splitting would shift the target's index in any layout where target is
|
|
||||||
/// inside the subtree that gets promoted.
|
|
||||||
fn apply_directional_drop<T: Clone + 'static>(
|
|
||||||
tree: &mut PanelTree<T>,
|
|
||||||
src_leaf: usize,
|
|
||||||
tab_pos: usize,
|
|
||||||
target_leaf: usize,
|
|
||||||
tab: crate::tab::Tab<T>,
|
|
||||||
dir: SplitDir,
|
|
||||||
side: ChildSide,
|
|
||||||
) {
|
|
||||||
if src_leaf == target_leaf {
|
|
||||||
// Dragging a tab onto the edge of its own pane.
|
|
||||||
// split_leaf keeps the existing tabs in one child and places `tab` in
|
|
||||||
// the other. We need to remove the original occurrence of the tab
|
|
||||||
// from the "existing-tabs" child afterwards.
|
|
||||||
tree.split_leaf(target_leaf, dir, tab, side);
|
|
||||||
|
|
||||||
// After the split, src_leaf is now a Split node. The child that
|
|
||||||
// received the original tabs is the opposite of `side`.
|
|
||||||
let old_child = match side {
|
|
||||||
ChildSide::First => PanelTree::<T>::right_child(target_leaf),
|
|
||||||
ChildSide::Second => PanelTree::<T>::left_child(target_leaf),
|
|
||||||
};
|
|
||||||
if let Node::Leaf { tabs, focused, .. } = &mut tree.nodes[old_child] {
|
|
||||||
if tab_pos < tabs.len() {
|
|
||||||
tabs.remove(tab_pos);
|
|
||||||
if !tabs.is_empty() && *focused >= tabs.len() {
|
|
||||||
*focused = tabs.len() - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If the old child is now empty (sole-tab pane split onto its own
|
|
||||||
// edge), collapse it so the layout stays consistent.
|
|
||||||
tree.collapse_empty_leaf(old_child);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: split the target. `split_leaf` only adds child nodes — it
|
|
||||||
// never moves any existing nodes — so `src_leaf` keeps its index.
|
|
||||||
tree.split_leaf(target_leaf, dir, tab, side);
|
|
||||||
|
|
||||||
// Step 2: remove the tab from the source.
|
|
||||||
match &mut tree.nodes[src_leaf] {
|
|
||||||
Node::Leaf { tabs, focused, .. } => {
|
|
||||||
if tab_pos < tabs.len() {
|
|
||||||
tabs.remove(tab_pos);
|
|
||||||
if !tabs.is_empty() && *focused >= tabs.len() {
|
|
||||||
*focused = tabs.len() - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => return,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: collapse the source if it is now empty.
|
|
||||||
let src_empty = matches!(&tree.nodes[src_leaf], Node::Leaf { tabs, .. } if tabs.is_empty());
|
|
||||||
if src_empty {
|
|
||||||
tree.collapse_empty_leaf(src_leaf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_root_directional_drop<T: Clone + 'static>(
|
|
||||||
tree: &mut PanelTree<T>,
|
|
||||||
src_leaf: usize,
|
|
||||||
tab_pos: usize,
|
|
||||||
tab: crate::tab::Tab<T>,
|
|
||||||
dir: SplitDir,
|
|
||||||
side: ChildSide,
|
|
||||||
) {
|
|
||||||
tree.wrap_root_with_split(dir, tab, side);
|
|
||||||
|
|
||||||
// After wrap_root_with_split the old subtree (rooted at old index 0) is
|
|
||||||
// placed at `existing_child` in the new tree. copy_subtree_with_offset
|
|
||||||
// maps old index `i` → `existing_child * 2^depth(i) + i`, where
|
|
||||||
// `depth(i) = floor(log2(i + 1))`. The previously used formulas
|
|
||||||
// `2*src_leaf+2` / `2*src_leaf+1` were only correct for the shallowest
|
|
||||||
// nodes and caused an out-of-bounds panic for deeper layouts.
|
|
||||||
let existing_child = match side {
|
|
||||||
ChildSide::First => PanelTree::<T>::right_child(0),
|
|
||||||
ChildSide::Second => PanelTree::<T>::left_child(0),
|
|
||||||
};
|
|
||||||
let depth = usize::BITS - (src_leaf + 1).leading_zeros() - 1;
|
|
||||||
let shifted_src_leaf = existing_child * (1 << depth) + src_leaf;
|
|
||||||
|
|
||||||
match &mut tree.nodes[shifted_src_leaf] {
|
|
||||||
Node::Leaf { tabs, focused, .. } => {
|
|
||||||
if tab_pos < tabs.len() {
|
|
||||||
tabs.remove(tab_pos);
|
|
||||||
if !tabs.is_empty() && *focused >= tabs.len() {
|
|
||||||
*focused = tabs.len() - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => return,
|
|
||||||
}
|
|
||||||
|
|
||||||
let src_empty =
|
|
||||||
matches!(&tree.nodes[shifted_src_leaf], Node::Leaf { tabs, .. } if tabs.is_empty());
|
|
||||||
if src_empty {
|
|
||||||
tree.collapse_empty_leaf(shifted_src_leaf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{DropPolicy, HeaderVisibility, PanelTree, Tab, TabDropPolicy};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn root_edge_drop_zone_prefers_nearest_edge() {
|
|
||||||
let root = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(200.0, 100.0));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
root_edge_drop_zone(egui::pos2(4.0, 50.0), root),
|
|
||||||
Some(DropZone::Left)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
root_edge_drop_zone(egui::pos2(196.0, 50.0), root),
|
|
||||||
Some(DropZone::Right)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
root_edge_drop_zone(egui::pos2(100.0, 4.0), root),
|
|
||||||
Some(DropZone::Top)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
root_edge_drop_zone(egui::pos2(100.0, 96.0), root),
|
|
||||||
Some(DropZone::Bottom)
|
|
||||||
);
|
|
||||||
assert_eq!(root_edge_drop_zone(egui::pos2(100.0, 50.0), root), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn drop_zone_allowed_respects_non_droppable_and_locked_panes() {
|
|
||||||
let mut options = crate::tree::PaneOptions::default();
|
|
||||||
options.drop_policy = DropPolicy::none();
|
|
||||||
assert!(!drop_zone_allowed(options, DropZone::Center));
|
|
||||||
assert!(!drop_zone_allowed(options, DropZone::Left));
|
|
||||||
|
|
||||||
options.drop_policy = DropPolicy::all();
|
|
||||||
options.lock_layout = true;
|
|
||||||
assert!(!drop_zone_allowed(options, DropZone::Center));
|
|
||||||
assert!(!drop_zone_allowed(options, DropZone::Right));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn apply_root_directional_drop_wraps_root_without_losing_tabs() {
|
|
||||||
let mut tree = PanelTree::new(vec![Tab::new("A", "a")]);
|
|
||||||
tree.insert_tab_into_leaf(0, Tab::new("B", "b"));
|
|
||||||
|
|
||||||
let tab = match tree.node(0) {
|
|
||||||
Node::Leaf { tabs, .. } => tabs[0].clone(),
|
|
||||||
_ => panic!("expected root leaf"),
|
|
||||||
};
|
|
||||||
|
|
||||||
apply_root_directional_drop(
|
|
||||||
&mut tree,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
tab,
|
|
||||||
SplitDir::Horizontal,
|
|
||||||
ChildSide::First,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(matches!(tree.node(0), Node::Split { .. }));
|
|
||||||
assert!(tree.find_tab(&"a").is_some());
|
|
||||||
assert!(tree.find_tab(&"b").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression: dragging a tab from a deep leaf (depth ≥ 2) onto the root
|
|
||||||
/// edge used to compute the shifted_src_leaf index as `right_child(src)`
|
|
||||||
/// / `left_child(src)`, which gives the wrong node and panics with
|
|
||||||
/// "index out of bounds" once the tree is tall enough.
|
|
||||||
#[test]
|
|
||||||
fn apply_root_directional_drop_deep_layout_no_panic() {
|
|
||||||
// Build a tree: root → [A | [B | C]]
|
|
||||||
// After two split_leaf calls the layout is:
|
|
||||||
// 0 = Split
|
|
||||||
// 1 = Leaf [A] (left child of root)
|
|
||||||
// 2 = Split
|
|
||||||
// 5 = Leaf [B] (left child of node 2)
|
|
||||||
// 6 = Leaf [C] (right child of node 2) ← src_leaf = 6
|
|
||||||
let mut tree = PanelTree::new(vec![Tab::new("A", "a")]);
|
|
||||||
tree.split_leaf(0, SplitDir::Horizontal, Tab::new("B", "b"), ChildSide::Second);
|
|
||||||
// node 0 = Split, node 1 = Leaf [A], node 2 = Leaf [B]
|
|
||||||
tree.split_leaf(2, SplitDir::Horizontal, Tab::new("C", "c"), ChildSide::Second);
|
|
||||||
// node 0 = Split, node 1 = Leaf [A], node 2 = Split, node 5 = Leaf [B], node 6 = Leaf [C]
|
|
||||||
|
|
||||||
let src_leaf = 6;
|
|
||||||
let tab = match tree.node(src_leaf) {
|
|
||||||
Node::Leaf { tabs, .. } => tabs[0].clone(),
|
|
||||||
_ => panic!("expected leaf at index {src_leaf}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// This used to panic with "index out of bounds" because shifted_src_leaf
|
|
||||||
// was computed as left_child(6) = 13 but the tree only had 11 nodes.
|
|
||||||
apply_root_directional_drop(
|
|
||||||
&mut tree,
|
|
||||||
src_leaf,
|
|
||||||
0,
|
|
||||||
tab,
|
|
||||||
SplitDir::Horizontal,
|
|
||||||
ChildSide::Second,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(matches!(tree.node(0), Node::Split { .. }));
|
|
||||||
assert!(tree.find_tab(&"a").is_some());
|
|
||||||
assert!(tree.find_tab(&"b").is_some());
|
|
||||||
// "c" was the dragged tab so it should be in the new root-edge pane
|
|
||||||
assert!(tree.find_tab(&"c").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn header_drop_index_handles_single_hidden_header_layouts() {
|
|
||||||
let mut tree = PanelTree::new(vec![Tab::new("A", "a"), Tab::new("B", "b")]);
|
|
||||||
let mut options = tree.pane_options(0).unwrap();
|
|
||||||
options.header_visibility = HeaderVisibility::WhenMultipleTabs;
|
|
||||||
tree.set_pane_options(0, options);
|
|
||||||
|
|
||||||
let rect = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(100.0, 20.0));
|
|
||||||
assert_eq!(
|
|
||||||
header_tab_drop_index(&tree, 0, rect, egui::pos2(5.0, 10.0)),
|
|
||||||
Some(0)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
header_tab_drop_index(&tree, 0, rect, egui::pos2(95.0, 10.0)),
|
|
||||||
Some(1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tab_drop_policy_blocks_non_whitelisted_target_panes() {
|
|
||||||
let mut tree = PanelTree::new(vec![Tab::new("A", "a")]);
|
|
||||||
let right_pane = tree.split_leaf(
|
|
||||||
0,
|
|
||||||
SplitDir::Horizontal,
|
|
||||||
Tab::new("B", "b").with_drop_policy(TabDropPolicy {
|
|
||||||
locked_to_pane: None,
|
|
||||||
locked_to_role: None,
|
|
||||||
allowed_panes: Some(vec![PaneId::from_raw(1)]),
|
|
||||||
allowed_roles: None,
|
|
||||||
blocked_panes: Vec::new(),
|
|
||||||
blocked_roles: Vec::new(),
|
|
||||||
}),
|
|
||||||
ChildSide::Second,
|
|
||||||
);
|
|
||||||
|
|
||||||
let right_idx = tree.pane_index(right_pane).expect("right pane should exist");
|
|
||||||
let tab = match tree.node(right_idx) {
|
|
||||||
Node::Leaf { tabs, .. } => tabs.first().expect("right pane should hold a tab"),
|
|
||||||
other => panic!("expected right leaf, got {:?}", other),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(!tab_allows_target_pane(tab, right_pane, None));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tab_drop_policy_honors_blocked_pane_list() {
|
|
||||||
let policy = TabDropPolicy {
|
|
||||||
locked_to_pane: None,
|
|
||||||
locked_to_role: None,
|
|
||||||
allowed_panes: None,
|
|
||||||
allowed_roles: None,
|
|
||||||
blocked_panes: vec![PaneId::from_raw(7)],
|
|
||||||
blocked_roles: Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(policy.allows_target(PaneId::from_raw(3), None));
|
|
||||||
assert!(!policy.allows_target(PaneId::from_raw(7), None));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tab_drop_policy_honors_role_filters() {
|
|
||||||
let policy = TabDropPolicy {
|
|
||||||
locked_to_pane: None,
|
|
||||||
locked_to_role: Some(PaneRole::Terminal),
|
|
||||||
allowed_panes: None,
|
|
||||||
allowed_roles: None,
|
|
||||||
blocked_panes: Vec::new(),
|
|
||||||
blocked_roles: Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(policy.allows_target(PaneId::from_raw(1), Some(PaneRole::Terminal)));
|
|
||||||
assert!(!policy.allows_target(PaneId::from_raw(1), Some(PaneRole::Editor)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
|||||||
use egui::Id;
|
|
||||||
|
|
||||||
use crate::tree::PaneId;
|
|
||||||
|
|
||||||
/// Stable [`Id`] for the resize handle between the two children of a split node.
|
|
||||||
pub(crate) fn resize_handle_id(tree_node_idx: usize) -> Id {
|
|
||||||
Id::new("grimdock::resize").with(tree_node_idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stable [`Id`] for a tab button inside a leaf node.
|
|
||||||
pub(crate) fn tab_button_id(pane_id: PaneId, tab_pos: usize) -> Id {
|
|
||||||
Id::new("grimdock::tab")
|
|
||||||
.with(pane_id.into_raw())
|
|
||||||
.with(tab_pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stable [`Id`] for a tab close button inside a leaf node.
|
|
||||||
pub(crate) fn tab_close_button_id(pane_id: PaneId, tab_pos: usize) -> Id {
|
|
||||||
Id::new("grimdock::tab_close")
|
|
||||||
.with(pane_id.into_raw())
|
|
||||||
.with(tab_pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stable [`Id`] for the content child-UI of a leaf node.
|
|
||||||
pub(crate) fn pane_content_id(pane_id: PaneId) -> Id {
|
|
||||||
Id::new("grimdock::content").with(pane_id.into_raw())
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
use egui::{Rect, Sense, Ui, vec2};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
ids::resize_handle_id,
|
|
||||||
style::PanelStyle,
|
|
||||||
tree::{Node, PanelTree, SplitDir},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Run the layout pass over the entire tree.
|
|
||||||
///
|
|
||||||
/// Assigns a rectangle to every node starting from the root, renders resize
|
|
||||||
/// handles for split nodes, and handles ratio updates from handle drags.
|
|
||||||
///
|
|
||||||
/// Returns a list of `(leaf_index, content_rect)` pairs for consumption by
|
|
||||||
/// the header and content passes.
|
|
||||||
pub(crate) fn layout_pass<T: Clone + 'static>(
|
|
||||||
ui: &mut Ui,
|
|
||||||
tree: &mut PanelTree<T>,
|
|
||||||
style: &PanelStyle,
|
|
||||||
) -> Vec<(usize, Rect)> {
|
|
||||||
let root_rect = ui.available_rect_before_wrap();
|
|
||||||
let mut leaf_rects: Vec<(usize, Rect)> = Vec::new();
|
|
||||||
layout_node(ui, tree, 0, root_rect, style, &mut leaf_rects);
|
|
||||||
leaf_rects
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute whether a node has vanished from the layout. A leaf with no tabs
|
|
||||||
/// is considered vanished UNLESS its `persist_when_empty` option is set, which
|
|
||||||
/// keeps the pane alive so that split ratios and sibling panes are preserved.
|
|
||||||
fn is_node_vanished<T: Clone + 'static>(tree: &PanelTree<T>, idx: usize) -> bool {
|
|
||||||
match tree.node(idx) {
|
|
||||||
Node::Leaf { tabs, options, .. } => tabs.is_empty() && !options.persist_when_empty,
|
|
||||||
Node::Split { .. } => {
|
|
||||||
let left = PanelTree::<T>::left_child(idx);
|
|
||||||
let right = PanelTree::<T>::right_child(idx);
|
|
||||||
is_node_vanished(tree, left) && is_node_vanished(tree, right)
|
|
||||||
}
|
|
||||||
Node::Empty => true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute the effective collapsed thickness for a child node. Collapsed
|
|
||||||
/// leaves that have no tabs (i.e. floated panels) are assigned zero space so
|
|
||||||
/// they vanish from the layout entirely.
|
|
||||||
fn effective_collapsed_thickness<T: Clone + 'static>(
|
|
||||||
tree: &PanelTree<T>,
|
|
||||||
child_idx: usize,
|
|
||||||
collapsed: bool,
|
|
||||||
default: f32,
|
|
||||||
) -> f32 {
|
|
||||||
if !collapsed {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
if is_node_vanished(tree, child_idx) {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layout_node<T: Clone + 'static>(
|
|
||||||
ui: &mut Ui,
|
|
||||||
tree: &mut PanelTree<T>,
|
|
||||||
idx: usize,
|
|
||||||
rect: Rect,
|
|
||||||
style: &PanelStyle,
|
|
||||||
leaf_rects: &mut Vec<(usize, Rect)>,
|
|
||||||
) {
|
|
||||||
// Store the rect in the node for DnD hit-testing later.
|
|
||||||
match tree.node_mut(idx) {
|
|
||||||
Node::Split { rect: r, .. } => *r = rect,
|
|
||||||
Node::Leaf { rect: r, .. } => *r = rect,
|
|
||||||
Node::Empty => return,
|
|
||||||
}
|
|
||||||
|
|
||||||
match tree.node(idx).clone() {
|
|
||||||
Node::Split { dir, mut ratio, .. } => {
|
|
||||||
let handle_w = style.handle_width;
|
|
||||||
let first_idx = PanelTree::<T>::left_child(idx);
|
|
||||||
let second_idx = PanelTree::<T>::right_child(idx);
|
|
||||||
let first_collapsed = tree.is_collapsed(first_idx) || is_node_vanished(tree, first_idx);
|
|
||||||
let second_collapsed = tree.is_collapsed(second_idx) || is_node_vanished(tree, second_idx);
|
|
||||||
let resize_locked = tree.split_resize_locked(idx);
|
|
||||||
|
|
||||||
let first_ct = effective_collapsed_thickness(
|
|
||||||
tree, first_idx, first_collapsed, style.collapsed_pane_thickness,
|
|
||||||
);
|
|
||||||
let second_ct = effective_collapsed_thickness(
|
|
||||||
tree, second_idx, second_collapsed, style.collapsed_pane_thickness,
|
|
||||||
);
|
|
||||||
|
|
||||||
let (_first_rect, handle_rect, _second_rect) = split_rect(
|
|
||||||
rect, dir, ratio, handle_w,
|
|
||||||
first_collapsed, second_collapsed,
|
|
||||||
first_ct, second_ct,
|
|
||||||
style.min_pane_size, !resize_locked,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Draw and interact with the resize handle.
|
|
||||||
let handle_id = resize_handle_id(idx);
|
|
||||||
let handle_response = ui.interact(
|
|
||||||
handle_rect,
|
|
||||||
handle_id,
|
|
||||||
if resize_locked {
|
|
||||||
Sense::hover()
|
|
||||||
} else {
|
|
||||||
Sense::drag()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let is_active = handle_response.dragged() || handle_response.hovered();
|
|
||||||
let color = if resize_locked {
|
|
||||||
style.handle.locked_color
|
|
||||||
} else if is_active {
|
|
||||||
style.handle.hover_color
|
|
||||||
} else {
|
|
||||||
style.handle.color
|
|
||||||
};
|
|
||||||
ui.painter().rect_filled(handle_rect, 0.0, color);
|
|
||||||
|
|
||||||
if !resize_locked && handle_response.hovered() {
|
|
||||||
let cursor = match dir {
|
|
||||||
SplitDir::Horizontal => egui::CursorIcon::ResizeHorizontal,
|
|
||||||
SplitDir::Vertical => egui::CursorIcon::ResizeVertical,
|
|
||||||
};
|
|
||||||
ui.ctx().set_cursor_icon(cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
if handle_response.dragged()
|
|
||||||
&& !resize_locked
|
|
||||||
&& !first_collapsed
|
|
||||||
&& !second_collapsed
|
|
||||||
{
|
|
||||||
let delta = handle_response.drag_delta();
|
|
||||||
let delta_ratio = match dir {
|
|
||||||
SplitDir::Horizontal => delta.x / rect.width().max(1.0),
|
|
||||||
SplitDir::Vertical => delta.y / rect.height().max(1.0),
|
|
||||||
};
|
|
||||||
|
|
||||||
let min_ratio = style.min_pane_size
|
|
||||||
/ match dir {
|
|
||||||
SplitDir::Horizontal => rect.width().max(1.0),
|
|
||||||
SplitDir::Vertical => rect.height().max(1.0),
|
|
||||||
};
|
|
||||||
let max_ratio = 1.0 - min_ratio;
|
|
||||||
ratio = (ratio + delta_ratio).clamp(min_ratio.max(0.0), max_ratio.min(1.0));
|
|
||||||
|
|
||||||
if let Node::Split { ratio: r, .. } = tree.node_mut(idx) {
|
|
||||||
*r = ratio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let (first_rect, _, second_rect) = split_rect(
|
|
||||||
rect, dir, ratio, handle_w,
|
|
||||||
first_collapsed, second_collapsed,
|
|
||||||
first_ct, second_ct,
|
|
||||||
style.min_pane_size, !resize_locked,
|
|
||||||
);
|
|
||||||
|
|
||||||
layout_node(ui, tree, first_idx, first_rect, style, leaf_rects);
|
|
||||||
layout_node(ui, tree, second_idx, second_rect, style, leaf_rects);
|
|
||||||
}
|
|
||||||
Node::Leaf { tabs, collapsed, .. } => {
|
|
||||||
let header_height = if tree.header_visible(idx) {
|
|
||||||
if collapsed && tabs.is_empty() { 0.0 } else { style.header_height }
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let content_rect = Rect::from_min_size(
|
|
||||||
rect.min + vec2(0.0, header_height),
|
|
||||||
vec2(rect.width(), (rect.height() - header_height).max(0.0)),
|
|
||||||
);
|
|
||||||
leaf_rects.push((idx, content_rect));
|
|
||||||
}
|
|
||||||
Node::Empty => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split `rect` into two sub-rects with a handle strip between them.
|
|
||||||
///
|
|
||||||
/// `first_ct` / `second_ct` are the collapsed thickness for each side when
|
|
||||||
/// that side is collapsed. A value of `0.0` makes the collapsed side vanish.
|
|
||||||
///
|
|
||||||
/// When `enforce_min` is `true`, each non-collapsed side is guaranteed at least
|
|
||||||
/// `min_pane_size` pixels of extent. Splits whose resize handle is locked
|
|
||||||
/// (e.g. the fixed-width Tools column) pass `enforce_min = false` so their
|
|
||||||
/// ratio-driven (possibly narrow) width is preserved.
|
|
||||||
fn split_rect(
|
|
||||||
rect: Rect,
|
|
||||||
dir: SplitDir,
|
|
||||||
ratio: f32,
|
|
||||||
handle_w: f32,
|
|
||||||
first_collapsed: bool,
|
|
||||||
second_collapsed: bool,
|
|
||||||
first_ct: f32,
|
|
||||||
second_ct: f32,
|
|
||||||
min_pane_size: f32,
|
|
||||||
enforce_min: bool,
|
|
||||||
) -> (Rect, Rect, Rect) {
|
|
||||||
let total_extent = match dir {
|
|
||||||
SplitDir::Horizontal => rect.width(),
|
|
||||||
SplitDir::Vertical => rect.height(),
|
|
||||||
}
|
|
||||||
.max(0.0);
|
|
||||||
|
|
||||||
let first_vanished = first_collapsed && first_ct == 0.0;
|
|
||||||
let second_vanished = second_collapsed && second_ct == 0.0;
|
|
||||||
|
|
||||||
if first_vanished && second_vanished {
|
|
||||||
return (Rect::NOTHING, Rect::NOTHING, Rect::NOTHING);
|
|
||||||
} else if first_vanished {
|
|
||||||
match dir {
|
|
||||||
SplitDir::Horizontal => {
|
|
||||||
let empty = Rect::from_min_max(rect.min, rect.min);
|
|
||||||
return (empty, empty, rect);
|
|
||||||
}
|
|
||||||
SplitDir::Vertical => {
|
|
||||||
let empty = Rect::from_min_max(rect.min, rect.min);
|
|
||||||
return (empty, empty, rect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if second_vanished {
|
|
||||||
match dir {
|
|
||||||
SplitDir::Horizontal => {
|
|
||||||
let empty = Rect::from_min_max(rect.max, rect.max);
|
|
||||||
return (rect, empty, empty);
|
|
||||||
}
|
|
||||||
SplitDir::Vertical => {
|
|
||||||
let empty = Rect::from_min_max(rect.max, rect.max);
|
|
||||||
return (rect, empty, empty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let first_extent = if first_collapsed && !second_collapsed {
|
|
||||||
first_ct.min((total_extent - handle_w).max(0.0))
|
|
||||||
} else if second_collapsed && !first_collapsed {
|
|
||||||
(total_extent - handle_w - second_ct).max(0.0)
|
|
||||||
} else {
|
|
||||||
let raw = total_extent * ratio;
|
|
||||||
if enforce_min && min_pane_size > 0.0 {
|
|
||||||
let usable = (total_extent - handle_w).max(0.0);
|
|
||||||
let min = min_pane_size.min(usable * 0.5);
|
|
||||||
raw.clamp(min, (usable - min).max(min))
|
|
||||||
} else {
|
|
||||||
raw
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match dir {
|
|
||||||
SplitDir::Horizontal => {
|
|
||||||
let split_x = rect.min.x + first_extent;
|
|
||||||
let first = Rect::from_min_max(rect.min, egui::pos2(split_x - handle_w / 2.0, rect.max.y));
|
|
||||||
let handle = Rect::from_min_max(
|
|
||||||
egui::pos2(split_x - handle_w / 2.0, rect.min.y),
|
|
||||||
egui::pos2(split_x + handle_w / 2.0, rect.max.y),
|
|
||||||
);
|
|
||||||
let second = Rect::from_min_max(egui::pos2(split_x + handle_w / 2.0, rect.min.y), rect.max);
|
|
||||||
(first, handle, second)
|
|
||||||
}
|
|
||||||
SplitDir::Vertical => {
|
|
||||||
let split_y = rect.min.y + first_extent;
|
|
||||||
let first = Rect::from_min_max(rect.min, egui::pos2(rect.max.x, split_y - handle_w / 2.0));
|
|
||||||
let handle = Rect::from_min_max(
|
|
||||||
egui::pos2(rect.min.x, split_y - handle_w / 2.0),
|
|
||||||
egui::pos2(rect.max.x, split_y + handle_w / 2.0),
|
|
||||||
);
|
|
||||||
let second = Rect::from_min_max(egui::pos2(rect.min.x, split_y + handle_w / 2.0), rect.max);
|
|
||||||
(first, handle, second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
#![allow(dead_code, unused_imports, unused_variables)]
|
|
||||||
//! # grimdock
|
|
||||||
//!
|
|
||||||
//! A dockable panel layout system for [egui](https://github.com/emilk/egui).
|
|
||||||
//!
|
|
||||||
//! Provides an IDE-style workspace where panels can be split, resized, and
|
|
||||||
//! rearranged by dragging tabs — all within egui's immediate-mode layer.
|
|
||||||
//!
|
|
||||||
//! ## Quick start
|
|
||||||
//!
|
|
||||||
//! ```rust,no_run
|
|
||||||
//! # use grimdock::{PanelTree, PanelStyle, PanelContext, Tab};
|
|
||||||
//! # fn show(ui: &mut egui::Ui, tree: &mut PanelTree<&'static str>) {
|
|
||||||
//! let style = PanelStyle::default();
|
|
||||||
//! PanelContext::new(ui, tree, &style).show(|ui, tab_id| {
|
|
||||||
//! ui.label(*tab_id);
|
|
||||||
//! });
|
|
||||||
//! # }
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
mod content;
|
|
||||||
mod dnd;
|
|
||||||
mod header;
|
|
||||||
mod ids;
|
|
||||||
mod layout;
|
|
||||||
mod persistence;
|
|
||||||
mod style;
|
|
||||||
mod tab;
|
|
||||||
mod tree;
|
|
||||||
|
|
||||||
pub use persistence::{
|
|
||||||
LegacyPersistedNode, LegacyPersistedPanelTree, PersistError, PersistedNode, PersistedPanelTree,
|
|
||||||
PersistedPanelTreeFile, PANEL_TREE_FORMAT_VERSION,
|
|
||||||
};
|
|
||||||
pub use style::{
|
|
||||||
ContentStyle, HandleStyle, HeaderButtonStyle, HeaderStyle, OverlayStyle, PaneStyleOverride,
|
|
||||||
PanelStyle, TabStateStyle, TabStyle, TabStyleOverride, TypographyStyle,
|
|
||||||
};
|
|
||||||
pub use tab::{Tab, TabDropPolicy, TabIcon};
|
|
||||||
pub use tree::{
|
|
||||||
ChildSide, DropPolicy, HeaderVisibility, Node, Pane, PaneAnchor, PaneBuilder, PaneId, PaneMut,
|
|
||||||
PaneOptions, PaneRole, PanelTree, SplitDir,
|
|
||||||
};
|
|
||||||
|
|
||||||
use egui::Ui;
|
|
||||||
|
|
||||||
/// Built-in behavior for resolving add/open actions when a tab with the same
|
|
||||||
/// identifier may already exist.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum OpenBehavior {
|
|
||||||
/// Always insert a fresh tab, even if the same identifier already exists.
|
|
||||||
AllowDuplicate,
|
|
||||||
/// Focus the existing tab anywhere in the dock instead of creating another.
|
|
||||||
FocusExisting,
|
|
||||||
/// Focus the existing tab only if it is already in the target pane.
|
|
||||||
/// Otherwise insert a duplicate into the target pane.
|
|
||||||
FocusExistingInPane,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A caller-provided entry shown in built-in add-tab and split-here menus.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct AddTabEntry<T: Clone + 'static> {
|
|
||||||
pub title: String,
|
|
||||||
pub tab: Tab<T>,
|
|
||||||
pub open_behavior: OpenBehavior,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Clone + 'static> AddTabEntry<T> {
|
|
||||||
pub fn new(title: impl Into<String>, tab: Tab<T>) -> Self {
|
|
||||||
Self {
|
|
||||||
title: title.into(),
|
|
||||||
tab,
|
|
||||||
open_behavior: OpenBehavior::FocusExisting,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_open_behavior(mut self, open_behavior: OpenBehavior) -> Self {
|
|
||||||
self.open_behavior = open_behavior;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A caller-provided pane action shown in the built-in pane menu.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct PaneMenuAction {
|
|
||||||
pub id: String,
|
|
||||||
pub title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PaneMenuAction {
|
|
||||||
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
id: id.into(),
|
|
||||||
title: title.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A custom pane action invoked from the built-in pane menu during this frame.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct PaneActionInvocation {
|
|
||||||
pub pane_id: PaneId,
|
|
||||||
pub action_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mutations emitted by a single [`PanelContext::show`] pass.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct PanelOutput<T> {
|
|
||||||
/// Tabs closed from pane headers during this frame.
|
|
||||||
pub closed_tabs: Vec<T>,
|
|
||||||
/// Custom pane actions invoked from built-in pane menus during this frame.
|
|
||||||
pub pane_actions: Vec<PaneActionInvocation>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> Default for PanelOutput<T> {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
closed_tabs: Vec::new(),
|
|
||||||
pane_actions: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Entry point for rendering the panel layout each frame.
|
|
||||||
///
|
|
||||||
/// The caller owns and persists the [`PanelTree`] across frames. Each frame,
|
|
||||||
/// construct a `PanelContext` and call [`PanelContext::show`] with a render
|
|
||||||
/// callback.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// # use grimdock::{PanelTree, PanelStyle, PanelContext, Tab};
|
|
||||||
/// # fn show(ui: &mut egui::Ui, tree: &mut PanelTree<&'static str>) {
|
|
||||||
/// PanelContext::new(ui, tree, &PanelStyle::default()).show(|ui, id| {
|
|
||||||
/// ui.heading(*id);
|
|
||||||
/// });
|
|
||||||
/// # }
|
|
||||||
/// ```
|
|
||||||
pub struct PanelContext<'ui, T: Clone + 'static> {
|
|
||||||
ui: &'ui mut Ui,
|
|
||||||
tree: &'ui mut PanelTree<T>,
|
|
||||||
style: &'ui PanelStyle,
|
|
||||||
add_tab_entries: &'ui [AddTabEntry<T>],
|
|
||||||
add_tab_provider: Option<&'ui dyn Fn(PaneId, &PanelTree<T>) -> Vec<AddTabEntry<T>>>,
|
|
||||||
pane_menu_actions: &'ui [PaneMenuAction],
|
|
||||||
pane_menu_provider: Option<&'ui dyn Fn(PaneId, &PanelTree<T>) -> Vec<PaneMenuAction>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'ui, T: Clone + 'static> PanelContext<'ui, T> {
|
|
||||||
pub fn new(
|
|
||||||
ui: &'ui mut Ui,
|
|
||||||
tree: &'ui mut PanelTree<T>,
|
|
||||||
style: &'ui PanelStyle,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
ui,
|
|
||||||
tree,
|
|
||||||
style,
|
|
||||||
add_tab_entries: &[],
|
|
||||||
add_tab_provider: None,
|
|
||||||
pane_menu_actions: &[],
|
|
||||||
pane_menu_provider: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provide the same entries for built-in add-tab, overflow, and split-here
|
|
||||||
/// menus in every pane.
|
|
||||||
pub fn with_add_tab_entries(mut self, add_tab_entries: &'ui [AddTabEntry<T>]) -> Self {
|
|
||||||
self.add_tab_entries = add_tab_entries;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provide pane-scoped entries for built-in add-tab and split-here menus.
|
|
||||||
pub fn with_add_tab_provider(
|
|
||||||
mut self,
|
|
||||||
add_tab_provider: &'ui dyn Fn(PaneId, &PanelTree<T>) -> Vec<AddTabEntry<T>>,
|
|
||||||
) -> Self {
|
|
||||||
self.add_tab_provider = Some(add_tab_provider);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provide the same custom pane menu actions for every pane.
|
|
||||||
pub fn with_pane_menu_actions(mut self, pane_menu_actions: &'ui [PaneMenuAction]) -> Self {
|
|
||||||
self.pane_menu_actions = pane_menu_actions;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provide pane-scoped custom actions for the built-in pane menu.
|
|
||||||
pub fn with_pane_menu_provider(
|
|
||||||
mut self,
|
|
||||||
pane_menu_provider: &'ui dyn Fn(PaneId, &PanelTree<T>) -> Vec<PaneMenuAction>,
|
|
||||||
) -> Self {
|
|
||||||
self.pane_menu_provider = Some(pane_menu_provider);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run all three rendering passes and resolve any drag-and-drop that
|
|
||||||
/// completed this frame.
|
|
||||||
///
|
|
||||||
/// `render` is called once per visible leaf with the egui [`Ui`] for that
|
|
||||||
/// pane's content area and a reference to the focused tab's identifier.
|
|
||||||
pub fn show(self, render: impl FnMut(&mut Ui, &T)) -> PanelOutput<T>
|
|
||||||
where
|
|
||||||
T: PartialEq,
|
|
||||||
{
|
|
||||||
let Self {
|
|
||||||
ui,
|
|
||||||
tree,
|
|
||||||
style,
|
|
||||||
add_tab_entries,
|
|
||||||
add_tab_provider,
|
|
||||||
pane_menu_actions,
|
|
||||||
pane_menu_provider,
|
|
||||||
} = self;
|
|
||||||
let mut output = PanelOutput::default();
|
|
||||||
|
|
||||||
// Pass 1: layout — assign rects, draw resize handles.
|
|
||||||
let leaf_rects = layout::layout_pass(ui, tree, style);
|
|
||||||
|
|
||||||
// Pass 2: headers — draw tab buttons, initiate drags.
|
|
||||||
header::header_pass(
|
|
||||||
ui,
|
|
||||||
tree,
|
|
||||||
&leaf_rects,
|
|
||||||
style,
|
|
||||||
add_tab_entries,
|
|
||||||
add_tab_provider,
|
|
||||||
pane_menu_actions,
|
|
||||||
pane_menu_provider,
|
|
||||||
&mut output.closed_tabs,
|
|
||||||
&mut output.pane_actions,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pass 3: content — invoke caller callback for each focused tab.
|
|
||||||
content::content_pass(ui, tree, &leaf_rects, style, render);
|
|
||||||
|
|
||||||
// Pass 4: drag-and-drop — draw overlay, resolve drops.
|
|
||||||
dnd::dnd_pass(ui, tree, style);
|
|
||||||
|
|
||||||
output
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
tab::Tab,
|
|
||||||
tree::{Node, PaneId, PaneOptions, PanelTree, SplitDir},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Current persisted layout format version.
|
|
||||||
pub const PANEL_TREE_FORMAT_VERSION: u32 = 1;
|
|
||||||
|
|
||||||
/// Versioned, runtime-independent layout payload for persistence.
|
|
||||||
///
|
|
||||||
/// This format is the intended stable save/load contract for `grimdock`.
|
|
||||||
/// It avoids runtime-only fields such as layout rects and carries an explicit
|
|
||||||
/// version number so future structural changes can be migrated intentionally.
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
pub struct PersistedPanelTree<T: Clone + 'static> {
|
|
||||||
pub version: u32,
|
|
||||||
pub next_pane_id: u64,
|
|
||||||
pub nodes: Vec<PersistedNode<T>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persisted node payload for [`PersistedPanelTree`].
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
pub enum PersistedNode<T: Clone + 'static> {
|
|
||||||
Empty,
|
|
||||||
Split {
|
|
||||||
dir: SplitDir,
|
|
||||||
ratio: f32,
|
|
||||||
},
|
|
||||||
Leaf {
|
|
||||||
pane: PaneId,
|
|
||||||
tabs: Vec<Tab<T>>,
|
|
||||||
focused: usize,
|
|
||||||
options: PaneOptions,
|
|
||||||
collapsed: bool,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Legacy unversioned persistence shape that mirrors direct serde of
|
|
||||||
/// [`PanelTree`]. This is only used for migration into the explicit versioned
|
|
||||||
/// format.
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
pub struct LegacyPersistedPanelTree<T: Clone + 'static> {
|
|
||||||
pub nodes: Vec<LegacyPersistedNode<T>>,
|
|
||||||
pub next_pane_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
pub enum LegacyPersistedNode<T: Clone + 'static> {
|
|
||||||
Empty,
|
|
||||||
Split {
|
|
||||||
dir: SplitDir,
|
|
||||||
ratio: f32,
|
|
||||||
},
|
|
||||||
Leaf {
|
|
||||||
pane: PaneId,
|
|
||||||
tabs: Vec<Tab<T>>,
|
|
||||||
focused: usize,
|
|
||||||
options: PaneOptions,
|
|
||||||
collapsed: bool,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialization entry point that accepts both the current versioned format
|
|
||||||
/// and the legacy unversioned format.
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
#[cfg_attr(feature = "serde", serde(untagged))]
|
|
||||||
pub enum PersistedPanelTreeFile<T: Clone + 'static> {
|
|
||||||
Versioned(PersistedPanelTree<T>),
|
|
||||||
Legacy(LegacyPersistedPanelTree<T>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum PersistError {
|
|
||||||
UnsupportedVersion(u32),
|
|
||||||
EmptyTree,
|
|
||||||
EmptyLeaf { pane: PaneId },
|
|
||||||
FocusOutOfRange {
|
|
||||||
pane: PaneId,
|
|
||||||
focused: usize,
|
|
||||||
tab_count: usize,
|
|
||||||
},
|
|
||||||
DuplicatePaneId(PaneId),
|
|
||||||
NextPaneIdTooLow {
|
|
||||||
next_pane_id: u64,
|
|
||||||
required_minimum: u64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Clone + 'static> PersistedPanelTree<T> {
|
|
||||||
/// Export a runtime tree into the current versioned persistence format.
|
|
||||||
pub fn current(tree: &PanelTree<T>) -> Self {
|
|
||||||
Self {
|
|
||||||
version: PANEL_TREE_FORMAT_VERSION,
|
|
||||||
next_pane_id: tree.next_pane_id,
|
|
||||||
nodes: tree
|
|
||||||
.nodes
|
|
||||||
.iter()
|
|
||||||
.map(|node| match node {
|
|
||||||
Node::Empty => PersistedNode::Empty,
|
|
||||||
Node::Split { dir, ratio, .. } => PersistedNode::Split {
|
|
||||||
dir: *dir,
|
|
||||||
ratio: *ratio,
|
|
||||||
},
|
|
||||||
Node::Leaf {
|
|
||||||
pane,
|
|
||||||
tabs,
|
|
||||||
focused,
|
|
||||||
options,
|
|
||||||
collapsed,
|
|
||||||
..
|
|
||||||
} => PersistedNode::Leaf {
|
|
||||||
pane: *pane,
|
|
||||||
tabs: tabs.clone(),
|
|
||||||
focused: *focused,
|
|
||||||
options: *options,
|
|
||||||
collapsed: *collapsed,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate the persisted payload before attempting to build a runtime tree.
|
|
||||||
pub fn validate(&self) -> Result<(), PersistError> {
|
|
||||||
if self.nodes.is_empty() {
|
|
||||||
return Err(PersistError::EmptyTree);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut panes = HashSet::new();
|
|
||||||
let mut max_pane = 0_u64;
|
|
||||||
|
|
||||||
for node in &self.nodes {
|
|
||||||
if let PersistedNode::Leaf {
|
|
||||||
pane,
|
|
||||||
tabs,
|
|
||||||
focused,
|
|
||||||
options,
|
|
||||||
..
|
|
||||||
} = node
|
|
||||||
{
|
|
||||||
if !panes.insert(*pane) {
|
|
||||||
return Err(PersistError::DuplicatePaneId(*pane));
|
|
||||||
}
|
|
||||||
max_pane = max_pane.max(pane.into_raw());
|
|
||||||
if tabs.is_empty() {
|
|
||||||
if options.persist_when_empty {
|
|
||||||
if *focused != 0 {
|
|
||||||
return Err(PersistError::FocusOutOfRange {
|
|
||||||
pane: *pane,
|
|
||||||
focused: *focused,
|
|
||||||
tab_count: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return Err(PersistError::EmptyLeaf { pane: *pane });
|
|
||||||
}
|
|
||||||
if *focused >= tabs.len() {
|
|
||||||
return Err(PersistError::FocusOutOfRange {
|
|
||||||
pane: *pane,
|
|
||||||
focused: *focused,
|
|
||||||
tab_count: tabs.len(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let required_minimum = max_pane.saturating_add(1).max(1);
|
|
||||||
if self.next_pane_id < required_minimum {
|
|
||||||
return Err(PersistError::NextPaneIdTooLow {
|
|
||||||
next_pane_id: self.next_pane_id,
|
|
||||||
required_minimum,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert the versioned persisted payload into a runtime tree.
|
|
||||||
pub fn into_panel_tree(self) -> Result<PanelTree<T>, PersistError> {
|
|
||||||
if self.version != PANEL_TREE_FORMAT_VERSION {
|
|
||||||
return Err(PersistError::UnsupportedVersion(self.version));
|
|
||||||
}
|
|
||||||
self.validate()?;
|
|
||||||
|
|
||||||
Ok(PanelTree {
|
|
||||||
nodes: self
|
|
||||||
.nodes
|
|
||||||
.into_iter()
|
|
||||||
.map(|node| match node {
|
|
||||||
PersistedNode::Empty => Node::Empty,
|
|
||||||
PersistedNode::Split { dir, ratio } => Node::Split {
|
|
||||||
dir,
|
|
||||||
ratio,
|
|
||||||
rect: egui::Rect::NOTHING,
|
|
||||||
},
|
|
||||||
PersistedNode::Leaf {
|
|
||||||
pane,
|
|
||||||
tabs,
|
|
||||||
focused,
|
|
||||||
options,
|
|
||||||
collapsed,
|
|
||||||
} => Node::Leaf {
|
|
||||||
pane,
|
|
||||||
tabs,
|
|
||||||
focused,
|
|
||||||
options,
|
|
||||||
collapsed,
|
|
||||||
rect: egui::Rect::NOTHING,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
next_pane_id: self.next_pane_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Clone + 'static> From<&PanelTree<T>> for PersistedPanelTree<T> {
|
|
||||||
fn from(value: &PanelTree<T>) -> Self {
|
|
||||||
Self::current(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Clone + 'static> From<&PanelTree<T>> for LegacyPersistedPanelTree<T> {
|
|
||||||
fn from(value: &PanelTree<T>) -> Self {
|
|
||||||
Self {
|
|
||||||
next_pane_id: value.next_pane_id,
|
|
||||||
nodes: value
|
|
||||||
.nodes
|
|
||||||
.iter()
|
|
||||||
.map(|node| match node {
|
|
||||||
Node::Empty => LegacyPersistedNode::Empty,
|
|
||||||
Node::Split { dir, ratio, .. } => LegacyPersistedNode::Split {
|
|
||||||
dir: *dir,
|
|
||||||
ratio: *ratio,
|
|
||||||
},
|
|
||||||
Node::Leaf {
|
|
||||||
pane,
|
|
||||||
tabs,
|
|
||||||
focused,
|
|
||||||
options,
|
|
||||||
collapsed,
|
|
||||||
..
|
|
||||||
} => LegacyPersistedNode::Leaf {
|
|
||||||
pane: *pane,
|
|
||||||
tabs: tabs.clone(),
|
|
||||||
focused: *focused,
|
|
||||||
options: *options,
|
|
||||||
collapsed: *collapsed,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Clone + 'static> From<LegacyPersistedPanelTree<T>> for PersistedPanelTree<T> {
|
|
||||||
fn from(value: LegacyPersistedPanelTree<T>) -> Self {
|
|
||||||
Self {
|
|
||||||
version: PANEL_TREE_FORMAT_VERSION,
|
|
||||||
next_pane_id: value.next_pane_id,
|
|
||||||
nodes: value
|
|
||||||
.nodes
|
|
||||||
.into_iter()
|
|
||||||
.map(|node| match node {
|
|
||||||
LegacyPersistedNode::Empty => PersistedNode::Empty,
|
|
||||||
LegacyPersistedNode::Split { dir, ratio } => PersistedNode::Split { dir, ratio },
|
|
||||||
LegacyPersistedNode::Leaf {
|
|
||||||
pane,
|
|
||||||
tabs,
|
|
||||||
focused,
|
|
||||||
options,
|
|
||||||
collapsed,
|
|
||||||
} => PersistedNode::Leaf {
|
|
||||||
pane,
|
|
||||||
tabs,
|
|
||||||
focused,
|
|
||||||
options,
|
|
||||||
collapsed,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Clone + 'static> PersistedPanelTreeFile<T> {
|
|
||||||
/// Migrate either the current versioned payload or the legacy payload into
|
|
||||||
/// the current versioned format.
|
|
||||||
pub fn migrate(self) -> Result<PersistedPanelTree<T>, PersistError> {
|
|
||||||
match self {
|
|
||||||
PersistedPanelTreeFile::Versioned(layout) => {
|
|
||||||
if layout.version != PANEL_TREE_FORMAT_VERSION {
|
|
||||||
return Err(PersistError::UnsupportedVersion(layout.version));
|
|
||||||
}
|
|
||||||
layout.validate()?;
|
|
||||||
Ok(layout)
|
|
||||||
}
|
|
||||||
PersistedPanelTreeFile::Legacy(layout) => {
|
|
||||||
let layout = PersistedPanelTree::from(layout);
|
|
||||||
layout.validate()?;
|
|
||||||
Ok(layout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert either the current or legacy persisted payload into a runtime tree.
|
|
||||||
pub fn into_panel_tree(self) -> Result<PanelTree<T>, PersistError> {
|
|
||||||
self.migrate()?.into_panel_tree()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Clone + 'static> PanelTree<T> {
|
|
||||||
/// Export the current layout into the versioned persisted format.
|
|
||||||
pub fn to_persisted(&self) -> PersistedPanelTree<T> {
|
|
||||||
PersistedPanelTree::from(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reconstruct a runtime tree from the versioned persisted format.
|
|
||||||
pub fn from_persisted(layout: PersistedPanelTree<T>) -> Result<Self, PersistError> {
|
|
||||||
layout.into_panel_tree()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{DropPolicy, HeaderVisibility, PaneBuilder, PaneRole, TabDropPolicy, TabStyleOverride};
|
|
||||||
|
|
||||||
fn sample_tree() -> PanelTree<&'static str> {
|
|
||||||
let mut options = PaneOptions::default();
|
|
||||||
options.header_visibility = HeaderVisibility::WhenMultipleTabs;
|
|
||||||
options.drop_policy = DropPolicy::merge_only();
|
|
||||||
options.style_override = Some(crate::style::PaneStyleOverride {
|
|
||||||
header_bg: Some(egui::Color32::from_rgb(1, 2, 3)),
|
|
||||||
content_bg: None,
|
|
||||||
border_color: None,
|
|
||||||
accent_color: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut tree = PanelTree::from_pane(
|
|
||||||
PaneBuilder::new(
|
|
||||||
Tab::new("A", "a").with_style_override(TabStyleOverride {
|
|
||||||
active_bg: Some(egui::Color32::from_rgb(4, 5, 6)),
|
|
||||||
inactive_bg: None,
|
|
||||||
hovered_bg: None,
|
|
||||||
text_color: None,
|
|
||||||
accent_color: None,
|
|
||||||
icon_color: None,
|
|
||||||
max_width: Some(120.0),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.push_tab(Tab::new("B", "b"))
|
|
||||||
.with_options(options),
|
|
||||||
);
|
|
||||||
tree.split_leaf(0, SplitDir::Horizontal, Tab::new("C", "c"), crate::ChildSide::Second);
|
|
||||||
tree
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persisted_round_trip_restores_tree() {
|
|
||||||
let tree = sample_tree();
|
|
||||||
let persisted = tree.to_persisted();
|
|
||||||
let restored = PanelTree::from_persisted(persisted).expect("persistence should round-trip");
|
|
||||||
|
|
||||||
assert_eq!(restored.next_pane_id, tree.next_pane_id);
|
|
||||||
assert_eq!(restored.nodes.len(), tree.nodes.len());
|
|
||||||
assert_eq!(restored.find_pane_containing(&"a"), tree.find_pane_containing(&"a"));
|
|
||||||
assert_eq!(restored.find_pane_containing(&"c"), tree.find_pane_containing(&"c"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persisted_validation_rejects_invalid_focus() {
|
|
||||||
let mut layout = sample_tree().to_persisted();
|
|
||||||
layout.nodes[1] = PersistedNode::Leaf {
|
|
||||||
pane: PaneId::from_raw(1),
|
|
||||||
tabs: vec![Tab::new("A", "a")],
|
|
||||||
focused: 3,
|
|
||||||
options: PaneOptions::default(),
|
|
||||||
collapsed: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let err = layout.validate().expect_err("layout should be invalid");
|
|
||||||
assert!(matches!(err, PersistError::FocusOutOfRange { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_layout_migrates_to_current_format() {
|
|
||||||
let legacy = LegacyPersistedPanelTree::from(&sample_tree());
|
|
||||||
let migrated = PersistedPanelTreeFile::Legacy(legacy)
|
|
||||||
.migrate()
|
|
||||||
.expect("legacy layout should migrate");
|
|
||||||
|
|
||||||
assert_eq!(migrated.version, PANEL_TREE_FORMAT_VERSION);
|
|
||||||
assert!(migrated.validate().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persisted_validation_allows_persistent_empty_leaf() {
|
|
||||||
let mut options = PaneOptions::default();
|
|
||||||
options.persist_when_empty = true;
|
|
||||||
let layout: PersistedPanelTree<&'static str> = PersistedPanelTree {
|
|
||||||
version: PANEL_TREE_FORMAT_VERSION,
|
|
||||||
next_pane_id: 2,
|
|
||||||
nodes: vec![PersistedNode::Leaf {
|
|
||||||
pane: PaneId::from_raw(1),
|
|
||||||
tabs: Vec::new(),
|
|
||||||
focused: 0,
|
|
||||||
options,
|
|
||||||
collapsed: false,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(layout.validate().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persisted_round_trip_preserves_tab_drop_policy() {
|
|
||||||
let tree = PanelTree::new(vec![
|
|
||||||
Tab::new("A", "a").with_drop_policy(TabDropPolicy {
|
|
||||||
locked_to_pane: Some(PaneId::from_raw(9)),
|
|
||||||
locked_to_role: Some(PaneRole::Terminal),
|
|
||||||
allowed_panes: Some(vec![PaneId::from_raw(9), PaneId::from_raw(10)]),
|
|
||||||
allowed_roles: Some(vec![PaneRole::Editor, PaneRole::Sidebar]),
|
|
||||||
blocked_panes: vec![PaneId::from_raw(10)],
|
|
||||||
blocked_roles: vec![PaneRole::Inspector],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
let persisted = tree.to_persisted();
|
|
||||||
let restored = PanelTree::from_persisted(persisted).expect("persistence should round-trip");
|
|
||||||
|
|
||||||
match restored.node(0) {
|
|
||||||
Node::Leaf { tabs, .. } => {
|
|
||||||
assert_eq!(tabs[0].drop_policy.locked_to_pane, Some(PaneId::from_raw(9)));
|
|
||||||
assert_eq!(tabs[0].drop_policy.locked_to_role, Some(PaneRole::Terminal));
|
|
||||||
assert_eq!(
|
|
||||||
tabs[0].drop_policy.allowed_panes,
|
|
||||||
Some(vec![PaneId::from_raw(9), PaneId::from_raw(10)])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tabs[0].drop_policy.allowed_roles,
|
|
||||||
Some(vec![PaneRole::Editor, PaneRole::Sidebar])
|
|
||||||
);
|
|
||||||
assert_eq!(tabs[0].drop_policy.blocked_panes, vec![PaneId::from_raw(10)]);
|
|
||||||
assert_eq!(tabs[0].drop_policy.blocked_roles, vec![PaneRole::Inspector]);
|
|
||||||
}
|
|
||||||
other => panic!("expected restored root leaf, got {:?}", other),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
use egui::{Color32, CornerRadius, FontFamily, FontId, Stroke, Style, TextStyle, Visuals};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct HeaderButtonStyle {
|
|
||||||
pub bg: Color32,
|
|
||||||
pub hover_bg: Color32,
|
|
||||||
pub stroke_color: Color32,
|
|
||||||
pub icon_color: Color32,
|
|
||||||
pub hover_icon_color: Color32,
|
|
||||||
pub rounding: CornerRadius,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct HeaderStyle {
|
|
||||||
pub bg: Color32,
|
|
||||||
pub border_color: Color32,
|
|
||||||
pub button: HeaderButtonStyle,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ContentStyle {
|
|
||||||
pub bg: Color32,
|
|
||||||
pub border_color: Color32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct HandleStyle {
|
|
||||||
pub color: Color32,
|
|
||||||
pub hover_color: Color32,
|
|
||||||
pub locked_color: Color32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub struct OverlayStyle {
|
|
||||||
pub fill: Color32,
|
|
||||||
pub stroke: Stroke,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct TabStateStyle {
|
|
||||||
pub bg: Color32,
|
|
||||||
pub text_color: Color32,
|
|
||||||
pub accent_color: Color32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct TabStyle {
|
|
||||||
pub active: TabStateStyle,
|
|
||||||
pub inactive: TabStateStyle,
|
|
||||||
pub hovered: TabStateStyle,
|
|
||||||
pub rounding: CornerRadius,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct TypographyStyle {
|
|
||||||
pub tab_title_font: FontId,
|
|
||||||
pub tab_icon_text_font: FontId,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
pub struct PaneStyleOverride {
|
|
||||||
pub header_bg: Option<Color32>,
|
|
||||||
pub content_bg: Option<Color32>,
|
|
||||||
pub border_color: Option<Color32>,
|
|
||||||
pub accent_color: Option<Color32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PaneStyleOverride {
|
|
||||||
pub const fn none() -> Self {
|
|
||||||
Self {
|
|
||||||
header_bg: None,
|
|
||||||
content_bg: None,
|
|
||||||
border_color: None,
|
|
||||||
accent_color: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
pub struct TabStyleOverride {
|
|
||||||
pub active_bg: Option<Color32>,
|
|
||||||
pub inactive_bg: Option<Color32>,
|
|
||||||
pub hovered_bg: Option<Color32>,
|
|
||||||
pub text_color: Option<Color32>,
|
|
||||||
pub accent_color: Option<Color32>,
|
|
||||||
/// Override colour applied only to the leading icon, not the title text.
|
|
||||||
pub icon_color: Option<Color32>,
|
|
||||||
/// Maximum header width for this tab when there is spare room.
|
|
||||||
/// `None` means the tab may stretch to fill the available width.
|
|
||||||
pub max_width: Option<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TabStyleOverride {
|
|
||||||
pub const fn none() -> Self {
|
|
||||||
Self {
|
|
||||||
active_bg: None,
|
|
||||||
inactive_bg: None,
|
|
||||||
hovered_bg: None,
|
|
||||||
text_color: None,
|
|
||||||
accent_color: None,
|
|
||||||
icon_color: None,
|
|
||||||
max_width: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All visual parameters for the panel layout system.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct PanelStyle {
|
|
||||||
pub header_height: f32,
|
|
||||||
pub collapsed_pane_thickness: f32,
|
|
||||||
pub handle_width: f32,
|
|
||||||
pub min_pane_size: f32,
|
|
||||||
pub content_inset: f32,
|
|
||||||
pub pane_rounding: CornerRadius,
|
|
||||||
pub typography: TypographyStyle,
|
|
||||||
pub header: HeaderStyle,
|
|
||||||
pub content: ContentStyle,
|
|
||||||
pub tabs: TabStyle,
|
|
||||||
pub handle: HandleStyle,
|
|
||||||
pub overlay: OverlayStyle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PanelStyle {
|
|
||||||
pub fn from_egui_style(style: &Style) -> Self {
|
|
||||||
let mut panel_style = Self::from_visuals(&style.visuals);
|
|
||||||
let button_font = style
|
|
||||||
.text_styles
|
|
||||||
.get(&TextStyle::Button)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| FontId::proportional(12.0));
|
|
||||||
let small_font = style
|
|
||||||
.text_styles
|
|
||||||
.get(&TextStyle::Small)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| FontId::monospace(11.0));
|
|
||||||
panel_style.typography = TypographyStyle {
|
|
||||||
tab_title_font: button_font,
|
|
||||||
tab_icon_text_font: small_font,
|
|
||||||
};
|
|
||||||
panel_style
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_visuals(visuals: &Visuals) -> Self {
|
|
||||||
let widgets = &visuals.widgets;
|
|
||||||
let inactive_fill = widgets.inactive.weak_bg_fill;
|
|
||||||
let hovered_fill = widgets.hovered.weak_bg_fill;
|
|
||||||
let active_fill = widgets.active.weak_bg_fill;
|
|
||||||
let accent = widgets.active.bg_fill;
|
|
||||||
let border = widgets.noninteractive.bg_stroke.color;
|
|
||||||
|
|
||||||
Self {
|
|
||||||
header_height: 26.0,
|
|
||||||
collapsed_pane_thickness: 32.0,
|
|
||||||
handle_width: 5.0,
|
|
||||||
min_pane_size: 200.0,
|
|
||||||
content_inset: 0.0,
|
|
||||||
pane_rounding: visuals.window_corner_radius,
|
|
||||||
typography: TypographyStyle {
|
|
||||||
tab_title_font: FontId::new(12.0, FontFamily::Proportional),
|
|
||||||
tab_icon_text_font: FontId::new(11.0, FontFamily::Monospace),
|
|
||||||
},
|
|
||||||
header: HeaderStyle {
|
|
||||||
bg: widgets.noninteractive.bg_fill,
|
|
||||||
border_color: border,
|
|
||||||
button: HeaderButtonStyle {
|
|
||||||
bg: inactive_fill,
|
|
||||||
hover_bg: hovered_fill,
|
|
||||||
stroke_color: border,
|
|
||||||
icon_color: widgets.inactive.fg_stroke.color,
|
|
||||||
hover_icon_color: widgets.hovered.fg_stroke.color,
|
|
||||||
rounding: CornerRadius::same(3),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
content: ContentStyle {
|
|
||||||
bg: visuals.panel_fill,
|
|
||||||
border_color: border,
|
|
||||||
},
|
|
||||||
tabs: TabStyle {
|
|
||||||
active: TabStateStyle {
|
|
||||||
bg: active_fill,
|
|
||||||
text_color: widgets.active.fg_stroke.color,
|
|
||||||
accent_color: accent,
|
|
||||||
},
|
|
||||||
inactive: TabStateStyle {
|
|
||||||
bg: inactive_fill,
|
|
||||||
text_color: widgets.inactive.fg_stroke.color,
|
|
||||||
accent_color: accent,
|
|
||||||
},
|
|
||||||
hovered: TabStateStyle {
|
|
||||||
bg: hovered_fill,
|
|
||||||
text_color: widgets.hovered.fg_stroke.color,
|
|
||||||
accent_color: accent,
|
|
||||||
},
|
|
||||||
rounding: CornerRadius::same(3),
|
|
||||||
},
|
|
||||||
handle: HandleStyle {
|
|
||||||
color: widgets.noninteractive.bg_fill,
|
|
||||||
hover_color: widgets.hovered.bg_fill,
|
|
||||||
locked_color: widgets.noninteractive.bg_fill.gamma_multiply(0.6),
|
|
||||||
},
|
|
||||||
overlay: OverlayStyle {
|
|
||||||
fill: accent.gamma_multiply(0.35),
|
|
||||||
stroke: Stroke::new(1.0, accent),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pane_header_bg(&self, pane: Option<PaneStyleOverride>) -> Color32 {
|
|
||||||
pane.and_then(|pane| pane.header_bg).unwrap_or(self.header.bg)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pane_content_bg(&self, pane: Option<PaneStyleOverride>) -> Color32 {
|
|
||||||
pane.and_then(|pane| pane.content_bg).unwrap_or(self.content.bg)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pane_border_color(&self, pane: Option<PaneStyleOverride>) -> Color32 {
|
|
||||||
pane.and_then(|pane| pane.border_color)
|
|
||||||
.unwrap_or(self.content.border_color)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pane_accent_color(&self, pane: Option<PaneStyleOverride>) -> Color32 {
|
|
||||||
pane.and_then(|pane| pane.accent_color)
|
|
||||||
.unwrap_or(self.tabs.active.accent_color)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn tab_state(
|
|
||||||
&self,
|
|
||||||
active: bool,
|
|
||||||
hovered: bool,
|
|
||||||
pane: Option<PaneStyleOverride>,
|
|
||||||
tab: Option<TabStyleOverride>,
|
|
||||||
) -> TabStateStyle {
|
|
||||||
let mut state = if active {
|
|
||||||
self.tabs.active
|
|
||||||
} else if hovered {
|
|
||||||
self.tabs.hovered
|
|
||||||
} else {
|
|
||||||
self.tabs.inactive
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(tab) = tab {
|
|
||||||
state.bg = if active {
|
|
||||||
tab.active_bg.unwrap_or(state.bg)
|
|
||||||
} else if hovered {
|
|
||||||
tab.hovered_bg.unwrap_or(state.bg)
|
|
||||||
} else {
|
|
||||||
tab.inactive_bg.unwrap_or(state.bg)
|
|
||||||
};
|
|
||||||
state.text_color = tab.text_color.unwrap_or(state.text_color);
|
|
||||||
state.accent_color = tab.accent_color.unwrap_or(state.accent_color);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(pane) = pane {
|
|
||||||
state.accent_color = pane.accent_color.unwrap_or(state.accent_color);
|
|
||||||
}
|
|
||||||
|
|
||||||
state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for PanelStyle {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
header_height: 26.0,
|
|
||||||
collapsed_pane_thickness: 32.0,
|
|
||||||
handle_width: 5.0,
|
|
||||||
min_pane_size: 200.0,
|
|
||||||
content_inset: 0.0,
|
|
||||||
pane_rounding: CornerRadius::same(2),
|
|
||||||
typography: TypographyStyle {
|
|
||||||
tab_title_font: FontId::new(12.0, FontFamily::Proportional),
|
|
||||||
tab_icon_text_font: FontId::new(11.0, FontFamily::Monospace),
|
|
||||||
},
|
|
||||||
header: HeaderStyle {
|
|
||||||
bg: Color32::from_rgb(30, 30, 35),
|
|
||||||
border_color: Color32::from_rgb(45, 45, 55),
|
|
||||||
button: HeaderButtonStyle {
|
|
||||||
bg: Color32::from_rgb(35, 35, 42),
|
|
||||||
hover_bg: Color32::from_rgb(50, 50, 60),
|
|
||||||
stroke_color: Color32::from_rgb(45, 45, 55),
|
|
||||||
icon_color: Color32::from_rgb(210, 210, 220),
|
|
||||||
hover_icon_color: Color32::from_rgb(80, 130, 220),
|
|
||||||
rounding: CornerRadius::same(3),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
content: ContentStyle {
|
|
||||||
bg: Color32::from_rgb(27, 27, 31),
|
|
||||||
border_color: Color32::from_rgb(45, 45, 55),
|
|
||||||
},
|
|
||||||
tabs: TabStyle {
|
|
||||||
active: TabStateStyle {
|
|
||||||
bg: Color32::from_rgb(50, 50, 60),
|
|
||||||
text_color: Color32::from_rgb(210, 210, 220),
|
|
||||||
accent_color: Color32::from_rgb(80, 130, 220),
|
|
||||||
},
|
|
||||||
inactive: TabStateStyle {
|
|
||||||
bg: Color32::from_rgb(35, 35, 42),
|
|
||||||
text_color: Color32::from_rgb(210, 210, 220),
|
|
||||||
accent_color: Color32::from_rgb(80, 130, 220),
|
|
||||||
},
|
|
||||||
hovered: TabStateStyle {
|
|
||||||
bg: Color32::from_rgb(42, 42, 50),
|
|
||||||
text_color: Color32::from_rgb(220, 220, 228),
|
|
||||||
accent_color: Color32::from_rgb(80, 130, 220),
|
|
||||||
},
|
|
||||||
rounding: CornerRadius::same(3),
|
|
||||||
},
|
|
||||||
handle: HandleStyle {
|
|
||||||
color: Color32::from_rgb(45, 45, 55),
|
|
||||||
hover_color: Color32::from_rgb(80, 110, 180),
|
|
||||||
locked_color: Color32::from_rgb(34, 34, 40),
|
|
||||||
},
|
|
||||||
overlay: OverlayStyle {
|
|
||||||
fill: Color32::from_rgba_premultiplied(80, 110, 180, 60),
|
|
||||||
stroke: Stroke::new(1.0, Color32::from_rgb(80, 130, 220)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tab_state_prefers_overrides() {
|
|
||||||
let style = PanelStyle::default();
|
|
||||||
let pane = PaneStyleOverride {
|
|
||||||
accent_color: Some(Color32::from_rgb(1, 2, 3)),
|
|
||||||
..PaneStyleOverride::none()
|
|
||||||
};
|
|
||||||
let tab = TabStyleOverride {
|
|
||||||
active_bg: Some(Color32::from_rgb(4, 5, 6)),
|
|
||||||
text_color: Some(Color32::from_rgb(7, 8, 9)),
|
|
||||||
max_width: Some(120.0),
|
|
||||||
..TabStyleOverride::none()
|
|
||||||
};
|
|
||||||
|
|
||||||
let state = style.tab_state(true, false, Some(pane), Some(tab));
|
|
||||||
assert_eq!(state.bg, Color32::from_rgb(4, 5, 6));
|
|
||||||
assert_eq!(state.text_color, Color32::from_rgb(7, 8, 9));
|
|
||||||
assert_eq!(state.accent_color, Color32::from_rgb(1, 2, 3));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_visuals_uses_widget_palette() {
|
|
||||||
let visuals = Visuals::dark();
|
|
||||||
let style = PanelStyle::from_visuals(&visuals);
|
|
||||||
assert_eq!(style.content.bg, visuals.panel_fill);
|
|
||||||
assert_eq!(
|
|
||||||
style.header.button.icon_color,
|
|
||||||
visuals.widgets.inactive.fg_stroke.color
|
|
||||||
);
|
|
||||||
assert_eq!(style.overlay.stroke.color, visuals.widgets.active.bg_fill);
|
|
||||||
assert_eq!(style.typography.tab_title_font, FontId::proportional(12.0));
|
|
||||||
assert_eq!(style.typography.tab_icon_text_font, FontId::monospace(11.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_egui_style_uses_button_and_small_text_styles() {
|
|
||||||
let mut egui_style = Style::default();
|
|
||||||
egui_style
|
|
||||||
.text_styles
|
|
||||||
.insert(TextStyle::Button, FontId::proportional(16.0));
|
|
||||||
egui_style
|
|
||||||
.text_styles
|
|
||||||
.insert(TextStyle::Small, FontId::monospace(13.0));
|
|
||||||
|
|
||||||
let style = PanelStyle::from_egui_style(&egui_style);
|
|
||||||
assert_eq!(style.typography.tab_title_font, FontId::proportional(16.0));
|
|
||||||
assert_eq!(style.typography.tab_icon_text_font, FontId::monospace(13.0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
style::TabStyleOverride,
|
|
||||||
tree::{PaneId, PaneRole},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Per-tab drop constraints evaluated against the destination pane.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
pub struct TabDropPolicy {
|
|
||||||
/// If set, this tab may only be dropped into the given pane.
|
|
||||||
pub locked_to_pane: Option<PaneId>,
|
|
||||||
/// If set, this tab may only be dropped into panes with the given role.
|
|
||||||
pub locked_to_role: Option<PaneRole>,
|
|
||||||
/// If set, this tab may only be dropped into panes in this allow-list.
|
|
||||||
pub allowed_panes: Option<Vec<PaneId>>,
|
|
||||||
/// If set, this tab may only be dropped into panes with roles in this allow-list.
|
|
||||||
pub allowed_roles: Option<Vec<PaneRole>>,
|
|
||||||
/// Panes in this block-list reject this tab even if otherwise allowed.
|
|
||||||
pub blocked_panes: Vec<PaneId>,
|
|
||||||
/// Roles in this block-list reject this tab even if otherwise allowed.
|
|
||||||
pub blocked_roles: Vec<PaneRole>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TabDropPolicy {
|
|
||||||
/// Return whether this tab may be dropped into the given pane target.
|
|
||||||
///
|
|
||||||
/// Pane identity and semantic role are evaluated together. This lets
|
|
||||||
/// applications express either "only this pane instance" or "any pane with
|
|
||||||
/// this role" style rules.
|
|
||||||
pub fn allows_target(&self, pane_id: PaneId, role: Option<PaneRole>) -> bool {
|
|
||||||
if let Some(locked_to_pane) = self.locked_to_pane {
|
|
||||||
return locked_to_pane == pane_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(locked_to_role) = self.locked_to_role {
|
|
||||||
return role == Some(locked_to_role);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(allowed_panes) = &self.allowed_panes {
|
|
||||||
if !allowed_panes.contains(&pane_id) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(allowed_roles) = &self.allowed_roles {
|
|
||||||
if !role.is_some_and(|role| allowed_roles.contains(&role)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.blocked_panes.contains(&pane_id) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
!role.is_some_and(|role| self.blocked_roles.contains(&role))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TabDropPolicy {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
locked_to_pane: None,
|
|
||||||
locked_to_role: None,
|
|
||||||
allowed_panes: None,
|
|
||||||
allowed_roles: None,
|
|
||||||
blocked_panes: Vec::new(),
|
|
||||||
blocked_roles: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Leading tab icon content rendered before the tab title.
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
pub enum TabIcon {
|
|
||||||
/// Short text marker such as ASCII tokens or compact glyphs.
|
|
||||||
Text(String),
|
|
||||||
/// Texture-backed image icon with an explicit display size.
|
|
||||||
Texture {
|
|
||||||
texture_id: egui::TextureId,
|
|
||||||
size: egui::Vec2,
|
|
||||||
},
|
|
||||||
/// Single Unicode glyph rendered centred, with no trailing title space.
|
|
||||||
CenteredGlyph(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single tab that lives inside a pane.
|
|
||||||
///
|
|
||||||
/// `T` is the caller-supplied identifier — must be `Clone + 'static`.
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
pub struct Tab<T: Clone + 'static> {
|
|
||||||
pub title: String,
|
|
||||||
pub id: T,
|
|
||||||
/// Optional leading icon rendered before the title in tab headers.
|
|
||||||
pub icon: Option<TabIcon>,
|
|
||||||
/// Whether the tab may be dragged to another pane.
|
|
||||||
pub draggable: bool,
|
|
||||||
/// Per-tab destination constraints evaluated during drag-and-drop.
|
|
||||||
pub drop_policy: TabDropPolicy,
|
|
||||||
/// Whether the tab may be closed from the header UI.
|
|
||||||
pub closable: bool,
|
|
||||||
/// Optional visual overrides for this tab.
|
|
||||||
pub style_override: Option<TabStyleOverride>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Clone + 'static> Tab<T> {
|
|
||||||
/// Create a new tab with a title and caller-owned identifier.
|
|
||||||
pub fn new(title: impl Into<String>, id: T) -> Self {
|
|
||||||
Self {
|
|
||||||
title: title.into(),
|
|
||||||
id,
|
|
||||||
icon: None,
|
|
||||||
draggable: true,
|
|
||||||
drop_policy: TabDropPolicy::default(),
|
|
||||||
closable: false,
|
|
||||||
style_override: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set a short text marker as the leading tab visual.
|
|
||||||
pub fn with_leading_visual(mut self, leading_visual: impl Into<String>) -> Self {
|
|
||||||
self.icon = Some(TabIcon::Text(leading_visual.into()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set a single centred glyph as the only tab visual.
|
|
||||||
pub fn with_centered_glyph(mut self, glyph: impl Into<String>) -> Self {
|
|
||||||
self.icon = Some(TabIcon::CenteredGlyph(glyph.into()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the leading icon payload directly.
|
|
||||||
pub fn with_icon(mut self, icon: TabIcon) -> Self {
|
|
||||||
self.icon = Some(icon);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set a texture-backed leading icon.
|
|
||||||
pub fn with_icon_texture(mut self, texture_id: egui::TextureId, size: egui::Vec2) -> Self {
|
|
||||||
self.icon = Some(TabIcon::Texture { texture_id, size });
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Control whether this tab may be dragged at all.
|
|
||||||
pub fn with_draggable(mut self, draggable: bool) -> Self {
|
|
||||||
self.draggable = draggable;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace the full tab drop-policy payload.
|
|
||||||
pub fn with_drop_policy(mut self, drop_policy: TabDropPolicy) -> Self {
|
|
||||||
self.drop_policy = drop_policy;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lock the tab to a specific pane instance.
|
|
||||||
pub fn with_locked_pane(mut self, pane_id: PaneId) -> Self {
|
|
||||||
self.drop_policy.locked_to_pane = Some(pane_id);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lock the tab to panes with a specific semantic role.
|
|
||||||
pub fn with_locked_role(mut self, role: PaneRole) -> Self {
|
|
||||||
self.drop_policy.locked_to_role = Some(role);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restrict the tab to a set of pane identifiers.
|
|
||||||
pub fn with_allowed_drop_panes(
|
|
||||||
mut self,
|
|
||||||
allowed_panes: impl IntoIterator<Item = PaneId>,
|
|
||||||
) -> Self {
|
|
||||||
self.drop_policy.allowed_panes = Some(allowed_panes.into_iter().collect());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reject drops into a set of pane identifiers.
|
|
||||||
pub fn with_blocked_drop_panes(
|
|
||||||
mut self,
|
|
||||||
blocked_panes: impl IntoIterator<Item = PaneId>,
|
|
||||||
) -> Self {
|
|
||||||
self.drop_policy.blocked_panes = blocked_panes.into_iter().collect();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restrict the tab to a set of pane roles.
|
|
||||||
pub fn with_allowed_drop_roles(
|
|
||||||
mut self,
|
|
||||||
allowed_roles: impl IntoIterator<Item = PaneRole>,
|
|
||||||
) -> Self {
|
|
||||||
self.drop_policy.allowed_roles = Some(allowed_roles.into_iter().collect());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reject drops into panes with a set of semantic roles.
|
|
||||||
pub fn with_blocked_drop_roles(
|
|
||||||
mut self,
|
|
||||||
blocked_roles: impl IntoIterator<Item = PaneRole>,
|
|
||||||
) -> Self {
|
|
||||||
self.drop_policy.blocked_roles = blocked_roles.into_iter().collect();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Control whether the tab may be closed from the built-in header UI.
|
|
||||||
pub fn with_closable(mut self, closable: bool) -> Self {
|
|
||||||
self.closable = closable;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply a per-tab style override used by the built-in header renderer.
|
|
||||||
pub fn with_style_override(mut self, style_override: TabStyleOverride) -> Self {
|
|
||||||
self.style_override = Some(style_override);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user