M3: paint: colour profiles (Display P3), image adjustment presets, style picker (#54)

* M3: colour profiles: sRGB or Display P3 per file, convert or keep values, tagged exports

- color: ColorProfile (srgb, displayP3) with conversions derived from the primaries (shared D65
  white and sRGB curve; out-of-gamut channels clip), table-driven pixel conversion, a generated
  ICC v4 matrix/TRC profile and PNG cICP code points.
- doc: Document.color_profile (serde default sRGB, written only when set); every colour value
  (paints, text runs, effects, grids, page and prototype backgrounds, overlay backdrops, styles,
  colour variables, document colours) can be mapped; set_color_profile converts or reinterprets.
- engine: file.colorProfile {profile?, convert?} (File > Color profile...; without a profile it
  opens the dialog); document.inspect reports colorProfile.
- render: images are taken as sRGB and managed into the file's space; encode_export tags PNG
  (sRGB chunk, or iCCP + cICP for Display P3) and embeds the ICC profile in Display P3 JPG/WebP;
  SVG and PDF export Display P3 files converted to sRGB. The CLI exports the same way.
- ui: Color profile dialog (keep appearance / keep values) and a Color profile row on the page
  panel; the canvas, presenter, thumbnails, swatches and colour picker show a Display P3 file's
  colours converted to the screen's sRGB, and Copy as PNG converts too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: image adjustment presets: built-in, saved in the file, reset

- color: ImageFilters::PRESETS (Vivid, Muted, Warm, Cool, Black & white, Fade, Reset), lookup by
  id or label, and sanitized values.
- doc: Document.image_presets (name + adjustments; written only when there are some).
- engine: image.adjust {ids?, target?, index?, preset?, filters?} sets the adjustments of image
  paints from a preset (built-in or saved) with values on top, one undo step; image.savePreset,
  image.deletePreset and image.presets.
- ui: the colour picker's image section gets a presets menu named after the preset in effect
  ("Custom" otherwise), saved presets with delete, "Save as preset...", a Reset button, and
  aligned adjustment sliders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: style picker: search, folders, list or swatch grid, edit, create, detach

The Fill, Stroke, Effects and Layout grid sections' Styles button opens a popover laid out like
the variable picker (crates/ui-egui/src/style_picker.rs):

- search over style names; styles grouped by the folders of their `/`-separated names, which
  collapse (searching opens them); colour styles as a list or a grid of swatches;
- click applies, a check marks the applied style; the row's edit button (or a right-click) edits
  the name, description and values: colour style paints open the colour picker on the style,
  effect and grid styles update from the selection; delete;
- `+` creates a style from the selection's current values and opens it for naming; detach;
- colour slots still list colour variables to bind;
- the applied style chip opens the picker; new icons Adjust, ViewList, ViewGrid (drawn in code);
- control channel: ui.set {stylePicker, stylePickerEdit, stylePickerView}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 07:38:17 +07:00 committed by GitHub
parent f2c4fb21e1
commit d3dbc6f495
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 2424 additions and 232 deletions

1
Cargo.lock generated
View File

@ -1427,6 +1427,7 @@ dependencies = [
name = "figcraft-render"
version = "0.1.0"
dependencies = [
"crc32fast",
"figcraft-color",
"figcraft-doc",
"figcraft-geom",

View File

@ -48,6 +48,7 @@ linesweeper = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
sha2 = "0.10"
base64 = "0.22"
crc32fast = "1"
# ui
egui = "0.36"

View File

@ -92,7 +92,7 @@
</td>
<td width="50%" valign="top">
<h3>Paints and effects</h3>
Solid, linear, radial, angular and diamond gradients with on-canvas handles, image fills (fill, fit, crop, tile), multiple fills and strokes, every blend mode, drop and inner shadows, layer and background blur, masks.
Solid, linear, radial, angular and diamond gradients with on-canvas handles, image fills (fill, fit, crop, tile) with adjustments and presets, multiple fills and strokes, every blend mode, drop and inner shadows, layer and background blur, masks. Files are sRGB or Display P3, with exports tagged with the file's colour profile.
</td>
</tr>
<tr>

View File

@ -114,7 +114,7 @@ dimension measured side by side.
- **Next:** the fidelity pass (7) and hardening (9: zoomed-out renders and save/load at 50k layers,
a real-file corpus); variable width strokes; component slots and nested override edge cases
(in progress); paint colour profiles and style picker polish (in progress).
(in progress).
- **Then:** interop depth (6: PDF export, .fig best effort), plugins and collaboration (8).
- **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs.
@ -164,7 +164,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Selection & transform | 8 | 68% | measure hover polish, scale tool on-canvas handles | 5–8 |
| Layers panel | 5 | 79% | keyboard row navigation | 1–2 |
| Shapes & vector | 8 | 63% | variable width, multi-point transform box, point snapping, live boolean editing, arc handles on canvas, shape builder on strokes and open paths | 10–15 |
| Paint | 7 | 65% | colour profiles (Display P3), paint style picker polish, image adjustment presets | 6–10 |
| Paint | 7 | 78% | a wide-gamut canvas (egui draws sRGB, so Display P3 colours outside sRGB clip on screen; exports keep them), images' own embedded colour profiles (images are taken as sRGB) | 1–3 |
| Strokes | 3 | 70% | stroke styles, variable width, arrowhead previews in the menus | 3–5 |
| Effects | 4 | 70% | glass/texture fidelity, shadow spread on non-rects exactness | 4–7 |
| Frames, groups, sections, masks | 5 | 75% | hiding section contents UI, section-level prototype/dev affordances | 2–4 |

View File

@ -84,13 +84,10 @@ pub fn render_page(doc: &Document, page: NodeId, scale: f64) -> CliResult<figcra
figcraft_render::render_view(doc, page, &view, &Default::default()).ok_or_else(|| CliError::Failed("render failed".into()))
}
fn encode(pix: &figcraft_render::Pixmap, e: &str) -> CliResult<Vec<u8>> {
match e {
"jpg" | "jpeg" => figcraft_render::encode_jpeg(pix, figcraft_doc::color::Color::WHITE, 90),
"webp" => figcraft_render::encode_webp(pix),
_ => figcraft_render::encode_png(pix),
}
.map_err(CliError::Failed)
/// Encode a render as the format extension `e` names, tagged with `doc`'s colour profile.
fn encode(doc: &Document, pix: &figcraft_render::Pixmap, e: &str) -> CliResult<Vec<u8>> {
let format = ExportFormat::from_ext(e).unwrap_or_default();
figcraft_render::encode_export(pix, format, doc.color_profile).map_err(CliError::Failed)
}
/// Write `doc` to `out`, choosing the format by extension.
@ -105,10 +102,10 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
}
"svg" => figcraft_svg::export_svg(doc, &children(doc, page), &Default::default())
"svg" => figcraft_svg::export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
.ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))?
.into_bytes(),
"png" | "jpg" | "jpeg" | "webp" => encode(&render_page(doc, page, 1.0)?, &e)?,
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
"pdf" => figcraft_engine::cmd::file::export_bytes(doc, &children(doc, page), ExportFormat::Pdf, 1.0)
.map_err(|e| CliError::Failed(e.to_string()))?,
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf)"))),
@ -242,7 +239,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul
return Err(CliError::Usage(format!("render writes .png, .jpg or .webp, not `{out}`")));
}
let pix = render_page(&doc, page, scale)?;
write(out, &encode(&pix, &e)?)?;
write(out, &encode(&doc, &pix, &e)?)?;
println!("{out} ({}×{})", pix.width(), pix.height());
Ok(ExitCode::SUCCESS)
}

133
crates/color/src/icc.rs Normal file
View File

@ -0,0 +1,133 @@
//! A minimal ICC v4 display profile writer (matrix/TRC: colorants, white point, chromatic
//! adaptation and parametric sRGB curves), built from the ICC.1 specification. Used to tag
//! exported images with the document's colour profile.
use crate::profile::{Mat3, apply, inverse, mul, xy_to_xyz};
/// The ICC profile connection space illuminant (D50).
const PCS_D50: [f64; 3] = [0.9642, 1.0, 0.8249];
/// The Bradford cone response matrix (for chromatic adaptation).
const BRADFORD: Mat3 = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]];
/// sRGB transfer curve as ICC parametric curve type 3: g, a, b, c, d.
const SRGB_CURVE: [f64; 5] = [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045];
/// A big-endian byte writer.
#[derive(Default)]
struct Be(Vec<u8>);
impl Be {
fn u16(&mut self, v: u16) -> &mut Self {
self.0.extend_from_slice(&v.to_be_bytes());
self
}
fn u32(&mut self, v: u32) -> &mut Self {
self.0.extend_from_slice(&v.to_be_bytes());
self
}
fn sig(&mut self, s: &[u8; 4]) -> &mut Self {
self.0.extend_from_slice(s);
self
}
/// An `s15Fixed16Number`.
fn fixed(&mut self, v: f64) -> &mut Self {
let v = if v.is_finite() { (v * 65536.0).round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32 } else { 0 };
self.0.extend_from_slice(&v.to_be_bytes());
self
}
fn zeros(&mut self, n: usize) -> &mut Self {
self.0.resize(self.0.len() + n, 0);
self
}
}
/// `multiLocalizedUnicodeType` with one en-US record.
fn mluc(text: &str) -> Vec<u8> {
let utf16: Vec<u8> = text.encode_utf16().flat_map(u16::to_be_bytes).collect();
let mut b = Be::default();
b.sig(b"mluc").zeros(4).u32(1).u32(12).sig(b"enUS").u32(utf16.len() as u32).u32(28);
b.0.extend_from_slice(&utf16);
b.0
}
fn xyz(v: [f64; 3]) -> Vec<u8> {
let mut b = Be::default();
b.sig(b"XYZ ").zeros(4).fixed(v[0]).fixed(v[1]).fixed(v[2]);
b.0
}
/// The Bradford matrix adapting XYZ under `src` white to XYZ under `dst` white.
pub(crate) fn bradford(src: [f64; 3], dst: [f64; 3]) -> Mat3 {
let (s, d) = (apply(&BRADFORD, src), apply(&BRADFORD, dst));
let ratio = |i: usize| if s[i].abs() < 1e-12 { 1.0 } else { d[i] / s[i] };
let scale = [[ratio(0), 0.0, 0.0], [0.0, ratio(1), 0.0], [0.0, 0.0, ratio(2)]];
let inv = inverse(&BRADFORD).unwrap_or(scale);
mul(&inv, &mul(&scale, &BRADFORD))
}
/// A matrix/TRC RGB display profile: `to_xyz` maps linear RGB to XYZ under `white`; every channel
/// uses the sRGB curve.
pub(crate) fn matrix_trc_profile(description: &str, to_xyz: &Mat3, white: (f64, f64)) -> Vec<u8> {
let chad = bradford(xy_to_xyz(white), PCS_D50);
let colorants = mul(&chad, to_xyz);
let column = |j: usize| [colorants[0][j], colorants[1][j], colorants[2][j]];
let mut sf32 = Be::default();
sf32.sig(b"sf32").zeros(4);
for row in chad {
for v in row {
sf32.fixed(v);
}
}
let mut para = Be::default();
para.sig(b"para").zeros(4).u16(3).u16(0);
for v in SRGB_CURVE {
para.fixed(v);
}
let tags: [(&[u8; 4], Vec<u8>); 10] = [
(b"desc", mluc(description)),
(b"cprt", mluc("No copyright, use freely")),
(b"wtpt", xyz(PCS_D50)),
(b"chad", sf32.0),
(b"rXYZ", xyz(column(0))),
(b"gXYZ", xyz(column(1))),
(b"bXYZ", xyz(column(2))),
(b"rTRC", para.0.clone()),
(b"gTRC", para.0.clone()),
(b"bTRC", para.0),
];
// Tag data follows the header and the tag table, each element 4-byte aligned.
let mut offset = 128 + 4 + 12 * tags.len();
let mut table = Be::default();
table.u32(tags.len() as u32);
let mut data = Vec::new();
for (sig, bytes) in &tags {
table.sig(sig).u32(offset as u32).u32(bytes.len() as u32);
let padded = bytes.len().div_ceil(4) * 4;
data.extend_from_slice(bytes);
data.resize(data.len() + padded - bytes.len(), 0);
offset += padded;
}
let mut h = Be::default();
h.u32(offset as u32) // profile size
.zeros(4) // preferred CMM
.u32(0x0430_0000) // version 4.3
.sig(b"mntr")
.sig(b"RGB ")
.sig(b"XYZ ")
.u16(2026)
.u16(1)
.u16(1)
.zeros(6) // creation date and time
.sig(b"acsp")
.zeros(4 + 4 + 4 + 4 + 8) // platform, flags, manufacturer, model, attributes
.u32(0) // perceptual rendering intent
.fixed(PCS_D50[0])
.fixed(PCS_D50[1])
.fixed(PCS_D50[2])
.zeros(4 + 16 + 28); // creator, profile id (not computed), reserved
let mut out = h.0;
out.extend_from_slice(&table.0);
out.extend_from_slice(&data);
out
}

View File

