diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index a202ba76..899d56c4 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1,5 +1,6 @@ //! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column -//! whose panels pop out to the left. The double arrow at the top of the dock collapses the tabbed +//! whose panels pop out to the left, and left of it the floating sets docked as columns +//! ([`floating::docked_columns`]). The double arrow at the top of the dock collapses the tabbed //! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column //! then expands it again. @@ -23,6 +24,12 @@ fn column_left_id() -> egui::Id { egui::Id::new("dock-icon-column-left") } +/// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's +/// (egui temp memory, one frame old). Popped-out panels open left of it. +fn dock_left_id() -> egui::Id { + egui::Id::new("dock-left") +} + /// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while /// one of its panels is popped out shows that panel's tab instead. pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) { @@ -148,7 +155,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); let column = column.response.rect; let dock = group.map_or(column, |g| g.union(column)); + // Floating sets docked as columns, left of the icon column. + let left = floating::docked_columns(app, ui).iter().map(|r| r.left()).fold(column.left(), f32::min); ui.ctx().data_mut(|d| { + d.insert_temp(dock_left_id(), left); d.insert_temp(column_left_id(), column.left()); d.insert_temp(floating::dock_rect_id(), dock); d.insert_temp(floating::icons_rect_id(), column); @@ -226,7 +236,9 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { } else { screen.right() }; - let right = column - 6.0; + let left = ctx.data(|d| d.get_temp::(dock_left_id())).filter(|x| x.is_finite() && *x > screen.left() && *x <= column).unwrap_or(column); + let right = if app.ui.dock && app.ui.screen_mode < 3 { left } else { column }; + let right = right - 6.0; // The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one // that keeps the flyout on screen. let width = panel_width(id); diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index f0edd063..ed2cefdf 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -73,6 +73,8 @@ enum Drop { Below(&'static str), /// In a set with the floating group holding this panel, above it (the top group of its set). Above(&'static str), + /// In the dock as a new column at this place (1: next to the icon column, counting left). + Column(u32), } fn move_id() -> Id { @@ -158,7 +160,7 @@ pub fn attach(ui: &mut UiState, moved: &[usize], onto: &str, below: bool) { 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 Some((pos, docked)) = ui.floating_panels.get(target).map(|g| (g.pos, g.docked)) else { return }; let (mut taken, mut kept) = (vec![], vec![]); for (i, mut g) in ui.floating_panels.drain(..).enumerate() { if i == target { @@ -167,6 +169,7 @@ pub fn attach(ui: &mut UiState, moved: &[usize], onto: &str, below: bool) { if moved.contains(&i) { g.column = Some(column); g.pos = pos; + g.docked = docked; taken.push(g); } else { kept.push(g); @@ -302,6 +305,18 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2 let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) }; let own = group_of(&app.ui, id); let moving = own.map(|i| set_of(&app.ui, i)).unwrap_or_default(); + // The left edge of the icon column or of a dock column docks the moved groups as a new column + // there. + if app.ui.dock && app.ui.screen_mode < 3 { + let icons = ctx.data(|d| d.get_temp::(icons_rect_id())); + let columns = ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default(); + for (k, r) in icons.into_iter().chain(columns).enumerate() { + if Rect::from_x_y_ranges((r.left() - EDGE - 3.0)..=(r.left() + EDGE - 1.0), r.y_range()).contains(at) { + let line = Rect::from_x_y_ranges((r.left() - 2.0)..=(r.left() + 2.0), r.y_range()); + return Some((Drop::Column(u32::try_from(k + 1).unwrap_or(u32::MAX)), line)); + } + } + } // Another group'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() { @@ -365,7 +380,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { } } match target { - Some((Drop::Below(_) | Drop::Above(_), line)) => insertion_line(ctx, line), + Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line), Some((_, zone)) => highlight(ctx, zone), None => {} } @@ -393,10 +408,11 @@ 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), + Drop::Column(slot) => dock_column(&mut app.ui, &set, slot), } } -/// What a press on a floating group did. +/// What a press on a floating or docked group did. enum Action { /// Show the group's `k`th tab. Activate(usize, usize), @@ -410,6 +426,8 @@ enum Action { Detach(usize, f32), /// Float this panel out of its group; its tab began this far right. Tear(&'static str, f32), + /// Float the docked column of this group, its top-left corner this far from the pointer. + Undock(usize, Vec2), } /// A floating group as drawn: its index, panels, the one shown and whether it's collapsed. @@ -420,8 +438,25 @@ struct Shown { collapsed: bool, } +/// The groups `set` as drawn, top to bottom. +fn shown_groups(ui: &UiState, set: &[usize]) -> Vec { + set.iter() + .filter_map(|&gi| { + let g = ui.floating_panels.get(gi)?; + let ids = ids_of(g); + let active = ids.get(g.active).or(ids.first()).copied()?; + Some(Shown { gi, ids, active, collapsed: g.collapsed }) + }) + .collect() +} + +/// The groups `set` heads are docked as a dock column. +fn docked(ui: &UiState, set: &[usize]) -> bool { + set.first().and_then(|&i| ui.floating_panels.get(i)).is_some_and(|g| g.docked.is_some()) +} + /// The floating groups, each a stack of tabs, stacked in their sets under a title bar. Hidden with -/// the dock (Tab, Presentation Mode). +/// the dock (Tab, Presentation Mode). Sets docked as dock columns are drawn by [`docked_columns`]. pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 { return; @@ -430,9 +465,16 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { 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); + if !docked(&app.ui, &set) { + show_set(app, ctx, &set, &mut action); + } } } + apply(app, ctx, action); +} + +/// Carry out what a press on a floating or docked group did. +fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) { match action { Some(Action::Activate(gi, k)) => { if let Some(g) = app.ui.floating_panels.get_mut(gi) @@ -463,12 +505,25 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } Some(Action::Detach(gi, left)) => detach(app, ctx, gi, left), Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)), + Some(Action::Undock(gi, grab)) => { + let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + let Some(id) = set_of(&app.ui, gi).first().and_then(|&i| shown(&app.ui, i)) else { return }; + let p = at - grab; + for i in set_of(&app.ui, gi) { + if let Some(g) = app.ui.floating_panels.get_mut(i) { + g.docked = None; + g.pos = [p.x, p.y]; + } + } + FloatingPanels::tidy(&mut app.ui.floating_panels); + start(ctx, Moving::Panel(id), grab); + } 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. +/// Take group `gi` out of its set (or its dock column) under the pointer, its tab strip having +/// begun `left` points from the window's left, and carry on moving it with the pointer. fn detach(app: &mut VectorcraftApp, ctx: &egui::Context, gi: usize, left: f32) { let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; let Some(id) = shown(&app.ui, gi) else { return }; @@ -476,33 +531,109 @@ fn detach(app: &mut VectorcraftApp, ctx: &egui::Context, gi: usize, left: f32) { let p = at - grab; if let Some(g) = app.ui.floating_panels.get_mut(gi) { g.column = None; + g.docked = None; g.pos = [p.x, p.y]; } FloatingPanels::tidy(&mut app.ui.floating_panels); start(ctx, Moving::Panel(id), grab); } +/// Put the groups `set` in the dock as a new column at `slot` (1: next to the icon column, counting +/// left), moving the columns from there one further left. +pub fn dock_column(ui: &mut UiState, set: &[usize], slot: u32) { + let slot = slot.max(1); + for (i, g) in ui.floating_panels.iter_mut().enumerate() { + if set.contains(&i) { + g.docked = Some(slot); + } else if let Some(d) = g.docked.filter(|d| *d >= slot) { + g.docked = Some(d.saturating_add(1)); + } + } + FloatingPanels::tidy(&mut ui.floating_panels); + if let Some(tab) = shown_tab(ui) { + ui.dock_tab = tab; + } +} + +/// The dock columns, as last drawn (egui temp memory), from the one next to the icon column +/// leftwards. +pub(crate) fn columns_id() -> Id { + Id::new("dock-columns") +} + +/// The sets docked as columns, by their column number (next to the icon column first). +fn docked_sets(ui: &UiState) -> Vec<(u32, Vec)> { + let mut sets: Vec<(u32, Vec)> = (0..ui.floating_panels.len()) + .filter(|&gi| leads(ui, gi)) + .filter_map(|gi| Some((ui.floating_panels.get(gi)?.docked?, set_of(ui, gi)))) + .collect(); + sets.sort_by_key(|(d, _)| *d); + sets +} + +/// The sets docked as columns, each a column left of the icon column (`ui`, the window's root, as +/// [`dock::show`] lays it out): a strip along its top that floats the column when dragged and +/// whose × puts its panels back in the dock, then its groups top to bottom. Returns the columns' +/// bounds, next to the icon column first. +pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec { + let t = Tokens::get(ui.ctx()); + let mut rects = vec![]; + let mut action = None; + for (_, set) in docked_sets(&app.ui) { + let groups = shown_groups(&app.ui, &set); + let Some(first) = groups.first().and_then(|g| g.ids.first().copied()) else { continue }; + let natural = groups.iter().flat_map(|g| g.ids.iter()).map(|id| dock::panel_width(id)).fold(200.0, f32::max); + let panel = egui::Panel::right(Id::new(("dock-column", first))) + .resizable(true) + .default_size(natural) + .size_range(200.0..=520.0) + .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border))) + .show(ui, |ui| { + ui.spacing_mut().item_spacing = Vec2::ZERO; + let width = ui.available_width(); + let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover()); + ui.painter().rect_filled(bar, 0.0, t.tab_strip); + let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE)); + let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("column-bar"), Sense::drag()); + for k in 0..2 { + let y = bar.center().y - 1.5 + k as f32 * 3.0; + ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled)); + } + if grip.drag_started() + && let (Some(at), Some(&gi)) = (ui.input(|i| i.pointer.press_origin()), set.first()) + { + action = Some(Action::Undock(gi, at - bar.min)); + } + grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group")); + let x = ui.interact(close, ui.id().with("column-close"), Sense::click()); + icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim }); + if x.on_hover_text(tl!("Put back in the dock")).clicked() { + action = Some(Action::Close(set.clone())); + } + let n = groups.len() as f32; + let open = groups.iter().filter(|g| !g.collapsed).count().max(1) as f32; + let tall = ((ui.available_height() - n * STRIP - (n - 1.0) * GAP - 24.0 * open) / open).max(80.0); + draw_groups(app, ui, &groups, width, tall, bar.top(), None, &mut action); + }); + rects.push(panel.response.rect); + } + ui.ctx().data_mut(|d| d.insert_temp(columns_id(), rects.clone())); + let ctx = ui.ctx().clone(); + apply(app, &ctx, action); + rects +} + /// Draw the set of floating groups `set` (top to bottom) as one floating box. fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action: &mut Option) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); - let pointer = ctx.input(|i| i.pointer.interact_pos()); - 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 groups = shown_groups(&app.ui, set); let Some((lead, first)) = groups.first().and_then(|g| Some((g, *g.ids.first()?))) else { return }; let Some(saved) = app.ui.floating_panels.get(lead.gi).map(|g| g.pos) else { return }; let given = set.iter().filter_map(|&i| app.ui.floating_panels.get(i)?.width).reduce(f32::max).map(|w| w.min(screen.width() - 16.0)); let area = area_id(first); let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(lead.active), 200.0), |r| r.size()); let pos = clamp(saved, size, screen); - 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. @@ -514,24 +645,17 @@ fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action 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 + let width = groups .iter() .map(|g| { - g.ids + let tabs_width: f32 = 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) + ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0 }) - .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(); + .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); @@ -554,65 +678,93 @@ fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action 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 (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 == 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.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::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())); - } - } - 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", g.gi)), Sense::drag()); - if resp.drag_started() { - *action = Some(if alone { Action::Move(g.active, pos) } else { Action::Detach(g.gi, strip.left()) }); - } - 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, 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)); - } - } + let alone = (groups.len() == 1).then_some(pos); + draw_groups(app, ui, &groups, width, tall, bar.top(), alone, action); }); }); } +/// Draw `groups` top to bottom, `width` wide, their open panels `tall`, the first one's bar having +/// begun at `top`: a bar between groups, each group's tab strip, then its shown panel. `alone`, a +/// lone floating group drawn at that corner: its tab strip moves it (else it takes its group out of +/// the set or dock column). +#[allow(clippy::too_many_arguments)] +fn draw_groups( + app: &mut VectorcraftApp, + ui: &mut egui::Ui, + groups: &[Shown], + width: f32, + tall: f32, + top: f32, + alone: Option, + action: &mut Option, +) { + let t = Tokens::get(ui.ctx()); + let pointer = ui.input(|i| i.pointer.interact_pos()); + for (k, g) in groups.iter().enumerate() { + let top = if k == 0 { + top + } else { + let (gap, _) = ui.allocate_exact_size(vec2(width, GAP), Sense::hover()); + ui.painter().rect_filled(gap, 0.0, t.tab_strip); + gap.top() + }; + // Tab strip: a click shows a tab, a double-click collapses the group, a drag out of the + // strip floats a tab on its own; a lone tab or the strip right of the tabs moves the group + // (out of its set). + let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.panel_darker); + let lone = g.ids.len() == 1; + let mut left = strip.left(); + for (j, &id) in g.ids.iter().enumerate() { + let label = panel(id).map_or(id, |(_, label)| label); + let color = if id == g.active { t.text_strong } else { t.text_dim }; + let galley = ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color); + 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 == 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.double_clicked() { + *action = Some(Action::Collapse(g.gi)); + } else if resp.clicked() { + *action = Some(Action::Activate(g.gi, j)); + } else if lone && resp.drag_started() { + *action = Some(match alone { + Some(pos) => Action::Move(id, pos), + None => Action::Detach(g.gi, strip.left()), + }); + } else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) { + *action = Some(Action::Tear(id, 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", g.gi)), Sense::drag()); + if resp.drag_started() { + *action = Some(match alone { + Some(pos) => Action::Move(g.active, pos), + None => Action::Detach(g.gi, strip.left()), + }); + } + let tip = if alone.is_some() { tl!("Drag to move") } else { tl!("Drag to move out of the set") }; + resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip); + } + panels::panel_menu(app, ui, g.active, menu); + if !g.collapsed { + dock::panel_body(app, ui, g.active, width, tall); + } + 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)); + } + } +} + /// `window.panel.float` and `window.panel.dock`; `None` for other commands. pub fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { let float = match id { @@ -660,18 +812,41 @@ fn float_or_dock(app: &mut VectorcraftApp, float_it: bool, p: &Value) -> Result< if [onto, below, above].iter().filter(|o| o.is_some()).count() > 1 { return Err("give only one of onto, below and above".into()); } + let slot = column_slot(&app.ui, p)?; app.ui.dock = true; + // Its own group already (not a panel taken out of its group). + let whole = own.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.panels.len() == ids.len())); if !float_it { - dock(&mut app.ui, &ids, id); - return Ok(json!({ "panel": id, "floating": false })); + let Some(slot) = slot else { + dock(&mut app.ui, &ids, id); + return Ok(json!({ "panel": id, "floating": false })); + }; + // A dock column of its own group (floated out of its group first) and the rest of its set. + if whole.is_none() { + float(&mut app.ui, &ids, id, Pos2::ZERO); + } + if let Some(gi) = group_of(&app.ui, id) { + let set = set_of(&app.ui, gi); + dock_column(&mut app.ui, &set, slot); + } + let column = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)).and_then(|g| g.docked); + return Ok(json!({ "panel": id, "floating": false, "column": column })); + } + // Floating a docked group floats its dock column. + if let Some(gi) = whole.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.docked.is_some())) { + for i in set_of(&app.ui, gi) { + if let Some(g) = app.ui.floating_panels.get_mut(i) { + g.docked = None; + } + } + FloatingPanels::tidy(&mut app.ui.floating_panels); } if let Some((next, under)) = below.map(|b| (b, true)).or(above.map(|a| (a, false))) { let next = crate::menus::normalize_panel(next) .filter(|o| !ids.contains(o) && group_of(&app.ui, o).is_some()) .ok_or("below and above must name a panel floating in another group")?; // The panels as a group of their own (their group already, or floated out), in the set. - let own = own.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.panels.len() == ids.len())); - if own.is_none() { + if whole.is_none() { float(&mut app.ui, &ids, id, Pos2::ZERO); } if let Some(gi) = group_of(&app.ui, id) { @@ -690,8 +865,23 @@ fn float_or_dock(app: &mut VectorcraftApp, float_it: bool, p: &Value) -> Result< 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 })) + let (group, pos, collapsed, column) = (g.map(|g| &g.panels), g.map(|g| g.pos), g.is_some_and(|g| g.collapsed), g.and_then(|g| g.docked)); + Ok(json!({ "panel": id, "floating": column.is_none(), "group": group, "set": set, "pos": pos, "collapsed": collapsed, "column": column })) +} + +/// The `column` param of `window.panel.dock`: `true` for a new dock column at the dock's left, or +/// the place of the new column (1: next to the icon column, counting left); none to dock the panel +/// where it lives. +fn column_slot(ui: &UiState, p: &Value) -> Result, String> { + let next = ui.floating_panels.iter().filter_map(|g| g.docked).max().unwrap_or(0).saturating_add(1); + match p.get("column") { + None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None), + Some(Value::Bool(true)) => Ok(Some(next)), + Some(v) => match v.as_u64().filter(|n| *n >= 1) { + Some(n) => Ok(Some(u32::try_from(n).unwrap_or(u32::MAX).min(next))), + None => Err("column must be true or a number from 1".into()), + }, + } } /// Float `ids` (`id` among them; `own`, the group holding `id`) as their own group at `at`, or @@ -718,6 +908,7 @@ fn float_onto( if let Some(at) = at { g.pos = [at.x, at.y]; g.column = None; + g.docked = None; FloatingPanels::tidy(&mut app.ui.floating_panels); } } @@ -1199,6 +1390,112 @@ mod tests { assert_eq!(edited.floating_panels[1].pos, [1.0, 2.0]); } + /// The dock columns as drawn, next to the icon column first. + fn columns(h: &Harness) -> Vec { + h.ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default() + } + + /// Each group's panels and dock column, in list order. + fn docked_of(groups: &[FloatingPanels]) -> Vec<(Vec<&str>, Option)> { + groups.iter().map(|g| (g.panels.iter().map(String::as_str).collect(), g.docked)).collect() + } + + #[test] + fn sets_dropped_on_the_docks_left_edge_become_dock_columns() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "color", "x": 300, "y": 150})).unwrap(); + h.settle(); + // Held over the icon column's left edge, a line shows the new column; dropped, it docks. + let icons = h.temp_rect(icons_rect_id()); + let edge = pos2(icons.left() - 2.0, icons.center().y); + h.hold(h.title("color"), edge); + let target = drop_target(&h.app, &h.ctx, Moving::Panel("color"), edge); + assert_eq!(target.map(|t| t.0), Some(Drop::Column(1))); + assert!(target.is_some_and(|t| t.1.width() <= 4.0), "a line: {target:?}"); + h.release(edge); + assert_eq!(docked_of(&h.app.ui.floating_panels), [(vec!["color"], Some(1))]); + let cols = columns(&h); + assert_eq!(cols.len(), 1); + let icons = h.temp_rect(icons_rect_id()); + assert!((cols[0].right() - icons.left()).abs() < 2.0, "right of it the icon column: {cols:?} {icons:?}"); + // Drawn in the column, not floating too (a floating box, drawn after the dock, would move it). + assert!(cols[0].contains_rect(h.temp_rect(group_rect_id("color")).shrink(1.0)), "Color in the column"); + // Swatches dropped on the docked Color's bottom edge joins its column. + h.app.run("window.panel.float", json!({"panel": "swatches", "x": 300, "y": 300})).unwrap(); + h.settle(); + let color = h.temp_rect(group_rect_id("color")); + let below = pos2(color.center().x, color.bottom() + 2.0); + h.drag(h.title("swatches"), below); + assert_eq!(docked_of(&h.app.ui.floating_panels), [(vec!["color"], Some(1)), (vec!["swatches"], Some(1))]); + assert!(columns(&h)[0].contains_rect(h.temp_rect(group_rect_id("swatches")).shrink(1.0))); + // Stroke dropped on the column's left edge makes a second column, left of it. + h.app.run("window.panel.float", json!({"panel": "stroke", "x": 300, "y": 400})).unwrap(); + h.settle(); + let col = columns(&h)[0]; + h.drag(h.title("stroke"), pos2(col.left() - 2.0, col.center().y)); + assert_eq!(docked_of(&h.app.ui.floating_panels).last(), Some(&(vec!["stroke"], Some(2)))); + let cols = columns(&h); + assert!(cols.len() == 2 && cols[1].right() <= cols[0].left() + 2.0, "{cols:?}"); + // A docked lone tab dragged away floats its group, leaving the rest of the column docked. + let swatches = h.temp_rect(group_rect_id("swatches")); + h.drag(pos2(swatches.left() + 20.0, swatches.top() + GAP + STRIP / 2.0), pos2(500.0, 500.0)); + let d = docked_of(&h.app.ui.floating_panels); + assert!(d.contains(&(vec!["swatches"], None)) && d.contains(&(vec!["color"], Some(1))), "{d:?}"); + // The column's top strip floats the whole column. + let col = columns(&h)[0]; + h.drag(pos2(col.left() + 30.0, col.top() + TITLE / 2.0), pos2(500.0, 200.0)); + let d = docked_of(&h.app.ui.floating_panels); + assert!(d.contains(&(vec!["color"], None)) && d.contains(&(vec!["stroke"], Some(1))), "renumbered: {d:?}"); + assert!(h.group("color").contains(pos2(500.0, 200.0)), "under the pointer"); + // The column's × puts its panels back in the dock. + let col = columns(&h)[0]; + h.click(pos2(col.right() - 11.0, col.top() + TITLE / 2.0)); + assert!(!h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"stroke".to_string()))); + assert!(columns(&h).is_empty()); + } + + #[test] + fn dock_columns_by_command_are_checked_and_kept_in_workspaces() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + assert!(app.run("window.panel.dock", json!({"panel": "color", "column": 0})).is_err()); + assert!(app.run("window.panel.dock", json!({"panel": "color", "column": "left"})).is_err()); + assert!(app.ui.floating_panels.is_empty()); + let r = app.run("window.panel.dock", json!({"panel": "color", "column": true})).unwrap(); + assert_eq!((r["column"].clone(), r["floating"].clone()), (json!(1), json!(false))); + app.run("window.panel.dock", json!({"panel": "layers", "column": true})).unwrap(); + // Next to the icon column, moving Color and Layers one column left. + app.run("window.panel.dock", json!({"panel": "stroke", "column": 1})).unwrap(); + app.run("window.panel.float", json!({"panel": "gradient", "below": "stroke"})).unwrap(); + let d = docked_of(&app.ui.floating_panels); + for (panel, column) in [("stroke", 1), ("gradient", 1), ("color", 2), ("layers", 3)] { + assert!(d.contains(&(vec![panel], Some(column))), "{panel} in column {column}: {d:?}"); + } + // Kept in a workspace and the preferences. + app.run("window.workspace.new", json!({"name": "Columns"})).unwrap(); + let saved = app.ui.floating_panels.clone(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + app.run("window.workspace", json!({"name": "Columns"})).unwrap(); + assert_eq!(app.ui.floating_panels, saved); + let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(prefs.sanitized().floating_panels, saved); + // Floated, Color's column leaves the dock and the columns close up. + let r = app.run("window.panel.float", json!({"panel": "color"})).unwrap(); + assert_eq!((r["floating"].clone(), r["column"].clone()), (json!(true), json!(null))); + assert!(docked_of(&app.ui.floating_panels).contains(&(vec!["layers"], Some(2)))); + // A hand-edited file: columns numbered without gaps, a set's groups in its first one's. + let edited: UiState = serde_json::from_value(json!({ + "floating_panels": [ + {"panels": ["layers"], "pos": [1, 2], "docked": 7}, + {"panels": ["color"], "pos": [1, 2], "column": 3, "docked": 3}, + {"panels": ["stroke"], "pos": [5, 6], "column": 3}, + ], + })) + .unwrap(); + let edited = edited.sanitized(); + let d = docked_of(&edited.floating_panels); + assert_eq!(d, [(vec!["layers"], Some(2)), (vec!["color"], Some(1)), (vec!["stroke"], Some(1))]); + } + 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/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7d5beda5..22b4dc9b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3876,6 +3876,7 @@ axis Both Ambos @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 column must be true or a number from 1 column debe ser true o un número desde 1 @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 00d33e73..0c4c1467 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3873,6 +3873,7 @@ axis Both Les deux @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 column must be true or a number from 1 column doit être true ou un nombre à partir de 1 @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 c5b53a78..070b9318 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3876,6 +3876,7 @@ axis Both Entrambi @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 column must be true or a number from 1 column deve essere true o un numero da 1 @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/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e9642d73..b950958f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3872,6 +3872,7 @@ axis Both Оба @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 column must be true or a number from 1 column должен быть true или числом от 1 @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 5ecd32f3..8171d890 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3859,6 +3859,7 @@ axis Both Обидві @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 column must be true or a number from 1 column має бути true або числом від 1 @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/menus.rs b/crates/ui-egui/src/menus.rs index 5ab14588..18ee497d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -260,13 +260,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel.float", "Float Panel", "", - "{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}", + "{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; a docked group floats its dock column. \"tools\" floats the Tools panel. Returns {panel, floating, group, set, pos, collapsed, column}", ), ( "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", + "{panel: id or \"tools\", group?: bool, column?: true or n} 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; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge. Returns {panel, floating, column}", ), ( "window.collapseDock", diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ec83303a..56038265 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -458,6 +458,10 @@ pub struct FloatingPanels { /// 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, + /// Docked as a column of the dock (with the rest of its set) instead of floating: the columns + /// stand left of the icon column, 1 next to it, counting left. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docked: Option, } impl FloatingPanels { @@ -477,7 +481,8 @@ impl FloatingPanels { } /// 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. + /// come, at its first one's position and in its dock column; a set left with one group floats + /// (or stays docked) on its own; the dock columns are numbered 1, 2… without gaps. pub fn tidy(groups: &mut Vec) { let mut out: Vec = Vec::with_capacity(groups.len()); for mut g in groups.drain(..) { @@ -485,6 +490,7 @@ impl FloatingPanels { Some(i) => { if let Some(first) = out.iter().find(|o| o.column == g.column) { g.pos = first.pos; + g.docked = first.docked; } out.insert(i + 1, g); } @@ -497,6 +503,14 @@ impl FloatingPanels { g.column = None; } } + let mut numbers: Vec = out.iter().filter_map(|g| g.docked).collect(); + numbers.sort_unstable(); + numbers.dedup(); + for g in &mut out { + if let Some(d) = g.docked { + g.docked = numbers.iter().position(|n| *n == d).and_then(|k| u32::try_from(k + 1).ok()); + } + } *groups = out; } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c33bf1a8..71b5cfa8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -264,16 +264,21 @@ pointer is over it) or closed with its × its panels go back to the tabbed group 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. +group to its tabs, and another expands it. Dropped on the left edge of the icon column or of a dock column, a set +docks as a new dock column there: its groups stack in it as in a floating set, take groups dropped on their edges and +tabs, and leave it dragged by their tab strip; the strip along the column's top floats the whole column again, and +its × puts its panels back where they live. `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 +`window.panel.dock {panel, group?, column?}` puts it back, or with `column` (`true`, or the place: 1 next to the icon +column, counting left) docks its group and set as a dock column; `window.panel.float` on a docked panel floats its +column. `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 (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 +index of the tab shown, the top-left corner, `column`: the set it is stacked in, top to bottom in list order, +`collapsed`, and `docked`: the dock column it stands in) 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).