Merge pull request #3000 from nrq6993-a11y/stream/vc-issue-2995-panel-tab-reorder

Fix #2995: reorder grouped panel tabs by dragging within the tab strip
This commit is contained in:
BFlat
2026-10-21 23:58:09 +07:00
committed by GitHub
4 changed files with 254 additions and 16 deletions
+70 -5
View File
@@ -4,7 +4,7 @@
//! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column
//! then expands it again.
use egui::{CornerRadius, Rect, Sense, Stroke, Ui, vec2};
use egui::{CornerRadius, Pos2, Rect, Sense, Stroke, Ui, vec2};
use serde_json::json;
use crate::state::{DockTab, ICON_PANEL_GROUPS, ICON_PANELS};
@@ -242,13 +242,16 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let area = Rect::from_min_max(strip.min, egui::pos2((menu.left() - 4.0).max(strip.left()), strip.bottom() - 1.0));
let out = panel_tabs(ui, "docktab", area, &names, selected, theme::semibold(12.5), true);
for (i, resp) in out.responses {
let Some(&tab) = tabs.get(i) else { continue };
let pointer = ui.input(|i| i.pointer.interact_pos());
for (i, resp) in &out.responses {
let Some(&tab) = tabs.get(*i) else { continue };
let id = tab.info().0;
if resp.clicked() {
if let Some((from, to)) = tab_reorder_on_release(strip, pointer, resp, &out.responses) {
apply_dock_tab_reorder(app, &tabs, from, to);
} else if resp.clicked() {
app.ui.dock_tab = tab;
}
crate::panel_docking::legacy_tab(app, ui, id, &resp);
crate::panel_docking::legacy_tab_in_strip(app, ui, id, resp, Some(strip));
}
if let Some(tab) = out.picked.and_then(|i| tabs.get(i)).copied() {
app.ui.dock_tab = tab;
@@ -271,6 +274,38 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
/// App styling and overflow menus around the shared tab fitting/interaction components. Docking
/// and floating groups consume the responses themselves, preserving their different drag rules.
/// Drag a tab onto another tab in the same strip to reorder (pointer still on the strip at release),
/// as in Illustrator's workspace basics
/// (<https://helpx.adobe.com/illustrator/using/workspace-basics.html>).
pub(crate) fn tab_reorder_on_release(
strip: Rect,
pointer: Option<Pos2>,
source: &egui::Response,
tabs: &[(usize, egui::Response)],
) -> Option<(usize, usize)> {
if !source.drag_stopped() {
return None;
}
let pointer = pointer.filter(|p| p.is_finite())?;
if !strip.expand(4.0).contains(pointer) {
return None;
}
if source.drag_delta().length() < 2.0 {
return None;
}
let from = tabs.iter().find(|(_, r)| r.id == source.id).map(|(i, _)| *i)?;
let to = tabs.iter().find(|(_, r)| r.rect.contains(pointer)).map(|(i, _)| *i)?;
(from != to).then_some((from, to))
}
fn apply_dock_tab_reorder(app: &mut VectorcraftApp, visible: &[DockTab], from: usize, to: usize) {
let Some(from_tab) = visible.get(from) else { return };
let Some(to_tab) = visible.get(to) else { return };
if let Err(error) = crate::panel_docking::strip_tab_reorder(app, from_tab.info().0, to_tab.info().0) {
app.ui.status = error;
}
}
pub(crate) struct PanelTabs {
pub responses: Vec<(usize, egui::Response)>,
pub picked: Option<usize>,
@@ -690,6 +725,7 @@ mod tests {
fn arrow(&self) -> egui::Pos2 {
egui::pos2(SCREEN.x - 10.0, HEADER / 2.0)
}
}
fn icon_panel_count() -> usize {
@@ -801,6 +837,35 @@ mod tests {
assert_eq!(h.app.ui.open_panel, None, "a click in the docked group puts {open:?} away");
}
#[test]
fn window_panel_move_reorders_legacy_dock_tabs_and_persists_order() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
assert!(app.ui.docking.is_none());
app.run(
"window.panel.move",
json!({ "panel": "libraries", "anchor": "properties", "before": "properties" }),
)
.unwrap();
let order: Vec<_> = floating::docked_tabs(&app.ui).map(|t| t.info().0).collect();
assert_eq!(order, ["libraries", "properties", "layers"]);
let saved: crate::state::UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();
assert_eq!(saved.dock_tab_order, app.ui.dock_tab_order);
}
#[test]
fn partial_dock_tab_order_appends_tabs_missing_from_preferences() {
let ui = crate::state::UiState {
dock_tab: DockTab::Layers,
dock_tab_order: vec![DockTab::Layers, DockTab::Properties],
..Default::default()
};
let order = floating::resolved_dock_tab_order(&ui);
assert_eq!(order.len(), DockTab::ALL.len());
assert_eq!(order[..2], [DockTab::Layers, DockTab::Properties]);
assert_eq!(order[2], DockTab::Libraries);
}
#[test]
fn the_collapse_command_sets_or_toggles_the_dock() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+67 -6
View File
@@ -138,6 +138,16 @@ pub fn group_of(ui: &UiState, id: &str) -> Option<usize> {
ui.floating_panels.iter().position(|g| g.panels.iter().any(|p| p == id))
}
/// Reorder tabs in a floating group by index, keeping the active tab on the same panel.
pub(crate) fn reorder_group_tab_indices(g: &mut FloatingPanels, from: usize, to: usize) {
if from != to && from < g.panels.len() && to < g.panels.len() {
let active = g.panels.get(g.active).cloned();
let moved = g.panels.remove(from);
g.panels.insert(to, moved);
g.active = active.and_then(|id| g.panels.iter().position(|p| *p == id)).unwrap_or(0);
}
}
/// The floating groups drawn with group `gi`, top to bottom: the groups of its set, or it alone.
pub fn set_of(ui: &UiState, gi: usize) -> Vec<usize> {
match ui.floating_panels.get(gi).and_then(|g| g.column) {
@@ -209,9 +219,30 @@ fn ids_of(g: &FloatingPanels) -> Vec<&'static str> {
g.panels.iter().filter_map(|p| panel(p)).map(|(id, _)| id).collect()
}
/// Saved dock tab order, appending any tabs missing from older preferences.
pub(crate) fn resolved_dock_tab_order(ui: &UiState) -> Vec<DockTab> {
if ui.dock_tab_order.is_empty() {
DockTab::ALL.to_vec()
} else {
let mut order = ui.dock_tab_order.clone();
for tab in DockTab::ALL {
if !order.contains(&tab) {
order.push(tab);
}
}
order
}
}
/// Persist dock tab order when it differs from the default visible strip.
pub(crate) fn set_dock_tab_order(ui: &mut UiState, visible: Vec<DockTab>) {
let default = DockTab::ALL.iter().copied().filter(|t| group_of(ui, t.info().0).is_none()).collect::<Vec<_>>();
ui.dock_tab_order = if visible == default { Vec::new() } else { visible };
}
/// The dock tabs left in the dock, in tab order.
pub fn docked_tabs(ui: &UiState) -> impl Iterator<Item = DockTab> + '_ {
DockTab::ALL.into_iter().filter(|t| group_of(ui, t.info().0).is_none())
resolved_dock_tab_order(ui).into_iter().filter(|t| group_of(ui, t.info().0).is_none())
}
/// The dock tab the tabbed group shows: the chosen one, or the first left when it floats.
@@ -566,6 +597,8 @@ enum Action {
Detach(usize, f32),
/// Float this panel out of its group; its tab began this far right.
Tear(&'static str, f32),
/// Reorder tabs in floating group `gi` from index `from` to `to`.
Reorder(usize, usize, usize),
/// Float the docked column of this group, its top-left corner this far from the pointer.
Undock(usize, Vec2),
}
@@ -624,6 +657,7 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>)
return;
}
let before = app.ui.clone();
let skip_reconcile = matches!(action, Some(Action::Reorder(_, _, _)));
match action {
Some(Action::Activate(gi, k)) => {
if let Some(g) = app.ui.floating_panels.get_mut(gi)
@@ -654,6 +688,19 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>)
}
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::Reorder(gi, from, to)) => {
let ids = app.ui.floating_panels.get(gi).and_then(|g| {
let panel = g.panels.get(from)?;
let before = g.panels.get(to)?;
Some((panel.clone(), before.clone()))
});
if let Some((panel, before)) = ids
&& let Err(error) = crate::panel_docking::strip_tab_reorder(app, &panel, &before)
{
app.ui.status = error;
}
return;
}
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 };
@@ -669,7 +716,7 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>)
}
None => {}
}
if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) {
if !skip_reconcile && let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) {
app.ui.status = error;
}
ctx.data_mut(|data| {
@@ -889,12 +936,14 @@ fn draw_groups(
let names: Vec<_> = g.ids.iter().zip(&labels).map(|(&id, label)| (id, label.as_str())).collect();
let selected = g.ids.iter().position(|&id| id == g.active).unwrap_or(0);
let out = dock::panel_tabs(ui, &format!("native-set-tabs-{}", g.gi), tab_area, &names, selected, theme::semibold(12.0), false);
for (j, resp) in out.responses {
let Some(&id) = g.ids.get(j) else { continue };
if resp.double_clicked() {
for (j, resp) in &out.responses {
let Some(&id) = g.ids.get(*j) else { continue };
if let Some((from, to)) = dock::tab_reorder_on_release(strip, pointer, resp, &out.responses) {
*action = Some(Action::Reorder(g.gi, from, to));
} else if resp.double_clicked() {
*action = Some(Action::Collapse(g.gi));
} else if resp.clicked() {
*action = Some(Action::Activate(g.gi, j));
*action = Some(Action::Activate(g.gi, *j));
} else if lone && resp.drag_started() {
*action = Some(match alone {
Some(pos) => Action::Move(id, pos),
@@ -1240,6 +1289,18 @@ mod tests {
}
}
#[test]
fn floating_group_tabs_reorder_horizontally_within_the_strip() {
let mut h = Harness::new();
h.drag(h.icons()[2].center(), pos2(500.0, 250.0));
h.drag(h.icons()[0].center(), h.shared_tab("swatches").center());
assert_eq!(h.groups(), [["swatches", "color"]]);
h.app
.run("window.panel.move", json!({ "panel": "color", "anchor": "swatches", "before": "swatches" }))
.unwrap();
assert_eq!(h.groups(), [["color", "swatches"]]);
}
#[test]
fn a_dock_tab_dragged_out_floats_and_dropped_on_the_dock_docks_again() {
let mut h = Harness::new();
+113 -5
View File
@@ -142,6 +142,79 @@ fn number(params: &Value, key: &str, default: f32) -> Result<f32, String> {
}
}
const LEGACY_REORDER_BAD_DEST: &str = "move requires an existing destination panel";
enum LegacyStripReorder {
DockStrip,
FloatingGroup(usize),
}
fn legacy_strip_reorder_target(
app: &VectorcraftApp,
before: &str,
dock_strip: bool,
panel_group: Option<usize>,
) -> Result<LegacyStripReorder, String> {
if dock_strip {
return Ok(LegacyStripReorder::DockStrip);
}
let gi = panel_group.ok_or(LEGACY_REORDER_BAD_DEST)?;
if crate::floating::group_of(&app.ui, before) != Some(gi) {
return Err(LEGACY_REORDER_BAD_DEST.into());
}
Ok(LegacyStripReorder::FloatingGroup(gi))
}
/// Legacy workspaces (no saved `docking` layout): reorder tabs in the dock strip or a floating
/// group without promoting to a shared layout.
fn legacy_same_strip_reorder(
app: &mut VectorcraftApp,
panel: &str,
before: &str,
target: LegacyStripReorder,
) -> Result<Value, String> {
use crate::state::DockTab;
match target {
LegacyStripReorder::DockStrip => {
let mut order: Vec<DockTab> = crate::floating::docked_tabs(&app.ui).collect();
let from = order.iter().position(|t| t.info().0 == panel).ok_or(LEGACY_REORDER_BAD_DEST)?;
let to = order.iter().position(|t| t.info().0 == before).ok_or(LEGACY_REORDER_BAD_DEST)?;
if from != to {
let item = order.remove(from);
order.insert(to, item);
crate::floating::set_dock_tab_order(&mut app.ui, order);
}
}
LegacyStripReorder::FloatingGroup(gi) => {
let g = app.ui.floating_panels.get_mut(gi).ok_or("invalid restored panel group")?;
let from = g.panels.iter().position(|id| id == panel).ok_or(LEGACY_REORDER_BAD_DEST)?;
let to = g.panels.iter().position(|id| id == before).ok_or(LEGACY_REORDER_BAD_DEST)?;
crate::floating::reorder_group_tab_indices(g, from, to);
if let Some(layout) = app.ui.docking.as_mut() {
let panels = g.panels.clone();
let active = g.active.min(panels.len().saturating_sub(1));
if let Some(group) = layout.floating.iter_mut().find(|group| group.panels.iter().any(|id| id == panel)) {
group.panels = panels;
group.active = active;
}
}
}
}
Ok(json!({ "panel": panel, "before": before }))
}
/// Same-strip tab reorder for `window.panel.move` and in-frame tab drags (`app.run` during egui deadlocks).
pub(crate) fn strip_tab_reorder(app: &mut VectorcraftApp, panel: &str, before: &str) -> Result<Value, String> {
use crate::state::DockTab;
let panel_group = crate::floating::group_of(&app.ui, panel);
let dock_strip_reorder = DockTab::from_id(panel).is_some()
&& DockTab::from_id(before).is_some()
&& panel_group.is_none()
&& crate::floating::group_of(&app.ui, before).is_none();
let target = legacy_strip_reorder_target(app, before, dock_strip_reorder, panel_group)?;
legacy_same_strip_reorder(app, panel, before, target)
}
fn placement(params: &Value) -> Result<Placement<String>, String> {
if let Some(before) = params.get("before") {
let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?;
@@ -294,6 +367,18 @@ fn apply_action(app: &mut VectorcraftApp, action: Action<String>) -> Result<Valu
fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result<Value, String> {
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
if operation == "move" && params.get("before").is_some() {
let before = params.get("before").and_then(Value::as_str).and_then(normalize).ok_or("before must name a panel")?;
use crate::state::DockTab;
let panel_group = crate::floating::group_of(&app.ui, panel);
let dock_strip_reorder = DockTab::from_id(panel).is_some()
&& DockTab::from_id(before).is_some()
&& panel_group.is_none()
&& crate::floating::group_of(&app.ui, before).is_none();
if app.ui.docking.is_none() || panel_group.is_some() || dock_strip_reorder {
return strip_tab_reorder(app, panel, before);
}
}
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
return Err("saved panel layout is invalid; reset the workspace first".into());
}
@@ -766,7 +851,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
{
return app.ui.docking.is_some();
}
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin")));
craft_ui::docking::cancel_drag::<String>(ui.ctx(), egui::Id::new("vectorcraft-panel-docking"));
ui.ctx()
.data_mut(|data| data.remove::<LegacyDrag>(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin")));
}
for action in output.actions {
if tab_drop
@@ -774,7 +861,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
{
continue;
}
if let Err(error) = app.run("window.panel.layout", json!({"action": action})) {
if let Err(error) = apply_action(app, action) {
app.ui.status = error;
}
}
@@ -857,6 +944,16 @@ fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
}
pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) {
legacy_tab_in_strip(app, ui, panel, response, None);
}
pub(crate) fn legacy_tab_in_strip(
app: &mut VectorcraftApp,
ui: &mut egui::Ui,
panel: &str,
response: &egui::Response,
strip: Option<egui::Rect>,
) {
let Some(panel) = normalize(panel) else { return };
response.context_menu(|ui| {
if ui.button(tl!("Float Panel")).clicked() {
@@ -880,7 +977,17 @@ pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &st
}
});
});
if response.drag_started()
let inside_strip = strip.is_some_and(|strip| {
ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| strip.expand(4.0).contains(p))
});
let area = egui::Id::new("vectorcraft-panel-docking");
let begun = ui.ctx().data(|d| d.get_temp::<LegacyDrag>(area.with("legacy-origin")).is_some());
let begin = if strip.is_some() {
!begun && (response.drag_started() || (!inside_strip && response.dragged()))
} else {
response.drag_started()
};
if begin
&& let Some(id) = normalize(panel)
{
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
@@ -1035,9 +1142,10 @@ mod tests;
/// Commit a pointer drop after shared and native panel windows have finished rendering.
pub(crate) fn finish_native_drop(app: &mut VectorcraftApp, ctx: &egui::Context) {
if let Some((command, params)) =
if let Some((id, params)) =
ctx.data_mut(|data| data.remove_temp::<(&'static str, serde_json::Value)>(egui::Id::new("vectorcraft-native-panel-drop")))
&& let Err(error) = app.run(command, params)
&& let Some(result) = command(app, id, &params)
&& let Err(error) = result
{
app.ui.status = error;
}
+4
View File
@@ -309,6 +309,9 @@ pub struct UiState {
pub legacy_language: Option<String>,
pub brightness: Brightness,
pub dock_tab: DockTab,
/// Tab order in the dock's tabbed group when it differs from [`DockTab::ALL`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dock_tab_order: Vec<DockTab>,
/// The dock's tabbed group (Properties | Layers | Libraries) is collapsed to icons at the top of
/// the icon column (the dock's double arrow, `window.collapseDock`).
#[serde(default)]
@@ -778,6 +781,7 @@ impl Default for UiState {
legacy_language: None,
brightness: Brightness::MediumDark,
dock_tab: DockTab::Properties,
dock_tab_order: Vec::new(),
dock_collapsed: false,
open_panel: None,
control_bar: false,