mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 07:04:10 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c10156bbf | ||
|
|
ca9a4acaa7 | ||
|
|
18426cd3a0 | ||
|
|
818d75278e |
No files matched your search
+3
-2
@@ -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>
|
||||
@@ -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 |
@@ -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 |
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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 평균
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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 平均
|
||||
|
||||
|
@@ -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.
|
@@ -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")),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user