@ -2,15 +2,19 @@
//!
//! [`Color`] is non-premultiplied sRGB in `0..=1` floats (what a design panel shows as hex + opacity).
//! [`Paint`] is one entry of a node's fill or stroke list: solid, the four gradient kinds or an
//! image, each with its own opacity, visibility and blend mode.
//! image, each with its own opacity, visibility and blend mode. [`ColorProfile`] says which RGB
//! space (sRGB or Display P3) a document's values are in and converts between them.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use serde::{Deserialize, Serialize};
mod blend;
mod hsx;
mod icc;
mod profile;
pub use blend::BlendMode;
pub use hsx::*;
pub use profile::{ColorProfile, ProfileConversion};
/// A non-premultiplied sRGB colour with alpha, components in `0..=1`. Deserializes from
/// `{r, g, b, a}`, `[r, g, b(, a)]`, or a CSS/hex string (`"#0D99FF"`, `"rgba(…)"`).
@ -324,6 +328,42 @@ impl ImageFilters {
pub fn is_identity(&self) -> bool {
*self == ImageFilters::default()
}
/// The built-in adjustment presets: `(id, label, adjustments)`. "Reset" is the identity.
pub const PRESETS: [(&'static str, &'static str, ImageFilters); 7] = [
("vivid", "Vivid", ImageFilters { contrast: 0.2, saturation: 0.45, highlights: -0.1, shadows: 0.1, ..ImageFilters::NONE }),
("muted", "Muted", ImageFilters { contrast: -0.15, saturation: -0.45, ..ImageFilters::NONE }),
("warm", "Warm", ImageFilters { temperature: 0.5, tint: 0.1, saturation: 0.1, ..ImageFilters::NONE }),
("cool", "Cool", ImageFilters { temperature: -0.5, tint: -0.05, ..ImageFilters::NONE }),
("blackAndWhite", "Black & white", ImageFilters { saturation: -1.0, contrast: 0.15, ..ImageFilters::NONE }),
("fade", "Fade", ImageFilters { contrast: -0.35, saturation: -0.2, shadows: 0.35, highlights: -0.1, ..ImageFilters::NONE }),
("reset", "Reset", ImageFilters::NONE),
];
/// No adjustment (a `const` [`Default`]).
pub const NONE: ImageFilters =
ImageFilters { exposure: 0.0, contrast: 0.0, saturation: 0.0, temperature: 0.0, tint: 0.0, highlights: 0.0, shadows: 0.0 };
/// The built-in preset `name` (its id or label, ignoring case, spaces, `-`, `_` and `&`).
pub fn preset(name: &str) -> Option<ImageFilters> {
let key = |s: &str| s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect::<String>();
let k = key(name);
Self::PRESETS.iter().find(|(id, label, _)| key(id) == k || key(label) == k).map(|(_, _, f)| *f)
}
/// Every value clamped into `-1..=1` (NaN → 0).
pub fn sanitized(self) -> Self {
let c = |v: f32| if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 };
Self {
exposure: c(self.exposure),
contrast: c(self.contrast),
saturation: c(self.saturation),
temperature: c(self.temperature),
tint: c(self.tint),
highlights: c(self.highlights),
shadows: c(self.shadows),
}
}
}
/// An image paint: the image is referenced by its content hash in the document's image store.
@ -460,6 +500,15 @@ impl Paint {
}
}
/// Visit every colour of this paint (the solid colour or the gradient stops).
pub fn for_each_color_mut(&mut self, f: &mut dyn FnMut(&mut Color)) {
match &mut self.kind {
PaintKind::Solid { color } => f(color),
PaintKind::Gradient(g) => g.stops.iter_mut().for_each(|s| f(&mut s.color)),
PaintKind::Image(_) => {}
}
}
/// A representative colour (for swatches and outlines).
pub fn preview_color(&self) -> Color {
match &self.kind {
@ -470,5 +519,7 @@ impl Paint {
}
}
#[cfg(test)]
mod profile_tests;
#[cfg(test)]
mod tests;

198
crates/color/src/profile.rs Normal file
View File

@ -0,0 +1,198 @@
//! Colour profiles: the RGB space a document's colour values live in (sRGB or Display P3), the
//! conversion between them and the ICC profile and PNG `cICP` code points that tag exports.
//!
//! Both spaces share the D65 white point and the sRGB transfer function; they differ only in their
//! primaries. A conversion decodes the transfer function, maps linear RGB through XYZ with the two
//! primaries matrices (derived here from the published chromaticities) and re-encodes. Colours a
//! smaller space can't hold are clipped per channel.
use serde::{Deserialize, Serialize};
use crate::Color;
/// The colour space a document's colour values are in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ColorProfile {
#[default]
Srgb,
DisplayP3,
}
/// A 3×3 matrix, row-major.
pub(crate) type Mat3 = [[f64; 3]; 3];
const IDENTITY: Mat3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
/// The D65 white point (CIE 1931 xy) both spaces use.
const D65: (f64, f64) = (0.3127, 0.3290);
impl ColorProfile {
pub const ALL: [ColorProfile; 2] = [ColorProfile::Srgb, ColorProfile::DisplayP3];
/// The id used in commands and files (`srgb`, `displayP3`).
pub fn id(self) -> &'static str {
match self {
ColorProfile::Srgb => "srgb",
ColorProfile::DisplayP3 => "displayP3",
}
}
pub fn label(self) -> &'static str {
match self {
ColorProfile::Srgb => "sRGB",
ColorProfile::DisplayP3 => "Display P3",
}
}
/// Parse an id or label, ignoring case, spaces, `-` and `_` (`"Display P3"`, `"display-p3"`, `"p3"`).
pub fn parse(s: &str) -> Option<Self> {
let k: String = s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect();
match k.as_str() {
"srgb" => Some(ColorProfile::Srgb),
"displayp3" | "p3" => Some(ColorProfile::DisplayP3),
_ => None,
}
}
pub fn is_srgb(&self) -> bool {
*self == ColorProfile::Srgb
}
/// Red, green and blue primaries (CIE 1931 xy).
fn primaries(self) -> [(f64, f64); 3] {
match self {
ColorProfile::Srgb => [(0.64, 0.33), (0.30, 0.60), (0.15, 0.06)],
ColorProfile::DisplayP3 => [(0.680, 0.320), (0.265, 0.690), (0.150, 0.060)],
}
}
/// Linear RGB → CIE XYZ (D65).
pub(crate) fn to_xyz(self) -> Mat3 {
rgb_to_xyz(self.primaries(), D65)
}
/// The conversion that keeps a colour's appearance when its values move from this space to `to`.
pub fn conversion_to(self, to: ColorProfile) -> ProfileConversion {
if self == to {
return ProfileConversion { m: None };
}
let m = mul(&inverse(&to.to_xyz()).unwrap_or(IDENTITY), &self.to_xyz());
ProfileConversion { m: Some(m) }
}
/// PNG `cICP` code points (ITU-T H.273): colour primaries, transfer function (13: the sRGB
/// curve), matrix coefficients (0: RGB) and full range.
pub fn cicp(self) -> [u8; 4] {
match self {
ColorProfile::Srgb => [1, 13, 0, 1],
ColorProfile::DisplayP3 => [12, 13, 0, 1],
}
}
/// An ICC v4 matrix/TRC display profile for this space (generated, not copied from anywhere).
pub fn icc(self) -> Vec<u8> {
crate::icc::matrix_trc_profile(self.label(), &self.to_xyz(), D65)
}
}
/// A colour conversion between two profiles (see [`ColorProfile::conversion_to`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ProfileConversion {
/// Linear RGB → linear RGB; `None` for the identity.
m: Option<Mat3>,
}
impl ProfileConversion {
pub fn is_identity(&self) -> bool {
self.m.is_none()
}
/// `c` in the target space (alpha unchanged; out-of-gamut channels clip).
pub fn apply(&self, c: Color) -> Color {
let Some(m) = &self.m else { return c };
let lin = [decode(c.r), decode(c.g), decode(c.b)];
let [r, g, b] = apply(m, lin);
Color::new(encode(r), encode(g), encode(b), c.a)
}
/// Convert premultiplied 8-bit RGBA pixels in place (table-driven; a no-op for the identity).
pub fn apply_pixels(&self, rgba: &mut [u8]) {
let Some(m) = &self.m else { return };
let to_linear: [f32; 256] = std::array::from_fn(|i| decode(i as f32 / 255.0) as f32);
let to_encoded: Vec<u8> = (0..ENCODE_STEPS).map(|i| (encode(i as f64 / (ENCODE_STEPS - 1) as f64) * 255.0).round() as u8).collect();
let m = m.map(|row| row.map(|v| v as f32));
let lin = |v: u8| to_linear.get(usize::from(v)).copied().unwrap_or(0.0);
let enc = |l: f32| to_encoded.get((l.clamp(0.0, 1.0) * (ENCODE_STEPS - 1) as f32).round() as usize).copied().unwrap_or(0);
for [r, g, b, a] in rgba.as_chunks_mut::<4>().0.iter_mut() {
if *a == 0 {
continue;
}
let alpha = u32::from(*a);
let straight = |c: u8| if alpha == 255 { c } else { ((u32::from(c) * 255 + alpha / 2) / alpha).min(255) as u8 };
let v = [lin(straight(*r)), lin(straight(*g)), lin(straight(*b))];
let out = m.map(|row| enc(row[0] * v[0] + row[1] * v[1] + row[2] * v[2]));
let premul = |c: u8| if alpha == 255 { c } else { ((u32::from(c) * alpha + 127) / 255) as u8 };
(*r, *g, *b) = (premul(out[0]), premul(out[1]), premul(out[2]));
}
}
}
/// Entries of the linear → encoded table [`ProfileConversion::apply_pixels`] uses.
const ENCODE_STEPS: usize = 4096;
/// The sRGB transfer function, encoded value → linear light.
fn decode(v: f32) -> f64 {
let v = f64::from(if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 });
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
/// The sRGB transfer function, linear light → encoded value (clipped to 0..=1).
fn encode(l: f64) -> f32 {
let l = if l.is_finite() { l.clamp(0.0, 1.0) } else { 0.0 };
(if l <= 0.003_130_8 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 }) as f32
}
/// The XYZ of chromaticity `(x, y)` at luminance 1.
pub(crate) fn xy_to_xyz((x, y): (f64, f64)) -> [f64; 3] {
let y = if y.abs() < 1e-12 { 1e-12 } else { y };
[x / y, 1.0, (1.0 - x - y) / y]
}
/// The linear RGB → XYZ matrix of an RGB space from its primaries and white point.
fn rgb_to_xyz(primaries: [(f64, f64); 3], white: (f64, f64)) -> Mat3 {
let [r, g, b] = primaries.map(xy_to_xyz);
let p = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]];
// Scale each primary so that RGB (1, 1, 1) lands on the white point.
let s = apply(&inverse(&p).unwrap_or(IDENTITY), xy_to_xyz(white));
p.map(|row| [row[0] * s[0], row[1] * s[1], row[2] * s[2]])
}
pub(crate) fn mul(a: &Mat3, b: &Mat3) -> Mat3 {
let mut out = [[0.0; 3]; 3];
for (i, row) in out.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell = (0..3).map(|k| a[i][k] * b[k][j]).sum();
}
}
out
}
pub(crate) fn apply(m: &Mat3, v: [f64; 3]) -> [f64; 3] {
m.map(|row| row[0] * v[0] + row[1] * v[1] + row[2] * v[2])
}
pub(crate) fn inverse(m: &Mat3) -> Option<Mat3> {
let [[a, b, c], [d, e, f], [g, h, i]] = *m;
let co = [e * i - f * h, f * g - d * i, d * h - e * g];
let det = a * co[0] + b * co[1] + c * co[2];
if !det.is_finite() || det.abs() < 1e-12 {
return None;
}
let inv = 1.0 / det;
Some([
[co[0] * inv, (c * h - b * i) * inv, (b * f - c * e) * inv],
[co[1] * inv, (a * i - c * g) * inv, (c * d - a * f) * inv],
[co[2] * inv, (b * g - a * h) * inv, (a * e - b * d) * inv],
])
}

View File

@ -0,0 +1,165 @@
use super::*;
fn close(a: f32, b: f32, eps: f32) -> bool {
(a - b).abs() <= eps
}
#[test]
fn profile_ids_parse_and_serde() {
for p in ColorProfile::ALL {
assert_eq!(ColorProfile::parse(p.id()), Some(p));
assert_eq!(ColorProfile::parse(p.label()), Some(p));
assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.id()));
}
assert_eq!(ColorProfile::parse("display-p3"), Some(ColorProfile::DisplayP3));
assert_eq!(ColorProfile::parse("P3"), Some(ColorProfile::DisplayP3));
assert_eq!(ColorProfile::parse("adobe rgb"), None);
assert_eq!(ColorProfile::default(), ColorProfile::Srgb);
}
#[test]
fn srgb_red_in_display_p3() {
// sRGB #FF0000 is color(display-p3 0.9175 0.2003 0.1386).
let p3 = ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3).apply(Color::new(1.0, 0.0, 0.0, 0.5));
assert!(close(p3.r, 0.9175, 0.001) && close(p3.g, 0.2003, 0.001) && close(p3.b, 0.1386, 0.001), "{p3:?}");
assert_eq!(p3.a, 0.5);
}
#[test]
fn conversions_round_trip_and_keep_neutrals() {
let to_p3 = ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3);
let back = ColorProfile::DisplayP3.conversion_to(ColorProfile::Srgb);
assert!(ColorProfile::Srgb.conversion_to(ColorProfile::Srgb).is_identity());
for hex in ["0D99FF", "F24822", "14AE5C", "000000", "FFFFFF", "808080", "9747FF", "FF0000", "00FF00"] {
let c = Color::from_hex(hex).unwrap();
assert_eq!(back.apply(to_p3.apply(c)).to_hex(), hex);
}
// Shared white point: greys stay grey.
let g = to_p3.apply(Color::from_hex("808080").unwrap());
assert!(close(g.r, g.g, 1e-4) && close(g.g, g.b, 1e-4), "{g:?}");
// Display P3's pure green is outside sRGB: it clips into range.
let c = back.apply(Color::new(0.0, 1.0, 0.0, 1.0));
assert!(c.r == 0.0 && c.g == 1.0 && c.b == 0.0, "{c:?}");
}
#[test]
fn conversion_survives_junk() {
let c = ColorProfile::DisplayP3.conversion_to(ColorProfile::Srgb).apply(Color { r: f32::NAN, g: f32::INFINITY, b: -3.0, a: 2.0 });
for v in [c.r, c.g, c.b, c.a] {
assert!((0.0..=1.0).contains(&v));
}
}
fn be_u32(b: &[u8], at: usize) -> u32 {
u32::from_be_bytes(b[at..at + 4].try_into().unwrap())
}
fn s15(b: &[u8], at: usize) -> f64 {
f64::from(i32::from_be_bytes(b[at..at + 4].try_into().unwrap())) / 65536.0
}
/// The tag `sig`'s data in an ICC profile.
fn tag<'a>(icc: &'a [u8], sig: &[u8; 4]) -> &'a [u8] {
let n = be_u32(icc, 128) as usize;
(0..n)
.map(|i| 132 + i * 12)
.find(|at| &icc[*at..*at + 4] == sig)
.map(|at| {
let (off, len) = (be_u32(icc, at + 4) as usize, be_u32(icc, at + 8) as usize);
&icc[off..off + len]
})
.unwrap()
}
#[test]
fn icc_profiles_are_well_formed() {
// Published D50-adapted colorants of sRGB and Display P3.
let expect = [
(ColorProfile::Srgb, [[0.4361, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7142]]),
(ColorProfile::DisplayP3, [[0.5151, 0.2412, -0.0010], [0.2920, 0.6922, 0.0419], [0.1571, 0.0666, 0.7844]]),
];
for (p, cols) in expect {
let icc = p.icc();
assert_eq!(be_u32(&icc, 0) as usize, icc.len());
assert_eq!(icc.len() % 4, 0);
assert_eq!(&icc[36..40], b"acsp");
assert_eq!(&icc[12..24], b"mntrRGB XYZ ");
assert_eq!(be_u32(&icc, 8), 0x0430_0000);
for (sig, col) in [(b"rXYZ", cols[0]), (b"gXYZ", cols[1]), (b"bXYZ", cols[2])] {
let t = tag(&icc, sig);
assert_eq!(&t[..4], b"XYZ ");
for (k, v) in col.iter().enumerate() {
assert!((s15(t, 8 + k * 4) - v).abs() < 0.0006, "{p:?} {} {k}: {}", String::from_utf8_lossy(sig), s15(t, 8 + k * 4));
}
}
// The colorants add up to the D50 white point.
let sum: f64 = [b"rXYZ", b"gXYZ", b"bXYZ"].iter().map(|s| s15(tag(&icc, s), 8)).sum();
assert!((sum - 0.9642).abs() < 0.0005, "{sum}");
let trc = tag(&icc, b"gTRC");
assert_eq!(&trc[..4], b"para");
assert!((s15(trc, 12) - 2.4).abs() < 1e-4);
let desc = tag(&icc, b"desc");
assert_eq!(&desc[..4], b"mluc");
let text: Vec<u16> = desc[28..].chunks(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
assert_eq!(String::from_utf16(&text).unwrap(), p.label());
}
}
#[test]
fn pixel_conversion_matches_colour_conversion() {
let conv = ColorProfile::DisplayP3.conversion_to(ColorProfile::Srgb);
let samples = [[234u8, 51, 35, 255], [10, 200, 90, 255], [0, 0, 0, 255], [255, 255, 255, 255], [60, 120, 30, 128], [0, 0, 0, 0]];
let mut px: Vec<u8> = samples.iter().flatten().copied().collect();
conv.apply_pixels(&mut px);
for (s, got) in samples.iter().zip(px.chunks(4)) {
assert_eq!(got[3], s[3], "alpha stays");
if s[3] == 0 {
assert_eq!(got, s, "transparent pixels stay");
continue;
}
// Premultiplied in, premultiplied out.
let a = f32::from(s[3]) / 255.0;
let un = Color::new(f32::from(s[0]) / 255.0 / a, f32::from(s[1]) / 255.0 / a, f32::from(s[2]) / 255.0 / a, 1.0);
let want = conv.apply(un).to_rgba8();
for c in 0..3 {
let w = (f32::from(want[c]) * a).round();
assert!((f32::from(got[c]) - w).abs() <= 2.0, "{s:?} → {got:?}, want {want:?}");
}
}
// sRGB #FF0000 kept by a Display P3 file comes back to the screen as itself.
let mut red = [234, 51, 35, 255];
conv.apply_pixels(&mut red);
assert_eq!(red, [255, 0, 0, 255]);
let mut same = px.clone();
ColorProfile::Srgb.conversion_to(ColorProfile::Srgb).apply_pixels(&mut same);
assert_eq!(same, px);
}
#[test]
fn image_presets() {
assert_eq!(ImageFilters::preset("Black & white"), ImageFilters::preset("blackAndWhite"));
assert_eq!(ImageFilters::preset("black-and-white").map(|f| f.saturation), Some(-1.0));
assert_eq!(ImageFilters::preset("reset"), Some(ImageFilters::default()));
assert!(ImageFilters::preset("sepia-ish").is_none());
for (id, label, f) in ImageFilters::PRESETS {
assert_eq!(ImageFilters::preset(id), Some(f));
assert_eq!(ImageFilters::preset(label), Some(f));
assert_eq!(f.sanitized(), f, "{id} stays in range");
}
let junk = ImageFilters { exposure: f32::NAN, contrast: 9.0, ..ImageFilters::NONE }.sanitized();
assert_eq!((junk.exposure, junk.contrast), (0.0, 1.0));
}
#[test]
fn paint_colors_visit_solids_and_stops() {
let mut p = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::WHITE));
let mut n = 0;
p.for_each_color_mut(&mut |c| {
*c = Color::BLACK.with_alpha(c.a);
n += 1;
});
assert_eq!(n, 2);
assert_eq!(p.preview_color(), Color::BLACK);
let mut img = Paint::image("h", ImageScaleMode::Fill);
img.for_each_color_mut(&mut |_| panic!("images have no colours"));
}

128
crates/doc/src/colors.rs Normal file
View File

@ -0,0 +1,128 @@
//! Every colour value a document holds, for converting a file between colour profiles: paints
//! (fills, strokes, text runs), effects, layout grids, page and prototype backgrounds, overlay
//! backdrops, styles, colour variables and the document colours. Image pixels are not values and
//! are left alone.
use figcraft_color::{Color, ColorProfile, ImageFilters};
use serde::{Deserialize, Serialize};
use crate::proto::Action;
use crate::{Document, Effect, EffectKind, LayoutGrid, Node, NodeKind, Style, StyleKind, TextData, TextStyle, VariableValue, Variables};
/// A visitor over colour values.
pub type ColorVisitor<'a> = dyn FnMut(&mut Color) + 'a;
/// Image adjustments saved in a document under a name (the image paint's preset menu lists them).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ImagePreset {
pub name: String,
pub filters: ImageFilters,
}
impl Effect {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
match &mut self.kind {
EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) => f(&mut s.color),
EffectKind::Noise { color, .. } => f(color),
EffectKind::LayerBlur { .. } | EffectKind::BackgroundBlur { .. } | EffectKind::Texture { .. } => {}
}
}
}
impl TextStyle {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
self.fills.iter_mut().flatten().for_each(|p| p.for_each_color_mut(f));
}
}
impl TextData {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
self.style.for_each_color_mut(f);
self.runs.iter_mut().for_each(|r| r.style.for_each_color_mut(f));
}
}
impl Node {
/// Visit every colour of this node (not its children).
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
self.fills.iter_mut().chain(self.strokes.iter_mut()).for_each(|p| p.for_each_color_mut(f));
self.effects.iter_mut().for_each(|e| e.for_each_color_mut(f));
self.grids.iter_mut().for_each(|g: &mut LayoutGrid| f(&mut g.color));
match &mut self.kind {
NodeKind::Page { background, prototype, .. } => {
f(background);
f(&mut prototype.background);
}
NodeKind::Text(t) => t.for_each_color_mut(f),
_ => {}
}
for a in self.interactions.iter_mut().flat_map(|i| i.actions.iter_mut()) {
if let Action::OpenOverlay { background: Some(c), .. } = a {
f(c);
}
}
}
}
impl Style {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
match &mut self.kind {
StyleKind::Paint { paints } => paints.iter_mut().for_each(|p| p.for_each_color_mut(f)),
StyleKind::Text { style } => style.for_each_color_mut(f),
StyleKind::Effect { effects } => effects.iter_mut().for_each(|e| e.for_each_color_mut(f)),
StyleKind::Grid { grids } => grids.iter_mut().for_each(|g| f(&mut g.color)),
}
}
}
impl Variables {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
for v in self.variables.iter_mut().flat_map(|v| v.values.values_mut()) {
if let VariableValue::Color(c) = v {
f(c);
}
}
}
}
impl Document {
/// Replace every colour value in the document with `map(value)`.
pub fn map_colors(&mut self, map: impl Fn(Color) -> Color) {
let mut f = |c: &mut Color| *c = map(*c);
let ids: Vec<_> = self.ids().collect();
for id in ids {
if let Some(n) = self.node_mut(id) {
n.for_each_color_mut(&mut f);
}
}
self.styles.iter_mut().for_each(|s| s.for_each_color_mut(&mut f));
self.variables.for_each_color_mut(&mut f);
self.document_colors.iter_mut().for_each(&mut f);
}
/// Switch the document to `profile`. With `convert`, colour values are converted so that
/// colours keep their appearance; without, the values stay and are reinterpreted in the new
/// space. Returns whether anything changed.
pub fn set_color_profile(&mut self, profile: ColorProfile, convert: bool) -> bool {
if self.color_profile == profile {
return false;
}
if convert {
let conv = self.color_profile.conversion_to(profile);
self.map_colors(|c| conv.apply(c));
}
self.color_profile = profile;
true
}
/// This document with its colour values in sRGB (a converted copy when it is in another
/// profile), for formats whose colours are always sRGB.
pub fn in_srgb(&self) -> std::borrow::Cow<'_, Document> {
if self.color_profile.is_srgb() {
return std::borrow::Cow::Borrowed(self);
}
let mut d = self.clone();
d.set_color_profile(ColorProfile::Srgb, true);
std::borrow::Cow::Owned(d)
}
}

