From 67c90e307f1dbc4d918423e4a7a8932f73afe0eb Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 10:12:59 +1100 Subject: [PATCH] Quantize the colour field when Only Web Colors is on. (#4138) * Quantize the colour field when Only Web Colors is on. The picked colour already snapped to the web-safe ramp, but the field and slider stayed continuous, so the restriction was invisible. Fixes #4107 * Only Web Colors: draw the field and slider in flat web-safe bands The coarse interpolated mesh blended between snapped corners, so it showed a smooth low-resolution gradient rather than bands. Draw flat cells sampled at their centres instead, and test that every cell is a single web-safe colour. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/color_picker_ui.rs | 56 ++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index e9729eab6..30752c462 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -400,6 +400,36 @@ fn grid_mesh(rect: Rect, n: usize, color: impl Fn(f32, f32) -> [f32; 3]) -> Mesh m } +/// Cells per side of the banded Only Web Colors field: fine enough that the band edges follow the +/// colour model, coarse enough to stay cheap to draw each frame. +const WEB_CELLS: usize = 64; + +/// Snap each channel to the web-safe ramp (00, 33, 66, 99, CC, FF). +fn web_safe(c: [f32; 3]) -> [f32; 3] { + c.map(|v| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0) +} + +/// Like [`grid_mesh`], but every cell is one flat colour sampled at its centre, so a quantized +/// field shows hard bands instead of blending between the snapped corners. +fn flat_grid_mesh(rect: Rect, n: usize, color: impl Fn(f32, f32) -> [f32; 3]) -> Mesh { + let mut m = Mesh::default(); + let n = n.max(1); + let (cw, ch) = (rect.width() / n as f32, rect.height() / n as f32); + for j in 0..n { + for i in 0..n { + let c = c32(color((i as f32 + 0.5) / n as f32, (j as f32 + 0.5) / n as f32)); + let (x0, y0) = (rect.left() + i as f32 * cw, rect.top() + j as f32 * ch); + let a = m.vertices.len() as u32; + for (dx, dy) in [(0.0, 0.0), (cw, 0.0), (cw, ch), (0.0, ch)] { + m.colored_vertex(pos2(x0 + dx, y0 + dy), c); + } + m.add_triangle(a, a + 1, a + 2); + m.add_triangle(a, a + 2, a + 3); + } + } + m +} + /// Room for both component columns, including units, without overflowing the title's width; with /// the frame and title the window fits Photoshop's (about 534 x 376 points). pub(crate) const CONTENT_WIDTH: f32 = 488.0; @@ -469,7 +499,12 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { let (fx, fy, fz) = locate(&mode, hsv, rgb, lab); // Colour field. let (field, resp) = ui.allocate_exact_size(vec2(FIELD, FIELD), Sense::click_and_drag()); - ui.painter().add(grid_mesh(field, 32, |x, y| field_color(&mode, fz, x, y))); + let web_only = f.get("__webOnly").and_then(Value::as_bool).unwrap_or(false); + // Only Web Colors bands the field to the 216 web-safe colours, as in Photoshop (#4107). + let mesh = |rect: Rect, color: &dyn Fn(f32, f32) -> [f32; 3]| { + if web_only { flat_grid_mesh(rect, WEB_CELLS, |x, y| web_safe(color(x, y))) } else { grid_mesh(rect, 32, color) } + }; + ui.painter().add(mesh(field, &|x, y| field_color(&mode, fz, x, y))); ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); let marker = pos2(field.left() + fx * field.width(), field.top() + fy * field.height()); ui.painter().circle_stroke(marker, 5.0, Stroke::new(1.5, if hsv[2] > 0.6 && hsv[1] < 0.4 { Color32::BLACK } else { Color32::WHITE })); @@ -486,7 +521,7 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { } // Component slider (hue runs 360° at the top to 0° at the bottom, like Photoshop). let (strip, sresp) = ui.allocate_exact_size(vec2(18.0, FIELD), Sense::click_and_drag()); - ui.painter().add(grid_mesh(strip, 32, |_, y| slider_color(&mode, 1.0 - y, hsv, rgb, lab))); + ui.painter().add(mesh(strip, &|_, y| slider_color(&mode, 1.0 - y, hsv, rgb, lab))); let sy = strip.top() + (1.0 - fz) * strip.height(); for (x, dir) in [(strip.left() - 1.0, 1.0f32), (strip.right() + 1.0, -1.0)] { let tri = vec![pos2(x, sy), pos2(x - 6.0 * dir, sy - 4.0), pos2(x - 6.0 * dir, sy + 4.0)]; @@ -767,6 +802,23 @@ mod tests { use super::*; + /// #4107: with Only Web Colors on, the field is drawn in flat web-safe bands. + #[test] + fn web_only_field_is_flat_web_safe_bands() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 200.0)); + let m = flat_grid_mesh(rect, WEB_CELLS, |x, y| web_safe(field_color("h", 0.3, x, y))); + let ramp = [0u8, 51, 102, 153, 204, 255]; + for v in &m.vertices { + assert!([v.color.r(), v.color.g(), v.color.b()].iter().all(|c| ramp.contains(c)), "{:?}", v.color); + } + // Each cell's four corners share one colour (no blending between bands). + for q in m.vertices.chunks(4) { + assert!(q.iter().all(|v| v.color == q[0].color)); + } + let distinct: std::collections::HashSet<_> = m.vertices.iter().map(|v| v.color).collect(); + assert!(distinct.len() > 1 && distinct.len() <= 216); + } + fn picker(color: &str) -> Harness<'static, Map> { let mut f = Map::new(); f.insert("__colorPicker".into(), json!("foreground"));