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);