UI: improve color picker layout (#2008)

Add a centered draggable header, stacked actions and aligned input groups.
Compact the color controls and use vertical scrolling in narrow windows.
Keep the screen sampler above the field and Tab through each color model
before the next. Confirmation, cancellation and live preview stay shared.

Preserve screen-reader button names and channel values, units and ranges.
Add pointer/key, layout, warning and AccessKit regressions, and update the
required progress notes without changing parity scores or estimates. Correct
the stale #1692 gap status while retaining the open Direct Selection gap.
Document dialog rendering, button sizing and the regression helpers.

Validation: cargo xtask ci passed all 10 steps with official Rust 1.99,
including strict Clippy, 8437 workspace tests and all WASM checks. All 17
targeted color picker tests passed. German, enlarged English, narrow-window
and color-warning UI renders were reviewed locally.

Fixes #2008
This commit is contained in:
Shayan Horbank
2026-10-20 01:30:19 +07:00
committed by bflatastic
parent 819da290f1
commit 472a6d5359
7 changed files with 581 additions and 68 deletions
+518 -63
View File
@@ -16,8 +16,8 @@ use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::ColorMode;
use vectorcraft_engine::cmd::color_value;
use super::DialogSpec;
use super::form::PREVIEWED;
use super::{DialogAction, DialogSpec};
use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe};
use crate::panels::{c32, color_json};
use crate::state::Dialog;
@@ -28,18 +28,34 @@ pub(super) const KIND: &str = "colorPicker";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Color Picker").into(),
body,
header: Some(header),
body_with_actions: Some(body),
confirm,
preview: true,
min_width: 600.0,
max_width: Some(640.0),
min_width: 0.0,
..DialogSpec::FORM
};
/// Side of the colour field (and height of the channel slider).
const FIELD: f32 = 240.0;
/// Width of the value fields.
const VALUE_W: f32 = 54.0;
/// The preview and actions share the top row; the fields span both columns beneath it.
const HEADER_H: f32 = 30.0;
const CHIP_H: f32 = 64.0;
const ACTION_GAP: f32 = 2.0;
const ACTIONS_H: f32 = 3.0 * 28.0 + 2.0 * ACTION_GAP;
const ROW_H: f32 = 26.0;
const VALUE_GAP: f32 = 4.0;
const ROW_GAP: f32 = 1.0;
const GROUP_GAP: f32 = 2.0;
const PREVIEW_GAP: f32 = 12.0;
const FIELD: f32 = ACTIONS_H + PREVIEW_GAP + 7.0 * ROW_H + 6.0 * ROW_GAP + GROUP_GAP;
const MIN_FIELD: f32 = 160.0;
// channel_slider reserves 6 points on each side for its arrows: the painted ramp is 20 wide.
const SLIDER_W: f32 = 32.0;
const SLIDER_GAP: f32 = 16.0;
const COLUMN_GAP: f32 = 12.0;
// Leave room for two warning chips below the preview, instead of an empty side column.
const PREVIEW_W: f32 = 88.0;
/// Matches the outer width of the existing hex field.
const VALUE_W: f32 = 74.0;
/// The component the channel slider edits; the colour field shows the other two.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -203,68 +219,152 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
Ok(Value::Null)
}
fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
/// Stable action width in both modes, including translated labels.
fn button_width(ui: &Ui) -> f32 {
["OK", "Cancel", "Color Swatches", "Color Models"].into_iter().fold(110.0_f32, |w, label| {
let text = ui.painter().layout_no_wrap(tl!(label).into(), crate::theme::semibold(12.5), Tokens::get(ui.ctx()).text);
w.max(text.size().x + 32.0)
})
}
/// A compact width shared by the heading and body, bounded by the available window width.
fn layout_size(ui: &Ui) -> (f32, f32, f32, bool) {
let button_w = button_width(ui);
let values_w = PREVIEW_W + COLUMN_GAP + button_w;
let fixed_w = SLIDER_GAP + SLIDER_W + COLUMN_GAP + values_w;
let stacked = ui.available_width() < MIN_FIELD + fixed_w;
let side = (ui.available_width() - fixed_w).clamp(MIN_FIELD, FIELD);
(side, values_w, button_w, stacked)
}
/// Full-width, draggable title band using the app's existing theme colours.
fn header(ui: &mut Ui, title: &str) {
let (side, values_w, _, stacked) = layout_size(ui);
let field_w = side + SLIDER_GAP + SLIDER_W;
let width = if stacked { field_w.max(values_w) } else { field_w + COLUMN_GAP + values_w };
ui.set_width(width.min(ui.available_width()));
let margin = f32::from(super::MARGIN);
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER_H - margin), Sense::hover());
let band = Rect::from_min_max(r.min - vec2(margin, margin), r.max + vec2(margin, 0.0));
let t = Tokens::get(ui.ctx());
let corners = ui.visuals().window_corner_radius;
// The band extends into the window's existing margins, stopping at its outer edges.
let painter = ui.painter().with_clip_rect(ui.ctx().content_rect());
painter.rect_filled(band, egui::CornerRadius { sw: 0, se: 0, ..corners }, t.panel_darker);
painter.hline(band.x_range(), band.bottom(), egui::Stroke::new(1.0, t.divider));
painter.text(band.center(), egui::Align2::CENTER_CENTER, title, crate::theme::semibold(16.0), t.text);
super::modal::drag_band(ui, band.bottom());
}
/// Draws the responsive picker and updates its color fields and live preview.
/// Returns the requested action for the shared frame to confirm or cancel.
fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> DialogAction {
let channel = Channel::parse(&d.str("channel")).unwrap_or_default();
let web = d.bool("webOnly");
let swatches = d.bool("swatches");
let p = picked(d);
let original = d.fields.get("original").and_then(color_value).unwrap_or(p.color);
let gamut = gamut_fix(app, ui.ctx(), "picker-gamut", &p.color);
let (side, values_w, button_w, stacked) = layout_size(ui);
let field_w = side + SLIDER_GAP + SLIDER_W;
let content_w = if stacked { field_w.max(values_w) } else { field_w + COLUMN_GAP + values_w };
let mut pick: Option<Picked> = None;
let mut new_channel = None;
let mut toggle_swatches = false;
let mut sample = false;
ui.horizontal_top(|ui| {
if swatches {
pick = swatch_list(app, ui, &p.color).map(Picked::of);
} else {
let (z, x, y) = field_pos(channel, &p);
let at = |x: f32, y: f32| c32(&field_pick(channel, z, x, y).snapped(web).color);
if let (Some((x, y)), _) = widgets::color_field(ui, "cp-field", vec2(FIELD, FIELD), (x, y), &at) {
pick = Some(field_pick(channel, z, x, y));
}
ui.add_space(6.0);
let track = |z: f32| c32(&Picked::of(slider_color(channel, z, x, y)).snapped(web).color);
let (v, suffix, max) = channel_value(channel, &p);
let shown = widgets::SliderValue::field(v as f64, 0.0..=max as f64, suffix, 0);
if let (Some(z), _) = widgets::channel_slider_valued(ui, "cp-slider", tl!(channel.label()), Some(shown), z, vec2(22.0, FIELD), &track) {
pick = Some(field_pick(channel, z, x, y));
}
}
ui.add_space(14.0);
ui.vertical(|ui| {
ui.horizontal_top(|ui| {
if new_original_chips(ui, &p.color, &original) {
pick = Some(Picked::of(original));
}
ui.add_space(4.0);
let mut action = DialogAction::None;
// Shrink the field first, then stack the columns in narrow windows. Only vertical
// scrolling is needed, so no controls depend on a horizontal scrollbar.
let height = (ui.ctx().content_rect().height() - 112.0).max(1.0);
// Let the auto-sized window grow for the checkbox too, rather than clipping it to the
// scroll area's previous height while the dialog measures itself.
let field_h = side + 8.0 + 20.0 + 2.0 * ui.spacing().item_spacing.y;
let body_height = if stacked { field_h + COLUMN_GAP + FIELD } else { field_h.max(FIELD) };
let layout = if stacked { egui::Layout::top_down(egui::Align::Min) } else { egui::Layout::left_to_right(egui::Align::Min) };
egui::ScrollArea::vertical()
.id_salt("cp-content")
.max_width(ui.available_width())
.max_height(height)
.min_scrolled_height((body_height + 32.0).min(height))
.show(ui, |ui| {
ui.set_min_width(content_w);
// Keep the screen sampler above the field, outside the value grids' Tab order.
ui.allocate_ui_with_layout(vec2(side, 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
sample = crate::screen_color::button(app, ui);
ui.add_space(4.0);
ui.vertical(|ui| {
if let Some(fix) = gamut
&& warning_chip(ui, GAMUT_WARNING, &fix)
{
pick = Some(Picked::of(fix));
}
let ws = web_safe(&p.color);
if !web && !is_web_safe(&p.color) && warning_chip(ui, WEB_WARNING, &ws) {
pick = Some(Picked::of(ws));
});
ui.add_space(4.0);
ui.with_layout(layout, |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.allocate_ui_with_layout(vec2(side + SLIDER_GAP + SLIDER_W, side), egui::Layout::top_down(egui::Align::Min), |ui| {
ui.horizontal_top(|ui| {
if swatches {
pick = swatch_list(app, ui, &p.color, side).map(Picked::of);
} else {
let (z, x, y) = field_pos(channel, &p);
let at = |x: f32, y: f32| c32(&field_pick(channel, z, x, y).snapped(web).color);
if let (Some((x, y)), _) = widgets::color_field(ui, "cp-field", vec2(side, side), (x, y), &at) {
pick = Some(field_pick(channel, z, x, y));
}
ui.add_space(SLIDER_GAP);
let track = |z: f32| c32(&Picked::of(slider_color(channel, z, x, y)).snapped(web).color);
let (v, suffix, max) = channel_value(channel, &p);
let shown = widgets::SliderValue::field(v as f64, 0.0..=max as f64, suffix, 0);
if let (Some(z), _) =
widgets::channel_slider_valued(ui, "cp-slider", tl!(channel.label()), Some(shown), z, vec2(SLIDER_W, side), &track)
{
pick = Some(field_pick(channel, z, x, y));
}
}
});
ui.add_space(8.0);
if widgets::check(ui, tl!("Only Web Colors"), web, true) {
d.fields.insert("webOnly".into(), json!(!web));
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
toggle_swatches = widgets::flat_button(ui, if swatches { tl!("Color Models") } else { tl!("Color Swatches") }, 110.0).clicked();
ui.add_space(COLUMN_GAP);
ui.allocate_ui_with_layout(vec2(values_w, FIELD), egui::Layout::top_down(egui::Align::Min), |ui| {
ui.spacing_mut().item_spacing = vec2(VALUE_GAP, ROW_GAP);
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.allocate_ui_with_layout(vec2(PREVIEW_W, ACTIONS_H), egui::Layout::top_down(egui::Align::Min), |ui| {
ui.set_min_size(vec2(PREVIEW_W, ACTIONS_H));
if new_original_chips(ui, &p.color, &original) {
pick = Some(Picked::of(original));
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = VALUE_GAP;
if let Some(fix) = gamut
&& warning_chip(ui, GAMUT_WARNING, &fix)
{
pick = Some(Picked::of(fix));
}
let ws = web_safe(&p.color);
if !web && !is_web_safe(&p.color) && warning_chip(ui, WEB_WARNING, &ws) {
pick = Some(Picked::of(ws));
}
});
});
ui.add_space(COLUMN_GAP);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = ACTION_GAP;
if widgets::primary_button_width(ui, "OK", button_w).clicked() {
action = DialogAction::Confirm;
}
if widgets::secondary_button_width(ui, "Cancel", button_w).clicked() {
action = DialogAction::Cancel;
}
toggle_swatches =
widgets::secondary_button_width(ui, if swatches { tl!("Color Models") } else { tl!("Color Swatches") }, button_w)
.clicked();
});
});
ui.add_space(PREVIEW_GAP - ROW_GAP);
let (edit, ch) = value_fields(ui, &p, channel);
pick = pick.or(edit);
new_channel = ch;
});
});
ui.add_space(10.0);
let (edit, ch) = value_fields(ui, &p, channel);
pick = pick.or(edit);
new_channel = ch;
});
});
ui.add_space(8.0);
if widgets::check(ui, tl!("Only Web Colors"), web, true) {
d.fields.insert("webOnly".into(), json!(!web));
}
if let Some(ch) = new_channel {
d.fields.insert("channel".into(), json!(ch.id()));
}
@@ -276,7 +376,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
store(d, &pick.unwrap_or(p).snapped(d.bool("webOnly")));
preview(app, d);
false
action
}
/// Show the picked colour on the selected art while the picker is open: each new colour replaces
@@ -311,7 +411,7 @@ fn paint_command(app: &VectorcraftApp, d: &Dialog) -> (&'static str, Value) {
/// The New (top) and Original (bottom) chips. Returns true when Original is clicked.
fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(64.0, 60.0), Sense::hover());
let (r, _) = ui.allocate_exact_size(vec2(64.0, CHIP_H), Sense::hover());
let top = Rect::from_min_max(r.min, egui::pos2(r.right(), r.center().y));
let bottom = Rect::from_min_max(egui::pos2(r.left(), r.center().y), r.max);
widgets::paint_chip(ui, top, &Paint::solid(*new));
@@ -335,11 +435,17 @@ fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option<Picked>, O
let cmyk = p.color.to_cmyk_managed(cms.settings().intent);
let mut edit = None;
let mut chosen = None;
let spacing = vec2(6.0, 4.0);
let spacing = vec2(VALUE_GAP, ROW_GAP);
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = spacing.x;
let left = egui::Grid::new("cp-values").spacing(spacing).show(ui, |ui| {
let left = egui::Grid::new("cp-values").num_columns(2).min_row_height(0.0).spacing(spacing).show(ui, |ui| {
for i in 0..7 {
if i == 3 {
for _ in 0..2 {
ui.allocate_exact_size(vec2(0.0, GROUP_GAP - ROW_GAP), Sense::hover());
}
ui.end_row();
}
if let Some(&ch) = Channel::ALL.get(i) {
if widgets::radio(ui, ch.label(), ch == channel, true) {
chosen = Some(ch);
@@ -370,8 +476,14 @@ fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option<Picked>, O
ui.end_row();
}
});
egui::Grid::new("cp-values-right").spacing(spacing).show(ui, |ui| {
egui::Grid::new("cp-values-right").num_columns(2).min_row_height(0.0).spacing(spacing).show(ui, |ui| {
for i in 0..7 {
if i == 3 {
for _ in 0..2 {
ui.allocate_exact_size(vec2(0.0, GROUP_GAP - ROW_GAP), Sense::hover());
}
ui.end_row();
}
let (label, v, suffix) = match i {
0 => ("L", lab.l, ""),
1 => ("a", lab.a, ""),
@@ -406,13 +518,14 @@ fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option<Picked>, O
}
/// The document's colour swatches as a list; returns the clicked swatch's colour.
fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color) -> Option<Color> {
fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color, side: f32) -> Option<Color> {
let t = Tokens::get(ui.ctx());
let mut chosen = None;
ui.allocate_ui(vec2(FIELD + 28.0, FIELD), |ui| {
ui.allocate_ui(vec2(side + SLIDER_GAP + SLIDER_W, side), |ui| {
ui.set_min_size(vec2(side + SLIDER_GAP + SLIDER_W, side));
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("cp-swatches").max_height(FIELD).show(ui, |ui| {
ui.set_width(FIELD + 24.0);
egui::ScrollArea::vertical().id_salt("cp-swatches").max_height((side - 8.0).max(1.0)).show(ui, |ui| {
ui.set_width(side + SLIDER_GAP + SLIDER_W - 8.0);
let Some(st) = app.session.active() else {
widgets::dim_label(ui, tl!("Open a document to see its swatches."));
return;
@@ -673,4 +786,346 @@ mod tests {
super::super::confirm(&mut app).unwrap();
assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })));
}
/// Inspect actual painted controls, rather than a second implementation of the layout.
#[derive(Default)]
struct Drawn {
texts: Vec<(String, Rect)>,
visible_texts: Vec<String>,
boxes: Vec<Rect>,
fields: Vec<Rect>,
}
impl Drawn {
/// Collects painted text, control bounds and color meshes, recording text visibility
/// against the clip rectangle so tests can detect clipped labels.
fn collect(&mut self, shape: &egui::Shape, clip: Rect) {
match shape {
egui::Shape::Text(t) => {
self.texts.push((t.galley.text().into(), t.visual_bounding_rect()));
if clip.expand(1.0).contains_rect(t.visual_bounding_rect()) {
self.visible_texts.push(t.galley.text().into());
}
}
egui::Shape::Rect(r) => self.boxes.push(r.rect),
egui::Shape::Mesh(m) if m.vertices.len() > 100 => self.fields.push(m.calc_bounds()),
egui::Shape::Vec(v) => v.iter().for_each(|s| self.collect(s, clip)),
_ => {}
}
}
/// Returns the bounds of exactly one painted label, failing on missing or duplicate text.
fn text(&self, label: &str) -> Rect {
let found: Vec<_> = self.texts.iter().filter(|(s, _)| s == label).collect();
assert_eq!(found.len(), 1, "one {label}, including no duplicate footer: {:?}", self.texts);
found[0].1
}
/// Finds the smallest painted control containing a unique label's center.
fn control(&self, label: &str) -> Rect {
let center = self.text(label).center();
self.boxes.iter().filter(|r| r.contains(center)).min_by(|a, b| a.area().total_cmp(&b.area())).copied().unwrap()
}
}
/// Runs a dialog frame with real egui input at a physical window size, accounting for UI zoom,
/// and returns the painted controls for layout and interaction assertions.
fn draw(ctx: &egui::Context, app: &mut VectorcraftApp, size: egui::Vec2, events: Vec<egui::Event>) -> Drawn {
let input =
egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size / ctx.zoom_factor())), events, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
crate::shortcuts::handle(app, ui.ctx());
super::super::show(app, ui.ctx());
});
let mut drawn = Drawn::default();
for shape in &out.shapes {
drawn.collect(&shape.shape, shape.clip_rect);
}
out.textures_delta.clear();
drawn
}
/// The physical window size used for pointer and keyboard interaction tests.
fn screen() -> egui::Vec2 {
vec2(1280.0, 900.0)
}
/// Lets the auto-sized dialog settle over six frames and returns the next frame's controls.
fn settled(ctx: &egui::Context, app: &mut VectorcraftApp, size: egui::Vec2) -> Drawn {
for _ in 0..6 {
draw(ctx, app, size, vec![]);
}
draw(ctx, app, size, vec![])
}
/// Creates a persistent test context with the application's fonts and default theme.
fn context() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
ctx
}
/// Sends a primary pointer press and release in separate frames at the given position.
fn click_at(ctx: &egui::Context, app: &mut VectorcraftApp, at: egui::Pos2) {
for pressed in [true, false] {
draw(
ctx,
app,
screen(),
vec![
egui::Event::PointerMoved(at),
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() },
],
);
}
}
/// Creates an unmodified, non-repeating key press for dialog shortcut tests.
fn key(key: egui::Key) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
}
/// Checks accessible action names and each channel slider's displayed value, range and units.
#[test]
fn compact_picker_preserves_screen_reader_controls_and_channel_values() {
use egui::accesskit::Role;
for (channel, value, maximum, text) in [
(Channel::Hue, 210.0, 360.0, "210°"),
(Channel::Saturation, 67.0, 100.0, "67%"),
(Channel::Brightness, 60.0, 100.0, "60%"),
(Channel::Red, 51.0, 255.0, "51"),
(Channel::Green, 102.0, 255.0, "102"),
(Channel::Blue, 153.0, 255.0, "153"),
] {
let mut app = app();
app.services.screen_color = Some(Box::new(|| Ok(std::sync::mpsc::channel().1)));
open(&mut app, &json!({"color": "#336699"})).unwrap();
let mut d = app.ui.dialog.take().unwrap();
d.fields.insert("channel".into(), json!(channel.id()));
let tree = widgets::accessibility_tests::tree(|ui| {
body(&mut app, ui, &mut d);
});
let slider = tree.iter().find(|n| n.role() == Role::Slider && n.label() == Some(channel.label())).unwrap();
assert_eq!(slider.numeric_value(), Some(value));
assert_eq!(slider.min_numeric_value(), Some(0.0));
assert_eq!(slider.max_numeric_value(), Some(maximum));
assert_eq!(slider.value(), Some(text));
for label in ["OK", "Cancel", "Color Swatches", "Sample Color from Screen"] {
assert!(tree.iter().any(|n| n.role() == Role::Button && n.label() == Some(label)), "missing button: {label}");
}
}
}
/// Checks aligned actions and input rows at two UI scales, and stable dimensions when
/// switching between color models and swatches.
#[test]
fn inline_actions_are_aligned_and_modes_keep_the_window_size() {
// Language selection is process-global; German is checked in the native app instead
// of racing the other parallel UI tests by changing the locale here.
for zoom in [1.0, 1.5] {
let (ctx, mut app) = (context(), app());
ctx.set_zoom_factor(zoom);
app.run("ui.colorPicker", json!({})).unwrap();
let drawn = settled(&ctx, &mut app, screen());
let ok = drawn.control(tl!("OK"));
let cancel = drawn.control(tl!("Cancel"));
let toggle = drawn.control(tl!("Color Swatches"));
assert!(ok.bottom() < cancel.top() && cancel.bottom() < toggle.top());
for r in [cancel, toggle] {
assert!((r.left() - ok.left()).abs() < 1.0 && (r.width() - ok.width()).abs() < 1.0);
}
let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap();
assert!((field.width() - field.height()).abs() < 1.0);
assert!((field.top() - ok.top()).abs() < 1.0);
let k = drawn.text("K");
let last_input = drawn
.boxes
.iter()
.find(|r| (r.width() - VALUE_W).abs() < 0.5 && r.left() > k.right() && r.contains(egui::pos2(r.center().x, k.center().y)))
.unwrap();
assert!((last_input.bottom() - field.bottom()).abs() < 2.0, "{last_input:?} vs {field:?}");
// Fonts and translated actions may leave one normal gutter, but never an unused column.
assert!((last_input.right() - ok.right()).abs() < COLUMN_GAP, "no unused column beside the inputs: {last_input:?} vs {ok:?}");
let id = egui::Id::new(("dialog", KIND));
let before = ctx.memory(|m| m.area_rect(id)).unwrap();
assert!(drawn.boxes.iter().all(|r| r.left() >= before.left() - 1.0 && r.right() <= before.right() + 1.0), "controls fit horizontally");
let title = drawn.text(tl!("Color Picker"));
assert!((title.center().x - before.center().x).abs() < 1.0, "centred heading");
assert!(title.bottom() < field.top(), "a separate title band above the content");
assert!(drawn.visible_texts.iter().any(|s| s == tl!("Only Web Colors")), "the checkbox label is fully visible");
assert!(drawn.text("L").top() - toggle.bottom() >= PREVIEW_GAP, "space below the actions");
click_at(&ctx, &mut app, toggle.center());
assert!(app.ui.dialog.as_ref().unwrap().bool("swatches"));
let drawn = settled(&ctx, &mut app, screen());
assert!((drawn.control(tl!("Color Models")).width() - ok.width()).abs() < 1.0);
let after = ctx.memory(|m| m.area_rect(id)).unwrap();
assert!((before.size() - after.size()).length() < 1.0, "{before:?} vs {after:?}");
assert!(
drawn.boxes.iter().all(|r| r.left() >= after.left() - 1.0 && r.right() <= after.right() + 1.0),
"swatch controls fit horizontally"
);
click_at(&ctx, &mut app, drawn.text(tl!("Color Models")).center());
assert!(!app.ui.dialog.as_ref().unwrap().bool("swatches"));
}
}
/// Clicks the sampler above the field, receives a color and checks Cancel restores the paint.
#[test]
fn sampler_above_the_field_previews_and_cancel_restores_the_original() {
let (ctx, mut app) = (context(), app());
let (tx, rx) = std::sync::mpsc::channel();
let mut receiver = Some(rx);
app.services.screen_color = Some(Box::new(move || receiver.take().ok_or("sampler already started".into())));
app.run("ui.colorPicker", json!({})).unwrap();
let original = fill_hex(&app);
let drawn = settled(&ctx, &mut app, screen());
let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap();
let hover = egui::pos2(field.right() - 12.0, field.top() - 20.0);
draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(hover)]);
let drawn = settled(&ctx, &mut app, screen());
let sampler = drawn.boxes.iter().find(|r| r.size() == vec2(24.0, 24.0) && r.bottom() < field.top()).unwrap();
assert!(sampler.top() > drawn.text(tl!("Color Picker")).bottom() && sampler.right() <= field.right());
click_at(&ctx, &mut app, sampler.center());
assert!(app.screen_sampler.is_some(), "the button above the field starts the host sampler");
tx.send(Ok(Some([0.0, 1.0, 0.0]))).unwrap();
crate::screen_color::poll(&mut app, &ctx);
let drawn = settled(&ctx, &mut app, screen());
assert_eq!(fill_hex(&app), "#00ff00");
click_at(&ctx, &mut app, drawn.text(tl!("Cancel")).center());
assert!(app.ui.dialog.is_none());
assert_eq!(fill_hex(&app), original);
}
/// Checks both correction warnings fit below the preview without widening the dialog,
/// and that clicking the web warning still corrects the color.
#[test]
fn warnings_fit_below_the_preview_without_widening_the_dialog() {
let (ctx, mut app) = (context(), app());
app.run("ui.colorPicker", json!({"color":"#ffffff"})).unwrap();
settled(&ctx, &mut app, screen());
let id = egui::Id::new(("dialog", KIND));
let before = ctx.memory(|m| m.area_rect(id)).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000FE"));
let drawn = settled(&ctx, &mut app, screen());
let ok = drawn.control(tl!("OK"));
let toggle = drawn.control(tl!("Color Swatches"));
let h = drawn.text("H");
let mut warnings: Vec<_> = drawn
.boxes
.iter()
.filter(|r| (r.width() - 16.0).abs() < 0.5 && (r.height() - 16.0).abs() < 0.5 && r.bottom() < h.top())
.copied()
.collect();
warnings.dedup(); // The tile's fill and border share one rectangle.
assert_eq!(warnings.len(), 2, "gamut and web correction chips are both present");
for r in &warnings {
assert!(r.right() < ok.left() && r.bottom() <= toggle.bottom());
assert!(r.top() > ok.top() + CHIP_H, "warnings below the preview");
}
let after = ctx.memory(|m| m.area_rect(id)).unwrap();
assert!((before.size() - after.size()).length() < 1.0, "{before:?} vs {after:?}");
click_at(&ctx, &mut app, warnings[1].center());
assert!(is_web_safe(&picked(app.ui.dialog.as_ref().unwrap()).color), "web correction remains clickable");
}
/// Drags the custom title band and checks the dialog moves by the pointer's displacement.
#[test]
fn colour_picker_moves_by_its_title_band() {
let (ctx, mut app) = (context(), app());
app.run("ui.colorPicker", json!({})).unwrap();
let drawn = settled(&ctx, &mut app, screen());
let title = drawn.text(tl!("Color Picker")).center();
let id = egui::Id::new(("dialog", KIND));
let before = ctx.memory(|m| m.area_rect(id)).unwrap();
let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(title), pointer(title, true)]);
for i in 1..=4 {
draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(title + vec2(6.0, 4.0) * i as f32)]);
}
draw(&ctx, &mut app, screen(), vec![pointer(title + vec2(24.0, 16.0), false)]);
settled(&ctx, &mut app, screen());
let after = ctx.memory(|m| m.area_rect(id)).unwrap();
assert!((after.center() - before.center() - vec2(24.0, 16.0)).length() < 1.5);
assert!(app.ui.dialog.is_some());
}
/// Checks OK and Enter commit typed values as one undo step, while Cancel and Escape
/// restore the original paint after a live preview.
#[test]
fn inline_ok_commits_typed_input_and_cancel_restores_the_preview() {
for enter in [false, true] {
let (ctx, mut app) = (context(), app());
app.run("ui.colorPicker", json!({"color": "#ff0000"})).unwrap();
let drawn = settled(&ctx, &mut app, screen());
let undo = app.session.doc().unwrap().history.undo.len();
// The first numeric field has selected text, just as in the other form dialogs.
draw(&ctx, &mut app, screen(), vec![egui::Event::Text("120".into())]);
if enter {
draw(&ctx, &mut app, screen(), vec![key(egui::Key::Enter)]);
} else {
click_at(&ctx, &mut app, drawn.text(tl!("OK")).center());
}
assert!(app.ui.dialog.is_none());
assert_eq!(fill_hex(&app), "#00ff00");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
app.run("ui.colorPicker", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff"));
let drawn = settled(&ctx, &mut app, screen());
assert_eq!(fill_hex(&app), "#0000ff");
if enter {
draw(&ctx, &mut app, screen(), vec![key(egui::Key::Escape)]);
} else {
click_at(&ctx, &mut app, drawn.text(tl!("Cancel")).center());
}
assert!(app.ui.dialog.is_none());
assert_eq!(fill_hex(&app), "#00ff00");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
}
}
/// Checks shrinking and stacking keep the dialog inside small windows, vertical scrolling
/// reaches the actions and last input, and Enter can still confirm.
#[test]
fn compact_picker_shrinks_then_scrolls_without_enlarging_the_window() {
for size in [vec2(740.0, 600.0), vec2(540.0, 420.0), vec2(400.0, 280.0)] {
let (ctx, mut app) = (context(), app());
app.run("ui.colorPicker", json!({"color": "#0000ff"})).unwrap();
let drawn = settled(&ctx, &mut app, size);
let outer = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", KIND)))).unwrap();
assert!(outer.width() <= size.x && outer.height() <= size.y, "{outer:?} in {size:?}");
let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap();
assert!((MIN_FIELD..=FIELD).contains(&field.width()));
assert!((field.width() - field.height()).abs() < 1.0);
assert!(drawn.boxes.iter().all(|r| r.left() >= outer.left() - 1.0 && r.right() <= outer.right() + 1.0), "controls fit horizontally");
if size.x < 500.0 {
assert!(drawn.control(tl!("OK")).top() > field.bottom(), "narrow windows stack the controls below the field");
let mut actions_visible = false;
let mut last_input_visible = false;
for _ in 0..30 {
draw(
&ctx,
&mut app,
size,
vec![
egui::Event::PointerMoved(outer.min + vec2(30.0, 80.0)),
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -20.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
},
],
);
let scrolled = settled(&ctx, &mut app, size);
actions_visible |= scrolled.visible_texts.iter().any(|s| s == tl!("OK"));
last_input_visible |= scrolled.visible_texts.iter().any(|s| s == "K");
}
assert!(actions_visible && last_input_visible, "vertical scrolling reveals actions and the last input");
}
// Keyboard confirmation remains available even when the actions require scrolling.
draw(&ctx, &mut app, size, vec![key(egui::Key::Enter)]);
assert!(app.ui.dialog.is_none());
}
}
}
+34 -2
View File
@@ -135,6 +135,15 @@ type DialogResult = Result<Value, String>;
const MARGIN: i8 = 22;
const EDGE_GAP: f32 = 8.0;
/// An action requested by a dialog that draws its buttons inside its body.
#[derive(Default)]
pub(crate) enum DialogAction {
#[default]
None,
Confirm,
Cancel,
}
/// How a dialog draws and applies itself. Specs start from [`DialogSpec::FORM`] and override what
/// differs.
pub(crate) struct DialogSpec {
@@ -143,8 +152,12 @@ pub(crate) struct DialogSpec {
/// The heading (and window title), in the UI language: the spec translates its own text and
/// leaves names in it (a document's, a plug-in's) as they are.
pub heading: fn(&Dialog) -> String,
/// Optional heading band inside the shared window; normal dialogs use `modal::heading`.
pub header: Option<fn(&mut egui::Ui, &str)>,
/// Draws the fields. Returns true to close the dialog as Cancel would.
pub body: fn(&mut VectorcraftApp, &mut egui::Ui, &mut Dialog) -> bool,
/// Optional body with inline buttons; replaces `body` and the shared footer.
pub body_with_actions: Option<fn(&mut VectorcraftApp, &mut egui::Ui, &mut Dialog) -> DialogAction>,
/// OK, Enter or `ui.dialog.confirm`: applies the dialog and closes it when done.
pub confirm: fn(&mut VectorcraftApp, &Dialog) -> DialogResult,
/// The OK button's label. None: no OK button, Enter does nothing and Cancel reads "Close".
@@ -167,10 +180,12 @@ impl DialogSpec {
pub const FORM: Self = Self {
window: None,
heading: |_| tl!("Dialog").into(),
header: None,
body: |app, ui, d| {
form::grid(ui, d, app.session.general_unit());
false
},
body_with_actions: None,
confirm: |app, _| {
app.ui.dialog = None;
Ok(Value::Null)
@@ -404,6 +419,8 @@ pub(crate) fn settle(app: &mut VectorcraftApp) {
/// until Enter has been let go.
pub(crate) const AFTER_PANEL: &str = "__afterPanel";
/// Draws the active dialog and handles shared initial focus, keyboard shortcuts and actions.
/// Inline actions and footer buttons use the same confirmation and preview rollback paths.
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
about::show(app, ctx);
settle(app);
@@ -463,18 +480,33 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP);
ui.set_min_width(spec.min_width.min(room));
ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room)));
modal::heading(ui, &heading);
if let Some(header) = spec.header {
header(ui, &heading);
} else {
modal::heading(ui, &heading);
}
ui.add_space(12.0);
// Just opened: its first enabled field takes the keyboard focus, as in the reference app
// (type a value, press Enter). Only the body's fields can take it.
if focus_first {
ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true));
}
cancel = (spec.body)(app, ui, &mut d);
if let Some(body) = spec.body_with_actions {
match body(app, ui, &mut d) {
DialogAction::None => {}
DialogAction::Confirm => ok = true,
DialogAction::Cancel => cancel = true,
}
} else {
cancel = (spec.body)(app, ui, &mut d);
}
// Taken (the flag is gone): done. Still there (the window's measuring frame): next frame.
if focus_first && ui.data_mut(|m| m.remove_temp::<bool>(widgets::dialog_focus_flag())).is_none() {
ui.data_mut(|m| m.remove::<bool>(focus_id));
}
if spec.body_with_actions.is_some() {
return;
}
ui.add_space(16.0);
// The button row is as wide as the fields above it and as tall as the buttons: a
// right-to-left layout would otherwise take all the room left in the window, so the
+18 -2
View File
@@ -101,11 +101,22 @@ pub fn primary_button(ui: &mut Ui, text: &str) -> Response {
/// [`primary_button`], greyed out and not clickable unless `enabled`.
pub fn primary_button_enabled(ui: &mut Ui, text: &str, enabled: bool) -> Response {
primary_button_sized(ui, text, enabled, 72.0)
}
/// Primary dialog button with a minimum width, for aligned action columns.
pub(crate) fn primary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> Response {
primary_button_sized(ui, text, true, min_width)
}
/// Draws a primary button at least `min_width` wide, growing to fit the translated label.
/// Returns its response with the enabled state and screen-reader name preserved.
fn primary_button_sized(ui: &mut Ui, text: &str, enabled: bool, min_width: f32) -> Response {
let t = Tokens::get(ui.ctx());
let fg = if enabled { Color32::WHITE } else { t.text_disabled };
let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), fg);
let w = galley.size().x + 32.0;
let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), if enabled { Sense::click() } else { Sense::hover() });
let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), if enabled { Sense::click() } else { Sense::hover() });
let bg = match (enabled, resp.hovered()) {
(false, _) => t.input,
(true, true) => t.accent,
@@ -122,10 +133,15 @@ pub fn primary_button_enabled(ui: &mut Ui, text: &str, enabled: bool) -> Respons
/// The quiet secondary button (dialog Cancel).
pub fn secondary_button(ui: &mut Ui, text: &str) -> Response {
secondary_button_width(ui, text, 72.0)
}
/// Secondary dialog button with a minimum width, for aligned action columns.
pub(crate) fn secondary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> Response {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), t.text);
let w = galley.size().x + 32.0;
let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), Sense::click());
let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), Sense::click());
ui.painter().rect_filled(rect, CornerRadius::same(6), if resp.hovered() { t.hover } else { t.button });
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text)));
+4 -1
View File
@@ -80,7 +80,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Missing:** Average and Join in one step, with a corner or smooth choice for the joined anchor. `path.average` and `path.join` are separate commands and Join has no such choice (coinciding anchors merge into one since #1533). [ui-parity.md](ui-parity.md) listed "Join with corner/smooth choice" as done until 2026-10-13. About 1–2 h (estimated), inside the estimate below.
- **Implemented (#1760):** Effect › Warp has a shared style selector with original previews, orientation buttons and percent sliders. Existing effects retain their stack slot and target. Focused checks passed and original captures were reviewed; optional Properties integration and remembered defaults remain outside this change.
- **Done (#2120):** Smart Guides line extension: dragging the end of a straight segment with Direct Selection or Curvature, the point snaps to the segment's own direction at any angle and the guide runs along it from the other end. Inside the estimate below.
- **Missing (noted for reporters, 2026-10-14):** Tab in the Color Picker moves through its fields in order (#1692); aligning anchor points selected with
- **Done (#1692):** Tab in the Color Picker moves through one color model's fields before the next. Completed upstream before the layout change (#2008).
- **Improved (#2008):** Color Picker layout: stacked top-right actions, aligned inputs, less empty space, a distinct header and compact color controls. Narrow windows use stacked sections and vertical scrolling. Pointer/key, warning, layout and mode-switch regressions cover the change; the broader fidelity pass and estimates remain unchanged.
- **Missing (noted for reporters, 2026-10-14):** aligning anchor points selected with
Direct Selection to a key anchor (from #1636: they align to their bounds; the current public Align page does
not say how the key anchor is chosen, so this waits for the owner). Inside the estimate below.
- **Missing:** a tool-by-tool, panel-by-panel pass over modifiers, cursors, the Properties panel per context,
@@ -357,6 +359,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
| 2026-10-19 | minor | Status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates. Contributor original-file acceptance confirmed; scores and estimates unchanged. |
| 2026-10-19 | minor | Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Regression coverage; scores and estimates unchanged. |
| 2026-10-19 | minor | Cached canvas reprojection preserves scale during resize, rotation and DPI changes. Contributor original-file acceptance confirmed; scores and estimates unchanged. |
| 2026-10-19 | minor | Color Picker layout (#2008): compact controls, centered header, stacked actions and aligned inputs; screen-reader channel values, current shared button names and styling, screen sampler and model-by-model Tab order retained. Narrow windows scroll vertically. Documentation correction: #1692 was completed upstream before #2008; the Direct Selection alignment gap remains open. Scores and hours unchanged. |
| 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. |
| 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them; a Top-handle regression confirms scaling of the default repeat. Center and end handles retain their move and rotation behavior. G1 scope and estimate unchanged. |
| 2026-10-17 | minor | Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged. Windows native canvas checks and screenshots reviewed in PR #2643. |
+2
View File
@@ -70,6 +70,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 |
| M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above |
| | **Total to full parity** (one agent; from [target-app-parity.md](target-app-parity.md)) | | **~360–590** |
Milestone estimates predate the 2026-10-10 re-measure for M1–M14; the area table in
@@ -92,6 +93,7 @@ M3 follow-up (#1968): native saves retain the per-document transparency grid set
| 2026-10-18 | minor | M4/DXF #1299: circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes retain POLYLINE width and closed partial arcs with an implicit closing chord are not promoted to CIRCLE. Scores and hours unchanged. |
| 2026-10-19 | minor | Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Regression coverage; scores and estimates unchanged. |
| 2026-10-19 | minor | Cached canvas reprojection preserves scale during resize, rotation and DPI changes. Contributor original-file acceptance confirmed; scores and estimates unchanged. |
| 2026-10-19 | minor | Color Picker layout (#2008): compact controls, centered header, stacked actions and aligned inputs; screen-reader channel values, current shared button names and styling, screen sampler and model-by-model Tab order retained. Narrow windows scroll vertically; scores and hours unchanged. |
| 2026-10-18 | minor | M4: PDF Place and clipboard paste preserve live text by default; explicit outlines on Place remain available. Synthetic regressions; estimates unchanged. |
| 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. |
| 2026-10-18 | minor | Selected Mirror Repeat bounding-box handles take priority over the axis line; a Top-handle regression confirms the default repeat scales vertically, while center and end handles retain their move and rotation behavior. Headline scores and estimates unchanged. |
+2
View File
@@ -669,6 +669,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value.
- **Color Picker layout (#2008):** a centered draggable header, equal-width actions above aligned HSB/RGB/Hex and Lab/CMYK fields, compact field and slider, and warnings below the preview. No horizontal scrolling; narrow windows stack the sections with vertical scrolling. Tests in `dialogs/color_picker.rs` cover actual pointer/key input, typed values before OK/Enter, Cancel/Escape rollback, one Undo step, mode-size stability, warnings and layout at two UI scales. The screen sampler sits above the field and the upstream two-grid Tab order is retained. AccessKit regressions check the action names and each channel slider's displayed value, units and range. German and enlarged English UI renders were reviewed locally; no reference images are shipped. This is a layout improvement, with no change to scores or remaining-hour estimates.
- **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement.
- **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C59 M12 Y3 K0, where the profile-free formula gave C63 M24 Y0 K13.
- **RGB input to other commands in a CMYK document:** type colors (`text.create`, `text.setStyle`, `text.setRangeStyle`), a Shape Builder hex fill, `paint.editGradient` stops, freeform gradient points, gradient mesh points (the Mesh tool gives a new point the fill's color in its own model), the Symbol Stainer's color and the swatch commands (`swatch.new {colors}`, `swatch.newGroup`, `swatch.editGroup`, `swatch.edit`) separate RGB through the color settings as `paint.setFill` does: with Generic CMYK, `text.setStyle {fill: "#53aadf"}` gives C59 M12 Y3 K0. A color a swatch already has keeps its model, and a swatch color given with `mode` is converted into that mode only.
@@ -845,6 +846,7 @@ Evidence for the shared light Color: geometry tests in `tests_lighting`, effect
| 2026-10-19 | minor | Cached canvas reprojection preserves scale during resize, rotation and DPI changes. Contributor original-file acceptance confirmed; scores and estimates unchanged. |
| 2026-10-18 | minor | PDF Place and clipboard paste use Open's live-text choice instead of forcing outlines; synthetic regressions cover page selection and explicit outlines. Scores and hours unchanged. |
| 2026-10-19 | minor | Effect rasters cropped to the view or made coarser are kept while the view stays the same: a drag over a 500 pt blur draws it from the still frame on this thread and on the worker, and an interaction stays on the worker that drew the frame shown (#2558). Scores and hours unchanged. |
| 2026-10-19 | minor | Color Picker layout (#2008): compact controls, centered header, stacked actions and aligned inputs; screen-reader channel values, current shared button names and styling, screen sampler and model-by-model Tab order retained. Narrow windows scroll vertically; scores and hours unchanged. |
| 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. |
| 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them: the Top handle scales the repeat, while the center still moves it and the ends still rotate it. Headless canvas regression confirms vertical scaling without width change; scores and hours unchanged. |
| 2026-10-17 | minor | Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged. Windows native canvas checks and screenshots reviewed in PR #2643. |
+3
View File
@@ -249,6 +249,8 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, #
- [x] Native saves restore Show Transparency Grid (#1968), without dirtying the document or adding an undo step.
- [x] Compact Color Picker with a distinct header, stacked actions and aligned input groups. #2008; translated action widths, visible warnings, stable mode switching and vertical scrolling in narrow windows; real egui events and two UI scales covered; German and enlarged English renders reviewed locally. Scores unchanged
## Revision history
| Date | Change | Summary |
@@ -257,6 +259,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, #
| 2026-10-19 | minor | Status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates. Contributor original-file acceptance confirmed; scores and estimates unchanged. |
| 2026-10-19 | minor | Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Regression coverage; scores and estimates unchanged. |
| 2026-10-19 | minor | Cached canvas reprojection preserves scale during resize, rotation and DPI changes. Contributor original-file acceptance confirmed; scores and estimates unchanged. |
| 2026-10-19 | minor | Color Picker layout (#2008): compact controls, centered header, stacked actions and aligned inputs; screen-reader channel values, current shared button names and styling, screen sampler and model-by-model Tab order retained. Narrow windows scroll vertically; scores and hours unchanged. |
| 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. |
| 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them: the Top handle scales the repeat, while the center still moves it and the ends still rotate it. A headless canvas regression confirms height changes while width stays fixed; scores and hours unchanged. |
| 2026-10-17 | minor | Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged. Windows native canvas checks and screenshots reviewed in PR #2643. |