Compare commits

...
Author SHA1 Message Date
Claude 2c10156bbf Merge #1745 into #1729: keep both i18n blocks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 12:16:08 +00:00
UnemarsandClaude Opus 5.5 ca9a4acaa7 Panel footers: Photoshop's bar along the panel bottom; own mask and adjustment glyphs
The Layers footer hung under an inset hairline: 9 px above the icons, 20 below, and the line
stopped at the body margins. Photoshop's (measured on 25.4) is a bar under a line from edge to
edge of the panel, its buttons centred in the bar's height and laid out from the right.

- widgets::panel_footer draws that bar: a 1 px line across the whole panel, a 30 pt bar that
  runs on over the body's bottom margin to the panel's edge, buttons centred, from the right.
  footer_height is what a filling body leaves for it. Layers, Channels, Paths and History use it,
  all with 26 pt buttons.
- Channels and Paths lay their buttons out from the right too, in Photoshop's order.
- Layers footer glyphs: Lucide's contrast (a stroked half circle inside a circle) read as "(D)"
  at 14 px and square-dot's 1 px dot vanished. New original layer-mask (a frame around a solid
  disc) and adjustment-layer (a circle, one half solid) glyphs replace them; the Kind filter row
  uses the adjustment one too. square-dot is no longer used and is removed.
- Link Layers and the linked-layer row indicator use Lucide's horizontal link-2 chain.
- fx and New Fill or Adjustment Layer get a small triangle: they open menus.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 12:40:54 +02:00
UnemarsandClaude Opus 5.5 18426cd3a0 Color Picker: Lab radios and the gamut / web-colour warnings, as in Photoshop
Measured on Photoshop 25.4's Color Picker:
- L, a and b have radios too. L lays the field out with a across (green to red) and b upwards
  (blue to yellow), its slider is L; a lays b across and L upwards; b lays a across and L
  upwards. Picking keeps the exact Lab values (`Keep::Lab`).
- Beside the new/current swatch: a warning triangle when the colour is out of gamut for printing
  and a cube when it isn't a web colour, each over a chip of the nearest colour that passes; a
  click takes it. "Out of gamut" is the CMYK round trip through the document (or built-in) CMYK
  profile moving the colour by more than ΔE 3: in-gamut colours move by 0.2-0.4 (black 1.4,
  with black-point compensation), out-of-gamut ones by 7-75. #714343 shows only the cube and
  #fd0303 both, as in Photoshop.

The two tooltips are translated in every catalog.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 11:44:40 +02:00
UnemarsandClaude Opus 5.5 818d75278e Toolbar colour chips: centred in the column, Photoshop's layout, rounded
The chips block was 36 points wide in a 30-point column (Pro theme), so it sat off-centre and
ran into the canvas edge. It is now laid out as in Photoshop: the Default Colors (D) and Switch
Colors (X) icons above, the foreground chip over the background one with a toolbar-coloured gap,
each chip framed by a light line inside a dark one, the whole group centred in the column (or
across both columns). Chips use the theme's small radius (3 pt; square in Classic).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 11:44:40 +02:00
28 changed files with 531 additions and 180 deletions

No files matched your search

+3 -2
View File
@@ -18,10 +18,11 @@ next to them.
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
@@ -9,6 +9,6 @@
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="12" r="9" />
<path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" />
</svg>
+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="16" x="2" y="4" rx="2" />
<circle cx="12" cy="12" r="4" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 316 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
<path d="M15 7h2a5 5 0 1 1 0 10h-2" />
<line x1="8" x2="16" y1="12" y2="12" />
</svg>

After

Width:  |  Height:  |  Size: 330 B

