git.lucas.co / cce-color-editor
color picker and palette editor
git clone https://git.lucas.co/cce-color-editor.git

src/main.rs (39K)

   1 use cce_ui::widget::Handle;
   2 use cce_ui::engine::{Application, WindowSettings, LogicalSize, LogicalPosition};
   3 use cce_ui::widget::{
   4     Adapted, Button, WidgetHost, EventCtx, UiContext, MouseButton, ElementState, KeyEvent,
   5     MouseScrollDelta, Event,
   6 };
   7 use cce_ui::layout::RenderTarget;
   8 use cce_ui::scene::layout::{Rect, Size};
   9 use cce_ui::scene::paint::PaintCtx;
  10 use std::io::IsTerminal;
  11 
  12 
  13 // Sizes only. Every inset and gap comes from the cce-ui spacing ladder
  14 // (`root_plate_inset` at the window edge, `root_plate_gap` between the
  15 // siblings standing on the root plate: the slider rows, the preview, the
  16 // buttons) — see `rebuild_layout` and `window_height`.
  17 const SLIDER_ROW_H: f32 = 36.0;
  18 /// The label column: the channel letter's slot before the track begins.
  19 const SLIDER_LABEL_W: f32 = 20.0;
  20 /// The readout column: the right-aligned value's slot after the track ends.
  21 const SLIDER_VALUE_W: f32 = 56.0;
  22 const SLIDER_TRACK_H: f32 = 20.0;
  23 const PREVIEW_W: f32 = 160.0;
  24 const PREVIEW_H: f32 = 72.0;
  25 const BUTTON_W: f32 = 100.0;
  26 
  27 /// The window's height for a given layout: the slider rows, the preview and
  28 /// (when the picker reports a result) the button row, each a root-plate
  29 /// sibling one `root_plate_gap` apart, the whole inset from the window edge
  30 /// by `root_plate_inset` top and bottom.
  31 fn window_height(with_alpha: bool, expecting_output: bool) -> u32 {
  32     let inset = cce_ui::layout::root_plate_inset();
  33     let gap = cce_ui::layout::root_plate_gap();
  34     let rows = if with_alpha { 7.0 } else { 6.0 };
  35     let mut bottom = inset + rows * SLIDER_ROW_H + gap + PREVIEW_H;
  36     if expecting_output {
  37         bottom += gap + cce_ui::layout::button_height();
  38     }
  39     (bottom + inset).ceil() as u32
  40 }
  41 
  42 // ── PageContent for custom Target Rendering ────────────────────────
  43 
  44 pub struct PageContent {
  45     pub rects: Vec<([f32; 4], f32, f32, f32, f32, f32, (bool, bool, bool, bool))>,
  46     pub texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
  47 }
  48 
  49 impl PageContent {
  50     pub fn new() -> Self {
  51         Self {
  52             rects: Vec::new(),
  53             texts: Vec::new(),
  54         }
  55     }
  56 }
  57 
  58 impl cce_ui::layout::RenderTarget for PageContent {
  59     fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
  60         self.rects.push((color, x, y, w, h, 0.0, (true, true, true, true)));
  61     }
  62 
  63     fn rect_with_radius(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32) {
  64         self.rects.push((color, x, y, w, h, radius, (true, true, true, true)));
  65     }
  66 
  67     fn rect_with_radius_corners(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, corners: (bool, bool, bool, bool)) {
  68         self.rects.push((color, x, y, w, h, radius, corners));
  69     }
  70 
  71     fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
  72         self.texts.push((content.to_string(), size, x, y, color, None, None));
  73     }
  74 
  75     fn text_with_font(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str) {
  76         self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), None));
  77     }
  78 
  79     fn text_with_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], bounds: Option<[f32; 4]>) {
  80         self.texts.push((content.to_string(), size, x, y, color, None, bounds));
  81     }
  82 
  83     fn text_with_font_and_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str, bounds: Option<[f32; 4]>) {
  84         self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), bounds));
  85     }
  86 }
  87 
  88 // ── Custom ColorSlider Widget (narrow traits, wrapped in Adapted) ────
  89 
  90 #[derive(Debug, Clone)]
  91 struct ColorSlider {
  92     value: f32,
  93     channel_index: usize,
  94     dragging: bool,
  95     label: String,
  96     just_changed: bool,
  97 
  98     r: f32, g: f32, b: f32,
  99     h: f32, s: f32, l: f32,
 100     a: f32,
 101 }
 102 
 103 impl ColorSlider {
 104     pub fn new(label: &str, channel_index: usize) -> Adapted<ColorSlider> {
 105         Adapted::new(Self {
 106             value: 0.5,
 107             channel_index,
 108             dragging: false,
 109             label: label.to_string(),
 110             just_changed: false,
 111             r: 0.5, g: 0.5, b: 0.5,
 112             h: 0.0, s: 0.0, l: 0.5,
 113             a: 1.0,
 114         })
 115     }
 116 }
 117 
 118 /// The track's geometry within the slider's laid-out row rect (shared by paint and events).
 119 /// The row is already inset from the window edge; the track sits between the
 120 /// label column and the readout column.
 121 fn track_rect(rect: Rect) -> (f32, f32, f32, f32) {
 122     let track_x = rect.x + SLIDER_LABEL_W;
 123     let track_w = rect.width - SLIDER_LABEL_W - SLIDER_VALUE_W;
 124     let track_h = SLIDER_TRACK_H;
 125     let track_y = rect.y + (rect.height - track_h) / 2.0;
 126     (track_x, track_y, track_w, track_h)
 127 }
 128 
 129 impl ColorSlider {
 130     /// The channel's gradient color at normalized track position `t`.
 131     fn color_at(&self, t: f32) -> [f32; 4] {
 132         match self.channel_index {
 133             0 => [t, self.g, self.b, self.a],
 134             1 => [self.r, t, self.b, self.a],
 135             2 => [self.r, self.g, t, self.a],
 136             3 => {
 137                 let (r, g, b) = hsl_to_rgb(t, self.s, self.l);
 138                 [r, g, b, self.a]
 139             }
 140             4 => {
 141                 let (r, g, b) = hsl_to_rgb(self.h, t, self.l);
 142                 [r, g, b, self.a]
 143             }
 144             5 => {
 145                 let (r, g, b) = hsl_to_rgb(self.h, self.s, t);
 146                 [r, g, b, self.a]
 147             }
 148             _ => [self.r, self.g, self.b, t],
 149         }
 150     }
 151 
 152     fn paint_band(&self, rect: Rect, track_x: f32, track_w: f32, ctx: &mut PaintCtx) {
 153         let band_t = cce_ui::layout::slider_band_thickness().max(0.5);
 154         let bulge_h = cce_ui::layout::slider_bulge_height().clamp(band_t, rect.height);
 155         let bulge_w = cce_ui::layout::slider_bulge_width().max(2.0);
 156         let cy = rect.y + rect.height * 0.5;
 157         let vx = track_x + self.value * track_w;
 158 
 159         let height_at = |x: f32| -> f32 {
 160             let t = ((x - vx) / bulge_w).clamp(-1.0, 1.0);
 161             let bell = 0.5 * (1.0 + (std::f32::consts::PI * t).cos());
 162             band_t + (bulge_h - band_t) * bell.powf(1.35)
 163         };
 164 
 165         // The well: shadow hugging the top contour, lit lip along the bottom
 166         // (DE light sits upper-left), stepped alphas riding bevel_depth.
 167         // 1px columns with EXACT widths — translucent quads must not overlap.
 168         // style: deliberate — the well's clearance around the band is carve
 169         // geometry (how far the wall stands off the band's contour), not a gap
 170         // between siblings; it has to hug the track, not follow the ladder.
 171         const WELL_CLEARANCE: f32 = 4.0;
 172         const WELL_WALL: f32 = 3.0;
 173         const WALL_STEPS: usize = 3;
 174         let strength = (cce_ui::layout::bevel_depth() / 0.15).clamp(0.0, 2.0);
 175         let a_dark = 0.32 * strength;
 176         let a_light = 0.16 * strength;
 177         let wx0 = track_x - WELL_CLEARANCE;
 178         let wx1 = track_x + track_w + WELL_CLEARANCE;
 179         let cols = (wx1 - wx0).ceil().max(1.0) as i32;
 180         let colw = (wx1 - wx0) / cols as f32;
 181         let sub = WELL_WALL / WALL_STEPS as f32;
 182         for i in 0..cols {
 183             let x = wx0 + i as f32 * colw;
 184             let xm = (x + colw * 0.5).clamp(track_x, track_x + track_w);
 185             let c = height_at(xm) * 0.5 + WELL_CLEARANCE;
 186             for k in 0..WALL_STEPS {
 187                 let fade = 1.0 - k as f32 / WALL_STEPS as f32;
 188                 ctx.quad(
 189                     Rect { x, y: cy - c + k as f32 * sub, width: colw, height: sub },
 190                     [0.0, 0.0, 0.0, a_dark * fade],
 191                 );
 192                 ctx.quad(
 193                     Rect { x, y: cy + c + k as f32 * sub, width: colw, height: sub },
 194                     [1.0, 1.0, 1.0, a_light * fade],
 195                 );
 196             }
 197         }
 198         // End walls close the well.
 199         let c0 = height_at(track_x) * 0.5 + WELL_CLEARANCE;
 200         let c1 = height_at(track_x + track_w) * 0.5 + WELL_CLEARANCE;
 201         for k in 0..WALL_STEPS {
 202             let fade = 1.0 - k as f32 / WALL_STEPS as f32;
 203             ctx.quad(
 204                 Rect { x: wx0 + k as f32 * sub, y: cy - c0, width: sub, height: 2.0 * c0 },
 205                 [0.0, 0.0, 0.0, a_dark * fade],
 206             );
 207             ctx.quad(
 208                 Rect { x: wx1 + k as f32 * sub, y: cy - c1, width: sub, height: 2.0 * c1 },
 209                 [1.0, 1.0, 1.0, a_light * fade],
 210             );
 211         }
 212 
 213         // Gradient fill: ~1px columns over the whole track, height from the
 214         // band profile (a hair of overlap so AA seams can't open).
 215         let steps = (track_w.ceil() as i32).max(1);
 216         let step_w = track_w / steps as f32;
 217         let checker_grid = SLIDER_TRACK_H / 2.0;
 218         for i in 0..steps {
 219             let x = track_x + i as f32 * step_w;
 220             let xm = x + step_w * 0.5;
 221             let h = height_at(xm);
 222             // Alpha track: checkerboard backdrop inside the band shape.
 223             if self.channel_index == 6 {
 224                 let cell = ((xm - track_x) / checker_grid).floor() as i32;
 225                 let (top, bottom) = if cell % 2 == 0 {
 226                     ([0.8, 0.8, 0.8, 1.0], [1.0, 1.0, 1.0, 1.0])
 227                 } else {
 228                     ([1.0, 1.0, 1.0, 1.0], [0.8, 0.8, 0.8, 1.0])
 229                 };
 230                 ctx.quad(Rect { x, y: cy - h * 0.5, width: step_w + 0.3, height: h * 0.5 }, top);
 231                 ctx.quad(Rect { x, y: cy, width: step_w + 0.3, height: h * 0.5 }, bottom);
 232             }
 233             let c = cce_ui::color::to_linear(self.color_at((xm - track_x) / track_w));
 234             ctx.quad(Rect { x, y: cy - h * 0.5, width: step_w + 0.3, height: h }, c);
 235         }
 236     }
 237 }
 238 
 239 impl cce_ui::widget::Layout for ColorSlider {
 240     fn intrinsic_size(&self) -> Option<Size> {
 241         Some(Size { width: 0.0, height: SLIDER_ROW_H })
 242     }
 243 }
 244 
 245 impl cce_ui::widget::Paint for ColorSlider {
 246     fn color(&self) -> [f32; 4] {
 247         [0.0, 0.0, 0.0, 0.0]
 248     }
 249 
 250     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
 251         let (track_x, _, track_w, _) = track_rect(rect);
 252 
 253         self.paint_band(rect, track_x, track_w, ctx);
 254 
 255         // 5. Own labels: channel letter + value readout, both sitting on the
 256         // track's centerline (align_text_y — the stock Slider's centering),
 257         // the readout right-aligned so every row's value shares one flush
 258         // edge, mirroring the label's left edge. The row rect is already
 259         // inset from the window edge (root_plate_inset), so both sit flush
 260         // with it.
 261         let label_size = 12.0;
 262         let value_size = 11.0;
 263         ctx.text(
 264             self.label.clone(),
 265             rect.x,
 266             cce_ui::layout::align_text_y(rect.y, rect.height, label_size, 0.0),
 267             label_size,
 268             [0xaa, 0xaa, 0xbb],
 269         );
 270 
 271         let val_str = if self.channel_index < 3 {
 272             format!("{}", (self.value * 255.0) as u8)
 273         } else if self.channel_index == 3 {
 274             format!("{}°", (self.value * 360.0).round() as u16)
 275         } else if self.channel_index < 6 {
 276             format!("{}%", (self.value * 100.0).round() as u8)
 277         } else {
 278             format!("{}", (self.value * 255.0).round() as u8)
 279         };
 280         // Default (font: None) text shapes in the preferred sans family —
 281         // measure with the same family so the right edge is exact.
 282         let (sans, ..) = cce_ui::layout::read_preferred_fonts();
 283         let vw = cce_ui::widget::display::measure_text_width(&val_str, &sans, value_size);
 284         ctx.text(
 285             val_str,
 286             rect.x + rect.width - vw,
 287             cce_ui::layout::align_text_y(rect.y, rect.height, value_size, 0.0),
 288             value_size,
 289             [0xcc, 0xcc, 0xdd],
 290         );
 291     }
 292 }
 293 
 294 impl cce_ui::widget::Input for ColorSlider {
 295     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
 296         match event {
 297             // Presses arrive hit-gated to the row rect; the track is narrower — re-check it.
 298             // Releases arrive ungated (commit/cancel contract): same track gate as legacy.
 299             Event::MouseButton { button, state, x, y, .. } => {
 300                 if *button != MouseButton::Left {
 301                     return false;
 302                 }
 303                 let (track_x, track_y, track_w, track_h) = track_rect(ectx.rect);
 304                 if *x >= track_x && *x <= track_x + track_w && *y >= track_y && *y <= track_y + track_h {
 305                     if *state == ElementState::Pressed {
 306                         self.dragging = true;
 307                         self.value = ((*x - track_x) / track_w).clamp(0.0, 1.0);
 308                         self.just_changed = true;
 309                     } else {
 310                         self.dragging = false;
 311                     }
 312                     return true;
 313                 }
 314                 false
 315             }
 316             Event::MouseWheel { delta, x, y, .. } => {
 317                 let Some(ui) = ectx.ui.as_deref_mut() else {
 318                     return false;
 319                 };
 320                 // Scroll-gesture gating: only the widget that initiated the gesture keeps it.
 321                 if !ui.scroll_gesture_new && ui.scroll_initiate_widget_id != Some(ectx.id) {
 322                     return false;
 323                 }
 324                 let r = ectx.rect;
 325                 if *x >= r.x && *x <= r.x + r.width && *y >= r.y && *y <= r.y + r.height {
 326                     if ui.scroll_gesture_new {
 327                         ui.scroll_initiate_widget_id = Some(ectx.id);
 328                     }
 329                     let scroll_amount = match delta {
 330                         MouseScrollDelta::LineDelta(_x, y) => *y,
 331                         MouseScrollDelta::PixelDelta(pos) => pos.y as f32 / 120.0,
 332                     };
 333 
 334                     let step = if self.channel_index == 3 {
 335                         5.0 / 360.0
 336                     } else if self.channel_index == 6 {
 337                         0.05
 338                     } else if self.channel_index >= 4 && self.channel_index <= 5 {
 339                         0.01
 340                     } else {
 341                         1.0 / 255.0
 342                     };
 343 
 344                     let new_value = (self.value + scroll_amount * step).clamp(0.0, 1.0);
 345                     if (new_value - self.value).abs() > 0.0001 {
 346                         self.value = new_value;
 347                         self.just_changed = true;
 348                     }
 349                     return true;
 350                 }
 351                 false
 352             }
 353             _ => false,
 354         }
 355     }
 356 
 357     fn draggable(&self, _rect: Rect) -> bool {
 358         true
 359     }
 360 
 361     fn is_dragging(&self) -> bool {
 362         self.dragging
 363     }
 364 
 365     fn drag_begin(&mut self, _px: f32, _py: f32, _rect: Rect) {
 366         self.dragging = true;
 367     }
 368 
 369     fn drag_update(&mut self, px: f32, _py: f32, rect: Rect) -> bool {
 370         let (track_x, _, track_w, _) = track_rect(rect);
 371         let val = ((px - track_x) / track_w).clamp(0.0, 1.0);
 372         if (val - self.value).abs() > 0.001 {
 373             self.value = val;
 374             self.just_changed = true;
 375             return true;
 376         }
 377         false
 378     }
 379 
 380     fn drag_end(&mut self) {
 381         self.dragging = false;
 382     }
 383 }
 384 
 385 // ── AppWidget for flat/rounded rect rendering ────────────────────────
 386 
 387 struct AppWidget {
 388     x: f32,
 389     y: f32,
 390     w: f32,
 391     h: f32,
 392     color: [f32; 4],
 393     radius: f32,
 394     corners: (bool, bool, bool, bool),
 395 }
 396 
 397 // ── Message definition ──────────────────────────────────────────────
 398 
 399 #[derive(Debug, Clone)]
 400 enum Message {
 401     Apply,
 402     Cancel,
 403 }
 404 
 405 // ── ColorApp State ───────────────────────────────────────────────────
 406 
 407 struct ColorApp {
 408     apply_btn: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
 409     cancel_btn: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
 410     sliders: Vec<Handle<Adapted<ColorSlider>>>,
 411 
 412     red: f32,
 413     green: f32,
 414     blue: f32,
 415     hue: f32,
 416     saturation: f32,
 417     lightness: f32,
 418     alpha: f32,
 419     with_alpha: bool,
 420     expecting_output: bool,
 421     // --stream: print every color change (flushed) so the launching widget
 422     // applies it live while this picker stays open; Cancel prints `cancel`
 423     // so the caller can restore the launch value.
 424     stream: bool,
 425     last_streamed: String,
 426 
 427     cursor_x: f32,
 428     cursor_y: f32,
 429     width: u32,
 430     height: u32,
 431     scale_factor: f64,
 432     needs_rebuild: bool,
 433     ui_context: UiContext,
 434 
 435     widgets: Vec<AppWidget>,
 436     // (content, font_size, x, y, color, font, bounds) — the PageContent text tuples,
 437     // emitted as display-list Text prims.
 438     texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
 439 }
 440 
 441 impl ColorApp {
 442     fn hex(&self) -> String {
 443         if self.with_alpha {
 444             format!(
 445                 "#{:02X}{:02X}{:02X}{:02X}",
 446                 (self.red * 255.0) as u8,
 447                 (self.green * 255.0) as u8,
 448                 (self.blue * 255.0) as u8,
 449                 (self.alpha * 255.0) as u8
 450             )
 451         } else {
 452             format!(
 453                 "#{:02X}{:02X}{:02X}",
 454                 (self.red * 255.0) as u8,
 455                 (self.green * 255.0) as u8,
 456                 (self.blue * 255.0) as u8
 457             )
 458         }
 459     }
 460 
 461     fn update_sliders_color_state(&mut self) {
 462         for &h in &self.sliders {
 463             let slider = &mut self.ui_context[h];
 464             slider.r = self.red;
 465             slider.g = self.green;
 466             slider.b = self.blue;
 467             slider.h = self.hue;
 468             slider.s = self.saturation;
 469             slider.l = self.lightness;
 470             slider.a = self.alpha;
 471         }
 472     }
 473 
 474     fn update_color_from_slider(&mut self, i: usize, val: f32) {
 475         match i {
 476             0 => {
 477                 self.red = val;
 478                 let (h, sat, l) = rgb_to_hsl(self.red, self.green, self.blue);
 479                 self.saturation = sat;
 480                 self.lightness = l;
 481                 if sat > 0.001 && l > 0.001 && l < 0.999 {
 482                     self.hue = h;
 483                 }
 484             }
 485             1 => {
 486                 self.green = val;
 487                 let (h, sat, l) = rgb_to_hsl(self.red, self.green, self.blue);
 488                 self.saturation = sat;
 489                 self.lightness = l;
 490                 if sat > 0.001 && l > 0.001 && l < 0.999 {
 491                     self.hue = h;
 492                 }
 493             }
 494             2 => {
 495                 self.blue = val;
 496                 let (h, sat, l) = rgb_to_hsl(self.red, self.green, self.blue);
 497                 self.saturation = sat;
 498                 self.lightness = l;
 499                 if sat > 0.001 && l > 0.001 && l < 0.999 {
 500                     self.hue = h;
 501                 }
 502             }
 503             3 => {
 504                 self.hue = val;
 505                 let (r, g, b) = hsl_to_rgb(self.hue, self.saturation, self.lightness);
 506                 self.red = r;
 507                 self.green = g;
 508                 self.blue = b;
 509             }
 510             4 => {
 511                 self.saturation = val;
 512                 let (r, g, b) = hsl_to_rgb(self.hue, self.saturation, self.lightness);
 513                 self.red = r;
 514                 self.green = g;
 515                 self.blue = b;
 516             }
 517             5 => {
 518                 self.lightness = val;
 519                 let (r, g, b) = hsl_to_rgb(self.hue, self.saturation, self.lightness);
 520                 self.red = r;
 521                 self.green = g;
 522                 self.blue = b;
 523             }
 524             _ => {
 525                 self.alpha = val;
 526             }
 527         }
 528         self.sync_slider_values();
 529         self.update_sliders_color_state();
 530     }
 531 
 532     fn sync_slider_values(&mut self) {
 533         let (ui, s) = (&mut self.ui_context, &self.sliders);
 534         ui[s[0]].value = self.red;
 535         ui[s[1]].value = self.green;
 536         ui[s[2]].value = self.blue;
 537         ui[s[3]].value = self.hue;
 538         ui[s[4]].value = self.saturation;
 539         ui[s[5]].value = self.lightness;
 540         if self.with_alpha {
 541             ui[s[6]].value = self.alpha;
 542         }
 543     }
 544 
 545     fn rebuild_layout(&mut self) {
 546         self.ui_context.clear_hierarchy();
 547         let mut widgets = Vec::new();
 548         let mut texts = Vec::new();
 549 
 550         // 1. Layout elements (root plate container DISSOLVED: widgets are top-level; its plate
 551         // is emitted below as the first tuple). Everything here stands on the root
 552         // plate: inset from the window edge by root_plate_inset, the slider rows,
 553         // the preview and the button row one root_plate_gap apart, the two
 554         // buttons one root_plate_gap apart.
 555         let inset = cce_ui::layout::root_plate_inset();
 556         let gap = cce_ui::layout::root_plate_gap();
 557         let content_w = self.width as f32 - 2.0 * inset;
 558 
 559         for (i, &h) in self.sliders.iter().enumerate() {
 560             let row_y = inset + i as f32 * SLIDER_ROW_H;
 561             self.ui_context[h].set_rect(inset, row_y, content_w, SLIDER_ROW_H);
 562         }
 563 
 564         let preview_x = inset;
 565         let preview_y = inset + self.sliders.len() as f32 * SLIDER_ROW_H + gap;
 566         let button_y = preview_y + PREVIEW_H + gap;
 567 
 568         let apply_x = inset;
 569         let cancel_x = inset + BUTTON_W + gap;
 570 
 571         if self.expecting_output {
 572             let btn_h = cce_ui::layout::button_height();
 573             self.ui_context[self.apply_btn].set_rect(apply_x, button_y, BUTTON_W, btn_h);
 574             self.ui_context[self.cancel_btn].set_rect(cancel_x, button_y, BUTTON_W, btn_h);
 575         }
 576 
 577         // 2. Each top-level widget rendered through the same immediate-mode path the root
 578         // recursion used: the sliders' plain gradient quads, then the rounded buttons.
 579         // The window base is not in this list — `display_list` emits the standard root
 580         // plate first, under everything gathered here.
 581         let mut window_pc = PageContent::new();
 582         for &h in &self.sliders {
 583             let (x, y, w, hh) = self.ui_context[h].rect();
 584             cce_ui::layout::render_widget_h(&mut window_pc, h, x, y, w, hh, &mut self.ui_context);
 585         }
 586         if self.expecting_output {
 587             for h in [self.apply_btn, self.cancel_btn] {
 588                 let (x, y, w, hh) = self.ui_context[h].rect();
 589                 cce_ui::layout::render_widget_h(&mut window_pc, h, x, y, w, hh, &mut self.ui_context);
 590             }
 591         }
 592 
 593         // 5. Render custom elements
 594         let mut custom_pc = PageContent::new();
 595 
 596 
 597 
 598         if self.with_alpha {
 599             // Draw checkerboard behind the preview box
 600             let px = preview_x;
 601             let py = preview_y;
 602             let pw = PREVIEW_W;
 603             let ph = PREVIEW_H;
 604 
 605             custom_pc.rect([0.8, 0.8, 0.8, 1.0], px, py, pw, ph);
 606 
 607             let grid_size = 12.0;
 608             let cols = (pw / grid_size).ceil() as i32;
 609             let rows = (ph / grid_size).ceil() as i32;
 610             for r in 0..rows {
 611                 for c in 0..cols {
 612                     if (r + c) % 2 == 1 {
 613                         let qx = px + c as f32 * grid_size;
 614                         let qy = py + r as f32 * grid_size;
 615                         let qw = grid_size.min(px + pw - qx);
 616                         let qh = grid_size.min(py + ph - qy);
 617                         if qw > 0.0 && qh > 0.0 {
 618                             custom_pc.rect([1.0, 1.0, 1.0, 1.0], qx, qy, qw, qh);
 619                         }
 620                     }
 621                 }
 622             }
 623         }
 624 
 625         // Color block preview
 626         let linear_col = cce_ui::color::to_linear([
 627             self.red,
 628             self.green,
 629             self.blue,
 630             if self.with_alpha { self.alpha } else { 1.0 },
 631         ]);
 632         custom_pc.rect(linear_col, preview_x, preview_y, PREVIEW_W, PREVIEW_H);
 633 
 634         // Hex string readout, one root_plate_gap to the right of the preview.
 635         let hex = self.hex();
 636         custom_pc.text(
 637             &hex,
 638             preview_x + PREVIEW_W + gap,
 639             // TODO(style): the readout's vertical placement in the preview's
 640             // height is an alignment, not a gap — align_text_y is the honest form.
 641             preview_y + 26.0,
 642             16.0,
 643             [0.88, 0.88, 0.91, 1.0],
 644         );
 645 
 646         // 6. Gather all quads and text labels
 647         for pc_part in [window_pc, custom_pc] {
 648             for (c, x, y, w, h, r, corners) in &pc_part.rects {
 649                 widgets.push(AppWidget {
 650                     x: *x,
 651                     y: *y,
 652                     w: *w,
 653                     h: *h,
 654                     color: *c,
 655                     radius: *r,
 656                     corners: *corners,
 657                 });
 658             }
 659             texts.extend(pc_part.texts);
 660         }
 661 
 662         self.widgets = widgets;
 663         self.texts = texts;
 664         self.ui_context.clear_dirty();
 665         self.needs_rebuild = false;
 666     }
 667 }
 668 
 669 // ── Application Trait Implementation ────────────────────────────────
 670 
 671 impl Application for ColorApp {
 672     type Message = Message;
 673 
 674     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
 675         Some(&self.ui_context)
 676     }
 677 
 678     fn create(_sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
 679         let args: Vec<String> = std::env::args().collect();
 680         let mut with_alpha = false;
 681         let mut stream = false;
 682         let mut hex_arg = None;
 683         for arg in args.iter().skip(1) {
 684             if arg == "--alpha" || arg == "-a" {
 685                 with_alpha = true;
 686             } else if arg == "--stream" {
 687                 stream = true;
 688             } else {
 689                 hex_arg = Some(arg.as_str());
 690             }
 691         }
 692 
 693         let (r, g, b, a) = if let Some(hex) = hex_arg {
 694             if let Some((r_parsed, g_parsed, b_parsed, parsed_a)) = parse_hex(hex) {
 695                 if parsed_a.is_some() {
 696                     with_alpha = true;
 697                 }
 698                 (r_parsed, g_parsed, b_parsed, parsed_a.unwrap_or(1.0))
 699             } else {
 700                 (0.5, 0.5, 0.5, 1.0)
 701             }
 702         } else {
 703             (0.5, 0.5, 0.5, 1.0)
 704         };
 705 
 706         let (hue, saturation, lightness) = rgb_to_hsl(r, g, b);
 707 
 708         let expecting_output = !std::io::stdout().is_terminal();
 709 
 710         let initial_w = 380;
 711         let initial_h = window_height(with_alpha, expecting_output);
 712 
 713         let btn_h = cce_ui::layout::button_height();
 714         let apply_btn = Button::new(0.0, 0.0, BUTTON_W, btn_h)
 715             .with_label("Apply")
 716             .with_bg([0.20, 0.40, 0.65, 1.0])
 717             .with_hover_bg([0.30, 0.52, 0.78, 1.0])
 718             .with_label_color([0.93, 0.93, 0.94, 1.0]);
 719 
 720         let cancel_btn = Button::new(0.0, 0.0, BUTTON_W, btn_h)
 721             .with_label("Cancel")
 722             .with_bg([0.40, 0.20, 0.20, 1.0])
 723             .with_hover_bg([0.55, 0.20, 0.20, 1.0])
 724             .with_label_color([0.93, 0.93, 0.94, 1.0]);
 725 
 726         let mut sliders = vec![
 727             ColorSlider::new("R", 0),
 728             ColorSlider::new("G", 1),
 729             ColorSlider::new("B", 2),
 730             ColorSlider::new("H", 3),
 731             ColorSlider::new("S", 4),
 732             ColorSlider::new("L", 5),
 733         ];
 734         if with_alpha {
 735             sliders.push(ColorSlider::new("A", 6));
 736         }
 737 
 738         // The context owns the widgets; the app keeps their handles.
 739         let mut ui_context = UiContext::new();
 740         let mut app = Self {
 741             apply_btn: ui_context.insert(apply_btn),
 742             cancel_btn: ui_context.insert(cancel_btn),
 743             sliders: sliders.into_iter().map(|w| ui_context.insert(w)).collect(),
 744             red: r,
 745             green: g,
 746             blue: b,
 747             hue,
 748             saturation,
 749             lightness,
 750             alpha: a,
 751             with_alpha,
 752             expecting_output,
 753             stream,
 754             last_streamed: String::new(),
 755             cursor_x: 0.0,
 756             cursor_y: 0.0,
 757             width: initial_w,
 758             height: initial_h,
 759             scale_factor: 1.0,
 760             needs_rebuild: true,
 761             ui_context,
 762             widgets: Vec::new(),
 763             texts: Vec::new(),
 764         };
 765 
 766         app.sync_slider_values();
 767         app.update_sliders_color_state();
 768         app.rebuild_layout();
 769         app
 770     }
 771 
 772     fn settings(&self) -> WindowSettings {
 773         let win_h = window_height(self.with_alpha, self.expecting_output);
 774         WindowSettings {
 775             title: "Color Editor".to_string(),
 776             app_id: "cce-color-editor".to_string(),
 777             width: 380,
 778             height: win_h,
 779             fullscreen: false,
 780             min_size: Some((380, win_h)),
 781         }
 782     }
 783 
 784     fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
 785         match msg {
 786             Message::Apply => {
 787                 println!("{}", self.hex());
 788                 *exit = true;
 789             }
 790             Message::Cancel => {
 791                 if self.stream {
 792                     use std::io::Write;
 793                     println!("cancel");
 794                     let _ = std::io::stdout().flush();
 795                 }
 796                 *exit = true;
 797             }
 798         }
 799     }
 800 
 801     fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {
 802         // Stream the live color to the launching widget on every change.
 803         if self.stream {
 804             let hex = self.hex();
 805             if hex != self.last_streamed {
 806                 use std::io::Write;
 807                 println!("{}", hex);
 808                 let _ = std::io::stdout().flush();
 809                 self.last_streamed = hex;
 810             }
 811         }
 812     }
 813 
 814     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
 815         let px = pos.x;
 816         let py = pos.y;
 817         let event = Event::MouseWheel {
 818             delta: *delta,
 819             x: px,
 820             y: py,
 821             local_x: px,
 822             local_y: py,
 823         };
 824         // root plate container dissolved: propagate to each slider directly (they own
 825         // mouse_wheel; the buttons never scrolled).
 826         let mut changed_slider = None;
 827         let mut any = false;
 828         for &h in &self.sliders {
 829             if self.ui_context.propagate_event(&event, h.id()) {
 830                 any = true;
 831                 break;
 832             }
 833         }
 834         if any {
 835             for (i, &h) in self.sliders.iter().enumerate() {
 836                 let slider = &mut self.ui_context[h];
 837                 if slider.just_changed {
 838                     slider.just_changed = false;
 839                     changed_slider = Some((i, slider.value));
 840                     break;
 841                 }
 842             }
 843         }
 844         if let Some((i, val)) = changed_slider {
 845             self.update_color_from_slider(i, val);
 846             *needs_rebuild = true;
 847             self.needs_rebuild = true;
 848         }
 849     }
 850 
 851     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
 852         // Phase 6 single paint path: the whole frame — geometry and text — is this one list.
 853         // rebuild_layout flattens the UI (incl. color ramps/gradients) into self.widgets/self.texts.
 854         if self.needs_rebuild || self.width != size.width as u32 || self.height != size.height as u32 || self.scale_factor != scale {
 855             self.width = size.width as u32;
 856             self.height = size.height as u32;
 857             self.scale_factor = scale;
 858             cce_ui::scale::set_scale_factor(scale as f32);
 859             self.rebuild_layout();
 860         }
 861         use cce_ui::scene::layout::Rect;
 862         let mut pc = cce_ui::scene::paint::PaintCtx::new();
 863         // The standard root plate (cce-ui PlateSpec::window).
 864         pc.root_plate(self.width as f32, self.height as f32);
 865         for w in &self.widgets {
 866             let rect = Rect { x: w.x, y: w.y, width: w.w, height: w.h };
 867             if w.radius > 0.1 {
 868                 pc.rounded_rect(rect, w.radius, w.corners, w.color);
 869             } else {
 870                 pc.quad(rect, w.color);
 871             }
 872         }
 873         for (text, font_size, x, y, col, font, bounds) in &self.texts {
 874             pc.text_with(
 875                 text.clone(),
 876                 *x,
 877                 *y,
 878                 *font_size,
 879                 [
 880                     (col[0] * 255.0) as u8,
 881                     (col[1] * 255.0) as u8,
 882                     (col[2] * 255.0) as u8,
 883                 ],
 884                 font.clone(),
 885                 *bounds,
 886             );
 887         }
 888         Some(pc.finish())
 889     }
 890 
 891     fn display_list_text(&self) -> bool {
 892         true
 893     }
 894 
 895     fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
 896         // root plate container dissolved: the surface itself is the movable plate.
 897         self.ui_context.drag_allowed_at(px, py)
 898     }
 899 
 900     fn clear_color(&self) -> [f32; 4] {
 901         [0.0, 0.0, 0.0, 0.0]
 902     }
 903 
 904     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
 905         self.cursor_x = pos.x;
 906         self.cursor_y = pos.y;
 907         cce_ui::widget::hover_animation::set_cursor_pos(pos.x, pos.y);
 908 
 909         // Routed dispatch (6bd shrink): one PointerMove through the router per root —
 910         // hover bookkeeping plus the router's drag forwarding (replaces the app-held
 911         // dragging index; DragUpdate reaches the drag target even off-rect).
 912         let ev = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
 913         if self.expecting_output {
 914             let apply = self.apply_btn.id();
 915             self.ui_context.propagate_event(&ev, apply);
 916             let cancel = self.cancel_btn.id();
 917             self.ui_context.propagate_event(&ev, cancel);
 918         }
 919         let slider_roots: Vec<_> = self.sliders.iter().map(|h| h.id()).collect();
 920         for root in slider_roots {
 921             self.ui_context.propagate_event(&ev, root);
 922         }
 923         // Drain the drag's value change like the wheel path does.
 924         let mut changed_slider = None;
 925         for (i, &h) in self.sliders.iter().enumerate() {
 926             let slider = &mut self.ui_context[h];
 927             if slider.just_changed {
 928                 slider.just_changed = false;
 929                 changed_slider = Some((i, slider.value));
 930                 break;
 931             }
 932         }
 933         if let Some((i, val)) = changed_slider {
 934             self.update_color_from_slider(i, val);
 935         }
 936         // Legacy parity: every pointer move rebuilt (hover fades ride the rebuild).
 937         *needs_rebuild = true;
 938         self.needs_rebuild = true;
 939     }
 940 
 941     fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
 942         if button != MouseButton::Left {
 943             return None;
 944         }
 945 
 946         // Routed dispatch (6bd shrink): the router hit-gates presses, records the drag
 947         // target, and delivers DragEnd on release; the app keeps the take_click /
 948         // just_changed drains.
 949         let ev = Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
 950         let was_dragging = self.ui_context.is_dragging;
 951         let mut handled = false;
 952 
 953         if self.expecting_output {
 954             let apply = self.apply_btn.id();
 955             if self.ui_context.propagate_event(&ev, apply) {
 956                 handled = true;
 957                 *needs_rebuild = true;
 958                 self.needs_rebuild = true;
 959             }
 960             let cancel = self.cancel_btn.id();
 961             if self.ui_context.propagate_event(&ev, cancel) {
 962                 handled = true;
 963                 *needs_rebuild = true;
 964                 self.needs_rebuild = true;
 965             }
 966 
 967             if self.ui_context[self.apply_btn].take_click() {
 968                 return Some(Message::Apply);
 969             }
 970             if self.ui_context[self.cancel_btn].take_click() {
 971                 return Some(Message::Cancel);
 972             }
 973         }
 974 
 975         if !handled {
 976             let slider_roots: Vec<_> = self.sliders.iter().map(|h| h.id()).collect();
 977             for root in slider_roots {
 978                 if self.ui_context.propagate_event(&ev, root) {
 979                     break;
 980                 }
 981             }
 982             let mut changed_slider = None;
 983             for (i, &h) in self.sliders.iter().enumerate() {
 984                 let slider = &mut self.ui_context[h];
 985                 if slider.just_changed {
 986                     slider.just_changed = false;
 987                     changed_slider = Some((i, slider.value));
 988                     break;
 989                 }
 990             }
 991             if let Some((i, val)) = changed_slider {
 992                 self.update_color_from_slider(i, val);
 993                 *needs_rebuild = true;
 994                 self.needs_rebuild = true;
 995             }
 996         }
 997 
 998         // The router delivered DragEnd on the first propagate call of a release; rebuild
 999         // so the thumb sheds its dragging state, as the legacy drag_end path did.
