Merge pull request #513 from storytold/fix/issue-495

Fix #495: panels, panel groups and the Tools panel undock into floating panels
This commit is contained in:
BFlat 2026-10-08 21:37:23 +07:00 committed by GitHub
commit 0e59e0a38d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1234 additions and 234 deletions

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,7 @@ use serde_json::json;
use crate::state::{DockTab, ICON_PANEL_GROUPS, ICON_PANELS};
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, icons, panels, widgets};
use crate::{VectorcraftApp, floating, icons, panels, widgets};
const ICON_COL: f32 = 38.0;
/// Height of the strip along the top of a dock column that carries its double arrow.
@ -57,18 +57,26 @@ fn toggle(app: &mut VectorcraftApp, collapsed: bool) {
}
}
/// One icon of the column: a click pops its panel out (or puts it away).
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, label: &str, icon: &str) {
/// One icon of the column: a click pops its panel out (or puts it away); dragged out of the
/// column (`column`), the panel floats.
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, column: Rect) {
let open = app.ui.open_panel.as_deref() == Some(id);
if widgets::icon_button(ui, icon, label, open, 30.0).clicked() {
// Its own id: the icons below it move up when it floats, and mustn't take over its drag.
let (_, rect) = ui.allocate_space(vec2(30.0, 30.0));
let resp = widgets::paint_icon_button(ui, ui.interact(rect, ui.id().with(("panel-icon", id)), Sense::click_and_drag()), icon, label, open);
if resp.clicked() {
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
}
if resp.dragged() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !column.contains(p)) {
floating::tear(app, ui.ctx(), &[id], id, floating::strip_grab(ui.ctx(), resp.rect.left()));
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// Main tabbed group, unless it is collapsed to icons.
if !app.ui.dock_collapsed {
let shown = floating::shown_tab(&app.ui);
// Main tabbed group, unless it is collapsed to icons or all its panels float.
let group = shown.filter(|_| !app.ui.dock_collapsed).map(|active| {
egui::Panel::right("dock")
.resizable(true)
.default_size(DOCK_WIDTH)
@ -78,47 +86,31 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if collapse_header(ui, false) {
toggle(app, true);
}
// Tab strip.
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let mut x = strip.left();
for tab in DockTab::ALL {
let (_, label, _) = tab.info();
let active = app.ui.dock_tab == tab;
let galley =
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim });
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click());
if active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
if resp.clicked() {
app.ui.dock_tab = tab;
}
x = r.right();
if tab_strip(app, ui, active) {
// It floats from this frame on.
return;
}
let menu_id = app.ui.dock_tab.info().0;
panels::panel_menu(app, ui, menu_id, egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)));
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab {
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match active {
DockTab::Properties => {
egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui));
}
DockTab::Layers => panels::layers::show(app, ui),
DockTab::Libraries => panels::libraries(app, ui),
});
});
}
})
.response
.rect
});
// Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock). A
// collapsed group's panels head it, under the « that expands them again.
let collapsed = app.ui.dock_collapsed;
// collapsed group's panels head it, under the « that expands them again. Floating panels leave
// it.
let collapsed = app.ui.dock_collapsed && shown.is_some();
let column = egui::Panel::right("icon_column")
.resizable(false)
.exact_size(ICON_COL)
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
.show(ui, |ui| {
let bounds = ui.max_rect();
if collapsed && collapse_header(ui, true) {
toggle(app, false);
}
@ -129,41 +121,98 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if collapsed {
for tab in DockTab::ALL {
let (id, label, icon) = tab.info();
panel_icon(app, ui, id, label, icon);
if floating::group_of(&app.ui, id).is_none() {
panel_icon(app, ui, id, label, icon, bounds);
}
}
}
for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() {
if gi > 0 || collapsed {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
ui.painter()
.line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
}
// A divider above each group with icons left, but the first.
let mut divider = collapsed;
for group in ICON_PANEL_GROUPS {
let mut first = true;
for id in group.iter() {
let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue };
panel_icon(app, ui, id, label, icon);
if floating::group_of(&app.ui, id).is_some() {
continue;
}
if std::mem::take(&mut first) && std::mem::replace(&mut divider, true) {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
ui.painter()
.line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
}
panel_icon(app, ui, id, label, icon, bounds);
}
}
});
});
});
let left = column.response.rect.left();
ui.ctx().data_mut(|d| d.insert_temp(column_left_id(), left));
let column = column.response.rect;
let dock = group.map_or(column, |g| g.union(column));
ui.ctx().data_mut(|d| {
d.insert_temp(column_left_id(), column.left());
d.insert_temp(floating::dock_rect_id(), dock);
});
}
/// The tabbed group's tab strip, showing `active`: a click on a tab shows it, a tab dragged out of
/// the strip floats on its own, and the strip right of the tabs floats the whole group. True when
/// the shown panel floats now.
fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
let t = Tokens::get(ui.ctx());
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let out = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p));
// What a drag out of the strip floats: the panels, the one shown, where its tab began.
let mut tear: Option<(Vec<&'static str>, &'static str, f32)> = None;
let mut x = strip.left();
for tab in DockTab::ALL {
let (id, label, _) = tab.info();
if floating::group_of(&app.ui, id).is_some() {
continue;
}
let galley =
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if tab == active { t.text_strong } else { t.text_dim });
let r = Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click_and_drag());
if tab == active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
if resp.clicked() {
app.ui.dock_tab = tab;
} else if resp.dragged() && out {
tear = Some((vec![id], id, r.left()));
}
x = r.right();
}
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let rest = Rect::from_min_max(egui::pos2(x, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
if rest.width() > 0.0 {
let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::drag());
if resp.dragged() && out {
tear = Some((floating::docked_tabs(&app.ui).map(|t| t.info().0).collect(), active.info().0, strip.left()));
}
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
}
panels::panel_menu(app, ui, active.info().0, menu);
if let Some((ids, id, left)) = tear {
floating::tear(app, ui.ctx(), &ids, id, floating::strip_grab(ui.ctx(), left));
}
// Torn off, or floated from its menu.
floating::group_of(&app.ui, active.info().0).is_some()
}
/// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels
/// while the dock is collapsed.
/// while the dock is collapsed. Dragging its title floats it.
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(id) = app.ui.open_panel.clone() else { return };
let tab = DockTab::from_id(&id);
let label = match tab {
Some(tab) if app.ui.dock_collapsed => tab.info().1,
// Expanded, the group shows its panels in the dock itself.
Some(_) => return,
None => match ICON_PANELS.iter().find(|p| p.0 == id) {
Some((_, label, _)) => *label,
None => return,
},
};
let Some((id, label)) = app.ui.open_panel.as_deref().and_then(|p| crate::state::all_panels().find(|(q, _)| *q == p)) else { return };
let tab = DockTab::from_id(id);
// Expanded, the group shows its panels in the dock itself; a floating panel shows in its group.
if tab.is_some() && !app.ui.dock_collapsed || floating::group_of(&app.ui, id).is_some() {
return;
}
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
// Pinned by its right edge, 6 points left of the icon column wherever the dock's width (or its
@ -179,52 +228,39 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
let right = column - 6.0;
// The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one
// that keeps the flyout on screen.
// The tabbed group's panels keep the width the dock gives them; icon panels are 256 points.
let width = if tab.is_some() { DOCK_WIDTH } else { 256.0 };
let width = panel_width(id);
let tall = (screen.height() - FLYOUT_TOP - 26.0 - 20.0 - 40.0).clamp(160.0, 560.0);
let mut open = true;
let mut torn = None;
let area = egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP);
area.fixed_pos(egui::pos2(right, FLYOUT_TOP)).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
ui.set_width(width);
let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover());
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
let title = egui::Rect::from_min_size(
let title = Rect::from_min_size(
strip.min,
vec2(ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
);
ui.painter().rect_filled(title, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
ui.painter().text(title.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text);
let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
let close = Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
if cr.clicked() {
open = false;
}
let menu_r = egui::Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0));
panels::panel_menu(app, ui, &id, menu_r);
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(width - 20.0);
match tab {
Some(DockTab::Properties) => {
// At least `tall` when the sections are taller: the flyout's area keeps the
// size its content needs, and egui sizes a new area at most 400 points high,
// so a scroll area that only took the room it was given never grew past it.
egui::ScrollArea::vertical()
.id_salt("props-flyout")
.max_height(tall)
.min_scrolled_height(tall)
.auto_shrink([false, true])
.show(ui, |ui| panels::properties::show(app, ui));
}
Some(DockTab::Layers) => {
ui.set_max_height(tall);
panels::layers::show(app, ui);
}
Some(DockTab::Libraries) => panels::libraries(app, ui),
None => panels::show_icon_panel(app, ui, &id),
}
});
let menu_r = Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0));
panels::panel_menu(app, ui, id, menu_r);
let drag = ui.interact(title, ui.id().with("title"), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab);
if drag.dragged() && ui.input(|i| i.pointer.is_decidedly_dragging()) {
torn = Some(title.left());
}
// Torn off, or floated from its menu: it floats from this frame on.
if torn.is_some() || floating::group_of(&app.ui, id).is_some() {
return;
}
panel_body(app, ui, id, width, tall);
});
});
// User Interface › Auto-Collapse Iconic Panels (#394): a press away from the flyout puts it
@ -240,11 +276,46 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
open = false;
}
}
if !open {
if let Some(left) = torn {
floating::tear(app, ctx, &[id], id, floating::strip_grab(ctx, left));
} else if !open {
app.ui.open_panel = None;
}
}
/// The width of a popped-out or floating panel: the tabbed group's panels keep the width the dock
/// gives them; icon panels are 256 points.
pub(crate) fn panel_width(id: &str) -> f32 {
if DockTab::from_id(id).is_some() { DOCK_WIDTH } else { 256.0 }
}
/// Panel `id`'s contents in a popped-out or floating panel `width` wide; the tabbed group's panels
/// fill (Layers) or scroll (Properties) `tall` points.
pub(crate) fn panel_body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32, tall: f32) {
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(width - 20.0);
match DockTab::from_id(id) {
Some(DockTab::Properties) => {
// At least `tall` when the sections are taller: the flyout's area keeps the size its
// content needs, and egui sizes a new area at most 400 points high, so a scroll area
// that only took the room it was given never grew past it.
egui::ScrollArea::vertical()
.id_salt("props-flyout")
.max_height(tall)
.min_scrolled_height(tall)
.auto_shrink([false, true])
.show(ui, |ui| panels::properties::show(app, ui));
}
Some(DockTab::Layers) => {
ui.set_max_height(tall);
panels::layers::show(app, ui);
}
Some(DockTab::Libraries) => panels::libraries(app, ui),
None => panels::show_icon_panel(app, ui, id),
}
});
}
#[cfg(test)]
mod tests {
use serde_json::json;

View File

@ -0,0 +1,769 @@
//! 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.
//!
//! `window.panel.float` / `window.panel.dock` do the same for agents. The groups 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;
/// 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)]
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, Copy, Debug)]
struct Move {
what: Moving,
grab: Vec2,
from: Pos2,
}
/// Where a moved group or Tools panel lands.
#[derive(Clone, Copy, Debug, PartialEq)]
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),
}
fn move_id() -> Id {
Id::new("floating-move")
}
/// 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.
pub(crate) fn tools_zone_id() -> Id {
Id::new("floating-tools-zone")
}
/// The area of the floating group whose first panel is `first`.
pub fn area_id(first: &str) -> Id {
Id::new(("floating-panels", 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 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()
});
}
/// 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] });
// 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(ctx: &egui::Context, what: Moving, grab: Vec2) {
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 }));
}
/// 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) {
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
float(&mut app.ui, ids, active, at - grab);
start(ctx, Moving::Panel(active), grab);
}
/// 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>) {
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
match floating {
Some(pos) if title.drag_started() => start(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;
app.ui.toolbar_pos = Some([p.x, p.y]);
start(ctx, Moving::Tools, grab);
}
_ => {}
}
}
/// Where the pointer at `at` would drop `what`, and the zone that lights up for it.
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);
// Another group's title bar and tabs stack with it.
for (i, g) in app.ui.floating_panels.iter().enumerate() {
let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| Some(i) != own) else { continue };
let head = ctx.memory(|m| m.area_rect(area_id(first))).map(|r| Rect::from_min_size(r.min, vec2(r.width(), 1.0 + TITLE + STRIP)));
if let Some(head) = head.filter(|h| h.contains(at)) {
return Some((Drop::Stack(first), head));
}
}
zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3).map(|r| (Drop::Dock, r))
}
/// Light up a drop zone: over the docked panels, under the floating ones (the moved one too).
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);
}
/// 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) {
let Some(m) = ctx.data(|d| d.get_temp::<Move>(move_id())) else { 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(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) {
g.pos = [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 Some((_, zone)) = target {
highlight(ctx, zone);
}
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;
};
let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)) else { return };
let ids = ids_of(g);
let active = ids.get(g.active).copied().unwrap_or(id);
match drop {
Drop::Dock => dock(&mut app.ui, &ids, active),
Drop::Stack(onto) => stack(&mut app.ui, &ids, active, onto),
}
}
/// What a press on a floating group did.
enum Action {
/// Show the group's `k`th tab.
Activate(usize, usize),
/// Its × puts the group's panels back in the dock.
Close(usize),
/// Move the group holding this panel, drawn at this corner.
Move(&'static str, Pos2),
/// Float this panel out of its group; its tab began this far right.
Tear(&'static str, f32),
}
/// The floating groups, each a stack of tabs under a title bar. Hidden with the dock (Tab,
/// Presentation Mode).
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 {
return;
}
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
let pointer = ctx.input(|i| i.pointer.interact_pos());
let mut action = None;
for gi in 0..app.ui.floating_panels.len() {
let Some(g) = app.ui.floating_panels.get(gi) else { break };
let ids = ids_of(g);
let Some(&first) = ids.first() else { continue };
let active = ids.get(g.active).copied().unwrap_or(first);
let area = area_id(first);
let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(active), 200.0), |r| r.size());
let pos = clamp(g.pos, 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).
let tall = (screen.bottom() - pos.y - TITLE - STRIP - 60.0).clamp(120.0, 560.0);
let lone = ids.len() == 1;
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 tabs: Vec<_> = ids
.iter()
.map(|id| {
let label = panel(id).map_or(*id, |(_, label)| label);
let color = if *id == active { t.text_strong } else { t.text_dim };
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color)
})
.collect();
let tabs_width: f32 = tabs.iter().map(|g| g.size().x + 24.0).sum();
let width = ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max);
ui.set_width(width);
// Title bar: a grip that moves the group, 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(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(gi));
}
// Tab strip: a click shows a tab, a drag out of the strip floats it on its own.
let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
let mut left = strip.left();
for (k, (&id, galley)) in ids.iter().zip(tabs).enumerate() {
let r = Rect::from_min_size(pos2(left, strip.top()), vec2(galley.size().x + 24.0, STRIP));
left = r.right();
let resp = ui.interact(r, ui.id().with(("tab", id)), Sense::click_and_drag());
if id == active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
if resp.clicked() {
action = Some(Action::Activate(gi, k));
} else if lone && resp.drag_started() {
action = Some(Action::Move(id, pos));
} else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) {
action = Some(Action::Tear(id, r.left()));
}
}
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let rest = Rect::from_min_max(pos2(left, strip.top()), pos2(menu.left() - 4.0, strip.bottom()));
if rest.width() > 0.0 {
let resp = ui.interact(rest, ui.id().with("strip"), Sense::drag());
if resp.drag_started() {
action = Some(Action::Move(active, pos));
}
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move"));
}
panels::panel_menu(app, ui, active, menu);
dock::panel_body(app, ui, active, width, tall);
});
});
}
match action {
Some(Action::Activate(gi, k)) => {
if let Some(g) = app.ui.floating_panels.get_mut(gi) {
g.active = k;
}
}
Some(Action::Close(gi)) => {
if let Some(g) = app.ui.floating_panels.get(gi) {
let ids = ids_of(g);
let active = ids.get(g.active).copied().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(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) {
g.pos = [pos.x, pos.y];
}
if let Some(at) = pointer {
start(ctx, Moving::Panel(id), at - pos);
}
}
Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)),
None => {}
}
}
/// `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],
};
app.ui.dock = true;
if !float_it {
dock(&mut app.ui, &ids, id);
return Ok(json!({ "panel": id, "floating": false }));
}
match p.get("onto").and_then(Value::as_str) {
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()) {
Some(g) => {
g.active = g.panels.iter().position(|p| p == id).unwrap_or(g.active);
if let Some(at) = at {
g.pos = [at.x, at.y];
}
}
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);
}
},
}
let g = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i));
Ok(json!({ "panel": id, "floating": true, "group": g.map(|g| &g.panels), "pos": g.map(|g| g.pos) }))
}
#[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, 1400 × 900, 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,
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
impl Harness {
fn new() -> 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 };
h.settle();
h
}
fn frame(&mut self, events: Vec<Event>) {
self.time += 0.05;
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), 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 {
self.ctx.memory(|m| m.area_rect(area_id(first))).unwrap()
}
/// A point on a floating group's title bar (left of its ×).
fn title(&self, first: &str) -> Pos2 {
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
}
/// The dock's tabs, left to right.
fn dock_tabs(&self) -> Vec<Rect> {
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(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, "Properties, Layers, Libraries: {tabs:?}");
let to = pos2(600.0, 400.0);
h.drag(tabs[1].center(), to);
assert_eq!(h.groups(), [["layers"]], "Layers floats");
assert!(h.group("layers").contains(to), "under the pointer: {:?}", h.group("layers"));
assert_eq!(h.dock_tabs().len(), 2, "its tab left the dock");
assert_eq!(h.app.ui.dock_tab, DockTab::Properties);
// A click on its title bar moves nothing, even over the dock.
let dock = h.temp_rect(dock_rect_id());
h.app.run("window.panel.float", json!({"panel": "layers", "x": dock.left() + 10.0, "y": 300})).unwrap();
h.settle();
h.click(h.title("layers"));
assert_eq!(h.groups(), [["layers"]], "a click doesn't dock it");
// Dragged by its title bar onto the dock, the dock lights up, and the release docks it.
h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 300})).unwrap();
h.settle();
let over = dock.center();
h.hold(h.title("layers"), over);
assert!(h.group("layers").contains(over), "the group follows the pointer");
let target = drop_target(&h.app, &h.ctx, Moving::Panel("layers"), over);
assert_eq!(target.map(|t| t.0), Some(Drop::Dock), "the dock is the drop zone");
h.release(over);
assert!(h.groups().is_empty(), "docked: {:?}", h.groups());
assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers), "back in the dock, shown");
}
#[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");
h.click(pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0));
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() {
let mut h = Harness::new();
// Color, Color Guide, Swatches… top to bottom.
let icons = h.icons();
let count = icons.len();
h.drag(icons[2].center(), pos2(500.0, 250.0));
assert_eq!(h.groups(), [["swatches"]]);
assert_eq!(h.icons().len(), count - 1, "its icon left the column");
h.drag(h.icons()[0].center(), pos2(500.0, 650.0));
assert_eq!(h.groups(), [["swatches"], ["color"]]);
// Color's title bar dropped on the Swatches group's tabs stacks them.
let onto = h.group("swatches").left_top() + vec2(150.0, 1.0 + TITLE + STRIP / 2.0);
h.hold(h.title("color"), onto);
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), onto).map(|t| t.0), Some(Drop::Stack("swatches")));
h.release(onto);
assert_eq!(h.groups(), [["swatches", "color"]]);
assert_eq!(h.app.ui.floating_panels[0].active, 1, "showing the panel dropped on it");
// A click shows a tab; a tab dragged out of the strip floats on its own again.
let g = h.group("swatches");
let tabs = h.widgets(|r| r.height() == STRIP && g.contains_rect(r));
assert_eq!(tabs.len(), 3, "two tabs and the strip right of them: {tabs:?}");
h.click(tabs[0].center());
assert_eq!(h.app.ui.floating_panels[0].active, 0);
let to = pos2(900.0, 500.0);
h.drag(tabs[1].center(), to);
assert_eq!(h.groups(), [["swatches"], ["color"]]);
assert!(h.group("color").contains(to));
// Window › Color shows it where it floats; Dock Panel puts it back.
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_eq!(h.icons().len(), count - 1, "Color is back in the column");
}
#[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 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] }]);
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");
}
fn floating_pos(app: &VectorcraftApp, id: &str) -> [f32; 2] {
group_of(&app.ui, id).map(|i| app.ui.floating_panels[i].pos).unwrap()
}
}