View File

@ -0,0 +1,119 @@
use super::*;
use figcraft_color::{ImageFilters, Paint, ProfileConversion};
use std::collections::BTreeMap;
const RED: Color = Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
fn to_p3() -> ProfileConversion {
ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3)
}
/// A file with a colour in every place one can live.
fn colourful() -> (Document, NodeId) {
let mut d = Document::new("c");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
let t = d.create(p, NodeKind::Text(TextData::default()), "T", 0.0, 0.0, 10.0, 10.0).unwrap();
{
let n = d.node_mut(r).unwrap();
n.fills = vec![Paint::solid(RED)];
n.strokes = vec![Paint::solid(RED)];
n.effects = vec![Effect { kind: EffectKind::DropShadow(Shadow { color: RED, ..Shadow::default() }), visible: true }];
n.grids = vec![LayoutGrid { color: RED, ..LayoutGrid::default() }];
n.interactions = vec![proto::Interaction {
trigger: Default::default(),
actions: vec![proto::Action::OpenOverlay { dest: p, position: Default::default(), close_on_outside: false, background: Some(RED) }],
transition: Default::default(),
duration_ms: 300,
easing: Default::default(),
}];
}
if let Some(NodeKind::Text(td)) = d.node_mut(t).map(|n| &mut n.kind) {
td.characters = "ab".into();
td.runs = vec![StyleRun { start: 0, end: 1, style: TextStyle { fills: Some(vec![Paint::solid(RED)]), ..TextStyle::default() } }];
}
if let Some(NodeKind::Page { background, .. }) = d.node_mut(p).map(|n| &mut n.kind) {
*background = RED;
}
d.styles.push(Style {
id: "s1".into(),
name: "Red".into(),
description: String::new(),
kind: StyleKind::Paint { paints: vec![Paint::solid(RED)] },
});
d.variables.collections.push(Collection { id: "c1".into(), name: "C".into(), modes: vec![Mode { id: "m1".into(), name: "M".into() }] });
d.variables.variables.push(Variable {
id: "v1".into(),
name: "red".into(),
collection: "c1".into(),
kind: VariableType::Color,
values: BTreeMap::from([("m1".to_string(), VariableValue::Color(RED))]),
description: String::new(),
scopes: None,
});
d.document_colors.push(RED);
(d, r)
}
fn all_colors(d: &mut Document) -> Vec<Color> {
let mut out = Vec::new();
let mut f = |c: &mut Color| out.push(*c);
for id in d.ids().collect::<Vec<_>>() {
d.node_mut(id).unwrap().for_each_color_mut(&mut f);
}
d.styles.iter_mut().for_each(|s| s.for_each_color_mut(&mut f));
d.variables.for_each_color_mut(&mut f);
d.document_colors.iter_mut().for_each(&mut f);
out
}
#[test]
fn converting_keeps_appearance_everywhere() {
let (mut d, _) = colourful();
let reds = all_colors(&mut d.clone()).into_iter().filter(|c| *c == RED).count();
assert_eq!(reds, 10, "fill, stroke, shadow, grid, overlay, text run, page, style, variable, document colour");
assert!(d.set_color_profile(ColorProfile::DisplayP3, true));
assert_eq!(d.color_profile, ColorProfile::DisplayP3);
let p3_red = to_p3().apply(RED);
assert_eq!(all_colors(&mut d).iter().filter(|c| **c == p3_red).count(), reds, "every red was converted");
assert!(!d.set_color_profile(ColorProfile::DisplayP3, true), "already Display P3");
// And back again.
d.set_color_profile(ColorProfile::Srgb, true);
assert_eq!(all_colors(&mut d).iter().filter(|c| c.to_hex() == "FF0000").count(), reds);
}
#[test]
fn assigning_keeps_values() {
let (mut d, r) = colourful();
assert!(d.set_color_profile(ColorProfile::DisplayP3, false));
assert_eq!(d.node(r).unwrap().fills[0].solid_color(), Some(RED));
assert_eq!(d.color_profile, ColorProfile::DisplayP3);
}
#[test]
fn in_srgb_borrows_or_converts() {
let (mut d, r) = colourful();
assert!(matches!(d.in_srgb(), std::borrow::Cow::Borrowed(_)));
d.set_color_profile(ColorProfile::DisplayP3, false);
let s = d.in_srgb();
assert_eq!(s.color_profile, ColorProfile::Srgb);
// Display P3 red is more saturated than sRGB can show: it clips to sRGB red.
assert_eq!(s.node(r).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000");
let p3_green = ColorProfile::DisplayP3.conversion_to(ColorProfile::Srgb).apply(Color::new(0.2, 0.6, 0.3, 1.0));
assert_ne!(p3_green.to_hex(), Color::new(0.2, 0.6, 0.3, 1.0).to_hex(), "in-gamut colours move");
assert_eq!(d.color_profile, ColorProfile::DisplayP3, "the original is untouched");
}
#[test]
fn profile_and_presets_serialize_only_when_set() {
let mut d = Document::new("s");
let v = serde_json::to_value(&d).unwrap();
assert!(v.get("color_profile").is_none() && v.get("image_presets").is_none());
d.color_profile = ColorProfile::DisplayP3;
d.image_presets.push(ImagePreset { name: "Mine".into(), filters: ImageFilters { contrast: 0.5, ..Default::default() } });
let v = serde_json::to_value(&d).unwrap();
assert_eq!(v["color_profile"], "displayP3");
let back: Document = serde_json::from_value(v).unwrap();
assert_eq!(back.color_profile, ColorProfile::DisplayP3);
assert_eq!(back.image_presets, d.image_presets);
}

View File

@ -8,6 +8,7 @@
//! live in higher crates.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod colors;
pub mod comments;
pub mod geometry;
pub mod library;
@ -19,6 +20,7 @@ pub mod text;
pub mod vector;
pub mod vector_faces;
pub use colors::*;
pub use comments::*;
pub use figcraft_color as color;
pub use figcraft_geom as geom;
@ -30,7 +32,7 @@ pub use text::*;
pub use vector::*;
pub use vector_faces::Face;
use figcraft_color::Color;
use figcraft_color::{Color, ColorProfile};
use figcraft_geom::{Affine, Point, Rect};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
@ -122,6 +124,12 @@ pub struct Document {
/// This file's published library; serialized separately by the format crate.
#[serde(skip)]
pub published: Option<Arc<PublishedLibrary>>,
/// The colour space colour values are in (sRGB unless the file says otherwise).
#[serde(default, skip_serializing_if = "ColorProfile::is_srgb")]
pub color_profile: ColorProfile,
/// Image adjustment presets saved in this file.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub image_presets: Vec<ImagePreset>,
}
impl Default for Document {
@ -147,6 +155,8 @@ impl Document {
comments: Vec::new(),
libraries: Arc::default(),
published: None,
color_profile: ColorProfile::Srgb,
image_presets: Vec::new(),
};
let p = doc.add_page("Page 1");
doc.current_page = Some(p);
@ -765,6 +775,8 @@ fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {
}
}
#[cfg(test)]
mod colors_tests;
#[cfg(test)]
mod diff_tests;
#[cfg(test)]

View File

@ -37,6 +37,7 @@ pub static MENU: &[MenuItem] = &[
m!("File", SEP, "", ""),
m!("File", "Duplicate", "file.duplicate", ""),
m!("File", "Rename", "file.rename", ""),
m!("File", "Color profile…", "file.colorProfile", ""),
m!("File", "Revert to saved", "file.revert", ""),
m!("File", "Close tab", "file.close", "Cmd+W"),
m!("File", "Open recent", "file.openRecent", ""),

View File

@ -70,6 +70,13 @@ pub fn specs() -> Vec<CommandSpec> {
s.doc_mut()?.name = name;
ok()
}),
spec!(
"file.colorProfile",
"Color profile",
"{profile?: srgb|displayP3, convert? (default true: convert colour values so colours keep their appearance; false: keep the values and reinterpret them)} without `profile`, returns the current one (and the app opens the dialog). Returns {profile, changed}",
true,
color_profile
),
spec!("file.duplicate", "Duplicate file", "{}", false, |s, _| {
let ds = s.ds()?;
let mut d = (*ds.doc).clone();
@ -546,6 +553,18 @@ pub fn file_bytes(ds: &DocState) -> Result<Vec<u8>> {
figcraft_format::save_bytes_with_versions(&doc, thumb.as_deref(), &ds.versions).map_err(|e| EngineError::Other(e.to_string()))
}
fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.colorProfile";
let Some(name) = string(p, "profile") else {
s.request_ui("dialog.colorProfile", json!({}));
return Ok(json!({"profile": s.doc()?.color_profile.id(), "changed": false}));
};
let profile = figcraft_color::ColorProfile::parse(&name).ok_or_else(|| bad(cmd, "profile: srgb or displayP3"))?;
let convert = boolean(p, "convert").unwrap_or(true);
let changed = s.doc()?.color_profile != profile && s.doc_mut()?.set_color_profile(profile, convert);
Ok(json!({"profile": profile.id(), "changed": changed}))
}
fn save_to(s: &mut Session, path: String) -> Result<Value> {
let ds = s.ds_mut()?;
let bytes = file_bytes(ds)?;
@ -590,10 +609,12 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale:
return Err(EngineError::Disabled("file.export".into(), "nothing to export".into()));
}
match format {
ExportFormat::Svg => figcraft_svg::export_svg(doc, ids, &Default::default())
// SVG and PDF colours are sRGB: a Display P3 file exports converted (out-of-gamut colours clip).
ExportFormat::Svg => figcraft_svg::export_svg(&doc.in_srgb(), ids, &Default::default())
.map(String::into_bytes)
.ok_or_else(|| EngineError::Other("nothing to export".into())),
ExportFormat::Pdf => {
let doc = &*doc.in_srgb();
// Frames export one page each; anything else shares one page.
let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
let r = if all_frames {
@ -622,12 +643,7 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale:
})
};
let pix = pix.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
match format {
ExportFormat::Jpg => figcraft_render::encode_jpeg(&pix, figcraft_color::Color::WHITE, 90),
ExportFormat::Webp => figcraft_render::encode_webp(&pix),
_ => figcraft_render::encode_png(&pix),
}
.map_err(EngineError::Other)
figcraft_render::encode_export(&pix, format, doc.color_profile).map_err(EngineError::Other)
}
}
}

View File

@ -0,0 +1,173 @@
//! Image paints: adjustment presets, built in and saved in the file.
use figcraft_color::{ImageFilters, PaintKind};
use figcraft_doc::{Document, ImagePreset};
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"image.adjust",
"Adjust image",
"{ids?, target? (fill|stroke, default fill), index? (default every image paint), preset? (vivid|muted|warm|cool|blackAndWhite|fade|reset, or the name of a preset saved in the file), filters? ({exposure, contrast, saturation, temperature, tint, highlights, shadows}: each -1..1, applied over the preset)} sets the adjustments of the layers' image paints; returns {changed}",
true,
adjust
),
spec!(
"image.savePreset",
"Save image preset",
"{name, filters? (default the adjustments of the first image paint of `ids`/the selection), ids?, target?, index?} saved in the file; a preset with the same name is replaced",
true,
save_preset
),
spec!("image.deletePreset", "Delete image preset", "{name}", true, |s, p| {
let name = req_string("image.deletePreset", p, "name")?;
let doc = s.doc_mut()?;
let before = doc.image_presets.len();
doc.image_presets.retain(|x| x.name != name);
if doc.image_presets.len() == before {
return Err(bad("image.deletePreset", format!("no preset named `{name}`")));
}
ok()
}),
spec!("image.presets", "Image presets", "{} → {builtIn: [{id, label, filters}], saved: [{name, filters}]}", false, |s, _| {
let built_in: Vec<Value> = ImageFilters::PRESETS.iter().map(|(id, label, f)| json!({"id": id, "label": label, "filters": f})).collect();
Ok(json!({"builtIn": built_in, "saved": s.doc()?.image_presets}))
}),
]
}
/// The longest name a saved preset may have.
const MAX_NAME: usize = 100;
/// The most presets a file keeps.
const MAX_PRESETS: usize = 500;
/// Whether `p` asks for strokes (`target: stroke`) rather than fills.
fn strokes(cmd: &str, p: &Value) -> Result<bool> {
match string(p, "target").as_deref() {
None | Some("fill") | Some("fills") => Ok(false),
Some("stroke") | Some("strokes") => Ok(true),
Some(_) => Err(bad(cmd, "target: fill|stroke")),
}
}
/// The preset `name`: one saved in the file under that exact name, else a built-in one.
pub fn find_preset(doc: &Document, name: &str) -> Option<ImageFilters> {
doc.image_presets.iter().find(|x| x.name == name).map(|x| x.filters).or_else(|| ImageFilters::preset(name))
}
/// `base` with the adjustments `v` names (an object of -1..1 numbers) set.
fn with_values(cmd: &str, base: ImageFilters, v: &Value) -> Result<ImageFilters> {
let Value::Object(m) = v else { return Err(bad(cmd, "filters: an object of adjustments")) };
let mut f = base;
for (k, val) in m {
let x = f64_of(val).ok_or_else(|| bad(cmd, format!("filters.{k}: a number")))? as f32;
let slot = match k.as_str() {
"exposure" => &mut f.exposure,
"contrast" => &mut f.contrast,
"saturation" => &mut f.saturation,
"temperature" => &mut f.temperature,
"tint" => &mut f.tint,
"highlights" => &mut f.highlights,
"shadows" => &mut f.shadows,
_ => return Err(bad(cmd, format!("unknown adjustment `{k}`"))),
};
*slot = x;
}
Ok(f.sanitized())
}
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.adjust";
let ids = req_targets(cmd, s, p)?;
let stroke = strokes(cmd, p)?;
let index = match get(p, "index") {
None | Some(Value::Null) => None,
Some(_) => Some(req_index(cmd, p, "index")?),
};
let preset = match string(p, "preset") {
Some(name) => Some(find_preset(s.doc()?, &name).ok_or_else(|| bad(cmd, format!("no preset named `{name}`")))?),
None => None,
};
let values = get(p, "filters").filter(|v| !v.is_null());
if preset.is_none() && values.is_none() {
return Err(bad(cmd, "pass `preset` or `filters`"));
}
let doc = s.doc_mut()?;
let mut changed = 0;
for id in ids {
let n = doc.get(id)?;
if !if stroke { &n.strokes } else { &n.fills }.iter().any(|x| matches!(x.kind, PaintKind::Image(_))) {
continue;
}
let n = doc.get_mut(id)?;
let list = if stroke { &mut n.strokes } else { &mut n.fills };
let mut touched = false;
for (i, paint) in list.iter_mut().enumerate() {
let PaintKind::Image(img) = &mut paint.kind else { continue };
if index.is_some_and(|k| k != i) {
continue;
}
let base = preset.unwrap_or(img.filters);
img.filters = match values {
Some(v) => with_values(cmd, base, v)?,
None => base,
};
touched = true;
changed += 1;
}
if touched {
// Like any paint edit, the layer leaves its paint style.
if stroke {
n.styles.stroke = None;
} else {
n.styles.fill = None;
}
}
}
if changed == 0 {
return Err(bad(cmd, if index.is_some() { "no image paint at that index" } else { "the layers have no image paints" }));
}
Ok(json!({"changed": changed}))
}
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.savePreset";
let name: String = req_string(cmd, p, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
if name.is_empty() {
return Err(bad(cmd, "`name` is empty"));
}
let filters = match get(p, "filters").filter(|v| !v.is_null()) {
Some(v) => with_values(cmd, ImageFilters::NONE, v)?,
None => {
let stroke = strokes(cmd, p)?;
let index = num(p, "index").filter(|k| k.is_finite() && *k >= 0.0).map(|k| k as usize);
let doc = s.doc()?;
targets(s, p)
.iter()
.filter_map(|id| doc.node(*id))
.flat_map(|n| if stroke { n.strokes.iter() } else { n.fills.iter() }.enumerate())
.find_map(|(i, x)| match &x.kind {
PaintKind::Image(img) if index.is_none_or(|k| k == i) => Some(img.filters),
_ => None,
})
.ok_or_else(|| bad(cmd, "pass `filters`, or select a layer with an image paint"))?
}
};
let doc = s.doc_mut()?;
let preset = ImagePreset { name: name.clone(), filters };
match doc.image_presets.iter().position(|x| x.name == name) {
Some(i) => {
if let Some(x) = doc.image_presets.get_mut(i) {
*x = preset;
}
}
None if doc.image_presets.len() >= MAX_PRESETS => return Err(bad(cmd, format!("a file keeps at most {MAX_PRESETS} presets"))),
None => doc.image_presets.push(preset),
}
Ok(json!({"name": name, "filters": filters}))
}