1000         if state == ElementState::Released && was_dragging {
1001             *needs_rebuild = true;
1002             self.needs_rebuild = true;
1003         }
1004 
1005         None
1006     }
1007 
1008     fn handle_key_input(&mut self, event: &KeyEvent, _needs_rebuild: &mut bool) -> Option<Self::Message> {
1009         if event.state == ElementState::Pressed {
1010             match event.logical_key {
1011                 cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Escape) => {
1012                     return Some(Message::Cancel);
1013                 }
1014                 cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Enter) => {
1015                     return Some(Message::Apply);
1016                 }
1017                 _ => {}
1018             }
1019         }
1020         None
1021     }
1022 }
1023 
1024 // ── Color Utilities ──────────────────────────────────────────────────
1025 
1026 fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
1027     let max = r.max(g.max(b));
1028     let min = r.min(g.min(b));
1029     let mut h = 0.0;
1030     let mut s = 0.0;
1031     let l = (max + min) / 2.0;
1032 
1033     if max != min {
1034         let d = max - min;
1035         s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
1036         if max == r {
1037             h = (g - b) / d + (if g < b { 6.0 } else { 0.0 });
1038         } else if max == g {
1039             h = (b - r) / d + 2.0;
1040         } else if max == b {
1041             h = (r - g) / d + 4.0;
1042         }
1043         h /= 6.0;
1044     }
1045 
1046     (h, s, l)
1047 }
1048 
1049 fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
1050     if s == 0.0 {
1051         return (l, l, l);
1052     }
1053 
1054     let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
1055     let p = 2.0 * l - q;
1056 
1057     let r = hue_to_rgb(p, q, h + 1.0 / 3.0);
1058     let g = hue_to_rgb(p, q, h);
1059     let b = hue_to_rgb(p, q, h - 1.0 / 3.0);
1060 
1061     (r, g, b)
1062 }
1063 
1064 fn hue_to_rgb(p: f32, q: f32, mut t: f32) -> f32 {
1065     if t < 0.0 { t += 1.0; }
1066     if t > 1.0 { t -= 1.0; }
1067     if t < 1.0 / 6.0 { return p + (q - p) * 6.0 * t; }
1068     if t < 1.0 / 2.0 { return q; }
1069     if t < 2.0 / 3.0 { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }
1070     p
1071 }
1072 
1073 fn parse_hex(hex: &str) -> Option<(f32, f32, f32, Option<f32>)> {
1074     let has_alpha = hex
1075         .trim_matches(|c| c == '"' || c == '\'' || c == ' ')
1076         .trim_start_matches('#')
1077         .len()
1078         >= 8;
1079     cce_ui::color::parse_hex_rgba(hex)
1080         .map(|[r, g, b, a]| (r, g, b, if has_alpha { Some(a) } else { None }))
1081 }
1082 
1083 // ── Main Entrypoint ──────────────────────────────────────────────────
1084 
1085 fn main() {
1086     cce_ui::engine::run::<ColorApp>();
1087 }