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 }