1use sequoia_core::{BrushStop, Color, Fill, Length, Point, Rect};
2use sequoia_input::{CursorIcon, Event, PointerButton, PointerEvent};
3use sequoia_layout::{Align, Inset, LayoutStyle, Position};
4use sequoia_reactive::{EffectScope, Environment, Signal, batch, effect};
5use sequoia_style::VisualState;
6use sequoia_view::{
7 AnyView, Canvas, Column, EventCtx, Orientation, Row, Spacer, Text, Unit, View, Wrap,
8};
9
10use crate::button::Button;
11use crate::clipboard::SharedClipboard;
12use crate::container::Container;
13use crate::context_menu::ContextMenu;
14use crate::drag_value::DragValue;
15use crate::dropdown::DropdownButton;
16use crate::floating_menu::FloatingMenu;
17use crate::slider1d::Slider1D;
18use crate::svpad::SvPad;
19use crate::text_field::TextField;
20use crate::toast::ToastQueue;
21use std::cell::Cell;
22use std::cell::RefCell;
23use std::rc::Rc;
24use std::time::{Duration, Instant};
25
26const BG_PANEL: Color = Color::rgb8(0x26, 0x26, 0x26);
27const BG_HEADER: Color = Color::rgb8(0x1E, 0x1E, 0x1E);
28const BG_INPUT: Color = Color::rgb8(0x2A, 0x2A, 0x2A);
29const BG_SECTION: Color = Color::rgb8(0x30, 0x30, 0x30);
30const BORDER_PANEL: Color = Color::rgb8(0x00, 0x00, 0x00);
31const BORDER_HAIRLINE: Color = Color::rgb8(0x1F, 0x1F, 0x1F);
32const BORDER_CONTROL: Color = Color::rgb8(0x45, 0x45, 0x45);
33const HAIR_TRACK: Color = Color::rgb8(0x1A, 0x1A, 0x1A);
34const TEXT_TITLE: Color = Color::rgb8(0xED, 0xED, 0xED);
35const TEXT_PRIMARY: Color = Color::rgb8(0xD4, 0xD4, 0xD4);
36const TEXT_SECONDARY: Color = Color::rgb8(0x9A, 0x9A, 0x9A);
37const ACCENT_ACTIVE: Color = Color::rgb8(0xE8, 0x84, 0x2C);
38
39pub const PIPETTE_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.763 8.177a2 2 0 0 1-.263.323l-4.621 4.621A3 3 0 0 1 3.758 14H3l-.793.793a.708.708 0 0 1-1-1L2 13v-.758a3 3 0 0 1 .879-2.12L7.5 5.5q.151-.15.322-.264zM12.879 1a2.121 2.121 0 0 1 1.5 3.621L12.5 6.5l.5.5a.707.707 0 0 1-1 1L8 4a.707.707 0 0 1 1-1l.5.5l1.879-1.879a2.12 2.12 0 0 1 1.5-.621"/></svg>"#;
40
41const DEFAULT_SWATCHES: [&str; 8] = [
42 "E5484D", "F76B15", "FFC53D", "46A758", "12A594", "35C2F1", "6E56CF", "E93D82",
43];
44
45const DEFAULT_H: f32 = 201.0;
46const DEFAULT_S: f32 = 0.52;
47const DEFAULT_V: f32 = 0.92;
48const HIST_LIMIT: usize = 100;
49const HIST_WINDOW: Duration = Duration::from_millis(800);
50
51fn clampf(v: f32, lo: f32, hi: f32) -> f32 {
52 v.clamp(lo, hi)
53}
54
55fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
56 let h = h.rem_euclid(360.0);
57 let f = |n: f32| {
58 let k = (n + h / 60.0) % 6.0;
59 v - v * s * 0f32.max((k.min(4.0 - k)).min(1.0))
60 };
61 let q = |c: f32| (clampf(c, 0.0, 1.0) * 255.0).round() as u8;
62 (q(f(5.0)), q(f(3.0)), q(f(1.0)))
63}
64
65fn rgb_to_hsv(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
66 let (r, g, b) = (r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
67 let mx = r.max(g).max(b);
68 let mn = r.min(g).min(b);
69 let d = mx - mn;
70 let mut h = 0.0;
71 if d > 0.0 {
72 if mx == r {
73 h = ((g - b) / d) % 6.0;
74 } else if mx == g {
75 h = (b - r) / d + 2.0;
76 } else {
77 h = (r - g) / d + 4.0;
78 }
79 h *= 60.0;
80 if h < 0.0 {
81 h += 360.0;
82 }
83 }
84 (h, if mx > 0.0 { d / mx } else { 0.0 }, mx)
85}
86
87fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
88 let (r, g, b) = (r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
89 let mx = r.max(g).max(b);
90 let mn = r.min(g).min(b);
91 let d = mx - mn;
92 let l = (mx + mn) / 2.0;
93 let mut h = 0.0;
94 let mut s = 0.0;
95 if d > 0.0 {
96 s = d / (1.0 - (2.0 * l - 1.0).abs());
97 if mx == r {
98 h = ((g - b) / d) % 6.0;
99 } else if mx == g {
100 h = (b - r) / d + 2.0;
101 } else {
102 h = (r - g) / d + 4.0;
103 }
104 h *= 60.0;
105 if h < 0.0 {
106 h += 360.0;
107 }
108 }
109 (h, s, l)
110}
111
112fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
113 let h = h.rem_euclid(360.0);
114 let a = s * l.min(1.0 - l);
115 let f = |n: f32| {
116 let k = (n + h / 30.0) % 12.0;
117 l - a * (-1f32).max((k - 3.0).min(9.0 - k).min(1.0))
118 };
119 let q = |c: f32| (clampf(c, 0.0, 1.0) * 255.0).round() as u8;
120 (q(f(0.0)), q(f(8.0)), q(f(4.0)))
121}
122
123fn rgb_to_hex(r: u8, g: u8, b: u8) -> String {
124 format!("{r:02X}{g:02X}{b:02X}")
125}
126
127fn parse_hex(s: &str) -> Option<String> {
128 let mut clean = s.trim();
129 if let Some(rest) = clean.strip_prefix('#') {
130 clean = rest;
131 } else if let Some(rest) = clean
132 .strip_prefix("0x")
133 .or_else(|| clean.strip_prefix("0X"))
134 {
135 clean = rest;
136 }
137 if clean.is_empty() || !clean.chars().all(|c| c.is_ascii_hexdigit()) {
138 return None;
139 }
140 let rgb = match clean.len() {
141 3 => clean.chars().flat_map(|c| [c, c]).collect::<String>(),
142 4 => clean[..3].chars().flat_map(|c| [c, c]).collect::<String>(),
143 6 => clean.to_string(),
144 8 => clean[..6].to_string(),
145 _ => return None,
146 };
147 Some(rgb.to_uppercase())
148}
149
150fn hex_to_rgb(hx: &str) -> (u8, u8, u8) {
151 let n = |i: usize| u8::from_str_radix(&hx[i..i + 2], 16).unwrap_or(0);
152 (n(0), n(2), n(4))
153}
154
155fn color_components(s: &str) -> Vec<(f32, bool)> {
156 let bytes = s.as_bytes();
157 let mut out = Vec::new();
158 let mut i = 0;
159 while i < bytes.len() {
160 let negative = bytes[i] == b'-';
161 let start_digit = if negative { i + 1 } else { i };
162 if start_digit >= bytes.len() || !bytes[start_digit].is_ascii_digit() {
163 i += 1;
164 continue;
165 }
166 let start = i;
167 i = start_digit;
168 let mut dot = false;
169 while i < bytes.len() {
170 match bytes[i] {
171 b'0'..=b'9' => i += 1,
172 b'.' if !dot => {
173 dot = true;
174 i += 1;
175 }
176 _ => break,
177 }
178 }
179 let Ok(value) = s[start..i].parse::<f32>() else {
180 continue;
181 };
182 let mut j = i;
183 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
184 j += 1;
185 }
186 let percent = j < bytes.len() && bytes[j] == b'%';
187 out.push((value, percent));
188 }
189 out
190}
191
192fn channel_byte(value: f32, percent: bool) -> u8 {
193 let v = if percent { value * 2.55 } else { value };
194 clampf(v, 0.0, 255.0).round() as u8
195}
196
197fn parse_color_text(s: &str) -> Option<(u8, u8, u8)> {
198 if let Some(hx) = parse_hex(s) {
199 return Some(hex_to_rgb(&hx));
200 }
201 let low = s.trim().to_ascii_lowercase();
202 let nums = color_components(&low);
203 if nums.len() < 3 {
204 return None;
205 }
206 if low.starts_with("rgb") {
207 return Some((
208 channel_byte(nums[0].0, nums[0].1),
209 channel_byte(nums[1].0, nums[1].1),
210 channel_byte(nums[2].0, nums[2].1),
211 ));
212 }
213 if low.starts_with("hsl") {
214 let h = nums[0].0;
215 let sat = clampf(nums[1].0, 0.0, 100.0) / 100.0;
216 let light = clampf(nums[2].0, 0.0, 100.0) / 100.0;
217 return Some(hsl_to_rgb(h, sat, light));
218 }
219 None
220}
221
222#[allow(dead_code)]
223fn fmt_val(v: f32, max: f32) -> String {
224 if max == 255.0 || max == 360.0 {
225 return format!("{}", v.round() as i32);
226 }
227 let r = v.round();
228 if (v - r).abs() < 0.05 {
229 format!("{r}")
230 } else {
231 format!("{:.1}", (v * 10.0).round() / 10.0)
232 }
233}
234
235struct Rec {
236 last_hex: String,
237 last_push: Option<Instant>,
238 in_history: bool,
239}
240
241#[derive(Clone, Copy)]
242struct PickerDrag {
243 origin: Point,
244 start: (f32, f32),
245}
246
247#[derive(Clone)]
248pub struct ColorPicker {
249 hue: Signal<f32>,
250 sat: Signal<f32>,
251 val: Signal<f32>,
252 model: Signal<usize>,
253 ref_hex: Signal<String>,
254 hex_text: Signal<String>,
255 editing_hex: Signal<bool>,
256 swatches: Signal<Vec<String>>,
257 undo: Signal<Vec<String>>,
258 redo: Signal<Vec<String>>,
259 active: Signal<Option<usize>>,
260 closed: Signal<bool>,
261 ch: [Signal<f32>; 3],
262 toasts: ToastQueue,
263 rec: Rc<RefCell<Rec>>,
264 on_close: Rc<RefCell<Option<Rc<dyn Fn()>>>>,
265
266 pos: Signal<(f32, f32)>,
267 drag: Rc<Cell<Option<PickerDrag>>>,
268 effects: Rc<RefCell<Option<EffectScope>>>,
269}
270
271pub type Picker = ColorPicker;
272
273impl Default for ColorPicker {
274 fn default() -> Self {
275 Self::new()
276 }
277}
278
279impl ColorPicker {
280 pub fn new() -> Self {
281 let (h, s, v) = (DEFAULT_H, DEFAULT_S, DEFAULT_V);
282 let (r, g, b) = hsv_to_rgb(h, s, v);
283 let start_hex = rgb_to_hex(r, g, b);
284 let p = Self {
285 hue: Signal::new(h),
286 sat: Signal::new(s),
287 val: Signal::new(v),
288 model: Signal::new(0),
289 ref_hex: Signal::new(start_hex.clone()),
290 hex_text: Signal::new(start_hex.clone()),
291 editing_hex: Signal::new(false),
292 swatches: Signal::new(DEFAULT_SWATCHES.iter().map(|s| s.to_string()).collect()),
293 undo: Signal::new(Vec::new()),
294 redo: Signal::new(Vec::new()),
295 active: Signal::new(None),
296 closed: Signal::new(false),
297 ch: [Signal::new(0.0), Signal::new(0.0), Signal::new(0.0)],
298 toasts: ToastQueue::new(),
299 rec: Rc::new(RefCell::new(Rec {
300 last_hex: start_hex,
301 last_push: None,
302 in_history: false,
303 })),
304 on_close: Rc::new(RefCell::new(None)),
305 pos: Signal::new((90.0, 70.0)),
306 drag: Rc::new(Cell::new(None)),
307 effects: Rc::new(RefCell::new(None)),
308 };
309 p.sync_channels();
310 p
311 }
312
313 pub fn set_hex(&self, hx: &str) {
314 self.apply_hex(hx);
315 }
316
317 pub fn hsv_signals(&self) -> (Signal<f32>, Signal<f32>, Signal<f32>) {
318 (self.hue.clone(), self.sat.clone(), self.val.clone())
319 }
320
321 pub fn toasts(&self) -> ToastQueue {
322 self.toasts.clone()
323 }
324
325 pub fn pos_signal(&self) -> Signal<(f32, f32)> {
326 self.pos.clone()
327 }
328
329 pub fn set_on_close(&self, f: impl Fn() + 'static) {
330 *self.on_close.borrow_mut() = Some(Rc::new(f));
331 }
332
333 fn close(&self) {
334 self.closed.set(true);
335 if let Some(f) = self.on_close.borrow().clone() {
336 f();
337 }
338 }
339
340 pub fn open(&self) {
341 self.closed.set(false);
342 }
343
344 pub fn is_open(&self) -> bool {
345 !self.closed.get_untracked()
346 }
347
348 pub fn cur_rgb(&self) -> (u8, u8, u8) {
349 hsv_to_rgb(
350 self.hue.get_untracked(),
351 self.sat.get_untracked(),
352 self.val.get_untracked(),
353 )
354 }
355
356 pub fn cur_hex(&self) -> String {
357 let (r, g, b) = self.cur_rgb();
358 rgb_to_hex(r, g, b)
359 }
360
361 pub fn cur_color(&self) -> Color {
362 let (r, g, b) = self.cur_rgb();
363 Color::rgb8(r, g, b)
364 }
365
366 fn set_rgb(&self, r: u8, g: u8, b: u8) {
367 let (nh, ns, nv) = rgb_to_hsv(r, g, b);
368 batch(|| {
369 if nv > 0.0001 {
370 if ns > 0.0001 {
371 self.hue.set(nh);
372 }
373 self.sat.set(ns);
374 }
375 self.val.set(nv);
376 });
377 }
378
379 fn apply_hex(&self, hx: &str) {
380 if let Some(n) = parse_hex(hx) {
381 let (r, g, b) = hex_to_rgb(&n);
382 self.set_rgb(r, g, b);
383 }
384 }
385
386 fn channel_value(&self, idx: usize) -> f32 {
387 match self.model.get_untracked() {
388 0 => {
389 let (r, g, b) = self.cur_rgb();
390 match idx {
391 0 => r as f32,
392 1 => g as f32,
393 _ => b as f32,
394 }
395 }
396 1 => match idx {
397 0 => self.hue.get_untracked(),
398 1 => self.sat.get_untracked() * 100.0,
399 _ => self.val.get_untracked() * 100.0,
400 },
401 _ => {
402 let (r, g, b) = self.cur_rgb();
403 let (h, s, l) = rgb_to_hsl(r, g, b);
404 let _ = h;
405 match idx {
406 0 => self.hue.get_untracked(),
407 1 => s * 100.0,
408 _ => l * 100.0,
409 }
410 }
411 }
412 }
413
414 fn apply_channel(&self, idx: usize, v: f32) {
415 batch(|| match self.model.get_untracked() {
416 0 => {
417 let (r, g, b) = self.cur_rgb();
418 let q = clampf(v, 0.0, 255.0).round() as u8;
419 match idx {
420 0 => self.set_rgb(q, g, b),
421 1 => self.set_rgb(r, q, b),
422 _ => self.set_rgb(r, g, q),
423 }
424 }
425 1 => match idx {
426 0 => self.hue.set(v.rem_euclid(360.0)),
427 1 => self.sat.set(clampf(v, 0.0, 100.0) / 100.0),
428 _ => self.val.set(clampf(v, 0.0, 100.0) / 100.0),
429 },
430 _ => {
431 if idx == 0 {
432 self.hue.set(v.rem_euclid(360.0));
433 } else {
434 let (r, g, b) = self.cur_rgb();
435 let (hh, mut ss, mut ll) = rgb_to_hsl(r, g, b);
436 if idx == 1 {
437 ss = clampf(v, 0.0, 100.0) / 100.0;
438 } else {
439 ll = clampf(v, 0.0, 100.0) / 100.0;
440 }
441 let (nr, ng, nb) = hsl_to_rgb(hh, ss, ll);
442 self.set_rgb(nr, ng, nb);
443 }
444 }
445 });
446 }
447
448 fn sync_channels(&self) {
449 for i in 0..3 {
450 let want = self.channel_value(i);
451 if (self.ch[i].get_untracked() - want).abs() > 1e-4 {
452 self.ch[i].set(want);
453 }
454 }
455 if !self.editing_hex.get_untracked() {
456 let hx = self.cur_hex();
457 if self.hex_text.get_untracked() != hx {
458 self.hex_text.set(hx);
459 }
460 }
461 }
462
463 fn note_change(&self, new_hex: String) {
464 let mut r = self.rec.borrow_mut();
465 if r.in_history || new_hex == r.last_hex {
466 return;
467 }
468 let now = Instant::now();
469 let coalesce = r
470 .last_push
471 .map(|t| now.duration_since(t) < HIST_WINDOW)
472 .unwrap_or(false);
473 if !coalesce {
474 let prev = r.last_hex.clone();
475 self.undo.update(|u| {
476 if u.last() != Some(&prev) {
477 u.push(prev);
478 if u.len() > HIST_LIMIT {
479 u.remove(0);
480 }
481 }
482 });
483 self.redo.set(Vec::new());
484 }
485 r.last_push = Some(now);
486 r.last_hex = new_hex;
487 }
488
489 pub fn do_undo(&self) {
490 let mut u = self.undo.get_untracked();
491 let Some(prev) = u.pop() else {
492 return;
493 };
494 let cur = self.cur_hex();
495 self.redo.update(|r| r.push(cur));
496 self.undo.set(u);
497 self.rec.borrow_mut().in_history = true;
498 self.apply_hex(&prev);
499 self.sync_channels();
500 let mut r = self.rec.borrow_mut();
501 r.last_hex = prev;
502 r.last_push = None;
503 r.in_history = false;
504 }
505
506 pub fn do_redo(&self) {
507 let mut rd = self.redo.get_untracked();
508 let Some(next) = rd.pop() else {
509 return;
510 };
511 let cur = self.cur_hex();
512 self.undo.update(|u| u.push(cur));
513 self.redo.set(rd);
514 self.rec.borrow_mut().in_history = true;
515 self.apply_hex(&next);
516 self.sync_channels();
517 let mut r = self.rec.borrow_mut();
518 r.last_hex = next;
519 r.last_push = None;
520 r.in_history = false;
521 }
522
523 pub fn set_reference(&self) {
524 self.ref_hex.set(self.cur_hex());
525 self.toast("Reference color set");
526 }
527
528 fn revert(&self) {
529 let rf = self.ref_hex.get_untracked();
530 if rf == self.cur_hex() {
531 return;
532 }
533 self.apply_hex(&rf);
534 self.toast("Reverted to reference");
535 }
536
537 fn with_clip(&self, env: &Environment, f: impl FnOnce(&SharedClipboard)) -> bool {
538 match env.get::<SharedClipboard>() {
539 Some(c) => {
540 f(&c);
541 true
542 }
543 None => false,
544 }
545 }
546
547 fn toast(&self, msg: impl Into<String>) {
548 self.toasts.info(msg, "");
549 }
550
551 fn copy_hex(&self, env: &Environment) {
552 let t = format!("#{}", self.cur_hex());
553 if self.with_clip(env, |c| c.0.set_text(&t)) {
554 self.toast(format!("Copied {t}"));
555 } else {
556 self.toast("Clipboard unavailable");
557 }
558 }
559
560 fn copy_rgb(&self, env: &Environment) {
561 let (r, g, b) = self.cur_rgb();
562 let t = format!("rgb({r}, {g}, {b})");
563 if self.with_clip(env, |c| c.0.set_text(&t)) {
564 self.toast(format!("Copied {t}"));
565 } else {
566 self.toast("Clipboard unavailable");
567 }
568 }
569
570 fn copy_hsl(&self, env: &Environment) {
571 let (r, g, b) = self.cur_rgb();
572 let (h, s, l) = rgb_to_hsl(r, g, b);
573 let t = format!(
574 "hsl({}, {}%, {}%)",
575 h.round() as i32,
576 (s * 100.0).round() as i32,
577 (l * 100.0).round() as i32
578 );
579 if self.with_clip(env, |c| c.0.set_text(&t)) {
580 self.toast(format!("Copied {t}"));
581 } else {
582 self.toast("Clipboard unavailable");
583 }
584 }
585
586 fn paste(&self, env: &Environment) {
587 let Some(clip) = env.get::<SharedClipboard>() else {
588 self.toast("Clipboard access denied");
589 return;
590 };
591 let Some(txt) = clip.0.get_text() else {
592 self.toast("Clipboard access denied");
593 return;
594 };
595 let txt = txt.trim().to_string();
596 if let Some((r, g, b)) = parse_color_text(&txt) {
597 self.set_rgb(r, g, b);
598 self.toast(format!("Pasted #{}", self.cur_hex()));
599 return;
600 }
601 self.toast("No color found in clipboard");
602 }
603
604 fn sw_add(&self) {
605 let hx = self.cur_hex();
606 let mut sw = self.swatches.get_untracked();
607 if sw.contains(&hx) {
608 self.toast(format!("#{hx} is already in swatches"));
609 return;
610 }
611 sw.push(hx);
612 self.swatches.set(sw);
613 }
614
615 fn sw_sort(&self) {
616 let mut sw = self.swatches.get_untracked();
617 sw.sort_by(|a, b| {
618 let (r1, g1, b1) = hex_to_rgb(a);
619 let (r2, g2, b2) = hex_to_rgb(b);
620 let (h1, s1, v1) = rgb_to_hsv(r1, g1, b1);
621 let (h2, s2, v2) = rgb_to_hsv(r2, g2, b2);
622 let _ = (s1, v1, s2, v2);
623 let g1 = if s1 < 0.06 { 999.0 } else { h1 };
624 let g2 = if s2 < 0.06 { 999.0 } else { h2 };
625 g1.partial_cmp(&g2)
626 .unwrap_or(std::cmp::Ordering::Equal)
627 .then(v2.partial_cmp(&v1).unwrap_or(std::cmp::Ordering::Equal))
628 });
629 self.swatches.set(sw);
630 }
631
632 fn effect_handle(&self) -> Self {
633 let mut handle = self.clone();
634 handle.effects = Rc::new(RefCell::new(None));
635 handle
636 }
637
638 pub fn install_effects(&self) {
639 if self.effects.borrow().is_some() {
640 return;
641 }
642 self.rec.borrow_mut().last_hex = self.cur_hex();
643
644 let p = self.effect_handle();
645 let s1 = effect(move || {
646 let _ = (p.hue.get(), p.sat.get(), p.val.get(), p.model.get());
647 p.note_change(p.cur_hex());
648 p.sync_channels();
649 });
650
651 let p = self.effect_handle();
652 let s2 = effect(move || {
653 let cur = [p.ch[0].get(), p.ch[1].get(), p.ch[2].get()];
654 let want = [p.channel_value(0), p.channel_value(1), p.channel_value(2)];
655 let eps = if p.model.get_untracked() == 0 {
656 0.51
657 } else {
658 0.02
659 };
660 let mut hit = None;
661 for i in 0..3 {
662 if (cur[i] - want[i]).abs() > eps {
663 hit = Some(i);
664 break;
665 }
666 }
667 if let Some(i) = hit {
668 p.active.set(Some(i));
669 p.apply_channel(i, cur[i]);
670 }
671 });
672
673 let p = self.effect_handle();
674 let s3 = effect(move || {
675 let t = p.hex_text.get();
676 if t.len() == 6
677 && let Some(n) = parse_hex(&t)
678 && n != p.cur_hex()
679 {
680 p.apply_hex(&n);
681 }
682 });
683
684 let mut scope = EffectScope::new();
685 scope.extend([s1, s2, s3]);
686 *self.effects.borrow_mut() = Some(scope);
687 }
688
689 pub fn uninstall_effects(&self) {
690 if let Some(mut scope) = self.effects.borrow_mut().take() {
691 scope.dispose_all();
692 }
693 }
694}
695
696fn channel_meta(model: usize, idx: usize) -> (&'static str, f32, f32, usize) {
697 match model {
698 0 => match idx {
699 0 => ("R", 0.0, 255.0, 0),
700 1 => ("G", 0.0, 255.0, 0),
701 _ => ("B", 0.0, 255.0, 0),
702 },
703 1 => match idx {
704 0 => ("H", 0.0, 360.0, 0),
705 1 => ("S", 0.0, 100.0, 1),
706 _ => ("V", 0.0, 100.0, 1),
707 },
708 _ => match idx {
709 0 => ("H", 0.0, 360.0, 0),
710 1 => ("S", 0.0, 100.0, 1),
711 _ => ("L", 0.0, 100.0, 1),
712 },
713 }
714}
715
716fn hue_stops() -> Vec<BrushStop> {
717 (0..=6)
718 .map(|i| {
719 BrushStop::new(
720 i as f32 / 6.0,
721 Color::from_hsva(i as f32 / 6.0, 1.0, 1.0, 1.0),
722 )
723 })
724 .collect()
725}
726
727fn channel_stops(p: &ColorPicker, idx: usize) -> Vec<BrushStop> {
728 let h = p.hue.get_untracked();
729 let s = p.sat.get_untracked();
730 let v = p.val.get_untracked();
731 let (r, g, b) = hsv_to_rgb(h, s, v);
732 match p.model.get_untracked() {
733 0 => {
734 let (a, c) = match idx {
735 0 => (Color::rgb8(0, g, b), Color::rgb8(255, g, b)),
736 1 => (Color::rgb8(r, 0, b), Color::rgb8(r, 255, b)),
737 _ => (Color::rgb8(r, g, 0), Color::rgb8(r, g, 255)),
738 };
739 vec![BrushStop::new(0.0, a), BrushStop::new(1.0, c)]
740 }
741 1 => match idx {
742 0 => hue_stops(),
743 1 => vec![
744 BrushStop::new(0.0, Color::from_hsva(h / 360.0, 0.0, v, 1.0)),
745 BrushStop::new(1.0, Color::from_hsva(h / 360.0, 1.0, v, 1.0)),
746 ],
747 _ => vec![
748 BrushStop::new(0.0, Color::BLACK),
749 BrushStop::new(1.0, Color::from_hsva(h / 360.0, s, 1.0, 1.0)),
750 ],
751 },
752 _ => {
753 let (_, _, l) = rgb_to_hsl(r, g, b);
754 match idx {
755 0 => hue_stops(),
756 1 => vec![
757 BrushStop::new(0.0, Color::from_hsl(h, 0.0, l)),
758 BrushStop::new(1.0, Color::from_hsl(h, 1.0, l)),
759 ],
760 _ => vec![
761 BrushStop::new(0.0, Color::BLACK),
762 BrushStop::new(0.5, Color::from_hsl(h, 1.0, 0.5)),
763 BrushStop::new(1.0, Color::WHITE),
764 ],
765 }
766 }
767 }
768}
769
770#[derive(Clone)]
771struct ChanRow {
772 p: ColorPicker,
773 idx: usize,
774}
775
776impl View for ChanRow {
777 fn body(&self) -> impl View + 'static {
778 let m = self.p.model.get();
779 let _ = (self.p.hue.get(), self.p.sat.get(), self.p.val.get());
780 let act = self.p.active.get();
781 let (label, min, max, prec) = channel_meta(m, self.idx);
782 let stops = channel_stops(&self.p, self.idx);
783 let sig = self.p.ch[self.idx].clone();
784 let sig2 = sig.clone();
785 let anchored = act == Some(self.idx);
786
787 let pc = self.p.clone();
788 let idx = self.idx;
789 let track = Slider1D::new(sig, min, max)
790 .gradient(stops, Orientation::Horizontal)
791 .track_height(8.0)
792 .on_commit(move |val| {
793 pc.active.set(Some(idx));
794 pc.apply_channel(idx, val);
795 });
796
797 let pc = self.p.clone();
798 let idx = self.idx;
799 let mut value = DragValue::new(sig2)
800 .min(min)
801 .max(max)
802 .precision(prec)
803 .width(52.0)
804 .line_full(true)
805 .on_commit(move |val| {
806 pc.active.set(Some(idx));
807 pc.apply_channel(idx, val);
808 });
809 if anchored {
810 value = value.line_color(ACCENT_ACTIVE).line_width(2.0);
811 }
812
813 Row::new()
814 .gap(8.0)
815 .align_items(Align::Center)
816 .child(
817 Text::new(label)
818 .size(11.0)
819 .color(if anchored {
820 TEXT_PRIMARY
821 } else {
822 TEXT_SECONDARY
823 })
824 .width(11.0),
825 )
826 .child_flex(track, 1.0)
827 .child(value)
828 }
829}
830
831#[derive(Clone)]
832struct PreviewBox {
833 p: ColorPicker,
834}
835
836impl PreviewBox {
837 const W: f32 = 64.0;
838 const H: f32 = 24.0;
839
840 const SPLIT: f32 = Self::W * 1.6 / 2.6;
841}
842
843impl View for PreviewBox {
844 fn body(&self) -> impl View + 'static {
845 let pd = self.p.clone();
846 let pc = self.p.clone();
847 let pt = self.p.clone();
848 let armed = Rc::new(Cell::new(false));
849 let armed_up = armed.clone();
850 Container::new()
851 .width(Self::W)
852 .height(Self::H)
853 .border(1.0, HAIR_TRACK)
854 .radius(2.0)
855 .clip(true)
856 .child(
857 Canvas::new()
858 .cursor(CursorIcon::Pointer)
859 .on_draw(move |g| {
860 let new_c = pd.cur_color();
861 let ref_c = Color::parse_hex(&pd.ref_hex.get_untracked()).unwrap_or(new_c);
862 g.fill(
863 Rect::from_xywh(0.0, 0.0, Self::SPLIT + 1.0, Self::H),
864 Fill::solid(new_c),
865 );
866 g.fill(
867 Rect::from_xywh(Self::SPLIT, 0.0, Self::W - Self::SPLIT, Self::H),
868 Fill::solid(ref_c),
869 );
870 })
871 .on_pointer(
872 move |pos: Point, ev: &PointerEvent, ctx: &mut EventCtx| match ev {
873 PointerEvent::Down {
874 button: PointerButton::Left,
875 ..
876 } => {
877 armed.set(pos.x >= Self::SPLIT);
878 if armed.get() {
879 ctx.capture_pointer();
880 ctx.handled();
881 }
882 }
883 PointerEvent::Up {
884 button: PointerButton::Left,
885 ..
886 } => {
887 if armed_up.take() && pos.x >= Self::SPLIT {
888 pc.revert();
889 }
890 ctx.handled();
891 }
892 _ => {}
893 },
894 )
895 .track(move || {
896 pt.hue.get();
897 pt.sat.get();
898 pt.val.get();
899 pt.ref_hex.get();
900 }),
901 )
902 }
903}
904
905#[derive(Clone)]
906struct SwatchBox {
907 p: ColorPicker,
908 hx: String,
909}
910
911impl View for SwatchBox {
912 fn body(&self) -> impl View + 'static {
913 let _ = (self.p.hue.get(), self.p.sat.get(), self.p.val.get());
914 let selected = self.p.cur_hex() == self.hx;
915 let bg = Color::parse_hex(&self.hx).unwrap_or(Color::BLACK);
916 let pa = self.p.clone();
917 let hx = self.hx.clone();
918 let cell = Container::new()
919 .width(22.0)
920 .height(22.0)
921 .background(bg)
922 .border(
923 1.0,
924 if selected {
925 Color::rgb8(0x35, 0xC2, 0xF1)
926 } else {
927 HAIR_TRACK
928 },
929 )
930 .radius(2.0)
931 .on_click(move || pa.apply_hex(&hx));
932 let po = self.p.clone();
933 let hx_a = self.hx.clone();
934 let hx2 = self.hx.clone();
935 let hx3 = self.hx.clone();
936 let hx_d = self.hx.clone();
937 ContextMenu::new(cell)
938 .item("Zastosuj", move || po.apply_hex(&hx_a))
939 .item("Overwrite with current", {
940 let po = self.p.clone();
941 move || {
942 let cur = po.cur_hex();
943 po.swatches.update(|sw| {
944 if let Some(slot) = sw.iter_mut().find(|s| *s == &hx2) {
945 *slot = cur;
946 }
947 });
948 }
949 })
950 .item_env("Kopiuj HEX", {
951 let po = self.p.clone();
952 move |env: &Environment| {
953 let t = format!("#{hx3}");
954 match env.get::<SharedClipboard>() {
955 Some(c) => {
956 c.0.set_text(&t);
957 po.toast(format!("Copied {t}"));
958 }
959 None => po.toast("Clipboard unavailable"),
960 }
961 }
962 })
963 .item("Delete", {
964 let po = self.p.clone();
965 move || {
966 po.swatches.update(|sw| sw.retain(|s| *s != hx_d));
967 }
968 })
969 }
970}
971
972#[derive(Clone)]
973struct SwatchesView {
974 p: ColorPicker,
975}
976
977impl View for SwatchesView {
978 fn body(&self) -> impl View + 'static {
979 let sw = self.p.swatches.get();
980 let _ = (self.p.hue.get(), self.p.sat.get(), self.p.val.get());
981 let mut wrap = Wrap::new()
982 .gap(6.0)
983 .padding_xy(10.0, 0.0)
984 .padding_bottom(10.0);
985 let pa = self.p.clone();
986 wrap = wrap.child(
987 Button::icon("plus")
988 .icon_size(12.0)
989 .padding(5.0, 5.0)
990 .on_click(move || pa.sw_add()),
991 );
992 for hx in sw {
993 wrap = wrap.child(SwatchBox {
994 p: self.p.clone(),
995 hx,
996 });
997 }
998 wrap
999 }
1000}
1001
1002impl ColorPicker {
1003 pub fn panel(&self) -> impl View + 'static {
1004 self.install_effects();
1005 let p = self.clone();
1006 let title = Text::with(move || {
1007 let dirty = p.cur_hex() != p.ref_hex.get();
1008 format!("BASE COLOR{}", if dirty { " *" } else { "" })
1009 })
1010 .size(11.0)
1011 .bold()
1012 .color(TEXT_TITLE);
1013
1014 let pc = self.clone();
1015 let header = Container::new()
1016 .fill_width()
1017 .height(34.0)
1018 .background(BG_HEADER)
1019 .border_bottom(1.0, BORDER_PANEL)
1020 .padding_xy(10.0, 0.0)
1021 .child(
1022 Row::new()
1023 .fill_size()
1024 .gap(8.0)
1025 .align_items(Align::Center)
1026 .child_flex(title, 1.0)
1027 .child(
1028 Button::icon("close")
1029 .icon_size(12.0)
1030 .padding_xy(8.0, 7.0)
1031 .on_click(move || pc.close()),
1032 ),
1033 );
1034
1035 let picker_row = Row::new()
1036 .gap(8.0)
1037 .padding_xy(10.0, 10.0)
1038 .child_flex(
1039 SvPad::new(self.sat.clone(), self.val.clone(), self.hue.clone()),
1040 1.0,
1041 )
1042 .child(
1043 Slider1D::vertical(self.hue.clone(), 0.0, 360.0)
1044 .gradient(hue_stops(), Orientation::Vertical)
1045 .track_height(8.0)
1046 .width(14.0),
1047 );
1048
1049 let pp = self.clone();
1050 let hex_field = TextField::new(self.hex_text.clone())
1051 .max_length(6)
1052 .width(88.0)
1053 .height(26.0)
1054 .font_size(12.0)
1055 .padding_xy(7.0, 0.0)
1056 .background(BG_INPUT)
1057 .border(1.0, BORDER_CONTROL)
1058 .radius(3.0)
1059 .on_edit_start({
1060 let pe = self.clone();
1061 move || pe.editing_hex.set(true)
1062 })
1063 .on_edit_end({
1064 let pe = self.clone();
1065 move |t: String| {
1066 pe.editing_hex.set(false);
1067 if parse_hex(&t).is_some() {
1068 pe.apply_hex(&t);
1069 } else {
1070 pe.toast("Invalid hex");
1071 }
1072 pe.sync_channels();
1073 }
1074 })
1075 .on_submit({
1076 let pe = self.clone();
1077 move |t: String| {
1078 pe.editing_hex.set(false);
1079 if parse_hex(&t).is_some() {
1080 pe.apply_hex(&t);
1081 }
1082 pe.sync_channels();
1083 }
1084 });
1085
1086 let info_row = Row::new()
1087 .gap(8.0)
1088 .padding_x(10.0)
1089 .align_items(Align::Center)
1090 .child(PreviewBox { p: self.clone() })
1091 .child(Spacer::flex(1.0))
1092 .child(Text::new("#").size(11.0).color(TEXT_SECONDARY))
1093 .child(hex_field)
1094 .child(Button::icon("pipette").icon_size(16.0).on_click(move || {
1095 pp.toast("Eyedropper unavailable in this build (browser-only API)");
1096 }));
1097
1098 let pm = self.clone();
1099 let model_drop = DropdownButton::new(
1100 self.model.clone(),
1101 vec!["RGB".to_string(), "HSV".to_string(), "HSL".to_string()],
1102 )
1103 .font_size(12.0)
1104 .padding_xy(7.0, 3.0)
1105 .on_select(move |i| {
1106 pm.model.set(i);
1107 pm.active.set(None);
1108 pm.sync_channels();
1109 });
1110 let model_row = Row::new()
1111 .height(26.0)
1112 .padding_x(10.0)
1113 .align_items(Align::Center)
1114 .child(model_drop)
1115 .child(Spacer::flex(1.0))
1116 .child(self.color_menu());
1117
1118 let channels = Column::new()
1119 .gap(4.0)
1120 .padding_x(10.0)
1121 .child(ChanRow {
1122 p: self.clone(),
1123 idx: 0,
1124 })
1125 .child(ChanRow {
1126 p: self.clone(),
1127 idx: 1,
1128 })
1129 .child(ChanRow {
1130 p: self.clone(),
1131 idx: 2,
1132 });
1133
1134 let section = Container::new()
1135 .fill_width()
1136 .height(24.0)
1137 .background(BG_SECTION)
1138 .border_top(1.0, BORDER_HAIRLINE)
1139 .border_bottom(1.0, BORDER_HAIRLINE)
1140 .padding_xy(10.0, 0.0)
1141 .child(
1142 Row::new()
1143 .fill_size()
1144 .align_items(Align::Center)
1145 .child(Text::new("Swatches").size(11.0).color(TEXT_PRIMARY))
1146 .child(Spacer::flex(1.0))
1147 .child(self.sw_menu()),
1148 );
1149
1150 let body = Column::new()
1151 .fill_width()
1152 .gap(10.0)
1153 .child(picker_row)
1154 .child(info_row)
1155 .child(model_row)
1156 .child(channels)
1157 .child(section)
1158 .child(SwatchesView { p: self.clone() });
1159
1160 Container::new()
1161 .width(280.0)
1162 .background(BG_PANEL)
1163 .border(1.0, BORDER_PANEL)
1164 .child(Column::new().fill_width().child(header).child(body))
1165 }
1166
1167 fn color_menu(&self) -> impl View + 'static {
1168 let dots = Button::new("···").font_size(11.0).padding_xy(6.0, 2.0);
1169 let p = self.clone();
1170 FloatingMenu::wrap(dots)
1171 .below_invoker()
1172 .open_on_click()
1173 .text_row("Cofnij Ctrl+Z", {
1174 let p = p.clone();
1175 move || p.do_undo()
1176 })
1177 .text_row("Redo Ctrl+Y", {
1178 let p = p.clone();
1179 move || p.do_redo()
1180 })
1181 .separator()
1182 .text_row_env("Kopiuj HEX", {
1183 let p = p.clone();
1184 move |env: &Environment| p.copy_hex(env)
1185 })
1186 .text_row_env("Kopiuj RGB", {
1187 let p = p.clone();
1188 move |env: &Environment| p.copy_rgb(env)
1189 })
1190 .text_row_env("Kopiuj HSL", {
1191 let p = p.clone();
1192 move |env: &Environment| p.copy_hsl(env)
1193 })
1194 .separator()
1195 .text_row_env("Wklej Ctrl+Shift+V", {
1196 let p = p.clone();
1197 move |env: &Environment| p.paste(env)
1198 })
1199 .text_row("Pipeta E", {
1200 let p = p.clone();
1201 move || p.toast("Eyedropper unavailable in this build (browser-only API)")
1202 })
1203 .separator()
1204 .text_row("Ustaw jako odniesienie Ctrl+S", {
1205 let p = p.clone();
1206 move || p.set_reference()
1207 })
1208 }
1209
1210 fn sw_menu(&self) -> impl View + 'static {
1211 let dots = Button::new("···").font_size(11.0).padding_xy(6.0, 2.0);
1212 let p = self.clone();
1213 FloatingMenu::wrap(dots)
1214 .below_invoker()
1215 .open_on_click()
1216 .text_row("Add Current Color", {
1217 let p = p.clone();
1218 move || p.sw_add()
1219 })
1220 .text_row("Sortuj wg odcienia", {
1221 let p = p.clone();
1222 move || p.sw_sort()
1223 })
1224 .separator()
1225 .text_row("Restore Defaults", {
1226 let p = p.clone();
1227 move || {
1228 p.swatches
1229 .set(DEFAULT_SWATCHES.iter().map(|s| s.to_string()).collect());
1230 p.toast("Default swatches restored");
1231 }
1232 })
1233 .text_row("Clear", {
1234 let p = p.clone();
1235 move || {
1236 p.swatches.set(Vec::new());
1237 p.toast("Swatches cleared");
1238 }
1239 })
1240 }
1241
1242 pub fn floating(&self) -> PickerFloat {
1243 PickerFloat {
1244 picker: self.clone(),
1245 }
1246 }
1247}
1248
1249const PICKER_HEADER_H: f32 = 34.0;
1250const PICKER_CLOSE_W: f32 = 40.0;
1251
1252#[derive(Clone)]
1253pub struct PickerFloat {
1254 picker: ColorPicker,
1255}
1256
1257impl PickerFloat {
1258 fn picker_style(&self) -> LayoutStyle {
1259 let (x, y) = self.picker.pos.get_untracked();
1260 LayoutStyle {
1261 width: Length::Fit,
1262 height: Length::Fit,
1263 position: Position::Absolute(Inset::xy(x, y)),
1264 ..LayoutStyle::default()
1265 }
1266 }
1267}
1268
1269impl View for PickerFloat {
1270 fn body(&self) -> impl View + 'static {
1271 let _ = self.picker.pos.get();
1272 let _ = self.picker.closed.get();
1273 Unit
1274 }
1275
1276 fn children_override(&self) -> Option<Vec<AnyView>> {
1277 if self.picker.closed.get() {
1278 return Some(Vec::new());
1279 }
1280 Some(vec![AnyView::new(self.picker.panel())])
1281 }
1282
1283 fn layout_style(&self) -> LayoutStyle {
1284 self.picker_style()
1285 }
1286
1287 fn layout_style_ctx(
1288 &self,
1289 _rt: &sequoia_view::Runtime,
1290 _el: sequoia_view::ElementId,
1291 ) -> LayoutStyle {
1292 self.picker_style()
1293 }
1294
1295 fn accessibility(&self) -> Option<sequoia_view::Accessible> {
1296 Some(sequoia_view::Accessible::new(
1297 sequoia_view::AccessibleRole::Group,
1298 "Color picker",
1299 ))
1300 }
1301
1302 fn has_visual_state(&self) -> bool {
1303 true
1304 }
1305
1306 fn cursor_icon(&self, _state: VisualState) -> CursorIcon {
1307 if self.picker.drag.get().is_some() {
1308 CursorIcon::Grabbing
1309 } else {
1310 CursorIcon::Default
1311 }
1312 }
1313
1314 fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
1315 if !self.picker.is_open() {
1316 return;
1317 }
1318 match event {
1319 Event::Pointer(PointerEvent::Down {
1320 button: PointerButton::Left,
1321 pos,
1322 ..
1323 }) => {
1324 let head = Rect::from_xywh(
1325 ctx.rect.left(),
1326 ctx.rect.top(),
1327 (ctx.rect.size.width - PICKER_CLOSE_W).max(0.0),
1328 PICKER_HEADER_H.min(ctx.rect.size.height),
1329 );
1330 if head.contains(*pos) {
1331 let (cx, cy) = self.picker.pos.get_untracked();
1332 self.picker.drag.set(Some(PickerDrag {
1333 origin: *pos,
1334 start: (cx, cy),
1335 }));
1336 ctx.capture_pointer();
1337 ctx.handled();
1338 }
1339 }
1340 Event::Pointer(PointerEvent::Move { pos, .. }) => {
1341 if let Some(st) = self.picker.drag.get() {
1342 let nx = st.start.0 + (pos.x - st.origin.x);
1343 let ny = st.start.1 + (pos.y - st.origin.y);
1344 if nx.is_finite() && ny.is_finite() {
1345 let win = ctx.window_size();
1346 let max_x = (win.width - 120.0).max(0.0);
1347 let max_y = (win.height - PICKER_HEADER_H).max(0.0);
1348 self.picker
1349 .pos
1350 .set((nx.clamp(0.0, max_x), ny.clamp(0.0, max_y)));
1351 }
1352 ctx.handled();
1353 }
1354 }
1355 Event::Pointer(PointerEvent::Up {
1356 button: PointerButton::Left,
1357 ..
1358 }) => {
1359 if self.picker.drag.take().is_some() {
1360 ctx.handled();
1361 }
1362 }
1363 Event::Pointer(PointerEvent::Cancel) => {
1364 self.picker.drag.set(None);
1365 }
1366 _ => {}
1367 }
1368 }
1369}
1370
1371#[cfg(test)]
1372mod tests {
1373 use super::*;
1374
1375 #[test]
1376 fn hex_parser_is_strict_and_accepts_short_forms() {
1377 assert_eq!(parse_hex("#abc"), Some("AABBCC".to_string()));
1378 assert_eq!(parse_hex("A1b2C3"), Some("A1B2C3".to_string()));
1379 assert_eq!(parse_hex("0x112233"), Some("112233".to_string()));
1380 assert_eq!(parse_hex("11223344"), Some("112233".to_string()));
1381 assert_eq!(parse_hex("abcd"), Some("AABBCC".to_string()));
1382 assert_eq!(parse_hex("rgb(255, 0, 0)"), None);
1383 assert_eq!(parse_hex("GGGGGG"), None);
1384 assert_eq!(parse_hex("#12345"), None);
1385 assert_eq!(parse_hex(""), None);
1386 }
1387
1388 #[test]
1389 fn color_text_parser_handles_css_rgb_and_hsl() {
1390 assert_eq!(parse_color_text("#f00"), Some((255, 0, 0)));
1391 assert_eq!(parse_color_text("rgb(255, 0, 0)"), Some((255, 0, 0)));
1392 assert_eq!(parse_color_text("rgb(100%, 0%, 50%)"), Some((255, 0, 128)));
1393 assert_eq!(
1394 parse_color_text("rgba(10, 20, 30, 0.5)"),
1395 Some((10, 20, 30))
1396 );
1397 assert_eq!(parse_color_text("hsl(120, 100%, 50%)"), Some((0, 255, 0)));
1398 assert_eq!(parse_color_text("nope"), None);
1399 }
1400
1401 #[test]
1402 fn set_hex_updates_picker_and_channel_text() {
1403 let picker = ColorPicker::new();
1404 picker.install_effects();
1405 picker.install_effects();
1406 picker.set_hex("#FF0000");
1407 assert_eq!(picker.cur_hex(), "FF0000");
1408 assert_eq!(picker.hex_text.get_untracked(), "FF0000");
1409 picker.set_hex("#00ff00");
1410 assert_eq!(picker.cur_hex(), "00FF00");
1411 }
1412
1413 #[test]
1414 fn close_and_open_toggle_floating_state() {
1415 let picker = ColorPicker::new();
1416 assert!(picker.is_open());
1417 picker.close();
1418 assert!(!picker.is_open());
1419 picker.open();
1420 assert!(picker.is_open());
1421 }
1422
1423 #[test]
1424 fn floating_uses_the_position_signal() {
1425 let picker = ColorPicker::new();
1426 picker.pos_signal().set((123.0, 45.0));
1427 let style = picker.floating().picker_style();
1428 match style.position {
1429 Position::Absolute(inset) => {
1430 assert_eq!(inset.left, Some(123.0));
1431 assert_eq!(inset.top, Some(45.0));
1432 }
1433 _ => panic!("expected absolute position"),
1434 }
1435 }
1436}