init
This commit is contained in:
@@ -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"
|
||||
@@ -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
@@ -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 isn’t covered by a navigation bar, tab bar,
|
||||
/// toolbar, or other views a window might provide. Safe areas are essential for avoiding a
|
||||
/// device’s 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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":1}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "a49454b6028db991f498a181c2ebc1ac416ae9e6"
|
||||
},
|
||||
"path_in_vcs": ""
|
||||
}
|
||||
+59
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user