diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 8e5b7284f8..ed93237ce8 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -82,7 +82,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let sections = visible_sections(&app.session.prefs().toolbar.hidden); // Two columns when the header chevron asks for them, or when one column doesn't fit. let slots: usize = sections.iter().map(Vec::len).sum(); - let double = app.ui.panels.toolbar_double || toolbar_needs_double(slots, sections.len(), bx, t.pro, ui.available_rect_before_wrap().height()); + let double = + app.ui.panels.toolbar_double || toolbar_needs_double(slots, sections.len(), bx, t.pro, t.round_chips, ui.available_rect_before_wrap().height()); let w = if double { w1 + bx + 2.0 } else { w1 }; egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))).show( ui, @@ -245,7 +246,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); } ui.add_space(if t.pro { 8.0 } else { 14.0 }); - color_chips(app, ui); + color_chips(app, ui, double); if t.pro { ui.add_space(8.0); let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); @@ -286,13 +287,13 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// Does the toolbar need two columns? Height of one column (header, tool slots, Edit Toolbar, /// colour chips, Quick Mask and Screen Mode) against the height the toolbar gets. -pub fn toolbar_needs_double(slots: usize, sections: usize, bx: f32, pro: bool, avail_h: f32) -> bool { +pub fn toolbar_needs_double(slots: usize, sections: usize, bx: f32, pro: bool, studio: bool, avail_h: f32) -> bool { let pitch = bx + 3.0; let needed = if pro { // margins + header + slots + "…" + gap + colour chips + gap + 2 buttons - 16.0 + 21.0 + (slots + 1) as f32 * pitch + 8.0 + chips_height(true) + 8.0 + 2.0 * pitch + 16.0 + 21.0 + (slots + 1) as f32 * pitch + 8.0 + chips_height(true, false, false) + 8.0 + 2.0 * pitch } else { - 16.0 + slots as f32 * pitch + sections.saturating_sub(1) as f32 * 12.0 + 14.0 + chips_height(false) + 16.0 + slots as f32 * pitch + sections.saturating_sub(1) as f32 * 12.0 + 14.0 + chips_height(false, studio, false) }; needed > avail_h } @@ -309,8 +310,21 @@ fn chip_metrics(pro: bool) -> (f32, f32) { /// Height of the default-colours and swap icons over the chips. const CHIP_ICON: f32 = 13.0; +/// Studio themes: round chip diameter and the background chip's vertical offset (points). +const STUDIO_CHIP: (f32, f32) = (30.0, 19.0); +/// Room under the round chips for the Default Colors and Switch Colors buttons. +const STUDIO_ROW: f32 = 15.0; +/// Room above the stacked round chips so Default Colors, at their top-right, clears them. +const STUDIO_TOP: f32 = 6.0; + /// Height of the colour chips block. -fn chips_height(pro: bool) -> f32 { +fn chips_height(pro: bool, studio: bool, wide: bool) -> f32 { + if studio && wide { + return STUDIO_CHIP.0 + STUDIO_ROW; + } + if studio { + return STUDIO_TOP + STUDIO_CHIP.0 + STUDIO_CHIP.1 + STUDIO_ROW; + } let (chip, step) = chip_metrics(pro); CHIP_ICON + 3.0 + chip + step } @@ -318,28 +332,52 @@ fn chips_height(pro: bool) -> f32 { /// Photoshop's foreground / background colour chips: the Default Colors (D) and Switch Colors (X) /// icons above them, the foreground chip over the background one, all centred in the toolbar /// column and kept inside it. -fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui) { +fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) { let t = Tokens::get(ui.ctx()); - let (chip, step) = chip_metrics(t.pro); - let group = chip + step; + // Studio themes: round chips stacked vertically, the controls below them. + let studio = t.round_chips; + let (chip, step) = if studio { STUDIO_CHIP } else { chip_metrics(t.pro) }; + // With two tool columns (a short window) the round chips lie side by side, which saves height. + let wide = studio && two_columns; + let group = if studio && !wide { chip } else { chip + step }; let w = ui.available_width().max(group); - let (rect, _) = ui.allocate_exact_size(vec2(w, chips_height(t.pro)), Sense::hover()); + let (rect, _) = ui.allocate_exact_size(vec2(w, chips_height(t.pro, studio, wide)), Sense::hover()); let left = (rect.left() + (w - group) / 2.0).round(); - let fg = Rect::from_min_size(pos2(left, rect.top() + CHIP_ICON + 3.0), vec2(chip, chip)); - let bg = fg.translate(vec2(step, step)); + let (fg, bg) = if wide { + let fg = Rect::from_min_size(pos2(left, rect.top()), vec2(chip, chip)); + (fg, fg.translate(vec2(step, 0.0))) + } else if studio { + let fg = Rect::from_min_size(pos2(left, rect.top() + STUDIO_TOP), vec2(chip, chip)); + (fg, fg.translate(vec2(0.0, step))) + } else { + let fg = Rect::from_min_size(pos2(left, rect.top() + CHIP_ICON + 3.0), vec2(chip, chip)); + (fg, fg.translate(vec2(step, step))) + }; let radius = t.radius_sm.min(3.0); let p = ui.painter(); - let frame = |r: Rect| { - // A light line inside a dark one, so any colour reads against the toolbar. - p.rect_stroke(r, radius, Stroke::new(1.0, t.text.gamma_multiply(0.9)), StrokeKind::Inside); - p.rect_stroke(r, radius, Stroke::new(1.0, t.field_border), StrokeKind::Outside); - }; - p.rect_filled(bg, radius, c32(app.session.tools.background)); - frame(bg); - // The foreground chip sits on the background one, a toolbar-coloured gap between them. - p.rect_filled(fg.expand(2.0), radius + 2.0, t.chrome); - p.rect_filled(fg, radius, c32(app.session.tools.foreground)); - frame(fg); + if studio { + let dot = |r: Rect, color: Color32| { + p.circle_filled(r.center(), r.width() / 2.0, color); + // A light line inside a dark one, so any colour reads against the toolbar. + p.circle_stroke(r.center(), r.width() / 2.0 - 0.5, Stroke::new(1.0, t.text.gamma_multiply(0.9))); + p.circle_stroke(r.center(), r.width() / 2.0 + 0.5, Stroke::new(1.0, t.field_border)); + }; + dot(bg, c32(app.session.tools.background)); + // The foreground chip sits on the background one, a toolbar-coloured gap between them. + p.circle_filled(fg.center(), fg.width() / 2.0 + 2.0, t.chrome); + dot(fg, c32(app.session.tools.foreground)); + } else { + let frame = |r: Rect| { + // A light line inside a dark one, so any colour reads against the toolbar. + p.rect_stroke(r, radius, Stroke::new(1.0, t.text.gamma_multiply(0.9)), StrokeKind::Inside); + p.rect_stroke(r, radius, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + }; + p.rect_filled(bg, radius, c32(app.session.tools.background)); + frame(bg); + p.rect_filled(fg.expand(2.0), radius + 2.0, t.chrome); + p.rect_filled(fg, radius, c32(app.session.tools.foreground)); + frame(fg); + } // Photoshop: clicking a chip opens the Color Picker for that colour. let bg_resp = ui.interact(bg, ui.id().with("bgchip"), Sense::click()); let fg_resp = ui.interact(fg, ui.id().with("fgchip"), Sense::click()); @@ -349,47 +387,96 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::color_picker_ui::open(app, "background"); } // Default Colors at the left, Switch Colors at the right, over the chips. - let icon = |x: f32| Rect::from_min_size(pos2(x, rect.top()), vec2(CHIP_ICON, CHIP_ICON)); - let (dr, sr) = (icon(left), icon(left + group - CHIP_ICON)); - let d = ui.interact(dr, ui.id().with("default-colors"), Sense::click()); + let (dr, sr) = if studio { + // Switch Colors is a curve right under the chips, centred on them so its heads touch + // them. Default Colors never moves it: at the left edge beside it when the chips lie side + // by side, at the block's top-right corner when they are stacked. + let (cx, row) = ((fg.center().x + bg.center().x) / 2.0, fg.bottom().max(bg.bottom()) + 3.5); + let default = if wide { + Rect::from_center_size(pos2(rect.left() + CHIP_ICON / 2.0, row), vec2(CHIP_ICON, CHIP_ICON)) + } else { + // Just high enough that its bottom-left corner clears the foreground chip's outline. + let x = rect.right() - CHIP_ICON; + let (dx, r) = ((x - fg.center().x).max(0.0), fg.width() / 2.0 + 1.0); + let clear = if dx < r { (r * r - dx * dx).sqrt() } else { 0.0 }; + Rect::from_min_size(pos2(x, fg.center().y - clear - CHIP_ICON), vec2(CHIP_ICON, CHIP_ICON)) + }; + (Some(default), Rect::from_center_size(pos2(cx, row), vec2(20.0, 14.0))) + } else { + let icon = |x: f32| Rect::from_min_size(pos2(x, rect.top()), vec2(CHIP_ICON, CHIP_ICON)); + (Some(icon(left)), icon(left + group - CHIP_ICON)) + }; + let d = dr.map(|dr| ui.interact(dr, ui.id().with("default-colors"), Sense::click())); let s = ui.interact(sr, ui.id().with("swap-colors"), Sense::click()); let p = ui.painter(); - for (r, resp) in [(dr, &d), (sr, &s)] { - if resp.hovered() { + for (r, resp) in dr.into_iter().zip(d.iter()).chain([(sr, &s)]) { + if resp.hovered() && !studio { p.rect_filled(r.expand(2.0), radius, t.hover); } } let ink = |resp: &egui::Response| if resp.hovered() { t.text } else { t.icon }; + if studio { + // Switch Colors: a shallow curve hugging the bottom chip, with a filled head at each end. + // It glows on hover instead of getting a button box. + let hot = s.hovered(); + let stroke = Stroke::new(1.5, ink(&s)); + let (c, rad) = (pos2(sr.center().x, sr.center().y - 5.0), 9.0); + let (from, to) = (0.45, std::f32::consts::PI - 0.45); + let arc: Vec<_> = (0..=12) + .map(|i| { + let a = from + (to - from) * i as f32 / 12.0; + c + vec2(rad * a.cos(), rad * a.sin()) + }) + .collect(); + if hot { + p.add(egui::Shape::line(arc.clone(), Stroke::new(5.0, t.text.gamma_multiply(0.18)))); + } + let ends = [(arc[0], from, -1.0_f32), (arc[arc.len() - 1], to, 1.0)]; + p.add(egui::Shape::line(arc, stroke)); + // Each head points along the curve, away from its middle. + for (end, a, sign) in ends { + let dir = vec2(-a.sin(), a.cos()) * sign; + let side = vec2(-dir.y, dir.x); + let tip = end + dir * 2.5; + let base = tip - dir * 5.0; + p.add(egui::Shape::convex_polygon(vec![tip, base + side * 3.0, base - side * 3.0], ink(&s), Stroke::NONE)); + } + } else { + // Switch Colors: a quarter-circle arrow with a head at each end. + let stroke = Stroke::new(1.3, ink(&s)); + let (c, rad) = (pos2(sr.left() + 2.0, sr.bottom() - 1.0), sr.width() - 4.0); + let arc: Vec<_> = (0..=8) + .map(|i| { + let a = std::f32::consts::FRAC_PI_2 * i as f32 / 8.0; + c + vec2(rad * a.sin(), -rad * a.cos()) + }) + .collect(); + let (start, end) = (arc[0], arc[arc.len() - 1]); + p.add(egui::Shape::line(arc, stroke)); + let head = 3.0; + p.line_segment([start, start + vec2(head, -head)], stroke); + p.line_segment([start, start + vec2(head, head)], stroke); + p.line_segment([end, end + vec2(-head, -head)], stroke); + p.line_segment([end, end + vec2(head, -head)], stroke); + } // Default Colors: a small black chip over a small white one. - let small = 7.0; - let (b, w) = (Rect::from_min_size(dr.min + vec2(1.0, 1.0), vec2(small, small)), Rect::from_min_size(dr.min + vec2(5.0, 5.0), vec2(small, small))); - p.rect_filled(w, 1.0, Color32::WHITE); - p.rect_stroke(w, 1.0, Stroke::new(1.0, ink(&d)), StrokeKind::Inside); - p.rect_filled(b.expand(1.0), 1.5, t.chrome); - p.rect_filled(b, 1.0, Color32::BLACK); - p.rect_stroke(b, 1.0, Stroke::new(1.0, ink(&d)), StrokeKind::Inside); - // Switch Colors: a quarter-circle arrow with a head at each end. - let stroke = Stroke::new(1.3, ink(&s)); - let (c, rad) = (pos2(sr.left() + 2.0, sr.bottom() - 1.0), sr.width() - 4.0); - let arc: Vec<_> = (0..=8) - .map(|i| { - let a = std::f32::consts::FRAC_PI_2 * i as f32 / 8.0; - c + vec2(rad * a.sin(), -rad * a.cos()) - }) - .collect(); - let (start, end) = (arc[0], arc[arc.len() - 1]); - p.add(egui::Shape::line(arc, stroke)); - let head = 3.0; - p.line_segment([start, start + vec2(head, -head)], stroke); - p.line_segment([start, start + vec2(head, head)], stroke); - p.line_segment([end, end + vec2(-head, -head)], stroke); - p.line_segment([end, end + vec2(head, -head)], stroke); - d.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Default colours (D)"))); + if let (Some(dr), Some(d)) = (dr, d.as_ref()) { + let small = 7.0; + let (b, w) = (Rect::from_min_size(dr.min + vec2(1.0, 1.0), vec2(small, small)), Rect::from_min_size(dr.min + vec2(5.0, 5.0), vec2(small, small))); + p.rect_filled(w, 1.0, Color32::WHITE); + p.rect_stroke(w, 1.0, Stroke::new(1.0, ink(d)), StrokeKind::Inside); + p.rect_filled(b.expand(1.0), 1.5, t.chrome); + p.rect_filled(b, 1.0, Color32::BLACK); + p.rect_stroke(b, 1.0, Stroke::new(1.0, ink(d)), StrokeKind::Inside); + } + if let Some(d) = &d { + d.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Default colours (D)"))); + } s.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Swap colours (X)"))); if s.on_hover_text(tl!("Swap colours (X)")).clicked() { let _ = app.run("tools.swapColors", json!({})); } - if d.on_hover_text(tl!("Default colours (D)")).clicked() { + if d.is_some_and(|d| d.on_hover_text(tl!("Default colours (D)")).clicked()) { let _ = app.run("tools.defaultColors", json!({})); } } @@ -3162,6 +3249,48 @@ mod color_tests { assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0xff, 0x80, 0x00]); } + /// Themes with `round_chips` (Studio) draw round chips, stacked or side by side in two tool + /// columns, with Switch Colors right under them and Default Colors at the top-right corner + /// (stacked) or the left edge (side by side); both buttons work in either layout. + #[test] + fn studio_color_chips_keep_default_and_swap_buttons_below() { + use egui_kittest::kittest::Queryable; + for (kind, two_columns) in [ + (crate::theme::ThemeKind::Studio, false), + (crate::theme::ThemeKind::StudioLight, false), + (crate::theme::ThemeKind::Studio, true), + (crate::theme::ThemeKind::StudioLight, true), + ] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.tools.foreground = [1.0, 0.0, 0.0, 1.0]; + app.session.tools.background = [0.0, 0.0, 1.0, 1.0]; + let mut h = egui_kittest::Harness::builder().with_size(vec2(80.0, 120.0)).build_ui_state(|ui, app: &mut PhotocraftApp| color_chips(app, ui, two_columns), app); + PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(2); + h.get_by_label("Swap colours (X)").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 1.0, 1.0], "{kind:?}"); + assert_eq!(h.state().session.tools.background, [1.0, 0.0, 0.0, 1.0], "{kind:?}"); + h.get_by_label("Default colours (D)").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 0.0, 1.0], "{kind:?}"); + assert_eq!(h.state().session.tools.background, [1.0, 1.0, 1.0, 1.0], "{kind:?}"); + } + } + + /// The Studio layout (chips stacked, icons below) fits the height the toolbar reserves for + /// the chips, so the two-column check does not change with the theme. + #[test] + fn studio_color_chips_fit_the_reserved_height() { + // Chips, then the row holding the Switch Colors curve. + assert!(STUDIO_CHIP.0 + STUDIO_CHIP.1 + 8.0 + 4.0 <= chips_height(false, true, false)); + // Side by side: the chips plus the same row, and shorter than stacked. + assert!(STUDIO_CHIP.0 + 8.0 + 4.0 <= chips_height(false, true, true)); + assert!(chips_height(false, true, true) < chips_height(false, true, false)); + // The Studio block is taller than Photoshop's, and the two-column check knows it. + assert!(chips_height(false, true, false) > chips_height(false, false, false)); + } + /// An incomplete entry never changes the colour and the field snaps back when focus leaves. #[test] fn color_panel_hex_field_ignores_partial_input() { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index ef8a1c84bc..43d0f87418 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -133,6 +133,8 @@ pub struct Tokens { pub bevel: bool, /// Pro (Photoshop-grammar) layout: tab strips, flat panels, checkboxes, pill buttons. pub pro: bool, + /// Toolbar colour chips are large and round, stacked, with the colour buttons below (Studio). + pub round_chips: bool, /// Panel tab-strip background (Pro). pub tab_strip: Color32, /// Selected list row (layers, history). @@ -208,6 +210,7 @@ impl Tokens { radius_lg: 6.0, bevel: false, pro: true, + round_chips: false, tab_strip: Color32::from_rgb(38, 38, 38), row_selected: Color32::from_rgb(82, 82, 82), histogram_bg: Color32::from_rgb(40, 40, 40), @@ -247,6 +250,7 @@ impl Tokens { radius_lg: 12.0, bevel: false, pro: false, + round_chips: true, tab_strip: Color32::TRANSPARENT, row_selected: Color32::TRANSPARENT, histogram_bg: Color32::from_rgb(14, 14, 15), @@ -286,6 +290,7 @@ impl Tokens { radius_lg: 12.0, bevel: false, pro: false, + round_chips: true, tab_strip: Color32::TRANSPARENT, row_selected: Color32::TRANSPARENT, histogram_bg: Color32::from_gray(40), @@ -325,6 +330,7 @@ impl Tokens { radius_lg: 0.0, bevel: true, pro: false, + round_chips: false, tab_strip: Color32::from_rgb(212, 208, 200), row_selected: Color32::from_rgb(10, 36, 106), histogram_bg: Color32::from_gray(40), @@ -367,6 +373,7 @@ impl Tokens { radius_lg: 12.0, bevel: false, pro: false, + round_chips: true, tab_strip: Color32::TRANSPARENT, row_selected: Color32::TRANSPARENT, histogram_bg: Color32::from_gray(40), @@ -406,6 +413,7 @@ impl Tokens { radius_lg: 12.0, bevel: false, pro: false, + round_chips: true, tab_strip: Color32::TRANSPARENT, row_selected: Color32::TRANSPARENT, histogram_bg: Color32::from_rgb(29, 29, 32), @@ -448,6 +456,7 @@ impl Tokens { radius_lg: 12.0, bevel: false, pro: false, + round_chips: true, tab_strip: Color32::TRANSPARENT, row_selected: Color32::TRANSPARENT, histogram_bg: Color32::from_rgb(0, 43, 54), diff --git a/docs/ui-design.md b/docs/ui-design.md index 78efade9f1..0b0e8ea5fb 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -14,6 +14,8 @@ Switch themes with the sun icon, Window → Theme, or `ui.set {"theme":"classic"}` over the control channel. +The toolbar's foreground and background colour chips follow the `Tokens::round_chips` flag. Pro and Classic leave it off and draw Photoshop's overlapping squares with Default Colors and Switch Colors above them. Studio, Studio Light, Solarized Dark, Adwaita and Adwaita Dark (the Studio-layout themes) turn it on and draw large round chips with a curved Switch Colors arrow centred right under them, its heads touching the chips: stacked vertically in one tool column, with Default Colors at their top-right, and side by side in two tool columns, with Default Colors at the toolbar's left edge. Another non-Pro theme can opt in by setting the flag. + ## Rules - **Colours and radii come from tokens.** Read them with `Tokens::get(ctx)`; never hard-code a colour in a widget.