View File

@ -6,6 +6,7 @@ pub mod component;
pub mod create;
pub mod edit;
pub mod file;
pub mod image;
pub mod layout;
pub mod library;
pub mod page;
@ -73,6 +74,7 @@ fn registry() -> &'static Registry {
list.extend(view::specs());
list.extend(text::specs());
list.extend(styles::specs());
list.extend(image::specs());
list.extend(variables::specs());
list.extend(component::specs());
list.extend(library::specs());

View File

@ -237,6 +237,7 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
"styles": doc.styles.iter().map(|st| json!({"id": st.id, "name": st.name, "type": st.kind.type_name()})).collect::<Vec<_>>(),
"variables": doc.variables.variables.len(),
"images": doc.images.len(),
"colorProfile": doc.color_profile.id(),
"textEdit": ds.text_edit,
"history": {"undo": ds.history.undo.iter().rev().take(20).map(|h| h.label.clone()).collect::<Vec<_>>(), "redo": ds.history.redo.len()},
})

View File

@ -887,6 +887,8 @@ mod import_export_tests;
#[cfg(test)]
mod mask_tests;
#[cfg(test)]
mod paint_tests;
#[cfg(test)]
mod proto_multi_tests;
#[cfg(test)]
mod rotated_select_tests;

View File

@ -0,0 +1,162 @@
//! Paint through the session: the file's colour profile (convert or assign, undo, tagged exports)
//! and image adjustment presets.
use serde_json::json;
use crate::Session;
use crate::params::id_of;
use figcraft_color::{Color, ColorProfile};
fn red_rect(s: &mut Session) -> figcraft_doc::NodeId {
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap()["id"]).unwrap();
s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
r
}
fn fill(s: &Session, id: figcraft_doc::NodeId) -> Color {
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap()
}
/// Whether `bytes` holds a PNG chunk of `kind` (with `data`, when given).
fn has_chunk(bytes: &[u8], kind: &[u8; 4], data: Option<&[u8]>) -> bool {
bytes.windows(4).enumerate().any(|(i, w)| w == kind && data.is_none_or(|d| bytes.get(i + 4..i + 4 + d.len()) == Some(d)))
}
#[test]
fn color_profile_converts_or_assigns_and_undoes() {
let mut s = Session::new();
let r = red_rect(&mut s);
let v = s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
assert_eq!(v, json!({"profile": "displayP3", "changed": true}));
assert_eq!(s.doc().unwrap().color_profile, ColorProfile::DisplayP3);
let p3 = fill(&s, r);
assert_eq!(p3, ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3).apply(Color::new(1.0, 0.0, 0.0, 1.0)), "kept the appearance");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().color_profile, ColorProfile::Srgb);
assert_eq!(fill(&s, r).to_hex(), "FF0000");
// Keep the values: only the profile changes.
s.execute("file.colorProfile", &json!({"profile": "Display P3", "convert": false})).unwrap();
assert_eq!(fill(&s, r).to_hex(), "FF0000");
assert_eq!(s.doc().unwrap().color_profile, ColorProfile::DisplayP3);
// The same profile again changes nothing (and adds no undo step).
let undo = s.ds().unwrap().history.undo.len();
assert_eq!(s.execute("file.colorProfile", &json!({"profile": "p3"})).unwrap()["changed"], false);
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
assert!(s.execute("file.colorProfile", &json!({"profile": "cmyk"})).is_err());
}
#[test]
fn color_profile_without_a_profile_asks_the_ui() {
let mut s = Session::new();
let v = s.execute("file.colorProfile", &json!({})).unwrap();
assert_eq!(v["profile"], "srgb");
assert!(s.ui_requests.iter().any(|u| u.kind == "dialog.colorProfile"));
assert!(s.ds().unwrap().history.undo.is_empty());
}
#[test]
fn exports_are_tagged_with_the_profile() {
let mut s = Session::new();
let r = red_rect(&mut s);
let png = |s: &mut Session, format: &str| {
let v = s.execute("file.export", &json!({"ids": [r], "format": format})).unwrap();
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, v["data"].as_str().unwrap()).unwrap()
};
let srgb = png(&mut s, "png");
assert!(has_chunk(&srgb, b"sRGB", None) && !has_chunk(&srgb, b"iCCP", None) && !has_chunk(&srgb, b"cICP", None));
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
let p3 = png(&mut s, "png");
assert!(has_chunk(&p3, b"iCCP", None) && has_chunk(&p3, b"cICP", Some(&[12, 13, 0, 1])) && !has_chunk(&p3, b"sRGB", None));
for f in ["jpg", "webp"] {
assert!(png(&mut s, f).windows(4).any(|w| w == b"mntr"), "{f} embeds the ICC profile");
}
// SVG colours are sRGB: the converted red goes back to #FF0000.
let svg = String::from_utf8(png(&mut s, "svg")).unwrap().to_ascii_uppercase();
assert!(svg.contains("#FF0000"), "{svg}");
let pdf = png(&mut s, "pdf");
assert!(pdf.starts_with(b"%PDF"));
}
#[test]
fn color_profile_survives_save_and_load() {
let mut s = Session::new();
s.execute("file.colorProfile", &json!({"profile": "displayP3", "convert": false})).unwrap();
let bytes = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap();
let (doc, _) = figcraft_format::load_bytes(&bytes).unwrap();
assert_eq!(doc.color_profile, ColorProfile::DisplayP3);
}
fn image_rect(s: &mut Session) -> figcraft_doc::NodeId {
let r = red_rect(s);
s.execute("fill.add", &json!({"ids": [r], "paint": {"type": "image", "image": "img"}})).unwrap();
r
}
fn filters(s: &Session, id: figcraft_doc::NodeId) -> figcraft_color::ImageFilters {
match &s.doc().unwrap().node(id).unwrap().fills[1].kind {
figcraft_color::PaintKind::Image(img) => img.filters,
_ => panic!("not an image"),
}
}
#[test]
fn image_presets_apply_combine_and_reset() {
let mut s = Session::new();
let r = image_rect(&mut s);
let v = s.execute("image.adjust", &json!({"ids": [r], "preset": "Black & white"})).unwrap();
assert_eq!(v["changed"], 1);
assert_eq!(filters(&s, r).saturation, -1.0);
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000", "solid fills untouched");
// Values apply over a preset, or over what the paint has.
s.execute("image.adjust", &json!({"ids": [r], "preset": "warm", "filters": {"exposure": 0.25}})).unwrap();
let f = filters(&s, r);
assert_eq!((f.temperature, f.exposure, f.saturation), (0.5, 0.25, 0.1));
s.execute("image.adjust", &json!({"ids": [r], "filters": {"tint": 7}})).unwrap();
assert_eq!(filters(&s, r).tint, 1.0, "clamped");
s.execute("image.adjust", &json!({"ids": [r], "preset": "reset"})).unwrap();
assert!(filters(&s, r).is_identity());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(filters(&s, r).tint, 1.0, "one undo step per adjust");
}
#[test]
fn image_adjust_refuses_what_it_cannot_do() {
let mut s = Session::new();
let plain = red_rect(&mut s);
let r = image_rect(&mut s);
assert!(s.execute("image.adjust", &json!({"ids": [plain], "preset": "vivid"})).is_err(), "no image paint");
assert!(s.execute("image.adjust", &json!({"ids": [r], "preset": "sepia"})).is_err(), "unknown preset");
assert!(s.execute("image.adjust", &json!({"ids": [r]})).is_err(), "nothing to set");
assert!(s.execute("image.adjust", &json!({"ids": [r], "filters": {"glow": 1}})).is_err());
assert!(s.execute("image.adjust", &json!({"ids": [r], "index": 0, "preset": "vivid"})).is_err(), "index 0 is the solid fill");
assert!(s.execute("image.adjust", &json!({"ids": [r], "target": "stroke", "preset": "vivid"})).is_err());
assert!(s.execute("image.adjust", &json!({"ids": [r], "target": "glow", "preset": "vivid"})).is_err());
// Mixed selections adjust the layers that have images.
assert_eq!(s.execute("image.adjust", &json!({"ids": [plain, r], "index": 1, "preset": "vivid"})).unwrap()["changed"], 1);
}
#[test]
fn saved_image_presets() {
let mut s = Session::new();
let r = image_rect(&mut s);
s.execute("image.adjust", &json!({"ids": [r], "filters": {"contrast": 0.4, "shadows": -0.2}})).unwrap();
let v = s.execute("image.savePreset", &json!({"ids": [r], "name": " Moody "})).unwrap();
assert_eq!(v["name"], "Moody");
s.execute("image.adjust", &json!({"ids": [r], "preset": "reset"})).unwrap();
s.execute("image.adjust", &json!({"ids": [r], "preset": "Moody"})).unwrap();
assert_eq!((filters(&s, r).contrast, filters(&s, r).shadows), (0.4, -0.2));
// Same name replaces; explicit values need no selection.
s.execute("image.savePreset", &json!({"name": "Moody", "filters": {"exposure": -0.5}})).unwrap();
let list = s.execute("image.presets", &json!({})).unwrap();
assert_eq!(list["saved"].as_array().unwrap().len(), 1);
assert_eq!(list["saved"][0]["filters"]["exposure"], -0.5);
assert_eq!(list["builtIn"].as_array().unwrap().len(), figcraft_color::ImageFilters::PRESETS.len());
// Saved presets travel with the file.
let bytes = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap();
assert_eq!(figcraft_format::load_bytes(&bytes).unwrap().0.image_presets.len(), 1);
s.execute("image.deletePreset", &json!({"name": "Moody"})).unwrap();
assert!(s.execute("image.deletePreset", &json!({"name": "Moody"})).is_err());
assert!(s.execute("image.savePreset", &json!({"name": " "})).is_err());
let plain = red_rect(&mut s);
assert!(s.execute("image.savePreset", &json!({"ids": [plain], "name": "X"})).is_err(), "no image to save from");
}

View File

@ -382,7 +382,7 @@ impl<'a> Exporter<'a> {
}
let data = self.doc.images.get(&img.image);
// Decoding through the renderer checks the image is readable and applies adjustments.
let made = figcraft_render::images::get(&img.image, data, &img.filters).map(|pm| {
let made = figcraft_render::images::get(&img.image, data, &img.filters, self.doc.color_profile).map(|pm| {
let (pw, ph) = (pm.width(), pm.height());
// Unadjusted images embed their original bytes (JPEG stays JPEG).
let original = data.filter(|_| img.filters.is_identity()).and_then(|d| {

View File

@ -16,6 +16,7 @@ figcraft-text = { workspace = true }
figcraft-pathops = { workspace = true }
tiny-skia = { workspace = true }
image = { workspace = true }
crc32fast = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]

View File

@ -1,13 +1,14 @@
//! Decoded image cache (content hash + filters → premultiplied pixmap).
//! Decoded image cache (content hash + filters + colour profile → premultiplied pixmap). Images
//! are taken to be sRGB; a Display P3 document gets them converted into its space.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use figcraft_color::ImageFilters;
use figcraft_color::{ColorProfile, ImageFilters};
use figcraft_doc::ImageData;
use tiny_skia::Pixmap;
type Key = (String, [i32; 7]);
type Key = (String, [i32; 7], ColorProfile);
fn cache() -> &'static Mutex<HashMap<Key, Arc<Pixmap>>> {
static C: OnceLock<Mutex<HashMap<Key, Arc<Pixmap>>>> = OnceLock::new();
@ -41,9 +42,9 @@ pub fn decode(bytes: &[u8]) -> Option<Pixmap> {
Some(p)
}
/// The decoded (and filtered) image for `hash`, cached.
pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters));
/// The decoded (and filtered) image for `hash` in the colour space `profile`, cached.
pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters), profile);
if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(p.clone());
}
@ -52,6 +53,7 @@ pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters) -> Opti
if !filters.is_identity() {
apply_filters(&mut p, filters);
}
ColorProfile::Srgb.conversion_to(profile).apply_pixels(p.data_mut());
let p = Arc::new(p);
let mut c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 256 {
@ -116,8 +118,8 @@ fn padded_cache() -> &'static Mutex<HashMap<Key, Arc<Pixmap>>> {
}
/// `pm` with a transparent 1-pixel border (cached).
pub fn padded(hash: &str, filters: &ImageFilters, pm: &Pixmap) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters));
pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pixmap) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters), profile);
if let Some(p) = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(p.clone());
}

View File

@ -15,8 +15,8 @@ pub use draw::{node_outline, paint_bounds, subtree_outline, ts_path};
pub use retained::{Retained, Work};
pub use tiny_skia::{FillRule, Pixmap};
use figcraft_color::Color;
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_color::{Color, ColorProfile};
use figcraft_doc::{Document, ExportFormat, NodeId, NodeKind};
use figcraft_geom::{Affine, Rect};
/// Which part of a page to draw, and how big.
@ -205,17 +205,35 @@ pub fn to_rgba8(p: &Pixmap) -> Vec<u8> {
out
}
/// Encode as PNG.
pub fn encode_png(p: &Pixmap) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
/// Bring a render of a document in `profile` into sRGB, the space the screen and the system
/// clipboard expect (out-of-gamut colours clip). A no-op for sRGB documents.
pub fn to_srgb(pix: &mut Pixmap, profile: ColorProfile) {
profile.conversion_to(ColorProfile::Srgb).apply_pixels(pix.data_mut());
}
/// Write `pixels` with `enc`, embedding `icc` when given.
fn write_encoded(
mut enc: impl image::ImageEncoder,
pixels: &[u8],
p: &Pixmap,
kind: image::ExtendedColorType,
icc: Option<Vec<u8>>,
) -> Result<(), String> {
if let Some(icc) = icc {
enc.set_icc_profile(icc).map_err(|e| e.to_string())?;
}
enc.write_image(pixels, p.width(), p.height(), kind).map_err(|e| e.to_string())
}
/// Encode as PNG, with an `iCCP` chunk when `icc` is given.
fn png_with(p: &Pixmap, icc: Option<Vec<u8>>) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
let enc = image::codecs::png::PngEncoder::new(&mut out);
image::ImageEncoder::write_image(enc, &rgba, p.width(), p.height(), image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
write_encoded(image::codecs::png::PngEncoder::new(&mut out), &to_rgba8(p), p, image::ExtendedColorType::Rgba8, icc)?;
Ok(out)
}
/// Encode as JPEG over `background` (JPEG has no alpha).
pub fn encode_jpeg(p: &Pixmap, background: Color, quality: u8) -> Result<Vec<u8>, String> {
/// Encode as JPEG over `background` (JPEG has no alpha), with an ICC profile when given.
fn jpeg_with(p: &Pixmap, background: Color, quality: u8, icc: Option<Vec<u8>>) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let bg = background.to_rgba8();
let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3);
@ -229,17 +247,68 @@ pub fn encode_jpeg(p: &Pixmap, background: Color, quality: u8) -> Result<Vec<u8>
}
let mut out = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
image::ImageEncoder::write_image(enc, &rgb, p.width(), p.height(), image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
write_encoded(enc, &rgb, p, image::ExtendedColorType::Rgb8, icc)?;
Ok(out)
}
/// Encode as lossless WebP, with an ICC profile when given.
fn webp_with(p: &Pixmap, icc: Option<Vec<u8>>) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
write_encoded(image::codecs::webp::WebPEncoder::new_lossless(&mut out), &to_rgba8(p), p, image::ExtendedColorType::Rgba8, icc)?;
Ok(out)
}
/// Encode as PNG (untagged).
pub fn encode_png(p: &Pixmap) -> Result<Vec<u8>, String> {
png_with(p, None)
}
/// Encode as JPEG over `background` (JPEG has no alpha).
pub fn encode_jpeg(p: &Pixmap, background: Color, quality: u8) -> Result<Vec<u8>, String> {
jpeg_with(p, background, quality, None)
}
/// Encode as lossless WebP.
pub fn encode_webp(p: &Pixmap) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let mut out = Vec::new();
let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut out);
image::ImageEncoder::write_image(enc, &rgba, p.width(), p.height(), image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
Ok(out)
webp_with(p, None)
}
/// Encode an export in `format` (PNG, JPEG over white, WebP) tagged with the document's colour
/// `profile`. An sRGB PNG gets an `sRGB` chunk; a Display P3 PNG gets the profile as `iCCP` plus
/// `cICP` code points; Display P3 JPEG and WebP files embed the ICC profile (untagged files are read
/// as sRGB). SVG and PDF are not pixel formats: an error.
pub fn encode_export(p: &Pixmap, format: ExportFormat, profile: ColorProfile) -> Result<Vec<u8>, String> {
let icc = (!profile.is_srgb()).then(|| profile.icc());
match format {
ExportFormat::Png => {
let png = png_with(p, icc)?;
Ok(if profile.is_srgb() { png_insert_chunk(png, b"sRGB", &[0]) } else { png_insert_chunk(png, b"cICP", &profile.cicp()) })
}
ExportFormat::Jpg => jpeg_with(p, Color::WHITE, 90, icc),
ExportFormat::Webp => webp_with(p, icc),
ExportFormat::Svg | ExportFormat::Pdf => Err(format!("{} is not a pixel format", format.label())),
}
}
/// Insert a chunk right after a PNG's `IHDR` (where colour-space chunks go: before `PLTE` and
/// `IDAT`). Anything that isn't a PNG comes back unchanged.
pub fn png_insert_chunk(png: Vec<u8>, kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
const IHDR_END: usize = 8 + 8 + 13 + 4;
let (Some(head), Some(rest)) = (png.get(..IHDR_END), png.get(IHDR_END..)) else { return png };
if head.get(12..16) != Some(b"IHDR".as_slice()) {
return png;
}
let mut crc = crc32fast::Hasher::new();
crc.update(kind);
crc.update(data);
let mut out = Vec::with_capacity(png.len() + 12 + data.len());
out.extend_from_slice(head);
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(kind);
out.extend_from_slice(data);
out.extend_from_slice(&crc.finalize().to_be_bytes());
out.extend_from_slice(rest);
out
}
#[cfg(test)]

