1use sequoia_core::{BrushStop, Color, Fill, Length, Point, Rect};
2use sequoia_input::{CursorIcon, Event, PointerButton, PointerEvent};
3use sequoia_layout::{Align, LayoutStyle};
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: String = s.chars().filter(|c| c.is_ascii_hexdigit()).collect();
129 if clean.len() == 3 {
130 clean = clean.chars().flat_map(|c| [c, c]).collect::<String>();
131 }
132 if clean.len() == 6 {
133 Some(clean.to_uppercase())
134 } else {
135 None
136 }
137}
138
139fn hex_to_rgb(hx: &str) -> (u8, u8, u8) {
140 let n = |i: usize| u8::from_str_radix(&hx[i..i + 2], 16).unwrap_or(0);
141 (n(0), n(2), n(4))
142}
143
144#[allow(dead_code)]
145fn fmt_val(v: f32, max: f32) -> String {
146 if max == 255.0 || max == 360.0 {
147 return format!("{}", v.round() as i32);
148 }
149 let r = v.round();
150 if (v - r).abs() < 0.05 {
151 format!("{r}")
152 } else {
153 format!("{:.1}", (v * 10.0).round() / 10.0)
154 }
155}
156
157struct Rec {
158 last_hex: String,
159 last_push: Option<Instant>,
160 in_history: bool,
161}
162
163#[derive(Clone, Copy)]
164struct PickerDrag {
165 origin: Point,
166 start: (f32, f32),
167}
168
169#[derive(Clone)]
170pub struct ColorPicker {
171 hue: Signal<f32>,
172 sat: Signal<f32>,
173 val: Signal<f32>,
174 model: Signal<usize>,
175 ref_hex: Signal<String>,
176 hex_text: Signal<String>,
177 editing_hex: Signal<bool>,
178 swatches: Signal<Vec<String>>,
179 undo: Signal<Vec<String>>,
180 redo: Signal<Vec<String>>,
181 active: Signal<Option<usize>>,
182 closed: Signal<bool>,
183 ch: [Signal<f32>; 3],
184 toasts: ToastQueue,
185 rec: Rc<RefCell<Rec>>,
186 on_close: Rc<RefCell<Option<Rc<dyn Fn()>>>>,
187
188 pos: Signal<(f32, f32)>,
189 drag: Rc<Cell<Option<PickerDrag>>>,
190}
191
192pub type Picker = ColorPicker;
193
194impl Default for ColorPicker {
195 fn default() -> Self {
196 Self::new()
197 }
198}
199
200impl ColorPicker {
201 pub fn new() -> Self {
202 let (h, s, v) = (DEFAULT_H, DEFAULT_S, DEFAULT_V);
203 let (r, g, b) = hsv_to_rgb(h, s, v);
204 let start_hex = rgb_to_hex(r, g, b);
205 let p = Self {
206 hue: Signal::new(h),
207 sat: Signal::new(s),
208 val: Signal::new(v),
209 model: Signal::new(0),
210 ref_hex: Signal::new(start_hex.clone()),
211 hex_text: Signal::new(start_hex.clone()),
212 editing_hex: Signal::new(false),
213 swatches: Signal::new(DEFAULT_SWATCHES.iter().map(|s| s.to_string()).collect()),
214 undo: Signal::new(Vec::new()),
215 redo: Signal::new(Vec::new()),
216 active: Signal::new(None),
217 closed: Signal::new(false),
218 ch: [Signal::new(0.0), Signal::new(0.0), Signal::new(0.0)],
219 toasts: ToastQueue::new(),
220 rec: Rc::new(RefCell::new(Rec {
221 last_hex: start_hex,
222 last_push: None,
223 in_history: false,
224 })),
225 on_close: Rc::new(RefCell::new(None)),
226 pos: Signal::new((90.0, 70.0)),
227 drag: Rc::new(Cell::new(None)),
228 };
229 p.sync_channels();
230 p
231 }
232
233 pub fn set_hex(&self, hx: &str) {
234 self.apply_hex(hx);
235 }
236
237 pub fn hsv_signals(&self) -> (Signal<f32>, Signal<f32>, Signal<f32>) {
238 (self.hue.clone(), self.sat.clone(), self.val.clone())
239 }
240
241 pub fn toasts(&self) -> ToastQueue {
242 self.toasts.clone()
243 }
244
245 pub fn pos_signal(&self) -> Signal<(f32, f32)> {
246 self.pos.clone()
247 }
248
249 pub fn set_on_close(&self, f: impl Fn() + 'static) {
250 *self.on_close.borrow_mut() = Some(Rc::new(f));
251 }
252
253 fn close(&self) {
254 if let Some(f) = self.on_close.borrow().clone() {
255 f();
256 } else {
257 self.closed.set(true);
258 }
259 }
260
261 pub fn cur_rgb(&self) -> (u8, u8, u8) {
262 hsv_to_rgb(
263 self.hue.get_untracked(),
264 self.sat.get_untracked(),
265 self.val.get_untracked(),
266 )
267 }
268
269 pub fn cur_hex(&self) -> String {
270 let (r, g, b) = self.cur_rgb();
271 rgb_to_hex(r, g, b)
272 }
273
274 pub fn cur_color(&self) -> Color {
275 let (r, g, b) = self.cur_rgb();
276 Color::rgb8(r, g, b)
277 }
278
279 fn set_rgb(&self, r: u8, g: u8, b: u8) {
280 let (nh, ns, nv) = rgb_to_hsv(r, g, b);
281 batch(|| {
282 if nv > 0.0001 {
283 if ns > 0.0001 {
284 self.hue.set(nh);
285 }
286 self.sat.set(ns);
287 }
288 self.val.set(nv);
289 });
290 }
291
292 fn apply_hex(&self, hx: &str) {
293 if let Some(n) = parse_hex(hx) {
294 let (r, g, b) = hex_to_rgb(&n);
295 self.set_rgb(r, g, b);
296 }
297 }
298
299 fn channel_value(&self, idx: usize) -> f32 {
300 match self.model.get_untracked() {
301 0 => {
302 let (r, g, b) = self.cur_rgb();
303 match idx {
304 0 => r as f32,
305 1 => g as f32,
306 _ => b as f32,
307 }
308 }
309 1 => match idx {
310 0 => self.hue.get_untracked(),
311 1 => self.sat.get_untracked() * 100.0,
312 _ => self.val.get_untracked() * 100.0,
313 },
314 _ => {
315 let (r, g, b) = self.cur_rgb();
316 let (h, s, l) = rgb_to_hsl(r, g, b);
317 let _ = h;
318 match idx {
319 0 => self.hue.get_untracked(),
320 1 => s * 100.0,
321 _ => l * 100.0,
322 }
323 }
324 }
325 }
326
327 fn apply_channel(&self, idx: usize, v: f32) {
328 batch(|| match self.model.get_untracked() {
329 0 => {
330 let (r, g, b) = self.cur_rgb();
331 let q = clampf(v, 0.0, 255.0).round() as u8;
332 match idx {
333 0 => self.set_rgb(q, g, b),
334 1 => self.set_rgb(r, q, b),
335 _ => self.set_rgb(r, g, q),
336 }
337 }
338 1 => match idx {
339 0 => self.hue.set(v.rem_euclid(360.0)),
340 1 => self.sat.set(clampf(v, 0.0, 100.0) / 100.0),
341 _ => self.val.set(clampf(v, 0.0, 100.0) / 100.0),
342 },
343 _ => {
344 if idx == 0 {
345 self.hue.set(v.rem_euclid(360.0));
346 } else {
347 let (r, g, b) = self.cur_rgb();
348 let (hh, mut ss, mut ll) = rgb_to_hsl(r, g, b);
349 if idx == 1 {
350 ss = clampf(v, 0.0, 100.0) / 100.0;
351 } else {
352 ll = clampf(v, 0.0, 100.0) / 100.0;
353 }
354 let (nr, ng, nb) = hsl_to_rgb(hh, ss, ll);
355 self.set_rgb(nr, ng, nb);
356 }
357 }
358 });
359 }
360
361 fn sync_channels(&self) {
362 for i in 0..3 {
363 let want = self.channel_value(i);
364 if (self.ch[i].get_untracked() - want).abs() > 1e-4 {
365 self.ch[i].set(want);
366 }
367 }
368 if !self.editing_hex.get_untracked() {
369 let hx = self.cur_hex();
370 if self.hex_text.get_untracked() != hx {
371 self.hex_text.set(hx);
372 }
373 }
374 }
375
376 fn note_change(&self, new_hex: String) {
377 let mut r = self.rec.borrow_mut();
378 if r.in_history || new_hex == r.last_hex {
379 return;
380 }
381 let now = Instant::now();
382 let coalesce = r
383 .last_push
384 .map(|t| now.duration_since(t) < HIST_WINDOW)
385 .unwrap_or(false);
386 if !coalesce {
387 let prev = r.last_hex.clone();
388 self.undo.update(|u| {
389 if u.last() != Some(&prev) {
390 u.push(prev);
391 if u.len() > HIST_LIMIT {
392 u.remove(0);
393 }
394 }
395 });
396 self.redo.set(Vec::new());
397 }
398 r.last_push = Some(now);
399 r.last_hex = new_hex;
400 }
401
402 pub fn do_undo(&self) {
403 let mut u = self.undo.get_untracked();
404 let Some(prev) = u.pop() else {
405 return;
406 };
407 let cur = self.cur_hex();
408 self.redo.update(|r| r.push(cur));
409 self.undo.set(u);
410 self.rec.borrow_mut().in_history = true;
411 self.apply_hex(&prev);
412 self.sync_channels();
413 let mut r = self.rec.borrow_mut();
414 r.last_hex = prev;
415 r.last_push = None;
416 r.in_history = false;
417 }
418
419 pub fn do_redo(&self) {
420 let mut rd = self.redo.get_untracked();
421 let Some(next) = rd.pop() else {
422 return;
423 };
424 let cur = self.cur_hex();
425 self.undo.update(|u| u.push(cur));
426 self.redo.set(rd);
427 self.rec.borrow_mut().in_history = true;
428 self.apply_hex(&next);
429 self.sync_channels();
430 let mut r = self.rec.borrow_mut();
431 r.last_hex = next;
432 r.last_push = None;
433 r.in_history = false;
434 }
435
436 pub fn set_reference(&self) {
437 self.ref_hex.set(self.cur_hex());
438 self.toast("Reference color set");
439 }
440
441 fn revert(&self) {
442 let rf = self.ref_hex.get_untracked();
443 if rf == self.cur_hex() {
444 return;
445 }
446 self.apply_hex(&rf);
447 self.toast("Reverted to reference");
448 }
449
450 fn with_clip(&self, env: &Environment, f: impl FnOnce(&SharedClipboard)) -> bool {
451 match env.get::<SharedClipboard>() {
452 Some(c) => {
453 f(&c);
454 true
455 }
456 None => false,
457 }
458 }
459
460 fn toast(&self, msg: impl Into<String>) {
461 self.toasts.info(msg, "");
462 }
463
464 fn copy_hex(&self, env: &Environment) {
465 let t = format!("#{}", self.cur_hex());
466 if self.with_clip(env, |c| c.0.set_text(&t)) {
467 self.toast(format!("Copied {t}"));
468 } else {
469 self.toast("Clipboard unavailable");
470 }
471 }
472
473 fn copy_rgb(&self, env: &Environment) {
474 let (r, g, b) = self.cur_rgb();
475 let t = format!("rgb({r}, {g}, {b})");
476 if self.with_clip(env, |c| c.0.set_text(&t)) {
477 self.toast(format!("Copied {t}"));
478 } else {
479 self.toast("Clipboard unavailable");
480 }
481 }
482
483 fn copy_hsl(&self, env: &Environment) {
484 let (r, g, b) = self.cur_rgb();
485 let (h, s, l) = rgb_to_hsl(r, g, b);
486 let t = format!(
487 "hsl({}, {}%, {}%)",
488 h.round() as i32,
489 (s * 100.0).round() as i32,
490 (l * 100.0).round() as i32
491 );
492 if self.with_clip(env, |c| c.0.set_text(&t)) {
493 self.toast(format!("Copied {t}"));
494 } else {
495 self.toast("Clipboard unavailable");
496 }
497 }
498
499 fn paste(&self, env: &Environment) {
500 let Some(clip) = env.get::<SharedClipboard>() else {
501 self.toast("Clipboard access denied");
502 return;
503 };
504 let Some(txt) = clip.0.get_text() else {
505 self.toast("Clipboard access denied");
506 return;
507 };
508 let txt = txt.trim().to_string();
509 if let Some(hx) = parse_hex(&txt) {
510 self.apply_hex(&hx);
511 self.toast(format!("Pasted #{hx}"));
512 return;
513 }
514 let low = txt.to_lowercase();
515 if low.contains("rgb") {
516 let nums: Vec<u8> = low
517 .split(|c: char| !c.is_ascii_digit())
518 .filter(|s| !s.is_empty())
519 .filter_map(|s| s.parse::<u16>().ok())
520 .map(|n| n.min(255) as u8)
521 .collect();
522 if nums.len() >= 3 {
523 self.set_rgb(nums[0], nums[1], nums[2]);
524 self.toast(format!("Pasted #{}", self.cur_hex()));
525 return;
526 }
527 }
528 self.toast("No color found in clipboard");
529 }
530
531 fn sw_add(&self) {
532 let hx = self.cur_hex();
533 let mut sw = self.swatches.get_untracked();
534 if sw.contains(&hx) {
535 self.toast(format!("#{hx} is already in swatches"));
536 return;
537 }
538 sw.push(hx);
539 self.swatches.set(sw);
540 }
541
542 fn sw_sort(&self) {
543 let mut sw = self.swatches.get_untracked();
544 sw.sort_by(|a, b| {
545 let (r1, g1, b1) = hex_to_rgb(a);
546 let (r2, g2, b2) = hex_to_rgb(b);
547 let (h1, s1, v1) = rgb_to_hsv(r1, g1, b1);
548 let (h2, s2, v2) = rgb_to_hsv(r2, g2, b2);
549 let _ = (s1, v1, s2, v2);
550 let g1 = if s1 < 0.06 { 999.0 } else { h1 };
551 let g2 = if s2 < 0.06 { 999.0 } else { h2 };
552 g1.partial_cmp(&g2)
553 .unwrap_or(std::cmp::Ordering::Equal)
554 .then(v2.partial_cmp(&v1).unwrap_or(std::cmp::Ordering::Equal))
555 });
556 self.swatches.set(sw);
557 }
558
559 pub fn install_effects(&self) {
560 self.rec.borrow_mut().last_hex = self.cur_hex();
561
562 let p = self.clone();
563 let s1 = effect(move || {
564 let _ = (p.hue.get(), p.sat.get(), p.val.get(), p.model.get());
565 p.note_change(p.cur_hex());
566 p.sync_channels();
567 });
568
569 let p = self.clone();
570 let s2 = effect(move || {
571 let cur = [p.ch[0].get(), p.ch[1].get(), p.ch[2].get()];
572 let want = [p.channel_value(0), p.channel_value(1), p.channel_value(2)];
573 let eps = if p.model.get_untracked() == 0 {
574 0.51
575 } else {
576 0.02
577 };
578 let mut hit = None;
579 for i in 0..3 {
580 if (cur[i] - want[i]).abs() > eps {
581 hit = Some(i);
582 break;
583 }
584 }
585 if let Some(i) = hit {
586 p.active.set(Some(i));
587 p.apply_channel(i, cur[i]);
588 }
589 });
590
591 let p = self.clone();
592 let s3 = effect(move || {
593 let t = p.hex_text.get();
594 if t.len() == 6
595 && let Some(n) = parse_hex(&t)
596 && n != p.cur_hex()
597 {
598 p.apply_hex(&n);
599 }
600 });
601
602 {
603 let mut scope = EffectScope::new();
604 scope.extend([s1, s2, s3]);
605 scope.leak();
606 }
607 }
608}
609
610fn channel_meta(model: usize, idx: usize) -> (&'static str, f32, f32, usize) {
611 match model {
612 0 => match idx {
613 0 => ("R", 0.0, 255.0, 0),
614 1 => ("G", 0.0, 255.0, 0),
615 _ => ("B", 0.0, 255.0, 0),
616 },
617 1 => match idx {
618 0 => ("H", 0.0, 360.0, 0),
619 1 => ("S", 0.0, 100.0, 1),
620 _ => ("V", 0.0, 100.0, 1),
621 },
622 _ => match idx {
623 0 => ("H", 0.0, 360.0, 0),
624 1 => ("S", 0.0, 100.0, 1),
625 _ => ("L", 0.0, 100.0, 1),
626 },
627 }
628}
629
630fn hue_stops() -> Vec<BrushStop> {
631 (0..=6)
632 .map(|i| {
633 BrushStop::new(
634 i as f32 / 6.0,
635 Color::from_hsva(i as f32 / 6.0, 1.0, 1.0, 1.0),
636 )
637 })
638 .collect()
639}
640
641fn channel_stops(p: &ColorPicker, idx: usize) -> Vec<BrushStop> {
642 let h = p.hue.get_untracked();
643 let s = p.sat.get_untracked();
644 let v = p.val.get_untracked();
645 let (r, g, b) = hsv_to_rgb(h, s, v);
646 match p.model.get_untracked() {
647 0 => {
648 let (a, c) = match idx {
649 0 => (Color::rgb8(0, g, b), Color::rgb8(255, g, b)),
650 1 => (Color::rgb8(r, 0, b), Color::rgb8(r, 255, b)),
651 _ => (Color::rgb8(r, g, 0), Color::rgb8(r, g, 255)),
652 };
653 vec![BrushStop::new(0.0, a), BrushStop::new(1.0, c)]
654 }
655 1 => match idx {
656 0 => hue_stops(),
657 1 => vec![
658 BrushStop::new(0.0, Color::from_hsva(h / 360.0, 0.0, v, 1.0)),
659 BrushStop::new(1.0, Color::from_hsva(h / 360.0, 1.0, v, 1.0)),
660 ],
661 _ => vec![
662 BrushStop::new(0.0, Color::BLACK),
663 BrushStop::new(1.0, Color::from_hsva(h / 360.0, s, 1.0, 1.0)),
664 ],
665 },
666 _ => {
667 let (_, _, l) = rgb_to_hsl(r, g, b);
668 match idx {
669 0 => hue_stops(),
670 1 => vec![
671 BrushStop::new(0.0, Color::from_hsl(h, 0.0, l)),
672 BrushStop::new(1.0, Color::from_hsl(h, 1.0, l)),
673 ],
674 _ => vec![
675 BrushStop::new(0.0, Color::BLACK),
676 BrushStop::new(0.5, Color::from_hsl(h, 1.0, 0.5)),
677 BrushStop::new(1.0, Color::WHITE),
678 ],
679 }
680 }
681 }
682}
683
684#[derive(Clone)]
685struct ChanRow {
686 p: ColorPicker,
687 idx: usize,
688}
689
690impl View for ChanRow {
691 fn body(&self) -> impl View + 'static {
692 let m = self.p.model.get();
693 let _ = (self.p.hue.get(), self.p.sat.get(), self.p.val.get());
694 let act = self.p.active.get();
695 let (label, min, max, prec) = channel_meta(m, self.idx);
696 let stops = channel_stops(&self.p, self.idx);
697 let sig = self.p.ch[self.idx].clone();
698 let sig2 = sig.clone();
699 let anchored = act == Some(self.idx);
700
701 let pc = self.p.clone();
702 let idx = self.idx;
703 let track = Slider1D::new(sig, min, max)
704 .gradient(stops, Orientation::Horizontal)
705 .track_height(8.0)
706 .on_commit(move |val| {
707 pc.active.set(Some(idx));
708 pc.apply_channel(idx, val);
709 });
710
711 let pc = self.p.clone();
712 let idx = self.idx;
713 let mut value = DragValue::new(sig2)
714 .min(min)
715 .max(max)
716 .precision(prec)
717 .width(52.0)
718 .line_full(true)
719 .on_commit(move |val| {
720 pc.active.set(Some(idx));
721 pc.apply_channel(idx, val);
722 });
723 if anchored {
724 value = value.line_color(ACCENT_ACTIVE).line_width(2.0);
725 }
726
727 Row::new()
728 .gap(8.0)
729 .align_items(Align::Center)
730 .child(
731 Text::new(label)
732 .size(11.0)
733 .color(if anchored {
734 TEXT_PRIMARY
735 } else {
736 TEXT_SECONDARY
737 })
738 .width(11.0),
739 )
740 .child_flex(track, 1.0)
741 .child(value)
742 }
743}
744
745#[derive(Clone)]
746struct PreviewBox {
747 p: ColorPicker,
748}
749
750impl PreviewBox {
751 const W: f32 = 64.0;
752 const H: f32 = 24.0;
753
754 const SPLIT: f32 = Self::W * 1.6 / 2.6;
755}
756
757impl View for PreviewBox {
758 fn body(&self) -> impl View + 'static {
759 let pd = self.p.clone();
760 let pc = self.p.clone();
761 let pt = self.p.clone();
762 let armed = Rc::new(Cell::new(false));
763 let armed_up = armed.clone();
764 Container::new()
765 .width(Self::W)
766 .height(Self::H)
767 .border(1.0, HAIR_TRACK)
768 .radius(2.0)
769 .clip(true)
770 .child(
771 Canvas::new()
772 .cursor(CursorIcon::Pointer)
773 .on_draw(move |g| {
774 let new_c = pd.cur_color();
775 let ref_c = Color::parse_hex(&pd.ref_hex.get_untracked()).unwrap_or(new_c);
776 g.fill(
777 Rect::from_xywh(0.0, 0.0, Self::SPLIT + 1.0, Self::H),
778 Fill::solid(new_c),
779 );
780 g.fill(
781 Rect::from_xywh(Self::SPLIT, 0.0, Self::W - Self::SPLIT, Self::H),
782 Fill::solid(ref_c),
783 );
784 })
785 .on_pointer(
786 move |pos: Point, ev: &PointerEvent, ctx: &mut EventCtx| match ev {
787 PointerEvent::Down {
788 button: PointerButton::Left,
789 ..
790 } => {
791 armed.set(pos.x >= Self::SPLIT);
792 if armed.get() {
793 ctx.capture_pointer();
794 ctx.handled();
795 }
796 }
797 PointerEvent::Up {
798 button: PointerButton::Left,
799 ..
800 } => {
801 if armed_up.take() && pos.x >= Self::SPLIT {
802 pc.revert();
803 }
804 ctx.handled();
805 }
806 _ => {}
807 },
808 )
809 .track(move || {
810 pt.hue.get();
811 pt.sat.get();
812 pt.val.get();
813 pt.ref_hex.get();
814 }),
815 )
816 }
817}
818
819#[derive(Clone)]
820struct SwatchBox {
821 p: ColorPicker,
822 hx: String,
823}
824
825impl View for SwatchBox {
826 fn body(&self) -> impl View + 'static {
827 let _ = (self.p.hue.get(), self.p.sat.get(), self.p.val.get());
828 let selected = self.p.cur_hex() == self.hx;
829 let bg = Color::parse_hex(&self.hx).unwrap_or(Color::BLACK);
830 let pa = self.p.clone();
831 let hx = self.hx.clone();
832 let cell = Container::new()
833 .width(22.0)
834 .height(22.0)
835 .background(bg)
836 .border(
837 1.0,
838 if selected {
839 Color::rgb8(0x35, 0xC2, 0xF1)
840 } else {
841 HAIR_TRACK
842 },
843 )
844 .radius(2.0)
845 .on_click(move || pa.apply_hex(&hx));
846 let po = self.p.clone();
847 let hx_a = self.hx.clone();
848 let hx2 = self.hx.clone();
849 let hx3 = self.hx.clone();
850 let hx_d = self.hx.clone();
851 ContextMenu::new(cell)
852 .item("Zastosuj", move || po.apply_hex(&hx_a))
853 .item("Overwrite with current", {
854 let po = self.p.clone();
855 move || {
856 let cur = po.cur_hex();
857 po.swatches.update(|sw| {
858 if let Some(slot) = sw.iter_mut().find(|s| *s == &hx2) {
859 *slot = cur;
860 }
861 });
862 }
863 })
864 .item_env("Kopiuj HEX", {
865 let po = self.p.clone();
866 move |env: &Environment| {
867 let t = format!("#{hx3}");
868 match env.get::<SharedClipboard>() {
869 Some(c) => {
870 c.0.set_text(&t);
871 po.toast(format!("Copied {t}"));
872 }
873 None => po.toast("Clipboard unavailable"),
874 }
875 }
876 })
877 .item("Delete", {
878 let po = self.p.clone();
879 move || {
880 po.swatches.update(|sw| sw.retain(|s| *s != hx_d));
881 }
882 })
883 }
884}
885
886#[derive(Clone)]
887struct SwatchesView {
888 p: ColorPicker,
889}
890
891impl View for SwatchesView {
892 fn body(&self) -> impl View + 'static {
893 let sw = self.p.swatches.get();
894 let _ = (self.p.hue.get(), self.p.sat.get(), self.p.val.get());
895 let mut wrap = Wrap::new()
896 .gap(6.0)
897 .padding_xy(10.0, 0.0)
898 .padding_bottom(10.0);
899 let pa = self.p.clone();
900 wrap = wrap.child(
901 Button::icon("plus")
902 .icon_size(12.0)
903 .padding(5.0, 5.0)
904 .on_click(move || pa.sw_add()),
905 );
906 for hx in sw {
907 wrap = wrap.child(SwatchBox {
908 p: self.p.clone(),
909 hx,
910 });
911 }
912 wrap
913 }
914}
915
916impl ColorPicker {
917 pub fn panel(&self) -> impl View + 'static {
918 let p = self.clone();
919 let title = Text::with(move || {
920 let dirty = p.cur_hex() != p.ref_hex.get();
921 format!("BASE COLOR{}", if dirty { " *" } else { "" })
922 })
923 .size(11.0)
924 .bold()
925 .color(TEXT_TITLE);
926
927 let pc = self.clone();
928 let header = Container::new()
929 .fill_width()
930 .height(34.0)
931 .background(BG_HEADER)
932 .border_bottom(1.0, BORDER_PANEL)
933 .padding_xy(10.0, 0.0)
934 .child(
935 Row::new()
936 .fill_size()
937 .gap(8.0)
938 .align_items(Align::Center)
939 .child_flex(title, 1.0)
940 .child(
941 Button::icon("close")
942 .icon_size(12.0)
943 .padding_xy(8.0, 7.0)
944 .on_click(move || pc.close()),
945 ),
946 );
947
948 let picker_row = Row::new()
949 .gap(8.0)
950 .padding_xy(10.0, 10.0)
951 .child_flex(
952 SvPad::new(self.sat.clone(), self.val.clone(), self.hue.clone()),
953 1.0,
954 )
955 .child(
956 Slider1D::vertical(self.hue.clone(), 0.0, 360.0)
957 .gradient(hue_stops(), Orientation::Vertical)
958 .track_height(8.0)
959 .width(14.0),
960 );
961
962 let pp = self.clone();
963 let hex_field = TextField::new(self.hex_text.clone())
964 .max_length(6)
965 .width(88.0)
966 .height(26.0)
967 .font_size(12.0)
968 .padding_xy(7.0, 0.0)
969 .background(BG_INPUT)
970 .border(1.0, BORDER_CONTROL)
971 .radius(3.0)
972 .on_edit_start({
973 let pe = self.clone();
974 move || pe.editing_hex.set(true)
975 })
976 .on_edit_end({
977 let pe = self.clone();
978 move |t: String| {
979 pe.editing_hex.set(false);
980 if parse_hex(&t).is_some() {
981 pe.apply_hex(&t);
982 } else {
983 pe.toast("Invalid hex");
984 }
985 pe.sync_channels();
986 }
987 })
988 .on_submit({
989 let pe = self.clone();
990 move |t: String| {
991 pe.editing_hex.set(false);
992 if parse_hex(&t).is_some() {
993 pe.apply_hex(&t);
994 }
995 pe.sync_channels();
996 }
997 });
998
999 let info_row = Row::new()
1000 .gap(8.0)
1001 .padding_x(10.0)
1002 .align_items(Align::Center)
1003 .child(PreviewBox { p: self.clone() })
1004 .child(Spacer::flex(1.0))
1005 .child(Text::new("#").size(11.0).color(TEXT_SECONDARY))
1006 .child(hex_field)
1007 .child(Button::icon("pipette").icon_size(16.0).on_click(move || {
1008 pp.toast("Eyedropper unavailable in this build (browser-only API)");
1009 }));
1010
1011 let pm = self.clone();
1012 let model_drop = DropdownButton::new(
1013 self.model.clone(),
1014 vec!["RGB".to_string(), "HSV".to_string(), "HSL".to_string()],
1015 )
1016 .font_size(12.0)
1017 .padding_xy(7.0, 3.0)
1018 .on_select(move |i| {
1019 pm.model.set(i);
1020 pm.active.set(None);
1021 pm.sync_channels();
1022 });
1023 let model_row = Row::new()
1024 .height(26.0)
1025 .padding_x(10.0)
1026 .align_items(Align::Center)
1027 .child(model_drop)
1028 .child(Spacer::flex(1.0))
1029 .child(self.color_menu());
1030
1031 let channels = Column::new()
1032 .gap(4.0)
1033 .padding_x(10.0)
1034 .child(ChanRow {
1035 p: self.clone(),
1036 idx: 0,
1037 })
1038 .child(ChanRow {
1039 p: self.clone(),
1040 idx: 1,
1041 })
1042 .child(ChanRow {
1043 p: self.clone(),
1044 idx: 2,
1045 });
1046
1047 let section = Container::new()
1048 .fill_width()
1049 .height(24.0)
1050 .background(BG_SECTION)
1051 .border_top(1.0, BORDER_HAIRLINE)
1052 .border_bottom(1.0, BORDER_HAIRLINE)
1053 .padding_xy(10.0, 0.0)
1054 .child(
1055 Row::new()
1056 .fill_size()
1057 .align_items(Align::Center)
1058 .child(Text::new("Swatches").size(11.0).color(TEXT_PRIMARY))
1059 .child(Spacer::flex(1.0))
1060 .child(self.sw_menu()),
1061 );
1062
1063 let body = Column::new()
1064 .fill_width()
1065 .gap(10.0)
1066 .child(picker_row)
1067 .child(info_row)
1068 .child(model_row)
1069 .child(channels)
1070 .child(section)
1071 .child(SwatchesView { p: self.clone() });
1072
1073 Container::new()
1074 .width(280.0)
1075 .background(BG_PANEL)
1076 .border(1.0, BORDER_PANEL)
1077 .child(Column::new().fill_width().child(header).child(body))
1078 }
1079
1080 fn color_menu(&self) -> impl View + 'static {
1081 let dots = Button::new("···").font_size(11.0).padding_xy(6.0, 2.0);
1082 let p = self.clone();
1083 FloatingMenu::wrap(dots)
1084 .below_invoker()
1085 .open_on_click()
1086 .text_row("Cofnij Ctrl+Z", {
1087 let p = p.clone();
1088 move || p.do_undo()
1089 })
1090 .text_row("Redo Ctrl+Y", {
1091 let p = p.clone();
1092 move || p.do_redo()
1093 })
1094 .separator()
1095 .text_row_env("Kopiuj HEX", {
1096 let p = p.clone();
1097 move |env: &Environment| p.copy_hex(env)
1098 })
1099 .text_row_env("Kopiuj RGB", {
1100 let p = p.clone();
1101 move |env: &Environment| p.copy_rgb(env)
1102 })
1103 .text_row_env("Kopiuj HSL", {
1104 let p = p.clone();
1105 move |env: &Environment| p.copy_hsl(env)
1106 })
1107 .separator()
1108 .text_row_env("Wklej Ctrl+Shift+V", {
1109 let p = p.clone();
1110 move |env: &Environment| p.paste(env)
1111 })
1112 .text_row("Pipeta E", {
1113 let p = p.clone();
1114 move || p.toast("Eyedropper unavailable in this build (browser-only API)")
1115 })
1116 .separator()
1117 .text_row("Ustaw jako odniesienie Ctrl+S", {
1118 let p = p.clone();
1119 move || p.set_reference()
1120 })
1121 }
1122
1123 fn sw_menu(&self) -> impl View + 'static {
1124 let dots = Button::new("···").font_size(11.0).padding_xy(6.0, 2.0);
1125 let p = self.clone();
1126 FloatingMenu::wrap(dots)
1127 .below_invoker()
1128 .open_on_click()
1129 .text_row("Add Current Color", {
1130 let p = p.clone();
1131 move || p.sw_add()
1132 })
1133 .text_row("Sortuj wg odcienia", {
1134 let p = p.clone();
1135 move || p.sw_sort()
1136 })
1137 .separator()
1138 .text_row("Restore Defaults", {
1139 let p = p.clone();
1140 move || {
1141 p.swatches
1142 .set(DEFAULT_SWATCHES.iter().map(|s| s.to_string()).collect());
1143 p.toast("Default swatches restored");
1144 }
1145 })
1146 .text_row("Clear", {
1147 let p = p.clone();
1148 move || {
1149 p.swatches.set(Vec::new());
1150 p.toast("Swatches cleared");
1151 }
1152 })
1153 }
1154
1155 pub fn floating(&self) -> PickerFloat {
1156 PickerFloat {
1157 picker: self.clone(),
1158 }
1159 }
1160}
1161
1162const PICKER_HEADER_H: f32 = 34.0;
1163const PICKER_CLOSE_W: f32 = 40.0;
1164
1165#[derive(Clone)]
1166pub struct PickerFloat {
1167 picker: ColorPicker,
1168}
1169
1170impl View for PickerFloat {
1171 fn body(&self) -> impl View + 'static {
1172 let _ = self.picker.pos.get();
1173 Unit
1174 }
1175
1176 fn children_override(&self) -> Option<Vec<AnyView>> {
1177 Some(vec![AnyView::new(self.picker.panel())])
1178 }
1179
1180 fn layout_style(&self) -> LayoutStyle {
1181 LayoutStyle {
1182 width: Length::Fit,
1183 height: Length::Fit,
1184 ..LayoutStyle::default()
1185 }
1186 }
1187
1188 fn has_visual_state(&self) -> bool {
1189 true
1190 }
1191
1192 fn cursor_icon(&self, _state: VisualState) -> CursorIcon {
1193 if self.picker.drag.get().is_some() {
1194 CursorIcon::Grabbing
1195 } else {
1196 CursorIcon::Default
1197 }
1198 }
1199
1200 fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
1201 match event {
1202 Event::Pointer(PointerEvent::Down {
1203 button: PointerButton::Left,
1204 pos,
1205 ..
1206 }) => {
1207 let head = Rect::from_xywh(
1208 ctx.rect.left(),
1209 ctx.rect.top(),
1210 (ctx.rect.size.width - PICKER_CLOSE_W).max(0.0),
1211 PICKER_HEADER_H.min(ctx.rect.size.height),
1212 );
1213 if head.contains(*pos) {
1214 let (cx, cy) = self.picker.pos.get_untracked();
1215 self.picker.drag.set(Some(PickerDrag {
1216 origin: *pos,
1217 start: (cx, cy),
1218 }));
1219 ctx.capture_pointer();
1220 ctx.handled();
1221 }
1222 }
1223 Event::Pointer(PointerEvent::Move { pos, .. }) => {
1224 if let Some(st) = self.picker.drag.get() {
1225 let nx = st.start.0 + (pos.x - st.origin.x);
1226 let ny = st.start.1 + (pos.y - st.origin.y);
1227 if nx.is_finite() && ny.is_finite() {
1228 let win = ctx.window_size();
1229 let max_x = (win.width - 120.0).max(0.0);
1230 let max_y = (win.height - PICKER_HEADER_H).max(0.0);
1231 self.picker
1232 .pos
1233 .set((nx.clamp(0.0, max_x), ny.clamp(0.0, max_y)));
1234 }
1235 ctx.handled();
1236 }
1237 }
1238 Event::Pointer(PointerEvent::Up {
1239 button: PointerButton::Left,
1240 ..
1241 }) => {
1242 if self.picker.drag.take().is_some() {
1243 ctx.handled();
1244 }
1245 }
1246 Event::Pointer(PointerEvent::Cancel) => {
1247 self.picker.drag.set(None);
1248 }
1249 _ => {}
1250 }
1251 }
1252}