This commit is contained in:
2026-07-09 02:59:53 +03:00
commit 7ace048d94
817 changed files with 234289 additions and 0 deletions
+396
View File
@@ -0,0 +1,396 @@
# 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 = "2024"
rust-version = "1.92"
name = "egui-winit"
version = "0.34.3"
authors = ["Emil Ernerfeldt <emil.ernerfeldt@gmail.com>"]
build = false
include = [
"../../LICENSE-APACHE",
"../../LICENSE-MIT",
"**/*.rs",
"Cargo.toml",
]
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Bindings for using egui with winit"
homepage = "https://github.com/emilk/egui/tree/main/crates/egui-winit"
readme = "README.md"
keywords = [
"winit",
"egui",
"gui",
"gamedev",
]
categories = [
"gui",
"game-development",
]
license = "MIT OR Apache-2.0"
repository = "https://github.com/emilk/egui/tree/main/crates/egui-winit"
resolver = "2"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--generate-link-to-definition"]
[features]
accesskit = ["dep:accesskit_winit"]
android-game-activity = ["winit/android-game-activity"]
android-native-activity = ["winit/android-native-activity"]
bytemuck = [
"egui/bytemuck",
"dep:bytemuck",
]
clipboard = [
"arboard",
"bytemuck",
"smithay-clipboard",
]
default = [
"clipboard",
"links",
"wayland",
"winit/default",
"x11",
]
links = ["webbrowser"]
serde = [
"egui/serde",
"dep:serde",
]
wayland = [
"winit/wayland",
"bytemuck",
]
x11 = [
"winit/x11",
"bytemuck",
]
[lib]
name = "egui_winit"
path = "src/lib.rs"
[dependencies.accesskit_winit]
version = "0.32.0"
optional = true
[dependencies.bytemuck]
version = "1.24.0"
optional = true
[dependencies.document-features]
version = "0.2.11"
optional = true
[dependencies.egui]
version = "0.34.3"
default-features = false
[dependencies.log]
version = "0.4.28"
features = ["std"]
[dependencies.profiling]
version = "1.0.17"
default-features = false
[dependencies.raw-window-handle]
version = "0.6.2"
[dependencies.serde]
version = "1.0.228"
features = ["derive"]
optional = true
[dependencies.web-time]
version = "1.1.0"
[dependencies.webbrowser]
version = "1.0.5"
optional = true
[dependencies.winit]
version = "0.30.13"
default-features = false
[target.'cfg(any(target_os="linux", target_os="dragonfly", target_os="freebsd", target_os="netbsd", target_os="openbsd"))'.dependencies.smithay-clipboard]
version = "0.7.2"
optional = true
[target.'cfg(any(target_os="linux", target_os="dragonfly", target_os="freebsd", target_os="netbsd", target_os="openbsd"))'.dependencies.wayland-cursor]
version = "0.31.11"
optional = true
default-features = false
[target.'cfg(not(target_os = "android"))'.dependencies.arboard]
version = "3.6.1"
features = ["image-data"]
optional = true
default-features = false
[target.'cfg(target_os = "ios")'.dependencies.objc2]
version = "0.6.4"
[target.'cfg(target_os = "ios")'.dependencies.objc2-foundation]
version = "0.3.2"
features = [
"std",
"NSThread",
]
default-features = false
[target.'cfg(target_os = "ios")'.dependencies.objc2-ui-kit]
version = "0.3.2"
features = [
"std",
"objc2-core-foundation",
"UIApplication",
"UIGeometry",
"UIResponder",
"UIScene",
"UISceneDefinitions",
"UIView",
"UIWindow",
"UIWindowScene",
]
default-features = false
[lints.clippy]
allow_attributes = "warn"
as_ptr_cast_mut = "warn"
assigning_clones = "allow"
await_holding_lock = "warn"
bool_to_int_with_if = "warn"
branches_sharing_code = "warn"
cast_possible_wrap = "allow"
char_lit_as_u8 = "warn"
checked_conversions = "warn"
clear_with_drain = "warn"
clone_on_ref_ptr = "warn"
cloned_instead_of_copied = "warn"
coerce_container_to_any = "warn"
comparison_chain = "allow"
dbg_macro = "warn"
debug_assert_with_mut_call = "warn"
default_union_representation = "warn"
derive_partial_eq_without_eq = "warn"
disallowed_macros = "warn"
disallowed_methods = "warn"
disallowed_names = "warn"
disallowed_script_idents = "warn"
disallowed_types = "warn"
doc_broken_link = "warn"
doc_comment_double_space_linebreaks = "warn"
doc_include_without_cfg = "warn"
doc_link_with_quotes = "warn"
doc_markdown = "warn"
elidable_lifetime_names = "warn"
empty_enum = "warn"
empty_enum_variants_with_brackets = "warn"
empty_line_after_outer_attr = "warn"
enum_glob_use = "warn"
equatable_if_let = "warn"
exit = "warn"
expl_impl_clone_on_copy = "warn"
explicit_deref_methods = "warn"
explicit_into_iter_loop = "warn"
explicit_iter_loop = "warn"
fallible_impl_from = "warn"
filter_map_next = "warn"
flat_map_option = "warn"
float_cmp_const = "warn"
fn_params_excessive_bools = "warn"
fn_to_numeric_cast_any = "warn"
from_iter_instead_of_collect = "warn"
get_unwrap = "warn"
if_let_mutex = "warn"
ignore_without_reason = "warn"
implicit_clone = "warn"
implied_bounds_in_impls = "warn"
imprecise_flops = "warn"
inconsistent_struct_constructor = "warn"
index_refutable_slice = "warn"
inefficient_to_string = "warn"
infinite_loop = "warn"
into_iter_without_iter = "warn"
invalid_upcast_comparisons = "warn"
ip_constant = "warn"
iter_filter_is_ok = "warn"
iter_filter_is_some = "warn"
iter_not_returning_iterator = "warn"
iter_on_empty_collections = "warn"
iter_on_single_items = "warn"
iter_over_hash_type = "warn"
iter_without_into_iter = "warn"
large_digit_groups = "warn"
large_futures = "warn"
large_include_file = "warn"
large_stack_arrays = "warn"
large_stack_frames = "warn"
large_types_passed_by_value = "warn"
let_underscore_must_use = "allow"
let_underscore_untyped = "allow"
let_unit_value = "warn"
linkedlist = "warn"
literal_string_with_formatting_args = "warn"
lossy_float_literal = "warn"
macro_use_imports = "warn"
manual_assert = "warn"
manual_clamp = "warn"
manual_instant_elapsed = "warn"
manual_is_power_of_two = "warn"
manual_is_variant_and = "warn"
manual_let_else = "warn"
manual_midpoint = "warn"
manual_ok_or = "warn"
manual_range_contains = "allow"
manual_string_new = "warn"
map_err_ignore = "warn"
map_flatten = "warn"
map_unwrap_or = "allow"
match_bool = "warn"
match_same_arms = "warn"
match_wild_err_arm = "warn"
match_wildcard_for_single_variants = "warn"
mem_forget = "warn"
mismatching_type_param_order = "warn"
missing_assert_message = "warn"
missing_enforced_import_renames = "warn"
missing_errors_doc = "warn"
missing_safety_doc = "warn"
mixed_attributes_style = "warn"
mut_mut = "warn"
mutex_integer = "warn"
needless_borrow = "warn"
needless_continue = "warn"
needless_for_each = "warn"
needless_pass_by_ref_mut = "warn"
needless_pass_by_value = "warn"
negative_feature_names = "warn"
non_std_lazy_statics = "warn"
non_zero_suggestions = "warn"
nonstandard_macro_braces = "warn"
option_as_ref_cloned = "warn"
option_option = "warn"
or_fun_call = "warn"
path_buf_push_overwrite = "warn"
pathbuf_init_then_push = "warn"
precedence_bits = "warn"
print_stderr = "warn"
print_stdout = "warn"
ptr_as_ptr = "warn"
ptr_cast_constness = "warn"
pub_underscore_fields = "warn"
pub_without_shorthand = "warn"
rc_mutex = "warn"
readonly_write_lock = "warn"
redundant_type_annotations = "warn"
ref_as_ptr = "warn"
ref_option_ref = "warn"
ref_patterns = "warn"
rest_pat_in_fully_bound_structs = "warn"
return_and_then = "warn"
same_functions_in_if_condition = "warn"
self_named_module_files = "allow"
self_only_used_in_recursion = "warn"
semicolon_if_nothing_returned = "warn"
set_contains_or_insert = "warn"
should_panic_without_expect = "allow"
significant_drop_tightening = "allow"
single_char_pattern = "warn"
single_match_else = "warn"
single_option_map = "warn"
str_split_at_newline = "warn"
str_to_string = "warn"
string_add = "warn"
string_add_assign = "warn"
string_lit_as_bytes = "warn"
string_lit_chars_any = "warn"
suspicious_command_arg_space = "warn"
suspicious_xor_used_as_pow = "warn"
todo = "warn"
too_long_first_doc_paragraph = "warn"
too_many_lines = "allow"
trailing_empty_array = "warn"
trait_duplication_in_bounds = "warn"
transmute_ptr_to_ptr = "warn"
tuple_array_conversions = "warn"
unchecked_time_subtraction = "warn"
undocumented_unsafe_blocks = "warn"
unimplemented = "warn"
uninhabited_references = "warn"
uninlined_format_args = "warn"
unnecessary_box_returns = "warn"
unnecessary_debug_formatting = "warn"
unnecessary_literal_bound = "warn"
unnecessary_safety_comment = "warn"
unnecessary_safety_doc = "warn"
unnecessary_self_imports = "warn"
unnecessary_semicolon = "warn"
unnecessary_struct_initialization = "warn"
unnecessary_wraps = "warn"
unnested_or_patterns = "warn"
unused_async = "warn"
unused_peekable = "warn"
unused_rounding = "warn"
unused_self = "warn"
unused_trait_names = "warn"
unwrap_used = "warn"
use_self = "warn"
useless_let_if_seq = "warn"
useless_transmute = "warn"
verbose_file_reads = "warn"
wildcard_dependencies = "warn"
wildcard_imports = "allow"
zero_sized_map_values = "warn"
[lints.clippy.all]
level = "warn"
priority = -1
[lints.rust]
elided_lifetimes_in_paths = "warn"
rust_2021_prelude_collisions = "warn"
semicolon_in_expressions_from_macros = "warn"
trivial_casts = "allow"
trivial_numeric_casts = "warn"
unexpected_cfgs = "warn"
unsafe_code = "deny"
unsafe_op_in_unsafe_fn = "warn"
unused_extern_crates = "warn"
unused_import_braces = "warn"
unused_lifetimes = "warn"
unused_qualifications = "allow"
[lints.rust.future_incompatible]
level = "warn"
priority = -1
[lints.rust.nonstandard_style]
level = "warn"
priority = -1
[lints.rust.rust_2018_idioms]
level = "warn"
priority = -1
[lints.rustdoc]
all = "warn"
broken_intra_doc_links = "warn"
missing_crate_level_docs = "warn"
+194
View File
@@ -0,0 +1,194 @@
use raw_window_handle::RawDisplayHandle;
/// Handles interfacing with the OS clipboard.
///
/// If the "clipboard" feature is off, or we cannot connect to the OS clipboard,
/// then a fallback clipboard that just works within the same app is used instead.
pub struct Clipboard {
#[cfg(all(
not(any(target_os = "android", target_os = "ios")),
feature = "arboard",
))]
arboard: Option<arboard::Clipboard>,
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
),
feature = "smithay-clipboard"
))]
smithay: Option<smithay_clipboard::Clipboard>,
/// Fallback manual clipboard.
clipboard: String,
}
impl Clipboard {
/// Construct a new instance
pub fn new(_raw_display_handle: Option<RawDisplayHandle>) -> Self {
Self {
#[cfg(all(
not(any(target_os = "android", target_os = "ios")),
feature = "arboard",
))]
arboard: init_arboard(),
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
),
feature = "smithay-clipboard"
))]
smithay: init_smithay_clipboard(_raw_display_handle),
clipboard: Default::default(),
}
}
pub fn get(&mut self) -> Option<String> {
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
),
feature = "smithay-clipboard"
))]
if let Some(clipboard) = &mut self.smithay {
match clipboard.load() {
Ok(text) => return Some(text),
Err(err) => {
log::error!("smithay paste error: {err}");
}
}
}
#[cfg(all(
not(any(target_os = "android", target_os = "ios")),
feature = "arboard",
))]
if let Some(clipboard) = &mut self.arboard {
return match clipboard.get_text() {
Ok(text) => Some(text),
Err(err) => {
log::error!("arboard paste error: {err}");
None
}
};
}
Some(self.clipboard.clone())
}
pub fn set_text(&mut self, text: String) {
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
),
feature = "smithay-clipboard"
))]
if let Some(clipboard) = &mut self.smithay {
clipboard.store(text);
return;
}
#[cfg(all(
not(any(target_os = "android", target_os = "ios")),
feature = "arboard",
))]
if let Some(clipboard) = &mut self.arboard {
if let Err(err) = clipboard.set_text(text) {
log::error!("arboard copy/cut error: {err}");
}
return;
}
self.clipboard = text;
}
pub fn set_image(&mut self, image: &egui::ColorImage) {
#[cfg(all(
not(any(target_os = "android", target_os = "ios")),
feature = "arboard",
))]
if let Some(clipboard) = &mut self.arboard {
if let Err(err) = clipboard.set_image(arboard::ImageData {
width: image.width(),
height: image.height(),
bytes: std::borrow::Cow::Borrowed(bytemuck::cast_slice(&image.pixels)),
}) {
log::error!("arboard copy/cut error: {err}");
}
log::debug!("Copied image to clipboard");
return;
}
log::error!(
"Copying images is not supported. Enable the 'clipboard' feature of `egui-winit` to enable it."
);
_ = image;
}
}
#[cfg(all(
not(any(target_os = "android", target_os = "ios")),
feature = "arboard",
))]
fn init_arboard() -> Option<arboard::Clipboard> {
profiling::function_scope!();
log::trace!("Initializing arboard clipboard…");
match arboard::Clipboard::new() {
Ok(clipboard) => Some(clipboard),
Err(err) => {
log::warn!("Failed to initialize arboard clipboard: {err}");
None
}
}
}
#[cfg(all(
any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
),
feature = "smithay-clipboard"
))]
fn init_smithay_clipboard(
raw_display_handle: Option<RawDisplayHandle>,
) -> Option<smithay_clipboard::Clipboard> {
#![expect(clippy::undocumented_unsafe_blocks)]
profiling::function_scope!();
if let Some(RawDisplayHandle::Wayland(display)) = raw_display_handle {
log::trace!("Initializing smithay clipboard…");
#[expect(unsafe_code)]
Some(unsafe { smithay_clipboard::Clipboard::new(display.display.as_ptr()) })
} else {
#[cfg(feature = "wayland")]
log::debug!("Cannot init smithay clipboard without a Wayland display handle");
#[cfg(not(feature = "wayland"))]
log::debug!(
"Cannot init smithay clipboard: the 'wayland' feature of 'egui-winit' is not enabled"
);
None
}
}
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
#[cfg(target_os = "ios")]
pub use ios::get_safe_area_insets;
#[cfg(target_os = "ios")]
mod ios {
use egui::{SafeAreaInsets, epaint::MarginF32};
use objc2::{ClassType, rc::Retained};
use objc2_foundation::{MainThreadMarker, NSObjectProtocol};
use objc2_ui_kit::{UIApplication, UISceneActivationState, UIWindowScene};
/// Gets the ios safe area insets.
///
/// A safe area defines the area within a view that isnt covered by a navigation bar, tab bar,
/// toolbar, or other views a window might provide. Safe areas are essential for avoiding a
/// devices interactive and display features, like Dynamic Island on iPhone or the camera
/// housing on some Mac models.
///
/// Once winit v0.31 has been released this can be removed in favor of
/// `winit::Window::safe_area`.
pub fn get_safe_area_insets() -> SafeAreaInsets {
let Some(main_thread_marker) = MainThreadMarker::new() else {
log::error!("Getting safe area insets needs to be performed on the main thread");
return SafeAreaInsets::default();
};
let app = UIApplication::sharedApplication(main_thread_marker);
#[expect(unsafe_code)]
unsafe {
// Look for the first window scene that's in the foreground
for scene in app.connectedScenes() {
if scene.isKindOfClass(UIWindowScene::class())
&& matches!(
scene.activationState(),
UISceneActivationState::ForegroundActive
| UISceneActivationState::ForegroundInactive
)
{
// SAFETY: class kind was checked above with `isKindOfClass`
let window_scene = Retained::cast_unchecked::<UIWindowScene>(scene.clone());
if let Some(window) = window_scene.keyWindow() {
let insets = window.safeAreaInsets();
return SafeAreaInsets(MarginF32 {
top: insets.top as f32,
left: insets.left as f32,
right: insets.right as f32,
bottom: insets.bottom as f32,
});
}
}
}
}
SafeAreaInsets::default()
}
}
+216
View File
@@ -0,0 +1,216 @@
use egui::ViewportBuilder;
/// Can be used to store native window settings (position and size).
#[derive(Clone, Copy, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct WindowSettings {
/// Position of window content in physical pixels.
inner_position_pixels: Option<egui::Pos2>,
/// Position of window frame/titlebar in physical pixels.
outer_position_pixels: Option<egui::Pos2>,
fullscreen: bool,
maximized: bool,
/// Inner size of window in logical pixels
inner_size_points: Option<egui::Vec2>,
}
impl WindowSettings {
pub fn from_window(egui_zoom_factor: f32, window: &winit::window::Window) -> Self {
let inner_size_points = window
.inner_size()
.to_logical::<f32>(egui_zoom_factor as f64 * window.scale_factor());
let inner_position_pixels = window
.inner_position()
.ok()
.map(|p| egui::pos2(p.x as f32, p.y as f32));
let outer_position_pixels = window
.outer_position()
.ok()
.map(|p| egui::pos2(p.x as f32, p.y as f32));
Self {
inner_position_pixels,
outer_position_pixels,
fullscreen: window.fullscreen().is_some(),
maximized: window.is_maximized(),
inner_size_points: Some(egui::vec2(
inner_size_points.width,
inner_size_points.height,
)),
}
}
pub fn inner_size_points(&self) -> Option<egui::Vec2> {
self.inner_size_points
}
pub fn initialize_viewport_builder(
&self,
egui_zoom_factor: f32,
event_loop: &winit::event_loop::ActiveEventLoop,
mut viewport_builder: ViewportBuilder,
) -> ViewportBuilder {
profiling::function_scope!();
// `WindowBuilder::with_position` expects inner position in Macos, and outer position elsewhere
// See [`winit::window::WindowBuilder::with_position`] for details.
let pos_px = if cfg!(target_os = "macos") {
self.inner_position_pixels
} else {
self.outer_position_pixels
};
if let Some(pos) = pos_px {
let monitor_scale_factor = if let Some(inner_size_points) = self.inner_size_points {
find_active_monitor(egui_zoom_factor, event_loop, inner_size_points, &pos)
.map_or(1.0, |monitor| monitor.scale_factor() as f32)
} else {
1.0
};
let scaled_pos = pos / (egui_zoom_factor * monitor_scale_factor);
viewport_builder = viewport_builder.with_position(scaled_pos);
}
if let Some(inner_size_points) = self.inner_size_points {
viewport_builder = viewport_builder
.with_inner_size(inner_size_points)
.with_fullscreen(self.fullscreen)
.with_maximized(self.maximized);
}
viewport_builder
}
pub fn initialize_window(&self, window: &winit::window::Window) {
if cfg!(target_os = "macos") {
// Mac sometimes has problems restoring the window to secondary monitors
// using only `WindowBuilder::with_position`, so we need this extra step:
if let Some(pos) = self.outer_position_pixels {
window.set_outer_position(winit::dpi::PhysicalPosition { x: pos.x, y: pos.y });
}
}
}
pub fn clamp_size_to_sane_values(&mut self, largest_monitor_size_points: egui::Vec2) {
use egui::NumExt as _;
if let Some(size) = &mut self.inner_size_points {
// Prevent ridiculously small windows:
let min_size = egui::Vec2::splat(64.0);
*size = size.at_least(min_size);
// Make sure we don't try to create a window larger than the largest monitor
// because on Linux that can lead to a crash.
*size = size.at_most(largest_monitor_size_points);
}
}
pub fn clamp_position_to_monitors(
&mut self,
egui_zoom_factor: f32,
event_loop: &winit::event_loop::ActiveEventLoop,
) {
// If the app last ran on two monitors and only one is now connected, then
// the given position is invalid.
// If this happens on Mac, the window is clamped into valid area.
// If this happens on Windows, the window becomes invisible to the user 🤦‍♂️
// So on Windows we clamp the position to the monitor it is on.
if !cfg!(target_os = "windows") {
return;
}
let Some(inner_size_points) = self.inner_size_points else {
return;
};
if let Some(pos_px) = &mut self.inner_position_pixels {
clamp_pos_to_monitors(egui_zoom_factor, event_loop, inner_size_points, pos_px);
}
if let Some(pos_px) = &mut self.outer_position_pixels {
clamp_pos_to_monitors(egui_zoom_factor, event_loop, inner_size_points, pos_px);
}
}
}
fn find_active_monitor(
egui_zoom_factor: f32,
event_loop: &winit::event_loop::ActiveEventLoop,
window_size_pts: egui::Vec2,
position_px: &egui::Pos2,
) -> Option<winit::monitor::MonitorHandle> {
profiling::function_scope!();
let monitors = event_loop.available_monitors();
// default to primary monitor, in case the correct monitor was disconnected.
let Some(mut active_monitor) = event_loop
.primary_monitor()
.or_else(|| event_loop.available_monitors().next())
else {
return None; // no monitors 🤷
};
for monitor in monitors {
let window_size_px = window_size_pts * (egui_zoom_factor * monitor.scale_factor() as f32);
let monitor_x_range = (monitor.position().x - window_size_px.x as i32)
..(monitor.position().x + monitor.size().width as i32);
let monitor_y_range = (monitor.position().y - window_size_px.y as i32)
..(monitor.position().y + monitor.size().height as i32);
if monitor_x_range.contains(&(position_px.x as i32))
&& monitor_y_range.contains(&(position_px.y as i32))
{
active_monitor = monitor;
}
}
Some(active_monitor)
}
fn clamp_pos_to_monitors(
egui_zoom_factor: f32,
event_loop: &winit::event_loop::ActiveEventLoop,
window_size_pts: egui::Vec2,
position_px: &mut egui::Pos2,
) {
profiling::function_scope!();
let Some(active_monitor) =
find_active_monitor(egui_zoom_factor, event_loop, window_size_pts, position_px)
else {
return; // no monitors 🤷
};
let mut window_size_px =
window_size_pts * (egui_zoom_factor * active_monitor.scale_factor() as f32);
// Add size of title bar. This is 32 px by default in Win 10/11.
if cfg!(target_os = "windows") {
window_size_px += egui::Vec2::new(
0.0,
32.0 * egui_zoom_factor * active_monitor.scale_factor() as f32,
);
}
let monitor_position = egui::Pos2::new(
active_monitor.position().x as f32,
active_monitor.position().y as f32,
);
let monitor_size_px = egui::Vec2::new(
active_monitor.size().width as f32,
active_monitor.size().height as f32,
);
// Window size cannot be negative or the subsequent `clamp` will panic.
let window_size = (monitor_size_px - window_size_px).max(egui::Vec2::ZERO);
// To get the maximum position, we get the rightmost corner of the display, then
// subtract the size of the window to get the bottom right most value window.position
// can have.
*position_px = position_px.clamp(monitor_position, monitor_position + window_size);
}
+1
View File
@@ -0,0 +1 @@
{"v":1}
+6
View File
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "a49454b6028db991f498a181c2ebc1ac416ae9e6"
},
"path_in_vcs": ""
}
+59
View File
@@ -0,0 +1,59 @@
name: Publish Crate
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check tag matches Cargo.toml version
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
CARGO_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
test "$TAG_VERSION" = "$CARGO_VERSION"
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Dry run publish
run: cargo publish --dry-run
- name: Publish to crates.io
run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- name: Check for changelog
id: changelog_file
run: |
if [ -f CHANGELOG.md ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
fi
- name: Get changelog entry
id: changelog
if: steps.changelog_file.outputs.present == 'true'
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
echo "body<<EOF" >> "$GITHUB_OUTPUT"
awk "/^## \[${TAG_VERSION}\]/{found=1; next} found && /^## /{exit} found{print}" CHANGELOG.md >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body: ${{ steps.changelog.outputs.body }}
+22
View File
@@ -0,0 +1,22 @@
/target
**/target
book/build/
# Cargo lockfiles are usually omitted for library-style crates and examples here.
/Cargo.lock
/examples/qt_viewport/Cargo.lock
# Local editor and OS noise.
.DS_Store
.idea/
.vscode/
.direnv/
*.log
*.swp
*.swo
*.user
*.qtc.user
docs/
settings.local.json
+84
View File
@@ -0,0 +1,84 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
name = "grimdock"
version = "0.2.0"
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Dockable panel layout system for egui"
readme = "README.md"
license = "MIT OR Apache-2.0"
[features]
default = []
serde = [
"dep:serde",
"egui/serde",
]
[lib]
name = "grimdock"
path = "src/lib.rs"
[[example]]
name = "1_basic"
path = "examples/1_basic.rs"
[[example]]
name = "2_split_layout"
path = "examples/2_split_layout.rs"
[[example]]
name = "3_header_features"
path = "examples/3_header_features.rs"
[[example]]
name = "4_persistence"
path = "examples/4_persistence.rs"
[[example]]
name = "5_texture_icons"
path = "examples/5_texture_icons.rs"
[[example]]
name = "6_policy_controls"
path = "examples/6_policy_controls.rs"
[[example]]
name = "7_theme_options"
path = "examples/7_theme_options.rs"
[[example]]
name = "demo"
path = "examples/demo.rs"
[dependencies.egui]
version = "0.34"
default-features = false
[dependencies.serde]
version = "1"
features = ["derive"]
optional = true
[dev-dependencies.eframe]
version = "0.33.3"
features = [
"default_fonts",
"glow",
]
default-features = false
+27
View File
@@ -0,0 +1,27 @@
# grimdock
`grimdock` is an [egui](https://github.com/emilk/egui) docking crate I use for my own projects.
It keeps to a single-surface IDE-style dock: split panes, draggable tabs, resizing, and rearranging without turning into a floating window system.
## Features
- split-pane dock layouts
- stable pane identity via `PaneId`
- pane-scoped policy and mutation APIs
- separate pane policy and tab drop policy
- built-in header interactions and pane actions
- pane-specific add/open menus
- pane anchors and semantic pane roles
- versioned persistence
- theme, typography, and icon support, including texture icons
## Why this exists
This crate has a few things I needed that other egui panel/docking options
didn't line up with:
- explicit stable pane identity
- pane-level policy separate from tab-level drop rules
- anchors and semantic pane roles built into the dock model
- pane-scoped add/open actions and persistence behavior
+49
View File
@@ -0,0 +1,49 @@
//! 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.");
});
});
}
}
@@ -0,0 +1,83 @@
//! 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.");
}
});
});
}
}
@@ -0,0 +1,152 @@
//! Header actions, add-tab menus, and tab markers:
//! `cargo run --example 3_header_features`
use eframe::egui;
use grimdock::{
AddTabEntry, HeaderVisibility, OpenBehavior, PanelContext, PanelStyle, PanelTree,
PersistedNode, Tab,
};
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("grimdock 3_header_features")
.with_inner_size([960.0, 540.0]),
..Default::default()
};
eframe::run_native(
"grimdock 3_header_features",
options,
Box::new(|_cc| Ok(Box::new(App::new()))),
)
}
#[derive(Clone, PartialEq, Eq, Debug)]
enum TabId {
Notes,
Search,
Problems,
Terminal,
Output,
}
struct App {
tree: PanelTree<TabId>,
style: PanelStyle,
hide_headers: bool,
}
impl App {
fn new() -> Self {
Self {
tree: PanelTree::new(vec![
Tab::new("notes", TabId::Notes)
.with_leading_visual(">")
.with_closable(true),
Tab::new("search", TabId::Search)
.with_leading_visual("?")
.with_closable(true),
Tab::new("problems", TabId::Problems)
.with_leading_visual("x")
.with_closable(true),
]),
style: PanelStyle::default(),
hide_headers: false,
}
}
fn sync_header_visibility(&mut self) {
let header_visibility = if self.hide_headers {
HeaderVisibility::Hidden
} else {
HeaderVisibility::Always
};
let pane_ids = self
.tree
.to_persisted()
.nodes
.into_iter()
.filter_map(|node| match node {
PersistedNode::Leaf { pane, .. } => Some(pane),
_ => None,
})
.collect::<Vec<_>>();
for pane_id in pane_ids {
if let Some(mut pane) = self.tree.pane_mut(pane_id) {
let mut options = pane.options();
options.header_visibility = header_visibility;
pane.set_options(options);
}
}
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.sync_header_visibility();
egui::CentralPanel::default()
.frame(egui::Frame::NONE)
.show(ctx, |ui| {
ui.checkbox(&mut self.hide_headers, "Hide pane headers");
ui.separator();
let output = PanelContext::new(ui, &mut self.tree, &self.style)
.with_add_tab_provider(&|pane_id, tree| {
let mut entries = vec![
AddTabEntry::new(
"Output",
Tab::new("output", TabId::Output)
.with_leading_visual("!")
.with_closable(true),
),
];
if tree.find_pane_containing(&TabId::Notes) == Some(pane_id) {
entries.push(
AddTabEntry::new(
"Terminal",
Tab::new("terminal", TabId::Terminal)
.with_leading_visual("$")
.with_closable(true),
)
.with_open_behavior(OpenBehavior::FocusExisting),
);
}
entries
})
.show(|ui, tab_id| match tab_id {
TabId::Notes => {
ui.heading("Notes");
ui.label("This pane gets both Output and Terminal in its + menu.");
ui.label("Right-click a tab for Close / Close others / Split actions.");
}
TabId::Search => {
ui.heading("Search");
ui.text_edit_singleline(&mut String::new());
}
TabId::Problems => {
ui.heading("Problems");
ui.label("No problems detected.");
}
TabId::Terminal => {
ui.heading("Terminal");
ui.label("$ cargo run");
}
TabId::Output => {
ui.heading("Output");
ui.label("[INFO] pane-specific add menus are enabled");
}
});
if !output.closed_tabs.is_empty() {
ui.separator();
ui.label(format!("Closed this frame: {:?}", output.closed_tabs));
}
});
}
}
+103
View File
@@ -0,0 +1,103 @@
//! 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
}
@@ -0,0 +1,111 @@
//! 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)
}
@@ -0,0 +1,332 @@
//! 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)
}
@@ -0,0 +1,323 @@
//! Theme and colour options example:
//! `cargo run --example 7_theme_options`
use eframe::egui;
use grimdock::{
ChildSide, PaneId, PaneStyleOverride, PanelContext, PanelStyle, PanelTree, SplitDir, Tab,
TabStyleOverride,
};
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("grimdock 7_theme_options")
.with_inner_size([1260.0, 760.0]),
..Default::default()
};
eframe::run_native(
"grimdock 7_theme_options",
options,
Box::new(|cc| Ok(Box::new(App::new(&cc.egui_ctx)))),
)
}
#[derive(Clone, PartialEq, Eq, Debug)]
enum TabId {
Files,
Editor,
Search,
Preview,
Terminal,
}
struct App {
tree: PanelTree<TabId>,
style: PanelStyle,
terminal_pane: PaneId,
terminal_override_enabled: bool,
terminal_override: PaneStyleOverride,
search_override_enabled: bool,
search_override: TabStyleOverride,
}
impl App {
fn new(ctx: &egui::Context) -> Self {
let (tree, terminal_pane) = make_tree();
Self {
tree,
style: PanelStyle::from_visuals(&ctx.style().visuals),
terminal_pane,
terminal_override_enabled: true,
terminal_override: PaneStyleOverride {
header_bg: Some(egui::Color32::from_rgb(45, 28, 20)),
content_bg: Some(egui::Color32::from_rgb(34, 22, 16)),
border_color: Some(egui::Color32::from_rgb(168, 99, 56)),
accent_color: Some(egui::Color32::from_rgb(230, 142, 78)),
},
search_override_enabled: true,
search_override: TabStyleOverride {
active_bg: Some(egui::Color32::from_rgb(30, 74, 66)),
inactive_bg: Some(egui::Color32::from_rgb(24, 52, 48)),
hovered_bg: Some(egui::Color32::from_rgb(36, 90, 80)),
text_color: Some(egui::Color32::from_rgb(214, 245, 238)),
accent_color: Some(egui::Color32::from_rgb(52, 204, 168)),
icon_color: None,
},
}
}
fn sync_overrides(&mut self) {
if let Some(mut pane) = self.tree.pane_mut(self.terminal_pane) {
let mut options = pane.options();
options.style_override = self.terminal_override_enabled.then_some(self.terminal_override);
pane.set_options(options);
}
if let Some(search_pane_id) = self.tree.find_pane_containing(&TabId::Search) {
if let Some(mut pane) = self.tree.pane_mut(search_pane_id) {
if let Some(tab) = pane.tabs_mut().iter_mut().find(|tab| tab.id == TabId::Search) {
tab.style_override = self.search_override_enabled.then_some(self.search_override);
}
}
}
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.sync_overrides();
egui::Panel::left("theme_controls")
.resizable(true)
.default_width(360.0)
.show(ctx, |ui| {
ui.heading("Theme workbench");
ui.label("This example exposes the global PanelStyle plus pane and tab color overrides.");
ui.horizontal(|ui| {
if ui.button("Use egui visuals").clicked() {
self.style = PanelStyle::from_visuals(&ctx.style().visuals);
}
if ui.button("Use crate defaults").clicked() {
self.style = PanelStyle::default();
}
});
ui.separator();
ui.collapsing("Layout metrics", |ui| {
ui.add(egui::Slider::new(&mut self.style.header_height, 20.0..=40.0).text("Header height"));
ui.add(
egui::Slider::new(&mut self.style.collapsed_pane_thickness, 20.0..=60.0)
.text("Collapsed thickness"),
);
ui.add(egui::Slider::new(&mut self.style.handle_width, 2.0..=12.0).text("Handle width"));
ui.add(egui::Slider::new(&mut self.style.min_pane_size, 40.0..=160.0).text("Min pane size"));
ui.add(egui::Slider::new(&mut self.style.content_inset, 0.0..=20.0).text("Content inset"));
corner_radius_slider(ui, "Pane rounding", &mut self.style.pane_rounding);
corner_radius_slider(ui, "Tab rounding", &mut self.style.tabs.rounding);
corner_radius_slider(ui, "Header button rounding", &mut self.style.header.button.rounding);
});
ui.collapsing("Typography", |ui| {
ui.label("Dock chrome text is painted directly by grimdock.");
ui.label("Font weight comes from whichever font family your app has loaded.");
font_controls(ui, "Tab title", &mut self.style.typography.tab_title_font);
font_controls(
ui,
"Leading text icon",
&mut self.style.typography.tab_icon_text_font,
);
});
ui.collapsing("Header", |ui| {
color_row(ui, "Header bg", &mut self.style.header.bg);
color_row(ui, "Header border", &mut self.style.header.border_color);
color_row(ui, "Button bg", &mut self.style.header.button.bg);
color_row(ui, "Button hover bg", &mut self.style.header.button.hover_bg);
color_row(ui, "Button stroke", &mut self.style.header.button.stroke_color);
color_row(ui, "Button icon", &mut self.style.header.button.icon_color);
color_row(ui, "Button hover icon", &mut self.style.header.button.hover_icon_color);
});
ui.collapsing("Content", |ui| {
color_row(ui, "Content bg", &mut self.style.content.bg);
color_row(ui, "Content border", &mut self.style.content.border_color);
});
ui.collapsing("Tabs", |ui| {
ui.label("Active");
color_row(ui, "Active bg", &mut self.style.tabs.active.bg);
color_row(ui, "Active text", &mut self.style.tabs.active.text_color);
color_row(ui, "Active accent", &mut self.style.tabs.active.accent_color);
ui.separator();
ui.label("Inactive");
color_row(ui, "Inactive bg", &mut self.style.tabs.inactive.bg);
color_row(ui, "Inactive text", &mut self.style.tabs.inactive.text_color);
color_row(ui, "Inactive accent", &mut self.style.tabs.inactive.accent_color);
ui.separator();
ui.label("Hovered");
color_row(ui, "Hovered bg", &mut self.style.tabs.hovered.bg);
color_row(ui, "Hovered text", &mut self.style.tabs.hovered.text_color);
color_row(ui, "Hovered accent", &mut self.style.tabs.hovered.accent_color);
});
ui.collapsing("Handles and overlay", |ui| {
color_row(ui, "Handle", &mut self.style.handle.color);
color_row(ui, "Handle hover", &mut self.style.handle.hover_color);
color_row(ui, "Handle locked", &mut self.style.handle.locked_color);
color_row(ui, "Overlay fill", &mut self.style.overlay.fill);
color_row(ui, "Overlay stroke", &mut self.style.overlay.stroke.color);
ui.add(
egui::Slider::new(&mut self.style.overlay.stroke.width, 0.5..=4.0)
.text("Overlay stroke width"),
);
});
ui.collapsing("Pane override", |ui| {
ui.checkbox(&mut self.terminal_override_enabled, "Enable Terminal pane override");
color_option_row(ui, "Header bg", &mut self.terminal_override.header_bg);
color_option_row(ui, "Content bg", &mut self.terminal_override.content_bg);
color_option_row(ui, "Border", &mut self.terminal_override.border_color);
color_option_row(ui, "Accent", &mut self.terminal_override.accent_color);
});
ui.collapsing("Tab override", |ui| {
ui.checkbox(&mut self.search_override_enabled, "Enable Search tab override");
color_option_row(ui, "Active bg", &mut self.search_override.active_bg);
color_option_row(ui, "Inactive bg", &mut self.search_override.inactive_bg);
color_option_row(ui, "Hovered bg", &mut self.search_override.hovered_bg);
color_option_row(ui, "Text", &mut self.search_override.text_color);
color_option_row(ui, "Accent", &mut self.search_override.accent_color);
});
});
egui::CentralPanel::default()
.frame(egui::Frame::NONE)
.show(ctx, |ui| {
PanelContext::new(ui, &mut self.tree, &self.style).show(|ui, tab_id| match tab_id {
TabId::Files => {
ui.heading("Files");
ui.label("Use this workbench to tune every main color and size in PanelStyle.");
}
TabId::Editor => {
ui.heading("Editor");
ui.label("Drag tabs and resize panes while adjusting the theme controls.");
}
TabId::Search => {
ui.heading("Search");
ui.label("This tab can demonstrate per-tab color overrides.");
}
TabId::Preview => {
ui.heading("Preview");
ui.label("The dock updates live as you change colors, rounding, and handle styles.");
}
TabId::Terminal => {
ui.heading("Terminal");
ui.label("This pane can demonstrate per-pane header/content/accent overrides.");
}
});
});
}
}
fn color_row(ui: &mut egui::Ui, label: &str, color: &mut egui::Color32) {
ui.horizontal(|ui| {
ui.label(label);
ui.color_edit_button_srgba(color);
});
}
fn color_option_row(ui: &mut egui::Ui, label: &str, color: &mut Option<egui::Color32>) {
let mut enabled = color.is_some();
ui.horizontal(|ui| {
ui.checkbox(&mut enabled, label);
if !enabled {
*color = None;
return;
}
let value = color.get_or_insert(egui::Color32::WHITE);
ui.color_edit_button_srgba(value);
});
}
fn corner_radius_slider(ui: &mut egui::Ui, label: &str, radius: &mut egui::CornerRadius) {
let mut value = radius.nw;
ui.add(egui::Slider::new(&mut value, 0..=16).text(label));
*radius = egui::CornerRadius::same(value);
}
fn font_controls(ui: &mut egui::Ui, label: &str, font: &mut egui::FontId) {
ui.group(|ui| {
ui.label(label);
ui.add(egui::Slider::new(&mut font.size, 8.0..=24.0).text("Size"));
let mut family = match &font.family {
egui::FontFamily::Proportional => 0,
egui::FontFamily::Monospace => 1,
egui::FontFamily::Name(_) => 2,
};
egui::ComboBox::from_label("Family")
.selected_text(match family {
0 => "Proportional",
1 => "Monospace",
_ => "Custom",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut family, 0, "Proportional");
ui.selectable_value(&mut family, 1, "Monospace");
ui.selectable_value(&mut family, 2, "Custom");
});
if family == 2 {
let mut name = match &font.family {
egui::FontFamily::Name(name) => name.to_string(),
_ => String::from("bold"),
};
ui.text_edit_singleline(&mut name);
font.family = egui::FontFamily::Name(name.into());
} else {
font.family = if family == 0 {
egui::FontFamily::Proportional
} else {
egui::FontFamily::Monospace
};
}
});
}
fn make_tree() -> (PanelTree<TabId>, PaneId) {
let mut tree = PanelTree::new(vec![
Tab::new("editor", TabId::Editor).with_leading_visual(">"),
Tab::new("search", TabId::Search).with_leading_visual("?"),
]);
tree.split_leaf(
0,
SplitDir::Horizontal,
Tab::new("files", TabId::Files).with_leading_visual("#"),
ChildSide::First,
);
let editor_pane = tree
.find_pane_containing(&TabId::Editor)
.expect("editor pane should exist");
let terminal_pane = tree
.pane_mut(editor_pane)
.expect("editor pane should exist")
.split(
SplitDir::Vertical,
Tab::new("terminal", TabId::Terminal).with_leading_visual("$"),
ChildSide::Second,
);
if let Some(mut pane) = tree.pane_mut(editor_pane) {
pane.push_tab(Tab::new("preview", TabId::Preview).with_leading_visual("*"));
}
(tree, terminal_pane)
}
+232
View File
@@ -0,0 +1,232 @@
//! Runnable demo: `cargo run --example demo`
//!
//! Opens a window with a pre-built split layout. Tabs can be dragged between
//! panes, panes can be resized by dragging the handle between them.
use eframe::egui;
use grimdock::{
AddTabEntry, ChildSide, DropPolicy, Node, PaneBuilder, PaneStyleOverride, PanelContext,
PanelStyle, PanelTree, SplitDir, Tab, TabStyleOverride,
};
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("grimdock demo")
.with_inner_size([1024.0, 680.0]),
..Default::default()
};
eframe::run_native("grimdock demo", options, Box::new(|_cc| Ok(Box::new(App::new()))))
}
/// A tab identifier. Using a simple enum so each variant renders unique content.
#[derive(Clone, PartialEq, Eq, Debug)]
enum TabId {
Editor(u32),
FileTree,
Terminal,
Search,
Problems,
Output,
}
impl std::fmt::Display for TabId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TabId::Editor(n) => write!(f, "editor_{n}"),
TabId::FileTree => write!(f, "file_tree"),
TabId::Terminal => write!(f, "terminal"),
TabId::Search => write!(f, "search"),
TabId::Problems => write!(f, "problems"),
TabId::Output => write!(f, "output"),
}
}
}
struct App {
tree: PanelTree<TabId>,
/// Counter that ticks up each frame in the terminal pane.
tick: u64,
}
impl App {
fn new() -> Self {
// Build an initial layout:
//
// ┌───────────┬──────────────────┐
// │ File Tree │ Editor 1 │
// │ ├──────────────────┤
// │ │ Terminal │ Output│
// └───────────┴──────────────────┘
//
// We do this by starting with a single pane and splitting.
let mut tree = PanelTree::new(vec![
Tab::new("editor_1", TabId::Editor(1))
.with_leading_visual(">")
.with_style_override(TabStyleOverride {
active_bg: Some(egui::Color32::from_rgb(62, 73, 50)),
inactive_bg: Some(egui::Color32::from_rgb(44, 52, 38)),
hovered_bg: Some(egui::Color32::from_rgb(55, 65, 45)),
text_color: Some(egui::Color32::from_rgb(221, 231, 200)),
accent_color: Some(egui::Color32::from_rgb(164, 196, 92)),
icon_color: None,
}),
Tab::new("editor_2", TabId::Editor(2)).with_leading_visual("+"),
]);
// Split root (editors) horizontally: file tree on the left (First side).
let _file_tree_pane = tree.split_leaf_with(
0,
SplitDir::Horizontal,
PaneBuilder::new(Tab::new("file_tree", TabId::FileTree).with_leading_visual("#")).with_options(
grimdock::PaneOptions {
style_override: Some(PaneStyleOverride {
header_bg: Some(egui::Color32::from_rgb(27, 47, 44)),
content_bg: Some(egui::Color32::from_rgb(21, 36, 34)),
border_color: Some(egui::Color32::from_rgb(52, 90, 84)),
accent_color: Some(egui::Color32::from_rgb(85, 188, 162)),
}),
drop_policy: DropPolicy::merge_only(),
..Default::default()
},
),
ChildSide::First,
);
// Node 0 is now Horizontal split.
// Node 1 (left/First): FileTree
// Node 2 (right/Second): editors
// Split the right pane (editors, node 2) vertically: bottom gets terminal.
let editors_pane = tree
.find_pane_containing(&TabId::Editor(1))
.expect("editor pane should exist after initial split");
let bottom_pane = {
let mut pane = tree.pane_mut(editors_pane).expect("editor pane should exist");
pane.split_with(
SplitDir::Vertical,
PaneBuilder::new(Tab::new("terminal", TabId::Terminal).with_leading_visual("$")),
ChildSide::Second,
)
};
// Node 2 is now Vertical split.
// Node 5 (top/First): editors
// Node 6 (bottom/Second): terminal
// Add extra tabs to the bottom bar.
// Nodes are: 5 = editors top pane, 6 = bottom pane (terminal).
let mut bottom = tree.pane_mut(bottom_pane).expect("bottom pane should exist");
bottom.push_tab(Tab::new("output", TabId::Output).with_leading_visual("!"));
bottom.push_tab(Tab::new("problems", TabId::Problems).with_leading_visual("x"));
bottom.push_tab(Tab::new("search", TabId::Search).with_leading_visual("?"));
// Narrow the left (file tree) column — set the split ratio.
if let Node::Split { ratio, .. } = tree.node_mut(0) {
*ratio = 0.22;
}
// Make the bottom strip shorter.
if let Node::Split { ratio, .. } = tree.node_mut(2) {
*ratio = 0.65;
}
Self {
tree,
tick: 0,
}
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.tick += 1;
egui::CentralPanel::default()
.frame(egui::Frame::NONE)
.show(ctx, |ui| {
let tree = &mut self.tree;
let mut style = PanelStyle::from_egui_style(ui.style().as_ref());
style.content_inset = 4.0;
style.tabs.rounding = egui::CornerRadius::same(5);
style.header.button.rounding = egui::CornerRadius::same(5);
let tick = self.tick;
let add_tab_entries = vec![
AddTabEntry::new(
"Editor 3",
Tab::new("editor_3", TabId::Editor(3))
.with_leading_visual("*")
.with_closable(true),
),
AddTabEntry::new(
"Terminal",
Tab::new("terminal", TabId::Terminal)
.with_leading_visual("$")
.with_closable(true),
),
AddTabEntry::new(
"Search",
Tab::new("search", TabId::Search)
.with_leading_visual("?")
.with_closable(true),
),
AddTabEntry::new(
"Problems",
Tab::new("problems", TabId::Problems)
.with_leading_visual("x")
.with_closable(true),
),
];
PanelContext::new(ui, tree, &style)
.with_add_tab_entries(&add_tab_entries)
.show(|ui, tab_id| {
render_tab(ui, tab_id, tick);
});
});
// Repaint continuously so the terminal counter updates.
ctx.request_repaint();
}
}
fn render_tab(ui: &mut egui::Ui, tab_id: &TabId, tick: u64) {
egui::ScrollArea::vertical().show(ui, |ui| {
match tab_id {
TabId::Editor(n) => {
ui.add_space(8.0);
ui.label(
egui::RichText::new(format!("// Editor {n}\n\nfn main() {{\n println!(\"Hello, world!\");\n}}"))
.monospace()
.size(13.0),
);
ui.add_space(8.0);
ui.label("Drag a tab to a different pane to rearrange.");
ui.label("Drag the handle between panes to resize.");
}
TabId::FileTree => {
ui.add_space(6.0);
for item in &["src/", " lib.rs", " tree.rs", " layout.rs", " header.rs", " dnd.rs", "Cargo.toml"] {
ui.label(egui::RichText::new(*item).monospace().size(12.0));
}
}
TabId::Terminal => {
ui.add_space(4.0);
ui.label(egui::RichText::new(format!("$ frame {tick}")).monospace().size(12.0));
ui.label(egui::RichText::new("grimdock running ✓").monospace().color(egui::Color32::from_rgb(100, 200, 120)).size(12.0));
}
TabId::Search => {
ui.add_space(8.0);
ui.label("Search");
ui.text_edit_singleline(&mut String::new());
}
TabId::Problems => {
ui.add_space(8.0);
ui.label(egui::RichText::new("No problems detected.").color(egui::Color32::from_rgb(100, 200, 120)));
}
TabId::Output => {
ui.add_space(4.0);
ui.label(egui::RichText::new("[INFO] Build complete.").monospace().size(12.0));
}
}
});
}
+50
View File
@@ -0,0 +1,50 @@
use egui::{Rect, Ui};
use crate::{
ids::pane_content_id,
style::PanelStyle,
tree::{Node, PanelTree},
};
/// Run the content pass: invoke the caller's render callback for each leaf's
/// focused tab inside a clipped child UI.
pub(crate) fn content_pass<T: Clone + 'static>(
ui: &mut Ui,
tree: &PanelTree<T>,
leaf_rects: &[(usize, Rect)],
style: &PanelStyle,
mut render: impl FnMut(&mut Ui, &T),
) {
for &(leaf_idx, content_rect) in leaf_rects {
if tree.is_collapsed(leaf_idx) {
continue;
}
let (pane_id, tab_id, focused_idx) = match tree.node(leaf_idx) {
Node::Leaf {
pane,
tabs,
focused,
..
} => {
if let Some(tab) = tabs.get(*focused) {
(*pane, tab.id.clone(), *focused)
} else {
continue;
}
}
_ => continue,
};
// Use a stable ID per leaf + focused tab so widget state (scroll
// position, text input, etc.) survives tab switches in other panes.
let content_id = pane_content_id(pane_id).with(focused_idx);
let inset_rect = content_rect.shrink(style.content_inset);
ui.push_id(content_id, |ui| {
ui.scope_builder(egui::UiBuilder::new().max_rect(inset_rect), |ui| {
ui.set_clip_rect(inset_rect);
render(ui, &tab_id);
});
});
}
}
+746
View File
@@ -0,0 +1,746 @@
use egui::{DragAndDrop, LayerId, Order, Pos2, Rect, Ui};
use crate::{
style::PanelStyle,
tab::Tab,
tree::{ChildSide, Node, PaneId, PaneOptions, PaneRole, PanelTree, SplitDir},
};
/// Payload carried through egui's drag-and-drop system.
#[derive(Clone, Debug)]
pub(crate) struct DragPayload {
/// Stable pane identifier of the leaf that owns the dragged tab.
pub src_pane: PaneId,
/// Position of the dragged tab within the source leaf's tab list.
pub tab_pos: usize,
}
/// Determines where relative to a pane's centre the cursor is sitting.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum DropZone {
Center,
Left,
Right,
Top,
Bottom,
}
fn drop_zone_allowed(options: PaneOptions, zone: DropZone) -> bool {
if options.lock_layout {
return false;
}
match zone {
DropZone::Center => options.drop_policy.allow_merge,
DropZone::Left => options.drop_policy.allows_split(SplitDir::Horizontal, ChildSide::First),
DropZone::Right => {
options
.drop_policy
.allows_split(SplitDir::Horizontal, ChildSide::Second)
}
DropZone::Top => options.drop_policy.allows_split(SplitDir::Vertical, ChildSide::First),
DropZone::Bottom => {
options
.drop_policy
.allows_split(SplitDir::Vertical, ChildSide::Second)
}
}
}
fn tab_allows_target_pane<T: Clone + 'static>(
tab: &Tab<T>,
pane_id: PaneId,
role: Option<PaneRole>,
) -> bool {
tab.drop_policy.allows_target(pane_id, role)
}
fn header_tab_drop_index<T: Clone + 'static>(
tree: &PanelTree<T>,
leaf_idx: usize,
header_rect: Rect,
cursor_pos: Pos2,
) -> Option<usize> {
let tab_count = match tree.node(leaf_idx) {
Node::Leaf { tabs, .. } => tabs.len(),
_ => return None,
};
if tab_count == 0 {
return None;
}
let tab_width = (header_rect.width() / tab_count.max(1) as f32)
.min(120.0)
.max(40.0);
let relative_x = (cursor_pos.x - header_rect.min.x).clamp(0.0, header_rect.width());
let raw_index = (relative_x / tab_width).floor() as usize;
Some(raw_index.min(tab_count.saturating_sub(1)))
}
fn header_tab_rect<T: Clone + 'static>(
tree: &PanelTree<T>,
leaf_idx: usize,
header_rect: Rect,
tab_pos: usize,
) -> Option<Rect> {
let tab_count = match tree.node(leaf_idx) {
Node::Leaf { tabs, .. } => tabs.len(),
_ => return None,
};
if tab_pos >= tab_count {
return None;
}
let tab_width = (header_rect.width() / tab_count.max(1) as f32)
.min(120.0)
.max(40.0);
Some(Rect::from_min_size(
egui::pos2(
header_rect.min.x + tab_pos as f32 * tab_width,
header_rect.min.y,
),
egui::vec2(tab_width, header_rect.height()),
))
}
fn root_edge_drop_zone(cursor: Pos2, root_rect: Rect) -> Option<DropZone> {
if !root_rect.contains(cursor) {
return None;
}
let edge_band = 20.0_f32.min(root_rect.width() * 0.15).min(root_rect.height() * 0.15);
let left = cursor.x - root_rect.min.x;
let right = root_rect.max.x - cursor.x;
let top = cursor.y - root_rect.min.y;
let bottom = root_rect.max.y - cursor.y;
let mut best: Option<(f32, DropZone)> = None;
for (dist, zone) in [
(left, DropZone::Left),
(right, DropZone::Right),
(top, DropZone::Top),
(bottom, DropZone::Bottom),
] {
if dist <= edge_band {
match best {
Some((best_dist, _)) if dist >= best_dist => {}
_ => best = Some((dist, zone)),
}
}
}
best.map(|(_, zone)| zone)
}
impl DropZone {
/// Classify `cursor` against the five zones of `rect`.
///
/// The central 20 % of each axis forms the `Center` zone. Outside that,
/// the remaining area is divided into four triangular quadrants by the two
/// diagonals of the rectangle.
pub(crate) fn classify(cursor: Pos2, rect: Rect) -> Self {
let center = rect.center();
let dx = (cursor.x - center.x) / rect.width().max(1.0);
let dy = (cursor.y - center.y) / rect.height().max(1.0);
// Central region: both axes within ±10 % of the centre.
if dx.abs() < 0.1 && dy.abs() < 0.1 {
return DropZone::Center;
}
// Outside the centre: pick the dominant axis.
if dx.abs() > dy.abs() {
if dx > 0.0 {
DropZone::Right
} else {
DropZone::Left
}
} else if dy > 0.0 {
DropZone::Bottom
} else {
DropZone::Top
}
}
/// Return the preview rectangle for this drop zone within `pane_rect`.
pub(crate) fn preview_rect(self, pane_rect: Rect) -> Rect {
match self {
DropZone::Center => pane_rect,
DropZone::Left => Rect::from_min_max(
pane_rect.min,
egui::pos2(pane_rect.min.x + pane_rect.width() * 0.25, pane_rect.max.y),
),
DropZone::Right => Rect::from_min_max(
egui::pos2(pane_rect.max.x - pane_rect.width() * 0.25, pane_rect.min.y),
pane_rect.max,
),
DropZone::Top => Rect::from_min_max(
pane_rect.min,
egui::pos2(pane_rect.max.x, pane_rect.min.y + pane_rect.height() * 0.25),
),
DropZone::Bottom => Rect::from_min_max(
egui::pos2(pane_rect.min.x, pane_rect.max.y - pane_rect.height() * 0.25),
pane_rect.max,
),
}
}
}
/// Run the drag-and-drop resolution pass.
///
/// Draws the drop-target overlay while a drag is live, and mutates the tree
/// on pointer release.
pub(crate) fn dnd_pass<T: Clone + 'static>(
ui: &mut Ui,
tree: &mut PanelTree<T>,
style: &PanelStyle,
) {
let ctx = ui.ctx().clone();
// Is a drag live?
let Some(live_payload) = DragAndDrop::payload::<DragPayload>(&ctx) else {
return;
};
let cursor_pos = match ctx.pointer_hover_pos() {
Some(p) => p,
None => return,
};
// Find which leaf the cursor is over.
let target_leaf = tree.leaf_indices().find(|&idx| {
if let Node::Leaf { rect, .. } = tree.node(idx) {
rect.contains(cursor_pos)
} else {
false
}
});
if let Some(target_leaf) = target_leaf {
let root_rect = match tree.node(0) {
Node::Split { rect, .. } => *rect,
Node::Leaf { rect, .. } => *rect,
Node::Empty => return,
};
let pane_rect = match tree.node(target_leaf) {
Node::Leaf { rect, .. } => *rect,
_ => return,
};
let Some(options) = tree.pane_options(target_leaf) else {
return;
};
// Cursor in the header bar → always merge (Center). Without this,
// a cursor sitting on a tab button lands near the top of the pane
// rect and gets classified as DropZone::Top, causing an unintended
// split when the user just wanted to drop onto that pane's tab bar.
let header_visible = tree.header_visible(target_leaf);
let header_rect = egui::Rect::from_min_size(
pane_rect.min,
egui::vec2(pane_rect.width(), style.header_height),
);
let zone = if header_visible && header_rect.contains(cursor_pos) {
DropZone::Center
} else {
// Classify relative to the content area only (below the header).
let header_height = if header_visible {
style.header_height
} else {
0.0
};
let content_rect = egui::Rect::from_min_max(
egui::pos2(pane_rect.min.x, pane_rect.min.y + header_height),
pane_rect.max,
);
DropZone::classify(cursor_pos, content_rect)
};
let Some(target_pane) = tree.pane_id_at(target_leaf) else {
return;
};
let target_role = match tree.node(target_leaf) {
Node::Leaf { options, .. } => options.role,
_ => None,
};
let dragged_tab = tree
.pane_index(live_payload.src_pane)
.and_then(|src_leaf| match &tree.nodes[src_leaf] {
Node::Leaf { tabs, .. } => tabs.get(live_payload.tab_pos),
_ => None,
});
if !dragged_tab
.map(|tab| tab_allows_target_pane(tab, target_pane, target_role))
.unwrap_or(false)
{
return;
}
let mut zone_allowed = drop_zone_allowed(options, zone);
let root_zone = root_edge_drop_zone(cursor_pos, root_rect)
.filter(|&root_zone| drop_zone_allowed(options, root_zone));
let effective_zone = root_zone.unwrap_or(zone);
zone_allowed = root_zone.is_some() || zone_allowed;
if !zone_allowed {
return;
}
let reorder_preview = if target_pane == live_payload.src_pane
&& effective_zone == DropZone::Center
&& header_visible
&& header_rect.contains(cursor_pos)
&& !options.lock_layout
&& options.allow_tab_reorder
{
header_tab_drop_index(tree, target_leaf, header_rect, cursor_pos)
.and_then(|target_pos| header_tab_rect(tree, target_leaf, header_rect, target_pos))
} else {
None
};
let preview = if let Some(reorder_rect) = reorder_preview {
reorder_rect
} else if root_zone.is_some() {
effective_zone.preview_rect(root_rect)
} else {
effective_zone.preview_rect(pane_rect)
};
// Draw translucent overlay on a top-level layer.
let overlay_painter = ctx.layer_painter(LayerId::new(
Order::Tooltip,
egui::Id::new("grimdock_dnd_overlay"),
));
overlay_painter.rect_filled(preview, style.pane_rounding, style.overlay.fill);
overlay_painter.rect_stroke(
preview,
style.pane_rounding,
style.overlay.stroke,
egui::StrokeKind::Inside,
);
// On release, apply the drop.
let released = ctx.input(|i| i.pointer.any_released());
if released {
// Take the payload — this clears the drag state.
if let Some(payload) = DragAndDrop::take_payload::<DragPayload>(&ctx) {
let Some(src_leaf) = tree.pane_index(payload.src_pane) else {
return;
};
let tab_pos = payload.tab_pos;
let draggable = match &tree.nodes[src_leaf] {
Node::Leaf { tabs, options, .. } => tabs
.get(tab_pos)
.map(|tab| {
tab.draggable
&& tab_allows_target_pane(tab, target_pane, target_role)
&& !options.lock_layout
&& (options.allow_tab_drag_out || options.allow_tab_reorder)
}),
_ => None,
};
if draggable != Some(true) {
return;
}
// Retrieve the tab before mutating the tree.
let tab = match &tree.nodes[src_leaf] {
Node::Leaf { tabs, .. } => tabs.get(tab_pos).cloned(),
_ => None,
};
if let Some(tab) = tab {
match effective_zone {
DropZone::Center => {
if src_leaf == target_leaf {
if header_visible && header_rect.contains(cursor_pos) && options.allow_tab_reorder {
if let Some(target_pos) =
header_tab_drop_index(tree, target_leaf, header_rect, cursor_pos)
{
let _ = tree.move_tab_within_leaf(target_leaf, tab_pos, target_pos);
}
}
} else {
tree.merge_tab_into(src_leaf, tab_pos, target_leaf);
tree.collapse_empty_leaf(src_leaf);
}
}
DropZone::Left => {
if root_zone.is_some() {
apply_root_directional_drop(
tree,
src_leaf,
tab_pos,
tab,
SplitDir::Horizontal,
ChildSide::First,
);
} else {
apply_directional_drop(
tree,
src_leaf,
tab_pos,
target_leaf,
tab,
SplitDir::Horizontal,
ChildSide::First,
);
}
}
DropZone::Right => {
if root_zone.is_some() {
apply_root_directional_drop(
tree,
src_leaf,
tab_pos,
tab,
SplitDir::Horizontal,
ChildSide::Second,
);
} else {
apply_directional_drop(
tree,
src_leaf,
tab_pos,
target_leaf,
tab,
SplitDir::Horizontal,
ChildSide::Second,
);
}
}
DropZone::Top => {
if root_zone.is_some() {
apply_root_directional_drop(
tree,
src_leaf,
tab_pos,
tab,
SplitDir::Vertical,
ChildSide::First,
);
} else {
apply_directional_drop(
tree,
src_leaf,
tab_pos,
target_leaf,
tab,
SplitDir::Vertical,
ChildSide::First,
);
}
}
DropZone::Bottom => {
if root_zone.is_some() {
apply_root_directional_drop(
tree,
src_leaf,
tab_pos,
tab,
SplitDir::Vertical,
ChildSide::Second,
);
} else {
apply_directional_drop(
tree,
src_leaf,
tab_pos,
target_leaf,
tab,
SplitDir::Vertical,
ChildSide::Second,
);
}
}
}
}
}
}
}
}
/// Remove the tab from its source and split the target pane to host it.
///
/// **Operation order**: split target first (no indices change), then remove
/// from source, then collapse if source is empty. Doing it in this order
/// keeps `src_leaf` at a stable index throughout — collapsing before
/// splitting would shift the target's index in any layout where target is
/// inside the subtree that gets promoted.
fn apply_directional_drop<T: Clone + 'static>(
tree: &mut PanelTree<T>,
src_leaf: usize,
tab_pos: usize,
target_leaf: usize,
tab: crate::tab::Tab<T>,
dir: SplitDir,
side: ChildSide,
) {
if src_leaf == target_leaf {
// Dragging a tab onto the edge of its own pane.
// split_leaf keeps the existing tabs in one child and places `tab` in
// the other. We need to remove the original occurrence of the tab
// from the "existing-tabs" child afterwards.
tree.split_leaf(target_leaf, dir, tab, side);
// After the split, src_leaf is now a Split node. The child that
// received the original tabs is the opposite of `side`.
let old_child = match side {
ChildSide::First => PanelTree::<T>::right_child(target_leaf),
ChildSide::Second => PanelTree::<T>::left_child(target_leaf),
};
if let Node::Leaf { tabs, focused, .. } = &mut tree.nodes[old_child] {
if tab_pos < tabs.len() {
tabs.remove(tab_pos);
if !tabs.is_empty() && *focused >= tabs.len() {
*focused = tabs.len() - 1;
}
}
}
// If the old child is now empty (sole-tab pane split onto its own
// edge), collapse it so the layout stays consistent.
tree.collapse_empty_leaf(old_child);
return;
}
// Step 1: split the target. `split_leaf` only adds child nodes — it
// never moves any existing nodes — so `src_leaf` keeps its index.
tree.split_leaf(target_leaf, dir, tab, side);
// Step 2: remove the tab from the source.
match &mut tree.nodes[src_leaf] {
Node::Leaf { tabs, focused, .. } => {
if tab_pos < tabs.len() {
tabs.remove(tab_pos);
if !tabs.is_empty() && *focused >= tabs.len() {
*focused = tabs.len() - 1;
}
}
}
_ => return,
}
// Step 3: collapse the source if it is now empty.
let src_empty = matches!(&tree.nodes[src_leaf], Node::Leaf { tabs, .. } if tabs.is_empty());
if src_empty {
tree.collapse_empty_leaf(src_leaf);
}
}
fn apply_root_directional_drop<T: Clone + 'static>(
tree: &mut PanelTree<T>,
src_leaf: usize,
tab_pos: usize,
tab: crate::tab::Tab<T>,
dir: SplitDir,
side: ChildSide,
) {
tree.wrap_root_with_split(dir, tab, side);
// After wrap_root_with_split the old subtree (rooted at old index 0) is
// placed at `existing_child` in the new tree. copy_subtree_with_offset
// maps old index `i` → `existing_child * 2^depth(i) + i`, where
// `depth(i) = floor(log2(i + 1))`. The previously used formulas
// `2*src_leaf+2` / `2*src_leaf+1` were only correct for the shallowest
// nodes and caused an out-of-bounds panic for deeper layouts.
let existing_child = match side {
ChildSide::First => PanelTree::<T>::right_child(0),
ChildSide::Second => PanelTree::<T>::left_child(0),
};
let depth = usize::BITS - (src_leaf + 1).leading_zeros() - 1;
let shifted_src_leaf = existing_child * (1 << depth) + src_leaf;
match &mut tree.nodes[shifted_src_leaf] {
Node::Leaf { tabs, focused, .. } => {
if tab_pos < tabs.len() {
tabs.remove(tab_pos);
if !tabs.is_empty() && *focused >= tabs.len() {
*focused = tabs.len() - 1;
}
}
}
_ => return,
}
let src_empty =
matches!(&tree.nodes[shifted_src_leaf], Node::Leaf { tabs, .. } if tabs.is_empty());
if src_empty {
tree.collapse_empty_leaf(shifted_src_leaf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DropPolicy, HeaderVisibility, PanelTree, Tab, TabDropPolicy};
#[test]
fn root_edge_drop_zone_prefers_nearest_edge() {
let root = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(200.0, 100.0));
assert_eq!(
root_edge_drop_zone(egui::pos2(4.0, 50.0), root),
Some(DropZone::Left)
);
assert_eq!(
root_edge_drop_zone(egui::pos2(196.0, 50.0), root),
Some(DropZone::Right)
);
assert_eq!(
root_edge_drop_zone(egui::pos2(100.0, 4.0), root),
Some(DropZone::Top)
);
assert_eq!(
root_edge_drop_zone(egui::pos2(100.0, 96.0), root),
Some(DropZone::Bottom)
);
assert_eq!(root_edge_drop_zone(egui::pos2(100.0, 50.0), root), None);
}
#[test]
fn drop_zone_allowed_respects_non_droppable_and_locked_panes() {
let mut options = crate::tree::PaneOptions::default();
options.drop_policy = DropPolicy::none();
assert!(!drop_zone_allowed(options, DropZone::Center));
assert!(!drop_zone_allowed(options, DropZone::Left));
options.drop_policy = DropPolicy::all();
options.lock_layout = true;
assert!(!drop_zone_allowed(options, DropZone::Center));
assert!(!drop_zone_allowed(options, DropZone::Right));
}
#[test]
fn apply_root_directional_drop_wraps_root_without_losing_tabs() {
let mut tree = PanelTree::new(vec![Tab::new("A", "a")]);
tree.insert_tab_into_leaf(0, Tab::new("B", "b"));
let tab = match tree.node(0) {
Node::Leaf { tabs, .. } => tabs[0].clone(),
_ => panic!("expected root leaf"),
};
apply_root_directional_drop(
&mut tree,
0,
0,
tab,
SplitDir::Horizontal,
ChildSide::First,
);
assert!(matches!(tree.node(0), Node::Split { .. }));
assert!(tree.find_tab(&"a").is_some());
assert!(tree.find_tab(&"b").is_some());
}
/// Regression: dragging a tab from a deep leaf (depth ≥ 2) onto the root
/// edge used to compute the shifted_src_leaf index as `right_child(src)`
/// / `left_child(src)`, which gives the wrong node and panics with
/// "index out of bounds" once the tree is tall enough.
#[test]
fn apply_root_directional_drop_deep_layout_no_panic() {
// Build a tree: root → [A | [B | C]]
// After two split_leaf calls the layout is:
// 0 = Split
// 1 = Leaf [A] (left child of root)
// 2 = Split
// 5 = Leaf [B] (left child of node 2)
// 6 = Leaf [C] (right child of node 2) ← src_leaf = 6
let mut tree = PanelTree::new(vec![Tab::new("A", "a")]);
tree.split_leaf(0, SplitDir::Horizontal, Tab::new("B", "b"), ChildSide::Second);
// node 0 = Split, node 1 = Leaf [A], node 2 = Leaf [B]
tree.split_leaf(2, SplitDir::Horizontal, Tab::new("C", "c"), ChildSide::Second);
// node 0 = Split, node 1 = Leaf [A], node 2 = Split, node 5 = Leaf [B], node 6 = Leaf [C]
let src_leaf = 6;
let tab = match tree.node(src_leaf) {
Node::Leaf { tabs, .. } => tabs[0].clone(),
_ => panic!("expected leaf at index {src_leaf}"),
};
// This used to panic with "index out of bounds" because shifted_src_leaf
// was computed as left_child(6) = 13 but the tree only had 11 nodes.
apply_root_directional_drop(
&mut tree,
src_leaf,
0,
tab,
SplitDir::Horizontal,
ChildSide::Second,
);
assert!(matches!(tree.node(0), Node::Split { .. }));
assert!(tree.find_tab(&"a").is_some());
assert!(tree.find_tab(&"b").is_some());
// "c" was the dragged tab so it should be in the new root-edge pane
assert!(tree.find_tab(&"c").is_some());
}
#[test]
fn header_drop_index_handles_single_hidden_header_layouts() {
let mut tree = PanelTree::new(vec![Tab::new("A", "a"), Tab::new("B", "b")]);
let mut options = tree.pane_options(0).unwrap();
options.header_visibility = HeaderVisibility::WhenMultipleTabs;
tree.set_pane_options(0, options);
let rect = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(100.0, 20.0));
assert_eq!(
header_tab_drop_index(&tree, 0, rect, egui::pos2(5.0, 10.0)),
Some(0)
);
assert_eq!(
header_tab_drop_index(&tree, 0, rect, egui::pos2(95.0, 10.0)),
Some(1)
);
}
#[test]
fn tab_drop_policy_blocks_non_whitelisted_target_panes() {
let mut tree = PanelTree::new(vec![Tab::new("A", "a")]);
let right_pane = tree.split_leaf(
0,
SplitDir::Horizontal,
Tab::new("B", "b").with_drop_policy(TabDropPolicy {
locked_to_pane: None,
locked_to_role: None,
allowed_panes: Some(vec![PaneId::from_raw(1)]),
allowed_roles: None,
blocked_panes: Vec::new(),
blocked_roles: Vec::new(),
}),
ChildSide::Second,
);
let right_idx = tree.pane_index(right_pane).expect("right pane should exist");
let tab = match tree.node(right_idx) {
Node::Leaf { tabs, .. } => tabs.first().expect("right pane should hold a tab"),
other => panic!("expected right leaf, got {:?}", other),
};
assert!(!tab_allows_target_pane(tab, right_pane, None));
}
#[test]
fn tab_drop_policy_honors_blocked_pane_list() {
let policy = TabDropPolicy {
locked_to_pane: None,
locked_to_role: None,
allowed_panes: None,
allowed_roles: None,
blocked_panes: vec![PaneId::from_raw(7)],
blocked_roles: Vec::new(),
};
assert!(policy.allows_target(PaneId::from_raw(3), None));
assert!(!policy.allows_target(PaneId::from_raw(7), None));
}
#[test]
fn tab_drop_policy_honors_role_filters() {
let policy = TabDropPolicy {
locked_to_pane: None,
locked_to_role: Some(PaneRole::Terminal),
allowed_panes: None,
allowed_roles: None,
blocked_panes: Vec::new(),
blocked_roles: Vec::new(),
};
assert!(policy.allows_target(PaneId::from_raw(1), Some(PaneRole::Terminal)));
assert!(!policy.allows_target(PaneId::from_raw(1), Some(PaneRole::Editor)));
}
}
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
use egui::Id;
use crate::tree::PaneId;
/// Stable [`Id`] for the resize handle between the two children of a split node.
pub(crate) fn resize_handle_id(tree_node_idx: usize) -> Id {
Id::new("grimdock::resize").with(tree_node_idx)
}
/// Stable [`Id`] for a tab button inside a leaf node.
pub(crate) fn tab_button_id(pane_id: PaneId, tab_pos: usize) -> Id {
Id::new("grimdock::tab")
.with(pane_id.into_raw())
.with(tab_pos)
}
/// Stable [`Id`] for a tab close button inside a leaf node.
pub(crate) fn tab_close_button_id(pane_id: PaneId, tab_pos: usize) -> Id {
Id::new("grimdock::tab_close")
.with(pane_id.into_raw())
.with(tab_pos)
}
/// Stable [`Id`] for the content child-UI of a leaf node.
pub(crate) fn pane_content_id(pane_id: PaneId) -> Id {
Id::new("grimdock::content").with(pane_id.into_raw())
}
+272
View File
@@ -0,0 +1,272 @@
use egui::{Rect, Sense, Ui, vec2};
use crate::{
ids::resize_handle_id,
style::PanelStyle,
tree::{Node, PanelTree, SplitDir},
};
/// Run the layout pass over the entire tree.
///
/// Assigns a rectangle to every node starting from the root, renders resize
/// handles for split nodes, and handles ratio updates from handle drags.
///
/// Returns a list of `(leaf_index, content_rect)` pairs for consumption by
/// the header and content passes.
pub(crate) fn layout_pass<T: Clone + 'static>(
ui: &mut Ui,
tree: &mut PanelTree<T>,
style: &PanelStyle,
) -> Vec<(usize, Rect)> {
let root_rect = ui.available_rect_before_wrap();
let mut leaf_rects: Vec<(usize, Rect)> = Vec::new();
layout_node(ui, tree, 0, root_rect, style, &mut leaf_rects);
leaf_rects
}
/// Compute whether a node has vanished from the layout. A leaf with no tabs
/// is considered vanished UNLESS its `persist_when_empty` option is set, which
/// keeps the pane alive so that split ratios and sibling panes are preserved.
fn is_node_vanished<T: Clone + 'static>(tree: &PanelTree<T>, idx: usize) -> bool {
match tree.node(idx) {
Node::Leaf { tabs, options, .. } => tabs.is_empty() && !options.persist_when_empty,
Node::Split { .. } => {
let left = PanelTree::<T>::left_child(idx);
let right = PanelTree::<T>::right_child(idx);
is_node_vanished(tree, left) && is_node_vanished(tree, right)
}
Node::Empty => true,
}
}
/// Compute the effective collapsed thickness for a child node. Collapsed
/// leaves that have no tabs (i.e. floated panels) are assigned zero space so
/// they vanish from the layout entirely.
fn effective_collapsed_thickness<T: Clone + 'static>(
tree: &PanelTree<T>,
child_idx: usize,
collapsed: bool,
default: f32,
) -> f32 {
if !collapsed {
return 0.0;
}
if is_node_vanished(tree, child_idx) {
0.0
} else {
default
}
}
fn layout_node<T: Clone + 'static>(
ui: &mut Ui,
tree: &mut PanelTree<T>,
idx: usize,
rect: Rect,
style: &PanelStyle,
leaf_rects: &mut Vec<(usize, Rect)>,
) {
// Store the rect in the node for DnD hit-testing later.
match tree.node_mut(idx) {
Node::Split { rect: r, .. } => *r = rect,
Node::Leaf { rect: r, .. } => *r = rect,
Node::Empty => return,
}
match tree.node(idx).clone() {
Node::Split { dir, mut ratio, .. } => {
let handle_w = style.handle_width;
let first_idx = PanelTree::<T>::left_child(idx);
let second_idx = PanelTree::<T>::right_child(idx);
let first_collapsed = tree.is_collapsed(first_idx) || is_node_vanished(tree, first_idx);
let second_collapsed = tree.is_collapsed(second_idx) || is_node_vanished(tree, second_idx);
let resize_locked = tree.split_resize_locked(idx);
let first_ct = effective_collapsed_thickness(
tree, first_idx, first_collapsed, style.collapsed_pane_thickness,
);
let second_ct = effective_collapsed_thickness(
tree, second_idx, second_collapsed, style.collapsed_pane_thickness,
);
let (_first_rect, handle_rect, _second_rect) = split_rect(
rect, dir, ratio, handle_w,
first_collapsed, second_collapsed,
first_ct, second_ct,
style.min_pane_size, !resize_locked,
);
// Draw and interact with the resize handle.
let handle_id = resize_handle_id(idx);
let handle_response = ui.interact(
handle_rect,
handle_id,
if resize_locked {
Sense::hover()
} else {
Sense::drag()
},
);
let is_active = handle_response.dragged() || handle_response.hovered();
let color = if resize_locked {
style.handle.locked_color
} else if is_active {
style.handle.hover_color
} else {
style.handle.color
};
ui.painter().rect_filled(handle_rect, 0.0, color);
if !resize_locked && handle_response.hovered() {
let cursor = match dir {
SplitDir::Horizontal => egui::CursorIcon::ResizeHorizontal,
SplitDir::Vertical => egui::CursorIcon::ResizeVertical,
};
ui.ctx().set_cursor_icon(cursor);
}
if handle_response.dragged()
&& !resize_locked
&& !first_collapsed
&& !second_collapsed
{
let delta = handle_response.drag_delta();
let delta_ratio = match dir {
SplitDir::Horizontal => delta.x / rect.width().max(1.0),
SplitDir::Vertical => delta.y / rect.height().max(1.0),
};
let min_ratio = style.min_pane_size
/ match dir {
SplitDir::Horizontal => rect.width().max(1.0),
SplitDir::Vertical => rect.height().max(1.0),
};
let max_ratio = 1.0 - min_ratio;
ratio = (ratio + delta_ratio).clamp(min_ratio.max(0.0), max_ratio.min(1.0));
if let Node::Split { ratio: r, .. } = tree.node_mut(idx) {
*r = ratio;
}
}
let (first_rect, _, second_rect) = split_rect(
rect, dir, ratio, handle_w,
first_collapsed, second_collapsed,
first_ct, second_ct,
style.min_pane_size, !resize_locked,
);
layout_node(ui, tree, first_idx, first_rect, style, leaf_rects);
layout_node(ui, tree, second_idx, second_rect, style, leaf_rects);
}
Node::Leaf { tabs, collapsed, .. } => {
let header_height = if tree.header_visible(idx) {
if collapsed && tabs.is_empty() { 0.0 } else { style.header_height }
} else {
0.0
};
let content_rect = Rect::from_min_size(
rect.min + vec2(0.0, header_height),
vec2(rect.width(), (rect.height() - header_height).max(0.0)),
);
leaf_rects.push((idx, content_rect));
}
Node::Empty => {}
}
}
/// Split `rect` into two sub-rects with a handle strip between them.
///
/// `first_ct` / `second_ct` are the collapsed thickness for each side when
/// that side is collapsed. A value of `0.0` makes the collapsed side vanish.
///
/// When `enforce_min` is `true`, each non-collapsed side is guaranteed at least
/// `min_pane_size` pixels of extent. Splits whose resize handle is locked
/// (e.g. the fixed-width Tools column) pass `enforce_min = false` so their
/// ratio-driven (possibly narrow) width is preserved.
fn split_rect(
rect: Rect,
dir: SplitDir,
ratio: f32,
handle_w: f32,
first_collapsed: bool,
second_collapsed: bool,
first_ct: f32,
second_ct: f32,
min_pane_size: f32,
enforce_min: bool,
) -> (Rect, Rect, Rect) {
let total_extent = match dir {
SplitDir::Horizontal => rect.width(),
SplitDir::Vertical => rect.height(),
}
.max(0.0);
let first_vanished = first_collapsed && first_ct == 0.0;
let second_vanished = second_collapsed && second_ct == 0.0;
if first_vanished && second_vanished {
return (Rect::NOTHING, Rect::NOTHING, Rect::NOTHING);
} else if first_vanished {
match dir {
SplitDir::Horizontal => {
let empty = Rect::from_min_max(rect.min, rect.min);
return (empty, empty, rect);
}
SplitDir::Vertical => {
let empty = Rect::from_min_max(rect.min, rect.min);
return (empty, empty, rect);
}
}
} else if second_vanished {
match dir {
SplitDir::Horizontal => {
let empty = Rect::from_min_max(rect.max, rect.max);
return (rect, empty, empty);
}
SplitDir::Vertical => {
let empty = Rect::from_min_max(rect.max, rect.max);
return (rect, empty, empty);
}
}
}
let first_extent = if first_collapsed && !second_collapsed {
first_ct.min((total_extent - handle_w).max(0.0))
} else if second_collapsed && !first_collapsed {
(total_extent - handle_w - second_ct).max(0.0)
} else {
let raw = total_extent * ratio;
if enforce_min && min_pane_size > 0.0 {
let usable = (total_extent - handle_w).max(0.0);
let min = min_pane_size.min(usable * 0.5);
raw.clamp(min, (usable - min).max(min))
} else {
raw
}
};
match dir {
SplitDir::Horizontal => {
let split_x = rect.min.x + first_extent;
let first = Rect::from_min_max(rect.min, egui::pos2(split_x - handle_w / 2.0, rect.max.y));
let handle = Rect::from_min_max(
egui::pos2(split_x - handle_w / 2.0, rect.min.y),
egui::pos2(split_x + handle_w / 2.0, rect.max.y),
);
let second = Rect::from_min_max(egui::pos2(split_x + handle_w / 2.0, rect.min.y), rect.max);
(first, handle, second)
}
SplitDir::Vertical => {
let split_y = rect.min.y + first_extent;
let first = Rect::from_min_max(rect.min, egui::pos2(rect.max.x, split_y - handle_w / 2.0));
let handle = Rect::from_min_max(
egui::pos2(rect.min.x, split_y - handle_w / 2.0),
egui::pos2(rect.max.x, split_y + handle_w / 2.0),
);
let second = Rect::from_min_max(egui::pos2(rect.min.x, split_y + handle_w / 2.0), rect.max);
(first, handle, second)
}
}
}
+243
View File
@@ -0,0 +1,243 @@
#![allow(dead_code, unused_imports, unused_variables)]
//! # grimdock
//!
//! A dockable panel layout system for [egui](https://github.com/emilk/egui).
//!
//! Provides an IDE-style workspace where panels can be split, resized, and
//! rearranged by dragging tabs — all within egui's immediate-mode layer.
//!
//! ## Quick start
//!
//! ```rust,no_run
//! # use grimdock::{PanelTree, PanelStyle, PanelContext, Tab};
//! # fn show(ui: &mut egui::Ui, tree: &mut PanelTree<&'static str>) {
//! let style = PanelStyle::default();
//! PanelContext::new(ui, tree, &style).show(|ui, tab_id| {
//! ui.label(*tab_id);
//! });
//! # }
//! ```
mod content;
mod dnd;
mod header;
mod ids;
mod layout;
mod persistence;
mod style;
mod tab;
mod tree;
pub use persistence::{
LegacyPersistedNode, LegacyPersistedPanelTree, PersistError, PersistedNode, PersistedPanelTree,
PersistedPanelTreeFile, PANEL_TREE_FORMAT_VERSION,
};
pub use style::{
ContentStyle, HandleStyle, HeaderButtonStyle, HeaderStyle, OverlayStyle, PaneStyleOverride,
PanelStyle, TabStateStyle, TabStyle, TabStyleOverride, TypographyStyle,
};
pub use tab::{Tab, TabDropPolicy, TabIcon};
pub use tree::{
ChildSide, DropPolicy, HeaderVisibility, Node, Pane, PaneAnchor, PaneBuilder, PaneId, PaneMut,
PaneOptions, PaneRole, PanelTree, SplitDir,
};
use egui::Ui;
/// Built-in behavior for resolving add/open actions when a tab with the same
/// identifier may already exist.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OpenBehavior {
/// Always insert a fresh tab, even if the same identifier already exists.
AllowDuplicate,
/// Focus the existing tab anywhere in the dock instead of creating another.
FocusExisting,
/// Focus the existing tab only if it is already in the target pane.
/// Otherwise insert a duplicate into the target pane.
FocusExistingInPane,
}
/// A caller-provided entry shown in built-in add-tab and split-here menus.
#[derive(Clone, Debug)]
pub struct AddTabEntry<T: Clone + 'static> {
pub title: String,
pub tab: Tab<T>,
pub open_behavior: OpenBehavior,
}
impl<T: Clone + 'static> AddTabEntry<T> {
pub fn new(title: impl Into<String>, tab: Tab<T>) -> Self {
Self {
title: title.into(),
tab,
open_behavior: OpenBehavior::FocusExisting,
}
}
pub fn with_open_behavior(mut self, open_behavior: OpenBehavior) -> Self {
self.open_behavior = open_behavior;
self
}
}
/// A caller-provided pane action shown in the built-in pane menu.
#[derive(Clone, Debug)]
pub struct PaneMenuAction {
pub id: String,
pub title: String,
}
impl PaneMenuAction {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
}
}
}
/// A custom pane action invoked from the built-in pane menu during this frame.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PaneActionInvocation {
pub pane_id: PaneId,
pub action_id: String,
}
/// Mutations emitted by a single [`PanelContext::show`] pass.
#[derive(Debug)]
pub struct PanelOutput<T> {
/// Tabs closed from pane headers during this frame.
pub closed_tabs: Vec<T>,
/// Custom pane actions invoked from built-in pane menus during this frame.
pub pane_actions: Vec<PaneActionInvocation>,
}
impl<T> Default for PanelOutput<T> {
fn default() -> Self {
Self {
closed_tabs: Vec::new(),
pane_actions: Vec::new(),
}
}
}
/// Entry point for rendering the panel layout each frame.
///
/// The caller owns and persists the [`PanelTree`] across frames. Each frame,
/// construct a `PanelContext` and call [`PanelContext::show`] with a render
/// callback.
///
/// # Example
///
/// ```rust,no_run
/// # use grimdock::{PanelTree, PanelStyle, PanelContext, Tab};
/// # fn show(ui: &mut egui::Ui, tree: &mut PanelTree<&'static str>) {
/// PanelContext::new(ui, tree, &PanelStyle::default()).show(|ui, id| {
/// ui.heading(*id);
/// });
/// # }
/// ```
pub struct PanelContext<'ui, T: Clone + 'static> {
ui: &'ui mut Ui,
tree: &'ui mut PanelTree<T>,
style: &'ui PanelStyle,
add_tab_entries: &'ui [AddTabEntry<T>],
add_tab_provider: Option<&'ui dyn Fn(PaneId, &PanelTree<T>) -> Vec<AddTabEntry<T>>>,
pane_menu_actions: &'ui [PaneMenuAction],
pane_menu_provider: Option<&'ui dyn Fn(PaneId, &PanelTree<T>) -> Vec<PaneMenuAction>>,
}
impl<'ui, T: Clone + 'static> PanelContext<'ui, T> {
pub fn new(
ui: &'ui mut Ui,
tree: &'ui mut PanelTree<T>,
style: &'ui PanelStyle,
) -> Self {
Self {
ui,
tree,
style,
add_tab_entries: &[],
add_tab_provider: None,
pane_menu_actions: &[],
pane_menu_provider: None,
}
}
/// Provide the same entries for built-in add-tab, overflow, and split-here
/// menus in every pane.
pub fn with_add_tab_entries(mut self, add_tab_entries: &'ui [AddTabEntry<T>]) -> Self {
self.add_tab_entries = add_tab_entries;
self
}
/// Provide pane-scoped entries for built-in add-tab and split-here menus.
pub fn with_add_tab_provider(
mut self,
add_tab_provider: &'ui dyn Fn(PaneId, &PanelTree<T>) -> Vec<AddTabEntry<T>>,
) -> Self {
self.add_tab_provider = Some(add_tab_provider);
self
}
/// Provide the same custom pane menu actions for every pane.
pub fn with_pane_menu_actions(mut self, pane_menu_actions: &'ui [PaneMenuAction]) -> Self {
self.pane_menu_actions = pane_menu_actions;
self
}
/// Provide pane-scoped custom actions for the built-in pane menu.
pub fn with_pane_menu_provider(
mut self,
pane_menu_provider: &'ui dyn Fn(PaneId, &PanelTree<T>) -> Vec<PaneMenuAction>,
) -> Self {
self.pane_menu_provider = Some(pane_menu_provider);
self
}
/// Run all three rendering passes and resolve any drag-and-drop that
/// completed this frame.
///
/// `render` is called once per visible leaf with the egui [`Ui`] for that
/// pane's content area and a reference to the focused tab's identifier.
pub fn show(self, render: impl FnMut(&mut Ui, &T)) -> PanelOutput<T>
where
T: PartialEq,
{
let Self {
ui,
tree,
style,
add_tab_entries,
add_tab_provider,
pane_menu_actions,
pane_menu_provider,
} = self;
let mut output = PanelOutput::default();
// Pass 1: layout — assign rects, draw resize handles.
let leaf_rects = layout::layout_pass(ui, tree, style);
// Pass 2: headers — draw tab buttons, initiate drags.
header::header_pass(
ui,
tree,
&leaf_rects,
style,
add_tab_entries,
add_tab_provider,
pane_menu_actions,
pane_menu_provider,
&mut output.closed_tabs,
&mut output.pane_actions,
);
// Pass 3: content — invoke caller callback for each focused tab.
content::content_pass(ui, tree, &leaf_rects, style, render);
// Pass 4: drag-and-drop — draw overlay, resolve drops.
dnd::dnd_pass(ui, tree, style);
output
}
}
+457
View File
@@ -0,0 +1,457 @@
use std::collections::HashSet;
use crate::{
tab::Tab,
tree::{Node, PaneId, PaneOptions, PanelTree, SplitDir},
};
/// Current persisted layout format version.
pub const PANEL_TREE_FORMAT_VERSION: u32 = 1;
/// Versioned, runtime-independent layout payload for persistence.
///
/// This format is the intended stable save/load contract for `grimdock`.
/// It avoids runtime-only fields such as layout rects and carries an explicit
/// version number so future structural changes can be migrated intentionally.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PersistedPanelTree<T: Clone + 'static> {
pub version: u32,
pub next_pane_id: u64,
pub nodes: Vec<PersistedNode<T>>,
}
/// Persisted node payload for [`PersistedPanelTree`].
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PersistedNode<T: Clone + 'static> {
Empty,
Split {
dir: SplitDir,
ratio: f32,
},
Leaf {
pane: PaneId,
tabs: Vec<Tab<T>>,
focused: usize,
options: PaneOptions,
collapsed: bool,
},
}
/// Legacy unversioned persistence shape that mirrors direct serde of
/// [`PanelTree`]. This is only used for migration into the explicit versioned
/// format.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LegacyPersistedPanelTree<T: Clone + 'static> {
pub nodes: Vec<LegacyPersistedNode<T>>,
pub next_pane_id: u64,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum LegacyPersistedNode<T: Clone + 'static> {
Empty,
Split {
dir: SplitDir,
ratio: f32,
},
Leaf {
pane: PaneId,
tabs: Vec<Tab<T>>,
focused: usize,
options: PaneOptions,
collapsed: bool,
},
}
/// Deserialization entry point that accepts both the current versioned format
/// and the legacy unversioned format.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(untagged))]
pub enum PersistedPanelTreeFile<T: Clone + 'static> {
Versioned(PersistedPanelTree<T>),
Legacy(LegacyPersistedPanelTree<T>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PersistError {
UnsupportedVersion(u32),
EmptyTree,
EmptyLeaf { pane: PaneId },
FocusOutOfRange {
pane: PaneId,
focused: usize,
tab_count: usize,
},
DuplicatePaneId(PaneId),
NextPaneIdTooLow {
next_pane_id: u64,
required_minimum: u64,
},
}
impl<T: Clone + 'static> PersistedPanelTree<T> {
/// Export a runtime tree into the current versioned persistence format.
pub fn current(tree: &PanelTree<T>) -> Self {
Self {
version: PANEL_TREE_FORMAT_VERSION,
next_pane_id: tree.next_pane_id,
nodes: tree
.nodes
.iter()
.map(|node| match node {
Node::Empty => PersistedNode::Empty,
Node::Split { dir, ratio, .. } => PersistedNode::Split {
dir: *dir,
ratio: *ratio,
},
Node::Leaf {
pane,
tabs,
focused,
options,
collapsed,
..
} => PersistedNode::Leaf {
pane: *pane,
tabs: tabs.clone(),
focused: *focused,
options: *options,
collapsed: *collapsed,
},
})
.collect(),
}
}
/// Validate the persisted payload before attempting to build a runtime tree.
pub fn validate(&self) -> Result<(), PersistError> {
if self.nodes.is_empty() {
return Err(PersistError::EmptyTree);
}
let mut panes = HashSet::new();
let mut max_pane = 0_u64;
for node in &self.nodes {
if let PersistedNode::Leaf {
pane,
tabs,
focused,
options,
..
} = node
{
if !panes.insert(*pane) {
return Err(PersistError::DuplicatePaneId(*pane));
}
max_pane = max_pane.max(pane.into_raw());
if tabs.is_empty() {
if options.persist_when_empty {
if *focused != 0 {
return Err(PersistError::FocusOutOfRange {
pane: *pane,
focused: *focused,
tab_count: 0,
});
}
continue;
}
return Err(PersistError::EmptyLeaf { pane: *pane });
}
if *focused >= tabs.len() {
return Err(PersistError::FocusOutOfRange {
pane: *pane,
focused: *focused,
tab_count: tabs.len(),
});
}
}
}
let required_minimum = max_pane.saturating_add(1).max(1);
if self.next_pane_id < required_minimum {
return Err(PersistError::NextPaneIdTooLow {
next_pane_id: self.next_pane_id,
required_minimum,
});
}
Ok(())
}
/// Convert the versioned persisted payload into a runtime tree.
pub fn into_panel_tree(self) -> Result<PanelTree<T>, PersistError> {
if self.version != PANEL_TREE_FORMAT_VERSION {
return Err(PersistError::UnsupportedVersion(self.version));
}
self.validate()?;
Ok(PanelTree {
nodes: self
.nodes
.into_iter()
.map(|node| match node {
PersistedNode::Empty => Node::Empty,
PersistedNode::Split { dir, ratio } => Node::Split {
dir,
ratio,
rect: egui::Rect::NOTHING,
},
PersistedNode::Leaf {
pane,
tabs,
focused,
options,
collapsed,
} => Node::Leaf {
pane,
tabs,
focused,
options,
collapsed,
rect: egui::Rect::NOTHING,
},
})
.collect(),
next_pane_id: self.next_pane_id,
})
}
}
impl<T: Clone + 'static> From<&PanelTree<T>> for PersistedPanelTree<T> {
fn from(value: &PanelTree<T>) -> Self {
Self::current(value)
}
}
impl<T: Clone + 'static> From<&PanelTree<T>> for LegacyPersistedPanelTree<T> {
fn from(value: &PanelTree<T>) -> Self {
Self {
next_pane_id: value.next_pane_id,
nodes: value
.nodes
.iter()
.map(|node| match node {
Node::Empty => LegacyPersistedNode::Empty,
Node::Split { dir, ratio, .. } => LegacyPersistedNode::Split {
dir: *dir,
ratio: *ratio,
},
Node::Leaf {
pane,
tabs,
focused,
options,
collapsed,
..
} => LegacyPersistedNode::Leaf {
pane: *pane,
tabs: tabs.clone(),
focused: *focused,
options: *options,
collapsed: *collapsed,
},
})
.collect(),
}
}
}
impl<T: Clone + 'static> From<LegacyPersistedPanelTree<T>> for PersistedPanelTree<T> {
fn from(value: LegacyPersistedPanelTree<T>) -> Self {
Self {
version: PANEL_TREE_FORMAT_VERSION,
next_pane_id: value.next_pane_id,
nodes: value
.nodes
.into_iter()
.map(|node| match node {
LegacyPersistedNode::Empty => PersistedNode::Empty,
LegacyPersistedNode::Split { dir, ratio } => PersistedNode::Split { dir, ratio },
LegacyPersistedNode::Leaf {
pane,
tabs,
focused,
options,
collapsed,
} => PersistedNode::Leaf {
pane,
tabs,
focused,
options,
collapsed,
},
})
.collect(),
}
}
}
impl<T: Clone + 'static> PersistedPanelTreeFile<T> {
/// Migrate either the current versioned payload or the legacy payload into
/// the current versioned format.
pub fn migrate(self) -> Result<PersistedPanelTree<T>, PersistError> {
match self {
PersistedPanelTreeFile::Versioned(layout) => {
if layout.version != PANEL_TREE_FORMAT_VERSION {
return Err(PersistError::UnsupportedVersion(layout.version));
}
layout.validate()?;
Ok(layout)
}
PersistedPanelTreeFile::Legacy(layout) => {
let layout = PersistedPanelTree::from(layout);
layout.validate()?;
Ok(layout)
}
}
}
/// Convert either the current or legacy persisted payload into a runtime tree.
pub fn into_panel_tree(self) -> Result<PanelTree<T>, PersistError> {
self.migrate()?.into_panel_tree()
}
}
impl<T: Clone + 'static> PanelTree<T> {
/// Export the current layout into the versioned persisted format.
pub fn to_persisted(&self) -> PersistedPanelTree<T> {
PersistedPanelTree::from(self)
}
/// Reconstruct a runtime tree from the versioned persisted format.
pub fn from_persisted(layout: PersistedPanelTree<T>) -> Result<Self, PersistError> {
layout.into_panel_tree()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DropPolicy, HeaderVisibility, PaneBuilder, PaneRole, TabDropPolicy, TabStyleOverride};
fn sample_tree() -> PanelTree<&'static str> {
let mut options = PaneOptions::default();
options.header_visibility = HeaderVisibility::WhenMultipleTabs;
options.drop_policy = DropPolicy::merge_only();
options.style_override = Some(crate::style::PaneStyleOverride {
header_bg: Some(egui::Color32::from_rgb(1, 2, 3)),
content_bg: None,
border_color: None,
accent_color: None,
});
let mut tree = PanelTree::from_pane(
PaneBuilder::new(
Tab::new("A", "a").with_style_override(TabStyleOverride {
active_bg: Some(egui::Color32::from_rgb(4, 5, 6)),
inactive_bg: None,
hovered_bg: None,
text_color: None,
accent_color: None,
icon_color: None,
max_width: Some(120.0),
}),
)
.push_tab(Tab::new("B", "b"))
.with_options(options),
);
tree.split_leaf(0, SplitDir::Horizontal, Tab::new("C", "c"), crate::ChildSide::Second);
tree
}
#[test]
fn persisted_round_trip_restores_tree() {
let tree = sample_tree();
let persisted = tree.to_persisted();
let restored = PanelTree::from_persisted(persisted).expect("persistence should round-trip");
assert_eq!(restored.next_pane_id, tree.next_pane_id);
assert_eq!(restored.nodes.len(), tree.nodes.len());
assert_eq!(restored.find_pane_containing(&"a"), tree.find_pane_containing(&"a"));
assert_eq!(restored.find_pane_containing(&"c"), tree.find_pane_containing(&"c"));
}
#[test]
fn persisted_validation_rejects_invalid_focus() {
let mut layout = sample_tree().to_persisted();
layout.nodes[1] = PersistedNode::Leaf {
pane: PaneId::from_raw(1),
tabs: vec![Tab::new("A", "a")],
focused: 3,
options: PaneOptions::default(),
collapsed: false,
};
let err = layout.validate().expect_err("layout should be invalid");
assert!(matches!(err, PersistError::FocusOutOfRange { .. }));
}
#[test]
fn legacy_layout_migrates_to_current_format() {
let legacy = LegacyPersistedPanelTree::from(&sample_tree());
let migrated = PersistedPanelTreeFile::Legacy(legacy)
.migrate()
.expect("legacy layout should migrate");
assert_eq!(migrated.version, PANEL_TREE_FORMAT_VERSION);
assert!(migrated.validate().is_ok());
}
#[test]
fn persisted_validation_allows_persistent_empty_leaf() {
let mut options = PaneOptions::default();
options.persist_when_empty = true;
let layout: PersistedPanelTree<&'static str> = PersistedPanelTree {
version: PANEL_TREE_FORMAT_VERSION,
next_pane_id: 2,
nodes: vec![PersistedNode::Leaf {
pane: PaneId::from_raw(1),
tabs: Vec::new(),
focused: 0,
options,
collapsed: false,
}],
};
assert!(layout.validate().is_ok());
}
#[test]
fn persisted_round_trip_preserves_tab_drop_policy() {
let tree = PanelTree::new(vec![
Tab::new("A", "a").with_drop_policy(TabDropPolicy {
locked_to_pane: Some(PaneId::from_raw(9)),
locked_to_role: Some(PaneRole::Terminal),
allowed_panes: Some(vec![PaneId::from_raw(9), PaneId::from_raw(10)]),
allowed_roles: Some(vec![PaneRole::Editor, PaneRole::Sidebar]),
blocked_panes: vec![PaneId::from_raw(10)],
blocked_roles: vec![PaneRole::Inspector],
}),
]);
let persisted = tree.to_persisted();
let restored = PanelTree::from_persisted(persisted).expect("persistence should round-trip");
match restored.node(0) {
Node::Leaf { tabs, .. } => {
assert_eq!(tabs[0].drop_policy.locked_to_pane, Some(PaneId::from_raw(9)));
assert_eq!(tabs[0].drop_policy.locked_to_role, Some(PaneRole::Terminal));
assert_eq!(
tabs[0].drop_policy.allowed_panes,
Some(vec![PaneId::from_raw(9), PaneId::from_raw(10)])
);
assert_eq!(
tabs[0].drop_policy.allowed_roles,
Some(vec![PaneRole::Editor, PaneRole::Sidebar])
);
assert_eq!(tabs[0].drop_policy.blocked_panes, vec![PaneId::from_raw(10)]);
assert_eq!(tabs[0].drop_policy.blocked_roles, vec![PaneRole::Inspector]);
}
other => panic!("expected restored root leaf, got {:?}", other),
}
}
}
+376
View File
@@ -0,0 +1,376 @@
use egui::{Color32, CornerRadius, FontFamily, FontId, Stroke, Style, TextStyle, Visuals};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HeaderButtonStyle {
pub bg: Color32,
pub hover_bg: Color32,
pub stroke_color: Color32,
pub icon_color: Color32,
pub hover_icon_color: Color32,
pub rounding: CornerRadius,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HeaderStyle {
pub bg: Color32,
pub border_color: Color32,
pub button: HeaderButtonStyle,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ContentStyle {
pub bg: Color32,
pub border_color: Color32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HandleStyle {
pub color: Color32,
pub hover_color: Color32,
pub locked_color: Color32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OverlayStyle {
pub fill: Color32,
pub stroke: Stroke,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TabStateStyle {
pub bg: Color32,
pub text_color: Color32,
pub accent_color: Color32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TabStyle {
pub active: TabStateStyle,
pub inactive: TabStateStyle,
pub hovered: TabStateStyle,
pub rounding: CornerRadius,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TypographyStyle {
pub tab_title_font: FontId,
pub tab_icon_text_font: FontId,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PaneStyleOverride {
pub header_bg: Option<Color32>,
pub content_bg: Option<Color32>,
pub border_color: Option<Color32>,
pub accent_color: Option<Color32>,
}
impl PaneStyleOverride {
pub const fn none() -> Self {
Self {
header_bg: None,
content_bg: None,
border_color: None,
accent_color: None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TabStyleOverride {
pub active_bg: Option<Color32>,
pub inactive_bg: Option<Color32>,
pub hovered_bg: Option<Color32>,
pub text_color: Option<Color32>,
pub accent_color: Option<Color32>,
/// Override colour applied only to the leading icon, not the title text.
pub icon_color: Option<Color32>,
/// Maximum header width for this tab when there is spare room.
/// `None` means the tab may stretch to fill the available width.
pub max_width: Option<f32>,
}
impl TabStyleOverride {
pub const fn none() -> Self {
Self {
active_bg: None,
inactive_bg: None,
hovered_bg: None,
text_color: None,
accent_color: None,
icon_color: None,
max_width: None,
}
}
}
/// All visual parameters for the panel layout system.
#[derive(Clone, Debug)]
pub struct PanelStyle {
pub header_height: f32,
pub collapsed_pane_thickness: f32,
pub handle_width: f32,
pub min_pane_size: f32,
pub content_inset: f32,
pub pane_rounding: CornerRadius,
pub typography: TypographyStyle,
pub header: HeaderStyle,
pub content: ContentStyle,
pub tabs: TabStyle,
pub handle: HandleStyle,
pub overlay: OverlayStyle,
}
impl PanelStyle {
pub fn from_egui_style(style: &Style) -> Self {
let mut panel_style = Self::from_visuals(&style.visuals);
let button_font = style
.text_styles
.get(&TextStyle::Button)
.cloned()
.unwrap_or_else(|| FontId::proportional(12.0));
let small_font = style
.text_styles
.get(&TextStyle::Small)
.cloned()
.unwrap_or_else(|| FontId::monospace(11.0));
panel_style.typography = TypographyStyle {
tab_title_font: button_font,
tab_icon_text_font: small_font,
};
panel_style
}
pub fn from_visuals(visuals: &Visuals) -> Self {
let widgets = &visuals.widgets;
let inactive_fill = widgets.inactive.weak_bg_fill;
let hovered_fill = widgets.hovered.weak_bg_fill;
let active_fill = widgets.active.weak_bg_fill;
let accent = widgets.active.bg_fill;
let border = widgets.noninteractive.bg_stroke.color;
Self {
header_height: 26.0,
collapsed_pane_thickness: 32.0,
handle_width: 5.0,
min_pane_size: 200.0,
content_inset: 0.0,
pane_rounding: visuals.window_corner_radius,
typography: TypographyStyle {
tab_title_font: FontId::new(12.0, FontFamily::Proportional),
tab_icon_text_font: FontId::new(11.0, FontFamily::Monospace),
},
header: HeaderStyle {
bg: widgets.noninteractive.bg_fill,
border_color: border,
button: HeaderButtonStyle {
bg: inactive_fill,
hover_bg: hovered_fill,
stroke_color: border,
icon_color: widgets.inactive.fg_stroke.color,
hover_icon_color: widgets.hovered.fg_stroke.color,
rounding: CornerRadius::same(3),
},
},
content: ContentStyle {
bg: visuals.panel_fill,
border_color: border,
},
tabs: TabStyle {
active: TabStateStyle {
bg: active_fill,
text_color: widgets.active.fg_stroke.color,
accent_color: accent,
},
inactive: TabStateStyle {
bg: inactive_fill,
text_color: widgets.inactive.fg_stroke.color,
accent_color: accent,
},
hovered: TabStateStyle {
bg: hovered_fill,
text_color: widgets.hovered.fg_stroke.color,
accent_color: accent,
},
rounding: CornerRadius::same(3),
},
handle: HandleStyle {
color: widgets.noninteractive.bg_fill,
hover_color: widgets.hovered.bg_fill,
locked_color: widgets.noninteractive.bg_fill.gamma_multiply(0.6),
},
overlay: OverlayStyle {
fill: accent.gamma_multiply(0.35),
stroke: Stroke::new(1.0, accent),
},
}
}
pub fn pane_header_bg(&self, pane: Option<PaneStyleOverride>) -> Color32 {
pane.and_then(|pane| pane.header_bg).unwrap_or(self.header.bg)
}
pub fn pane_content_bg(&self, pane: Option<PaneStyleOverride>) -> Color32 {
pane.and_then(|pane| pane.content_bg).unwrap_or(self.content.bg)
}
pub fn pane_border_color(&self, pane: Option<PaneStyleOverride>) -> Color32 {
pane.and_then(|pane| pane.border_color)
.unwrap_or(self.content.border_color)
}
pub fn pane_accent_color(&self, pane: Option<PaneStyleOverride>) -> Color32 {
pane.and_then(|pane| pane.accent_color)
.unwrap_or(self.tabs.active.accent_color)
}
pub fn tab_state(
&self,
active: bool,
hovered: bool,
pane: Option<PaneStyleOverride>,
tab: Option<TabStyleOverride>,
) -> TabStateStyle {
let mut state = if active {
self.tabs.active
} else if hovered {
self.tabs.hovered
} else {
self.tabs.inactive
};
if let Some(tab) = tab {
state.bg = if active {
tab.active_bg.unwrap_or(state.bg)
} else if hovered {
tab.hovered_bg.unwrap_or(state.bg)
} else {
tab.inactive_bg.unwrap_or(state.bg)
};
state.text_color = tab.text_color.unwrap_or(state.text_color);
state.accent_color = tab.accent_color.unwrap_or(state.accent_color);
}
if let Some(pane) = pane {
state.accent_color = pane.accent_color.unwrap_or(state.accent_color);
}
state
}
}
impl Default for PanelStyle {
fn default() -> Self {
Self {
header_height: 26.0,
collapsed_pane_thickness: 32.0,
handle_width: 5.0,
min_pane_size: 200.0,
content_inset: 0.0,
pane_rounding: CornerRadius::same(2),
typography: TypographyStyle {
tab_title_font: FontId::new(12.0, FontFamily::Proportional),
tab_icon_text_font: FontId::new(11.0, FontFamily::Monospace),
},
header: HeaderStyle {
bg: Color32::from_rgb(30, 30, 35),
border_color: Color32::from_rgb(45, 45, 55),
button: HeaderButtonStyle {
bg: Color32::from_rgb(35, 35, 42),
hover_bg: Color32::from_rgb(50, 50, 60),
stroke_color: Color32::from_rgb(45, 45, 55),
icon_color: Color32::from_rgb(210, 210, 220),
hover_icon_color: Color32::from_rgb(80, 130, 220),
rounding: CornerRadius::same(3),
},
},
content: ContentStyle {
bg: Color32::from_rgb(27, 27, 31),
border_color: Color32::from_rgb(45, 45, 55),
},
tabs: TabStyle {
active: TabStateStyle {
bg: Color32::from_rgb(50, 50, 60),
text_color: Color32::from_rgb(210, 210, 220),
accent_color: Color32::from_rgb(80, 130, 220),
},
inactive: TabStateStyle {
bg: Color32::from_rgb(35, 35, 42),
text_color: Color32::from_rgb(210, 210, 220),
accent_color: Color32::from_rgb(80, 130, 220),
},
hovered: TabStateStyle {
bg: Color32::from_rgb(42, 42, 50),
text_color: Color32::from_rgb(220, 220, 228),
accent_color: Color32::from_rgb(80, 130, 220),
},
rounding: CornerRadius::same(3),
},
handle: HandleStyle {
color: Color32::from_rgb(45, 45, 55),
hover_color: Color32::from_rgb(80, 110, 180),
locked_color: Color32::from_rgb(34, 34, 40),
},
overlay: OverlayStyle {
fill: Color32::from_rgba_premultiplied(80, 110, 180, 60),
stroke: Stroke::new(1.0, Color32::from_rgb(80, 130, 220)),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tab_state_prefers_overrides() {
let style = PanelStyle::default();
let pane = PaneStyleOverride {
accent_color: Some(Color32::from_rgb(1, 2, 3)),
..PaneStyleOverride::none()
};
let tab = TabStyleOverride {
active_bg: Some(Color32::from_rgb(4, 5, 6)),
text_color: Some(Color32::from_rgb(7, 8, 9)),
max_width: Some(120.0),
..TabStyleOverride::none()
};
let state = style.tab_state(true, false, Some(pane), Some(tab));
assert_eq!(state.bg, Color32::from_rgb(4, 5, 6));
assert_eq!(state.text_color, Color32::from_rgb(7, 8, 9));
assert_eq!(state.accent_color, Color32::from_rgb(1, 2, 3));
}
#[test]
fn from_visuals_uses_widget_palette() {
let visuals = Visuals::dark();
let style = PanelStyle::from_visuals(&visuals);
assert_eq!(style.content.bg, visuals.panel_fill);
assert_eq!(
style.header.button.icon_color,
visuals.widgets.inactive.fg_stroke.color
);
assert_eq!(style.overlay.stroke.color, visuals.widgets.active.bg_fill);
assert_eq!(style.typography.tab_title_font, FontId::proportional(12.0));
assert_eq!(style.typography.tab_icon_text_font, FontId::monospace(11.0));
}
#[test]
fn from_egui_style_uses_button_and_small_text_styles() {
let mut egui_style = Style::default();
egui_style
.text_styles
.insert(TextStyle::Button, FontId::proportional(16.0));
egui_style
.text_styles
.insert(TextStyle::Small, FontId::monospace(13.0));
let style = PanelStyle::from_egui_style(&egui_style);
assert_eq!(style.typography.tab_title_font, FontId::proportional(16.0));
assert_eq!(style.typography.tab_icon_text_font, FontId::monospace(13.0));
}
}
+216
View File
@@ -0,0 +1,216 @@
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