View File

@ -121,13 +121,13 @@ pub fn prepare(paint: &Paint, w: f64, h: f64, doc: &Document) -> Option<Prepared
}
PaintKind::Image(img) => {
let data = doc.images.get(&img.image);
let pm = crate::images::get(&img.image, data, &img.filters)?;
let pm = crate::images::get(&img.image, data, &img.filters, doc.color_profile)?;
let transform = image_transform(img, f64::from(pm.width()), f64::from(pm.height()), w, h);
if img.scale_mode == ImageScaleMode::Tile {
return Some(Prepared::Pattern { pixmap: pm, mode: SpreadMode::Repeat, quality: FilterQuality::Bicubic, opacity: op, transform });
}
// Fit/Fill/Crop: a transparent 1-px border so Pad spreads transparency, not edge pixels.
let padded = crate::images::padded(&img.image, &img.filters, &pm)?;
let padded = crate::images::padded(&img.image, &img.filters, doc.color_profile, &pm)?;
let transform = Transform::from_translate(-1.0, -1.0).post_concat(transform);
Some(Prepared::Pattern { pixmap: padded, mode: SpreadMode::Pad, quality: FilterQuality::Bicubic, opacity: op, transform })
}

View File

@ -138,6 +138,8 @@ impl Retained {
&& last.pix.width() == snapped.width
&& last.pix.height() == snapped.height
&& last.fonts == fonts
// Images are decoded into the file's colour space: a new profile redraws everything.
&& last.doc.color_profile == doc.color_profile
{
let same_doc = Arc::ptr_eq(&last.doc, doc);
let s = if same_doc { last.sensitive.clone() } else { Arc::new(sensitive(doc)) };

View File

@ -225,6 +225,53 @@ fn export_png_roundtrip() {
let _ = encode_jpeg(&pix, Color::WHITE, 90).unwrap();
}
#[test]
fn exports_carry_the_colour_profile() {
use figcraft_color::ColorProfile;
use figcraft_doc::ExportFormat;
use image::ImageDecoder;
let (d, _, r) = doc_with_rect(Color::from_hex("00FF00").unwrap());
let pix = render_node(&d, r, 1.0).unwrap();
for profile in ColorProfile::ALL {
let png = encode_export(&pix, ExportFormat::Png, profile).unwrap();
// The inserted chunk has a valid CRC: the decoder accepts the file and keeps the pixels.
let mut dec = image::codecs::png::PngDecoder::new(std::io::Cursor::new(&png)).unwrap();
let icc = dec.icc_profile().unwrap();
assert_eq!(icc.is_some(), !profile.is_srgb(), "{profile:?}");
assert_eq!(image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(5, 5).0, [0, 255, 0, 255]);
for f in [ExportFormat::Jpg, ExportFormat::Webp] {
assert!(!encode_export(&pix, f, profile).unwrap().is_empty());
}
assert!(encode_export(&pix, ExportFormat::Svg, profile).is_err());
}
assert_eq!(png_insert_chunk(b"not a png".to_vec(), b"sRGB", &[0]), b"not a png");
}
#[test]
fn images_are_managed_into_the_file_profile() {
use figcraft_color::{ColorProfile, ImageScaleMode, Paint};
let (mut d, _, r) = doc_with_rect(Color::WHITE);
let mut red = Pixmap::new(4, 4).unwrap();
red.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255));
d.images.insert("red".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(encode_png(&red).unwrap()), width: 4, height: 4 });
d.get_mut(r).unwrap().fills = vec![Paint::image("red", ImageScaleMode::Fill)];
let center = |d: &Document| {
let pix = render_node(d, r, 1.0).unwrap();
let (x, y) = (pix.width() / 2, pix.height() / 2);
let px = pix.pixel(x, y).unwrap();
(pix, [px.red(), px.green(), px.blue()])
};
assert_eq!(center(&d).1, [255, 0, 0]);
// In a Display P3 file the (sRGB) image is converted into P3 values…
d.color_profile = ColorProfile::DisplayP3;
let (mut pix, rgb) = center(&d);
assert!(rgb[0].abs_diff(234) <= 1 && rgb[1].abs_diff(51) <= 1 && rgb[2].abs_diff(35) <= 1, "{rgb:?}");
// …and comes back to the screen unchanged.
to_srgb(&mut pix, d.color_profile);
let px = pix.pixel(pix.width() / 2, pix.height() / 2).unwrap();
assert!(px.red() >= 254 && px.green() <= 1 && px.blue() <= 1, "{px:?}");
}
#[test]
fn hostile_sizes() {
let (mut d, p, r) = doc_with_rect(Color::BLACK);

View File

@ -128,8 +128,7 @@ pub(crate) fn thumbnail(ctx: &egui::Context, doc: &Document, id: NodeId, px: f32
}
let scale = (f64::from(px) / side).min(2.0);
let pix = figcraft_render::render_node(doc, id, scale)?;
let size = [pix.width() as usize, pix.height() as usize];
let img = egui::ColorImage::from_rgba_unmultiplied(size, &figcraft_render::to_rgba8(&pix));
let img = crate::render_worker::screen_image(&pix, doc.color_profile);
Some(ctx.load_texture(format!("asset-thumb-{}", id.0), img, egui::TextureOptions::LINEAR))
}

View File

@ -11,7 +11,7 @@ use serde_json::json;
use crate::FigApp;
use crate::render_worker::{Job, Worker};
use crate::theme::{ACCENT, COMPONENT, Tokens, c32, regular, semibold};
use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold};
pub struct CanvasState {
pub rect: Rect,
@ -161,7 +161,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
.ds()
.ok()
.and_then(|ds| match ds.doc.node(ds.page).map(|n| &n.kind) {
Some(NodeKind::Page { background, .. }) => Some(c32(*background)),
Some(NodeKind::Page { background, .. }) => Some(doc_c32(&ctx, *background)),
_ => None,
})
.unwrap_or(t.canvas);
@ -502,7 +502,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
if view.layout_grids && !n.grids.is_empty() {
let w = doc.world_transform(*c);
for g in n.grids.iter().filter(|g| g.visible) {
let col = c32(g.color);
let col = doc_c32(painter.ctx(), g.color);
for r in figcraft_layout::grid_lines(g, n.width, n.height) {
let a = w * Point::new(r.x0, r.y0);
let b = w * Point::new(r.x1, r.y1);

View File

@ -2,13 +2,13 @@
//! hex and RGB fields, gradient stops, image fills and document colours.
use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Hsb, ImageScaleMode, Paint, PaintKind};
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImageScaleMode, Paint, PaintKind};
use figcraft_doc::{EffectKind, NodeId, NodeKind};
use serde_json::json;
use crate::FigApp;
use crate::icons::{self, Icon};
use crate::theme::{Tokens, c32, regular, semibold};
use crate::theme::{Tokens, doc_c32, regular, semibold};
use crate::widgets;
#[derive(Clone, Debug, PartialEq)]
@ -34,6 +34,19 @@ pub enum PickerTarget {
id: String,
mode: String,
},
/// One paint of a colour style (edited from the style picker).
Style {
id: String,
index: usize,
},
}
/// The paints of colour style `id`.
fn style_paints(doc: &figcraft_doc::Document, id: &str) -> Option<Vec<Paint>> {
match &doc.style(id)?.kind {
figcraft_doc::StyleKind::Paint { paints } => Some(paints.clone()),
_ => None,
}
}
/// The fills a text range paints with (its own, else the layer's).
@ -68,6 +81,7 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
}
_ => None,
},
PickerTarget::Style { id, index } => style_paints(doc, id)?.get(*index).cloned(),
}
}
@ -100,6 +114,13 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
let c = paint.solid_color().unwrap_or(Color::BLACK);
app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.with_alpha(c.a * paint.opacity).to_css_hex()}))
}
PickerTarget::Style { id, index } => {
let Some(mut paints) = app.session.doc().ok().and_then(|d| style_paints(d, id)) else { return };
if let Some(slot) = paints.get_mut(*index) {
*slot = paint.clone();
}
app.session.execute("style.update", &json!({"style": id, "value": paints}))
}
};
if let Err(e) = r {
app.toast(e.to_string());
@ -244,14 +265,14 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
out
}
fn hue_color(h: f32) -> Color32 {
c32(Hsb { h, s: 1.0, b: 1.0 }.to_color())
fn hue_color(ctx: &egui::Context, h: f32) -> Color32 {
doc_c32(ctx, Hsb { h, s: 1.0, b: 1.0 }.to_color())
}
fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) {
// White → hue horizontally, then transparent → black vertically.
let mut m = Mesh::default();
let hc = hue_color(hsb.h);
let hc = hue_color(ui.ctx(), hsb.h);
let corners = [(rect.left_top(), Color32::WHITE), (rect.right_top(), hc), (rect.right_bottom(), hc), (rect.left_bottom(), Color32::WHITE)];
for (p, c) in corners {
m.colored_vertex(p, c);
@ -292,7 +313,7 @@ fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) {
for i in 0..=n {
let t = i as f32 / n as f32;
let x = rect.min.x + t * rect.width();
let c = hue_color(t * 360.0);
let c = hue_color(ui.ctx(), t * 360.0);
m.colored_vertex(pos2(x, rect.min.y), c);
m.colored_vertex(pos2(x, rect.max.y), c);
if i > 0 {
@ -329,7 +350,7 @@ fn slider_knob(ui: &mut Ui, rect: Rect, t: f32, id: Id) -> Option<(f32, bool)> {
fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> {
widgets::checker(&ui.painter().with_clip_rect(rect), rect, rect.height() / 2.0);
let mut m = Mesh::default();
let opaque = c32(color.with_alpha(1.0));
let opaque = doc_c32(ui.ctx(), color.with_alpha(1.0));
for (p, c) in [
(rect.left_top(), Color32::TRANSPARENT),
(rect.right_top(), opaque),
@ -445,7 +466,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
let a = i as f32 / n as f32;
let b = (i + 1) as f32 / n as f32;
let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y));
ui.painter().rect_filled(r, CornerRadius::ZERO, c32(g.sample((a + b) / 2.0)));
ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0)));
}
let mut changed = false;
let mut done = false;
@ -453,7 +474,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
let x = bar.min.x + s.position * bar.width();
let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0));
let resp = ui.interact(r, Id::new(("cp_stopk", i)), Sense::click_and_drag());
ui.painter().rect_filled(r, CornerRadius::same(3), c32(s.color.with_alpha(1.0)));
ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0)));
ui.painter().rect_stroke(
r,
CornerRadius::same(3),
@ -545,6 +566,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
write(app, &target, &paint, false);
}
});
image_presets(app, ui, &target, &mut paint, img.filters);
for (label, val) in [
("Exposure", img.filters.exposure),
("Contrast", img.filters.contrast),
@ -556,7 +578,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
] {
ui.horizontal(|ui| {
ui.add_space(16.0);
ui.label(egui::RichText::new(label).font(regular(11.0)));
// Labels share a column so the sliders line up.
let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover());
ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, label, regular(11.0), t.text);
let mut v = val;
let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false));
if r.changed() {
@ -688,6 +712,126 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
let _ = Pos2::ZERO;
}
/// What the image preset row was asked to do.
enum PresetAction {
Apply(String),
Delete(String),
}
/// A saved preset in the presets menu: click applies it, the trailing button deletes it.
fn saved_preset_row(ui: &mut Ui, name: &str) -> Option<PresetAction> {
let t = Tokens::get(ui.ctx());
let w = ui.available_width().max(180.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
let del = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(20.0, 20.0));
let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).on_hover_text("Delete preset");
let hot = resp.hovered() || del_resp.hovered();
if hot {
ui.painter().rect_filled(r, CornerRadius::same(5), crate::theme::ACCENT);
}
let col = if hot { Color32::WHITE } else { t.text };
ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, name, regular(11.5), col);
if hot {
icons::paint(ui.painter(), del.shrink(4.0), Icon::Close, col);
}
if del_resp.clicked() {
Some(PresetAction::Delete(name.to_string()))
} else if resp.clicked() {
Some(PresetAction::Apply(name.to_string()))
} else {
None
}
}
/// The image paint's adjustment presets: a menu of the built-in presets and the file's saved ones
/// (named after the one in effect), "Save as preset…" and Reset.
fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &mut Paint, filters: ImageFilters) {
let saved: Vec<(String, ImageFilters)> =
app.session.doc().map(|d| d.image_presets.iter().map(|p| (p.name.clone(), p.filters)).collect()).unwrap_or_default();
let built_in = ImageFilters::PRESETS.iter().filter(|(id, ..)| *id != "reset");
let current = built_in
.clone()
.map(|(_, label, f)| (label.to_string(), *f))
.chain(saved.iter().cloned())
.find(|(_, f)| *f == filters)
.map(|(name, _)| name)
.unwrap_or_else(|| if filters.is_identity() { "No preset".into() } else { "Custom".into() });
let name_id = Id::new("cp_preset_name");
let mut naming: Option<String> = ui.data(|d| d.get_temp(name_id));
let mut action = None;
ui.horizontal(|ui| {
ui.add_space(16.0);
let resp = widgets::dropdown(ui, &current, 152.0, None).on_hover_text("Adjustment presets");
egui::Popup::menu(&resp).show(|ui| {
for (id, label, _) in built_in {
if crate::right::menu_item(ui, label, "") {
action = Some(PresetAction::Apply(id.to_string()));
ui.close();
}
}
ui.separator();
for (name, _) in &saved {
if let Some(a) = saved_preset_row(ui, name) {
action = Some(a);
ui.close();
}
}
if !saved.is_empty() {
ui.separator();
}
if crate::right::menu_item(ui, "Save as preset…", "") {
naming = Some(format!("Preset {}", saved.len() + 1));
ui.close();
}
});
if widgets::button(ui, "Reset", false, !filters.is_identity()).on_hover_text("Reset adjustments").clicked() {
action = Some(PresetAction::Apply("reset".into()));
}
});
if let Some(name) = naming.as_mut() {
let mut save = false;
ui.horizontal(|ui| {
ui.add_space(16.0);
let r = ui.add(egui::TextEdit::singleline(name).font(regular(11.0)).desired_width(136.0));
if !r.has_focus() && ui.memory(|m| m.focused().is_none()) {
r.request_focus();
}
save = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
save |= widgets::button(ui, "Save", true, !name.trim().is_empty()).clicked();
});
if save && !name.trim().is_empty() {
app.run("image.savePreset", json!({"name": name.trim(), "filters": filters}));
naming = None;
}
}
ui.data_mut(|d| match &naming {
Some(n) => {
d.insert_temp(name_id, n.clone());
}
None => d.remove::<String>(name_id),
});
match action {
Some(PresetAction::Delete(name)) => {
app.run("image.deletePreset", json!({"name": name}));
}
Some(PresetAction::Apply(name)) => match target {
// Layer paints go through the command; other targets (a text range, a style) get the
// preset's values written like any edit.
PickerTarget::Paint { fills, index, ids } => {
app.run("image.adjust", json!({"ids": ids, "target": if *fills { "fill" } else { "stroke" }, "index": index, "preset": name}));
}
_ => {
let preset = app.session.doc().ok().and_then(|d| figcraft_engine::cmd::image::find_preset(d, &name));
if let (Some(f), PaintKind::Image(img)) = (preset, &mut paint.kind) {
img.filters = f;
write(app, target, paint, false);
}
}
},
None => {}
}
}
fn figcraft_format_add(doc: &mut figcraft_doc::Document, bytes: Vec<u8>) -> Option<String> {
// Images are keyed by content hash; the engine's image.place uses the same scheme.
let (w, h) = image::ImageReader::new(std::io::Cursor::new(&bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;

View File

@ -272,6 +272,22 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
};
player.ok_or("nothing is presented")?.scroll_to(at(0), at(1));
}
// The style picker: a slot (fill, stroke, effect, grid) opens it for the selection, null
// closes it; `stylePickerEdit` (a style id or name) opens its editor; `stylePickerView`
// is `list` or `grid`.
if let Some(v) = p.get("stylePicker") {
match v.as_str().and_then(crate::style_picker::StyleSlot::parse) {
Some(slot) => crate::style_picker::open_for_selection(app, slot, p.get("stylePickerEdit").and_then(Value::as_str)),
None => app.ui.style_picker = None,
}
}
if let Some(v) = p.get("stylePickerView").and_then(Value::as_str) {
app.ui.style_grid = v == "grid";
}
// The Color profile dialog: a profile id opens it with that profile chosen, null closes it.
if let Some(v) = p.get("colorProfileDialog") {
app.ui.color_profile = v.as_str().and_then(figcraft_color::ColorProfile::parse);
}
if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) {
app.ui.export_thumb = v;
}

View File

