From 52657301c9ea779044b4e9e3e652274a0cf1449d Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sat, 22 Apr 2023 11:02:19 +0200 Subject: [PATCH] dock wip --- Cargo.lock | 13 + crates/egui_extras/Cargo.toml | 5 + crates/egui_extras/src/dock/mod.rs | 459 +++++++++++++++++++++++++++++ crates/egui_extras/src/lib.rs | 1 + examples/dock/Cargo.toml | 16 + examples/dock/README.md | 7 + examples/dock/src/main.rs | 125 ++++++++ 7 files changed, 626 insertions(+) create mode 100644 crates/egui_extras/src/dock/mod.rs create mode 100644 examples/dock/Cargo.toml create mode 100644 examples/dock/README.md create mode 100644 examples/dock/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index df8f811ae..0747a84e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1128,6 +1128,15 @@ dependencies = [ "libloading 0.7.4", ] +[[package]] +name = "dock" +version = "0.1.0" +dependencies = [ + "eframe", + "egui_extras", + "env_logger", +] + [[package]] name = "document-features" version = "0.2.7" @@ -1319,8 +1328,10 @@ dependencies = [ "chrono", "document-features", "egui", + "getrandom", "image", "log", + "rand", "resvg", "serde", "tiny-skia", @@ -1692,8 +1703,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] diff --git a/crates/egui_extras/Cargo.toml b/crates/egui_extras/Cargo.toml index f2cb4b1e4..ecaaf0dee 100644 --- a/crates/egui_extras/Cargo.toml +++ b/crates/egui_extras/Cargo.toml @@ -39,6 +39,11 @@ log = ["dep:log", "egui/log"] [dependencies] egui = { version = "0.21.0", path = "../egui", default-features = false } +# For dock: +# required to make rand work on wasm, see https://github.com/rust-random/rand#wasm-support +getrandom = { version = "0.2", features = ["js"] } +rand = { version = "0.8.5", features = ["getrandom", "small_rng"] } + serde = { version = "1", features = ["derive"] } #! ### Optional dependencies diff --git a/crates/egui_extras/src/dock/mod.rs b/crates/egui_extras/src/dock/mod.rs new file mode 100644 index 000000000..0e3552416 --- /dev/null +++ b/crates/egui_extras/src/dock/mod.rs @@ -0,0 +1,459 @@ +use std::collections::{HashMap, HashSet}; + +use egui::{pos2, vec2, CursorIcon, NumExt, Rect, WidgetText}; + +// ---------------------------------------------------------------------------- +// Types required for state + +/// An identifier for a node in the dock tree, be it a branch or a leaf. +#[derive( + Clone, Copy, Debug, Default, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize, +)] +pub struct NodeId(u128); + +impl NodeId { + pub const ZERO: Self = Self(0); + + pub fn random() -> Self { + use rand::Rng as _; + Self(rand::thread_rng().gen()) + } +} + +/// The top level type. Contains all peristent state, including layouts and sizes. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Dock { + pub root: NodeId, + pub nodes: Nodes, +} + +impl Default for Dock { + fn default() -> Self { + Self { + root: Default::default(), + nodes: Default::default(), + } + } +} + +/// Contains all node state, but no root. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Nodes { + pub nodes: HashMap>, +} + +impl Default for Nodes { + fn default() -> Self { + Self { + nodes: Default::default(), + } + } +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct NodeState { + pub layout: NodeLayout, + + /// Filled in by the layout step at the start of each frame. + #[serde(skip)] + #[serde(default = "nan_rect")] + pub rect: Rect, +} + +fn nan_rect() -> Rect { + Rect::NAN +} + +impl From> for NodeState { + fn from(layout: NodeLayout) -> Self { + Self { + layout, + rect: Rect::NAN, + } + } +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum NodeLayout { + Leaf(Leaf), + Tabs(Tabs), + Horizontal(Horizontal), + // Vertical(Vertical) + // Grid(Grid) +} + +impl NodeLayout { + pub fn name(&self) -> &'static str { + match self { + NodeLayout::Leaf(_) => "Leaf", + NodeLayout::Tabs(_) => "Tabs", + NodeLayout::Horizontal(_) => "Horizontal", + } + } +} + +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct Tabs { + pub children: Vec, + pub active: NodeId, +} + +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct Horizontal { + pub children: Vec, + pub shares: Shares, +} + +/// How large of a share of space each child has, on a 1D axis. +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct Shares { + /// How large of a share each child has. + /// + /// For instance, the shares `[1, 2, 3]` means that the first child gets 1/6 of the space, + /// the second gets 2/6 and the third gets 3/6. + pub shares: HashMap, +} + +impl Shares { + fn split(&self, children: &[NodeId], available_width: f32) -> Vec { + let mut num_shares = 0.0; + for child in children { + num_shares += self.shares.get(child).copied().unwrap_or(1.0); + } + if num_shares == 0.0 { + num_shares = 1.0; + } + children + .iter() + .map(|child| { + available_width * self.shares.get(child).copied().unwrap_or(1.0) / num_shares + }) + .collect() + } +} + +impl From for NodeLayout { + fn from(tabs: Tabs) -> Self { + NodeLayout::Tabs(tabs) + } +} + +impl From for NodeLayout { + fn from(horizontal: Horizontal) -> Self { + NodeLayout::Horizontal(horizontal) + } +} + +// ---------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NodeAction { + Keep, + Remove, +} + +/// Trait defining how the [`Dock`] and its leaf should be shown. +pub trait Behavior { + /// Show this leaf node in the given [`egui::Ui`]. + /// + /// If this is an unknown node, return [`NodeAction::Remove`] and the node will be removed. + fn leaf_ui(&mut self, _ui: &mut egui::Ui, _node_id: NodeId, _leaf: &mut Leaf); + + fn tab_text_for_leaf(&mut self, leaf: &Leaf) -> egui::WidgetText; + + fn tab_text_for_node(&mut self, nodes: &Nodes, node_id: NodeId) -> WidgetText { + match &nodes.nodes[&node_id].layout { + NodeLayout::Leaf(leaf) => self.tab_text_for_leaf(leaf), + layout => layout.name().into(), + } + } + + fn tab_ui( + &mut self, + nodes: &Nodes, + ui: &mut egui::Ui, + node_id: NodeId, + selected: bool, + ) -> egui::Response { + let text = self.tab_text_for_node(nodes, node_id); + let font_id = egui::TextStyle::Button.resolve(ui.style()); + let galley = text.into_galley(ui, Some(false), f32::INFINITY, font_id); + let (rect, response) = ui.allocate_exact_size(galley.size(), egui::Sense::click_and_drag()); + let widget_style = ui.style().interact_selectable(&response, selected); + ui.painter() + .galley_with_color(rect.min, galley.galley, widget_style.text_color()); + response + } + + /// Returns `false` if this leaf should be removed from its parent. + fn retain_leaf(&mut self, _leaf: &Leaf) -> bool { + true + } + + // --- + // Settings: + + /// The height of the bar holding tab names. + fn tab_bar_height(&self, _style: &egui::Style) -> f32 { + 20.0 + } + + /// Width of the gap between nodes in a horizontal or vertical layout + fn gap_width(&self, _style: &egui::Style) -> f32 { + 1.0 + } +} + +// ---------------------------------------------------------------------------- +// Construction + +impl Dock { + pub fn new(root: NodeId, nodes: Nodes) -> Self { + Self { root, nodes } + } +} + +impl Nodes { + #[must_use] + pub fn insert_node(&mut self, node: NodeState) -> NodeId { + let id = NodeId::random(); + self.nodes.insert(id, node); + id + } + + #[must_use] + pub fn insert_leaf(&mut self, leaf: Leaf) -> NodeId { + self.insert_node(NodeLayout::Leaf(leaf).into()) + } + + #[must_use] + pub fn insert_tab_node(&mut self, children: Vec) -> NodeId { + let tabs = Tabs { + active: children.first().copied().unwrap_or_default(), + children, + }; + self.insert_node(NodeLayout::Tabs(tabs).into()) + } + + #[must_use] + pub fn insert_horizontal_node(&mut self, children: Vec) -> NodeId { + let horizontal = Horizontal { + children, + shares: Default::default(), + }; + self.insert_node(NodeLayout::Horizontal(horizontal).into()) + } + + pub fn get(&self, node_id: NodeId) -> Option<&NodeLayout> { + self.nodes.get(&node_id).map(|node| &node.layout) + } +} + +// Usage +impl Dock { + pub fn root(&self) -> NodeId { + self.root + } + + pub fn ui(&mut self, behavior: &mut dyn Behavior, ui: &mut egui::Ui) { + self.nodes.gc_root(behavior, self.root); + + self.nodes.layout_node( + ui.style(), + behavior, + ui.available_rect_before_wrap(), + self.root, + ); + + self.nodes.node_ui(behavior, ui, self.root); + } +} + +// ---------------------------------------------------------------------------- +// gc + +#[derive(PartialEq, Eq)] +enum GcAction { + Keep, + Remove, +} + +impl Nodes { + fn gc_root(&mut self, behavior: &mut dyn Behavior, root_id: NodeId) { + let mut visited = HashSet::default(); + self.gc_node_id(behavior, &mut visited, root_id); + self.nodes.retain(|node_id, _| visited.contains(node_id)); + } + + fn gc_node_id( + &mut self, + behavior: &mut dyn Behavior, + visited: &mut HashSet, + node_id: NodeId, + ) -> GcAction { + let Some(mut node) = self.nodes.remove(&node_id) else { return GcAction::Remove; }; + if !visited.insert(node_id) { + #[cfg(feature = "log")] + log::warn!("Cycle detected in egui_extras::dock"); + return GcAction::Remove; + } + + match &mut node.layout { + NodeLayout::Leaf(leaf) => { + if !behavior.retain_leaf(leaf) { + return GcAction::Remove; + } + } + NodeLayout::Tabs(layout) => { + layout + .children + .retain(|&child| self.gc_node_id(behavior, visited, child) == GcAction::Keep); + } + NodeLayout::Horizontal(layout) => { + layout + .children + .retain(|&child| self.gc_node_id(behavior, visited, child) == GcAction::Keep); + } + } + self.nodes.insert(node_id, node); + GcAction::Keep + } +} + +// ---------------------------------------------------------------------------- +// layout + +impl Nodes { + fn layout_node( + &mut self, + style: &egui::Style, + behavior: &mut dyn Behavior, + rect: Rect, + node_id: NodeId, + ) { + let Some(mut node) = self.nodes.remove(&node_id) else { return; }; + node.rect = rect; + + match &node.layout { + NodeLayout::Leaf(_) => {} + NodeLayout::Tabs(tabs) => { + self.layout_tabs(style, behavior, rect, tabs); + } + NodeLayout::Horizontal(horizontal) => { + self.layout_horizontal(style, behavior, rect, horizontal); + } + } + + self.nodes.insert(node_id, node); + } + + fn layout_tabs( + &mut self, + style: &egui::Style, + behavior: &mut dyn Behavior, + rect: Rect, + tabs: &Tabs, + ) { + let mut active_rect = rect; + active_rect.min.y += behavior.tab_bar_height(style); + + if false { + self.layout_node(style, behavior, active_rect, tabs.active); + } else { + // Layout all nodes in case the user switches active tab + for &child_id in &tabs.children { + self.layout_node(style, behavior, active_rect, child_id); + } + } + } + + fn layout_horizontal( + &mut self, + style: &egui::Style, + behavior: &mut dyn Behavior, + rect: Rect, + horizontal: &Horizontal, + ) { + if horizontal.children.is_empty() { + return; + } + let num_gaps = horizontal.children.len() - 1; + let gap_width = behavior.gap_width(style); + let total_gap_width = gap_width * num_gaps as f32; + let available_width = (rect.width() - total_gap_width).at_least(0.0); + + let widths = horizontal + .shares + .split(&horizontal.children, available_width); + + let mut x = rect.min.x; + for (child, width) in horizontal.children.iter().zip(widths) { + let child_rect = Rect::from_min_size(pos2(x, rect.min.y), vec2(width, rect.height())); + self.layout_node(style, behavior, child_rect, *child); + x += width + gap_width; + } + } +} + +// ---------------------------------------------------------------------------- +// ui + +impl Nodes { + fn node_ui(&mut self, behavior: &mut dyn Behavior, ui: &mut egui::Ui, node_id: NodeId) { + let Some(mut node) = self.nodes.remove(&node_id) else { return }; + + match &mut node.layout { + NodeLayout::Leaf(leaf) => { + let mut leaf_ui = ui.child_ui(node.rect, *ui.layout()); + behavior.leaf_ui(&mut leaf_ui, node_id, leaf); + } + NodeLayout::Tabs(tabs) => self.tabs_ui(behavior, ui, node.rect, tabs), + NodeLayout::Horizontal(horizontal) => { + self.horizontal_ui(behavior, ui, node.rect, horizontal); + } + }; + self.nodes.insert(node_id, node); + } + + fn tabs_ui( + &mut self, + behavior: &mut dyn Behavior, + ui: &mut egui::Ui, + rect: Rect, + tabs: &mut Tabs, + ) { + let tab_bar_height = behavior.tab_bar_height(ui.style()); + let tab_bar_rect = { + let mut r = rect; + r.max.y = r.min.y + tab_bar_height; + r + }; + let mut tab_bar_ui = ui.child_ui(tab_bar_rect, *ui.layout()); + + // Show tab bar: + tab_bar_ui.horizontal(|ui| { + for &child_id in &tabs.children { + let selected = child_id == tabs.active; + let response = behavior.tab_ui(self, ui, child_id, selected); + let response = response.on_hover_cursor(CursorIcon::Grab); + if response.clicked() { + tabs.active = child_id; + } + } + }); + + self.node_ui(behavior, ui, tabs.active); + } + + fn horizontal_ui( + &mut self, + behavior: &mut dyn Behavior, + ui: &mut egui::Ui, + _rect: Rect, + horizontal: &mut Horizontal, + ) { + for child in &horizontal.children { + self.node_ui(behavior, ui, *child); + } + } +} diff --git a/crates/egui_extras/src/lib.rs b/crates/egui_extras/src/lib.rs index 243127f24..ece4ce5b8 100644 --- a/crates/egui_extras/src/lib.rs +++ b/crates/egui_extras/src/lib.rs @@ -13,6 +13,7 @@ #[cfg(feature = "chrono")] mod datepicker; +pub mod dock; pub mod image; mod layout; mod sizing; diff --git a/examples/dock/Cargo.toml b/examples/dock/Cargo.toml new file mode 100644 index 000000000..ea2d0c7d9 --- /dev/null +++ b/examples/dock/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dock" +version = "0.1.0" +authors = ["Emil Ernerfeldt "] +license = "MIT OR Apache-2.0" +edition = "2021" +rust-version = "1.65" +publish = false + + +[dependencies] +eframe = { path = "../../crates/eframe", features = [ + "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO +] } +egui_extras = { path = "../../crates/egui_extras" } +env_logger = "0.10" diff --git a/examples/dock/README.md b/examples/dock/README.md new file mode 100644 index 000000000..a778c575d --- /dev/null +++ b/examples/dock/README.md @@ -0,0 +1,7 @@ +Example how to showcase `egui_extras::dock`. + +```sh +cargo run -p dock +``` + +![](screenshot.png) diff --git a/examples/dock/src/main.rs b/examples/dock/src/main.rs new file mode 100644 index 000000000..cdc7ad6f9 --- /dev/null +++ b/examples/dock/src/main.rs @@ -0,0 +1,125 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release + +use eframe::egui; +use egui::Color32; + +use egui_extras::dock; + +fn main() -> Result<(), eframe::Error> { + env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). + let options = eframe::NativeOptions { + initial_window_size: Some(egui::vec2(320.0, 240.0)), + ..Default::default() + }; + eframe::run_native("Dock", options, Box::new(|_cc| Box::::default())) +} + +pub struct View { + title: String, + color: Color32, +} + +impl View { + pub fn with_nr(i: usize) -> Self { + Self { + title: format!("Node {}", i), + color: egui::epaint::Hsva::new(0.1 * i as f32, 0.5, 0.5, 1.0).into(), + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + ui.painter().rect_filled(ui.max_rect(), 0.0, self.color); + ui.label(&self.title); + } +} + +struct MyApp { + dock: dock::Dock, +} + +impl Default for MyApp { + fn default() -> Self { + let mut next_view_nr = 0; + let mut gen_view = || { + let view = View::with_nr(next_view_nr); + next_view_nr += 1; + view + }; + + let mut nodes = dock::Nodes::default(); + + let tab0 = { nodes.insert_leaf(gen_view()) }; + let tab1 = { + let a = nodes.insert_leaf(gen_view()); + let b = nodes.insert_leaf(gen_view()); + nodes.insert_tab_node(vec![a, b]) + }; + let tab2 = { + let a = nodes.insert_leaf(gen_view()); + let b = nodes.insert_leaf(gen_view()); + nodes.insert_horizontal_node(vec![a, b]) + }; + + let root = nodes.insert_tab_node(vec![tab0, tab1, tab2]); + + let dock = dock::Dock::new(root, nodes); + + Self { dock } + } +} + +impl eframe::App for MyApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let mut behavior = DockBehavior {}; + + egui::SidePanel::left("tree").show(ctx, |ui| { + tree_ui(ui, &mut behavior, &self.dock.nodes, self.dock.root); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + self.dock.ui(&mut behavior, ui); + }); + } +} + +fn tree_ui( + ui: &mut egui::Ui, + behavior: &mut dyn dock::Behavior, + nodes: &dock::Nodes, + node_id: dock::NodeId, +) { + let Some(node) = nodes.get(node_id) else { return; }; + + // if let dock::NodeLayout::Leaf(view) = node { + // ui.label(&view.title); + // return; + // } + + egui::CollapsingHeader::new(behavior.tab_text_for_node(nodes, node_id)) + .default_open(true) + .show(ui, |ui| match node { + dock::NodeLayout::Leaf(_) => {} + dock::NodeLayout::Tabs(tabs) => { + for &child in &tabs.children { + tree_ui(ui, behavior, nodes, child); + } + } + dock::NodeLayout::Horizontal(horizontal) => { + for &child in &horizontal.children { + tree_ui(ui, behavior, nodes, child); + } + } + }); +} + +struct DockBehavior {} + +impl dock::Behavior for DockBehavior { + fn leaf_ui(&mut self, ui: &mut egui::Ui, _node_id: dock::NodeId, view: &mut View) { + view.ui(ui); + } + + fn tab_text_for_leaf(&mut self, view: &View) -> egui::WidgetText { + view.title.clone().into() + } +}