From 59cb4c49bd236df04d8e37d4764253f29ca6600a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:13:32 -0400 Subject: [PATCH] Panels: remove the native dock's drawing, strip and drag code (#9583) craft-ui's docking draws every layout, the default one included, so the native dock's own renderer no longer runs. Remove dock::show with its per-frame group and strip records, peek, splitter resize, group and tab drag and drop; the column height solver it used; DockLayout's move_group and move_tab, which only it called; panel_docking's drag promotion from the native strip and its extra strip menu; the card widgets that drew the native groups and the Pro tab strip; and the tab drag and reorder paths of the strip the Studio Properties card still uses. The native dock's state, the commands that change it, the default layout built from it and workspace loading and saving stay as they are. Tests of the removed code go; the default-height test runs on the drawn layout. --- crates/ui-egui/src/dock.rs | 502 +------------------ crates/ui-egui/src/dock_tests.rs | 263 +--------- crates/ui-egui/src/panel_docking.rs | 121 +---- crates/ui-egui/src/panels.rs | 6 +- crates/ui-egui/src/tab_strip.rs | 181 +------ crates/ui-egui/src/widgets.rs | 134 ----- crates/ui-egui/tests/it/workspace_presets.rs | 4 +- 7 files changed, 55 insertions(+), 1156 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index dc4989b39c..91cccc453b 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1,11 +1,11 @@ -//! Right-dock layout (#88): panel groups with fixed heights, like Photoshop's dock columns. +//! The native dock's state (#88): the panel groups of the end-side dock column, their order, +//! tab order, hidden tabs, heights and collapse. //! -//! A group's height never follows its content: content taller than the group scrolls inside -//! it. The last expanded group (Layers by default) fills what the others leave. Drag the gap -//! between two groups to resize them, double-click a tab (or use the panel menu) to collapse a -//! group to its tab strip, drag a tab strip (outside its tabs, or a tab dragged off the strip) to -//! move the group up or down the column, and drag a tab along its strip to reorder the group's -//! tabs (#2272), or drop a tab on another group's strip to move only that tab. Window › Workspace › Lock Workspace stops all these moves. +//! craft-ui's docking draws every layout (`crate::panel_docking`). Until the workspace is +//! customized, the default layout is built each frame from this state: one dock column of the +//! shown groups, top to bottom, where a group the user never resized gets its default height and +//! the last expanded group (Layers by default) fills what the others leave. The Window menu's +//! panel toggles, the icon rail, collapse and `ui.set` change this state. //! //! The layout is [`DockLayout`] in `UiState::dock` (serialisable, drivable with `ui.set`), saved //! with Window › Workspace › New Workspace…, reset by Reset Workspace, and remembered across @@ -13,14 +13,11 @@ use std::collections::BTreeMap; -use egui::{Pos2, Rect, Sense, Stroke, pos2, vec2}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::PhotocraftApp; use crate::state::{DockTabs, Panels}; -use crate::theme::Tokens; -use crate::widgets; /// A dock panel group (one tab strip). #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -59,7 +56,7 @@ impl Group { } /// Height (points, tab strip included) a group gets until the user resizes it, when the - /// column has room (see [`DockLayout::heights_for`]). + /// column has room. pub fn default_height(self) -> f32 { match self { Group::Color => 190.0, @@ -203,8 +200,6 @@ fn lenient_tab_order<'de, D: serde::Deserializer<'de>>(d: D) -> Result, pro: bool) { - if before == Some(tab) { - return; - } - let mut order = self.ordered_tabs(group, pro); - let Some(at) = order.iter().position(|(i, _)| *i == tab) else { return }; - let moved = order.remove(at); - let to = before.and_then(|b| order.iter().position(|(i, _)| *i == b)).unwrap_or(order.len()); - order.insert(to, moved); - if order.iter().map(|(i, _)| *i).eq(0..order.len()) { - self.tab_order.remove(&group); - } else { - self.tab_order.insert(group, order.into_iter().map(|(_, n)| n.to_owned()).collect()); - } - } - /// Hide one tab; it can be reopened through Window › Panel. pub fn hide_tab(&mut self, group: Group, tab: usize, pro: bool) { let Some(&name) = group.tabs(pro).get(tab) else { return }; @@ -315,84 +292,6 @@ impl DockLayout { _ => g.default_height(), } } - - /// Move `g` so it is drawn just before `before` (or last when `None`). - pub fn move_group(&mut self, g: Group, before: Option) { - if before == Some(g) { - return; - } - let mut order = self.order(); - order.retain(|x| *x != g); - let at = before.and_then(|b| order.iter().position(|x| *x == b)).unwrap_or(order.len()); - order.insert(at, g); - self.order = order; - } - - /// Lay out the `shown` groups (in display order) in a column `avail` points tall with - /// `strip`-high tab strips. Returns each group's height. The last expanded group fills the - /// rest. Groups the user never resized give way first, down to their compact heights, so - /// the filler gets its preferred height (Layers: ~10 rows, #147); when the column is still - /// too short every group gives way down to its minimum height. - pub fn heights_for(&self, shown: &[Group], avail: f32, strip: f32) -> Vec<(Group, f32)> { - let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 }; - let filler = shown.iter().rposition(|g| !self.is_collapsed(*g)); - let mut hs: Vec = shown - .iter() - .enumerate() - .map(|(i, g)| { - if self.is_collapsed(*g) { - strip - } else if Some(i) == filler { - 0.0 - } else { - self.height(*g) - } - }) - .collect(); - if let Some(f) = filler { - let gaps = GAP * shown.len().saturating_sub(1) as f32; - let min_fill = shown.get(f).map_or(0.0, |g| g.min_height()); - let pref_fill = shown.get(f).map_or(0.0, |g| g.preferred_fill()); - let used: f32 = hs.iter().sum::() + gaps; - let mut deficit = (used + pref_fill - avail).max(0.0); - for i in (0..f).rev() { - if deficit <= 0.0 { - break; - } - let Some(g) = shown.get(i) else { continue }; - if self.is_collapsed(*g) || self.heights.contains_key(g) { - continue; - } - if let Some(h) = hs.get_mut(i) { - let give = (*h - g.compact_height()).max(0.0).min(deficit); - *h -= give; - deficit -= give; - } - } - let used: f32 = hs.iter().sum::() + gaps; - let mut deficit = (used + min_fill - avail).max(0.0); - // Squeeze the expanded groups nearest the filler first. - for i in (0..f).rev() { - if deficit <= 0.0 { - break; - } - let Some(g) = shown.get(i) else { continue }; - if self.is_collapsed(*g) { - continue; - } - if let Some(h) = hs.get_mut(i) { - let give = (*h - g.min_height()).max(0.0).min(deficit); - *h -= give; - deficit -= give; - } - } - let rest = avail - hs.iter().sum::() - gaps; - if let Some(h) = hs.get_mut(f) { - *h = rest.max(min_fill); - } - } - shown.iter().copied().zip(hs).collect() - } } /// Show `g` and expand it (Window › , the icon rail): a panel asked for is always @@ -438,391 +337,6 @@ pub(crate) fn close_tab(app: &mut PhotocraftApp, g: Group, tab: usize, pro: bool } } -/// Per-group interactions collected while drawing, applied afterwards. -enum Action { - ToggleCollapse(Group), - Close(Group), - CloseTab(Group, usize), - Move(Group, Option), - MoveTab(Group, usize, Option), -} - -/// Rects of the groups drawn last frame (screen points), for tests and automation. -pub fn last_rects(ctx: &egui::Context) -> Vec<(Group, Rect)> { - ctx.data(|d| d.get_temp::>(rects_id())).unwrap_or_default() -} - -fn rects_id() -> egui::Id { - egui::Id::unique("dock-group-rects") -} - -/// The group expanded from its collapsed tab strip (see [`show`]), if any: with Preferences ▸ -/// Workspace ▸ Auto-Collapse Iconic Panels on, the first click outside it collapses it again. -fn peek_id() -> egui::Id { - egui::Id::unique("dock-peek") -} - -fn auto_collapse_peek(app: &mut PhotocraftApp, ctx: &egui::Context) { - let Some(g) = ctx.data(|d| d.get_temp::>(peek_id())).flatten() else { return }; - let Some(p) = ctx.input(|i| i.pointer.any_pressed().then(|| i.pointer.press_origin()).flatten()) else { - return; - }; - let inside = last_rects(ctx).into_iter().find(|(x, _)| *x == g).is_some_and(|(_, r)| r.contains(p)); - if !inside { - app.ui.dock.set_collapsed(g, true); - ctx.data_mut(|d| { - d.insert_temp(peek_id(), None::); - }); - } -} - -/// A group's tab strip as drawn last frame (screen points), for tests and automation. -#[derive(Clone, Debug, PartialEq)] -pub struct StripRects { - pub group: Group, - /// The area accepting a tab drop, excluding the group menu. - pub area: Rect, - /// `(tab index, rect)` of the tabs on the strip (the others are in the chevron menu). - pub tabs: Vec<(usize, Rect)>, - /// The panel menu button. - pub menu: Rect, - /// The » overflow button, when some tabs didn't fit. - pub chevron: Option, -} - -/// The tab strips drawn last frame. -pub fn last_strips(ctx: &egui::Context) -> Vec { - ctx.data(|d| d.get_temp::>(strips_id())).unwrap_or_default() -} - -fn strips_id() -> egui::Id { - egui::Id::unique("dock-strip-rects") -} - -/// Draw the `shown` groups (any order; the layout decides) filling `ui`. `body` draws one -/// group's tab content. -pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut body: impl FnMut(&mut PhotocraftApp, &mut egui::Ui, Group, usize)) { - let t = Tokens::get(ui.ctx()); - let auto_collapse = app.session.prefs().workspace.auto_collapse_icon_panels; - if auto_collapse { - auto_collapse_peek(app, ui.ctx()); - } else { - ui.ctx().data_mut(|d| { - d.insert_temp(peek_id(), None::); - }); - } - let strip = if t.pro { 28.0 } else { 40.0 }; - let order: Vec = app.ui.dock.order().into_iter().filter(|g| shown.contains(g)).collect(); - let area = ui.available_rect_before_wrap(); - let heights = app.ui.dock.heights_for(&order, area.height(), strip); - let locked = app.session.prefs().workspace_locked; - let rects = rects_after_layout(&heights, area); - let mut actions: Vec = Vec::new(); - let mut peek_after: Option = None; - let mut tab_drops: Vec<(Group, usize, Pos2)> = Vec::new(); - let mut dragging: Option = None; - let mut strips: Vec = Vec::with_capacity(rects.len()); - for (i, (g, rect)) in rects.iter().copied().enumerate() { - let collapsed = app.ui.dock.is_collapsed(g); - let mut child = ui.new_child(egui::UiBuilder::new().id_salt(("dock-group", g.key())).max_rect(rect)); - child.set_clip_rect(rect.intersect(ui.clip_rect())); - child.spacing_mut().item_spacing.y = if t.pro { 0.0 } else { 6.0 }; - let before = *g.tab_mut(&mut app.ui.dock_tabs); - let visible = app.ui.dock.visible_tabs(g, t.pro); - if visible.is_empty() { - continue; - } - let indices: Vec = visible.iter().map(|(i, _)| *i).collect(); - let tabs: Vec<&str> = visible.iter().map(|(_, name)| *name).collect(); - let mut sel = indices.iter().position(|i| *i == before).unwrap_or(0); - let resp = widgets::card_ex(&mut child, g.key(), &tabs, &mut sel, collapsed, !locked, |ui, shown_tab| { - let Some(&tab) = indices.get(shown_tab) else { return }; - let inner = ui.available_height().max(0.0); - if g.scrolls_itself(tab) { - ui.set_min_height(inner); - body(app, ui, g, tab); - } else { - crate::panels::offer_color_field_fill(ui, t.pro, g, tab); - // No scroll fade: it darkens real colours, swatches and previews at the edge - // (#2726); the scroll bar already shows there is more. - ui.spacing_mut().scroll.fade.strength = 0.0; - egui::ScrollArea::vertical() - .id_salt(("dock-scroll", g.key(), tab)) - .max_height(inner) - .auto_shrink([false, false]) - .show(ui, |ui| body(app, ui, g, tab)); - } - }); - for (index, response) in &resp.responses { - if let Some(panel) = tabs.get(*index) { - crate::panel_docking::legacy_tab(app, &mut child, panel, response); - } - } - strips.push(StripRects { - group: g, - area: Rect::from_min_max(pos2(rect.left(), resp.menu.rect.top()), pos2(resp.menu.rect.left(), resp.menu.rect.bottom())), - tabs: resp.tabs.iter().filter_map(|(i, r)| indices.get(*i).map(|original| (*original, *r))).collect(), - menu: resp.menu.rect, - chevron: resp.chevron, - }); - // The strip uses visible indices; dockTabs and the panel bodies use original indices. - if let Some(&picked) = indices.get(sel) - && picked != before - { - *g.tab_mut(&mut app.ui.dock_tabs) = picked; - } - if let Some(context) = resp.tab_context { - match context { - crate::tab_strip::TabContextAction::Close(i) => { - if let Some(&tab) = indices.get(i) { - actions.push(Action::CloseTab(g, tab)); - } - } - crate::tab_strip::TabContextAction::CloseGroup => actions.push(Action::Close(g)), - } - } - if resp.strip.double_clicked() || resp.tab_double_clicked || (collapsed && resp.tab_clicked) { - actions.push(Action::ToggleCollapse(g)); - // A single click on a collapsed strip's tab expands the group as a peek: the next - // click outside closes it again, with Auto-Collapse Iconic Panels on. - if collapsed && resp.tab_clicked && !resp.tab_double_clicked && auto_collapse { - peek_after = Some(g); - } - } - // The strip uses visible indices; reorder in original ones. - if !locked - && let Some((from, before)) = resp.tab_reorder - && let Some(&tab) = indices.get(from) - { - actions.push(Action::MoveTab(g, tab, before.and_then(|b| indices.get(b).copied()))); - } - // A tab dragged off its strip moves the whole group, as the strip itself does. - if !locked && app.ui.docking.is_none() && (resp.strip.dragged() || resp.tab_dragging_out.is_some()) { - dragging = Some(g); - } - if !locked - && app.ui.docking.is_none() - && resp.strip.drag_stopped() - && let Some(p) = ui.ctx().pointer_interact_pos().or_else(|| ui.ctx().pointer_latest_pos()) - { - actions.push(Action::Move(g, drop_before(&order, &rects, g, p.y))); - } - if !locked - && app.ui.docking.is_none() - && let Some(shown_tab) = resp.tab_dropped_out - // The strip uses visible indices; the drop resolves the panel by its original one. - && let Some(&tab) = indices.get(shown_tab) - && let Some(p) = ui.ctx().pointer_interact_pos().or_else(|| ui.ctx().pointer_latest_pos()) - { - tab_drops.push((g, tab, p)); - } - if dragging == Some(g) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } - egui::Popup::menu(&resp.menu).show(|ui| { - crate::widgets::style_spectrum_popup_menu(ui); - ui.set_min_width(170.0); - if tabs.get(sel) == Some(&"Layers") { - crate::layer_row_ui::panel_menu(app, ui); - ui.separator(); - } - if tabs.get(sel) == Some(&"Color") { - crate::color_panel_ui::panel_menu(app, ui); - ui.separator(); - } - if tabs.get(sel) == Some(&"Swatches") { - crate::swatches_ui::panel_menu(app, ui); - ui.separator(); - } - if tabs.get(sel) == Some(&"History") { - crate::history_menu_ui::panel_menu(app, ui); - ui.separator(); - } - if tabs.get(sel) == Some(&"Brush Settings") { - crate::brush_panel::panel_menu(app, ui); - ui.separator(); - } - if tabs.get(sel) == Some(&"Histogram") { - crate::histogram_panel::panel_menu(app, ui); - ui.separator(); - } - if tabs.get(sel) == Some(&"Actions") { - crate::actions::panel_menu(app, ui); - ui.separator(); - } - if let Some(label) = tabs.get(sel) { - crate::panel_docking::legacy_menu(app, ui, label); - } - if ui.button(if collapsed { tl!("Expand Panel Group") } else { tl!("Collapse Panel Group") }).clicked() { - actions.push(Action::ToggleCollapse(g)); - ui.close(); - } - let pos = order.iter().position(|x| *x == g).unwrap_or(0); - if ui.add_enabled(!locked && pos > 0, egui::Button::new(tl!("Move Group Up"))).clicked() { - actions.push(Action::Move(g, order.get(pos.saturating_sub(1)).copied())); - ui.close(); - } - if ui.add_enabled(!locked && pos + 1 < order.len(), egui::Button::new(tl!("Move Group Down"))).clicked() { - actions.push(Action::Move(g, order.get(pos + 2).copied())); - ui.close(); - } - ui.separator(); - if ui.button(tl!("Close")).clicked() { - if let Some(&tab) = indices.get(sel) { - actions.push(Action::CloseTab(g, tab)); - } - ui.close(); - } - if ui.button(tl!("Close Tab Group")).clicked() { - actions.push(Action::Close(g)); - ui.close(); - } - }); - // Splitter in the gap below this group: resizes it, pushing the neighbouring groups. - if i + 1 < rects.len() && !collapsed && rects.iter().skip(i + 1).any(|(n, _)| !app.ui.dock.is_collapsed(*n)) { - let gap = Rect::from_min_size(pos2(rect.left(), rect.bottom()), vec2(rect.width(), GAP)).expand2(vec2(0.0, 2.0)); - let sresp = ui.interact(gap, ui.scope_id().with(("dock-splitter", g.key())), Sense::drag()); - if sresp.hovered() || sresp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); - ui.painter().line_segment([gap.left_center(), gap.right_center()], Stroke::new(2.0, t.accent.gamma_multiply(0.7))); - } - if sresp.dragged() { - resize(&mut app.ui.dock, &heights, i, sresp.drag_delta().y); - } - } - } - let mut panel_moves = Vec::new(); - for (from, tab, position) in tab_drops { - let target = strips.iter().find(|strip| strip.group != from && strip.area.contains(position)); - if let Some(target) = target { - let pro = t.pro; - let source = from.tabs(pro).get(tab).and_then(|label| crate::panel_docking::normalize(label)); - let slot = crate::tab_strip::drop_slot(&target.tabs, target.area, position); - let before = slot.and_then(|slot| target.tabs.get(slot).map(|(index, _)| *index)); - let anchor = before.or_else(|| target.tabs.first().map(|(index, _)| *index)); - let destination = anchor.and_then(|index| target.group.tabs(pro).get(index).and_then(|label| crate::panel_docking::normalize(label))); - if let (Some(source), Some(destination)) = (source, destination) { - panel_moves.push((source, destination, before.is_some())); - } - } else { - actions.push(Action::Move(from, drop_before(&order, &rects, from, position.y))); - } - } - // Drop indicator while a group is dragged by its tab strip. - if let (Some(g), Some(p)) = (dragging, ui.ctx().pointer_interact_pos()) { - let before = drop_before(&order, &rects, g, p.y); - let line_y = match before.and_then(|b| rects.iter().find(|(x, _)| *x == b)) { - Some((_, r)) => r.top() - GAP / 2.0, - None => rects.last().map_or(area.top(), |(_, r)| r.bottom() + GAP / 2.0), - }; - ui.painter().line_segment([pos2(area.left(), line_y), pos2(area.right(), line_y)], Stroke::new(3.0, t.accent)); - } - ui.ctx().data_mut(|d| { - d.insert_temp(rects_id(), rects); - d.insert_temp(strips_id(), strips); - }); - ui.advance_cursor_after_rect(area); - for a in actions { - match a { - Action::ToggleCollapse(g) => { - let on = !app.ui.dock.is_collapsed(g); - app.ui.dock.set_collapsed(g, on); - } - Action::Close(g) => *g.shown_mut(&mut app.ui.panels) = false, - Action::CloseTab(g, tab) => close_tab(app, g, tab, t.pro), - Action::MoveTab(g, tab, before) => app.ui.dock.move_tab(g, tab, before, t.pro), - Action::Move(g, before) => { - if before != Some(g) { - app.ui.dock.move_group(g, before); - } - } - } - } - if let Some(g) = peek_after - && !app.ui.dock.is_collapsed(g) - { - ui.ctx().data_mut(|d| { - d.insert_temp(peek_id(), Some(g)); - }); - } - for (panel, anchor, before) in panel_moves { - let params = if before { json!({"panel":panel,"anchor":anchor,"before":anchor}) } else { json!({"panel":panel,"anchor":anchor}) }; - if let Err(error) = app.run("window.panel.move", params) { - app.ui.status = error; - } - } -} - -fn rects_after_layout(heights: &[(Group, f32)], area: Rect) -> Vec<(Group, Rect)> { - let mut y = area.top(); - heights - .iter() - .map(|(g, h)| { - let r = Rect::from_min_size(pos2(area.left(), y), vec2(area.width(), h.max(0.0))); - y = r.bottom() + GAP; - (*g, r) - }) - .collect() -} - -/// The group the dragged one lands before when released at `y` (`None` = last). -fn drop_before(order: &[Group], rects: &[(Group, Rect)], dragged: Group, y: f32) -> Option { - let hit = rects.iter().find(|(_, r)| y < r.center().y).map(|(g, _)| *g); - match hit { - // Dropping onto itself, or just below itself, keeps the place. - Some(g) if g == dragged => Some(dragged), - Some(g) => { - let after_self = order.iter().position(|x| *x == dragged).zip(order.iter().position(|x| *x == g)).is_some_and(|(a, b)| b == a + 1); - if after_self { Some(dragged) } else { Some(g) } - } - None => None, - } -} - -/// Splitter `i` (below group `i`) dragged by `dy`. Like Photoshop, the groups on the side the -/// splitter moves into shrink in turn, nearest first, each down to its minimum (#2573), and the -/// group on the other side grows by what they gave. The filler absorbs its share implicitly, -/// so the column height stays the same. -fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32) { - if !dy.is_finite() || dy == 0.0 { - return; - } - let expanded = |k: &usize| heights.get(*k).is_some_and(|(g, _)| !layout.is_collapsed(*g)); - let filler = (0..heights.len()).rev().find(expanded); - let Some(j) = (i + 1..heights.len()).find(expanded) else { return }; - if !expanded(&i) { - return; - } - // Dragging down grows group `i` at the expense of the groups below; dragging up grows the - // next expanded group at the expense of `i` and the groups above it. - let (grow, shrink): (usize, Vec) = - if dy > 0.0 { (i, (j..heights.len()).filter(expanded).collect()) } else { (j, (0..=i).rev().filter(expanded).collect()) }; - // The first drag pins the other groups at the heights they show, so groups still at their - // defaults (which give way to the filler) don't shift while this one is resized. - for (k, (o, oh)) in heights.iter().enumerate() { - if Some(k) != filler && !layout.is_collapsed(*o) { - layout.heights.entry(*o).or_insert(*oh); - } - } - let mut left = dy.abs(); - for k in shrink { - if left <= 0.0 { - break; - } - let Some(&(o, oh)) = heights.get(k) else { continue }; - let give = (oh - o.min_height()).max(0.0).min(left); - left -= give; - if Some(k) != filler { - layout.heights.insert(o, oh - give); - } - } - if let Some(&(o, oh)) = heights.get(grow) - && Some(grow) != filler - { - layout.heights.insert(o, oh + (dy.abs() - left)); - } -} - /// What `prefs.panelLayout` holds: the live layout and open panels. pub(crate) fn snapshot(app: &PhotocraftApp) -> Value { let mut layout = json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "timelineOpen": app.ui.timeline.open, "docking": app.ui.docking, "dockingReturns": app.ui.docking_returns, "dockingCollapsed": app.ui.docking_collapsed}); diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index be8a81a1da..c03c067f6d 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -8,44 +8,6 @@ use super::*; use crate::panel_docking::probe; use crate::theme::ThemeKind; -const ESSENTIALS: [Group; 3] = [Group::Color, Group::Properties, Group::Layers]; - -#[test] -fn last_expanded_group_fills_the_column() { - let l = DockLayout::default(); - let hs = l.heights_for(&ESSENTIALS, 1200.0, 28.0); - assert_eq!(hs[0], (Group::Color, Group::Color.default_height())); - assert_eq!(hs[1], (Group::Properties, Group::Properties.default_height())); - let total: f32 = hs.iter().map(|(_, h)| h).sum::() + 2.0 * GAP; - assert!((total - 1200.0).abs() < 1e-3, "{hs:?}"); - // Collapsed groups shrink to the tab strip; the one above Layers keeps its height. - let mut l = DockLayout::default(); - l.set_collapsed(Group::Color, true); - let hs = l.heights_for(&ESSENTIALS, 1000.0, 28.0); - assert_eq!(hs[0].1, 28.0); - assert_eq!(hs[1].1, Group::Properties.default_height()); - // Layers collapsed: Properties becomes the filler. - l.set_collapsed(Group::Layers, true); - let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0); - assert_eq!(hs[2].1, 28.0); - assert!((hs[1].1 - (800.0 - 56.0 - 2.0 * GAP)).abs() < 1e-3); -} - -#[test] -fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() { - let l = DockLayout::default(); - let hs = l.heights_for(&ESSENTIALS, 400.0, 28.0); - assert_eq!(hs[2].1, Group::Layers.min_height(), "Layers keeps its minimum: {hs:?}"); - assert_eq!(hs[0].1, Group::Color.compact_height(), "the group farthest from Layers gives way last"); - assert!(hs[1].1 < Group::Properties.compact_height()); - for avail in [0.0, -50.0, 1.0, f32::NAN, f32::INFINITY, 1e9] { - for (g, h) in l.heights_for(&ESSENTIALS, avail, 28.0) { - assert!(h.is_finite() && h >= 0.0, "{g:?} at {avail}: {h}"); - } - } - assert!(l.heights_for(&[], 500.0, 28.0).is_empty()); -} - #[test] fn bad_stored_values_are_sanitised() { let mut l = DockLayout { order: vec![Group::Layers, Group::Layers, Group::Color], ..Default::default() }; @@ -64,17 +26,6 @@ fn bad_stored_values_are_sanitised() { assert_eq!(ui.dock, DockLayout::default()); } -#[test] -fn move_group_reorders() { - let mut l = DockLayout::default(); - l.move_group(Group::Layers, Some(Group::Color)); - assert_eq!(l.order()[0], Group::Layers); - l.move_group(Group::Layers, None); - assert_eq!(l.order().last(), Some(&Group::Layers)); - l.move_group(Group::Color, Some(Group::Color)); - assert_eq!(l.order()[0], Group::Color); -} - fn app_with_layers() -> (PhotocraftApp, photocraft_doc::LayerId, photocraft_doc::LayerId) { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); @@ -196,92 +147,6 @@ fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() { assert_eq!(app4.ui.dock, DockLayout::default()); } -/// `resize` on `shown` laid out in a column `avail` tall, then the new layout. -fn resized(l: &mut DockLayout, shown: &[Group], avail: f32, i: usize, dy: f32) -> Vec<(Group, f32)> { - let hs = l.heights_for(shown, avail, 28.0); - resize(l, &hs, i, dy); - l.heights_for(shown, avail, 28.0) -} - -fn total(hs: &[(Group, f32)]) -> f32 { - hs.iter().map(|(_, h)| h).sum::() + GAP * hs.len().saturating_sub(1) as f32 -} - -#[test] -fn dragging_a_splitter_down_pushes_every_group_below_in_turn() { - let shown = [Group::Color, Group::Properties, Group::Navigator, Group::Layers]; - let mut l = DockLayout::default(); - let before = l.heights_for(&shown, 1200.0, 28.0); - // Properties gives way first, then Navigator, then the filler; the column stays 1200. - let hs = resized(&mut l, &shown, 1200.0, 0, 300.0); - assert_eq!(hs[0].1, before[0].1 + 300.0, "{hs:?}"); - assert_eq!(hs[1].1, Group::Properties.min_height()); - assert_eq!(hs[2].1, before[2].1 - (300.0 - (before[1].1 - Group::Properties.min_height()))); - assert_eq!(hs[3].1, before[3].1, "the filler is untouched until the groups above it are at minimum"); - assert!((total(&hs) - 1200.0).abs() < 1e-3); - // Far past the end: everything below is at its minimum and the drag stops there. - let hs = resized(&mut l, &shown, 1200.0, 0, 5000.0); - assert_eq!(hs[1].1, Group::Properties.min_height()); - assert_eq!(hs[2].1, Group::Navigator.min_height()); - assert_eq!(hs[3].1, Group::Layers.min_height()); - assert!((total(&hs) - 1200.0).abs() < 1e-3, "{hs:?}"); - let full = hs[0].1; - assert_eq!(resized(&mut l, &shown, 1200.0, 0, 50.0)[0].1, full); -} - -#[test] -fn dragging_a_splitter_up_pushes_every_group_above_in_turn() { - let shown = [Group::Color, Group::Properties, Group::Navigator, Group::Layers]; - let mut l = DockLayout::default(); - let before = l.heights_for(&shown, 1200.0, 28.0); - // The splitter under Navigator: Navigator shrinks first, then Properties, then Color. - let hs = resized(&mut l, &shown, 1200.0, 2, -200.0); - assert_eq!(hs[2].1, Group::Navigator.min_height()); - assert_eq!(hs[1].1, before[1].1 - (200.0 - (before[2].1 - Group::Navigator.min_height()))); - assert_eq!(hs[0].1, before[0].1); - assert_eq!(hs[3].1, before[3].1 + 200.0, "the filler takes the room"); - let hs = resized(&mut l, &shown, 1200.0, 2, -5000.0); - assert!(hs[..3].iter().all(|(g, h)| *h == g.min_height()), "{hs:?}"); - assert!((total(&hs) - 1200.0).abs() < 1e-3); - // The group grown when the filler isn't next: the splitter under Color grows Properties. - let mut l = DockLayout::default(); - let hs = resized(&mut l, &shown, 1200.0, 0, -500.0); - assert_eq!(hs[0].1, Group::Color.min_height()); - assert_eq!(hs[1].1, before[1].1 + (before[0].1 - Group::Color.min_height())); - assert_eq!(hs[2].1, before[2].1); -} - -#[test] -fn pushing_skips_collapsed_groups_and_respects_the_filler() { - let shown = [Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers]; - let mut l = DockLayout::default(); - l.set_collapsed(Group::Properties, true); - l.set_collapsed(Group::Layers, true); - let before = l.heights_for(&shown, 1200.0, 28.0); - // History is the filler now; Properties keeps its strip and Layers stays collapsed. - let hs = resized(&mut l, &shown, 1200.0, 0, 5000.0); - assert_eq!(hs[1].1, 28.0); - assert_eq!(hs[4].1, 28.0); - assert_eq!(hs[2].1, Group::Navigator.min_height()); - assert_eq!(hs[3].1, Group::History.min_height()); - assert_eq!(hs[0].1, before[0].1 + (before[2].1 - Group::Navigator.min_height()) + (before[3].1 - Group::History.min_height())); - assert!((total(&hs) - 1200.0).abs() < 1e-3); - assert!(!l.heights.contains_key(&Group::History), "the filler's height is never stored"); - // Dragging up from Navigator over the collapsed Properties shrinks Color. - let hs = resized(&mut l, &shown, 1200.0, 2, -5000.0); - assert_eq!(hs[0].1, Group::Color.min_height()); - assert_eq!(hs[1].1, 28.0); - assert_eq!(hs[2].1, Group::Navigator.min_height()); - assert!((total(&hs) - 1200.0).abs() < 1e-3); - // Nothing to resize against, bad deltas and out-of-range splitters are ignored. - let snapshot = l.clone(); - for (i, dy) in [(3, 50.0), (4, 50.0), (9, 50.0), (0, f32::NAN), (0, f32::INFINITY), (0, 0.0)] { - let hs = l.heights_for(&shown, 1200.0, 28.0); - resize(&mut l, &hs, i, dy); - assert_eq!(l, snapshot, "splitter {i} by {dy}"); - } -} - #[test] fn reset_workspace_restores_the_default_layout_and_new_workspaces_keep_theirs() { let (app, _, _) = app_with_layers(); @@ -329,7 +194,7 @@ fn a_single_tab_click_expands_a_collapsed_panel() { fn dragging_a_reordered_tab_onto_another_group_moves_that_panel() { let (mut app, _, _) = app_with_layers(); // Gradients (original tab 2) drawn first: Gradients | Color | Swatches | Patterns. - app.ui.dock.move_tab(Group::Color, 2, Some(0), true); + app.ui.dock.tab_order.insert(Group::Color, vec!["Gradients".into()]); let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium); let tab = |h: &Harness<'static, PhotocraftApp>, panel: &str| probe::tab_rect(&h.ctx, panel).unwrap_or_else(|| panic!("{panel} tab drawn")); assert!(tab(&h, "gradients").left() < tab(&h, "color").left()); @@ -337,7 +202,7 @@ fn dragging_a_reordered_tab_onto_another_group_moves_that_panel() { drag(&mut h, source, destination); - assert!(h.state().ui.docking.is_some(), "moving one native tab promotes to the shared layout"); + assert!(h.state().ui.docking.is_some(), "moving one tab of the default layout customizes the workspace"); let moved = probe::group_of(h.state(), "gradients"); assert!(moved.contains(&"layers".to_owned()), "the dragged Gradients tab joined Layers: {moved:?}"); assert!(!moved.contains(&"color".to_owned()), "Color stayed in its group: {moved:?}"); @@ -660,24 +525,24 @@ fn default_layout_shows_ten_layer_rows_at_900pt() { } } +/// #147: in a tall column the groups above Layers keep their default heights, and heights the +/// user set stay as they are in a shorter one. #[test] fn defaults_give_way_to_layers_but_user_sizes_stay() { - let l = DockLayout::default(); - // A tall column: everyone at their defaults, Layers takes the rest. - let hs = l.heights_for(&ESSENTIALS, 1200.0, 28.0); - assert_eq!(hs[0].1, Group::Color.default_height()); - assert_eq!(hs[1].1, Group::Properties.default_height()); - // A short one: default-sized groups shrink toward their compact heights so Layers keeps - // its preferred height, the one nearest Layers first. - let hs = l.heights_for(&ESSENTIALS, 820.0, 28.0); - assert!(hs[2].1 >= Group::Layers.preferred_fill() - 0.5, "{hs:?}"); - assert!(hs[1].1 < Group::Properties.default_height() && hs[1].1 >= Group::Properties.compact_height(), "{hs:?}"); - // Heights the user dragged to (saved in prefs) are kept as they are. - let mut mine = DockLayout::default(); - mine.heights.insert(Group::Properties, 340.0); - mine.heights.insert(Group::Color, 200.0); - let hs = mine.heights_for(&ESSENTIALS, 800.0, 28.0); - assert_eq!((hs[0].1, hs[1].1), (200.0, 340.0), "{hs:?}"); + let (app, _, _) = app_with_layers(); + let h = harness(app, vec2(1440.0, 1400.0), ThemeKind::ProMedium); + for g in [Group::Color, Group::Properties] { + let height = rect_of(&h, g).height(); + assert!((height - g.default_height()).abs() < 2.0, "{g:?}: {height}"); + } + let (mut app, _, _) = app_with_layers(); + app.ui.dock.heights.insert(Group::Properties, 340.0); + app.ui.dock.heights.insert(Group::Color, 200.0); + let h = harness(app, vec2(1440.0, 800.0), ThemeKind::ProMedium); + for (g, want) in [(Group::Color, 200.0), (Group::Properties, 340.0)] { + let height = rect_of(&h, g).height(); + assert!((height - want).abs() < 2.0, "{g:?}: {height}"); + } } /// #150: Window › Character opens a Character | Paragraph group next to Properties (which @@ -721,83 +586,6 @@ fn window_character_opens_its_own_group_and_keeps_properties() { assert_eq!(moved.order(), vec![Group::Layers, Group::Brushes, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); } -type StripProbe = Option<(Vec<(usize, Rect)>, Rect, Option, Rect)>; - -/// #151: at narrow widths and 2× scale, in every theme, tabs elide or overflow into a chevron -/// and never run under the panel menu button. -#[test] -fn tab_strips_never_overlap_the_menu_button() { - let overlap = |a: Rect, b: Rect| { - let i = a.intersect(b); - i.width() > 0.5 && i.height() > 0.5 - }; - for theme in ThemeKind::ALL { - for scale in [1.0, 2.0] { - for width in [180.0, 250.0, 290.0, 420.0] { - for g in Group::ALL { - let tabs = g.tabs(is_pro(theme)); - for sel in 0..tabs.len() { - let mut h = Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).build_ui_state( - move |ui, out: &mut StripProbe| { - if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { - return; - } - let mut s = sel; - let r = crate::widgets::card_ex(ui, "t", tabs, &mut s, false, false, |ui, _| { - ui.label("body"); - }); - *out = Some((r.tabs, r.menu.rect, r.chevron, ui.max_rect())); - }, - None, - ); - PhotocraftApp::setup_context(&h.ctx, theme); - h.run_steps(3); - let (shown, menu, chevron, card) = h.state().clone().unwrap(); - let what = format!("{theme:?} {scale}x {width}pt {g:?} selected {sel}"); - assert!(shown.iter().any(|(i, _)| *i == sel), "{what}: the selected tab is on the strip: {shown:?}"); - for (i, r) in &shown { - assert!(!overlap(*r, menu), "{what}: tab {i} {r:?} runs under the menu {menu:?}"); - assert!(r.left() >= card.left() - 0.5 && r.right() <= card.right() + 0.5, "{what}: tab {i} outside the card"); - } - for w in shown.windows(2) { - assert!(!overlap(w[0].1, w[1].1), "{what}: tabs overlap"); - } - if let Some(c) = chevron { - assert!(!overlap(c, menu) && shown.iter().all(|(_, r)| !overlap(*r, c)), "{what}: chevron overlaps"); - } else { - assert_eq!(shown.len(), tabs.len(), "{what}: a tab went missing without a chevron"); - } - } - } - } - } - } -} - -/// #151: a card strip too narrow for its tabs still offers the hidden ones in its chevron menu. -#[test] -fn the_chevron_menu_switches_tabs() { - let mut h = Harness::builder().with_size(vec2(150.0, 200.0)).build_ui_state( - |ui, sel: &mut usize| { - if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { - return; - } - let _ = crate::widgets::card_ex(ui, "color", &["Color", "Swatches", "Gradients", "Patterns"], sel, false, false, |ui, _| { - ui.label("body"); - }); - }, - 0usize, - ); - PhotocraftApp::setup_context(&h.ctx, ThemeKind::ProMedium); - h.run_steps(3); - use egui_kittest::kittest::Queryable; - h.get_by_label("More panels").click(); - h.run_steps(3); - h.get_by_label("Patterns").click(); - h.run_steps(3); - assert_eq!(*h.state(), 3, "Patterns chosen from the chevron menu"); -} - #[test] fn tab_hides_all_panels_and_shift_tab_only_the_dock() { // #1313: Photoshop's Tab hides the Tools panel, the options bar and the panel dock (Tab again @@ -924,22 +712,13 @@ fn click(h: &mut Harness<'static, PhotocraftApp>, p: Pos2) { } #[test] -fn move_tab_reorders_by_name_and_bad_saved_orders_fall_back() { +fn tab_order_is_by_name_and_bad_saved_orders_fall_back() { let mut l = DockLayout::default(); let names = |l: &DockLayout, g, pro| l.visible_tabs(g, pro).into_iter().map(|(_, n)| n).collect::>(); - l.move_tab(Group::Layers, 2, Some(0), true); + l.tab_order.insert(Group::Layers, vec!["Paths".into()]); assert_eq!(l.visible_tabs(Group::Layers, true), vec![(2, "Paths"), (0, "Layers"), (1, "Channels")]); - l.move_tab(Group::Layers, 2, None, true); - assert_eq!(names(&l, Group::Layers, true), ["Layers", "Channels", "Paths"]); - assert!(l.tab_order.is_empty(), "the default order is stored as nothing"); - // Out-of-range tabs and moves onto themselves change nothing; an unknown `before` is last. - for (tab, before) in [(9, Some(0)), (usize::MAX, None), (1, Some(1)), (2, Some(9))] { - let mut m = l.clone(); - m.move_tab(Group::Layers, tab, before, true); - assert_eq!(names(&m, Group::Layers, true), ["Layers", "Channels", "Paths"], "{tab} {before:?}"); - } // Names, not positions: the order survives the Pro/Studio swap of Color and Swatches. - l.move_tab(Group::Color, 3, Some(0), true); + l.tab_order.insert(Group::Color, vec!["Patterns".into(), "Color".into()]); assert_eq!(names(&l, Group::Color, true), ["Patterns", "Color", "Swatches", "Gradients"]); assert_eq!(names(&l, Group::Color, false), ["Patterns", "Color", "Swatches", "Gradients"]); // Hidden tabs keep their place for when they come back. diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index afc2e4bd1b..b2cd933235 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -120,31 +120,6 @@ fn docked(layout: &Layout) -> Layout { Layout { root: layout.root.clone(), floating: Vec::new() } } -#[derive(Clone, Default)] -struct LegacyDrag { - generation: u64, - workspace: Option, - tabs: crate::state::DockTabs, - returns: Returns, -} - -fn cancel_legacy_drag(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { - let key = area_id().with("legacy-origin"); - let previous = ctx.data_mut(|data| data.remove_temp::(key)); - if let Some(previous) = previous { - craft_ui::docking::cancel_drag::(ctx, area_id()); - if previous.generation != app.ui.docking_generation { - return false; - } - app.ui.dock_tabs = previous.tabs; - app.ui.docking = previous.workspace; - app.ui.docking_returns = previous.returns; - true - } else { - false - } -} - pub(crate) fn normalize(raw: &str) -> Option<&'static str> { PANELS.iter().find(|(id, label, _)| id.eq_ignore_ascii_case(raw.trim()) || label.eq_ignore_ascii_case(raw.trim())).map(|p| p.0) } @@ -553,23 +528,13 @@ fn app_owned(app: &mut PhotocraftApp, command: DockCommand) -> Result(area.with("legacy-origin")).is_some_and(|origin| origin.generation != app.ui.docking_generation)); let changed_workspace = ui.ctx().data_mut(|data| { let previous = data.get_temp::(area.with("generation")); data.insert_temp(area.with("generation"), app.ui.docking_generation); previous.is_some_and(|generation| generation != app.ui.docking_generation) }); - if changed_workspace || stale_origin { - ui.ctx().data_mut(|data| data.insert_temp(area.with("legacy-suppressed"), true)); + if changed_workspace { craft_ui::docking::cancel_drag::(ui.ctx(), area); - ui.ctx().data_mut(|data| data.remove::(area.with("legacy-origin"))); - } - - let cancelled = - ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released())); - if cancelled && cancel_legacy_drag(app, ui.ctx()) { - ui.ctx().data_mut(|data| data.insert_temp(area.with("legacy-suppressed"), true)); } if app.ui.docking.as_ref().is_some_and(|workspace| !valid_workspace(workspace)) { app.ui.docking = None; @@ -653,15 +618,6 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Keys pressed in a floating panel's own OS window run the main window's shortcuts. let forwarded = std::mem::take(&mut output.forwarded_events); crate::shortcuts::handle_forwarded(app, ui.ctx(), forwarded, output.forwarded_modifiers.unwrap_or_default()); - if ui.input(|input| input.pointer.any_released()) { - let moved = output.actions.iter().any(|action| { - matches!(action, Action::Move { .. } | Action::Float { .. } | Action::MovePanel { .. } | Action::MoveGroup { .. } | Action::MoveWindow { .. }) - }); - if !moved && cancel_legacy_drag(app, ui.ctx()) { - return; - } - ui.ctx().data_mut(|data| data.remove::(area.with("legacy-origin"))); - } let mut actions = output.actions; pin_group_heights(ui.ctx(), &layout, &mut actions); for action in actions { @@ -814,9 +770,9 @@ fn set_dock_width(app: &mut PhotocraftApp, width: f32) { } } -/// The width the user dragged the native dock to is in egui's remembered memory under the native -/// dock's panel id. The default layout's column takes it once, after the canvas has been laid -/// out so the Studio Properties card keeps its place. +/// A dock width saved by earlier versions is in egui's remembered memory under the `dock` side +/// panel's id. The default layout's column takes it once, after the canvas has been laid out so +/// the Studio Properties card keeps its place. fn native_dock_width(app: &mut PhotocraftApp, ctx: &egui::Context) { let id = crate::panels::panel_state_id(ctx, "dock"); let Some(state) = ctx.data_mut(|data| data.get_persisted::(id)) else { return }; @@ -849,8 +805,7 @@ fn presented(mut layout: Layout, collapsed: &std::collections::HashSet) -> std::collections::HashSet { let mut out = std::collections::HashSet::new(); let mut pending: Vec<&Node> = layout.root.iter().collect(); @@ -868,45 +823,6 @@ fn fillers(layout: &Layout) -> std::collections::HashSet { out } -pub(crate) fn legacy_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { - let suppressed = area_id().with("legacy-suppressed"); - if ui.input(|input| input.pointer.button_pressed(egui::PointerButton::Primary)) { - ui.ctx().data_mut(|data| data.remove::(suppressed)); - } - if ui.ctx().data(|data| data.get_temp::(suppressed)).unwrap_or(false) || app.session.prefs().workspace_locked { - return; - } - let Some(panel) = normalize(panel) else { return }; - // Native tab reordering and vertical whole-group moves stay in the dock. - // Crossing the dock column horizontally detaches through the shared layout. - let strip = egui::Rect::from_min_max(egui::pos2(ui.max_rect().left(), response.rect.top()), egui::pos2(ui.max_rect().right(), response.rect.bottom())); - if response.dragged() - && ui.input(|input| input.focused && input.pointer.primary_down() && !input.key_pressed(egui::Key::Escape)) - && ui.ctx().pointer_interact_pos().is_some_and(|position| position.x < strip.left() - 24.0 || position.x > strip.right() + 24.0) - && let Ok(mut workspace) = workspace_of(app) - { - let bounds = ui.max_rect(); - let source = egui::Rect::from_min_size( - egui::pos2(bounds.left(), response.rect.top()), - egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)), - ); - if ensure(app, &mut workspace, panel).is_ok() && craft_ui::docking::begin_drag(ui.ctx(), area_id(), panel.to_string(), source) { - ui.ctx().data_mut(|data| { - data.insert_temp( - area_id().with("legacy-origin"), - LegacyDrag { - generation: app.ui.docking_generation, - workspace: app.ui.docking.clone(), - tabs: app.ui.dock_tabs, - returns: app.ui.docking_returns.clone(), - }, - ) - }); - app.ui.docking = Some(workspace); - } - } -} - fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: &str) { let Some((_, label, group)) = PANELS.iter().find(|p| p.0 == id) else { return }; let pro = crate::theme::Tokens::get(ui.ctx()).pro; @@ -1405,30 +1321,3 @@ fn panel_active(layout: &Layout, panel: &str) -> bool { .find(|group| group.panels.iter().any(|id| id == panel)) .is_some_and(|group| group.panels.get(group.active).is_some_and(|id| id == panel)) } - -/// Extra legacy menu choices use the same commands as the customized renderer. -pub(crate) fn legacy_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, label: &str) { - let Some(panel) = normalize(label) else { return }; - let locked = app.session.prefs().workspace_locked; - ui.add_enabled_ui(!locked, |ui| { - if ui.button(tl!("Float Panel")).clicked() { - if let Err(error) = app.run("window.panel.float", json!({"panel":panel})) { - app.ui.status = error; - } - ui.close(); - } - ui.menu_button(tl!("Group with"), |ui| { - for &(anchor, label, _) in PANELS { - if anchor != panel && ui.button(tl!(label)).clicked() { - let result = app - .run("window.panel.activate", json!({"panel":anchor})) - .and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor}))); - if let Err(error) = result { - app.ui.status = error; - } - ui.close(); - } - } - }); - }); -} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index e82dc22020..b86d7b2af9 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1944,7 +1944,7 @@ pub fn request_dock_width(ctx: &egui::Context, w: f32) { ctx.data_mut(|d| d.insert_temp(dock_width_id(), w)); } -/// One dock group's tab content; `dock` bounds it and scrolls it when it's taller. +/// One dock group's tab content; `panel_docking` bounds it and scrolls it when it's taller. pub(crate) fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate::dock::Group, tab: usize) { use crate::dock::Group; let pro = Tokens::get(ui.ctx()).pro; @@ -4022,11 +4022,11 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } drag = grip.drag_delta(); - let strip = crate::tab_strip::pill_tabs(ui, card_id.with("tabs"), bar, &tabs, &mut sel, false); + let context = crate::tab_strip::pill_tabs(ui, card_id.with("tabs"), bar, &tabs, &mut sel); if let Some(&picked) = indices.get(sel) { app.ui.dock_tabs.properties = picked; } - match strip.context { + match context { Some(crate::tab_strip::TabContextAction::Close(i)) => { if let Some(&tab) = indices.get(i) { crate::dock::close_tab(app, group, tab, false); diff --git a/crates/ui-egui/src/tab_strip.rs b/crates/ui-egui/src/tab_strip.rs index 9148daec44..728486e973 100644 --- a/crates/ui-egui/src/tab_strip.rs +++ b/crates/ui-egui/src/tab_strip.rs @@ -1,10 +1,9 @@ -//! Dock tab strips that fit any width (#151), like Photoshop's: when the tabs don't fit beside -//! the panel menu button they shrink, eliding their labels with '…', and once they are at their -//! minimum width the ones that still don't fit move into a » chevron menu at the end of the -//! strip. The selected tab always stays on the strip, and no tab ever runs under the menu button. -//! On a reorderable strip a tab dragged along the strip moves to where it is dropped (#2272). +//! Tab strips that fit any width (#151): when the tabs don't fit beside the panel menu button +//! they shrink, eliding their labels with '…', and once they are at their minimum width the ones +//! that still don't fit move into a » chevron menu at the end of the strip. The selected tab +//! always stays on the strip, and no tab ever runs under the menu button. -use egui::{CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use crate::theme::{self, Tokens}; @@ -30,26 +29,6 @@ pub enum TabContextAction { CloseGroup, } -/// What a strip reported this frame. -pub struct StripOut { - /// A tab was activated, including a choice from the overflow menu. - pub clicked: bool, - pub double_clicked: bool, - pub context: Option, - /// Rects of the tabs on the strip, `(tab index, rect)`. - pub tabs: Vec<(usize, Rect)>, - pub responses: Vec<(usize, Response)>, - /// The » overflow button, when some tabs didn't fit. - pub chevron: Option, - /// A tab was dragged along the strip and dropped: `(tab, before)`, draw `tab` just before - /// tab `before` (`None`: last). - pub reorder: Option<(usize, Option)>, - /// The index of a tab being dragged away from this strip. - pub dragging_out: Option, - /// The index of a tab whose drag ended away from this strip. - pub dropped_out: Option, -} - /// Width of the » overflow button. pub const CHEVRON_W: f32 = 18.0; @@ -93,10 +72,9 @@ fn tabs_in( font: egui::FontId, pad: f32, min_w: f32, - reorderable: bool, active: impl Fn(usize) -> bool, mut paint_tab: impl FnMut(&Ui, Rect, usize, &str, std::sync::Arc, &Response, bool), -) -> StripOut { +) -> Option { let t = Tokens::get(ui.ctx()); // Panel names are English keys; draw them in the UI language. let names: Vec<&str> = panel_ids.iter().map(|n| tl!(n)).collect(); @@ -104,20 +82,7 @@ fn tabs_in( let natural: Vec = translated_tabs.iter().map(|n| ui.painter().layout_no_wrap((*n).to_owned(), font.clone(), t.text).size().x + pad).collect(); let f = fit(&natural, *selected, area.width(), min_w, CHEVRON_W); let mut x = area.left(); - let mut out = StripOut { - clicked: false, - double_clicked: false, - context: None, - tabs: Vec::with_capacity(f.shown.len()), - responses: Vec::with_capacity(f.shown.len()), - chevron: None, - reorder: None, - dragging_out: None, - dropped_out: None, - }; - // Without reordering, drags on a tab fall through to the strip behind it. - let sense = if reorderable { Sense::click_and_drag() } else { Sense::click() }; - let mut drag: Option<(usize, bool)> = None; + let mut context = None; for &(i, w) in &f.shown { let Some(name) = translated_tabs.get(i) else { continue }; let panel_id = panel_ids[i]; @@ -125,39 +90,27 @@ fn tabs_in( // The padding gives way (down to a third) before the label is cut. let galley = elided(ui, name, font.clone(), t.text, (w - pad / 3.0).max(1.0)); let cut = galley.size().x + pad + 0.5 < natural.get(i).copied().unwrap_or(0.0) && galley.size().x + pad / 3.0 >= w - 0.5; - let resp = craft_ui::tabs::Tab::new(id.with(("tab", i)), name, i == *selected).sense(sense).focus_stroke(Stroke::new(1.0, t.accent)).show_at( + let resp = craft_ui::tabs::Tab::new(id.with(("tab", i)), name, i == *selected).sense(Sense::click()).focus_stroke(Stroke::new(1.0, t.accent)).show_at( ui, r, |ui, resp| paint_tab(ui, r, i, panel_id, galley, resp, active(i)), ); let resp = if cut { resp.on_hover_text(*name) } else { resp }; - if resp.drag_started() { - *selected = i; - } - if resp.dragged() { - drag = Some((i, false)); - } else if resp.drag_stopped() { - drag = Some((i, true)); - } - out.double_clicked |= resp.double_clicked(); if resp.clicked() { - out.clicked = true; *selected = i; } - // The context menu belongs to the actual tab response, so its normal clicks - // and double-click-to-collapse behavior are not intercepted by an overlay. + // The context menu belongs to the actual tab response, so its clicks are not + // intercepted by an overlay. resp.context_menu(|ui| { if ui.button(tl!("Close")).clicked() { - out.context = Some(TabContextAction::Close(i)); + context = Some(TabContextAction::Close(i)); ui.close(); } if ui.button(tl!("Close Tab Group")).clicked() { - out.context = Some(TabContextAction::CloseGroup); + context = Some(TabContextAction::CloseGroup); ui.close(); } }); - out.tabs.push((i, r)); - out.responses.push((i, resp)); x = r.right(); } if !f.overflow.is_empty() && f.overflow_width > 0.0 { @@ -165,82 +118,15 @@ fn tabs_in( let mut picked = None; overflow_button(ui, id.with("tab-overflow"), r, tl!("More panels"), translated_tabs, &f.overflow, None, &mut picked); if let Some(i) = picked { - out.clicked = true; *selected = i; } - out.chevron = Some(r); } - if let Some((tab, released)) = drag - && let Some(p) = ui.ctx().pointer_interact_pos().or_else(|| ui.ctx().pointer_latest_pos()) - { - match drop_slot(&out.tabs, area, p) { - Some(slot) => { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - let before = match out.tabs.get(slot) { - Some((b, _)) => Some(*b), - // After the last tab on the strip: before the first overflowing one, if any. - None => out.tabs.last().map(|(l, _)| l + 1).filter(|b| *b < panel_ids.len()), - }; - if released { - out.reorder = Some((tab, before)); - } else if let Some((_, r)) = out.tabs.get(slot.min(out.tabs.len().saturating_sub(1))) { - crate::widgets::drop_line(ui, *r, slot >= out.tabs.len(), true, &t); - } - } - None if released => out.dropped_out = Some(tab), - None => out.dragging_out = Some(tab), - } - } - out + context } -/// Where a tab dragged to `p` lands: before the `n`th tab on the strip (`tabs.len()`: after the -/// last), or `None` once the pointer has left the strip. -pub fn drop_slot(tabs: &[(usize, Rect)], area: Rect, p: egui::Pos2) -> Option { - if !area.expand2(vec2(24.0, area.height())).contains(p) { - return None; - } - Some(tabs.iter().filter(|(_, r)| r.center().x < p.x).count()) -} - -/// Photoshop-grammar strip (Pro themes): flat tabs on the dark strip; `menu_left` is where the -/// panel menu button starts. -#[allow(clippy::too_many_arguments)] -pub fn pro_tabs(ui: &mut Ui, id: egui::Id, strip: Rect, menu_left: f32, tabs: &[&str], selected: &mut usize, collapsed: bool, reorderable: bool) -> StripOut { - let t = Tokens::get(ui.ctx()); - let area = Rect::from_min_max(strip.min, pos2(menu_left.max(strip.left()), strip.bottom())); - let sel = *selected; - tabs_in( - ui, - id, - area, - tabs, - selected, - egui::FontId::proportional(11.5), - 22.0, - 40.0, - reorderable, - |i| i == sel && !collapsed, - |ui, r, i, _, galley, resp, active| { - if active { - ui.painter().rect_filled(r, CornerRadius { nw: if i == 0 { 3 } else { 0 }, ne: 0, sw: 0, se: 0 }, t.card); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4)); - } - let color = if active { - t.text - } else if resp.hovered() { - t.text_dim - } else { - t.text_faint - }; - ui.painter().with_clip_rect(r.intersect(ui.clip_rect())).galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color); - }, - ) -} - -/// Studio strip: pill tabs in `area` (the row minus the menu button). -pub fn pill_tabs(ui: &mut Ui, id: egui::Id, area: Rect, tabs: &[&str], selected: &mut usize, reorderable: bool) -> StripOut { +/// Studio strip: pill tabs in `area` (the row minus the menu button). Returns the action chosen +/// from a tab's context menu. +pub fn pill_tabs(ui: &mut Ui, id: egui::Id, area: Rect, tabs: &[&str], selected: &mut usize) -> Option { let t = Tokens::get(ui.ctx()); let sel = *selected; tabs_in( @@ -252,7 +138,6 @@ pub fn pill_tabs(ui: &mut Ui, id: egui::Id, area: Rect, tabs: &[&str], selected: theme::medium(12.5), 20.0, 44.0, - reorderable, |i| i == sel, |ui, r, _, _, galley, resp, active| { // 2 pt between pills, as the old horizontal layout had. @@ -322,40 +207,6 @@ mod tests { } } - #[test] - fn tiny_panel_strips_keep_tabs_and_overflow_inside_the_available_area() { - let ctx = egui::Context::default(); - crate::PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro); - for width in [0.0, 1.0, 5.0, 18.0, 19.0, 80.0, 180.0, 300.0] { - let area = Rect::from_min_size(pos2(20.0, 20.0), vec2(width, 26.0)); - let mut selected = 3; - let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { - let out = pro_tabs( - ui, - ui.scope_id().with("tiny-tabs"), - area, - area.right(), - &["Color", "Swatches", "Gradients", "Patterns"], - &mut selected, - false, - false, - ); - assert!(out.tabs.iter().any(|(i, _)| *i == 3)); - for (_, rect) in &out.tabs { - assert!(rect.left() >= area.left() && rect.right() <= area.right() + 0.001, "{width}: {rect:?}"); - } - if let Some(chevron) = out.chevron { - assert!(chevron.left() >= area.left() && chevron.right() <= area.right() + 0.001, "{width}: {chevron:?}"); - } - }); - output.textures_delta.clear(); - assert_eq!(selected, 3); - for shape in output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Text(_))) { - assert!(shape.clip_rect.right() <= area.right() + 0.001, "{width}: text is clipped to its tab"); - } - } - } - #[test] fn overflow_icon_and_hover_paint_stay_inside_a_tiny_button() { for scale in [1.0, 2.0] { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fca70b652b..9becc29532 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -61,102 +61,6 @@ pub fn surface(ui: &Ui, rect: Rect, fill: Color32, raised: bool) { } } -/// A dock card: rounded container with a header of pill tabs and optional trailing actions. -/// Returns the index of the selected tab. -pub fn card(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: impl FnOnce(&mut Ui, usize)) { - let _ = card_ex(ui, id, tabs, selected, false, false, body); -} - -/// What happened on a card's tab strip this frame (see [`card_ex`]). -pub struct CardResponse { - /// The tab strip background: drag to move the group, double-click to collapse it. - pub strip: Response, - /// The panel menu button (hamburger in Pro, ellipsis in Studio). - pub menu: Response, - /// A tab was clicked or chosen from the overflow menu. - pub tab_clicked: bool, - /// A tab was double-clicked (Photoshop collapses the group). - pub tab_double_clicked: bool, - /// Right-click action selected for a visible tab. - pub tab_context: Option, - /// Rects of the tabs on the strip, `(tab index, rect)`; tabs that don't fit are in the - /// chevron menu instead (#151). - pub tabs: Vec<(usize, Rect)>, - pub responses: Vec<(usize, Response)>, - /// The » overflow button, when some tabs didn't fit. - pub chevron: Option, - /// A tab dragged along the strip was dropped: `(tab, before)` (#2272). - pub tab_reorder: Option<(usize, Option)>, - /// The index of a tab being dragged away from the strip. - pub tab_dragging_out: Option, - /// The index of a tab whose drag ended away from the strip. - pub tab_dropped_out: Option, -} - -impl CardResponse { - fn new(strip: Response, menu: Response, tabs: crate::tab_strip::StripOut) -> Self { - CardResponse { - strip, - menu, - tab_clicked: tabs.clicked, - tab_double_clicked: tabs.double_clicked, - tab_context: tabs.context, - tabs: tabs.tabs, - responses: tabs.responses, - chevron: tabs.chevron, - tab_reorder: tabs.reorder, - tab_dragging_out: tabs.dragging_out, - tab_dropped_out: tabs.dropped_out, - } - } -} - -/// [`card`] that can be collapsed to its tab strip and reports strip and menu interactions. -/// With `reorderable`, tabs can be dragged along the strip (reported, not applied). -#[allow(clippy::too_many_arguments)] -pub fn card_ex( - ui: &mut Ui, - id: &str, - tabs: &[&str], - selected: &mut usize, - collapsed: bool, - reorderable: bool, - body: impl FnOnce(&mut Ui, usize), -) -> CardResponse { - let t = Tokens::get(ui.ctx()); - if t.pro { - return pro_panel(ui, id, tabs, selected, collapsed, reorderable, body); - } - let m = body_margin(false); - let frame = egui::Frame::NONE - .fill(t.card) - .stroke(Stroke::new(1.0, t.card_border)) - .corner_radius(CornerRadius::same(t.radius as u8)) - .inner_margin(egui::Margin { bottom: if collapsed { 6 } else { m.bottom }, ..m }); - let out = frame - .show(ui, |ui| { - ui.set_width(ui.available_width()); - // Registered before the tabs so they keep their clicks; drags fall through to it. - let strip_rect = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 22.0)); - let strip = ui.interact(strip_rect, ui.scope_id().with((id, "strip")), Sense::click_and_drag()); - // Pill tabs left of the menu button; they elide or overflow into a chevron (#151). - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover()); - let tip = crate::i18n::fmt(tl!("{name} options"), &[("name", tl!(tabs.get(*selected).copied().unwrap_or(id)))]); - let menu_rect = Rect::from_min_max(pos2(row.right() - 22.0, row.top() + 1.0), pos2(row.right(), row.bottom() - 1.0)); - let menu = crate::icons::button(&mut ui.new_child(egui::UiBuilder::new().max_rect(menu_rect)), "ellipsis", 22.0, false, &tip); - let area = Rect::from_min_max(row.min, pos2((menu_rect.left() - 4.0).max(row.left()), row.bottom())); - let tabs_out = crate::tab_strip::pill_tabs(ui, ui.scope_id().with((id, "tabs")), area, tabs, selected, reorderable); - if !collapsed { - ui.add_space(6.0); - body(ui, *selected); - } - CardResponse::new(strip, menu, tabs_out) - }) - .inner; - ui.add_space(6.0); - out -} - /// Inner margin of a dock panel's body: a Pro panel's, or a Studio card's below its tab strip. pub(crate) fn body_margin(pro: bool) -> egui::Margin { if pro { egui::Margin::same(8) } else { egui::Margin { left: 8, right: 8, top: 6, bottom: 10 } } @@ -190,44 +94,6 @@ pub fn panel_footer(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { add(&mut bar_ui) } -/// Photoshop-grammar panel group: dark tab strip with flat tabs, flat body, hamburger menu. -fn pro_panel( - ui: &mut Ui, - id: &str, - tabs: &[&str], - selected: &mut usize, - collapsed: bool, - reorderable: bool, - body: impl FnOnce(&mut Ui, usize), -) -> CardResponse { - let t = Tokens::get(ui.ctx()); - let width = ui.available_width(); - // Tab strip. Its background senses drags (move the group) and double-clicks (collapse); - // the tabs, registered after it, keep their clicks. - let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover()); - let strip_resp = ui.interact(strip, ui.scope_id().with((id, "strip")), Sense::click_and_drag()); - let rounding = if collapsed { CornerRadius::same(3) } else { CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 } }; - ui.painter().rect_filled(strip, rounding, t.tab_strip); - // Panel menu (hamburger); the tabs stay left of it, eliding or overflowing (#151). - let menu = Rect::from_center_size(pos2(strip.right() - 14.0, strip.center().y), vec2(20.0, 18.0)); - let tabs_out = crate::tab_strip::pro_tabs(ui, ui.scope_id().with((id, "tabs")), strip, menu.left(), tabs, selected, collapsed, reorderable); - let mresp = ui.interact(menu, ui.scope_id().with((id, "menu")), Sense::click()); - let c = if mresp.hovered() { t.text } else { t.text_faint }; - for k in 0..3 { - let y = menu.center().y - 3.5 + k as f32 * 3.5; - ui.painter().line_segment([pos2(menu.center().x - 5.0, y), pos2(menu.center().x + 5.0, y)], Stroke::new(1.0, c)); - } - // Body. - if !collapsed { - egui::Frame::NONE.fill(t.card).corner_radius(CornerRadius { nw: 0, ne: 0, sw: 3, se: 3 }).inner_margin(body_margin(true)).show(ui, |ui| { - ui.set_width(width - 16.0); - body(ui, *selected); - }); - } - ui.add_space(2.0); - CardResponse::new(strip_resp, mresp, tabs_out) -} - /// The panel menu button (hamburger in Pro, ellipsis in Studio), matching the dock and cards. pub fn panel_menu_button(ui: &mut Ui) -> Response { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/tests/it/workspace_presets.rs b/crates/ui-egui/tests/it/workspace_presets.rs index 1e6fc335e3..a26006f328 100644 --- a/crates/ui-egui/tests/it/workspace_presets.rs +++ b/crates/ui-egui/tests/it/workspace_presets.rs @@ -63,7 +63,7 @@ fn presets_select_expected_panels_and_reset_every_dock_group() { app.ui.panels.character = !app.ui.panels.character; app.ui.panels.navigator = !app.ui.panels.navigator; app.ui.timeline.open = !timeline; - app.ui.dock.move_group(Group::Layers, Some(Group::Color)); + app.ui.dock.order = vec![Group::Layers, Group::Color]; app.ui.dock.set_collapsed(Group::Layers, true); app.ui.dock.heights.insert(Group::Properties, 99.0); app.ui.dock_tabs.color = 2; @@ -131,7 +131,7 @@ fn custom_workspaces_restore_timeline_visibility_and_dock_after_presets() { let mut app = app(); for (name, suffix) in [("Custom still", "pixelArt"), ("Custom motion", "motion")] { select(&mut app, suffix); - app.ui.dock.move_group(Group::Navigator, Some(Group::Color)); + app.ui.dock.order = vec![Group::Navigator, Group::Color]; app.ui.dock_tabs.color = 1; app.ui.dock.set_collapsed(Group::Properties, true); let expected = layout(&app);