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