+11 -16
View File
@@ -82,7 +82,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let shown_colors = if colors > 1 { colors } else { 0 };
let mut actions: Vec<(String, Value)> = Vec::new();
// The buttons sit in a footer at the panel's bottom, like Photoshop's.
let footer = 34.0;
let footer = widgets::footer_height(ui) + ui.spacing().item_spacing.y;
let fill = ui.available_height() > footer + 60.0;
let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY };
let mut mask_click = None;
@@ -270,30 +270,25 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
});
}
});
ui.add_space(4.0);
widgets::hairline(ui);
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
// Photoshop's order from the left: Load as selection, Save selection, New, Delete.
widgets::panel_footer(ui, |ui| {
let target_ref = match view.target {
ChannelTarget::Alpha(i) => json!(i),
ChannelTarget::Color(k) => json!({ "color": k }),
ChannelTarget::Composite => json!("composite"),
};
if icons::button(ui, "circle-dashed", 26.0, false, tl!("Load channel as selection")).clicked() {
actions.push(("select.loadSelection".into(), json!({ "channel": target_ref })));
if icons::button(ui, "trash", 26.0, false, tl!("Delete current channel")).clicked() {
actions.push(("channel.delete".into(), json!({})));
}
if icons::button(ui, "plus", 26.0, false, tl!("Create new channel")).clicked() {
actions.push(("channel.new".into(), json!({})));
}
if icons::button(ui, "square-dashed", 26.0, false, tl!("Save selection as channel")).clicked() {
actions.push(("select.saveSelection".into(), json!({})));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if icons::button(ui, "trash", 26.0, false, tl!("Delete current channel")).clicked() {
actions.push(("channel.delete".into(), json!({})));
}
if icons::button(ui, "plus", 26.0, false, tl!("Create new channel")).clicked() {
actions.push(("channel.new".into(), json!({})));
}
});
if icons::button(ui, "circle-dashed", 26.0, false, tl!("Load channel as selection")).clicked() {
actions.push(("select.loadSelection".into(), json!({ "channel": target_ref })));
}
});
ctx.data_mut(|d| d.insert_temp(thumbs_id(), drawn));
if let Some(on) = mask_click {
+193 -38
View File
@@ -1,6 +1,6 @@
//! Photoshop's Color Picker (Foreground / Background Color) dialog: a 2D colour field and a slider
//! for the selected component (H, S, B, R, G or B radio), new/current swatches, and HSB, RGB, Lab,
//! CMYK and hex fields. The colour lives in the dialog fields (`color` as `#rrggbb`, plus the HSB
//! for the selected component (H, S, B, R, G, B, L, a or b radio), new/current swatches with the
//! out-of-gamut and web-colour warnings, and HSB, RGB, Lab, CMYK and hex fields. The colour lives in the dialog fields (`color` as `#rrggbb`, plus the HSB
//! floats so hue survives greys), so `ui.dialog.set` drives it; OK runs `tools.setColors`.
//! While it is the top dialog the image is its eyedropper, as in Photoshop: the pointer over the
//! canvas is a pipette, and a click or drag there samples into the new colour ([`sample_at`]).
@@ -15,6 +15,45 @@ use crate::widgets;
/// Radio components, Photoshop order: H, S, B, R, G, B.
pub const MODES: &[(&str, &str)] = &[("h", "H:"), ("s", "S:"), ("v", "B:"), ("r", "R:"), ("g", "G:"), ("b", "B:")];
/// Lab radio components (Photoshop's right-hand column): L, a, b.
pub const LAB_MODES: &[(&str, &str)] = &[("lab_l", "L:"), ("lab_a", "a:"), ("lab_b", "b:")];
/// The Lab colour at field position (x, y) in 0..1 (y down) with the slider at `z`, for a Lab
/// `mode`, as Photoshop 25.4 lays them out: L shows a across and b upwards; a shows b across and L
/// upwards; b shows a across and L upwards (a and b run -128..127, L 0..100).
fn field_lab(mode: &str, z: f32, x: f32, y: f32) -> Option<[f32; 3]> {
let ab = |t: f32| t * 255.0 - 128.0;
match mode {
"lab_l" => Some([z * 100.0, ab(x), ab(1.0 - y)]),
"lab_a" => Some([(1.0 - y) * 100.0, ab(z), ab(x)]),
"lab_b" => Some([(1.0 - y) * 100.0, ab(x), ab(z)]),
_ => None,
}
}
/// Lab → sRGB, clamped to what the field can show.
fn lab_rgb(lab: [f32; 3]) -> [f32; 3] {
photocraft_color::convert::lab_to_srgb(lab).map(|c| c.clamp(0.0, 1.0))
}
/// Colour differences the CMYK round trip leaves inside the printable gamut (ΔE ≤ 0.4, black ≈ 1.4
/// with black-point compensation) stay below this; out-of-gamut colours move far more.
const GAMUT_DE: f32 = 3.0;
/// The nearest printable colour, when `rgb` is outside the CMYK gamut (Photoshop's ⚠ warning).
pub fn out_of_gamut(rgb: [f32; 3]) -> Option<[f32; 3]> {
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk, srgb_to_lab};
let back = cmyk_to_rgb(rgb_to_cmyk(rgb)).map(|c| c.clamp(0.0, 1.0));
let (a, b) = (srgb_to_lab(rgb), srgb_to_lab(back));
let de = ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
(de > GAMUT_DE).then_some(back)
}
/// The nearest web colour (channels in steps of 51), when `rgb` isn't one.
pub fn not_web_safe(rgb: [f32; 3]) -> Option<[f32; 3]> {
let web = rgb.map(|v| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0);
(hex(web) != hex(rgb)).then_some(web)
}
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] {
let h = (h.rem_euclid(360.0)) / 60.0;
@@ -65,6 +104,9 @@ pub fn parse_hex(s: &str) -> Option<[f32; 3]> {
/// The colour at field position (x, y) in 0..1 (y down) for `mode` with the slider at `z` (0..1),
/// given the current HSV/RGB (used for the components the field doesn't vary).
pub fn field_color(mode: &str, z: f32, x: f32, y: f32) -> [f32; 3] {
if let Some(lab) = field_lab(mode, z, x, y) {
return lab_rgb(lab);
}
match mode {
"s" => hsv_to_rgb(x * 360.0, z, 1.0 - y),
"v" => hsv_to_rgb(x * 360.0, 1.0 - y, z),
@@ -76,9 +118,14 @@ pub fn field_color(mode: &str, z: f32, x: f32, y: f32) -> [f32; 3] {
}
/// Field position and slider value of a colour in `mode` (inverse of [`field_color`]).
pub fn locate(mode: &str, hsv: [f32; 3], rgb: [f32; 3]) -> (f32, f32, f32) {
pub fn locate(mode: &str, hsv: [f32; 3], rgb: [f32; 3], lab: [f32; 3]) -> (f32, f32, f32) {
let h = hsv[0] / 360.0;
let ab = |v: f32| ((v + 128.0) / 255.0).clamp(0.0, 1.0);
let l = (lab[0] / 100.0).clamp(0.0, 1.0);
match mode {
"lab_l" => (ab(lab[1]), 1.0 - ab(lab[2]), l),
"lab_a" => (ab(lab[2]), 1.0 - l, ab(lab[1])),
"lab_b" => (ab(lab[1]), 1.0 - l, ab(lab[2])),
"s" => (h, 1.0 - hsv[2], hsv[1]),
"v" => (h, 1.0 - hsv[1], hsv[2]),
"r" => (rgb[2], 1.0 - rgb[1], rgb[0]),
@@ -89,7 +136,10 @@ pub fn locate(mode: &str, hsv: [f32; 3], rgb: [f32; 3]) -> (f32, f32, f32) {
}
/// Colour for slider position `z` (0..1) in `mode`, other components from the current colour.
fn slider_color(mode: &str, z: f32, hsv: [f32; 3], rgb: [f32; 3]) -> [f32; 3] {
fn slider_color(mode: &str, z: f32, hsv: [f32; 3], rgb: [f32; 3], lab: [f32; 3]) -> [f32; 3] {
if let Some(l) = slider_lab(mode, z, lab) {
return lab_rgb(l);
}
match mode {
"s" => hsv_to_rgb(hsv[0], z, hsv[2]),
"v" => hsv_to_rgb(hsv[0], hsv[1], z),
@@ -100,6 +150,17 @@ fn slider_color(mode: &str, z: f32, hsv: [f32; 3], rgb: [f32; 3]) -> [f32; 3] {
}
}
/// The Lab colour with the slider of a Lab `mode` at `z`, the other components kept.
fn slider_lab(mode: &str, z: f32, lab: [f32; 3]) -> Option<[f32; 3]> {
let ab = z * 255.0 - 128.0;
match mode {
"lab_l" => Some([z * 100.0, lab[1], lab[2]]),
"lab_a" => Some([lab[0], ab, lab[2]]),
"lab_b" => Some([lab[0], lab[1], ab]),
_ => None,
}
}
fn c32(c: [f32; 3]) -> Color32 {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
Color32::from_rgb(q(c[0]), q(c[1]), q(c[2]))
@@ -293,8 +354,8 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let t = Tokens::get(ui.ctx());
let mode = f.get("__mode").and_then(Value::as_str).unwrap_or("h").to_string();
let now = current(f);
let (rgb, hsv) = (now.rgb, now.hsv);
let (fx, fy, fz) = locate(&mode, hsv, rgb);
let (rgb, hsv, lab) = (now.rgb, now.hsv, now.lab);
let (fx, fy, fz) = locate(&mode, hsv, rgb, lab);
// Colour field.
let (field, resp) = ui.allocate_exact_size(vec2(280.0, 280.0), Sense::click_and_drag());
ui.painter().add(grid_mesh(field, 32, |x, y| field_color(&mode, fz, x, y)));
@@ -308,13 +369,13 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
"h" => Keep::Hsv([fz * 360.0, x, 1.0 - y]),
"s" => Keep::Hsv([x * 360.0, fz, 1.0 - y]),
"v" => Keep::Hsv([x * 360.0, 1.0 - y, fz]),
_ => Keep::Nothing,
m => field_lab(m, fz, x, y).map_or(Keep::Nothing, Keep::Lab),
};
set_rgb(f, c, keep);
}
// Component slider (hue runs 360° at the top to 0° at the bottom, like Photoshop).
let (strip, sresp) = ui.allocate_exact_size(vec2(20.0, 280.0), Sense::click_and_drag());
ui.painter().add(grid_mesh(strip, 32, |_, y| slider_color(&mode, 1.0 - y, hsv, rgb)));
ui.painter().add(grid_mesh(strip, 32, |_, y| slider_color(&mode, 1.0 - y, hsv, rgb, lab)));
let sy = strip.top() + (1.0 - fz) * strip.height();
for (x, dir) in [(strip.left() - 1.0, 1.0f32), (strip.right() + 1.0, -1.0)] {
let tri = vec![pos2(x, sy), pos2(x - 6.0 * dir, sy - 4.0), pos2(x - 6.0 * dir, sy + 4.0)];
@@ -328,9 +389,10 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
"v" => Some([hsv[0], hsv[1], z]),
_ => None,
};
match h {
Some(h) => set_rgb(f, hsv_to_rgb(h[0], h[1], h[2]), Keep::Hsv(h)),
None => set_rgb(f, slider_color(&mode, z, hsv, rgb), Keep::Nothing),
match (h, slider_lab(&mode, z, lab)) {
(Some(h), _) => set_rgb(f, hsv_to_rgb(h[0], h[1], h[2]), Keep::Hsv(h)),
(None, Some(l)) => set_rgb(f, lab_rgb(l), Keep::Lab(l)),
(None, None) => set_rgb(f, slider_color(&mode, z, hsv, rgb, lab), Keep::Nothing),
}
}
}
@@ -338,22 +400,84 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
fn swatches(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let t = Tokens::get(ui.ctx());
let rgb = current(f).rgb;
ui.allocate_ui_with_layout(vec2(64.0, 104.0), egui::Layout::top_down(egui::Align::Center), |ui| {
// new / current swatches.
ui.label(egui::RichText::new(tl!("new")).size(11.0).color(t.text_dim));
let (sw, _) = ui.allocate_exact_size(vec2(64.0, 72.0), Sense::hover());
let orig = f.get("__orig").and_then(Value::as_str).and_then(parse_hex).unwrap_or(rgb);
ui.painter().rect_filled(Rect::from_min_size(sw.min, vec2(64.0, 36.0)), 0.0, c32(rgb));
let cur = Rect::from_min_size(sw.min + vec2(0.0, 36.0), vec2(64.0, 36.0));
ui.painter().rect_filled(cur, 0.0, c32(orig));
ui.painter().rect_stroke(sw, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
let click_cur = ui.interact(cur, ui.id().with("cp-current"), Sense::click());
click_cur.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Click to restore the current colour")));
if click_cur.on_hover_text(tl!("Click to restore the current colour")).clicked() {
set_rgb(f, orig, Keep::Nothing);
let orig = f.get("__orig").and_then(Value::as_str).and_then(parse_hex).unwrap_or(rgb);
let (area, _) = ui.allocate_exact_size(vec2(64.0 + 26.0, 104.0), Sense::hover());
// new / current swatches, labelled above and below.
let sw = Rect::from_min_size(area.min + vec2(0.0, 16.0), vec2(64.0, 72.0));
let p = ui.painter();
let label = |text: &str, at: egui::Pos2, align: egui::Align2| {
p.text(at, align, text, egui::FontId::proportional(11.0), t.text_dim);
};
label(tl!("new"), pos2(sw.center().x, sw.top() - 3.0), egui::Align2::CENTER_BOTTOM);
label(tl!("current"), pos2(sw.center().x, sw.bottom() + 3.0), egui::Align2::CENTER_TOP);
p.rect_filled(Rect::from_min_size(sw.min, vec2(64.0, 36.0)), 0.0, c32(rgb));
let cur = Rect::from_min_size(sw.min + vec2(0.0, 36.0), vec2(64.0, 36.0));
p.rect_filled(cur, 0.0, c32(orig));
p.rect_stroke(sw, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
let click_cur = ui.interact(cur, ui.id().with("cp-current"), Sense::click());
click_cur.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Click to restore the current colour")));
if click_cur.on_hover_text(tl!("Click to restore the current colour")).clicked() {
set_rgb(f, orig, Keep::Nothing);
}
// Photoshop's warnings beside the swatch, each over a chip of the nearest colour that passes
// (a click takes it): out of gamut for printing on top, not a web colour below.
let x = sw.right() + 8.0;
let gamut = tl!("Out of gamut for printing: click for the nearest printable colour");
let web = tl!("Not a web colour: click for the nearest web colour");
for (slot, warning, tip) in [(0.0, out_of_gamut(rgb), gamut), (34.0, not_web_safe(rgb), web)] {
let Some(near) = warning else { continue };
let icon = Rect::from_min_size(pos2(x, sw.top() + 2.0 + slot), vec2(13.0, 13.0));
let chip = Rect::from_min_size(pos2(x + 1.5, icon.bottom() + 3.0), vec2(10.0, 10.0));
let p = ui.painter();
if slot == 0.0 {
warning_triangle(p, icon, t.text, t.chrome);
} else {
web_cube(p, icon, t.text);
}
ui.label(egui::RichText::new(tl!("current")).size(11.0).color(t.text_dim));
});
p.rect_filled(chip, 0.0, c32(near));
p.rect_stroke(chip, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Outside);
let resp = ui.interact(icon.union(chip), ui.id().with(("cp-warning", slot as i32)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tip));
if resp.on_hover_text(tip).clicked() {
set_rgb(f, near, Keep::Nothing);
}
}
}
/// ⚠: a filled triangle with an exclamation mark cut out of it.
fn warning_triangle(p: &egui::Painter, r: Rect, ink: Color32, cut: Color32) {
let tri = vec![pos2(r.center().x, r.top()), pos2(r.right(), r.bottom()), pos2(r.left(), r.bottom())];
p.add(egui::Shape::convex_polygon(tri, ink, Stroke::NONE));
let x = r.center().x;
p.line_segment([pos2(x, r.top() + 4.5), pos2(x, r.bottom() - 4.0)], Stroke::new(1.5, cut));
p.circle_filled(pos2(x, r.bottom() - 2.0), 0.9, cut);
}
/// A cube seen corner-on: a hexagon with three edges meeting in the middle.
fn web_cube(p: &egui::Painter, r: Rect, ink: Color32) {
let (c, rad) = (r.center(), r.width() / 2.0);
let corner = |k: f32| {
let a = std::f32::consts::FRAC_PI_3 * k - std::f32::consts::FRAC_PI_2;
c + vec2(rad * a.cos(), rad * a.sin())
};
let hex: Vec<_> = (0..6).map(|k| corner(k as f32)).collect();
let stroke = Stroke::new(1.2, ink);
p.add(egui::Shape::closed_line(hex, stroke));
for k in [1.0, 3.0, 5.0] {
p.line_segment([c, corner(k)], stroke);
}
}
/// A component radio: true when clicked.
fn radio(ui: &mut egui::Ui, on: bool, label: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::click());
ui.painter().circle_stroke(r.center(), 5.5, Stroke::new(1.2, if on { t.accent } else { t.text_faint }));
if on {
ui.painter().circle_filled(r.center(), 3.0, t.accent);
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, ui.is_enabled(), on, label));
resp.clicked()
}
/// Component radios and numeric fields: HSB, RGB, Lab, CMYK and hex.
@@ -372,12 +496,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, mode: &str, now: &Compo
for i in 0..6 {
let (key, label) = MODES[i];
let on = mode == key;
let (r, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::click());
ui.painter().circle_stroke(r.center(), 5.5, Stroke::new(1.2, if on { t.accent } else { t.text_faint }));
if on {
ui.painter().circle_filled(r.center(), 3.0, t.accent);
}
if resp.clicked() {
if radio(ui, on, label) {
new_mode = Some(key);
}
ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim));
@@ -422,9 +541,12 @@ fn fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, mode: &str, now: &Compo
}
});
});
egui::Grid::new("cp-lab-cmyk").min_row_height(0.0).num_columns(3).spacing([6.0, 4.0]).show(ui, |ui| {
for (i, (label, v, range)) in [("L:", lab[0], 0.0..=100.0), ("a:", lab[1], -128.0..=127.0), ("b:", lab[2], -128.0..=127.0)].into_iter().enumerate()
{
egui::Grid::new("cp-lab-cmyk").min_row_height(0.0).num_columns(4).spacing([6.0, 4.0]).show(ui, |ui| {
for (i, (v, range)) in [(lab[0], 0.0..=100.0), (lab[1], -128.0..=127.0), (lab[2], -128.0..=127.0)].into_iter().enumerate() {
let (key, label) = LAB_MODES[i];
if radio(ui, mode == key, label) {
new_mode = Some(key);
}
ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim));
let mut x = v.round();
if widgets::value_field(ui, &mut x, range, "", 54.0).changed() {
@@ -438,6 +560,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, mode: &str, now: &Compo
ui.allocate_exact_size(vec2(0.0, 6.0), Sense::hover());
ui.end_row();
for (i, label) in ["C:", "M:", "Y:", "K:"].into_iter().enumerate() {
ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim));
let mut x = (cmyk[i] * 100.0).round();
if widgets::value_field(ui, &mut x, 0.0..=100.0, "", 54.0).changed() {
@@ -659,14 +782,46 @@ mod tests {
#[test]
fn field_and_locate_are_inverse_in_every_mode() {
let rgb = [0.8, 0.3, 0.2];
let hsv = rgb_to_hsv(rgb);
for (m, _) in MODES {
let (x, y, z) = locate(m, hsv, rgb);
let (hsv, lab) = (rgb_to_hsv(rgb), photocraft_color::convert::srgb_to_lab(rgb));
for (m, _) in MODES.iter().chain(LAB_MODES) {
let (x, y, z) = locate(m, hsv, rgb, lab);
let c = field_color(m, z, x, y);
assert_eq!(hex(c), hex(rgb), "mode {m}");
}
}
#[test]
fn lab_fields_are_laid_out_like_photoshops() {
// Photoshop 25.4. L: a across (green to red), b upwards (blue to yellow).
let l = |x, y| photocraft_color::convert::srgb_to_lab(field_color("lab_l", 0.6, x, y));
assert!(l(0.0, 0.5)[1] < -40.0 && l(1.0, 0.5)[1] > 40.0, "{:?} {:?}", l(0.0, 0.5), l(1.0, 0.5));
assert!(l(0.5, 1.0)[2] < -40.0 && l(0.5, 0.0)[2] > 40.0, "{:?} {:?}", l(0.5, 1.0), l(0.5, 0.0));
// a: b across, L upwards (white at the top, black at the bottom).
assert!(field_color("lab_a", 0.5, 0.5, 0.0).iter().all(|&c| c > 0.99), "white at the top");
assert!(field_color("lab_a", 0.5, 0.5, 1.0).iter().all(|&c| c < 0.01), "black at the bottom");
let a = |x| photocraft_color::convert::srgb_to_lab(field_color("lab_a", 0.5, x, 0.5));
assert!(a(0.0)[2] < -40.0 && a(1.0)[2] > 40.0);
// b: a across, L upwards; its slider runs from yellow (top) to blue.
let b = |x| photocraft_color::convert::srgb_to_lab(field_color("lab_b", 0.5, x, 0.5));
assert!(b(0.0)[1] < -40.0 && b(1.0)[1] > 40.0);
let lab = [60.0, 0.0, 0.0];
let top = photocraft_color::convert::srgb_to_lab(slider_color("lab_b", 1.0, [0.0; 3], [0.0; 3], lab));
let bottom = photocraft_color::convert::srgb_to_lab(slider_color("lab_b", 0.0, [0.0; 3], [0.0; 3], lab));
assert!(top[2] > 40.0 && bottom[2] < -40.0, "{top:?} {bottom:?}");
}
#[test]
fn gamut_and_web_warnings() {
// Photoshop 25.4: #714343 is printable but not a web colour; #fd0303 is neither.
let rgb = |h| parse_hex(h).unwrap();
assert_eq!(out_of_gamut(rgb("#714343")), None);
assert!(out_of_gamut(rgb("#fd0303")).is_some());
assert_eq!(out_of_gamut(rgb("#000000")), None, "black prints");
assert_eq!(out_of_gamut(rgb("#ffffff")), None);
assert_eq!(not_web_safe(rgb("#714343")).map(hex).as_deref(), Some("#663333"));
assert_eq!(not_web_safe(rgb("#cc3366")), None);
}
#[test]
fn hue_survives_grey_and_ok_sets_the_colour() {
let mut f = Map::new();
+4
View File
@@ -2249,6 +2249,10 @@ fontWeight Italic Kurzíva
Reset View Obnovit zobrazení
Drag around the centre to rotate the view · Shift constrains to 15° Tažením kolem středu otočíte zobrazení · Shift omezí na 15°
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Mimo gamut tisku: klepnutím zvolíte nejbližší tisknutelnou barvu
Not a web colour: click for the nearest web colour Není webová barva: klepnutím zvolíte nejbližší webovou barvu
# Eyedropper Sample Size and Sample (#1649).
Point Sample Bodový vzorek
3 by 3 Average Průměr 3 × 3
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2288,6 +2288,10 @@ fontWeight Italic Kursiv
Reset View Ansicht zurücksetzen
Drag around the centre to rotate the view · Shift constrains to 15° Ziehen Sie um die Mitte, um die Ansicht zu drehen · Umschalt rastet auf 15° ein
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Außerhalb des Druckfarbumfangs: Klicken für die nächstliegende druckbare Farbe
Not a web colour: click for the nearest web colour Keine Webfarbe: Klicken für die nächstliegende Webfarbe
# Eyedropper Sample Size and Sample (#1649).
Point Sample Punktprobe
3 by 3 Average 3 × 3 Durchschnitt
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -3362,6 +3362,10 @@ fontWeight Thin Πολύ λεπτά
Preset Name Όνομα προεπιλογής
No saved presets yet. Δεν υπάρχουν ακόμη αποθηκευμένες προεπιλογές.
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Εκτός γκάμας εκτύπωσης: κάντε κλικ για το πλησιέστερο εκτυπώσιμο χρώμα
Not a web colour: click for the nearest web colour Δεν είναι χρώμα web: κάντε κλικ για το πλησιέστερο χρώμα web
# Eyedropper Sample Size and Sample (#1649).
Point Sample Σημειακό δείγμα
3 by 3 Average Μέσος όρος 3 × 3
Can't render this file because it contains an unexpected character in line 960 and column 18.
+4
View File
@@ -2318,6 +2318,10 @@ fontWeight Italic Cursiva
Reset View Restablecer vista
Drag around the centre to rotate the view · Shift constrains to 15° Arrastre alrededor del centro para rotar la vista · Mayús fija a 15°
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Fuera de la gama de impresión: haga clic para el color imprimible más cercano
Not a web colour: click for the nearest web colour No es un color web: haga clic para el color web más cercano
# Eyedropper Sample Size and Sample (#1649).
Point Sample Muestra puntual
3 by 3 Average Promedio de 3 × 3
Can't render this file because it contains an unexpected character in line 4 and column 47.
+4
View File
@@ -2672,6 +2672,10 @@ fontWeight Italic Italique
Reset View Réinitialiser la vue
Drag around the centre to rotate the view · Shift constrains to 15° Faites glisser autour du centre pour tourner la vue · Maj contraint à 15°
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Hors de la gamme d’impression : cliquez pour la couleur imprimable la plus proche
Not a web colour: click for the nearest web colour Pas une couleur web : cliquez pour la couleur web la plus proche
# Eyedropper Sample Size and Sample (#1649).
Point Sample Échantillon ponctuel
3 by 3 Average Moyenne 3 × 3
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2659,6 +2659,10 @@ fontWeight Italic Miring
Reset View Atur Ulang Tampilan
Drag around the centre to rotate the view · Shift constrains to 15° Seret di sekitar pusat untuk memutar tampilan · Shift membatasi ke 15°
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Di luar gamut cetak: klik untuk warna cetak terdekat
Not a web colour: click for the nearest web colour Bukan warna web: klik untuk warna web terdekat
# Eyedropper Sample Size and Sample (#1649).
Point Sample Sampel Titik
3 by 3 Average Rata-rata 3 × 3
Can't render this file because it contains an unexpected character in line 4 and column 47.
+4
View File
@@ -2319,6 +2319,10 @@ fontWeight Italic Corsivo
Reset View Reimposta vista
Drag around the centre to rotate the view · Shift constrains to 15° Trascina intorno al centro per ruotare la vista · Maiusc vincola a 15°
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Fuori gamma di stampa: fai clic per il colore stampabile più vicino
Not a web colour: click for the nearest web colour Non è un colore web: fai clic per il colore web più vicino
# Eyedropper Sample Size and Sample (#1649).
Point Sample Campione puntiforme
3 by 3 Average Media 3 × 3
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
+4
View File
@@ -2232,6 +2232,10 @@ fontWeight Italic 斜体
Reset View 表示をリセット
Drag around the centre to rotate the view · Shift constrains to 15° 中心の周りをドラッグして表示を回転 · Shift で 15° 単位
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour 印刷の色域外:クリックすると最も近い印刷可能な色になります
Not a web colour: click for the nearest web colour Web カラーではありません:クリックすると最も近い Web カラーになります
# Eyedropper Sample Size and Sample (#1649).
Point Sample ポイントサンプル
3 by 3 Average 3 × 3 平均
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2828,6 +2828,10 @@ fontWeight Italic 기울임
Reset View 보기 재설정
Drag around the centre to rotate the view · Shift constrains to 15° 중심 주위를 드래그해 보기를 회전합니다 · Shift는 15°로 제한
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour 인쇄 색역 밖: 클릭하면 가장 가까운 인쇄 가능한 색상으로 바뀝니다
Not a web colour: click for the nearest web colour 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다
# Eyedropper Sample Size and Sample (#1649).
Point Sample 포인트 샘플
3 by 3 Average 3 × 3 평균
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
+4
View File
@@ -3273,6 +3273,10 @@ cameraRaw Roughness Chropowatość
Reset View Resetuj widok
Drag around the centre to rotate the view · Shift constrains to 15° Przeciągnij wokół środka, aby obrócić widok · Shift ogranicza do 15°
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Poza gamą druku: kliknij, aby wybrać najbliższy kolor do druku
Not a web colour: click for the nearest web colour To nie jest kolor internetowy: kliknij, aby wybrać najbliższy kolor internetowy
# Eyedropper Sample Size and Sample (#1649).
Point Sample Próbka punktowa
3 by 3 Average Uśrednianie 3 × 3
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2679,6 +2679,10 @@ fontWeight Italic Itálico
Reset View Redefinir visualização
Drag around the centre to rotate the view · Shift constrains to 15° Arraste ao redor do centro para girar a visualização · Shift restringe a 15°
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Fora da gama de impressão: clique para a cor imprimível mais próxima
Not a web colour: click for the nearest web colour Não é uma cor da Web: clique para a cor da Web mais próxima
# Eyedropper Sample Size and Sample (#1649).
Point Sample Amostra pontual
3 by 3 Average Média de 3 × 3
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2520,6 +2520,10 @@ fontWeight Italic Курсив
Reset View Сбросить вид
Drag around the centre to rotate the view · Shift constrains to 15° Перетащите вокруг центра, чтобы повернуть вид · Shift фиксирует шаг 15°
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Вне цветового охвата печати: щёлкните, чтобы выбрать ближайший печатаемый цвет
Not a web colour: click for the nearest web colour Не веб-цвет: щёлкните, чтобы выбрать ближайший веб-цвет
# Eyedropper Sample Size and Sample (#1649).
Point Sample Точечная выборка
3 by 3 Average Среднее 3 × 3
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2257,6 +2257,10 @@ fontWeight Italic 斜体
Reset View 重置视图
Drag around the centre to rotate the view · Shift constrains to 15° 绕中心拖动以旋转视图 · Shift 限制为 15°
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour 超出打印色域:单击使用最接近的可打印颜色
Not a web colour: click for the nearest web colour 不是 Web 颜色:单击使用最接近的 Web 颜色
# Eyedropper Sample Size and Sample (#1649).
Point Sample 采样点
3 by 3 Average 3 × 3 平均
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
+4
View File
@@ -2226,6 +2226,10 @@ fontWeight Italic 斜體
Reset View 重設檢視
Drag around the centre to rotate the view · Shift constrains to 15° 繞中心拖曳以旋轉檢視 · Shift 限制為 15°
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour 超出列印色域:按一下以使用最接近的可列印顏色
Not a web colour: click for the nearest web colour 不是網頁顏色:按一下以使用最接近的網頁顏色
# Eyedropper Sample Size and Sample (#1649).
Point Sample 取樣點
3 by 3 Average 3 × 3 平均
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -1
View File
@@ -1,6 +1,7 @@
//! Lucide icons (ISC licence), embedded and tinted at runtime.
pub static ICONS: &[(&str, &[u8])] = &[
("adjustment-layer", include_bytes!("../../../assets/icons/adjustment-layer.svg")),
("align-center", include_bytes!("../../../assets/icons/align-center.svg")),
("align-justify", include_bytes!("../../../assets/icons/align-justify.svg")),
("align-left", include_bytes!("../../../assets/icons/align-left.svg")),
@@ -55,8 +56,10 @@ pub static ICONS: &[(&str, &[u8])] = &[
("lasso", include_bytes!("../../../assets/icons/lasso.svg")),
("lasso-magnetic", include_bytes!("../../../assets/icons/lasso-magnetic.svg")),
("lasso-select", include_bytes!("../../../assets/icons/lasso-select.svg")),
("layer-mask", include_bytes!("../../../assets/icons/layer-mask.svg")),
("layers", include_bytes!("../../../assets/icons/layers.svg")),
("link", include_bytes!("../../../assets/icons/link.svg")),
("link-2", include_bytes!("../../../assets/icons/link-2.svg")),
("lock", include_bytes!("../../../assets/icons/lock.svg")),
("lock-open", include_bytes!("../../../assets/icons/lock-open.svg")),
("lollipop", include_bytes!("../../../assets/icons/lollipop.svg")),
@@ -98,7 +101,6 @@ pub static ICONS: &[(&str, &[u8])] = &[
("square", include_bytes!("../../../assets/icons/square.svg")),
("square-dashed", include_bytes!("../../../assets/icons/square-dashed.svg")),
("square-dashed-mouse-pointer", include_bytes!("../../../assets/icons/square-dashed-mouse-pointer.svg")),
("square-dot", include_bytes!("../../../assets/icons/square-dot.svg")),
("square-plus", include_bytes!("../../../assets/icons/square-plus.svg")),
("squares-subtract", include_bytes!("../../../assets/icons/squares-subtract.svg")),
("stamp", include_bytes!("../../../assets/icons/stamp.svg")),
+1 -1
View File
@@ -55,7 +55,7 @@ fn row(h: &Harness<'static, PhotocraftApp>, name: &str) -> Option<Rect> {
/// Fully inside the rows viewport (below the strip and the blend/lock rows, above the footer).
fn in_view(h: &Harness<'static, PhotocraftApp>, r: Rect) -> bool {
let layers = last_rects(&h.ctx).into_iter().find(|(g, _)| *g == Group::Layers).unwrap().1;
r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - 36.0
r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - (crate::widgets::FOOTER_BAR + 1.0)
}
fn expanded(app: &PhotocraftApp, group: LayerId) -> bool {
+1 -1
View File
@@ -140,7 +140,7 @@ pub fn indicators(
for &(kind, r) in &rects {
match kind {
Indicator::Lock => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "lock", 12.0, t.text_faint),
Indicator::Link => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "link", 12.0, t.text_faint),
Indicator::Link => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "link-2", 12.0, t.text_faint),
Indicator::Fx => {
if let Some(g) = fx_galley.clone() {
painter.galley(pos2(r.left(), cy - g.size().y / 2.0), g, t.text_dim);
+20
View File
@@ -311,3 +311,23 @@ fn alt_dragging_a_row_drops_a_copy_and_a_plain_drag_moves() {
assert_eq!(names(&h), ["Background", "L0", "L2", "L0 copy", "L1"]);
assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Reorder Layers"));
}
/// Photoshop's Layers footer is a bar along the panel's bottom edge: its buttons sit in the middle
/// of the bar's height, from the right. They used to hang under the line with twice the gap below.
#[test]
fn the_footer_is_a_bar_along_the_panel_bottom_with_its_buttons_centred() {
for theme in ["promedium", "studio"] {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let h = harness(s, 1.0, theme, 290.0);
let group = crate::dock::last_rects(&h.ctx).into_iter().find(|(g, _)| *g == crate::dock::Group::Layers).unwrap().1;
let delete = h.get_by_label("Delete layer").rect();
let bar_middle = group.bottom() - crate::widgets::FOOTER_BAR / 2.0;
assert!((delete.center().y - bar_middle).abs() <= 1.5, "{theme}: button at {delete:?}, bar middle {bar_middle}, group {group:?}");
assert!(group.right() - delete.right() <= 10.0, "{theme}: laid out from the right: {delete:?} in {group:?}");
let new_layer = h.get_by_label("Create a new layer").rect();
assert_eq!(new_layer.center().y, delete.center().y, "{theme}: one row");
assert!(new_layer.right() <= delete.left(), "{theme}: New Layer left of Delete");
}
}
+2 -2
View File
@@ -176,9 +176,9 @@ fn paths_list_the_layer_path_above_a_bottom_footer() {
let p = h.get_by_label("Badge Shape Path").rect().center();
click_with(&mut h, p, Modifiers::NONE);
assert_eq!(h.state().ui.selected_path.as_deref(), Some("layer"));
// Footer: fill, stroke, load as selection, … (24 pt buttons, 2 pt apart).
// Footer: fill, stroke, load as selection, … (26 pt buttons, 2 pt apart).
let footer = crate::vector_ui::paths_footer(&h.ctx).unwrap();
let p = pos2(footer.left() + 2.0 * 26.0 + 12.0, footer.center().y);
let p = pos2(footer.left() + 2.0 * 28.0 + 13.0, footer.center().y);
click_with(&mut h, p, Modifiers::NONE);
let sel = h.state().session.active().unwrap().doc.selection.clone().expect("selection from the shape path");
let b = sel.content_bounds();
+163 -98
View File
@@ -283,10 +283,10 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
pub fn toolbar_needs_double(slots: usize, sections: usize, bx: f32, pro: bool, avail_h: f32) -> bool {
let pitch = bx + 3.0;
let needed = if pro {
// margins + header + slots + "…" + gap + chips (38 + swap row) + gap + 2 buttons
16.0 + 21.0 + (slots + 1) as f32 * pitch + 8.0 + 59.0 + 8.0 + 2.0 * pitch
// 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
} else {
16.0 + slots as f32 * pitch + sections.saturating_sub(1) as f32 * 12.0 + 14.0 + 59.0
16.0 + slots as f32 * pitch + sections.saturating_sub(1) as f32 * 12.0 + 14.0 + chips_height(false)
};
needed > avail_h
}
@@ -295,17 +295,45 @@ fn c32(c: [f32; 4]) -> Color32 {
Color32::from_rgba_unmultiplied((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8, (c[3] * 255.0) as u8)
}
/// Colour chips: chip size and the background chip's offset (points).
fn chip_metrics(pro: bool) -> (f32, f32) {
if pro { (18.0, 10.0) } else { (21.0, 12.0) }
}
/// Height of the default-colours and swap icons over the chips.
const CHIP_ICON: f32 = 13.0;
/// Height of the colour chips block.
fn chips_height(pro: bool) -> f32 {
let (chip, step) = chip_metrics(pro);
CHIP_ICON + 3.0 + chip + step
}
/// 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) {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(36.0, 38.0), Sense::hover());
let bg = Rect::from_min_size(rect.min + vec2(13.0, 13.0), vec2(21.0, 21.0));
let fg = Rect::from_min_size(rect.min + vec2(2.0, 2.0), vec2(21.0, 21.0));
let (chip, step) = chip_metrics(t.pro);
let group = chip + step;
let w = ui.available_width().max(group);
let (rect, _) = ui.allocate_exact_size(vec2(w, chips_height(t.pro)), 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 radius = t.radius_sm.min(3.0);
let p = ui.painter();
p.rect_filled(bg, 5.0, c32(app.session.tools.background));
p.rect_stroke(bg, 5.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
p.rect_filled(fg, 5.0, c32(app.session.tools.foreground));
p.rect_stroke(fg, 5.0, Stroke::new(1.5, t.chrome), StrokeKind::Outside);
p.rect_stroke(fg, 5.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
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);
// 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());
@@ -314,15 +342,50 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
} else if bg_resp.on_hover_text(tl!("Set background color")).clicked() {
crate::color_picker_ui::open(app, "background");
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
if icons::button(ui, "arrow-left-right", 18.0, false, tl!("Swap colours (X)")).clicked() {
let _ = app.run("tools.swapColors", json!({}));
// 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 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() {
p.rect_filled(r.expand(2.0), radius, t.hover);
}
if icons::button(ui, "contrast", 18.0, false, tl!("Default colours (D)")).clicked() {
let _ = app.run("tools.defaultColors", json!({}));
}
});
}
let ink = |resp: &egui::Response| if resp.hovered() { t.text } else { t.icon };
// 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)")));
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() {
let _ = app.run("tools.defaultColors", json!({}));
}
}
// ----------------------------------------------------------------------------- title bar
@@ -1416,7 +1479,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.add_space(4.0);
for (kind, icon, tip) in [
("pixel", "image", tl!("Filter for pixel layers")),
("adjustment", "contrast", tl!("Filter for adjustment layers")),
("adjustment", "adjustment-layer", tl!("Filter for adjustment layers")),
("type", "type", tl!("Filter for type layers")),
("shape", "square", tl!("Filter for shape layers")),
("smart", "app-window", tl!("Filter for smart objects")),
@@ -1511,7 +1574,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// Top of the stack first, groups above their contents, closed groups' contents hidden (#126).
let rows = crate::layer_tree_ui::display_rows(&doc, !app.ui.layer_filter.is_empty());
let ctx = ui.ctx().clone();
let footer = 38.0;
let footer = widgets::footer_height(ui) + ui.spacing().item_spacing.y;
let fill = ui.available_height() > footer + 60.0;
let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY };
egui::ScrollArea::vertical()
@@ -1582,79 +1645,75 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if ctx.input(|i| i.pointer.any_released()) {
ctx.data_mut(|d| d.remove::<u64>(egui::Id::new("layer-drag")));
}
ui.add_space(4.0);
widgets::hairline(ui);
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let trash = icons::button(ui, "trash", 26.0, false, tl!("Delete layer"));
if trash.clicked() {
actions.push(("layer.delete".into(), json!({})));
}
actions.extend(footer_drop(ui, &trash, footer_drag, "layer.delete"));
let new_layer = icons::button(ui, "square-plus", 26.0, false, &crate::shortcuts::tip_label(app, "Create a new layer", "layer.new.layer"));
if new_layer.clicked() {
actions.push(("layer.new.layer".into(), json!({})));
}
actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate"));
let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group"));
if group.clicked() {
actions.push(("layer.new.group".into(), json!({})));
}
actions.extend(footer_drop(ui, &group, footer_drag, "layer.groupLayers"));
let adj = icons::button(ui, "contrast", 26.0, false, tl!("Create new fill or adjustment layer"));
egui::Popup::menu(&adj).show(|ui| {
ui.set_min_width(190.0);
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("layer.newAdjustmentLayer.")) {
if ui.button(tl!(c.label).trim_end_matches('…')).clicked() {
actions.push((c.id.into(), json!({})));
ui.close();
}
}
ui.separator();
if ui.button(tl!("Solid Color…")).clicked() {
actions.push(("layer.newFillLayer.solidColor".into(), json!({})));
widgets::panel_footer(ui, |ui| {
let trash = icons::button(ui, "trash", 26.0, false, tl!("Delete layer"));
if trash.clicked() {
actions.push(("layer.delete".into(), json!({})));
}
actions.extend(footer_drop(ui, &trash, footer_drag, "layer.delete"));
let new_layer = icons::button(ui, "square-plus", 26.0, false, &crate::shortcuts::tip_label(app, "Create a new layer", "layer.new.layer"));
if new_layer.clicked() {
actions.push(("layer.new.layer".into(), json!({})));
}
actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate"));
let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group"));
if group.clicked() {
actions.push(("layer.new.group".into(), json!({})));
}
actions.extend(footer_drop(ui, &group, footer_drag, "layer.groupLayers"));
let adj = icons::button(ui, "adjustment-layer", 26.0, false, tl!("Create new fill or adjustment layer"));
menu_mark(ui, &adj);
egui::Popup::menu(&adj).show(|ui| {
ui.set_min_width(190.0);
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("layer.newAdjustmentLayer.")) {
if ui.button(tl!(c.label).trim_end_matches('…')).clicked() {
actions.push((c.id.into(), json!({})));
ui.close();
}
if ui.button(tl!("Gradient…")).clicked() {
actions.push(("layer.newFillLayer.gradient".into(), json!({})));
ui.close();
}
});
if icons::button(
ui,
"square-dot",
26.0,
false,
&crate::i18n::fmt(tl!("Add a mask (from the selection; {key} inverts)"), &[("key", &crate::shortcuts::pretty("Alt"))]),
)
.clicked()
{
let alt = ui.input(|i| i.modifiers.alt);
actions.push((crate::layer_menu_ui::add_mask_command(doc.selection.is_some(), alt).into(), json!({})));
}
let fx = fx_button(ui, 26.0, tl!("Add a layer style"));
egui::Popup::menu(&fx).show(|ui| {
ui.set_min_width(180.0);
if ui.button(tl!("Blending Options…")).clicked() {
crate::layer_style::open(app, None);
ui.close();
}
ui.separator();
for &(kind, label) in crate::layer_style::KINDS {
if ui.button(format!("{}…", tl!(label))).clicked() {
crate::layer_style::open(app, Some(kind));
ui.close();
}
}
});
// Photoshop's footer starts with Link Layers (enabled with two or more layers selected).
let can_link = app.session.is_enabled("layer.linkLayers");
if ui.add_enabled_ui(can_link, |ui| icons::button(ui, "link", 26.0, false, tl!("Link layers"))).inner.clicked() {
actions.push(("layer.linkLayers".into(), json!({})));
ui.separator();
if ui.button(tl!("Solid Color…")).clicked() {
actions.push(("layer.newFillLayer.solidColor".into(), json!({})));
ui.close();
}
if ui.button(tl!("Gradient…")).clicked() {
actions.push(("layer.newFillLayer.gradient".into(), json!({})));
ui.close();
}
});
if icons::button(
ui,
"layer-mask",
26.0,
false,
&crate::i18n::fmt(tl!("Add a mask (from the selection; {key} inverts)"), &[("key", &crate::shortcuts::pretty("Alt"))]),
)
.clicked()
{
let alt = ui.input(|i| i.modifiers.alt);
actions.push((crate::layer_menu_ui::add_mask_command(doc.selection.is_some(), alt).into(), json!({})));
}
let fx = fx_button(ui, 26.0, tl!("Add a layer style"));
menu_mark(ui, &fx);
egui::Popup::menu(&fx).show(|ui| {
ui.set_min_width(180.0);
if ui.button(tl!("Blending Options…")).clicked() {
crate::layer_style::open(app, None);
ui.close();
}
ui.separator();
for &(kind, label) in crate::layer_style::KINDS {
if ui.button(format!("{}…", tl!(label))).clicked() {
crate::layer_style::open(app, Some(kind));
ui.close();
}
}
});
// Photoshop's footer starts with Link Layers (enabled with two or more layers selected).
let can_link = app.session.is_enabled("layer.linkLayers");
if ui.add_enabled_ui(can_link, |ui| icons::button(ui, "link-2", 26.0, false, tl!("Link layers"))).inner.clicked() {
actions.push(("layer.linkLayers".into(), json!({})));
}
});
for (id, p) in actions {
if id == "ui.maskTarget" {
@@ -1708,6 +1767,16 @@ fn footer_label(command: &str) -> &'static str {
}
}
/// The small triangle at the corner of a footer button that opens a menu (Photoshop's fx and
/// New Fill or Adjustment Layer buttons).
fn menu_mark(ui: &egui::Ui, button: &egui::Response) {
let t = Tokens::get(ui.ctx());
let c = button.rect.center() + vec2(7.0, 8.5);
let color = if button.hovered() { t.text } else { t.icon };
let tip = vec![pos2(c.x - 3.0, c.y - 1.5), pos2(c.x + 3.0, c.y - 1.5), pos2(c.x, c.y + 1.5)];
ui.painter().add(egui::Shape::convex_polygon(tip, color, Stroke::NONE));
}
/// Photoshop's italic "fx" footer button (no icon-font equivalent).
fn fx_button(ui: &mut egui::Ui, size: f32, tip: &str) -> egui::Response {
let t = Tokens::get(ui.ctx());
@@ -2073,7 +2142,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let mut target: Option<isize> = None;
let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true)));
// The dock gives History a fixed height: the rows scroll above the footer.
let footer = if t.pro { 34.0 } else { 0.0 };
let footer = if t.pro { widgets::footer_height(ui) + ui.spacing().item_spacing.y } else { 0.0 };
let max_h = (ui.available_height() - footer).max(40.0);
egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, false]).show(ui, |ui| {
for (i, (e, is_redo)) in all.enumerate() {
@@ -2106,14 +2175,10 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
});
if t.pro {
widgets::hairline(ui);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let _ = icons::button(ui, "trash", 24.0, false, tl!("Delete current state"));
let _ = icons::button(ui, "scan", 24.0, false, tl!("Create new snapshot"));
let _ = icons::button(ui, "file-plus", 24.0, false, tl!("Create new document from current state"));
});
widgets::panel_footer(ui, |ui| {
let _ = icons::button(ui, "trash", 26.0, false, tl!("Delete current state"));
let _ = icons::button(ui, "scan", 26.0, false, tl!("Create new snapshot"));
let _ = icons::button(ui, "file-plus", 26.0, false, tl!("Create new document from current state"));
});
}
// An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under
+11 -10
View File
@@ -804,7 +804,7 @@ fn ctx_data_footer(ctx: &egui::Context, r: Rect) {
ctx.data_mut(|d| d.insert_temp(footer_id(), r));
}
/// Where the Paths panel's button footer was drawn last frame.
/// Where the Paths panel's footer buttons were drawn last frame (the rect around them all).
pub fn paths_footer(ctx: &egui::Context) -> Option<Rect> {
ctx.data(|d| d.get_temp(footer_id()))
}
@@ -861,7 +861,7 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let has_layer_path = rows.iter().any(|r| r.kind == PathRow::Layer);
let mut action: Option<(&str, Value)> = None;
// The buttons sit in a footer at the panel's bottom, like Photoshop's.
let footer = 34.0;
let footer = crate::widgets::footer_height(ui) + ui.spacing().item_spacing.y;
let fill = ui.available_height() > footer + 60.0;
let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY };
egui::ScrollArea::vertical().id_salt("path-rows").max_height(rows_h).min_scrolled_height(if fill { rows_h } else { 0.0 }).auto_shrink([false, !fill]).show(
@@ -929,12 +929,8 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
},
);
ui.add_space(4.0);
crate::widgets::hairline(ui);
ui.add_space(2.0);
let sel = app.ui.selected_path.clone().filter(|s| s != "layer" || has_layer_path).unwrap_or_else(|| "work".into());
let footer_rect = ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let buttons = crate::widgets::panel_footer(ui, |ui| {
let fg = hex(app.session.tools.foreground);
let mut n = doc.paths.len() + 1;
while doc.paths.iter().any(|p| p.name == format!("Path {n}")) {
@@ -955,14 +951,19 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let items = items.map(|(icon, tip, cmd, p)| {
if cmd == "path.delete" && delete_layer_path { (icon, "Delete vector mask", "layer.vectorMask.delete", json!({})) } else { (icon, tip, cmd, p) }
});
for (icon, tip, cmd, p) in items {
// Laid out from the right, so the last button goes first.
let mut buttons = Rect::NOTHING;
for (icon, tip, cmd, p) in items.into_iter().rev() {
let icon = if crate::icons::exists(icon) { icon } else { "square" };
if crate::icons::button(ui, icon, 24.0, false, tip).clicked() {
let b = crate::icons::button(ui, icon, 26.0, false, tip);
if b.clicked() {
action = Some((cmd, p));
}
buttons = buttons.union(b.rect);
}
buttons
});
ctx_data_footer(ui.ctx(), footer_rect.response.rect);
ctx_data_footer(ui.ctx(), buttons);
if let Some((cmd, p)) = action
&& let Err(e) = app.run(cmd, p)
{
+36 -9
View File
@@ -59,11 +59,12 @@ pub fn card_ex(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, colla
if t.pro {
return pro_panel(ui, id, tabs, selected, collapsed, 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 { left: 8, right: 8, top: 6, bottom: if collapsed { 6 } else { 10 } });
.inner_margin(egui::Margin { bottom: if collapsed { 6 } else { m.bottom }, ..m });
let out = frame
.show(ui, |ui| {
ui.set_width(ui.available_width());
@@ -88,6 +89,36 @@ pub fn card_ex(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, colla
out
}
/// Inner margin of a dock panel's body: a Pro panel's, or a Studio card's below its tab strip.
fn body_margin(pro: bool) -> egui::Margin {
if pro { egui::Margin::same(8) } else { egui::Margin { left: 8, right: 8, top: 6, bottom: 10 } }
}
/// Height of a panel footer's bar ([`panel_footer`]), under its 1 px line.
pub const FOOTER_BAR: f32 = 30.0;
/// The height a [`panel_footer`] takes from the panel body: its line, and its bar down to the
/// body's bottom margin (the bar runs on over the margin).
pub fn footer_height(ui: &Ui) -> f32 {
1.0 + FOOTER_BAR - body_margin(Tokens::get(ui.ctx()).pro).bottom as f32
}
/// A panel footer as in Photoshop's Layers and History panels: a bar of buttons under a line that
/// runs from edge to edge of the panel, the buttons centred in the bar's height and laid out from
/// the right. Drawn last in a body that fills its group (leave it [`footer_height`]), the bar ends
/// at the panel's bottom edge.
pub fn panel_footer<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
let t = Tokens::get(ui.ctx());
let m = body_margin(t.pro);
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), footer_height(ui)), Sense::hover());
let y = r.top() + 0.5;
ui.painter().line_segment([pos2(r.left() - m.left as f32, y), pos2(r.right() + m.right as f32, y)], Stroke::new(1.0, t.separator));
let bar = Rect::from_min_size(pos2(r.left(), r.top() + 1.0), vec2(r.width(), FOOTER_BAR));
let mut bar_ui = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
bar_ui.spacing_mut().item_spacing.x = 2.0;
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, body: impl FnOnce(&mut Ui, usize)) -> CardResponse {
let t = Tokens::get(ui.ctx());
@@ -109,14 +140,10 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collaps
}
// Body.
if !collapsed {
egui::Frame::NONE
.fill(t.card)
.corner_radius(CornerRadius { nw: 0, ne: 0, sw: 3, se: 3 })
.inner_margin(egui::Margin { left: 8, right: 8, top: 8, bottom: 8 })
.show(ui, |ui| {
ui.set_width(width - 16.0);
body(ui, *selected);
});
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 { strip: strip_resp, menu: mresp, tab_double_clicked: tabs_out.double_clicked, tabs: tabs_out.tabs, chevron: tabs_out.chevron }