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 }