mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
1825 lines
91 KiB
Rust
1825 lines
91 KiB
Rust
//! Floating panels: panels dragged out of the dock float inside the window as groups of tabs.
|
||
//!
|
||
//! A dock tab, an icon of the icon column or a popped-out panel's title dragged out of the dock
|
||
//! floats that panel; the strip right of the dock's tabs floats the whole tabbed group. A floating
|
||
//! group moves by its title bar (or the strip right of its tabs, or the tab of a lone panel), and a
|
||
//! tab dragged out of a group floats on its own. Dropped on the dock, which lights up while the
|
||
//! pointer is over it, a group's panels go back to where they live in the dock (the tabbed group or
|
||
//! the icon column); dropped on another group's title bar or tabs, they stack with that group. The
|
||
//! × of a group puts its panels back in the dock too. The Tools panel floats the same way by its
|
||
//! title bar ([`crate::toolbar`]) and docks again on the window's left edge.
|
||
//!
|
||
//! Dropped on the upper or lower half of another group (a line shows where), a group joins it in
|
||
//! a set right above or below it: the groups stack top to bottom, each showing one of its panels,
|
||
//! and move together by the top group's title bar, whose × docks them all; dropped on its tabs (lit
|
||
//! up), its panels join that group as tabs. A group dragged by its tab strip (a lone panel's
|
||
//! tab, or the strip right of the tabs) leaves its set and floats on its own. A double-click on a
|
||
//! tab collapses a group to its tabs, and another expands it. A dock column whose panels don't fit
|
||
//! the window scrolls.
|
||
//!
|
||
//! `window.panel.float` (`onto`, `below`, `above`, `collapsed`) / `window.panel.dock` do the same
|
||
//! for agents. The groups, their sets and the Tools panel's position are kept with the preferences
|
||
//! and in user workspaces, and a position saved on a bigger window is clamped into this one when
|
||
//! drawn.
|
||
|
||
use egui::{CornerRadius, Id, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::state::{DockTab, FloatingPanels, UiState, all_panels};
|
||
use crate::theme::{self, Tokens};
|
||
use crate::{VectorcraftApp, dock, icons, panels};
|
||
|
||
/// Height of a floating group's title bar (its grip and ×).
|
||
pub const TITLE: f32 = 14.0;
|
||
/// Height of a floating group's tab strip.
|
||
pub const STRIP: f32 = 26.0;
|
||
/// Height of the bar between two groups of a set.
|
||
pub const GAP: f32 = 3.0;
|
||
/// The least height of an open panel in a dock column: more panels than fit scroll.
|
||
const MIN_TALL: f32 = 160.0;
|
||
/// How far from a group's top or bottom edge a drop stacks it above or below the group.
|
||
const EDGE: f32 = 7.0;
|
||
/// Where `window.panel.float` floats a group given no position; each further group 24 points
|
||
/// lower right.
|
||
const FLOAT_AT: [f32; 2] = [420.0, 120.0];
|
||
/// Where `window.panel.float {panel: "tools"}` floats the Tools panel given no position.
|
||
const TOOLS_AT: [f32; 2] = [60.0, 120.0];
|
||
/// Points the pointer travels from its press before a drop counts (a click on a title bar that
|
||
/// overlaps the dock doesn't dock its group).
|
||
const TRAVEL: f32 = 4.0;
|
||
|
||
/// What a move carries.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub(crate) enum Moving {
|
||
/// The floating group holding this panel.
|
||
Panel(&'static str),
|
||
/// The floating Tools panel.
|
||
Tools,
|
||
}
|
||
|
||
/// A move in progress: what moves, where the pointer holds it from its top-left corner, and where
|
||
/// the press began.
|
||
#[derive(Clone, Debug)]
|
||
struct Move {
|
||
what: Moving,
|
||
grab: Vec2,
|
||
from: Pos2,
|
||
original: UiState,
|
||
}
|
||
|
||
/// Where a moved group or Tools panel lands.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub(crate) enum Drop {
|
||
/// Back in the dock (the Tools panel: at the window's left edge).
|
||
Dock,
|
||
/// Stacked with the floating group holding this panel.
|
||
Stack(&'static str),
|
||
/// In a set with the floating group holding this panel, below it.
|
||
Below(&'static str),
|
||
/// In a set with the floating group holding this panel, above it (the top group of its set).
|
||
Above(&'static str),
|
||
/// In the dock as a new column at this place (1: next to the icon column, counting left).
|
||
Column(u32),
|
||
}
|
||
|
||
fn move_id() -> Id {
|
||
Id::new("floating-move")
|
||
}
|
||
|
||
fn suppressed_id() -> Id {
|
||
move_id().with("suppressed-until-primary-press")
|
||
}
|
||
|
||
fn gesture_allowed(ctx: &egui::Context) -> bool {
|
||
ctx.input(|input| input.focused && !input.key_pressed(egui::Key::Escape))
|
||
&& !ctx.data(|data| data.get_temp::<bool>(suppressed_id()).unwrap_or(false))
|
||
}
|
||
|
||
/// The dock (tabbed group and icon column) as last drawn: a group dropped there docks.
|
||
pub(crate) fn dock_rect_id() -> Id {
|
||
Id::new("floating-dock-rect")
|
||
}
|
||
|
||
/// The strip along the window's left edge where the Tools panel docks, as last laid out.
|
||
/// The icon column's bounds (egui temp memory, one frame old), where panels without a tab in the
|
||
/// tabbed group go back to when docked.
|
||
pub(crate) fn icons_rect_id() -> Id {
|
||
Id::new("floating-icons-rect")
|
||
}
|
||
|
||
pub(crate) fn tools_zone_id() -> Id {
|
||
Id::new("floating-tools-zone")
|
||
}
|
||
|
||
/// The area of the floating group whose first panel is `first` (of the set it heads).
|
||
pub fn area_id(first: &str) -> Id {
|
||
Id::new(("floating-panels", first))
|
||
}
|
||
|
||
/// The floating group whose first panel is `first`, as last drawn (egui temp memory): in a set,
|
||
/// from the bar above its tabs to its panel's bottom.
|
||
pub(crate) fn group_rect_id(first: &str) -> Id {
|
||
Id::new(("floating-group-rect", first))
|
||
}
|
||
|
||
/// Panel `id` (`window.panel`) as a static string and its English label, if it is a panel.
|
||
fn panel(id: &str) -> Option<(&'static str, &'static str)> {
|
||
all_panels().find(|(p, _)| *p == id)
|
||
}
|
||
|
||
/// The floating group holding panel `id`.
|
||
pub fn group_of(ui: &UiState, id: &str) -> Option<usize> {
|
||
ui.floating_panels.iter().position(|g| g.panels.iter().any(|p| p == id))
|
||
}
|
||
|
||
/// The floating groups drawn with group `gi`, top to bottom: the groups of its set, or it alone.
|
||
pub fn set_of(ui: &UiState, gi: usize) -> Vec<usize> {
|
||
match ui.floating_panels.get(gi).and_then(|g| g.column) {
|
||
Some(c) => ui.floating_panels.iter().enumerate().filter(|(_, g)| g.column == Some(c)).map(|(i, _)| i).collect(),
|
||
None => vec![gi],
|
||
}
|
||
}
|
||
|
||
/// Group `gi` heads its set (or floats alone): it draws the set's title bar.
|
||
fn leads(ui: &UiState, gi: usize) -> bool {
|
||
set_of(ui, gi).first() == Some(&gi)
|
||
}
|
||
|
||
/// Move the set of group `gi` to `pos`.
|
||
fn set_pos(ui: &mut UiState, gi: usize, pos: [f32; 2]) {
|
||
for i in set_of(ui, gi) {
|
||
if let Some(g) = ui.floating_panels.get_mut(i) {
|
||
g.pos = pos;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The panels of the groups `set`, in order.
|
||
fn ids_in(ui: &UiState, set: &[usize]) -> Vec<&'static str> {
|
||
set.iter().filter_map(|&i| ui.floating_panels.get(i)).flat_map(ids_of).collect()
|
||
}
|
||
|
||
/// The panel shown by group `gi`.
|
||
fn shown(ui: &UiState, gi: usize) -> Option<&'static str> {
|
||
let g = ui.floating_panels.get(gi)?;
|
||
let ids = ids_of(g);
|
||
ids.get(g.active).or(ids.first()).copied()
|
||
}
|
||
|
||
/// Put the groups `moved` in a set with the group holding `onto` (not one of them), right below or
|
||
/// right above it, at its position.
|
||
pub fn attach(ui: &mut UiState, moved: &[usize], onto: &str, below: bool) {
|
||
let Some(target) = group_of(ui, onto).filter(|t| !moved.contains(t)) else { return };
|
||
let column = match ui.floating_panels.get(target).and_then(|g| g.column) {
|
||
Some(c) => c,
|
||
None => ui.floating_panels.iter().filter_map(|g| g.column).max().map_or(1, |c| c.saturating_add(1)),
|
||
};
|
||
let Some((pos, docked)) = ui.floating_panels.get(target).map(|g| (g.pos, g.docked)) else { return };
|
||
let (mut taken, mut kept) = (vec![], vec![]);
|
||
for (i, mut g) in ui.floating_panels.drain(..).enumerate() {
|
||
if i == target {
|
||
g.column = Some(column);
|
||
}
|
||
if moved.contains(&i) {
|
||
g.column = Some(column);
|
||
g.pos = pos;
|
||
g.docked = docked;
|
||
taken.push(g);
|
||
} else {
|
||
kept.push(g);
|
||
}
|
||
}
|
||
let at = kept.iter().position(|g| g.panels.iter().any(|p| p == onto)).map_or(kept.len(), |i| if below { i + 1 } else { i });
|
||
kept.splice(at..at, taken);
|
||
ui.floating_panels = kept;
|
||
FloatingPanels::tidy(&mut ui.floating_panels);
|
||
if let Some(tab) = shown_tab(ui) {
|
||
ui.dock_tab = tab;
|
||
}
|
||
}
|
||
|
||
/// The panels of group `g`, as static ids.
|
||
fn ids_of(g: &FloatingPanels) -> Vec<&'static str> {
|
||
g.panels.iter().filter_map(|p| panel(p)).map(|(id, _)| id).collect()
|
||
}
|
||
|
||
/// The dock tabs left in the dock, in tab order.
|
||
pub fn docked_tabs(ui: &UiState) -> impl Iterator<Item = DockTab> + '_ {
|
||
DockTab::ALL.into_iter().filter(|t| group_of(ui, t.info().0).is_none())
|
||
}
|
||
|
||
/// The dock tab the tabbed group shows: the chosen one, or the first left when it floats.
|
||
pub fn shown_tab(ui: &UiState) -> Option<DockTab> {
|
||
docked_tabs(ui).find(|t| *t == ui.dock_tab).or_else(|| docked_tabs(ui).next())
|
||
}
|
||
|
||
/// Take `ids` out of the floating groups, dropping the groups left empty.
|
||
fn take(ui: &mut UiState, ids: &[&str]) {
|
||
ui.floating_panels.retain_mut(|g| {
|
||
let active = g.panels.get(g.active).filter(|p| !ids.contains(&p.as_str())).cloned();
|
||
g.panels.retain(|p| !ids.contains(&p.as_str()));
|
||
g.active = active.and_then(|a| g.panels.iter().position(|p| *p == a)).unwrap_or(0);
|
||
!g.panels.is_empty()
|
||
});
|
||
FloatingPanels::tidy(&mut ui.floating_panels);
|
||
}
|
||
|
||
/// Float `ids` (out of the dock or of the group they were in) as a new group with its top-left
|
||
/// corner at `pos`, showing `active`.
|
||
pub fn float(ui: &mut UiState, ids: &[&str], active: &str, pos: Pos2) {
|
||
take(ui, ids);
|
||
if ui.open_panel.as_deref().is_some_and(|p| ids.contains(&p)) {
|
||
ui.open_panel = None;
|
||
}
|
||
let panels: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
|
||
let active = panels.iter().position(|p| p == active).unwrap_or(0);
|
||
ui.floating_panels.push(FloatingPanels { panels, active, pos: [pos.x, pos.y], ..Default::default() });
|
||
// The tabbed group shows another of its tabs when the one it showed floats.
|
||
if let Some(tab) = shown_tab(ui) {
|
||
ui.dock_tab = tab;
|
||
}
|
||
}
|
||
|
||
/// Stack `ids` with the floating group holding `onto` (not one of them), showing `active`.
|
||
pub fn stack(ui: &mut UiState, ids: &[&str], active: &str, onto: &str) {
|
||
take(ui, ids);
|
||
if ui.open_panel.as_deref().is_some_and(|p| ids.contains(&p)) {
|
||
ui.open_panel = None;
|
||
}
|
||
if let Some(g) = group_of(ui, onto).and_then(|i| ui.floating_panels.get_mut(i)) {
|
||
g.panels.extend(ids.iter().map(|id| id.to_string()));
|
||
g.active = g.panels.iter().position(|p| p == active).unwrap_or(g.active);
|
||
}
|
||
if let Some(tab) = shown_tab(ui) {
|
||
ui.dock_tab = tab;
|
||
}
|
||
}
|
||
|
||
/// Put `ids` back in the dock: the dock tabs in the tabbed group (showing `active`, or the first of
|
||
/// them), the other panels in the icon column.
|
||
pub fn dock(ui: &mut UiState, ids: &[&str], active: &str) {
|
||
take(ui, ids);
|
||
if let Some(tab) = DockTab::from_id(active).or_else(|| ids.iter().find_map(|id| DockTab::from_id(id))) {
|
||
ui.dock_tab = tab;
|
||
}
|
||
ui.dock = true;
|
||
}
|
||
|
||
/// `pos`, a saved top-left corner, moved so that a `size` box there is inside `screen` (its
|
||
/// top-left corner first when it doesn't fit). Pulled in by whole points: egui rounds an area's
|
||
/// position, which would push a box of fractional size half a point past the edge.
|
||
pub fn clamp(pos: [f32; 2], size: Vec2, screen: Rect) -> Pos2 {
|
||
let [x, y] = pos;
|
||
let (x, y) = if x.is_finite() && y.is_finite() { (x, y) } else { (screen.left() + 60.0, screen.top() + 100.0) };
|
||
let fit = |v: f32, max: f32| if v > max { max.floor() } else { v };
|
||
pos2(fit(x, screen.right() - size.x).max(screen.left()), fit(y, screen.bottom() - size.y).max(screen.top()))
|
||
}
|
||
|
||
/// Start moving `what` with the pointer, held `grab` from its top-left corner.
|
||
fn start(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, grab: Vec2) {
|
||
if !gesture_allowed(ctx) {
|
||
return;
|
||
}
|
||
let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return };
|
||
ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from, original: app.ui.clone() }));
|
||
}
|
||
|
||
/// Where the pointer holds a group torn off by a press `left` points right of where the group's
|
||
/// tab strip will begin (under its title bar, past its 1-point border).
|
||
pub(crate) fn strip_grab(ctx: &egui::Context, left: f32) -> Vec2 {
|
||
let x = ctx.input(|i| i.pointer.press_origin()).map_or(12.0, |p| p.x - left);
|
||
vec2(1.0 + x.max(0.0), 1.0 + TITLE + STRIP / 2.0)
|
||
}
|
||
|
||
/// Float `ids` under the pointer, held `grab` from the new group's top-left corner, and carry on
|
||
/// moving them with the pointer (a tab or group dragged out of the dock or out of its group).
|
||
pub(crate) fn tear(app: &mut VectorcraftApp, ctx: &egui::Context, ids: &[&'static str], active: &'static str, grab: Vec2) {
|
||
// A restored popout may retain its drag response in the cancellation frame. Do not
|
||
// immediately tear it out again after track restored the gesture's original layout.
|
||
if !gesture_allowed(ctx) {
|
||
return;
|
||
}
|
||
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
|
||
let original = app.ui.clone();
|
||
let pos = at - grab;
|
||
if let Err(error) = app.run("window.panel.float", json!({"panel":active,"group":ids.len() > 1,"x":pos.x,"y":pos.y})) {
|
||
app.ui.status = error;
|
||
return;
|
||
}
|
||
start(app, ctx, Moving::Panel(active), grab);
|
||
ctx.data_mut(|data| {
|
||
if let Some(mut drag) = data.get_temp::<Move>(move_id()) {
|
||
drag.original = original;
|
||
data.insert_temp(move_id(), drag);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// The Tools panel's title bar was pressed (`title`, the panel's bounds `bounds`): docked, dragging
|
||
/// it out of the panel floats the panel under the pointer; floating at `floating`, a drag moves it.
|
||
pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: &egui::Response, bounds: Rect, floating: Option<Pos2>) {
|
||
if !gesture_allowed(ctx) {
|
||
return;
|
||
}
|
||
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
|
||
match floating {
|
||
Some(pos) if title.drag_started() => start(app, ctx, Moving::Tools, at - pos),
|
||
None if title.dragged() && !bounds.contains(at) => {
|
||
// Held where the press grabbed the title bar.
|
||
let grab = ctx.input(|i| i.pointer.press_origin()).map_or(vec2(10.0, 7.0), |p| p - bounds.min);
|
||
let p = at - grab;
|
||
start(app, ctx, Moving::Tools, grab);
|
||
app.ui.toolbar_pos = Some([p.x, p.y]);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// Where the pointer at `at` would drop `what`, and the zone that lights up for it.
|
||
pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> {
|
||
let zone = |id: Id| ctx.data(|d| d.get_temp::<Rect>(id)).filter(|r| r.contains(at));
|
||
let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) };
|
||
let own = group_of(&app.ui, id);
|
||
let moving = own.map(|i| set_of(&app.ui, i)).unwrap_or_default();
|
||
// The left edge of the icon column or of a dock column docks the moved groups as a new column
|
||
// there.
|
||
if app.ui.dock && app.ui.screen_mode < 3 {
|
||
let icons = ctx.data(|d| d.get_temp::<Rect>(icons_rect_id()));
|
||
let columns = ctx.data(|d| d.get_temp::<Vec<Rect>>(columns_id())).unwrap_or_default();
|
||
for (k, r) in icons.into_iter().chain(columns).enumerate() {
|
||
if Rect::from_x_y_ranges((r.left() - EDGE - 3.0)..=(r.left() + EDGE - 1.0), r.y_range()).contains(at) {
|
||
let line = Rect::from_x_y_ranges((r.left() - 2.0)..=(r.left() + 2.0), r.y_range());
|
||
return Some((Drop::Column(u32::try_from(k + 1).unwrap_or(u32::MAX)), line));
|
||
}
|
||
}
|
||
}
|
||
// Another group (floating or docked): its tabs (and a set's title bar) stack the moved groups
|
||
// with it as tabs; the upper half of the group (and just above a set) puts them in its set
|
||
// right above it, the lower half (and just below it) right below it.
|
||
let columns = ctx.data(|d| d.get_temp::<Vec<Rect>>(columns_id())).unwrap_or_default();
|
||
for (i, g) in app.ui.floating_panels.iter().enumerate() {
|
||
let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue };
|
||
let Some(mut r) = ctx
|
||
.data(|d| d.get_temp::<Rect>(group_rect_id(first)))
|
||
.or_else(|| ctx.memory(|m| m.area_rect(Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())))))
|
||
else {
|
||
continue;
|
||
};
|
||
// A visible window owns hits inside its body. Old rectangles and lower windows
|
||
// must not capture a drop through another panel or popup; edge insertion remains
|
||
// available just outside the window. Dock columns reserve their own panel space.
|
||
if g.docked.is_none() && r.contains(at) {
|
||
let area = if app.ui.docking.is_some() && !crate::panel_docking::decorated(g) {
|
||
Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))
|
||
} else {
|
||
let lead = set_of(&app.ui, i).first().and_then(|&index| app.ui.floating_panels.get(index)).and_then(|group| group.panels.first());
|
||
area_id(lead.map_or(first, String::as_str))
|
||
};
|
||
let moving_areas: Vec<Id> = moving
|
||
.iter()
|
||
.filter_map(|&index| app.ui.floating_panels.get(index).and_then(|group| group.panels.first()))
|
||
.flat_map(|panel| [area_id(panel), Id::new("vectorcraft-panel-docking").with(("floating", panel))])
|
||
.collect();
|
||
let owner = ctx.memory(|memory| {
|
||
let layers: Vec<_> = memory.layer_ids().collect();
|
||
layers.into_iter().rev().find(|layer| {
|
||
!moving_areas.contains(&layer.id)
|
||
&& memory.areas().is_visible(layer)
|
||
&& memory.area_rect(layer.id).is_some_and(|rect| rect.contains(at))
|
||
})
|
||
});
|
||
if owner.is_some_and(|layer| layer.id != area) {
|
||
continue;
|
||
}
|
||
}
|
||
// In a dock column that scrolls, only the part in view: a group scrolled away takes nothing.
|
||
if let Some(col) = g.docked.and_then(|d| columns.get(usize::try_from(d).ok()?.checked_sub(1)?)) {
|
||
r = r.intersect(*col);
|
||
if r.height() <= 0.0 || r.width() <= 0.0 {
|
||
continue;
|
||
}
|
||
}
|
||
let line = |y: f32| Rect::from_x_y_ranges(r.x_range(), (y - 2.0)..=(y + 2.0));
|
||
let lead = leads(&app.ui, i);
|
||
let head = Rect::from_min_size(r.min, vec2(r.width(), if lead { 1.0 + TITLE + STRIP } else { GAP + STRIP }));
|
||
if head.contains(at) {
|
||
return Some((Drop::Stack(first), head));
|
||
}
|
||
let above = if lead { EDGE } else { 0.0 };
|
||
if Rect::from_x_y_ranges(r.x_range(), (r.top() - above)..=(r.bottom() + EDGE)).contains(at) {
|
||
let mid = (head.bottom() + r.bottom()) / 2.0;
|
||
return Some(if at.y < mid { (Drop::Above(first), line(r.top())) } else { (Drop::Below(first), line(r.bottom())) });
|
||
}
|
||
}
|
||
// A dock column's room under its groups puts them at its bottom.
|
||
for ((_, set), col) in docked_sets(&app.ui).into_iter().zip(columns) {
|
||
if !col.contains(at) || set.iter().any(|i| moving.contains(i)) {
|
||
continue;
|
||
}
|
||
let Some(last) = set.last().and_then(|&i| app.ui.floating_panels.get(i)).and_then(|g| g.panels.first()).and_then(|p| panel(p)) else {
|
||
continue;
|
||
};
|
||
let y = ctx.data(|d| d.get_temp::<Rect>(group_rect_id(last.0))).map_or(col.bottom(), |r| r.bottom()).min(col.bottom() - 2.0);
|
||
return Some((Drop::Below(last.0), Rect::from_x_y_ranges(col.x_range(), (y - 2.0)..=(y + 2.0))));
|
||
}
|
||
let dock = zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3)?;
|
||
// Panels that aren't tabs of the tabbed group (or all of them, with it collapsed) go back to
|
||
// their icons: light up the icon column, where they land, rather than the tabs.
|
||
let tabbed = !app.ui.dock_collapsed
|
||
&& own.and_then(|i| app.ui.floating_panels.get(i)).is_some_and(|g| ids_of(g).into_iter().any(|p| DockTab::from_id(p).is_some()));
|
||
let lit = if tabbed { dock } else { ctx.data(|d| d.get_temp::<Rect>(icons_rect_id())).unwrap_or(dock) };
|
||
Some((Drop::Dock, lit))
|
||
}
|
||
|
||
/// Light up a drop zone: over the docked panels, under the floating ones (the moved one too).
|
||
pub(crate) fn highlight(ctx: &egui::Context, zone: Rect) {
|
||
let t = Tokens::get(ctx);
|
||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Background, Id::new("floating-drop-zone")));
|
||
painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
||
}
|
||
|
||
/// Light up the tabs a drop stacks the moved panels with, over the floating and docked groups.
|
||
fn tab_zone(ctx: &egui::Context, tabs: Rect) {
|
||
let t = Tokens::get(ctx);
|
||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("floating-drop-tabs")));
|
||
painter.rect(tabs.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.25), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
||
}
|
||
|
||
/// Show where a drop puts the moved groups in a set: a line over the floating groups.
|
||
fn insertion_line(ctx: &egui::Context, line: Rect) {
|
||
let t = Tokens::get(ctx);
|
||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("floating-drop-line")));
|
||
painter.rect_filled(line, CornerRadius::same(2), t.accent);
|
||
}
|
||
|
||
pub(crate) fn preview(ctx: &egui::Context, target: Drop, rect: Rect) {
|
||
match target {
|
||
Drop::Below(_) | Drop::Above(_) | Drop::Column(_) => insertion_line(ctx, rect),
|
||
Drop::Stack(_) => tab_zone(ctx, rect),
|
||
Drop::Dock => highlight(ctx, rect),
|
||
}
|
||
}
|
||
|
||
/// Carry a move on (at the start of a frame, before the panels are drawn): the group or Tools panel
|
||
/// follows the pointer and the zone it would drop on lights up; the release drops it there.
|
||
pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||
if ctx.input(|input| input.pointer.primary_pressed()) {
|
||
ctx.data_mut(|data| data.remove::<bool>(suppressed_id()));
|
||
}
|
||
let before = app.ui.clone();
|
||
let Some(m) = ctx.data(|d| d.get_temp::<Move>(move_id())) else { return };
|
||
if m.original.docking_generation != app.ui.docking_generation {
|
||
ctx.data_mut(|data| {
|
||
data.remove::<Move>(move_id());
|
||
data.insert_temp(suppressed_id(), true);
|
||
});
|
||
return;
|
||
}
|
||
if ctx.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) {
|
||
app.ui.docking = m.original.docking;
|
||
app.ui.docking_hidden = m.original.docking_hidden;
|
||
app.ui.docking_icons = m.original.docking_icons;
|
||
app.ui.floating_panels = m.original.floating_panels;
|
||
app.ui.open_panel = m.original.open_panel;
|
||
app.ui.dock_tab = m.original.dock_tab;
|
||
app.ui.toolbar_pos = m.original.toolbar_pos;
|
||
ctx.data_mut(|data| {
|
||
data.remove::<Move>(move_id());
|
||
data.insert_temp(suppressed_id(), true);
|
||
});
|
||
return;
|
||
}
|
||
let at = ctx.input(|i| i.pointer.interact_pos());
|
||
let target = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_target(app, ctx, m.what, at));
|
||
if ctx.input(|i| i.pointer.primary_down()) {
|
||
if let Some(p) = at.map(|at| at - m.grab)
|
||
&& p.x.is_finite()
|
||
&& p.y.is_finite()
|
||
{
|
||
match m.what {
|
||
Moving::Panel(id) => {
|
||
if let Some(gi) = group_of(&app.ui, id) {
|
||
set_pos(&mut app.ui, gi, [p.x, p.y]);
|
||
}
|
||
}
|
||
Moving::Tools if app.ui.toolbar_pos.is_some() => app.ui.toolbar_pos = Some([p.x, p.y]),
|
||
Moving::Tools => {}
|
||
}
|
||
}
|
||
if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) {
|
||
app.ui.status = error;
|
||
}
|
||
match target {
|
||
Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line),
|
||
Some((Drop::Stack(_), tabs)) => tab_zone(ctx, tabs),
|
||
Some((_, zone)) => highlight(ctx, zone),
|
||
None => {}
|
||
}
|
||
return;
|
||
}
|
||
ctx.data_mut(|d| d.remove::<Move>(move_id()));
|
||
let Some((drop, _)) = target else { return };
|
||
let Moving::Panel(id) = m.what else {
|
||
app.ui.toolbar_pos = None;
|
||
return;
|
||
};
|
||
// The moved set: its groups' panels, showing the top group's.
|
||
let Some(gi) = group_of(&app.ui, id) else { return };
|
||
let set = set_of(&app.ui, gi);
|
||
let ids = ids_in(&app.ui, &set);
|
||
let active = set.first().and_then(|&i| shown(&app.ui, i)).unwrap_or(id);
|
||
match drop {
|
||
Drop::Dock => {
|
||
dock(&mut app.ui, &ids, active);
|
||
// One that went back to its icon pops out of it, so it doesn't seem to vanish.
|
||
if app.ui.dock_collapsed || DockTab::from_id(active).is_none() {
|
||
app.ui.open_panel = Some(active.to_string());
|
||
}
|
||
}
|
||
Drop::Stack(onto) => stack(&mut app.ui, &ids, active, onto),
|
||
Drop::Below(onto) => attach(&mut app.ui, &set, onto, true),
|
||
Drop::Above(onto) => attach(&mut app.ui, &set, onto, false),
|
||
Drop::Column(slot) => dock_column(&mut app.ui, &set, slot),
|
||
}
|
||
if let Err(error) = crate::panel_docking::reconcile_native(app, before) {
|
||
app.ui.status = error;
|
||
}
|
||
}
|
||
|
||
/// What a press on a floating or docked group did.
|
||
enum Action {
|
||
/// Show the group's `k`th tab.
|
||
Activate(usize, usize),
|
||
/// A double-click on a tab collapses the group to its tabs, or expands it.
|
||
Collapse(usize),
|
||
/// Its × puts the panels of the set's groups back in the dock.
|
||
Close(Vec<usize>),
|
||
/// Move the set of the group holding this panel, drawn at this corner.
|
||
Move(&'static str, Pos2),
|
||
/// Take this group out of its set and move it on its own; its tab strip began this far right.
|
||
Detach(usize, f32),
|
||
/// Float this panel out of its group; its tab began this far right.
|
||
Tear(&'static str, f32),
|
||
/// Float the docked column of this group, its top-left corner this far from the pointer.
|
||
Undock(usize, Vec2),
|
||
}
|
||
|
||
/// A floating group as drawn: its index, panels, the one shown and whether it's collapsed.
|
||
struct Shown {
|
||
gi: usize,
|
||
ids: Vec<&'static str>,
|
||
active: &'static str,
|
||
collapsed: bool,
|
||
}
|
||
|
||
/// The groups `set` as drawn, top to bottom.
|
||
fn shown_groups(ui: &UiState, set: &[usize]) -> Vec<Shown> {
|
||
set.iter()
|
||
.filter_map(|&gi| {
|
||
let g = ui.floating_panels.get(gi)?;
|
||
let ids = ids_of(g);
|
||
let active = ids.get(g.active).or(ids.first()).copied()?;
|
||
Some(Shown { gi, ids, active, collapsed: g.collapsed })
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The groups `set` heads are docked as a dock column.
|
||
fn docked(ui: &UiState, set: &[usize]) -> bool {
|
||
set.first().and_then(|&i| ui.floating_panels.get(i)).is_some_and(|g| g.docked.is_some())
|
||
}
|
||
|
||
/// The floating groups, each a stack of tabs, stacked in their sets under a title bar. Hidden with
|
||
/// the dock (Tab, Presentation Mode). Sets docked as dock columns are drawn by [`docked_columns`].
|
||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||
if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 {
|
||
crate::panel_docking::finish_native_drop(app, ctx);
|
||
return;
|
||
}
|
||
let mut action = None;
|
||
for gi in 0..app.ui.floating_panels.len() {
|
||
if leads(&app.ui, gi) && (app.ui.docking.is_none() || app.ui.floating_panels.get(gi).is_some_and(crate::panel_docking::decorated)) {
|
||
let set = set_of(&app.ui, gi);
|
||
if !docked(&app.ui, &set) {
|
||
show_set(app, ctx, &set, &mut action);
|
||
}
|
||
}
|
||
}
|
||
apply(app, ctx, action);
|
||
crate::panel_docking::finish_native_drop(app, ctx);
|
||
}
|
||
|
||
/// Carry out what a press on a floating or docked group did.
|
||
fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>) {
|
||
if action.is_none() {
|
||
return;
|
||
}
|
||
if matches!(action, Some(Action::Move(..) | Action::Detach(..) | Action::Tear(..) | Action::Undock(..))) && !gesture_allowed(ctx) {
|
||
return;
|
||
}
|
||
let before = app.ui.clone();
|
||
match action {
|
||
Some(Action::Activate(gi, k)) => {
|
||
if let Some(g) = app.ui.floating_panels.get_mut(gi)
|
||
&& g.active != k
|
||
{
|
||
g.active = k;
|
||
g.collapsed = false;
|
||
}
|
||
}
|
||
Some(Action::Collapse(gi)) => {
|
||
if let Some(g) = app.ui.floating_panels.get_mut(gi) {
|
||
g.collapsed = !g.collapsed;
|
||
}
|
||
}
|
||
Some(Action::Close(set)) => {
|
||
let ids = ids_in(&app.ui, &set);
|
||
let active = set.first().and_then(|&i| shown(&app.ui, i)).unwrap_or_default();
|
||
dock(&mut app.ui, &ids, active);
|
||
}
|
||
Some(Action::Move(id, pos)) => {
|
||
// From where it is drawn (a position saved on a bigger window was clamped into this one).
|
||
if let Some(gi) = group_of(&app.ui, id) {
|
||
set_pos(&mut app.ui, gi, [pos.x, pos.y]);
|
||
}
|
||
if let Some(at) = ctx.input(|i| i.pointer.interact_pos()) {
|
||
start(app, ctx, Moving::Panel(id), at - pos);
|
||
}
|
||
}
|
||
Some(Action::Detach(gi, left)) => detach(app, ctx, gi, left),
|
||
Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)),
|
||
Some(Action::Undock(gi, grab)) => {
|
||
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
|
||
let Some(id) = set_of(&app.ui, gi).first().and_then(|&i| shown(&app.ui, i)) else { return };
|
||
let p = at - grab;
|
||
for i in set_of(&app.ui, gi) {
|
||
if let Some(g) = app.ui.floating_panels.get_mut(i) {
|
||
g.docked = None;
|
||
g.pos = [p.x, p.y];
|
||
}
|
||
}
|
||
FloatingPanels::tidy(&mut app.ui.floating_panels);
|
||
start(app, ctx, Moving::Panel(id), grab);
|
||
}
|
||
None => {}
|
||
}
|
||
if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) {
|
||
app.ui.status = error;
|
||
}
|
||
ctx.data_mut(|data| {
|
||
if let Some(mut drag) = data.get_temp::<Move>(move_id()) {
|
||
drag.original = before;
|
||
data.insert_temp(move_id(), drag);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Take group `gi` out of its set (or its dock column) under the pointer, its tab strip having
|
||
/// begun `left` points from the window's left, and carry on moving it with the pointer.
|
||
fn detach(app: &mut VectorcraftApp, ctx: &egui::Context, gi: usize, left: f32) {
|
||
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
|
||
let Some(id) = shown(&app.ui, gi) else { return };
|
||
let grab = strip_grab(ctx, left);
|
||
let p = at - grab;
|
||
if let Some(g) = app.ui.floating_panels.get_mut(gi) {
|
||
g.column = None;
|
||
g.docked = None;
|
||
g.pos = [p.x, p.y];
|
||
}
|
||
FloatingPanels::tidy(&mut app.ui.floating_panels);
|
||
start(app, ctx, Moving::Panel(id), grab);
|
||
}
|
||
|
||
/// Put the groups `set` in the dock as a new column at `slot` (1: next to the icon column, counting
|
||
/// left), moving the columns from there one further left.
|
||
pub fn dock_column(ui: &mut UiState, set: &[usize], slot: u32) {
|
||
let slot = slot.max(1);
|
||
for (i, g) in ui.floating_panels.iter_mut().enumerate() {
|
||
if set.contains(&i) {
|
||
g.docked = Some(slot);
|
||
} else if let Some(d) = g.docked.filter(|d| *d >= slot) {
|
||
g.docked = Some(d.saturating_add(1));
|
||
}
|
||
}
|
||
FloatingPanels::tidy(&mut ui.floating_panels);
|
||
if let Some(tab) = shown_tab(ui) {
|
||
ui.dock_tab = tab;
|
||
}
|
||
}
|
||
|
||
/// The dock columns, as last drawn (egui temp memory), from the one next to the icon column
|
||
/// leftwards.
|
||
pub(crate) fn columns_id() -> Id {
|
||
Id::new("dock-columns")
|
||
}
|
||
|
||
/// The sets docked as columns, by their column number (next to the icon column first).
|
||
fn docked_sets(ui: &UiState) -> Vec<(u32, Vec<usize>)> {
|
||
let mut sets: Vec<(u32, Vec<usize>)> = (0..ui.floating_panels.len())
|
||
.filter(|&gi| leads(ui, gi))
|
||
.filter_map(|gi| Some((ui.floating_panels.get(gi)?.docked?, set_of(ui, gi))))
|
||
.collect();
|
||
sets.sort_by_key(|(d, _)| *d);
|
||
sets
|
||
}
|
||
|
||
/// The sets docked as columns, each a column left of the icon column (`ui`, the window's root, as
|
||
/// [`dock::show`] lays it out): a strip along its top that floats the column when dragged and
|
||
/// whose × puts its panels back in the dock, then its groups top to bottom. Returns the columns'
|
||
/// bounds, next to the icon column first.
|
||
pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec<Rect> {
|
||
let t = Tokens::get(ui.ctx());
|
||
let mut rects = vec![];
|
||
let mut action = None;
|
||
for (_, set) in docked_sets(&app.ui) {
|
||
let groups = shown_groups(&app.ui, &set);
|
||
let Some(first) = groups.first().and_then(|g| g.ids.first().copied()) else { continue };
|
||
let natural = groups.iter().flat_map(|g| g.ids.iter()).map(|id| dock::panel_width(id)).fold(200.0, f32::max);
|
||
let panel = egui::Panel::right(Id::new(("dock-column", first)))
|
||
.resizable(true)
|
||
.default_size(natural)
|
||
.size_range(200.0..=520.0)
|
||
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
|
||
.show(ui, |ui| {
|
||
ui.spacing_mut().item_spacing = Vec2::ZERO;
|
||
let width = ui.available_width();
|
||
let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover());
|
||
ui.painter().rect_filled(bar, 0.0, t.tab_strip);
|
||
let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE));
|
||
let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("column-bar"), Sense::drag());
|
||
for k in 0..2 {
|
||
let y = bar.center().y - 1.5 + k as f32 * 3.0;
|
||
ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled));
|
||
}
|
||
if grip.drag_started()
|
||
&& let (Some(at), Some(&gi)) = (ui.input(|i| i.pointer.press_origin()), set.first())
|
||
{
|
||
action = Some(Action::Undock(gi, at - bar.min));
|
||
}
|
||
grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
|
||
let x = ui.interact(close, ui.id().with("column-close"), Sense::click());
|
||
icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim });
|
||
if x.on_hover_text(tl!("Put back in the dock")).clicked() {
|
||
action = Some(Action::Close(set.clone()));
|
||
}
|
||
let n = groups.len() as f32;
|
||
let open = groups.iter().filter(|g| !g.collapsed).count().max(1) as f32;
|
||
// The open panels share the column's height, each at least `MIN_TALL`: more than fit
|
||
// scroll, with the column.
|
||
let tall = ((ui.available_height() - n * STRIP - (n - 1.0) * GAP - 24.0 * open) / open).max(MIN_TALL);
|
||
egui::ScrollArea::vertical().id_salt(("dock-column-scroll", first)).auto_shrink([false, false]).show(ui, |ui| {
|
||
ui.spacing_mut().item_spacing = Vec2::ZERO;
|
||
let width = ui.available_width();
|
||
let top = ui.cursor().top();
|
||
draw_groups(app, ui, &groups, width, tall, top, None, &mut action);
|
||
});
|
||
});
|
||
rects.push(panel.response.rect);
|
||
}
|
||
ui.ctx().data_mut(|d| {
|
||
let icons = d.get_temp::<Rect>(icons_rect_id());
|
||
if let Some(left) = icons.map(|r| rects.iter().map(|r| r.left()).fold(r.left(), f32::min)) {
|
||
d.insert_temp(dock::dock_left_id(), left);
|
||
}
|
||
d.insert_temp(columns_id(), rects.clone());
|
||
});
|
||
let ctx = ui.ctx().clone();
|
||
apply(app, &ctx, action);
|
||
rects
|
||
}
|
||
|
||
/// Draw the set of floating groups `set` (top to bottom) as one floating box.
|
||
fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action: &mut Option<Action>) {
|
||
let t = Tokens::get(ctx);
|
||
let screen = ctx.content_rect();
|
||
let groups = shown_groups(&app.ui, set);
|
||
let Some((lead, first)) = groups.first().and_then(|g| Some((g, *g.ids.first()?))) else { return };
|
||
let Some(saved) = app.ui.floating_panels.get(lead.gi).map(|g| g.pos) else { return };
|
||
let given = set.iter().filter_map(|&i| app.ui.floating_panels.get(i)?.width).reduce(f32::max).map(|w| w.min(screen.width() - 16.0));
|
||
let area = area_id(first);
|
||
let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(lead.active), 200.0), |r| r.size());
|
||
let pos = clamp(saved, size, screen);
|
||
// The tabbed group's panels fill (Layers) or scroll (Properties) to near the window's bottom,
|
||
// leaving room for their margins (a group that only just fitted would creep up a little each
|
||
// frame); the open panels of a set share that height.
|
||
let n = groups.len() as f32;
|
||
let open = groups.iter().filter(|g| !g.collapsed).count().max(1) as f32;
|
||
let tall = ((screen.bottom() - pos.y - TITLE - n * STRIP - (n - 1.0) * GAP - 60.0) / open).clamp(120.0, 560.0);
|
||
let lead_active = lead.active;
|
||
egui::Area::new(area).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| {
|
||
let frame = egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO);
|
||
frame.show(ui, |ui| {
|
||
ui.spacing_mut().item_spacing = Vec2::ZERO;
|
||
let width = groups
|
||
.iter()
|
||
.map(|g| {
|
||
let tabs_width: f32 = g
|
||
.ids
|
||
.iter()
|
||
.map(|id| {
|
||
let label = panel(id).map_or(*id, |(_, label)| label);
|
||
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0
|
||
})
|
||
.sum();
|
||
g.ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max)
|
||
})
|
||
.fold(given.unwrap_or(0.0), f32::max);
|
||
ui.set_width(width);
|
||
// Title bar: a grip that moves the set, and the × that docks it.
|
||
let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover());
|
||
ui.painter().rect_filled(bar, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_strip);
|
||
let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE));
|
||
let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("title"), Sense::drag());
|
||
for k in 0..2 {
|
||
let y = bar.center().y - 1.5 + k as f32 * 3.0;
|
||
ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled));
|
||
}
|
||
if grip.drag_started() {
|
||
*action = Some(Action::Move(lead_active, pos));
|
||
}
|
||
grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move"));
|
||
let x = ui.interact(close, ui.id().with("close"), Sense::click());
|
||
icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim });
|
||
if x.on_hover_text(tl!("Put back in the dock")).clicked() {
|
||
*action = Some(Action::Close(set.to_vec()));
|
||
}
|
||
let alone = (groups.len() == 1).then_some(pos);
|
||
draw_groups(app, ui, &groups, width, tall, bar.top(), alone, action);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Draw `groups` top to bottom, `width` wide, their open panels `tall`, the first one's bar having
|
||
/// begun at `top`: a bar between groups, each group's tab strip, then its shown panel. `alone`, a
|
||
/// lone floating group drawn at that corner: its tab strip moves it (else it takes its group out of
|
||
/// the set or dock column).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn draw_groups(
|
||
app: &mut VectorcraftApp,
|
||
ui: &mut egui::Ui,
|
||
groups: &[Shown],
|
||
width: f32,
|
||
tall: f32,
|
||
top: f32,
|
||
alone: Option<Pos2>,
|
||
action: &mut Option<Action>,
|
||
) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let pointer = ui.input(|i| i.pointer.interact_pos());
|
||
for (k, g) in groups.iter().enumerate() {
|
||
let top = if k == 0 {
|
||
top
|
||
} else {
|
||
let (gap, _) = ui.allocate_exact_size(vec2(width, GAP), Sense::hover());
|
||
ui.painter().rect_filled(gap, 0.0, t.tab_strip);
|
||
gap.top()
|
||
};
|
||
// Tab strip: a click shows a tab, a double-click collapses the group, a drag out of the
|
||
// strip floats a tab on its own; a lone tab or the strip right of the tabs moves the group
|
||
// (out of its set).
|
||
let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover());
|
||
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
|
||
let lone = g.ids.len() == 1;
|
||
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
|
||
let tab_area = Rect::from_min_max(strip.min, pos2((menu.left() - 4.0).max(strip.left()), strip.bottom()));
|
||
let names: Vec<_> = g.ids.iter().map(|&id| (id, panel(id).map_or(id, |(_, label)| label))).collect();
|
||
let selected = g.ids.iter().position(|&id| id == g.active).unwrap_or(0);
|
||
let out = dock::panel_tabs(ui, &format!("native-set-tabs-{}", g.gi), tab_area, &names, selected, theme::semibold(12.0), false);
|
||
for (j, resp) in out.responses {
|
||
let Some(&id) = g.ids.get(j) else { continue };
|
||
if resp.double_clicked() {
|
||
*action = Some(Action::Collapse(g.gi));
|
||
} else if resp.clicked() {
|
||
*action = Some(Action::Activate(g.gi, j));
|
||
} else if lone && resp.drag_started() {
|
||
*action = Some(match alone {
|
||
Some(pos) => Action::Move(id, pos),
|
||
None => Action::Detach(g.gi, strip.left()),
|
||
});
|
||
} else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) {
|
||
*action = Some(Action::Tear(id, resp.rect.left()));
|
||
}
|
||
}
|
||
if let Some(j) = out.picked {
|
||
*action = Some(Action::Activate(g.gi, j));
|
||
}
|
||
let rest = Rect::from_min_max(pos2(out.right, strip.top()), pos2(menu.left() - 4.0, strip.bottom()));
|
||
if rest.width() > 0.0 {
|
||
let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag());
|
||
if resp.drag_started() {
|
||
*action = Some(match alone {
|
||
Some(pos) => Action::Move(g.active, pos),
|
||
None => Action::Detach(g.gi, strip.left()),
|
||
});
|
||
}
|
||
let tip = if alone.is_some() { tl!("Drag to move") } else { tl!("Drag to move out of the set") };
|
||
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip);
|
||
}
|
||
panels::panel_menu(app, ui, g.active, menu);
|
||
if !g.collapsed {
|
||
dock::panel_body(app, ui, g.active, width, tall);
|
||
}
|
||
// Where the next widget would go: in a dock column, the ui's own bounds reach its bottom.
|
||
let bottom = ui.cursor().top().max(strip.bottom());
|
||
if let Some(&id) = g.ids.first() {
|
||
let rect = Rect::from_min_max(pos2(strip.left(), top), pos2(strip.left() + width, bottom));
|
||
ui.ctx().data_mut(|d| d.insert_temp(group_rect_id(id), rect));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `window.panel.float` and `window.panel.dock`; `None` for other commands.
|
||
pub fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||
let float = match id {
|
||
"window.panel.float" => true,
|
||
"window.panel.dock" => false,
|
||
_ => return None,
|
||
};
|
||
Some(float_or_dock(app, float, p))
|
||
}
|
||
|
||
/// The `x`, `y` params: a top-left corner in screen points, if given.
|
||
fn position(p: &Value) -> Result<Option<Pos2>, String> {
|
||
let coord = |k: &str| match p.get(k) {
|
||
None | Some(Value::Null) => Some(None),
|
||
Some(v) => v.as_f64().filter(|f| f.abs() < 1e6).map(|f| Some(f as f32)),
|
||
};
|
||
match (coord("x"), coord("y")) {
|
||
(Some(Some(x)), Some(Some(y))) => Ok(Some(pos2(x, y))),
|
||
(Some(None), Some(None)) => Ok(None),
|
||
_ => Err("x and y must both be numbers".into()),
|
||
}
|
||
}
|
||
|
||
fn float_or_dock(app: &mut VectorcraftApp, float_it: bool, p: &Value) -> Result<Value, String> {
|
||
let raw = p.get("panel").and_then(Value::as_str).unwrap_or_default().trim();
|
||
let group = crate::menus::opt_bool(p, "group")?.unwrap_or(false);
|
||
let at = position(p)?;
|
||
if raw.eq_ignore_ascii_case("tools") || raw.eq_ignore_ascii_case("toolbar") {
|
||
app.ui.toolbar_pos = float_it.then(|| at.map_or(TOOLS_AT, |p| [p.x, p.y]));
|
||
app.ui.toolbar |= float_it;
|
||
return Ok(json!({ "panel": "tools", "floating": float_it, "pos": app.ui.toolbar_pos }));
|
||
}
|
||
let Some(id) = crate::menus::normalize_panel(raw) else { return Err(format!("unknown panel `{raw}`")) };
|
||
let own = group_of(&app.ui, id);
|
||
// The panel, or with `group` its whole group: its floating group, or the tabs left in the dock.
|
||
let ids: Vec<&'static str> = match (group, own.and_then(|i| app.ui.floating_panels.get(i))) {
|
||
(true, Some(g)) => ids_of(g),
|
||
(true, None) if DockTab::from_id(id).is_some() => docked_tabs(&app.ui).map(|t| t.info().0).collect(),
|
||
_ => vec![id],
|
||
};
|
||
let collapsed = crate::menus::opt_bool(p, "collapsed")?;
|
||
let onto = p.get("onto").and_then(Value::as_str);
|
||
let below = p.get("below").and_then(Value::as_str);
|
||
let above = p.get("above").and_then(Value::as_str);
|
||
if [onto, below, above].iter().filter(|o| o.is_some()).count() > 1 {
|
||
return Err("give only one of onto, below and above".into());
|
||
}
|
||
let slot = column_slot(&app.ui, p)?;
|
||
app.ui.dock = true;
|
||
// Its own group already (not a panel taken out of its group).
|
||
let whole = own.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.panels.len() == ids.len()));
|
||
if !float_it {
|
||
let Some(slot) = slot else {
|
||
dock(&mut app.ui, &ids, id);
|
||
return Ok(json!({ "panel": id, "floating": false }));
|
||
};
|
||
// A dock column of its own group (floated out of its group first) and the rest of its set.
|
||
if whole.is_none() {
|
||
float(&mut app.ui, &ids, id, Pos2::ZERO);
|
||
}
|
||
if let Some(gi) = group_of(&app.ui, id) {
|
||
let set = set_of(&app.ui, gi);
|
||
dock_column(&mut app.ui, &set, slot);
|
||
}
|
||
let column = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)).and_then(|g| g.docked);
|
||
return Ok(json!({ "panel": id, "floating": false, "column": column }));
|
||
}
|
||
// Floating a docked group floats its dock column.
|
||
if let Some(gi) = whole.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.docked.is_some())) {
|
||
for i in set_of(&app.ui, gi) {
|
||
if let Some(g) = app.ui.floating_panels.get_mut(i) {
|
||
g.docked = None;
|
||
}
|
||
}
|
||
FloatingPanels::tidy(&mut app.ui.floating_panels);
|
||
}
|
||
if let Some((next, under)) = below.map(|b| (b, true)).or(above.map(|a| (a, false))) {
|
||
let next = crate::menus::normalize_panel(next)
|
||
.filter(|o| !ids.contains(o) && group_of(&app.ui, o).is_some())
|
||
.ok_or("below and above must name a panel floating in another group")?;
|
||
// The panels as a group of their own (their group already, or floated out), in the set.
|
||
if whole.is_none() {
|
||
float(&mut app.ui, &ids, id, Pos2::ZERO);
|
||
}
|
||
if let Some(gi) = group_of(&app.ui, id) {
|
||
attach(&mut app.ui, &[gi], next, under);
|
||
}
|
||
} else {
|
||
float_onto(app, &ids, id, own, onto, at)?;
|
||
}
|
||
if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) {
|
||
g.active = g.panels.iter().position(|q| q == id).unwrap_or(g.active);
|
||
if let Some(c) = collapsed {
|
||
g.collapsed = c;
|
||
}
|
||
}
|
||
let gi = group_of(&app.ui, id);
|
||
let g = gi.and_then(|i| app.ui.floating_panels.get(i));
|
||
let set: Vec<&Vec<String>> =
|
||
gi.map(|i| set_of(&app.ui, i)).unwrap_or_default().iter().filter_map(|&i| app.ui.floating_panels.get(i)).map(|g| &g.panels).collect();
|
||
let (group, pos, collapsed, column) = (g.map(|g| &g.panels), g.map(|g| g.pos), g.is_some_and(|g| g.collapsed), g.and_then(|g| g.docked));
|
||
Ok(json!({ "panel": id, "floating": column.is_none(), "group": group, "set": set, "pos": pos, "collapsed": collapsed, "column": column }))
|
||
}
|
||
|
||
/// The `column` param of `window.panel.dock`: `true` for a new dock column at the dock's left, or
|
||
/// the place of the new column (1: next to the icon column, counting left); none to dock the panel
|
||
/// where it lives.
|
||
fn column_slot(ui: &UiState, p: &Value) -> Result<Option<u32>, String> {
|
||
let next = ui.floating_panels.iter().filter_map(|g| g.docked).max().unwrap_or(0).saturating_add(1);
|
||
match p.get("column") {
|
||
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
|
||
Some(Value::Bool(true)) => Ok(Some(next)),
|
||
Some(v) => match v.as_u64().filter(|n| *n >= 1) {
|
||
Some(n) => Ok(Some(u32::try_from(n).unwrap_or(u32::MAX).min(next))),
|
||
None => Err("column must be true or a number from 1".into()),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Float `ids` (`id` among them; `own`, the group holding `id`) as their own group at `at`, or
|
||
/// stacked as tabs `onto` another group.
|
||
fn float_onto(
|
||
app: &mut VectorcraftApp,
|
||
ids: &[&'static str],
|
||
id: &'static str,
|
||
own: Option<usize>,
|
||
onto: Option<&str>,
|
||
at: Option<Pos2>,
|
||
) -> Result<(), String> {
|
||
match onto {
|
||
Some(onto) => {
|
||
let onto = crate::menus::normalize_panel(onto)
|
||
.filter(|o| !ids.contains(o) && group_of(&app.ui, o).is_some_and(|g| Some(g) != own))
|
||
.ok_or("onto must name a panel floating in another group")?;
|
||
stack(&mut app.ui, ids, id, onto);
|
||
}
|
||
// Its own group already: moved there (or left where it is), showing the panel.
|
||
None => match own.and_then(|i| app.ui.floating_panels.get_mut(i)).filter(|g| g.panels.len() == ids.len()) {
|
||
// Moved to `at` takes it out of its set; else it stays where it is.
|
||
Some(g) => {
|
||
if let Some(at) = at {
|
||
g.pos = [at.x, at.y];
|
||
g.column = None;
|
||
g.docked = None;
|
||
FloatingPanels::tidy(&mut app.ui.floating_panels);
|
||
}
|
||
}
|
||
None => {
|
||
let n = app.ui.floating_panels.len() as f32;
|
||
let at = at.unwrap_or(pos2(FLOAT_AT[0] + 24.0 * n, FLOAT_AT[1] + 24.0 * n));
|
||
float(&mut app.ui, ids, id, at);
|
||
}
|
||
},
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use egui::{Event, PointerButton};
|
||
use serde_json::json;
|
||
use vectorcraft_engine::Session;
|
||
|
||
use super::*;
|
||
|
||
const SCREEN: Vec2 = vec2(1400.0, 900.0);
|
||
|
||
/// Frames of the whole window, with pointer input one event a frame (as the control
|
||
/// channel's `ui.drag` sends it).
|
||
struct Harness {
|
||
app: VectorcraftApp,
|
||
ctx: egui::Context,
|
||
time: f64,
|
||
screen: Vec2,
|
||
}
|
||
|
||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
|
||
}
|
||
|
||
impl Harness {
|
||
fn new() -> Self {
|
||
Self::on(SCREEN)
|
||
}
|
||
|
||
/// The same window at another size. The icon column scrolls when it is too short for
|
||
/// every icon, which hides the ones past the bottom — a test that counts them needs
|
||
/// a window tall enough to show them all.
|
||
fn on(screen: Vec2) -> Self {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||
let mut h = Self { app, ctx: egui::Context::default(), time: 0.0, screen };
|
||
h.settle();
|
||
h
|
||
}
|
||
|
||
fn frame(&mut self, events: Vec<Event>) {
|
||
self.time += 0.05;
|
||
let screen = Rect::from_min_size(Pos2::ZERO, self.screen);
|
||
let focused =
|
||
events.iter().rev().find_map(|event| if let Event::WindowFocused(focused) = event { Some(*focused) } else { None }).unwrap_or(true);
|
||
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), focused, events, ..Default::default() };
|
||
let app = &mut self.app;
|
||
self.ctx
|
||
.run_ui(raw, |ui| {
|
||
app.logic(ui.ctx());
|
||
app.ui(ui);
|
||
})
|
||
.textures_delta
|
||
.clear();
|
||
}
|
||
|
||
fn settle(&mut self) {
|
||
for _ in 0..4 {
|
||
self.frame(vec![]);
|
||
}
|
||
}
|
||
|
||
/// Press at `from` and move to `to` in steps, the button still down.
|
||
fn hold(&mut self, from: Pos2, to: Pos2) {
|
||
self.frame(vec![Event::PointerMoved(from)]);
|
||
self.frame(vec![button(from, true)]);
|
||
for k in 1..=8 {
|
||
self.frame(vec![Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))]);
|
||
}
|
||
}
|
||
|
||
fn release(&mut self, at: Pos2) {
|
||
self.frame(vec![button(at, false)]);
|
||
self.settle();
|
||
}
|
||
|
||
fn drag(&mut self, from: Pos2, to: Pos2) {
|
||
self.hold(from, to);
|
||
self.release(to);
|
||
}
|
||
|
||
fn click(&mut self, at: Pos2) {
|
||
self.frame(vec![Event::PointerMoved(at), button(at, true)]);
|
||
self.release(at);
|
||
}
|
||
|
||
fn temp_rect(&self, id: Id) -> Rect {
|
||
self.ctx.data(|d| d.get_temp::<Rect>(id)).unwrap()
|
||
}
|
||
|
||
/// The floating group whose first panel is `first`, as drawn.
|
||
fn group(&self, first: &str) -> Rect {
|
||
let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated);
|
||
let id = if self.app.ui.docking.is_some() && !native {
|
||
Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))
|
||
} else {
|
||
area_id(first)
|
||
};
|
||
self.ctx.memory(|m| m.area_rect(id)).unwrap()
|
||
}
|
||
|
||
/// A point on a floating group's title bar (left of its ×).
|
||
fn title(&self, first: &str) -> Pos2 {
|
||
let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated);
|
||
if self.app.ui.docking.is_some() && !native {
|
||
let rect = self.group(first);
|
||
return rect.left_top() + vec2(6.0, 16.0);
|
||
}
|
||
let r = self.group(first);
|
||
pos2(r.left() + 30.0, r.top() + 1.0 + TITLE / 2.0)
|
||
}
|
||
|
||
/// The rects of the widgets that sense a drag and pass `keep`, top to bottom, left to right.
|
||
fn widgets(&self, keep: impl Fn(Rect) -> bool) -> Vec<Rect> {
|
||
let mut r: Vec<Rect> = self.ctx.viewport(|vp| {
|
||
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.sense.senses_drag() && keep(w.rect)).map(|w| w.rect).collect()
|
||
});
|
||
r.sort_by(|a, b| a.top().total_cmp(&b.top()).then(a.left().total_cmp(&b.left())));
|
||
r
|
||
}
|
||
|
||
fn shared_tab(&self, panel: &str) -> Rect {
|
||
self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect
|
||
}
|
||
|
||
/// The dock's tabs, left to right.
|
||
fn dock_tabs(&self) -> Vec<Rect> {
|
||
if let Some(layout) = &self.app.ui.docking {
|
||
let root = craft_ui::docking::Layout { root: layout.root.clone(), floating: Vec::new() };
|
||
return root
|
||
.panels()
|
||
.into_iter()
|
||
.filter_map(|panel| {
|
||
self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", panel))).map(|response| response.rect)
|
||
})
|
||
.collect();
|
||
}
|
||
let dock = self.temp_rect(dock_rect_id());
|
||
self.widgets(|r| r.height() == 32.0 && dock.contains_rect(r))
|
||
}
|
||
|
||
/// The icon column's panel icons, top to bottom.
|
||
fn icons(&self) -> Vec<Rect> {
|
||
let dock = self.temp_rect(if self.app.ui.docking.is_some() { icons_rect_id() } else { dock_rect_id() });
|
||
self.widgets(|r| r.size() == vec2(30.0, 30.0) && dock.contains_rect(r))
|
||
}
|
||
|
||
fn groups(&self) -> Vec<Vec<&str>> {
|
||
self.app.ui.floating_panels.iter().map(|g| g.panels.iter().map(String::as_str).collect()).collect()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_dock_tab_dragged_out_floats_and_dropped_on_the_dock_docks_again() {
|
||
let mut h = Harness::new();
|
||
let tabs = h.dock_tabs();
|
||
assert_eq!(tabs.len(), 3);
|
||
let to = pos2(600.0, 400.0);
|
||
h.drag(tabs[1].center(), to);
|
||
assert_eq!(h.groups(), [["layers"]]);
|
||
assert!(h.group("layers").contains(to));
|
||
assert_eq!(h.dock_tabs().len(), 2);
|
||
assert_eq!(h.app.ui.dock_tab, DockTab::Properties);
|
||
h.click(h.title("layers"));
|
||
assert_eq!(h.groups(), [["layers"]], "a click only activates the floating tab");
|
||
let over = h.shared_tab("properties").center();
|
||
h.drag(h.title("layers"), over);
|
||
assert!(h.groups().is_empty());
|
||
assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers));
|
||
}
|
||
|
||
#[test]
|
||
fn the_strip_right_of_the_tabs_floats_the_whole_group_and_its_close_box_docks_it() {
|
||
let mut h = Harness::new();
|
||
let tabs = h.dock_tabs();
|
||
let rest = tabs[2].right_center() + vec2(30.0, 0.0);
|
||
h.drag(rest, pos2(500.0, 300.0));
|
||
assert_eq!(h.groups(), [["properties", "layers", "libraries"]]);
|
||
assert!(h.dock_tabs().is_empty(), "the tabbed group left the dock");
|
||
let column = h.temp_rect(dock_rect_id());
|
||
assert!(column.width() < 40.0, "only the icon column is left: {column:?}");
|
||
// Its × puts them back.
|
||
let g = h.group("properties");
|
||
let close =
|
||
if h.app.ui.docking.is_some() { pos2(g.right() - 36.0, g.top() + 16.0) } else { pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0) };
|
||
h.click(close);
|
||
assert!(h.groups().is_empty());
|
||
assert_eq!(h.dock_tabs().len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn icons_dragged_out_float_stack_onto_another_group_and_tear_out_of_it() {
|
||
// Tall enough that the icon column shows every icon, so counting them says what it
|
||
// means: one leaves the column, rather than one more scrolling into view.
|
||
let mut h = Harness::on(vec2(1400.0, 1600.0));
|
||
// Color, Color Guide, Swatches… top to bottom.
|
||
let icons = h.icons();
|
||
let count = icons.len();
|
||
assert_eq!(count, crate::state::ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum::<usize>(), "every group's icons are drawn");
|
||
h.drag(icons[2].center(), pos2(500.0, 250.0));
|
||
assert_eq!(h.groups(), [["swatches"]]);
|
||
assert_eq!(h.icons().len(), count - 1);
|
||
let separate = pos2(900.0, 650.0);
|
||
assert!(!h.group("swatches").contains(separate));
|
||
h.drag(h.icons()[0].center(), separate);
|
||
assert_eq!(h.groups(), [["swatches"], ["color"]]);
|
||
let onto = h.shared_tab("swatches").right_center() - vec2(3.0, 0.0);
|
||
h.drag(h.title("color"), onto);
|
||
assert_eq!(h.groups(), [["swatches", "color"]]);
|
||
assert_eq!(h.app.ui.floating_panels[0].active, 1);
|
||
h.click(h.shared_tab("swatches").center());
|
||
assert_eq!(h.app.ui.floating_panels[0].active, 0);
|
||
let to = pos2(900.0, 500.0);
|
||
h.drag(h.shared_tab("color").center(), to);
|
||
assert_eq!(h.groups(), [["swatches"], ["color"]]);
|
||
assert!(h.group("color").contains(to));
|
||
assert_eq!(h.app.run("window.panel", json!({"panel": "Color"})).unwrap(), json!({"floating": ["color"]}));
|
||
assert_eq!(crate::menus::checked(&h.app, "window.panel", &json!({"panel": "color"})), Some(true));
|
||
h.app.run("window.panel.dock", json!({"panel": "color"})).unwrap();
|
||
h.settle();
|
||
assert!(h.groups().iter().all(|group| !group.contains(&"color")));
|
||
assert_eq!(h.icons().len(), count - 1, "Color is back in the column");
|
||
assert_eq!(h.app.ui.open_panel.as_deref(), Some("color"));
|
||
}
|
||
|
||
#[test]
|
||
fn an_icon_panel_can_be_dropped_into_another_docked_tab_group() {
|
||
let mut h = Harness::new();
|
||
h.drag(h.icons()[0].center(), pos2(500.0, 400.0));
|
||
assert_eq!(h.groups(), [["color"]]);
|
||
h.drag(h.shared_tab("color").center(), h.shared_tab("layers").center());
|
||
assert!(h.groups().is_empty());
|
||
assert!(h.app.ui.docking.as_ref().unwrap().contains(&"color".into()));
|
||
assert!(h.app.ui.open_panel.is_none(), "the actual shared group owns Color");
|
||
assert_eq!(h.dock_tabs().len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn a_panel_dropped_on_the_dock_lights_up_and_pops_out_of_its_icon() {
|
||
let mut h = Harness::new();
|
||
let count = h.icons().len();
|
||
h.drag(h.icons()[0].center(), pos2(500.0, 400.0));
|
||
assert_eq!(h.groups(), [["color"]]);
|
||
// Over the tabbed group: Color has no tab there, so the icon column lights up, not the tabs.
|
||
let (dock, column) = (h.temp_rect(dock_rect_id()), h.temp_rect(icons_rect_id()));
|
||
let over = h.dock_tabs()[1].center();
|
||
h.hold(h.title("color"), over);
|
||
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), over), Some((Drop::Dock, column)));
|
||
assert!(column.width() < dock.width(), "the column, not the whole dock: {column:?} {dock:?}");
|
||
// Dropped, it's back in the column and pops out of its icon rather than vanishing.
|
||
h.release(over);
|
||
assert!(h.groups().is_empty());
|
||
assert_eq!((h.icons().len(), h.app.ui.open_panel.as_deref()), (count, Some("color")));
|
||
}
|
||
|
||
#[test]
|
||
fn a_popped_out_panel_dragged_by_its_title_floats() {
|
||
let mut h = Harness::new();
|
||
h.app.run("window.panel", json!({"panel": "swatches"})).unwrap();
|
||
h.settle();
|
||
let flyout = h.ctx.memory(|m| m.area_rect(Id::new("icon-panel"))).unwrap();
|
||
let to = pos2(500.0, 400.0);
|
||
h.drag(flyout.left_top() + vec2(20.0, 13.0), to);
|
||
assert_eq!((h.groups(), h.app.ui.open_panel.as_deref()), (vec![vec!["swatches"]], None));
|
||
assert!(h.group("swatches").contains(to), "under the pointer: {:?}", h.group("swatches"));
|
||
// Its panel menu docks it again.
|
||
h.app.run("window.panel.dock", json!({"panel": "swatches"})).unwrap();
|
||
assert!(h.groups().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn popped_out_title_drag_cancels_on_escape_focus_loss_and_workspace_change() {
|
||
for reason in ["escape", "focus", "workspace"] {
|
||
let mut h = Harness::new();
|
||
h.app.run("window.panel", json!({"panel":"swatches"})).unwrap();
|
||
h.settle();
|
||
let original = h.app.ui.clone();
|
||
let flyout = h.ctx.memory(|m| m.area_rect(Id::new("icon-panel"))).unwrap();
|
||
let to = pos2(500.0, 400.0);
|
||
h.hold(flyout.left_top() + vec2(20.0, 13.0), to);
|
||
assert!(h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"swatches".into())));
|
||
match reason {
|
||
"escape" => h.frame(vec![Event::Key {
|
||
key: egui::Key::Escape,
|
||
physical_key: None,
|
||
pressed: true,
|
||
repeat: false,
|
||
modifiers: Default::default(),
|
||
}]),
|
||
"focus" => h.frame(vec![Event::WindowFocused(false)]),
|
||
_ => {
|
||
crate::workspaces::apply(&mut h.app.ui, &crate::workspaces::Workspace::default());
|
||
h.frame(vec![Event::PointerMoved(pos2(650.0, 450.0))]);
|
||
}
|
||
}
|
||
assert!(h.ctx.data(|d| d.get_temp::<Move>(move_id())).is_none(), "{reason}: stale gesture removed");
|
||
assert!(h.app.ui.docking.is_none(), "{reason}: original/selected workspace retained");
|
||
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: no stale floating group");
|
||
if reason != "workspace" {
|
||
assert_eq!(h.app.ui.open_panel, original.open_panel);
|
||
}
|
||
if reason == "focus" {
|
||
h.frame(vec![Event::WindowFocused(true)]);
|
||
}
|
||
for step in 1..=3 {
|
||
h.frame(vec![Event::PointerMoved(to + vec2(15.0 * step as f32, 8.0))]);
|
||
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: canceled drag stays canceled on held frame {step}");
|
||
}
|
||
h.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Secondary, pressed: true, modifiers: Default::default() }]);
|
||
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: secondary press does not restart primary gesture");
|
||
h.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Secondary, pressed: false, modifiers: Default::default() }]);
|
||
h.release(to);
|
||
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: release does not restart canceled gesture");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn docked_tools_drag_cancellation_restores_the_docked_origin() {
|
||
let mut h = Harness::new();
|
||
let zone = h.temp_rect(tools_zone_id());
|
||
let title = zone.left_top() + vec2(30.0, 7.0);
|
||
let to = pos2(400.0, 300.0);
|
||
h.hold(title, to);
|
||
assert!(h.app.ui.toolbar_pos.is_some());
|
||
h.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
|
||
assert_eq!(h.app.ui.toolbar_pos, None, "cancel restores docked Tools origin");
|
||
h.frame(vec![Event::PointerMoved(to + vec2(30.0, 20.0))]);
|
||
assert_eq!(h.app.ui.toolbar_pos, None);
|
||
h.release(to);
|
||
assert_eq!(h.app.ui.toolbar_pos, None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() {
|
||
let mut h = Harness::new();
|
||
let zone = h.temp_rect(tools_zone_id());
|
||
let title = zone.left_top() + vec2(30.0, 7.0);
|
||
let to = pos2(400.0, 300.0);
|
||
h.drag(title, to);
|
||
let pos = h.app.ui.toolbar_pos.expect("the Tools panel floats");
|
||
let float = h.ctx.memory(|m| m.area_rect(Id::new("toolbar-floating"))).unwrap();
|
||
assert!(float.contains(to) && (pos2(pos[0], pos[1]) - float.min).length() < 1.0, "under the pointer: {float:?}");
|
||
// Back on the window's left edge.
|
||
let edge = pos2(10.0, 400.0);
|
||
h.hold(float.left_top() + vec2(30.0, 7.0), edge);
|
||
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Tools, edge).map(|t| t.0), Some(Drop::Dock));
|
||
h.release(edge);
|
||
assert_eq!(h.app.ui.toolbar_pos, None, "docked");
|
||
// A click on the title bar still switches the columns.
|
||
let double = h.app.ui.toolbar_double;
|
||
h.click(title);
|
||
assert_ne!(h.app.ui.toolbar_double, double);
|
||
}
|
||
|
||
#[test]
|
||
fn floating_panels_are_kept_in_workspaces_and_preferences_and_clamped_into_the_window() {
|
||
let mut h = Harness::new();
|
||
let app = &mut h.app;
|
||
// Saved on a much bigger screen.
|
||
app.run("window.panel.float", json!({"panel": "layers", "x": 5000, "y": 4000})).unwrap();
|
||
let r = app.run("window.panel.float", json!({"panel": "stroke", "onto": "Layers"})).unwrap();
|
||
assert_eq!(r["group"], json!(["layers", "stroke"]));
|
||
app.run("window.panel.float", json!({"panel": "tools", "x": 300, "y": 200})).unwrap();
|
||
app.run("window.workspace.new", json!({"name": "Floating"})).unwrap();
|
||
let saved = app.ui.floating_panels.clone();
|
||
app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
|
||
assert!(app.ui.floating_panels.is_empty() && app.ui.toolbar_pos.is_none(), "Essentials docks everything");
|
||
app.run("window.workspace", json!({"name": "Floating"})).unwrap();
|
||
assert_eq!((&app.ui.floating_panels, app.ui.toolbar_pos), (&saved, Some([300.0, 200.0])));
|
||
let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();
|
||
let prefs = prefs.sanitized();
|
||
assert_eq!((&prefs.floating_panels, prefs.toolbar_pos), (&saved, Some([300.0, 200.0])));
|
||
// Unknown and repeated panels (a hand-edited file) are dropped.
|
||
let edited: UiState = serde_json::from_value(json!({
|
||
"floating_panels": [{"panels": ["nope", "layers"], "active": 7, "pos": [1, 2]}, {"panels": ["layers"], "pos": [3, 4]}],
|
||
}))
|
||
.unwrap();
|
||
let edited = edited.sanitized();
|
||
assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0], ..Default::default() }]);
|
||
h.settle();
|
||
let g = h.group("layers");
|
||
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
|
||
assert!(screen.contains_rect(g), "clamped into the window: {g:?}");
|
||
assert!(h.dock_tabs().len() == 2 && h.app.ui.dock_tab == DockTab::Properties);
|
||
}
|
||
|
||
#[test]
|
||
fn float_and_dock_commands_check_their_params() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
assert!(app.run("window.panel.float", json!({"panel": "nope"})).is_err());
|
||
assert!(app.run("window.panel.float", json!({"panel": "layers", "x": "left", "y": 2})).is_err());
|
||
assert!(app.run("window.panel.float", json!({"panel": "layers", "x": 2})).is_err(), "both x and y");
|
||
assert!(app.run("window.panel.float", json!({"panel": "layers", "group": "yes"})).is_err());
|
||
assert!(app.run("window.panel.float", json!({"panel": "layers", "onto": "color"})).is_err(), "Color isn't floating");
|
||
assert!(app.ui.floating_panels.is_empty());
|
||
// The tabbed group's tabs float together.
|
||
let r = app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap();
|
||
assert_eq!(r["group"], json!(["properties", "layers", "libraries"]));
|
||
assert_eq!(app.ui.floating_panels[0].active, 1);
|
||
assert!(app.run("window.panel.float", json!({"panel": "layers", "onto": "properties"})).is_err(), "not onto its own group");
|
||
// Floated out of its group; a lone panel moves.
|
||
app.run("window.panel.float", json!({"panel": "layers", "x": 10, "y": 20})).unwrap();
|
||
assert_eq!((app.ui.floating_panels.len(), floating_pos(&app, "layers")), (2, [10.0, 20.0]));
|
||
app.run("window.panel.float", json!({"panel": "layers", "x": 30, "y": 40})).unwrap();
|
||
assert_eq!((app.ui.floating_panels.len(), floating_pos(&app, "layers")), (2, [30.0, 40.0]));
|
||
app.run("window.panel.dock", json!({"panel": "properties", "group": true})).unwrap();
|
||
assert_eq!(app.ui.floating_panels.len(), 1);
|
||
assert_eq!(app.ui.dock_tab, DockTab::Properties);
|
||
assert_eq!(app.run("window.panel.dock", json!({"panel": "tools"})).unwrap()["floating"], json!(false));
|
||
app.ui.toolbar = false;
|
||
app.run("window.panel.float", json!({"panel": "Toolbar"})).unwrap();
|
||
assert!(app.ui.toolbar && app.ui.toolbar_pos == Some(TOOLS_AT), "floating shows the Tools panel");
|
||
}
|
||
|
||
/// The groups' sets: each group's panels and its column, in list order.
|
||
fn sets(groups: &[FloatingPanels]) -> Vec<(Vec<&str>, Option<u32>)> {
|
||
groups.iter().map(|g| (g.panels.iter().map(String::as_str).collect(), g.column)).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn groups_dropped_on_an_edge_stack_into_a_set_that_moves_together_and_tears_apart() {
|
||
let mut h = Harness::new();
|
||
h.app.run("window.panel.float", json!({"panel": "color", "x": 500, "y": 150})).unwrap();
|
||
h.app.run("window.panel.float", json!({"panel": "swatches", "x": 950, "y": 450})).unwrap();
|
||
h.settle();
|
||
// Swatches' title bar held over Color's bottom edge: a line shows it goes below.
|
||
let color = h.temp_rect(group_rect_id("color"));
|
||
let below = pos2(color.center().x, color.bottom() + 2.0);
|
||
h.hold(h.title("swatches"), below);
|
||
let target = drop_target(&h.app, &h.ctx, Moving::Panel("swatches"), below);
|
||
assert_eq!(target.map(|t| t.0), Some(Drop::Below("color")));
|
||
assert!(target.is_some_and(|t| t.1.height() <= 4.0), "a line, not a box: {target:?}");
|
||
h.release(below);
|
||
let s = sets(&h.app.ui.floating_panels);
|
||
assert_eq!((s[0].0.clone(), s[1].0.clone()), (vec!["color"], vec!["swatches"]), "Color, then Swatches");
|
||
assert!(s[0].1.is_some() && s[0].1 == s[1].1, "one set: {s:?}");
|
||
assert_eq!(h.app.ui.floating_panels[0].pos, h.app.ui.floating_panels[1].pos, "at the set's corner");
|
||
// Drawn as one box: Swatches right under Color.
|
||
let (color, swatches) = (h.temp_rect(group_rect_id("color")), h.temp_rect(group_rect_id("swatches")));
|
||
assert!((swatches.top() - color.bottom()).abs() < 1.0 && swatches.left() == color.left(), "{color:?} {swatches:?}");
|
||
assert!(h.group("color").contains_rect(swatches.shrink(1.0)), "in Color's area");
|
||
// The set moves by its title bar.
|
||
let to = pos2(300.0, 120.0);
|
||
let grab = h.title("color") - h.group("color").min;
|
||
h.drag(h.title("color"), to);
|
||
let moved = [to.x - grab.x, to.y - grab.y];
|
||
assert!(h.app.ui.floating_panels.iter().all(|g| (g.pos[0] - moved[0]).abs() < 1.0 && (g.pos[1] - moved[1]).abs() < 1.0));
|
||
assert_eq!(sets(&h.app.ui.floating_panels).len(), 2);
|
||
// Swatches' lone tab dragged away takes it out of the set: both float on their own.
|
||
let swatches = h.temp_rect(group_rect_id("swatches"));
|
||
let tab = pos2(swatches.left() + 20.0, swatches.top() + GAP + STRIP / 2.0);
|
||
let away = pos2(1000.0, 600.0);
|
||
h.drag(tab, away);
|
||
assert_eq!(sets(&h.app.ui.floating_panels), [(vec!["color"], None), (vec!["swatches"], None)]);
|
||
assert!(h.group("swatches").contains(away), "under the pointer: {:?}", h.group("swatches"));
|
||
// Dropped on Color's top edge, it goes above Color, which it now heads.
|
||
let color = h.temp_rect(group_rect_id("color"));
|
||
let above = pos2(color.center().x, color.top() - 3.0);
|
||
h.hold(h.title("swatches"), above);
|
||
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("swatches"), above).map(|t| t.0), Some(Drop::Above("color")));
|
||
h.release(above);
|
||
let s = sets(&h.app.ui.floating_panels);
|
||
assert_eq!((s[0].0.clone(), s[1].0.clone()), (vec!["swatches"], vec!["color"]));
|
||
assert!(s[0].1.is_some() && s[0].1 == s[1].1);
|
||
// A group's tab strip still takes tabs: Stroke dropped on Color's tabs joins Color's group.
|
||
h.app.run("window.panel.float", json!({"panel": "stroke", "x": 1000, "y": 650})).unwrap();
|
||
h.settle();
|
||
let color = h.temp_rect(group_rect_id("color"));
|
||
let tabs = pos2(color.center().x, color.top() + GAP + STRIP / 2.0);
|
||
h.drag(h.title("stroke"), tabs);
|
||
assert_eq!(sets(&h.app.ui.floating_panels).iter().map(|s| s.0.clone()).collect::<Vec<_>>(), [vec!["swatches"], vec!["color", "stroke"]]);
|
||
// The set's × docks every group.
|
||
let g = h.group("swatches");
|
||
h.click(pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0));
|
||
assert!(h.groups().is_empty(), "docked: {:?}", h.groups());
|
||
}
|
||
|
||
#[test]
|
||
fn a_double_click_on_a_tab_collapses_a_floating_group_and_another_expands_it() {
|
||
let mut h = Harness::new();
|
||
h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 150})).unwrap();
|
||
h.app.run("window.panel.float", json!({"panel": "stroke", "below": "layers"})).unwrap();
|
||
h.settle();
|
||
let open = h.temp_rect(group_rect_id("layers"));
|
||
let tab = pos2(open.left() + 20.0, open.top() + 1.0 + TITLE + STRIP / 2.0);
|
||
let double = |h: &mut Harness| {
|
||
// Well after any earlier click, so the first click doesn't make a double-click with it.
|
||
h.time += 1.0;
|
||
h.frame(vec![Event::PointerMoved(tab), button(tab, true)]);
|
||
h.frame(vec![button(tab, false)]);
|
||
h.frame(vec![button(tab, true)]);
|
||
h.frame(vec![button(tab, false)]);
|
||
h.settle();
|
||
};
|
||
double(&mut h);
|
||
assert!(h.app.ui.floating_panels[0].collapsed, "collapsed");
|
||
let shut = h.temp_rect(group_rect_id("layers"));
|
||
assert!(shut.height() < 1.0 + TITLE + STRIP + 2.0, "only its tabs: {shut:?}");
|
||
assert!(h.temp_rect(group_rect_id("stroke")).top() < open.bottom(), "Stroke moved up under it");
|
||
double(&mut h);
|
||
assert!(!h.app.ui.floating_panels[0].collapsed, "expanded");
|
||
// Collapsed by command, Window › Layers expands it.
|
||
h.app.run("window.panel.float", json!({"panel": "layers", "collapsed": true})).unwrap();
|
||
assert!(h.app.ui.floating_panels[0].collapsed);
|
||
h.app.run("window.panel", json!({"panel": "layers"})).unwrap();
|
||
assert!(!h.app.ui.floating_panels[0].collapsed);
|
||
}
|
||
|
||
#[test]
|
||
fn sets_by_command_are_checked_and_kept_in_workspaces_and_preferences() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("window.panel.float", json!({"panel": "layers", "x": 100, "y": 50})).unwrap();
|
||
assert!(app.run("window.panel.float", json!({"panel": "color", "below": "swatches"})).is_err(), "Swatches isn't floating");
|
||
assert!(app.run("window.panel.float", json!({"panel": "color", "onto": "layers", "below": "layers"})).is_err(), "one of them");
|
||
assert!(app.run("window.panel.float", json!({"panel": "layers", "below": "layers"})).is_err(), "not below itself");
|
||
assert!(app.run("window.panel.float", json!({"panel": "layers", "collapsed": "yes"})).is_err());
|
||
assert_eq!(app.ui.floating_panels.len(), 1, "nothing floated by the errors");
|
||
// Color floats straight into the set, below Layers; Stroke above it.
|
||
let r = app.run("window.panel.float", json!({"panel": "color", "below": "Layers"})).unwrap();
|
||
assert_eq!(r["set"], json!([["layers"], ["color"]]));
|
||
let r = app.run("window.panel.float", json!({"panel": "stroke", "above": "color"})).unwrap();
|
||
assert_eq!(r["set"], json!([["layers"], ["stroke"], ["color"]]));
|
||
assert!(app.ui.floating_panels.iter().all(|g| g.pos == [100.0, 50.0]), "at the set's corner");
|
||
// Stroke's whole group moves to the set's bottom.
|
||
app.run("window.panel.float", json!({"panel": "stroke", "below": "color"})).unwrap();
|
||
assert_eq!(sets(&app.ui.floating_panels).iter().map(|s| s.0.clone()).collect::<Vec<_>>(), [vec!["layers"], vec!["color"], vec!["stroke"]]);
|
||
// Kept in a workspace and the preferences.
|
||
app.run("window.panel.float", json!({"panel": "color", "collapsed": true})).unwrap();
|
||
app.run("window.workspace.new", json!({"name": "Sets"})).unwrap();
|
||
let saved = app.ui.floating_panels.clone();
|
||
app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
|
||
assert!(app.ui.floating_panels.is_empty());
|
||
app.run("window.workspace", json!({"name": "Sets"})).unwrap();
|
||
assert_eq!(app.ui.floating_panels, saved);
|
||
let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();
|
||
assert_eq!(prefs.sanitized().floating_panels, saved);
|
||
// Moved to x, y, a group leaves its set; the set left with one group floats on its own.
|
||
let r = app.run("window.panel.float", json!({"panel": "color", "x": 600, "y": 400})).unwrap();
|
||
assert_eq!((r["set"].clone(), r["pos"].clone()), (json!([["color"]]), json!([600.0, 400.0])));
|
||
app.run("window.panel.dock", json!({"panel": "layers"})).unwrap();
|
||
assert_eq!(sets(&app.ui.floating_panels), [(vec!["stroke"], None), (vec!["color"], None)]);
|
||
// A hand-edited file: a set's groups kept together at its first group's corner, a lone one alone.
|
||
let edited: UiState = serde_json::from_value(json!({
|
||
"floating_panels": [
|
||
{"panels": ["layers"], "pos": [1, 2], "column": 4},
|
||
{"panels": ["color"], "pos": [9, 9]},
|
||
{"panels": ["stroke"], "pos": [5, 6], "column": 4},
|
||
{"panels": ["swatches"], "pos": [7, 8], "column": 9},
|
||
],
|
||
}))
|
||
.unwrap();
|
||
let edited = edited.sanitized();
|
||
let s = sets(&edited.floating_panels);
|
||
assert_eq!(s, [(vec!["layers"], Some(4)), (vec!["stroke"], Some(4)), (vec!["color"], None), (vec!["swatches"], None)]);
|
||
assert_eq!(edited.floating_panels[1].pos, [1.0, 2.0]);
|
||
}
|
||
|
||
/// The dock columns as drawn, next to the icon column first.
|
||
fn columns(h: &Harness) -> Vec<Rect> {
|
||
h.ctx.data(|d| d.get_temp::<Vec<Rect>>(columns_id())).unwrap_or_default()
|
||
}
|
||
|
||
/// Each group's panels and dock column, in list order.
|
||
fn docked_of(groups: &[FloatingPanels]) -> Vec<(Vec<&str>, Option<u32>)> {
|
||
groups.iter().map(|g| (g.panels.iter().map(String::as_str).collect(), g.docked)).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn sets_dropped_on_the_docks_left_edge_become_dock_columns() {
|
||
let mut h = Harness::new();
|
||
h.app.run("window.panel.float", json!({"panel": "color", "x": 300, "y": 150})).unwrap();
|
||
h.settle();
|
||
// Held over the icon column's left edge, a line shows the new column; dropped, it docks.
|
||
let icons = h.temp_rect(icons_rect_id());
|
||
let edge = pos2(icons.left() - 2.0, icons.center().y);
|
||
h.hold(h.title("color"), edge);
|
||
let target = drop_target(&h.app, &h.ctx, Moving::Panel("color"), edge);
|
||
assert_eq!(target.map(|t| t.0), Some(Drop::Column(1)));
|
||
assert!(target.is_some_and(|t| t.1.width() <= 4.0), "a line: {target:?}");
|
||
h.release(edge);
|
||
assert_eq!(docked_of(&h.app.ui.floating_panels), [(vec!["color"], Some(1))]);
|
||
let cols = columns(&h);
|
||
assert_eq!(cols.len(), 1);
|
||
let icons = h.temp_rect(icons_rect_id());
|
||
assert!((cols[0].right() - icons.left()).abs() < 2.0, "right of it the icon column: {cols:?} {icons:?}");
|
||
// Drawn in the column, not floating too (a floating box, drawn after the dock, would move it).
|
||
assert!(cols[0].contains_rect(h.temp_rect(group_rect_id("color")).shrink(1.0)), "Color in the column");
|
||
// Swatches dropped on the docked Color's bottom edge joins its column.
|
||
h.app.run("window.panel.float", json!({"panel": "swatches", "x": 300, "y": 300})).unwrap();
|
||
h.settle();
|
||
let color = h.temp_rect(group_rect_id("color"));
|
||
let below = pos2(color.center().x, color.bottom() + 2.0);
|
||
h.drag(h.title("swatches"), below);
|
||
assert_eq!(docked_of(&h.app.ui.floating_panels), [(vec!["color"], Some(1)), (vec!["swatches"], Some(1))]);
|
||
assert!(columns(&h)[0].contains_rect(h.temp_rect(group_rect_id("swatches")).shrink(1.0)));
|
||
// Stroke dropped on the column's left edge makes a second column, left of it.
|
||
h.app.run("window.panel.float", json!({"panel": "stroke", "x": 300, "y": 400})).unwrap();
|
||
h.settle();
|
||
let col = columns(&h)[0];
|
||
h.drag(h.title("stroke"), pos2(col.left() - 2.0, col.center().y));
|
||
assert_eq!(docked_of(&h.app.ui.floating_panels).last(), Some(&(vec!["stroke"], Some(2))));
|
||
let cols = columns(&h);
|
||
assert!(cols.len() == 2 && cols[1].right() <= cols[0].left() + 2.0, "{cols:?}");
|
||
// A docked lone tab dragged away floats its group, leaving the rest of the column docked.
|
||
let swatches = h.temp_rect(group_rect_id("swatches"));
|
||
h.drag(pos2(swatches.left() + 20.0, swatches.top() + GAP + STRIP / 2.0), pos2(500.0, 500.0));
|
||
let d = docked_of(&h.app.ui.floating_panels);
|
||
assert!(d.contains(&(vec!["swatches"], None)) && d.contains(&(vec!["color"], Some(1))), "{d:?}");
|
||
// The column's top strip floats the whole column.
|
||
let col = columns(&h)[0];
|
||
h.drag(pos2(col.left() + 30.0, col.top() + TITLE / 2.0), pos2(500.0, 200.0));
|
||
let d = docked_of(&h.app.ui.floating_panels);
|
||
assert!(d.contains(&(vec!["color"], None)) && d.contains(&(vec!["stroke"], Some(1))), "renumbered: {d:?}");
|
||
assert!(h.group("color").contains(pos2(500.0, 200.0)), "under the pointer");
|
||
// The column's × puts its panels back in the dock.
|
||
let col = columns(&h)[0];
|
||
h.click(pos2(col.right() - 11.0, col.top() + TITLE / 2.0));
|
||
assert!(!h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"stroke".to_string())));
|
||
assert!(columns(&h).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn dock_columns_by_command_are_checked_and_kept_in_workspaces() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
assert!(app.run("window.panel.dock", json!({"panel": "color", "column": 0})).is_err());
|
||
assert!(app.run("window.panel.dock", json!({"panel": "color", "column": "left"})).is_err());
|
||
assert!(app.ui.floating_panels.is_empty());
|
||
let r = app.run("window.panel.dock", json!({"panel": "color", "column": true})).unwrap();
|
||
assert_eq!((r["column"].clone(), r["floating"].clone()), (json!(1), json!(false)));
|
||
app.run("window.panel.dock", json!({"panel": "layers", "column": true})).unwrap();
|
||
// Next to the icon column, moving Color and Layers one column left.
|
||
app.run("window.panel.dock", json!({"panel": "stroke", "column": 1})).unwrap();
|
||
app.run("window.panel.float", json!({"panel": "gradient", "below": "stroke"})).unwrap();
|
||
let d = docked_of(&app.ui.floating_panels);
|
||
for (panel, column) in [("stroke", 1), ("gradient", 1), ("color", 2), ("layers", 3)] {
|
||
assert!(d.contains(&(vec![panel], Some(column))), "{panel} in column {column}: {d:?}");
|
||
}
|
||
// Kept in a workspace and the preferences.
|
||
app.run("window.workspace.new", json!({"name": "Columns"})).unwrap();
|
||
let saved = app.ui.floating_panels.clone();
|
||
app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
|
||
app.run("window.workspace", json!({"name": "Columns"})).unwrap();
|
||
assert_eq!(app.ui.floating_panels, saved);
|
||
let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();
|
||
assert_eq!(prefs.sanitized().floating_panels, saved);
|
||
// Floated, Color's column leaves the dock and the columns close up.
|
||
let r = app.run("window.panel.float", json!({"panel": "color"})).unwrap();
|
||
assert_eq!((r["floating"].clone(), r["column"].clone()), (json!(true), json!(null)));
|
||
assert!(docked_of(&app.ui.floating_panels).contains(&(vec!["layers"], Some(2))));
|
||
// A hand-edited file: columns numbered without gaps, a set's groups in its first one's.
|
||
let edited: UiState = serde_json::from_value(json!({
|
||
"floating_panels": [
|
||
{"panels": ["layers"], "pos": [1, 2], "docked": 7},
|
||
{"panels": ["color"], "pos": [1, 2], "column": 3, "docked": 3},
|
||
{"panels": ["stroke"], "pos": [5, 6], "column": 3},
|
||
],
|
||
}))
|
||
.unwrap();
|
||
let edited = edited.sanitized();
|
||
let d = docked_of(&edited.floating_panels);
|
||
assert_eq!(d, [(vec!["layers"], Some(2)), (vec!["color"], Some(1)), (vec!["stroke"], Some(1))]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_dock_column_takes_groups_anywhere_in_it_and_as_tabs() {
|
||
let mut h = Harness::new();
|
||
h.app.run("window.panel.dock", json!({"panel": "color", "column": true})).unwrap();
|
||
h.app.run("window.panel.float", json!({"panel": "swatches", "below": "color"})).unwrap();
|
||
h.app.run("window.panel.float", json!({"panel": "stroke", "below": "swatches"})).unwrap();
|
||
h.settle();
|
||
let order = |h: &Harness| h.app.ui.floating_panels.iter().map(|g| g.panels.join("+")).collect::<Vec<_>>();
|
||
// Dropped on the upper half of Swatches, Gradient goes in right above it.
|
||
h.app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 200})).unwrap();
|
||
h.settle();
|
||
let r = h.temp_rect(group_rect_id("swatches"));
|
||
let tabs_bottom = r.top() + GAP + STRIP;
|
||
let upper = pos2(r.center().x, tabs_bottom + (r.bottom() - tabs_bottom) / 4.0);
|
||
h.hold(h.title("gradient"), upper);
|
||
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("gradient"), upper).map(|t| t.0), Some(Drop::Above("swatches")));
|
||
h.release(upper);
|
||
assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke"]);
|
||
assert!(h.app.ui.floating_panels.iter().all(|g| g.docked == Some(1)), "all in the column");
|
||
// Dropped on Stroke's tabs, Transparency joins Stroke's group as a tab (Color and Gradient
|
||
// collapsed, so Stroke is in view in the column, which scrolls).
|
||
for g in h.app.ui.floating_panels.iter_mut().take(2) {
|
||
g.collapsed = true;
|
||
}
|
||
h.app.run("window.panel.float", json!({"panel": "transparency", "x": 300, "y": 200})).unwrap();
|
||
h.settle();
|
||
let r = h.temp_rect(group_rect_id("stroke"));
|
||
let tabs = pos2(r.left() + 120.0, r.top() + GAP + STRIP / 2.0);
|
||
h.hold(h.title("transparency"), tabs);
|
||
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("transparency"), tabs).map(|t| t.0), Some(Drop::Stack("stroke")));
|
||
h.release(tabs);
|
||
assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke+transparency"]);
|
||
// Dropped in the room under the groups (three collapsed to make some), Align goes at the
|
||
// column's bottom.
|
||
for g in h.app.ui.floating_panels.iter_mut().take(3) {
|
||
g.collapsed = true;
|
||
}
|
||
h.app.run("window.panel.float", json!({"panel": "align", "x": 300, "y": 200})).unwrap();
|
||
h.settle();
|
||
let col = columns(&h)[0];
|
||
h.drag(h.title("align"), pos2(col.center().x, col.bottom() - 6.0));
|
||
assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke+transparency", "align"]);
|
||
assert!(h.app.ui.floating_panels.iter().all(|g| g.docked == Some(1)));
|
||
}
|
||
|
||
#[test]
|
||
fn a_dock_column_too_tall_for_the_window_scrolls() {
|
||
let mut h = Harness::new();
|
||
h.app.run("window.panel.dock", json!({"panel": "layers", "column": true})).unwrap();
|
||
let mut above = "layers";
|
||
for p in ["properties", "color", "swatches", "stroke", "gradient", "transparency", "align"] {
|
||
h.app.run("window.panel.float", json!({"panel": p, "below": above})).unwrap();
|
||
above = p;
|
||
}
|
||
h.settle();
|
||
let col = columns(&h)[0];
|
||
assert!(col.bottom() <= SCREEN.y + 1.0, "the column stays in the window: {col:?}");
|
||
let last = h.temp_rect(group_rect_id("align"));
|
||
assert!(last.top() > col.bottom(), "Align starts below the window: {last:?} {col:?}");
|
||
// The wheel over the column (Color's tabs, outside any panel's own scrolling) scrolls it,
|
||
// bringing Align up.
|
||
let color = h.temp_rect(group_rect_id("color"));
|
||
let over = pos2(color.left() + 150.0, color.top() + GAP + STRIP / 2.0);
|
||
for _ in 0..4 {
|
||
h.frame(crate::toolbar::tests::wheel(over, 300.0));
|
||
}
|
||
h.settle();
|
||
let moved = h.temp_rect(group_rect_id("align"));
|
||
assert!(moved.top() < last.top() - 100.0, "scrolled up: {moved:?} from {last:?}");
|
||
}
|
||
|
||
fn floating_pos(app: &VectorcraftApp, id: &str) -> [f32; 2] {
|
||
group_of(&app.ui, id).map(|i| app.ui.floating_panels[i].pos).unwrap()
|
||
}
|
||
}
|