@ -1,6 +1,7 @@
//! Dialogs: About, Preferences, keyboard shortcuts, find & replace, variables, export.
use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2};
use figcraft_color::ColorProfile;
use figcraft_doc::{ExportFormat, ExportSetting, ExportSize};
use figcraft_engine::catalog::MENU;
use figcraft_engine::cmd::file::{export_jobs, exportable_layers};
@ -160,6 +161,65 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
if app.ui.export_preview {
export_dialog(app, ctx);
}
color_profile_dialog(app, ctx);
}
/// File ▸ Color profile: pick sRGB or Display P3, then keep the colours' appearance (convert the
/// values) or keep the values (reinterpret them in the new space).
fn color_profile_dialog(app: &mut FigApp, ctx: &Context) {
let Some(mut chosen) = app.ui.color_profile else { return };
let Ok(current) = app.session.doc().map(|d| d.color_profile) else {
app.ui.color_profile = None;
return;
};
let t = Tokens::get(ctx);
let mut open = true;
let mut apply = None;
window("Color profile", ctx).open(&mut open).show(ctx, |ui| {
ui.set_width(340.0);
ui.label(RichText::new("Color values in this file are in this color space.").color(t.text_secondary).font(regular(11.0)));
ui.add_space(8.0);
for p in ColorProfile::ALL {
let label = if p == current { format!("{} (current)", p.label()) } else { p.label().to_string() };
if crate::widgets::radio(ui, chosen == p, &label) {
chosen = p;
}
}
ui.add_space(8.0);
if chosen == current {
ui.label(RichText::new("Choose another profile to change it.").color(t.text_secondary).font(regular(11.0)));
return;
}
ui.label(
RichText::new(format!(
"Keep appearance converts every color value to {} so colors look the same. Keep values leaves the numbers as they are, so colors shift.",
chosen.label()
))
.font(regular(11.0)),
);
if !chosen.is_srgb() {
ui.add_space(4.0);
ui.label(
RichText::new("The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3.")
.color(t.text_secondary)
.font(regular(11.0)),
);
}
ui.add_space(12.0);
ui.horizontal(|ui| {
if crate::widgets::button(ui, "Keep appearance", true, true).clicked() {
apply = Some(true);
}
if crate::widgets::button(ui, "Keep values", false, true).clicked() {
apply = Some(false);
}
});
});
app.ui.color_profile = open.then_some(chosen);
if let Some(convert) = apply {
app.run("file.colorProfile", json!({"profile": chosen.id(), "convert": convert}));
app.ui.color_profile = None;
}
}
/// What the Export dialog was asked to do.

View File

