From faa3678a3f1fba8d5a58eb7281fcbcbacaa51330 Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Fri, 9 Oct 2026 17:48:31 +0000 Subject: [PATCH] UI: stack floating panel groups into sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Floating panel groups dropped on another group's top or bottom edge join it in a set: the groups stack top to bottom, move together by the top group's title bar (whose × docks them all), and a group dragged by its tab strip leaves the set. A double-click on a tab collapses a group to its tabs. Sets and collapsed groups are kept in preferences and workspaces, and window.panel.float takes below, above and collapsed. Behaviour from public documentation: https://helpx.adobe.com/illustrator/using/workspace-basics.html Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/ui-egui/src/floating.rs | 618 ++++++++++++++++++++++------ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/state.rs | 39 +- docs/control-protocol.md | 17 +- 14 files changed, 575 insertions(+), 122 deletions(-) diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 3080a25c..f0edd063 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -9,9 +9,16 @@ //! × 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. +//! Dropped on the top or bottom edge of another group (a line shows where), a group joins it in a +//! set: 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. 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. +//! +//! `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}; @@ -24,6 +31,10 @@ use crate::{VectorcraftApp, dock, icons, panels}; 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; +/// 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]; @@ -58,6 +69,10 @@ enum Drop { 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), } fn move_id() -> Id { @@ -80,11 +95,17 @@ pub(crate) fn tools_zone_id() -> Id { Id::new("floating-tools-zone") } -/// The area of the floating group whose first panel is `first`. +/// 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) @@ -95,6 +116,71 @@ pub fn group_of(ui: &UiState, id: &str) -> Option { 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 { + 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) = ui.floating_panels.get(target).map(|g| g.pos) 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; + 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() @@ -118,6 +204,7 @@ fn take(ui: &mut UiState, ids: &[&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 @@ -129,7 +216,7 @@ pub fn float(ui: &mut UiState, ids: &[&str], active: &str, pos: Pos2) { } let panels: Vec = 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], width: None }); + 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; @@ -214,11 +301,22 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2 let zone = |id: Id| ctx.data(|d| d.get_temp::(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. + let moving = own.map(|i| set_of(&app.ui, i)).unwrap_or_default(); + // Another group's top or bottom edge puts the moved groups in a set with it; its title bar and + // tabs stack them as tabs. 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)) { + let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue }; + let Some(r) = ctx.data(|d| d.get_temp::(group_rect_id(first))) else { continue }; + let line = |y: f32| Rect::from_x_y_ranges(r.x_range(), (y - 2.0)..=(y + 2.0)); + if Rect::from_x_y_ranges(r.x_range(), (r.bottom() - EDGE)..=(r.bottom() + EDGE)).contains(at) { + return Some((Drop::Below(first), line(r.bottom()))); + } + let lead = leads(&app.ui, i); + if lead && Rect::from_x_y_ranges(r.x_range(), (r.top() - EDGE)..=r.top()).contains(at) { + return Some((Drop::Above(first), line(r.top()))); + } + 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)); } } @@ -238,6 +336,13 @@ fn highlight(ctx: &egui::Context, zone: Rect) { painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), 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); +} + /// 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) { @@ -251,16 +356,18 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { { 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]; + 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 Some((_, zone)) = target { - highlight(ctx, zone); + match target { + Some((Drop::Below(_) | Drop::Above(_), line)) => insertion_line(ctx, line), + Some((_, zone)) => highlight(ctx, zone), + None => {} } return; } @@ -270,9 +377,11 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { 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); + // 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); @@ -282,6 +391,8 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { } } 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), } } @@ -289,130 +400,217 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { 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. + /// 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), + /// 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), } -/// The floating groups, each a stack of tabs under a title bar. Hidden with the dock (Tab, -/// Presentation Mode). +/// 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 floating groups, each a stack of tabs, stacked in their sets 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 mut action = None; + for gi in 0..app.ui.floating_panels.len() { + if leads(&app.ui, gi) { + let set = set_of(&app.ui, gi); + show_set(app, ctx, &set, &mut action); + } + } + 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(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)), + None => {} + } +} + +/// Take group `gi` out of its set 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.pos = [p.x, p.y]; + } + FloatingPanels::tidy(&mut app.ui.floating_panels); + start(ctx, Moving::Panel(id), grab); +} + +/// 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) { 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 given = g.width.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(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).max(given.unwrap_or(0.0)); - 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 groups: Vec = set + .iter() + .filter_map(|&gi| { + let g = app.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(); + 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); + let alone = groups.len() == 1; + // 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 tabs: Vec> = groups + .iter() + .map(|g| { + g.ids + .iter() + .map(|id| { + let label = panel(id).map_or(*id, |(_, label)| label); + let color = if *id == g.active { t.text_strong } else { t.text_dim }; + ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color) + }) + .collect() + }) + .collect(); + let width = groups + .iter() + .zip(&tabs) + .map(|(g, tabs)| { + let tabs_width: f32 = tabs.iter().map(|tab| tab.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())); + } + for (k, (g, tabs)) in groups.iter().zip(tabs).enumerate() { + let top = if k == 0 { + bar.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 mut left = strip.left(); - for (k, (&id, galley)) in ids.iter().zip(tabs).enumerate() { + for (j, (&id, galley)) in g.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 { + if id == g.active && !g.collapsed { 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)); + if resp.double_clicked() { + *action = Some(Action::Collapse(g.gi)); + } else if resp.clicked() { + *action = Some(Action::Activate(g.gi, j)); + } else if lone && alone && resp.drag_started() { + *action = Some(Action::Move(id, pos)); } else if lone && resp.drag_started() { - action = Some(Action::Move(id, pos)); + *action = Some(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, r.left())); + *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()); + let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag()); if resp.drag_started() { - action = Some(Action::Move(active, pos)); + *action = Some(if alone { Action::Move(g.active, pos) } else { Action::Detach(g.gi, strip.left()) }); } - resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move")); + let tip = if alone { 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, active, menu); - dock::panel_body(app, ui, active, width, tall); - }); + panels::panel_menu(app, ui, g.active, menu); + if !g.collapsed { + dock::panel_body(app, ui, g.active, width, tall); + } + let bottom = ui.min_rect().bottom().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)); + } + } }); - } - 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. @@ -455,35 +653,82 @@ fn float_or_dock(app: &mut VectorcraftApp, float_it: bool, p: &Value) -> Result< (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()); + } 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) { + 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. + let own = own.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.panels.len() == ids.len())); + if own.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> = + 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) = (g.map(|g| &g.panels), g.map(|g| g.pos), g.is_some_and(|g| g.collapsed)); + Ok(json!({ "panel": id, "floating": true, "group": group, "set": set, "pos": pos, "collapsed": collapsed })) +} + +/// 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, + onto: Option<&str>, + at: Option, +) -> 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); + 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) => { - g.active = g.panels.iter().position(|p| p == id).unwrap_or(g.active); if let Some(at) = at { g.pos = [at.x, at.y]; + g.column = 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); + 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) })) + Ok(()) } #[cfg(test)] @@ -775,7 +1020,7 @@ mod tests { })) .unwrap(); let edited = edited.sanitized(); - assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0], width: None }]); + 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); @@ -811,6 +1056,149 @@ mod tests { 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)> { + 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!["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!["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]); + } + fn floating_pos(app: &VectorcraftApp, id: &str) -> [f32; 2] { group_of(&app.ui, id).map(|i| app.ui.floating_panels[i].pos).unwrap() } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4cafe577..e3a0dda6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -628,6 +628,7 @@ % of all commits % všech commitů Lines +/− Řádky +/− Drag to move Přetažením přesunete + Drag to move out of the set Přetažením přesunete mimo sadu 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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9769b110..7d5beda5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3317,6 +3317,7 @@ axis Horizontal Horizontal axis Vertical Vertical axis Both Ambos Drag to move Arrastrar para mover + Drag to move out of the set Arrastrar para sacar del conjunto 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 @@ -3873,6 +3874,8 @@ axis Both Ambos @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 below and above must name a panel floating in another group below y above deben nombrar un panel que flote en otro grupo +@msg give only one of onto, below and above indica solo uno de onto, below y above @msg x and y must both be numbers x e y deben ser números @msg "{0}" is not a colour: write it as #rrggbb "{0}" no es un color: escríbelo como #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead esto no es un logotipo de colores planos ({_1}% de sus zonas sólidas coinciden con unos pocos colores planos), así que el modo Logotipo plano convertiría sus degradados en manchas: usa el modo Color diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 74c18533..00d33e73 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3314,6 +3314,7 @@ axis Horizontal Horizontal axis Vertical Vertical axis Both Les deux Drag to move Faire glisser pour déplacer + Drag to move out of the set Faire glisser pour sortir de l’ensemble Drag to float as a toolbar Faire glisser pour détacher en barre d’outils flottante Put back in the toolbar Remettre dans la barre d’outils Float Panel Rendre le panneau flottant @@ -3870,6 +3871,8 @@ axis Both Les deux @msg tool (a tool id) is required tool (un identifiant d’outil) est obligatoire @msg {tool} isn't in a toolbar group of several tools {tool} ne fait pas partie d’un groupe de plusieurs outils dans la barre d’outils @msg onto must name a panel floating in another group onto doit désigner un panneau flottant dans un autre groupe +@msg below and above must name a panel floating in another group below et above doivent désigner un panneau flottant dans un autre groupe +@msg give only one of onto, below and above indiquez un seul de onto, below et above @msg x and y must both be numbers x et y doivent tous deux être des nombres @msg "{0}" is not a colour: write it as #rrggbb « {0} » n’est pas une couleur : écrivez-la sous la forme #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ce n’est pas un logo en aplats ({_1} % de ses zones pleines correspondent à quelques couleurs unies), donc le mode Logo en aplats transformerait ses dégradés en taches : utilisez le mode Couleur diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5d423c7b..c5b53a78 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3317,6 +3317,7 @@ axis Horizontal Orizzontale axis Vertical Verticale axis Both Entrambi Drag to move Trascina per spostare + Drag to move out of the set Trascina per spostare fuori dall’insieme Drag to float as a toolbar Trascina per rendere mobile come barra degli strumenti Put back in the toolbar Riporta nella barra degli strumenti Float Panel Rendi mobile pannello @@ -3873,6 +3874,8 @@ axis Both Entrambi @msg tool (a tool id) is required tool (un id di strumento) è obbligatorio @msg {tool} isn't in a toolbar group of several tools {tool} non fa parte di un gruppo di più strumenti nella barra degli strumenti @msg onto must name a panel floating in another group onto deve indicare un pannello mobile in un altro gruppo +@msg below and above must name a panel floating in another group below e above devono indicare un pannello mobile in un altro gruppo +@msg give only one of onto, below and above indica solo uno tra onto, below e above @msg x and y must both be numbers x e y devono essere entrambi numeri @msg "{0}" is not a colour: write it as #rrggbb "{0}" non è un colore: scrivilo come #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead questo non è un logo a colori piatti ({_1}% delle sue aree piene corrisponde a pochi colori piatti), quindi la modalità Logo piatto trasformerebbe i suoi sfumati in macchie: usa la modalità Colore diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ec170582..547525c3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3265,6 +3265,7 @@ axis Horizontal 水平方向 axis Vertical 垂直方向 axis Both 両方 Drag to move 移動するにはドラッグ + Drag to move out of the set ドラッグしてセットから出す Drag to float as a toolbar ドラッグしてツールバーとして浮かせる Put back in the toolbar ツールバーに戻す Float Panel パネルをフローティング diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c1d67d4b..b7df5bd0 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2697,6 +2697,7 @@ % of all commits % de todos os commits Lines +/− Linhas +/− Drag to move Arraste para mover + Drag to move out of the set Arraste para tirar do conjunto 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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 14543a6c..e9642d73 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3313,6 +3313,7 @@ axis Horizontal Горизонтально axis Vertical Вертикально axis Both Оба Drag to move Перетащите для перемещения + Drag to move out of the set Перетащите, чтобы вынести из набора Drag to float as a toolbar Перетащите, чтобы сделать плавающей панелью инструментов Put back in the toolbar Вернуть на панель инструментов Float Panel Сделать панель плавающей @@ -3869,6 +3870,8 @@ axis Both Оба @msg tool (a tool id) is required требуется tool (идентификатор инструмента) @msg {tool} isn't in a toolbar group of several tools {tool} не входит в группу панели инструментов из нескольких инструментов @msg onto must name a panel floating in another group onto должен называть панель, плавающую в другой группе +@msg below and above must name a panel floating in another group below и above должны называть панель, плавающую в другой группе +@msg give only one of onto, below and above укажите только один из onto, below и above @msg x and y must both be numbers x и y должны быть числами @msg "{0}" is not a colour: write it as #rrggbb «{0}» не является цветом: запишите его как #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead это не логотип из плоских цветов ({_1}% его сплошных областей совпадает с несколькими плоскими цветами), поэтому режим «Плоский логотип» превратил бы градиенты в пятна: используйте режим «Цвет» diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f46baa1e..5ecd32f3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3305,6 +3305,7 @@ axis Horizontal Горизонтальна axis Vertical Вертикальна axis Both Обидві Drag to move Перетягніть для переміщення + Drag to move out of the set Перетягніть, щоб винести з набору Drag to float as a toolbar Перетягніть, щоб від’єднати панель інструментів Put back in the toolbar Повернути на панель інструментів Float Panel Від’єднати панель @@ -3856,6 +3857,8 @@ axis Both Обидві @msg tool (a tool id) is required потрібно tool (ідентифікатор інструменту) @msg {tool} isn't in a toolbar group of several tools {tool} не входить до групи панелі інструментів з кількох інструментів @msg onto must name a panel floating in another group onto має називати панель, що плаває в іншій групі +@msg below and above must name a panel floating in another group below і above мають називати панель, що плаває в іншій групі +@msg give only one of onto, below and above вкажіть лише один із onto, below і above @msg x and y must both be numbers x і y мають бути числами @msg "{0}" is not a colour: write it as #rrggbb "{0}" не є кольором: запишіть його як #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead це не логотип із плоских кольорів ({_1}% його суцільних областей збігається з кількома плоскими кольорами), тому режим «Плоский логотип» перетворив би градієнти на плями: використовуйте режим «Колір» diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6358d2ae..05d705b5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3517,6 +3517,7 @@ axis Horizontal 水平 axis Vertical 垂直 axis Both 两者 Drag to move 拖动以移动 + Drag to move out of the set 拖动以移出该组合 Drag to float as a toolbar 拖动使其浮动为工具栏 Put back in the toolbar 放回工具栏 Float Panel 浮动面板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2df10453..e5e99980 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3295,6 +3295,7 @@ axis Horizontal 水平 axis Vertical 垂直 axis Both 兩者 Drag to move 拖曳以移動 + Drag to move out of the set 拖曳以移出該組合 Drag to float as a toolbar 拖曳使其浮動為工具列 Put back in the toolbar 放回工具列 Float Panel 浮動面板 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 091fb5da..5ab14588 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -260,7 +260,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "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}", + "{panel: id or \"tools\", x?, y?, onto?: id, below?: id, above?: id, group?: bool, collapsed?: 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 as a tab with the floating group holding that panel instead; `below` / `above` puts its group in a set with that group, right below or above it, as dropping it on that group's bottom or top edge does (the set's groups stack top to bottom and move together); `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 (out of its set, given x, y); `collapsed` shows its group's tabs only, as a double-click on a tab does. \"tools\" floats the Tools panel. Returns {panel, floating, group, set, pos, collapsed}", ), ( "window.panel.dock", @@ -1126,6 +1126,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option): the set moves by the top +/// group's title bar, and a group dragged by its tab strip leaves it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct FloatingPanels { /// Panel ids (`window.panel`), in tab order. pub panels: Vec, @@ -446,6 +451,13 @@ pub struct FloatingPanels { /// A width the group was given (the Tabs panel sized over its text), else its panels' own. #[serde(default, skip_serializing_if = "Option::is_none")] pub width: Option, + /// The set this group is stacked in with others (the same number), top to bottom in list + /// order; none when it floats on its own. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub column: Option, + /// Collapsed to its tabs (a double-click on a tab), its panel hidden. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub collapsed: bool, } impl FloatingPanels { @@ -458,10 +470,35 @@ impl FloatingPanels { g.active = g.active.min(g.panels.len().saturating_sub(1)); !g.panels.is_empty() }); + Self::tidy(groups); if toolbar_pos.is_some_and(|p| !p.iter().all(|v| v.is_finite())) { *toolbar_pos = None; } } + + /// Keep each set's groups together in the list, after its first one and in the order they + /// come, at its first one's position; a set left with one group floats on its own. + pub fn tidy(groups: &mut Vec) { + let mut out: Vec = Vec::with_capacity(groups.len()); + for mut g in groups.drain(..) { + match g.column.and_then(|c| out.iter().rposition(|o| o.column == Some(c))) { + Some(i) => { + if let Some(first) = out.iter().find(|o| o.column == g.column) { + g.pos = first.pos; + } + out.insert(i + 1, g); + } + None => out.push(g), + } + } + let alone: Vec = out.iter().filter_map(|g| g.column).filter(|c| out.iter().filter(|g| g.column == Some(*c)).count() < 2).collect(); + for g in &mut out { + if g.column.is_some_and(|c| alone.contains(&c)) { + g.column = None; + } + } + *groups = out; + } } impl UiState { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8ad6a967..c33bf1a8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -260,13 +260,20 @@ Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title 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`; +pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column. Dropped on another +group's bottom or top edge (a line shows where), a group joins it in a set: 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; a group dragged by +its tab strip (a lone panel's tab or the strip right of the tabs) leaves the set. A double-click on a tab collapses a +group to its tabs, and another expands it. +`window.panel.float {panel, x?, y?, onto?, below?, above?, group?, collapsed?}` floats a panel (with `group`, its whole +group) with its top-left corner at `x`, `y` (window points; default cascaded), or stacks it as a tab with the floating +group holding `onto`, or puts its group in a set right `below` or `above` the group holding that panel; `collapsed` +collapses or expands its group; `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 +panel shows its tab (expanding its group). `ui.floating_panels` in `ui.inspect` lists the groups (panel ids, the +index of the tab shown, the top-left corner, `column`: the set it is stacked in, top to bottom in list order, and +`collapsed`) 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).