View File

@ -611,4 +611,6 @@
Drag to move Přetažením přesunete
Drag to float as a toolbar Přetažením vytvoříte plovoucí panel nástrojů
Put back in the toolbar Vrátit do panelu nástrojů
Float Panel Plovoucí panel
Dock Panel Ukotvit panel
Float Tool Group Plovoucí skupina nástrojů

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3053,6 +3053,12 @@ axis Both Ambos
Drag to move Arrastrar para mover
Drag to float as a toolbar Arrastrar para flotar como barra de herramientas
Put back in the toolbar Devolver a la barra de herramientas
Float Panel Hacer flotar el panel
Dock Panel Anclar panel
Drag to float the panel group Arrastrar para hacer flotar el grupo de paneles
Put back in the dock Devolver al área de paneles
Drag to float the toolbar Arrastrar para hacer flotar la barra de herramientas
Drag to the window's left edge to dock Arrastrar al borde izquierdo de la ventana para anclar
Float Tool Group Hacer flotar el grupo de herramientas
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
@ -3573,3 +3579,5 @@ axis Both Ambos
@msg Reload the page to go on (changes not saved yet may be lost). Vuelve a cargar la página para continuar (los cambios que aún no se guardaron podrían perderse).
@msg tool (a tool id) is required se requiere tool (un id de herramienta)
@msg {tool} isn't in a toolbar group of several tools {tool} no está en un grupo de la barra de herramientas con varias herramientas
@msg onto must name a panel floating in another group onto debe nombrar un panel que flote en otro grupo
@msg x and y must both be numbers x e y deben ser números

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3041,4 +3041,10 @@ axis Both 両方
Drag to move 移動するにはドラッグ
Drag to float as a toolbar ドラッグしてツールバーとして浮かせる
Put back in the toolbar ツールバーに戻す
Float Panel パネルをフローティング
Dock Panel パネルをドッキング
Drag to float the panel group ドラッグしてパネルグループをフローティング
Put back in the dock ドックに戻す
Drag to float the toolbar ドラッグしてツールバーをフローティング
Drag to the window's left edge to dock ウィンドウの左端までドラッグしてドッキング
Float Tool Group ツールグループをフローティング

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2633,4 +2633,6 @@
Drag to move Arraste para mover
Drag to float as a toolbar Arraste para flutuar como barra de ferramentas
Put back in the toolbar Devolver à barra de ferramentas
Float Panel Flutuar Painel
Dock Panel Encaixar Painel
Float Tool Group Flutuar Grupo de Ferramentas

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3298,4 +3298,10 @@ axis Both 两者
Drag to move 拖动以移动
Drag to float as a toolbar 拖动使其浮动为工具栏
Put back in the toolbar 放回工具栏
Float Panel 浮动面板
Dock Panel 停放面板
Drag to float the panel group 拖动以浮动面板组
Put back in the dock 放回停放区
Drag to float the toolbar 拖动以浮动工具栏
Drag to the window's left edge to dock 拖到窗口左边缘以停放
Float Tool Group 浮动工具组

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3072,4 +3072,10 @@ axis Both 兩者
Drag to move 拖曳以移動
Drag to float as a toolbar 拖曳使其浮動為工具列
Put back in the toolbar 放回工具列
Float Panel 浮動面板
Dock Panel 停駐面板
Drag to float the panel group 拖曳以浮動面板群組
Put back in the dock 放回停駐區
Drag to float the toolbar 拖曳以浮動工具列
Drag to the window's left edge to dock 拖曳到視窗左側邊緣以停駐
Float Tool Group 浮動工具組

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -24,6 +24,7 @@ pub mod cursors;
pub mod dialogs;
pub mod dock;
pub mod find_font;
pub mod floating;
pub mod font_menu;
pub mod graphics;
pub mod i18n;
@ -895,6 +896,7 @@ impl VectorcraftApp {
let t0 = now_ms();
scrub::begin_frame(self, &ctx);
font_menu::end_stale_preview(self, &ctx);
floating::track(self, &ctx);
let t = theme::Tokens::get(&ctx);
if self.ui.screen_mode < 2 {
chrome::app_bar(self, ui);
@ -919,6 +921,7 @@ impl VectorcraftApp {
canvas::show(self, ui);
});
dock::floating_panel(self, &ctx);
floating::show(self, &ctx);
panels::library_panel::show_window(self, &ctx);
dialogs::show(self, &ctx);
palette::show(self, &ctx);

View File

@ -225,6 +225,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"),
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"),
(
"window.panel.float",
"Float Panel",
"",
"{panel: id or \"tools\", x?, y?, onto?: id, group?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it with the floating group holding that panel instead; `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved. \"tools\" floats the Tools panel. Returns {panel, floating, group, pos}",
),
(
"window.panel.dock",
"Dock Panel",
"",
"{panel: id or \"tools\", group?: bool} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; \"tools\" docks the Tools panel at the window's left edge",
),
(
"window.collapseDock",
"Collapse Panels to Icons",
@ -772,13 +784,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
/// Canonical panel id for `window.panel`: a dock tab's or an icon panel's id or display label,
/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`).
fn normalize_panel(input: &str) -> Option<&'static str> {
pub(crate) fn normalize_panel(input: &str) -> Option<&'static str> {
let name = input.trim();
crate::state::all_panels().find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label)).map(|(id, _)| id)
}
/// An optional `{key?: bool}` param: none when it is omitted or null (the commands then toggle).
fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> {
pub(crate) fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
@ -803,7 +815,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
if let Some(r) = crate::panels::character::intercept_text_command(app, id) {
return Some(r);
}
if let Some(r) = crate::shortcut_editor::run_command(app, id, p).or_else(|| crate::workspaces::run_command(app, id, p)) {
if let Some(r) = crate::shortcut_editor::run_command(app, id, p)
.or_else(|| crate::workspaces::run_command(app, id, p))
.or_else(|| crate::floating::command(app, id, p))
{
return Some(r);
}
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
@ -1059,6 +1074,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
// Canonical id (case-insensitive; display labels work too), then the
// dock tab it names, if any.
let canonical = normalize_panel(&raw);
// A floating panel shows its tab in its group.
if let Some(p) = canonical
&& let Some(g) = crate::floating::group_of(&app.ui, p).and_then(|i| app.ui.floating_panels.get_mut(i))
{
g.active = g.panels.iter().position(|q| q == p).unwrap_or(g.active);
let out = json!({ "floating": g.panels });
app.ui.dock = true;
return Some(Ok(out));
}
let tab = canonical.and_then(DockTab::from_id);
match (canonical, tab) {
// A collapsed dock pops the panel out of its icon, like the icon panels.
@ -1370,7 +1394,8 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
let canonical = normalize_panel(panel);
match canonical.and_then(DockTab::from_id) {
Some(tab) if !app.ui.dock_collapsed => app.ui.dock_tab == tab,
_ if canonical.is_some_and(|id| crate::floating::group_of(&app.ui, id).is_some()) => true,
Some(tab) if !app.ui.dock_collapsed => crate::floating::shown_tab(&app.ui) == Some(tab),
_ => canonical.is_some_and(|id| app.ui.open_panel.as_deref() == Some(id)),
}
}

View File

@ -113,8 +113,9 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
}
}
/// Items of a panel's (≡) menu. Unimplemented Illustrator items are listed disabled.
pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
/// Items of a panel's (≡) menu. Unimplemented Illustrator items are listed disabled. False for a
/// panel without items of its own.
pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool {
match id {
"swatches" => swatches::menu(app, ui),
"color" => color::menu(app, ui),
@ -152,10 +153,9 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
links::ID => links::menu(app, ui),
asset_export::ID => asset_export::menu(app, ui),
css_properties::ID => css_properties::menu(app, ui),
_ => {
ui.add_enabled(false, egui::Button::new(tl!("No options")).frame(false));
}
_ => return false,
}
true
}
/// The ≡ panel-menu button drawn into `rect` (the right end of a panel's title/tab strip).
@ -167,7 +167,20 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) {
egui::Popup::menu(&resp).show(|ui| {
crate::widgets::menu_scroll(ui, |ui| {
ui.set_min_width(220.0);
panel_menu_items(app, ui, id);
if panel_menu_items(app, ui, id) {
ui.separator();
}
// The panel floats out of the dock or goes back in, as dragging its tab does.
let (label, cmd) = if crate::floating::group_of(&app.ui, id).is_some() {
(tl!("Dock Panel"), "window.panel.dock")
} else {
(tl!("Float Panel"), "window.panel.float")
};
if crate::widgets::menu_item(ui, label, true, false)
&& let Err(e) = app.run(cmd, json!({ "panel": id }))
{
app.ui.status = e;
}
});
});
}

View File

@ -268,6 +268,13 @@ pub struct UiState {
/// Tool flyouts torn off the toolbar into floating panels.
#[serde(default)]
pub floating_flyouts: Vec<FloatingFlyout>,
/// Panels dragged out of the dock: each group floats as its own stack of tabs.
#[serde(default)]
pub floating_panels: Vec<FloatingPanels>,
/// The Tools panel floats with its top-left corner here (dragged out by its title bar); `None`:
/// docked at the window's left edge.
#[serde(default)]
pub toolbar_pos: Option<[f32; 2]>,
pub status_bar: bool,
pub dock: bool,
pub view: ViewFlags,
@ -411,6 +418,34 @@ pub struct TaskBarPlace {
pub offset: Option<(u64, egui::Vec2)>,
}
/// A group of panels dragged out of the dock: it floats as a stack of tabs, one panel shown.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FloatingPanels {
/// Panel ids (`window.panel`), in tab order.
pub panels: Vec<String>,
/// The tab shown.
#[serde(default)]
pub active: usize,
/// Top-left corner in screen points.
pub pos: [f32; 2],
}
impl FloatingPanels {
/// Keep known panels, each in one group, drop the groups left empty and the toolbar position
/// that isn't a number (a hand-edited preferences file or workspace).
pub fn sanitize(groups: &mut Vec<FloatingPanels>, toolbar_pos: &mut Option<[f32; 2]>) {
let mut seen = std::collections::BTreeSet::new();
groups.retain_mut(|g| {
g.panels.retain(|id| all_panels().any(|(p, _)| p == id) && seen.insert(id.clone()));
g.active = g.active.min(g.panels.len().saturating_sub(1));
!g.panels.is_empty()
});
if toolbar_pos.is_some_and(|p| !p.iter().all(|v| v.is_finite())) {
*toolbar_pos = None;
}
}
}
impl UiState {
/// Clear transient state after loading saved preferences.
pub fn sanitized(mut self) -> Self {
@ -429,6 +464,7 @@ impl UiState {
f.tools.retain(|id| vectorcraft_tools::tool_info(id).is_some());
f.tools.first().is_some_and(|k| seen.insert(k.clone()))
});
FloatingPanels::sanitize(&mut self.floating_panels, &mut self.toolbar_pos);
// Overrides that can't fire (modifier-only chords recorded by older versions, #487) give
// the default back.
self.shortcut_overrides.retain(|_, c| c.is_empty() || crate::shortcut_editor::normalize(c).is_some());
@ -452,6 +488,8 @@ impl Default for UiState {
task_bar_place: TaskBarPlace::default(),
slot_tool: Default::default(),
floating_flyouts: vec![],
floating_panels: vec![],
toolbar_pos: None,
status_bar: true,
dock: true,
view: ViewFlags::default(),

View File

@ -89,148 +89,170 @@ pub fn remember(app: &mut VectorcraftApp, id: &str) {
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let all = slots(app);
// The user's choice alone: a window too short for the tools scrolls them.
let cols = if app.ui.toolbar_double { 2 } else { 1 };
let w = if cols == 2 { 76.0 } else { WIDTH };
egui::Panel::left("toolbar")
.resizable(false)
.exact_size(w)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).stroke(Stroke::new(1.5, t.border)))
.show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
// Dock header with » and a grip strip.
let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::click());
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
icons::paint(
ui,
if cols == 2 { "chevrons-left" } else { "chevrons-right" },
egui::Rect::from_min_size(hdr.left_top() + vec2(3.0, 2.0), vec2(10.0, 10.0)),
if hresp.hovered() { t.text_strong } else { t.text },
);
if hresp.on_hover_text(tl!("Toggle single/double column")).clicked()
// It only fails on bad params, which this never sends; show it all the same.
&& let Err(e) = app.run("window.toolbarColumns", json!({ "double": cols == 1 }))
{
app.ui.status = e;
}
let (grip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 6.0), Sense::hover());
for k in 0..6 {
ui.painter().line_segment(
[pos2(grip.center().x - 9.0, grip.top() + 1.5 + k as f32 * 0.6), pos2(grip.center().x + 9.0, grip.top() + 1.5 + k as f32 * 0.6)],
Stroke::new(0.4, t.text_disabled),
);
}
// The tools and the controls under them scroll in a window too short for them.
widgets::strip_scroll(ui, "toolbar", |ui| {
let active = app.session.tool_id();
// The tool button whose long press opened its flyout, until the next press.
let held_id = egui::Id::new("toolbar-held");
if ui.input(|inp| inp.pointer.any_pressed()) {
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
}
let held: Option<egui::Id> = ui.data(|d| d.get_temp(held_id));
let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None;
// A group torn off into a floating flyout is raised instead of opening its flyout.
let mut raise: Option<&'static str> = None;
let mut i = 0;
while i < all.len() {
if let Some(cat) = all[i].0 {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover());
// A long name is cut to its first four characters in the single column.
let cat = tl!(cat);
let label = if cols == 1 && cat.chars().count() > 6 {
format!("{}...", cat.chars().take(4).collect::<String>())
} else {
cat.to_string()
};
ui.painter().text(r.center() + vec2(0.0, 2.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(11.0), t.text);
}
// One row = `cols` slots (a category label always starts a new row).
let mut row = vec![i];
while row.len() < cols && i + row.len() < all.len() && all[i + row.len()].0.is_none() {
row.push(i + row.len());
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
if cols == 1 {
ui.add_space((WIDTH - 36.0) / 2.0);
} else {
ui.add_space(2.0);
}
for &k in &row {
let slot = &all[k].1;
let shown_id = if slot.contains(&active) {
active.to_string()
} else {
app.ui.slot_tool.get(slot[0]).cloned().unwrap_or_else(|| slot[0].to_string())
};
let Some(shown) = tool_info(&shown_id).or_else(|| tool_info(slot[0])) else { continue };
let is_active = slot.contains(&active);
let (rect, resp) = ui.allocate_exact_size(vec2(36.0, PITCH - 1.0), Sense::click_and_drag());
let well = egui::Rect::from_center_size(rect.center(), vec2(35.5, 27.5));
if is_active {
ui.painter().rect_filled(well, CornerRadius::same(1), t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(well, CornerRadius::same(1), t.hover);
}
let ir = egui::Rect::from_center_size(rect.center(), vec2(18.0, 18.0));
icons::paint(ui, icons::tool_icon(shown.icon), ir, if is_active { t.text_strong } else { t.icon });
if slot.len() > 1 {
let c = rect.center() + vec2(12.5, 10.0);
ui.painter().add(egui::Shape::convex_polygon(
vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)],
t.icon,
Stroke::NONE,
));
}
let press = if slot.len() > 1 { flyout_press(ui, &resp, rect) } else { None };
let alt = ui.input(|inp| inp.modifiers.alt);
if press.is_some() {
if is_floating(app, slot[0]) {
raise = Some(slot[0]);
} else {
open_flyout = Some((slot.clone(), rect));
}
if press == Some(FlyoutPress::Primary) {
ui.data_mut(|d| d.insert_temp(held_id, resp.id));
}
} else if resp.clicked() && held == Some(resp.id) {
// Releasing the long press that opened the flyout leaves it open.
} else if resp.clicked() && alt && slot.len() > 1 {
let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0);
app.select_tool(slot[(idx + 1) % slot.len()]);
} else if resp.double_clicked() {
app.select_tool(shown.id);
app.run("tool.options", json!({ "tool": shown.id })).ok();
} else if resp.clicked() {
app.select_tool(shown.id);
}
resp.on_hover_text(tip(shown));
}
});
i += row.len();
}
if let Some(key) = raise {
raise_floating(ui.ctx(), key);
}
if let Some((slot, rect)) = open_flyout {
app.ui.flyout = Some(0);
ui.data_mut(|d| {
d.insert_temp(egui::Id::new("flyout-anchor"), rect);
d.insert_temp(egui::Id::new("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::<Vec<String>>());
});
}
ui.add_space(8.0);
bottom_controls(app, ui, &t);
// Where the panel docks: along the window's left edge, under the bars.
let edge = ui.available_rect_before_wrap();
ui.ctx().data_mut(|d| d.insert_temp(crate::floating::tools_zone_id(), egui::Rect::from_min_size(edge.min, vec2(WIDTH, edge.height()))));
let frame =
egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).stroke(Stroke::new(1.5, t.border));
match app.ui.toolbar_pos {
None => {
egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(frame).show(ui, |ui| body(app, ui, cols, None));
}
Some(pos) => {
// Floating, kept inside the window, its tools scrolling when they don't fit under it.
let ctx = ui.ctx().clone();
let id = egui::Id::new("toolbar-floating");
let screen = ctx.content_rect();
// Kept on screen by its title bar and first tools: the rest scrolls.
let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(w, 160.0), |r| vec2(r.width(), r.height().min(160.0)));
let at = crate::floating::clamp(pos, size, screen);
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(at).show(&ctx, |ui| {
flyout_frame(&t, false).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).show(ui, |ui| {
ui.set_width(w);
ui.set_max_height(screen.bottom() - at.y - 12.0);
body(app, ui, cols, Some(at));
});
});
});
}
}
// Before the flyout: one torn off this frame floats from the next (its bar mustn't be in two
// layers in one frame).
floating(app, ui.ctx());
flyout(app, ui.ctx());
}
/// The Tools panel's contents, docked or floating at `floating`.
fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egui::Pos2>) {
let t = Tokens::get(ui.ctx());
let all = slots(app);
let bounds = ui.max_rect();
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
// Title bar with » and a grip strip: a drag on either floats the panel or moves it.
let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::click_and_drag());
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
icons::paint(
ui,
if cols == 2 { "chevrons-left" } else { "chevrons-right" },
egui::Rect::from_min_size(hdr.left_top() + vec2(3.0, 2.0), vec2(10.0, 10.0)),
if hresp.hovered() { t.text_strong } else { t.text },
);
let (grip, gresp) = ui.allocate_exact_size(vec2(ui.available_width(), 6.0), Sense::drag());
for k in 0..6 {
ui.painter().line_segment(
[pos2(grip.center().x - 9.0, grip.top() + 1.5 + k as f32 * 0.6), pos2(grip.center().x + 9.0, grip.top() + 1.5 + k as f32 * 0.6)],
Stroke::new(0.4, t.text_disabled),
);
}
crate::floating::drag_tools(app, ui.ctx(), &hresp.union(gresp.clone()), bounds, floating);
if hresp.on_hover_text(tl!("Toggle single/double column")).clicked()
// It only fails on bad params, which this never sends; show it all the same.
&& let Err(e) = app.run("window.toolbarColumns", json!({ "double": cols == 1 }))
{
app.ui.status = e;
}
let grip_tip = if floating.is_some() { tl!("Drag to the window's left edge to dock") } else { tl!("Drag to float the toolbar") };
gresp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(grip_tip);
// The tools and the controls under them scroll in a window too short for them.
widgets::strip_scroll(ui, "toolbar", |ui| {
let active = app.session.tool_id();
// The tool button whose long press opened its flyout, until the next press.
let held_id = egui::Id::new("toolbar-held");
if ui.input(|inp| inp.pointer.any_pressed()) {
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
}
let held: Option<egui::Id> = ui.data(|d| d.get_temp(held_id));
let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None;
// A group torn off into a floating flyout is raised instead of opening its flyout.
let mut raise: Option<&'static str> = None;
let mut i = 0;
while i < all.len() {
if let Some(cat) = all[i].0 {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover());
// A long name is cut to its first four characters in the single column.
let cat = tl!(cat);
let label =
if cols == 1 && cat.chars().count() > 6 { format!("{}...", cat.chars().take(4).collect::<String>()) } else { cat.to_string() };
ui.painter().text(r.center() + vec2(0.0, 2.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(11.0), t.text);
}
// One row = `cols` slots (a category label always starts a new row).
let mut row = vec![i];
while row.len() < cols && i + row.len() < all.len() && all[i + row.len()].0.is_none() {
row.push(i + row.len());
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
if cols == 1 {
ui.add_space((WIDTH - 36.0) / 2.0);
} else {
ui.add_space(2.0);
}
for &k in &row {
let slot = &all[k].1;
let shown_id = if slot.contains(&active) {
active.to_string()
} else {
app.ui.slot_tool.get(slot[0]).cloned().unwrap_or_else(|| slot[0].to_string())
};
let Some(shown) = tool_info(&shown_id).or_else(|| tool_info(slot[0])) else { continue };
let is_active = slot.contains(&active);
let (rect, resp) = ui.allocate_exact_size(vec2(36.0, PITCH - 1.0), Sense::click_and_drag());
let well = egui::Rect::from_center_size(rect.center(), vec2(35.5, 27.5));
if is_active {
ui.painter().rect_filled(well, CornerRadius::same(1), t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(well, CornerRadius::same(1), t.hover);
}
let ir = egui::Rect::from_center_size(rect.center(), vec2(18.0, 18.0));
icons::paint(ui, icons::tool_icon(shown.icon), ir, if is_active { t.text_strong } else { t.icon });
if slot.len() > 1 {
let c = rect.center() + vec2(12.5, 10.0);
ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)], t.icon, Stroke::NONE));
}
let press = if slot.len() > 1 { flyout_press(ui, &resp, rect) } else { None };
let alt = ui.input(|inp| inp.modifiers.alt);
if press.is_some() {
if is_floating(app, slot[0]) {
raise = Some(slot[0]);
} else {
open_flyout = Some((slot.clone(), rect));
}
if press == Some(FlyoutPress::Primary) {
ui.data_mut(|d| d.insert_temp(held_id, resp.id));
}
} else if resp.clicked() && held == Some(resp.id) {
// Releasing the long press that opened the flyout leaves it open.
} else if resp.clicked() && alt && slot.len() > 1 {
let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0);
app.select_tool(slot[(idx + 1) % slot.len()]);
} else if resp.double_clicked() {
app.select_tool(shown.id);
app.run("tool.options", json!({ "tool": shown.id })).ok();
} else if resp.clicked() {
app.select_tool(shown.id);
}
resp.on_hover_text(tip(shown));
}
});
i += row.len();
}
if let Some(key) = raise {
raise_floating(ui.ctx(), key);
}
if let Some((slot, rect)) = open_flyout {
app.ui.flyout = Some(0);
ui.data_mut(|d| {
d.insert_temp(egui::Id::new("flyout-anchor"), rect);
d.insert_temp(egui::Id::new("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::<Vec<String>>());
});
}
ui.add_space(8.0);
bottom_controls(app, ui, &t);
});
}
/// How a press on a tool group's button opens its flyout.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum FlyoutPress {
@ -631,9 +653,7 @@ fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
// Kept on screen (a smaller window, or a position saved on a bigger one).
let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(strip_width(&f.tools), PITCH), |r| r.size());
let [x, y] = f.pos;
let (x, y) = if x.is_finite() && y.is_finite() { (x, y) } else { (screen.left() + 60.0, screen.top() + 100.0) };
let pos = pos2(x.min(screen.right() - size.x).max(screen.left()), y.min(screen.bottom() - size.y).max(screen.top()));
let pos = crate::floating::clamp(f.pos, size, screen);
let mut input = FlyoutInput::default();
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| {
flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true));

View File

@ -9,8 +9,15 @@ use crate::{icons, scrub};
/// Square icon button; `selected` draws the pressed well.
pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response {
let (_, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
paint_icon_button(ui, resp, icon, tip, selected)
}
/// [`icon_button`] drawn for a response the caller made (its own id or sense).
pub fn paint_icon_button(ui: &mut Ui, resp: Response, icon: &str, tip: &str, selected: bool) -> Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
let rect = resp.rect;
let size = rect.width();
let bg = if selected {
t.tool_active
} else if resp.hovered() {

View File

@ -7,7 +7,7 @@ use std::sync::atomic::Ordering;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::state::{Dialog, DockTab, FloatingFlyout, UiState};
use crate::state::{Dialog, DockTab, FloatingFlyout, FloatingPanels, UiState};
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
@ -29,6 +29,10 @@ pub struct Workspace {
pub status_bar: bool,
/// Tool groups torn off the toolbar into floating strips (built-in workspaces: none).
pub floating_flyouts: Vec<FloatingFlyout>,
/// Panels dragged out of the dock, where they float (built-in workspaces: none).
pub floating_panels: Vec<FloatingPanels>,
/// Where the Tools panel floats (built-in workspaces: docked).
pub toolbar_pos: Option<[f32; 2]>,
}
impl Default for Workspace {
@ -123,6 +127,8 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
open_panel: ui.open_panel.clone(),
status_bar: ui.status_bar,
floating_flyouts: ui.floating_flyouts.clone(),
floating_panels: ui.floating_panels.clone(),
toolbar_pos: ui.toolbar_pos,
}
}
@ -140,6 +146,9 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
ui.open_panel = w.open_panel.clone();
ui.status_bar = w.status_bar;
ui.floating_flyouts = w.floating_flyouts.clone();
ui.floating_panels = w.floating_panels.clone();
ui.toolbar_pos = w.toolbar_pos;
FloatingPanels::sanitize(&mut ui.floating_panels, &mut ui.toolbar_pos);
ui.flyout = None;
ui.workspace = w.name.clone();
}

View File

@ -214,6 +214,20 @@ Bar Position (`window.taskBar.reset`), which unpins it and puts it back under th
Panel. As in Illustrator, neither the position nor the pin is saved: each launch starts with the bar under the
selection.
Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel
inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A
floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own;
dropped on another group's title bar or tabs it stacks with that group, and dropped on the dock (lit up while the
pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column.
`window.panel.float {panel, x?, y?, onto?, group?}` floats a panel (with `group`, its whole group) with its top-left
corner at `x`, `y` (window points; default cascaded), or stacks it with the floating group holding `onto`;
`window.panel.dock {panel, group?}` puts it back. `panel` takes the `window.panel` ids and labels, and `"tools"` for
the Tools panel, which floats by its title bar and docks on the window's left edge. `window.panel` on a floating
panel shows its tab. `ui.floating_panels` in `ui.inspect` lists the groups (panel ids, the index of the tab shown,
the top-left corner) and `ui.toolbar_pos` the Tools panel's corner (null: docked); both are saved with the
preferences and in user workspaces (the built-in workspaces dock everything), and a corner saved on a bigger window
is clamped into this one when drawn. Floating panels stay inside the app window (no separate OS windows).
Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection
(fields `preset`: a preset name, setting it loads that preset's options; the option keys of
`object.flattenTransparency`: `balance` 0–100, `lineArtPpi` and `gradientPpi` 1–2400, `textToOutlines`,