@ -249,7 +249,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
for (i, s) in g.stops.iter().enumerate() {
let p = a + (b - a) * s.position;
let r = Rect::from_center_size(p, vec2(12.0, 12.0));
painter.rect_filled(r, CornerRadius::same(2), crate::theme::c32(s.color.with_alpha(1.0)));
painter.rect_filled(r, CornerRadius::same(2), crate::theme::doc_c32(painter.ctx(), s.color.with_alpha(1.0)));
painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
if i == selected {
painter.rect_stroke(r.expand(2.0), CornerRadius::same(3), Stroke::new(1.5, ACCENT), StrokeKind::Outside);

View File

@ -129,6 +129,12 @@ pub enum Icon {
Logo,
/// Ignore auto layout: a layer pinned at an offset inside its frame.
AbsolutePosition,
/// Edit settings: two sliders.
Adjust,
/// Show as a list.
ViewList,
/// Show as a grid of swatches.
ViewGrid,
}
struct P<'a> {
@ -716,6 +722,23 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
p.line(10.5, 7.75, 13.5, 7.75);
p.line(8.75, 2.5, 8.75, 6.0);
}
Adjust => {
p.line(2.5, 5.0, 13.5, 5.0);
p.line(2.5, 11.0, 13.5, 11.0);
p.dot(10.0, 5.0, 2.0);
p.dot(6.0, 11.0, 2.0);
}
ViewList => {
for y in [4.0, 8.0, 12.0] {
p.fill_rect(2.5, y - 1.25, 5.0, y + 1.25, 0.5);
p.line(7.0, y, 13.5, y);
}
}
ViewGrid => {
for (x, y) in [(2.5, 2.5), (9.0, 2.5), (2.5, 9.0), (9.0, 9.0)] {
p.rect(x, y, x + 4.5, y + 4.5, 1.0);
}
}
Logo => {
// FigCraft's own mark: three stacked rounded tiles forming an "F" shape.
p.fill_rect(3.0, 2.0, 13.0, 5.5, 1.75);

View File

@ -30,6 +30,7 @@ pub mod right;
pub mod scale_panel;
pub mod section_panel;
pub mod shortcuts;
pub mod style_picker;
pub mod tabs;
pub mod theme;
pub mod toolbar;
@ -145,6 +146,14 @@ pub struct UiState {
pub quit: bool,
/// The Export dialog is open.
pub export_preview: bool,
/// The Color profile dialog is open, with this profile chosen.
pub color_profile: Option<figcraft_color::ColorProfile>,
/// The open style picker.
pub style_picker: Option<style_picker::Picker>,
/// The style picker shows colour styles as a grid of swatches.
pub style_grid: bool,
/// Style folders closed in the style picker.
pub style_folds: BTreeSet<String>,
/// The Design panel's Export section shows its preview.
pub export_thumb: bool,
pub zoom_menu: bool,
@ -198,6 +207,10 @@ impl Default for UiState {
render_ms: 0.0,
quit: false,
export_preview: false,
color_profile: None,
style_picker: None,
style_grid: false,
style_folds: BTreeSet::new(),
export_thumb: false,
zoom_menu: false,
find: None,
@ -280,6 +293,7 @@ impl FigApp {
}
"home" => self.ui.home = true,
"dialog.saveAs" => self.save_as_dialog(),
"dialog.colorProfile" => self.ui.color_profile = self.session.doc().ok().map(|d| d.color_profile),
"present" => {
if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) {
self.ui.present = Some(present::Presenter::new(start));
@ -397,6 +411,7 @@ impl FigApp {
let t = theme::Tokens::get(&ctx);
// Control requests run first so their results reflect this frame.
control::drain(self, &ctx);
theme::set_doc_profile(&ctx, self.session.doc().map_or(figcraft_color::ColorProfile::Srgb, |d| d.color_profile));
// The Inspect tab measures on hover without Alt.
self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect;
if self.ui.present.is_some() {
@ -448,6 +463,7 @@ impl FigApp {
shortcuts::handle(self, &ctx);
menus::popups(self, &ctx);
variable_ui::picker(self, &ctx);
style_picker::show(self, &ctx);
dialogs::show(self, &ctx);
self.toast_ui(&ctx);
#[cfg(not(target_arch = "wasm32"))]
@ -496,6 +512,9 @@ impl FigApp {
"palette": self.ui.palette.is_some(),
"variables": self.ui.variables.is_some(),
"versionHistory": self.ui.versions.open,
"colorProfileDialog": self.ui.color_profile.map(|p| p.id()),
"stylePicker": self.ui.style_picker.as_ref().map(|p| p.slot().key()),
"stylePickerView": if self.ui.style_grid { "grid" } else { "list" },
"comments": {"open": self.ui.comments.open, "draft": self.ui.comments.draft.as_ref().map(|d| [d.0, d.1]), "showResolved": self.ui.comments.show_resolved},
"frameMs": self.ui.frame_ms,
"renderMs": self.ui.render_ms,

View File

@ -94,8 +94,11 @@ pub fn invoke(app: &mut FigApp, id: &str) {
"edit.copyAsPng" => {
let ids = app.session.selection();
if let Some(first) = ids.first()
&& let Some(pix) = app.session.doc().ok().and_then(|d| figcraft_render::render_node(d, *first, 2.0))
&& let Some((mut pix, profile)) =
app.session.doc().ok().and_then(|d| Some((figcraft_render::render_node(d, *first, 2.0)?, d.color_profile)))
{
// Clipboard images are read as sRGB.
figcraft_render::to_srgb(&mut pix, profile);
let rgba = figcraft_render::to_rgba8(&pix);
app.services.clipboard_set_png(pix.width(), pix.height(), &rgba);
app.toast("Copied as PNG");

View File

@ -516,7 +516,7 @@ impl Presenter {
}
let bounds = figcraft_render::export_bounds(doc, id)?;
let pix = figcraft_render::render_node(doc, id, px_scale)?;
let img = egui::ColorImage::from_rgba_premultiplied([pix.width() as usize, pix.height() as usize], pix.data());
let img = crate::render_worker::screen_image(&pix, doc.color_profile);
let tex = ctx.load_texture(format!("present-{}", id.0), img, egui::TextureOptions::LINEAR);
let ft = FrameTex { key, tex, bounds };
self.textures.insert(id, ft.clone());
@ -1244,7 +1244,7 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome)
let ctx = ui.ctx().clone();
let bar = Rect::from_min_size(rect.min, vec2(rect.width(), chrome.bar_h));
let area = Rect::from_min_max(pos2(rect.min.x, bar.max.y), rect.max);
let bg = crate::theme::c32(p.doc.as_ref().map(|d| page_settings(d, p.current)).unwrap_or_default().background);
let bg = crate::theme::doc_c32(ui.ctx(), p.doc.as_ref().map(|d| page_settings(d, p.current)).unwrap_or_default().background);
let r = chrome.round;
ui.painter().rect_filled(area, CornerRadius { nw: 0, ne: 0, sw: r, se: r }, bg);
let action = top_bar(p, ui, bar, now, chrome);
@ -1665,7 +1665,7 @@ fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Lay
let tmp = smart_interpolate(&a.from_doc, a.from_frame, &l.disp, p.current, e);
let bounds = figcraft_render::export_bounds(&tmp, p.current);
if let (Some(b), Some(pix)) = (bounds, figcraft_render::render_node(&tmp, p.current, px)) {
let img = egui::ColorImage::from_rgba_premultiplied([pix.width() as usize, pix.height() as usize], pix.data());
let img = crate::render_worker::screen_image(&pix, tmp.color_profile);
let tex = match &mut p.anim_tex {
Some(t) => {
t.set(img, egui::TextureOptions::LINEAR);
@ -1739,7 +1739,7 @@ fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Lay
transition_frames(o.transition, e)
};
if let Some(bgc) = o.background {
let c = crate::theme::c32(bgc);
let c = crate::theme::doc_c32(ctx, bgc);
on_screen.rect_filled(l.screen, CornerRadius::ZERO, c.gamma_multiply(raw as f32));
}
if let Some(tex) = p.texture(ctx, &l.disp, *id, px) {

View File

@ -5,8 +5,9 @@
use std::sync::Arc;
use figcraft_color::ColorProfile;
use figcraft_doc::{Document, NodeId};
use figcraft_render::{Retained, View, Work};
use figcraft_render::{Pixmap, Retained, View, Work};
#[derive(Clone)]
pub struct Job {
@ -28,12 +29,23 @@ pub struct Done {
pub work: Work,
}
/// A render of a document in `profile` as a texture image for the screen (sRGB).
pub fn screen_image(pix: &Pixmap, profile: ColorProfile) -> egui::ColorImage {
let size = [pix.width() as usize, pix.height() as usize];
if profile.is_srgb() {
return egui::ColorImage::from_rgba_premultiplied(size, pix.data());
}
let mut data = pix.data().to_vec();
profile.conversion_to(ColorProfile::Srgb).apply_pixels(&mut data);
egui::ColorImage::from_rgba_premultiplied(size, &data)
}
fn run(job: &Job, retained: &mut Retained) -> Option<Done> {
#[cfg(not(target_arch = "wasm32"))]
let t0 = std::time::Instant::now();
let opts = figcraft_render::Options { outline_mode: job.outline, skip: job.skip.clone(), ..Default::default() };
let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?;
let image = egui::ColorImage::from_rgba_premultiplied([pix.width() as usize, pix.height() as usize], pix.data());
let image = screen_image(pix, job.doc.color_profile);
#[cfg(not(target_arch = "wasm32"))]
let ms = t0.elapsed().as_secs_f64() * 1000.0;
#[cfg(target_arch = "wasm32")]

View File

@ -6,6 +6,7 @@ use figcraft_doc::*;
use serde_json::{Value, json};
use crate::icons::{self, Icon};
use crate::style_picker::{self, StyleSlot};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD};
use crate::{FigApp, RightTab};
@ -280,6 +281,21 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
app.run("page.setBackground", json!({"color": c.to_css_hex()}));
}
widgets::separator(ui);
widgets::section_header(ui, "Color profile", &[], false);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, doc.color_profile.label(), 140.0, None);
egui::Popup::menu(&resp).show(|ui| {
for p in figcraft_color::ColorProfile::ALL {
if menu_item(ui, p.label(), "") {
if p != doc.color_profile {
app.ui.color_profile = Some(p);
}
ui.close();
}
}
});
});
widgets::separator(ui);
// Local variables & styles.
let vars = doc.variables.variables.len();
widgets::section_header(ui, "Local variables", &[], vars == 0);
@ -712,11 +728,10 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
if i == 1 {
app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids}));
} else {
ui.data_mut(|d| d.insert_temp(slot.menu_id(), true));
style_picker::open(app, ui, &ids, slot);
}
}
style_picker(app, ui, doc, &ids, slot);
style_chip(app, ui, doc, &ids, slot);
style_picker::chip(app, ui, doc, &ids, slot);
match &list {
None => row(ui, 32.0, |ui| widgets::label(ui, "Click + to replace mixed content", t.text_secondary)),
Some(l) => {
@ -965,149 +980,6 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
});
}
/// A style slot on a layer: what a style picker lists and what a style chip shows.
#[derive(Clone, Copy, PartialEq)]
enum StyleSlot {
Fill,
Stroke,
Effect,
Grid,
}
impl StyleSlot {
/// The slot's name in `style.apply {target}` / `style.detach {which}`.
fn key(self) -> &'static str {
match self {
StyleSlot::Fill => "fill",
StyleSlot::Stroke => "stroke",
StyleSlot::Effect => "effect",
StyleSlot::Grid => "grid",
}
}
fn menu_id(self) -> Id {
Id::new(("style_menu", self.key()))
}
fn linked(self, n: &Node) -> Option<String> {
match self {
StyleSlot::Fill => n.styles.fill.clone(),
StyleSlot::Stroke => n.styles.stroke.clone(),
StyleSlot::Effect => n.styles.effect.clone(),
StyleSlot::Grid => n.styles.grid.clone(),
}
}
fn paints(self) -> bool {
matches!(self, StyleSlot::Fill | StyleSlot::Stroke)
}
}
/// The applied style's name with a detach button.
fn style_chip(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot: StyleSlot) {
let t = Tokens::get(ui.ctx());
let Some(name) = common(doc, ids, |n| slot.linked(n)).flatten().and_then(|s| doc.style(&s).map(|st| st.name.clone())) else { return };
row(ui, 32.0, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
icons::paint(ui.painter(), r, Icon::Styles, t.icon_secondary);
widgets::label(ui, &name, t.text);
if icons::button(ui, Icon::Detach, 24.0, false, "Detach style").clicked() {
app.run("style.detach", json!({"ids": ids, "which": slot.key()}));
}
});
}
/// The style list under a section header: the document's styles for `slot` (click to apply) and
/// "Create style" from the first selected layer; paint slots also list colour variables.
fn style_picker(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot: StyleSlot) {
let key = slot.menu_id();
if !ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(false) {
return;
}
let t = Tokens::get(ui.ctx());
let styles: Vec<(String, String, Option<Paint>)> = doc
.styles
.iter()
.filter_map(|s| match (&s.kind, slot) {
(StyleKind::Paint { paints }, StyleSlot::Fill | StyleSlot::Stroke) => Some((s.id.clone(), s.name.clone(), paints.first().cloned())),
(StyleKind::Effect { .. }, StyleSlot::Effect) | (StyleKind::Grid { .. }, StyleSlot::Grid) => Some((s.id.clone(), s.name.clone(), None)),
_ => None,
})
.collect();
let (title, create_type, default_name) = match slot {
StyleSlot::Fill => ("Color styles", "paint", "Color"),
StyleSlot::Stroke => ("Color styles", "stroke", "Color"),
StyleSlot::Effect => ("Effect styles", "effect", "Effect"),
StyleSlot::Grid => ("Grid styles", "grid", "Grid"),
};
let mut close = false;
egui::Frame::NONE.fill(t.hover).inner_margin(egui::Margin::symmetric(16, 8)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(title).font(semibold(11.0)));
if ui.small_button("Create style").clicked() {
let n = styles.len() + 1;
app.run("style.create", json!({"type": create_type, "name": format!("{default_name} {n}"), "from": ids.first()}));
close = true;
}
if ui.small_button("Close").clicked() {
close = true;
}
});
if styles.is_empty() {
ui.label(egui::RichText::new("No styles yet").font(regular(11.0)).color(t.text_secondary));
}
for (id, name, paint) in &styles {
ui.horizontal(|ui| {
match paint {
Some(p) => {
widgets::swatch(ui, p, 14.0);
}
None => {
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
icons::paint(ui.painter(), r, if slot == StyleSlot::Grid { Icon::Grid } else { Icon::Effects }, t.icon_secondary);
}
}
if ui.selectable_label(false, name).clicked() {
let mut q = json!({"ids": ids, "style": id});
if slot.paints() {
q["target"] = json!(slot.key());
}
app.run("style.apply", q);
close = true;
}
});
}
if !slot.paints() {
return;
}
// Colour variables bind the first paint's colour.
let modes = std::collections::BTreeMap::new();
let colors: Vec<(String, String, figcraft_color::Color)> = doc
.variables
.variables
.iter()
.filter(|v| v.kind == VariableType::Color)
.filter_map(|v| match doc.variables.resolve(&v.id, &modes) {
Some(VariableValue::Color(c)) => Some((v.id.clone(), v.name.clone(), c)),
_ => None,
})
.collect();
if !colors.is_empty() {
ui.add_space(4.0);
ui.label(egui::RichText::new("Color variables").font(semibold(11.0)));
for (vid, name, c) in &colors {
ui.horizontal(|ui| {
widgets::swatch(ui, &Paint::solid(*c), 14.0);
if ui.selectable_label(false, name).clicked() {
app.run("variable.bind", json!({"ids": ids, "field": slot.key(), "variable": vid}));
close = true;
}
});
}
}
});
if close {
ui.data_mut(|d| d.remove::<bool>(key));
}
}
fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let t = Tokens::get(ui.ctx());
let ids = sel.to_vec();
@ -1117,11 +989,10 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
if i == 1 {
app.run("effect.add", json!({"ids": ids, "type": "dropShadow"}));
} else {
ui.data_mut(|d| d.insert_temp(StyleSlot::Effect.menu_id(), true));
style_picker::open(app, ui, &ids, StyleSlot::Effect);
}
}
style_picker(app, ui, doc, &ids, StyleSlot::Effect);
style_chip(app, ui, doc, &ids, StyleSlot::Effect);
style_picker::chip(app, ui, doc, &ids, StyleSlot::Effect);
let Some(list) = list else { return };
for (i, e) in list.iter().enumerate().rev() {
let mut open = ui.data(|d| d.get_temp::<bool>(Id::new(("fx_open", i)))).unwrap_or(false);
@ -1273,11 +1144,10 @@ fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
if i == 1 {
app.run("grid.add", json!({"ids": ids}));
} else {
ui.data_mut(|d| d.insert_temp(StyleSlot::Grid.menu_id(), true));
style_picker::open(app, ui, &ids, StyleSlot::Grid);
}
}
style_picker(app, ui, doc, &ids, StyleSlot::Grid);
style_chip(app, ui, doc, &ids, StyleSlot::Grid);
style_picker::chip(app, ui, doc, &ids, StyleSlot::Grid);
let Some(list) = list else { return };
for (i, g) in list.iter().enumerate() {
row(ui, 32.0, |ui| {

View File

@ -46,7 +46,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
for (name, hex) in SECTION_COLORS {
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
let c = figcraft_color::Color::from_hex(hex).unwrap_or(figcraft_color::Color::WHITE);
ui.painter().rect_filled(r, CornerRadius::same(9), crate::theme::c32(c));
ui.painter().rect_filled(r, CornerRadius::same(9), crate::theme::doc_c32(ui.ctx(), c));
let on = current.as_ref().and_then(|c| c.as_ref()).is_some_and(|h| h.eq_ignore_ascii_case(hex.trim_start_matches('#')));
let stroke = if on { Stroke::new(2.0, crate::theme::ACCENT) } else { Stroke::new(1.0, t.border) };
ui.painter().rect_stroke(r, CornerRadius::same(9), stroke, egui::StrokeKind::Inside);

View File

@ -0,0 +1,642 @@
//! The style picker: the popover a Fill, Stroke, Effects or Layout grid section's Styles button
//! opens, laid out like the variable picker. Search; styles grouped by the folders of their
//! `/`-separated names (folders collapse); a list or, for colour styles, a grid of swatches. Click
//! applies a style; its edit button (or a right-click) edits the name, description and values; `+`
//! creates a style from the selection's current values; the detach button unlinks it. Colour slots
//! also list colour variables to bind.
use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, Ui, pos2, vec2};
use figcraft_color::Paint;
use figcraft_doc::{Document, GridKind, Node, NodeId, Style, StyleKind, VariableType, VariableValue};
use serde_json::json;
use crate::FigApp;
use crate::colorpicker::PickerTarget;
use crate::icons::{self, Icon};
use crate::theme::{Tokens, regular, semibold};
use crate::widgets;
/// Content width of the popover.
const W: f32 = 240.0;
/// Height of a list row.
const ROW_H: f32 = 26.0;
/// Side of a swatch in the grid view.
const SWATCH: f32 = 24.0;
/// A style slot on a layer: what a style picker lists and what a style chip shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StyleSlot {
Fill,
Stroke,
Effect,
Grid,
}
impl StyleSlot {
pub fn parse(s: &str) -> Option<Self> {
match s {
"fill" => Some(StyleSlot::Fill),
"stroke" => Some(StyleSlot::Stroke),
"effect" => Some(StyleSlot::Effect),
"grid" => Some(StyleSlot::Grid),
_ => None,
}
}
/// The slot's name in `style.apply {target}`, `style.detach {which}` and `variable.bind {field}`.
pub fn key(self) -> &'static str {
match self {
StyleSlot::Fill => "fill",
StyleSlot::Stroke => "stroke",
StyleSlot::Effect => "effect",
StyleSlot::Grid => "grid",
}
}
fn linked(self, n: &Node) -> Option<String> {
match self {
StyleSlot::Fill => n.styles.fill.clone(),
StyleSlot::Stroke => n.styles.stroke.clone(),
StyleSlot::Effect => n.styles.effect.clone(),
StyleSlot::Grid => n.styles.grid.clone(),
}
}
fn paints(self) -> bool {
matches!(self, StyleSlot::Fill | StyleSlot::Stroke)
}
fn title(self) -> &'static str {
match self {
StyleSlot::Fill | StyleSlot::Stroke => "Color styles",
StyleSlot::Effect => "Effect styles",
StyleSlot::Grid => "Grid styles",
}
}
/// `style.create {type}` for this slot (taken from the layer's fills, strokes, effects or grids).
fn create_type(self) -> &'static str {
match self {
StyleSlot::Fill => "paint",
StyleSlot::Stroke => "stroke",
StyleSlot::Effect => "effect",
StyleSlot::Grid => "grid",
}
}
/// The base of a new style's name ("Color 3").
fn default_name(self) -> &'static str {
match self {
StyleSlot::Fill | StyleSlot::Stroke => "Color",
StyleSlot::Effect => "Effect",
StyleSlot::Grid => "Grid",
}
}
fn holds(self, k: &StyleKind) -> bool {
matches!(
(k, self),
(StyleKind::Paint { .. }, StyleSlot::Fill | StyleSlot::Stroke)
| (StyleKind::Effect { .. }, StyleSlot::Effect)
| (StyleKind::Grid { .. }, StyleSlot::Grid)
)
}
fn icon(self) -> Icon {
if self == StyleSlot::Grid { Icon::Grid } else { Icon::Effects }
}
}
/// The style being edited, with its name and description as typed.
#[derive(Clone, Debug)]
struct Editing {
id: String,
name: String,
description: String,
/// Focus the name (and select it) on the next frame: a style that was just created.
focus: bool,
}
/// The open picker.
#[derive(Clone, Debug)]
pub struct Picker {
slot: StyleSlot,
ids: Vec<NodeId>,
/// Screen y of the button that opened it.
y: f32,
query: String,
edit: Option<Editing>,
}
impl Picker {
pub fn slot(&self) -> StyleSlot {
self.slot
}
}
/// Open the picker for `slot` of `ids`, level with the pointer.
pub fn open(app: &mut FigApp, ui: &Ui, ids: &[NodeId], slot: StyleSlot) {
let y = ui.input(|i| i.pointer.interact_pos()).map_or(140.0, |p| p.y - 12.0);
app.ui.style_picker = Some(Picker { slot, ids: ids.to_vec(), y, query: String::new(), edit: None });
}
/// Open the picker for `slot` of the selection (the control channel), optionally editing `style`.
pub fn open_for_selection(app: &mut FigApp, slot: StyleSlot, style: Option<&str>) {
let ids = app.session.selection();
let edit = style.and_then(|s| app.session.doc().ok()?.style(s).map(editing));
app.ui.style_picker = Some(Picker { slot, ids, y: 140.0, query: String::new(), edit });
}
fn editing(s: &Style) -> Editing {
Editing { id: s.id.clone(), name: s.name.clone(), description: s.description.clone(), focus: false }
}
/// The style `slot` of every one of `ids` is linked to, if they agree.
fn linked_style(doc: &Document, ids: &[NodeId], slot: StyleSlot) -> Option<String> {
crate::right::common(doc, ids, |n| slot.linked(n)).flatten()
}
/// The applied style's name (click opens the picker) with a detach button.
pub fn chip(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot: StyleSlot) {
let t = Tokens::get(ui.ctx());
let Some(name) = linked_style(doc, ids, slot).and_then(|s| doc.style(&s).map(|st| st.name.clone())) else { return };
let mut open_it = false;
crate::right::row(ui, 32.0, |ui| {
let w = (ui.available_width() - 32.0).max(40.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, widgets::FIELD_H), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0)), Icon::Styles, t.icon_secondary);
let galley = ui.painter().layout(name.clone(), regular(11.0), t.text, r.width() - 32.0);
ui.painter().galley(pos2(r.min.x + 26.0, r.center().y - galley.size().y / 2.0), galley, t.text);
open_it = resp.on_hover_text("Change style").clicked();
if icons::button(ui, Icon::Detach, 24.0, false, "Detach style").clicked() {
app.run("style.detach", json!({"ids": ids, "which": slot.key()}));
}
});
if open_it {
open(app, ui, ids, slot);
}
}
/// What the user asked the picker to do this frame.
enum Act {
Apply(String),
Bind(String),
Create,
Detach,
Edit(String),
Back,
EditPaint(String, usize),
UpdateFromSelection(String),
Delete(String),
ToggleFolder(String),
ToggleView,
}
/// An icon button on the dark popover.
fn menu_icon(ui: &mut Ui, rect: Rect, icon: Icon, tip: &str, on: bool) -> Response {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, ui.id().with(("style_picker_btn", tip)), Sense::click()).on_hover_text(tip);
if on || resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.menu_hover);
}
icons::paint(ui.painter(), rect.shrink(5.0), icon, t.menu_text);
resp
}
/// A text button on the dark popover.
fn menu_button(ui: &mut Ui, label: &str) -> Response {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(label.to_string(), regular(11.0), t.menu_text);
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 20.0, widgets::FIELD_H), Sense::click());
ui.painter().rect_filled(rect, CornerRadius::same(5), if resp.hovered() { t.menu_hover } else { Color32::TRANSPARENT });
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.menu_dim), egui::StrokeKind::Inside);
ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, regular(11.0), t.menu_text);
resp
}
/// A dim caption on the popover.
fn caption(ui: &mut Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(text).font(regular(10.0)).color(t.menu_dim));
}
/// The folder part of a style name ("Brand/Blue/500" → "Brand/Blue"), empty at the top level.
fn folder(name: &str) -> &str {
name.rsplit_once('/').map_or("", |(f, _)| f.trim())
}
/// The last part of a style name ("Brand/Blue/500" → "500").
fn leaf(name: &str) -> &str {
name.rsplit_once('/').map_or(name, |(_, l)| l.trim())
}
/// `base N` with the smallest N no style of the document is named.
fn next_name(doc: &Document, base: &str) -> String {
(1..).map(|n| format!("{base} {n}")).find(|n| doc.styles.iter().all(|s| &s.name != n)).unwrap_or_else(|| base.to_string())
}
/// Draw a style's preview (its first paint, or the slot's icon) centred in `r`.
fn preview(ui: &Ui, r: Rect, s: &Style, slot: StyleSlot) {
let t = Tokens::get(ui.ctx());
match &s.kind {
StyleKind::Paint { paints } => {
let p = paints.last().cloned().unwrap_or_else(|| Paint::solid(figcraft_color::Color::TRANSPARENT));
widgets::paint_swatch(ui.painter(), r, &p);
}
_ => icons::paint(ui.painter(), r, slot.icon(), t.menu_text),
}
}
/// One style in the list: preview, name, a check when applied and an edit button on hover.
fn style_row(ui: &mut Ui, s: &Style, slot: StyleSlot, applied: bool, editable: bool, act: &mut Option<Act>) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(W, ROW_H), Sense::click());
let edit_r = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(22.0, 22.0));
let hovered = resp.hovered() || ui.rect_contains_pointer(edit_r);
if hovered {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
preview(ui, Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0)), s, slot);
let galley = ui.painter().layout(leaf(&s.name).to_string(), regular(11.0), t.menu_text, W - 64.0);
ui.painter().galley(pos2(r.min.x + 26.0, r.center().y - galley.size().y / 2.0), galley, t.menu_text);
if hovered && editable {
if menu_icon(ui, edit_r, Icon::Adjust, "Edit style", false).clicked() {
*act = Some(Act::Edit(s.id.clone()));
return;
}
} else if applied {
icons::paint(ui.painter(), edit_r.shrink(5.0), Icon::Check, t.menu_text);
}
let resp = if s.description.is_empty() { resp } else { resp.on_hover_text(&s.description) };
if resp.secondary_clicked() && editable {
*act = Some(Act::Edit(s.id.clone()));
} else if resp.clicked() {
*act = Some(Act::Apply(s.id.clone()));
}
}
/// Colour styles as a grid of swatches (name on hover).
fn swatch_grid(ui: &mut Ui, list: &[&Style], applied: Option<&str>, editable: impl Fn(&str) -> bool, act: &mut Option<Act>) {
let t = Tokens::get(ui.ctx());
let per_row = ((W - 8.0 + 6.0) / (SWATCH + 6.0)).floor().max(1.0) as usize;
for chunk in list.chunks(per_row) {
ui.horizontal(|ui| {
ui.add_space(4.0);
ui.spacing_mut().item_spacing.x = 6.0;
for s in chunk {
let (r, resp) = ui.allocate_exact_size(vec2(SWATCH, SWATCH), Sense::click());
preview(ui, r, s, StyleSlot::Fill);
if applied == Some(s.id.as_str()) {
ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(4), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.menu_text), egui::StrokeKind::Inside);
}
let resp = resp.on_hover_text(&s.name);
if resp.secondary_clicked() && editable(&s.id) {
*act = Some(Act::Edit(s.id.clone()));
} else if resp.clicked() {
*act = Some(Act::Apply(s.id.clone()));
}
}
});
ui.add_space(2.0);
}
}
/// The style list: header, search, folders of styles and (colour slots) colour variables.
fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, act: &mut Option<Act>) {
let t = Tokens::get(ui.ctx());
let slot = p.slot;
let applied = linked_style(doc, &p.ids, slot);
let grid = slot.paints() && app.ui.style_grid;
// Header: title, then view toggle, detach and create.
let (hr, _) = ui.allocate_exact_size(vec2(W, 24.0), Sense::hover());
ui.painter().text(pos2(hr.min.x + 2.0, hr.center().y), Align2::LEFT_CENTER, slot.title(), semibold(11.0), t.menu_text);
let mut x = hr.max.x - 24.0;
let mut slot_rect = || {
let r = Rect::from_min_size(pos2(x, hr.min.y), vec2(24.0, 24.0));
x -= 26.0;
r
};
if menu_icon(ui, slot_rect(), Icon::Plus, "Create style", false).clicked() {
*act = Some(Act::Create);
}
if applied.is_some() && menu_icon(ui, slot_rect(), Icon::Detach, "Detach style", false).clicked() {
*act = Some(Act::Detach);
}
if slot.paints() {
let (icon, tip) = if grid { (Icon::ViewList, "Show as list") } else { (Icon::ViewGrid, "Show as grid") };
if menu_icon(ui, slot_rect(), icon, tip, false).clicked() {
*act = Some(Act::ToggleView);
}
}
ui.add_space(4.0);
let r = ui.add(egui::TextEdit::singleline(&mut p.query).hint_text("Search").font(regular(11.0)).desired_width(W));
if !r.has_focus() && ui.memory(|m| m.focused().is_none()) {
r.request_focus();
}
ui.add_space(4.0);
let q = p.query.trim().to_lowercase();
let matches = |name: &str| q.is_empty() || name.to_lowercase().contains(&q);
// Styles by folder, folders in order of first appearance.
let mut groups: Vec<(&str, Vec<&Style>)> = Vec::new();
for s in doc.styles.iter().filter(|s| slot.holds(&s.kind) && matches(&s.name)) {
let f = folder(&s.name);
match groups.iter_mut().find(|(g, _)| *g == f) {
Some((_, list)) => list.push(s),
None => groups.push((f, vec![s])),
}
}
groups.sort_by_key(|(f, _)| !f.is_empty());
let editable = |id: &str| !figcraft_engine::library::is_remote_style(doc, id);
let modes = std::collections::BTreeMap::new();
let colors: Vec<(&figcraft_doc::Variable, figcraft_color::Color)> = if slot.paints() {
doc.variables
.variables
.iter()
.filter(|v| v.kind == VariableType::Color && matches(&v.name))
.filter_map(|v| match doc.variables.resolve(&v.id, &modes) {
Some(VariableValue::Color(c)) => Some((v, c)),
_ => None,
})
.collect()
} else {
Vec::new()
};
egui::ScrollArea::vertical().id_salt("style_picker_scroll").max_height(320.0).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
if groups.is_empty() {
let any = doc.styles.iter().any(|s| slot.holds(&s.kind));
caption(ui, if any { "No matching styles" } else { "No styles yet. Click + to create one from the selection." });
}
for (f, list) in &groups {
if !f.is_empty() {
// Searching shows every match, folders open.
let closed = q.is_empty() && app.ui.style_folds.contains(*f);
let (r, resp) = ui.allocate_exact_size(vec2(W, ROW_H), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
let chevron = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0));
icons::paint(ui.painter(), chevron, if closed { Icon::ChevronRight } else { Icon::Chevron }, t.menu_dim);
let label = f.split('/').map(str::trim).collect::<Vec<_>>().join(" / ");
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.menu_dim);
if resp.clicked() && q.is_empty() {
*act = Some(Act::ToggleFolder(f.to_string()));
}
if closed {
continue;
}
}
if grid {
ui.add_space(4.0);
swatch_grid(ui, list, applied.as_deref(), editable, act);
} else {
for s in list {
style_row(ui, s, slot, applied.as_deref() == Some(s.id.as_str()), editable(&s.id), act);
}
}
}
if !colors.is_empty() {
ui.add_space(6.0);
caption(ui, "Color variables");
for (v, c) in &colors {
let (r, resp) = ui.allocate_exact_size(vec2(W, ROW_H), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
widgets::paint_swatch(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0)), &Paint::solid(*c));
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, &v.name, regular(11.0), t.menu_text);
if resp.clicked() {
*act = Some(Act::Bind(v.id.clone()));
}
}
}
});
}
/// One property row in the editor (a paint, an effect, a grid); paints open the colour picker.
fn property_row(ui: &mut Ui, label: &str, paint: Option<&Paint>, slot: StyleSlot) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(W, ROW_H), if paint.is_some() { Sense::click() } else { Sense::hover() });
if paint.is_some() && resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
let pr = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0));
match paint {
Some(p) => widgets::paint_swatch(ui.painter(), pr, p),
None => icons::paint(ui.painter(), pr, slot.icon(), t.menu_text),
}
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.menu_text);
paint.is_some() && resp.clicked()
}
/// A layout grid's summary ("Columns 12", "Grid 8").
fn grid_label(g: &figcraft_doc::LayoutGrid) -> String {
match g.kind {
GridKind::Grid => format!("Grid {}", g.size),
GridKind::Columns => format!("Columns {}", g.count),
GridKind::Rows => format!("Rows {}", g.count),
}
}
/// Editing one style: name, description, its values (paints open the colour picker), update from
/// the selection, delete.
fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, act: &mut Option<Act>) {
let t = Tokens::get(ui.ctx());
let slot = p.slot;
let has_selection = !p.ids.is_empty();
let Some(e) = p.edit.as_mut() else { return };
let Some(style) = doc.style(&e.id).cloned() else {
*act = Some(Act::Back);
return;
};
let (hr, _) = ui.allocate_exact_size(vec2(W, 24.0), Sense::hover());
if menu_icon(ui, Rect::from_min_size(hr.min, vec2(24.0, 24.0)), Icon::ChevronLeft, "Back to styles", false).clicked() {
*act = Some(Act::Back);
}
ui.painter().text(pos2(hr.min.x + 30.0, hr.center().y), Align2::LEFT_CENTER, "Edit style", semibold(11.0), t.menu_text);
ui.add_space(6.0);
caption(ui, "Name");
let r = ui.add(egui::TextEdit::singleline(&mut e.name).font(regular(11.0)).desired_width(W));
if e.focus {
r.request_focus();
let mut state = egui::TextEdit::load_state(ui.ctx(), r.id).unwrap_or_default();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(e.name.chars().count()));
state.cursor.set_char_range(Some(all));
egui::TextEdit::store_state(ui.ctx(), r.id, state);
e.focus = false;
}
ui.add_space(6.0);
caption(ui, "Description");
ui.add(egui::TextEdit::multiline(&mut e.description).font(regular(11.0)).desired_width(W).desired_rows(2));
ui.add_space(8.0);
caption(ui, "Properties");
ui.scope(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
match &style.kind {
StyleKind::Paint { paints } if paints.is_empty() => caption(ui, "No paints"),
StyleKind::Paint { paints } => {
// Top of the list paints on top: show in reverse like the Fill section.
for (i, paint) in paints.iter().enumerate().rev() {
let label = format!("{} {:.0}%", paint.label(), paint.opacity * 100.0);
if property_row(ui, &label, Some(paint), slot) {
*act = Some(Act::EditPaint(style.id.clone(), i));
}
}
}
StyleKind::Effect { effects } => {
for fx in effects.iter().rev() {
property_row(ui, fx.label(), None, slot);
}
}
StyleKind::Grid { grids } => {
for g in grids {
property_row(ui, &grid_label(g), None, slot);
}
}
StyleKind::Text { .. } => {}
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
if has_selection && menu_button(ui, "Update from selection").on_hover_text("Take the values from the first selected layer").clicked() {
*act = Some(Act::UpdateFromSelection(style.id.clone()));
}
if menu_button(ui, "Delete").on_hover_text("Delete the style (layers keep their values)").clicked() {
*act = Some(Act::Delete(style.id.clone()));
}
});
}
/// Write the typed name and description of the style being edited, when they changed.
fn commit_edit(app: &mut FigApp, doc: &Document, e: &Editing) {
let Some(s) = doc.style(&e.id) else { return };
let name = e.name.trim();
let mut q = json!({"style": e.id});
if !name.is_empty() && name != s.name {
q["name"] = json!(name);
}
if e.description != s.description {
q["description"] = json!(e.description);
}
if q.as_object().is_some_and(|o| o.len() > 1) {
app.run("style.update", q);
}
}
/// The picker popover (call once per frame).
pub fn show(app: &mut FigApp, ctx: &egui::Context) {
let Some(mut p) = app.ui.style_picker.take() else { return };
let Ok(doc) = app.session.ds().map(|d| d.doc.clone()) else { return };
// A new selection closes the picker (it acts on the layers it was opened for).
if app.session.selection() != p.ids {
if let Some(e) = &p.edit {
commit_edit(app, &doc, e);
}
return;
}
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
// Left of the Design panel; further left while the colour picker (which sits there) is open.
let mut x = screen.max.x - app.ui.right_width - W - 32.0;
if app.ui.picker.is_some() {
x -= 260.0;
}
let y = p.y.clamp(screen.min.y + 48.0, (screen.max.y - 460.0).max(screen.min.y + 48.0));
let mut act = None;
let area = egui::Area::new(Id::new("style_picker_area")).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| {
egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
ui.set_width(W);
if p.edit.is_some() {
editor(ui, &doc, &mut p, &mut act);
} else {
listing(app, ui, &doc, &mut p, &mut act);
}
});
});
let rect = area.response.rect;
ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("style_picker_border"))).rect_stroke(
rect,
CornerRadius::same(10),
Stroke::new(1.0, Color32::from_black_alpha(40)),
egui::StrokeKind::Inside,
);
// Clicks in the colour picker editing this picker's style keep it open.
let picker_rect =
app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(Id::new("color_picker"))));
let outside = ctx.input(|i| {
i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|pos| !rect.contains(pos) && !picker_rect.is_some_and(|r| r.contains(pos)))
});
let mut close = outside || ctx.input(|i| i.key_pressed(egui::Key::Escape));
let ids = p.ids.clone();
let slot = p.slot;
match act {
Some(Act::Apply(style)) => {
let mut q = json!({"ids": ids, "style": style});
if slot.paints() {
q["target"] = json!(slot.key());
}
app.run("style.apply", q);
close = true;
}
Some(Act::Bind(variable)) => {
app.run("variable.bind", json!({"ids": ids, "field": slot.key(), "variable": variable}));
close = true;
}
Some(Act::Detach) => {
app.run("style.detach", json!({"ids": ids, "which": slot.key()}));
}
Some(Act::Create) => {
let name = next_name(&doc, slot.default_name());
let made = app.run("style.create", json!({"type": slot.create_type(), "name": name, "from": ids.first()}));
if let Some(id) = made.as_ref().and_then(|v| v["id"].as_str()) {
p.query.clear();
p.edit = Some(Editing { id: id.to_string(), name, description: String::new(), focus: true });
}
}
Some(Act::Edit(id)) => p.edit = doc.style(&id).map(editing),
Some(Act::Back) => {
if let Some(e) = p.edit.take() {
commit_edit(app, &doc, &e);
}
}
Some(Act::EditPaint(id, index)) => {
if let Some(e) = &p.edit {
commit_edit(app, &doc, e);
}
app.ui.picker = Some(PickerTarget::Style { id, index });
}
Some(Act::UpdateFromSelection(id)) => {
app.run("style.update", json!({"style": id, "from": ids.first()}));
}
Some(Act::Delete(id)) => {
app.run("style.delete", json!({"style": id}));
p.edit = None;
}
Some(Act::ToggleFolder(f)) => {
if !app.ui.style_folds.remove(&f) {
app.ui.style_folds.insert(f);
}
}
Some(Act::ToggleView) => app.ui.style_grid = !app.ui.style_grid,
None => {}
}
if close {
if let Some(e) = &p.edit {
commit_edit(app, &doc, e);
}
if matches!(app.ui.picker, Some(PickerTarget::Style { .. })) {
app.ui.picker = None;
}
} else {
app.ui.style_picker = Some(p);
}
}
#[cfg(test)]
#[path = "style_picker_tests.rs"]
mod tests;

