Toolbar: Studio themes draw round colour chips with Switch Colors below (#2002)

* Toolbar: Studio themes draw large round colour chips with Switch Colors right below

* Toolbar: round chips keep Default Colors, follow Tokens::round_chips, lie side by side in two columns

* Theme: Solarized Dark and Adwaita use the Studio round colour chips

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Luniiya 2026-10-10 03:14:37 +02:00 committed by GitHub
parent cc584d9aff
commit 782bcb67fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 193 additions and 53 deletions

View File

@ -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() {

View File

@ -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),

View File

@ -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.