View File

@ -0,0 +1,70 @@
//! The style picker: name folders, new style names, and the popover drawn headless (list, grid,
//! editor), committing a typed name when it closes.
use egui::{Pos2, RawInput, Rect, vec2};
use figcraft_engine::Session;
use serde_json::json;
use super::*;
use crate::{FigApp, NoServices};
#[test]
fn names_split_into_folders() {
assert_eq!((folder("Brand/Blue/500"), leaf("Brand/Blue/500")), ("Brand/Blue", "500"));
assert_eq!((folder("Danger"), leaf("Danger")), ("", "Danger"));
assert_eq!((folder(" Brand / Primary "), leaf(" Brand / Primary ")), ("Brand", "Primary"));
let mut d = Document::new("d");
assert_eq!(next_name(&d, "Color"), "Color 1");
for n in ["Color 1", "Color 2"] {
d.styles.push(Style { id: n.into(), name: n.into(), description: String::new(), kind: StyleKind::Paint { paints: vec![] } });
}
assert_eq!(next_name(&d, "Color"), "Color 3");
assert!(StyleSlot::Fill.holds(&StyleKind::Paint { paints: vec![] }) && !StyleSlot::Effect.holds(&StyleKind::Paint { paints: vec![] }));
for s in [StyleSlot::Fill, StyleSlot::Stroke, StyleSlot::Effect, StyleSlot::Grid] {
assert_eq!(StyleSlot::parse(s.key()), Some(s));
}
}
fn frame(ctx: &egui::Context, app: &mut FigApp, t: &mut f64) {
*t += 1.0 / 60.0;
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(*t), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| app.ui(ui));
out.textures_delta.clear();
}
#[test]
fn picker_draws_every_view_and_commits_a_typed_name_on_close() {
let mut s = Session::new();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
for (name, c) in [("Brand/Primary", "#0D99FF"), ("Brand/Accent", "#FFC700"), ("Danger", "#F24822")] {
s.execute("style.create", &json!({"type": "paint", "name": name, "value": [{"type": "solid", "color": c}]})).unwrap();
}
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("style.apply", &json!({"ids": [r], "style": "Brand/Primary", "target": "fill"})).unwrap();
let mut app = FigApp::new(s, Box::new(NoServices));
let ctx = egui::Context::default();
let mut t = 0.0;
for slot in [StyleSlot::Fill, StyleSlot::Stroke, StyleSlot::Effect, StyleSlot::Grid] {
open_for_selection(&mut app, slot, None);
for grid in [false, true] {
app.ui.style_grid = grid;
for _ in 0..3 {
frame(&ctx, &mut app, &mut t);
}
assert_eq!(app.ui.style_picker.as_ref().map(Picker::slot), Some(slot), "{slot:?} stays open");
}
}
// Folders fold.
app.ui.style_folds.insert("Brand".into());
frame(&ctx, &mut app, &mut t);
// The editor; a name typed there is written when the picker closes (here: a new selection).
open_for_selection(&mut app, StyleSlot::Fill, Some("Brand/Accent"));
frame(&ctx, &mut app, &mut t);
let edit = app.ui.style_picker.as_mut().and_then(|p| p.edit.as_mut()).expect("editing");
edit.name = "Brand/Gold".into();
app.run("select.none", json!({}));
frame(&ctx, &mut app, &mut t);
assert!(app.ui.style_picker.is_none(), "a new selection closes the picker");
let doc = app.session.doc().unwrap();
assert!(doc.style("Brand/Gold").is_some(), "{:?}", doc.styles.iter().map(|s| &s.name).collect::<Vec<_>>());
}

View File

@ -229,3 +229,21 @@ pub fn c32(c: figcraft_color::Color) -> Color32 {
let [r, g, b, a] = c.to_rgba8();
Color32::from_rgba_unmultiplied(r, g, b, a)
}
fn screen_conversion_id() -> egui::Id {
egui::Id::new("figcraft_screen_conversion")
}
/// Remember the colour profile of the document on screen (once per frame): document colours drawn
/// by the UI ([`doc_c32`]) go from it to the screen's sRGB.
pub fn set_doc_profile(ctx: &Context, profile: figcraft_color::ColorProfile) {
let conv = profile.conversion_to(figcraft_color::ColorProfile::Srgb);
ctx.data_mut(|d| d.insert_temp(screen_conversion_id(), conv));
}
/// A document colour (a paint, swatch, background) for the screen: converted from the document's
/// colour profile to sRGB. UI chrome colours use [`c32`].
pub fn doc_c32(ctx: &Context, c: figcraft_color::Color) -> Color32 {
let conv = ctx.data(|d| d.get_temp::<figcraft_color::ProfileConversion>(screen_conversion_id()));
c32(conv.map_or(c, |k| k.apply(c)))
}

View File

@ -470,8 +470,8 @@ pub fn paint_swatch(painter: &egui::Painter, rect: Rect, paint: &figcraft_color:
match &paint.kind {
PaintKind::Solid { color } => {
// Left half opaque, right half with alpha (like Figma's swatches).
let c = crate::theme::c32(color.with_alpha(color.a * paint.opacity));
let opaque = crate::theme::c32(color.with_alpha(1.0));
let c = crate::theme::doc_c32(painter.ctx(), color.with_alpha(color.a * paint.opacity));
let opaque = crate::theme::doc_c32(painter.ctx(), color.with_alpha(1.0));
clip.rect_filled(Rect::from_min_max(rect.min, pos2(rect.center().x, rect.max.y)), CornerRadius::ZERO, opaque);
clip.rect_filled(Rect::from_min_max(pos2(rect.center().x, rect.min.y), rect.max), CornerRadius::ZERO, c);
if paint.opacity >= 0.999 && color.a >= 0.999 {
@ -481,7 +481,7 @@ pub fn paint_swatch(painter: &egui::Painter, rect: Rect, paint: &figcraft_color:
PaintKind::Gradient(g) => {
let n = 16;
for i in 0..n {
let c = crate::theme::c32(g.sample(i as f32 / (n - 1) as f32));
let c = crate::theme::doc_c32(painter.ctx(), g.sample(i as f32 / (n - 1) as f32));
let x0 = rect.min.x + rect.width() * i as f32 / n as f32;
let x1 = rect.min.x + rect.width() * (i + 1) as f32 / n as f32;
clip.rect_filled(Rect::from_min_max(pos2(x0, rect.min.y), pos2(x1, rect.max.y)), CornerRadius::ZERO, c);
@ -510,6 +510,21 @@ pub fn checkbox(ui: &mut Ui, on: bool, label: &str) -> bool {
resp.clicked()
}
/// A radio button with label; returns whether it was clicked.
pub fn radio(ui: &mut Ui, on: bool, label: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(240.0), FIELD_H), Sense::click());
let c = pos2(rect.min.x + 7.0, rect.center().y);
if on {
ui.painter().circle_filled(c, 7.0, t.accent);
ui.painter().circle_filled(c, 2.5, Color32::WHITE);
} else {
ui.painter().circle_stroke(c, 6.5, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.border_strong }));
}
ui.painter().text(pos2(rect.min.x + 22.0, rect.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text);
resp.clicked()
}
/// A text button: `primary` is filled with the accent colour, otherwise outlined. A disabled
/// button is dimmed and never reports clicks.
pub fn button(ui: &mut Ui, label: &str, primary: bool, enabled: bool) -> Response {

View File

@ -46,7 +46,8 @@ Every command: `[{id, label, params, menu, shortcut, undoable}]`.
The active document as JSON: name, path, dirty, currentPage, selection, pages (the current page's
layer tree to `depth`, default 8; others summarised unless `allPages`), styles, variable count,
images, the text being edited and the undo history.
images, the colour profile (`colorProfile`: `srgb` or `displayP3`), the text being edited and the
undo history.
### `node.get {id, depth?}`
@ -93,7 +94,7 @@ text tool).
Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`.
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?}`
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?}`
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `colorPicker`
@ -103,7 +104,11 @@ open the layers search with that filter; `null` closes it), `collapseLayers` (tr
layers tree), `exportPreview` (bool: the Design panel Export section's preview), `preview` (true or a
frame id: open the prototype preview pane like `proto.preview`; false or `null` closes it),
`presentScroll` (`[x, y]`: scroll the presented screen, the full window's else the pane's, clamped
to what it can scroll). Returns `ui.inspect`.
to what it can scroll), `colorProfileDialog` (`srgb|displayP3`: open File ▸ Color profile with that
profile chosen; `null` closes it), `stylePicker` (`fill|stroke|effect|grid`: open the style picker
for the selection; `null` closes it) with `stylePickerEdit` (a style id or name: open its editor),
`stylePickerView` (`list|grid`). Returns `ui.inspect` (which reports `stylePicker` and
`stylePickerView`).
### `ui.screenshot {path}`

View File

@ -82,6 +82,19 @@ Or in a project's `.mcp.json`:
tools, `color` may also be a hex string and gradients can be written
`{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; the server converts them to the
engine's paint JSON.
- **Colour profile**: colour values are in the file's profile, sRGB (default) or Display P3
(`inspect_document` reports `colorProfile`). `file.colorProfile {profile: srgb|displayP3,
convert?}` changes it: `convert` (default true) converts every colour value so colours keep their
appearance; `false` keeps the values and reinterprets them. Images are treated as sRGB and managed
into the file's space. PNG exports are tagged (`sRGB` chunk, or a Display P3 ICC profile plus
`cICP`), Display P3 JPG and WebP embed the ICC profile, and SVG and PDF export Display P3 files
converted to sRGB (out-of-gamut colours clip).
- **Image adjustments**: image paints carry `filters` (exposure, contrast, saturation, temperature,
tint, highlights, shadows; each -1..1). `image.adjust {ids?, target?: fill|stroke, index?,
preset?, filters?}` applies a built-in preset (`vivid`, `muted`, `warm`, `cool`, `blackAndWhite`,
`fade`, `reset`) or one saved in the file, with any `filters` values on top.
`image.savePreset {name, filters?}` saves the current (or given) adjustments in the file,
`image.deletePreset {name}` removes one and `image.presets` lists both kinds.
- Text props (`fontFamily`, `fontStyle`, `fontSize`, `lineHeight`, `letterSpacing`, `textDecoration`,
`textCase`, `textFills`, `fontVariations`…) apply to the whole layer, or to chars `start`..`end`
when given (`text.style {id, start, end, …}` does the same). Styled ranges come back as

View File

@ -1,8 +1,8 @@
# Parity report
Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (376 commands in the engine).
Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (381 commands in the engine).
**196 / 201 menu items (97.5%)**
**197 / 202 menu items (97.5%)**
## Missing