day-pieces 0.6.1

Declarative app development API using native UI toolkits
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
// Copyright © The Daybrite Project
// SPDX-License-Identifier: MPL-2.0

//! day_pieces::colorpicker is a color chooser bound two-way to a `Signal<Color>`, in two idioms
//! (docs/colorpicker.md; DESIGN.md §15).
//!
//! ```ignore
//! let tint = Signal::new(Color::hex(0xE86A3C));
//! row((
//!     vector(gv::nav_animation).tint(move || tint.get()).frame(28.0, 28.0),
//!     color_picker(tint).alpha(),              // the platform's chooser where there is one
//!     color_picker(tint).composed(),               // Day's own panel, identical everywhere
//! ))
//! ```
//!
//! Both idioms give the app the same control, a **color well**: a swatch showing the current
//! color that opens a chooser when pressed. They differ only in who draws the chooser.
//!
//! - [`PickerIdiom::Native`] realizes a native leaf: `NSColorWell` onto the shared
//!   `NSColorPanel`, `UIColorWell` onto the iOS color picker, `GtkColorDialogButton`, a swatch
//!   button onto `QColorDialog`, the XAML `ColorPicker` in a button flyout, and
//!   `<input type="color">`. Each is the system chooser, chrome and all.
//! - [`PickerIdiom::Composed`] builds the whole thing out of ordinary Day pieces: a drawn swatch
//!   opening an [`unrouted`](crate::Cover::unrouted) [`cover`] that holds a canvas
//!   saturation/brightness field, a hue strip, an opacity strip and a preset palette. Every part
//!   of it is Rust that already runs everywhere, so it is the same picker on all nine targets.
//!
//! [`PickerIdiom::Automatic`], the default, is `Native` where the toolkit has a chooser and
//! `Composed` where it does not. Two toolkits have none at any layer: Android ships no color
//! picker in the framework, in Material, or in AndroidX, and HarmonyOS has none in ArkTS or in the
//! ArkUI NDK. Rather than each of those growing a hand-written dialog in its own language, they
//! get the composed panel, which is also why an app that wants one picker everywhere can just ask
//! for it.
//!
//! The value is Day's ordinary [`Color`], so the same signal drives `.tint(…)`, `.background(…)`,
//! a canvas fill or a gradient stop with no conversion; see [color guide](https://daybrite.dev/docs/reference/color/) for what
//! that currency does and does not carry across from a native pick.
//!
//! The native leaf also accepts `Event::TextChanged` carrying any form [`Color::parse`] reads
//! (`"#e86a3c"`, `"#e86a3c80"`, `"0.91 0.42 0.24 1"`) as a synthetic pick, so dayscript's `input:`
//! step drives it on every backend.

use crate::prelude::*;
use crate::{IntoText, TextSource};
use day_core::{BuildCx, Flex, Piece, RNode, with_tree};
use day_reactive::{Binding, bind_seeded, watch};
use day_spec::{Event, Support};

pub use day_spec::controls::colorpicker::*;

/// Which chooser a [`ColorPicker`] opens.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PickerIdiom {
    /// The platform's own chooser where the toolkit has one; Day's composed panel where it does
    /// not (android-material and harmony-arkui). The default, and what an app wants unless it has a
    /// reason to pin the answer.
    #[default]
    Automatic,
    /// The platform's own chooser, on the nose: this realizes the native leaf whatever the
    /// backend is, so on a toolkit with no renderer for it the app gets Day's visible
    /// `⟨day.piece.colorpicker⟩` placeholder, the same answer any unrendered kind gives, and a
    /// gap a screenshot can see. Pin this only behind a [`support`] check; if you want "native
    /// where there is one", that is [`Automatic`](PickerIdiom::Automatic).
    Native,
    /// Day's composed panel, on every target. Ask for this when one identical color experience
    /// matters more than platform chrome: a design tool, a themed app, a branded editor.
    Composed,
}

/// Whether the compiled backend has a native color chooser, which is what
/// [`PickerIdiom::Automatic`] resolves against.
///
/// [`Support::Native`] on appkit, uikit, gtk, qt, xaml and web-dom. [`Support::Emulated`] on
/// android-material and harmony-arkui, where `Automatic` gives the composed panel instead.
///
/// This does not report whether the picker works (there is no target where it does not), so an
/// app showing a "not supported here" banner from this answer would be wrong. Use it to say
/// *which* picker the user gets, or ignore it, unless the app pins [`PickerIdiom::Native`]:
/// that realizes the leaf unconditionally and so does need this checked first.
pub fn support() -> day_spec::Support {
    let support = day_core::capability(day_spec::Cap::ColorPicker);
    if support == day_spec::Support::Unsupported {
        day_spec::Support::Emulated
    } else {
        support
    }
}

/// The palette the composed panel offers when the app names none: one hue ring at full
/// saturation, plus the grayscale ends. Ordered by hue so the row reads as a spectrum.
fn default_presets() -> Vec<Color> {
    let mut v: Vec<Color> = (0..12)
        .map(|i| Color::hsv(i as f64 * 30.0, 0.78, 0.92))
        .collect();
    v.push(Color::WHITE);
    v.push(Color::rgb(0.6, 0.6, 0.62));
    v.push(Color::BLACK);
    v
}

/// A color well bound two-way to a [`Color`] signal. Build with [`color_picker`].
pub struct ColorPicker<C: Binding<Color>> {
    color: C,
    alpha: bool,
    idiom: PickerIdiom,
    title: Option<TextSource>,
    presets: Option<Vec<Color>>,
    key: day_core::ElementId,
}

/// `color_picker(color)` is a swatch showing `color` that opens a color chooser. `color` is a
/// `Signal<Color>` or any other two-way binding (a day-model `Field`, mapped).
pub fn color_picker<C: Binding<Color>>(color: C) -> ColorPicker<C> {
    // web-dom's registry is populated at runtime (no `linkme` on wasm), and a constructor always
    // runs before the node it returns is realized, so this is where the arm registers itself.
    ColorPicker {
        color,
        alpha: false,
        idiom: PickerIdiom::Automatic,
        title: None,
        presets: None,
        key: day_core::id!(color_picker),
    }
}

#[crate::builder(ColorPickerBuilder)]
impl<C: Binding<Color>> ColorPicker<C> {
    /// Offer an opacity channel, and let the bound signal carry a non-opaque alpha. Honored by
    /// the composed panel everywhere, and by every native chooser but one: a browser's
    /// `<input type="color">` gained an `alpha` attribute only recently, so the web arm sets it
    /// and stays opaque where the browser has not shipped it (docs/colorpicker.md).
    pub fn alpha(mut self) -> Self {
        self.alpha = true;
        self
    }
    /// Which chooser this well opens (see [`PickerIdiom`]).
    pub fn idiom(mut self, idiom: PickerIdiom) -> Self {
        self.idiom = idiom;
        self
    }
    /// Pin this well to the platform's chooser, [`PickerIdiom::Native`].
    pub fn native(self) -> Self {
        self.idiom(PickerIdiom::Native)
    }
    /// Pin this well to Day's composed panel, [`PickerIdiom::Composed`].
    pub fn composed(self) -> Self {
        self.idiom(PickerIdiom::Composed)
    }
    /// The chooser's heading. Native: the iOS picker's navigation title, the GTK dialog heading,
    /// `QColorDialog`'s window title, the XAML button's tooltip; AppKit sets the shared color
    /// panel's title. Composed: the panel's own heading. A constant, `Signal<String>` or closure,
    /// read once at build.
    pub fn title<M>(mut self, t: impl IntoText<M>) -> Self {
        self.title = Some(t.into_text());
        self
    }
    /// The swatch row the composed panel offers below its strips (ignored by the native
    /// choosers, which each have their own palette). Pass an empty vector to drop the row.
    pub fn presets(mut self, presets: Vec<Color>) -> Self {
        self.presets = Some(presets);
        self
    }
    /// The composed well's dayscript id (default `"color-picker"`).
    ///
    /// It goes here rather than on `Decorate::id` because what an app can reach from outside is
    /// the layout wrapper the piece returns, and an id on that tags a node no toolkit realizes;
    /// a `tap:` against it resolves to nothing while every step still reports ✓. Give two
    /// composed pickers on one page different keys so a script can tell them apart.
    ///
    /// The panel's own parts carry fixed ids (`color-picker-panel`, `-shade`, `-hue`,
    /// `-opacity`, `-presets`, `-value`, `-cancel`, `-done`); only one panel is ever open, so
    /// they need no disambiguation.
    pub fn id(mut self, id: day_core::ElementId) -> Self {
        self.key = id;
        self
    }
}

impl<C: Binding<Color>> Piece for ColorPicker<C> {
    fn build(self, cx: &mut BuildCx) -> RNode {
        let ColorPicker {
            color,
            alpha,
            idiom,
            title,
            presets,
            key,
        } = self;
        let title = title.map(|t| t.initial()).unwrap_or_default();
        let native = match idiom {
            PickerIdiom::Composed => false,
            PickerIdiom::Native => true,
            PickerIdiom::Automatic => support() == Support::Native,
        };
        if native {
            build_native(cx, color, alpha, title, key)
        } else {
            composed_well(
                color,
                alpha,
                title,
                presets.unwrap_or_else(default_presets),
                key,
            )
            .build(cx)
        }
    }
}

/// The native idiom: one leaf of [`KIND`], bound two-way.
fn build_native<C: Binding<Color>>(
    cx: &mut BuildCx,
    color: C,
    alpha: bool,
    title: String,
    key: day_core::ElementId,
) -> RNode {
    let initial = color.peek();
    let node = cx.leaf(
        KIND,
        &ColorProps {
            color: initial,
            alpha,
            title,
        },
        Flex::default(),
    );
    // The leaf's dayscript id: one `.key` serves both idioms (the day-piece-stepper rule:
    // `Decorate::id` on the piece would tag a wrapper no toolkit realizes).
    with_tree(|t| t.set_id(node, key.as_str().to_owned()));
    // App writes → the native well. Every arm no-ops on an unchanged value, so a pick echoing
    // back through the signal never loops.
    let c2 = color.clone();
    bind_seeded(
        initial,
        move || c2.read(),
        move |c: &Color| {
            with_tree(|t| t.patch(node, Box::new(ColorPatch::SetColor(*c)), false));
        },
    );
    // Native picks (`Custom`, carrying the lossless component form) and dayscript's `input:` step
    // (`TextChanged`, carrying whatever a human typed) → the signal. Both go through
    // `Color::parse`, which reads hex and components alike, so there is one decode path.
    cx.on(node, move |ev| {
        let picked = match ev {
            Event::Custom { text, .. } => Color::parse(text),
            Event::TextChanged(s) => Color::parse(s),
            _ => None,
        };
        if let Some(c) = picked {
            // An opaque-only picker must not be able to clear the alpha the app set.
            color.write(if alpha { c } else { c.with_alpha(1.0) });
        }
    });
    node
}

// ===========================================================================
// The composed idiom: ordinary Day pieces, no native code, identical on all nine targets.
// ===========================================================================

/// The saturation/brightness field, in points. Fixed rather than fluid: a picker panel is a
/// fixed-size object on every platform that has one, and a known size is what lets a press
/// location become a value without the canvas having to report its own bounds first.
const FIELD_W: f64 = 264.0;
const FIELD_H: f64 = 160.0;
/// The shade field's corner rounding. Drawn inside the canvas rather than asked of the toolkit
/// as a `corner_radius`, which is a view-level outline clip: on Android that is
/// `setClipToOutline`, the one thing this panel does that nothing else in the app does, and a
/// clip a canvas can apply to its own drawing costs the toolkit nothing on any target.
const FIELD_R: f64 = 10.0;
/// The hue and opacity strips.
const STRIP_H: f64 = 20.0;
/// One preset swatch.
const SWATCH: f64 = 26.0;
/// The panel card. `FIELD_W` plus its padding on both sides.
const CARD_PAD: f64 = 18.0;
const CARD_W: f64 = FIELD_W + CARD_PAD * 2.0;

/// The panel is a neutral dark surface on every target, light appearance included, and its text
/// color is stated rather than inherited.
///
/// A bright surround biases color judgment (the reason every serious color tool sits its swatches
/// on a dark neutral), so a picker that flipped to a white card in light mode would make the same
/// pick look like a different color. And a card whose fill this piece chose cannot then take the
/// platform's label color for its text: on a light-appearance device that resolves to black, which
/// is what put dark-on-dark text in the first iOS screenshot of this panel.
const PANEL_SURFACE: Color = Color::rgb(0.13, 0.14, 0.17);
const PANEL_TEXT: Color = Color::rgb(0.93, 0.94, 0.96);

/// The well, in points: wide enough for its caption at the caption size: `#rrggbbaa` when the
/// picker carries alpha, `#rrggbb` when it does not, since a row beside a slider in a narrow
/// inspector has no width to spare.
fn well_width(alpha: bool) -> f64 {
    if alpha { 96.0 } else { 72.0 }
}
const WELL_H: f64 = 26.0;

/// The well itself: a drawn swatch showing the current color and its hex, which presents the
/// panel when pressed.
///
/// Drawn rather than a `button(hex).tint(color)`, even though a tinted button is native and
/// carries press feedback and focus with no extra work. What the composed idiom promises is one
/// picker, identical on every target, and a tinted button is the opposite of identical: AppKit
/// composites the color through the bezel, Material draws a filled container with its own
/// elevation, GTK and Qt apply it through their themes, and the web takes CSS. The color would
/// read differently on all nine. A canvas draws the color the app asked for.
///
/// The trade is keyboard activation: a drawn well takes a press, not a Return key. It carries a
/// `Button` role and a label so a screen reader still announces it correctly
/// (docs/colorpicker.md records the gap).
fn composed_well<C: Binding<Color>>(
    color: C,
    alpha: bool,
    title: String,
    presets: Vec<Color>,
    key: day_core::ElementId,
) -> AnyPiece {
    let open: Signal<Option<String>> = Signal::new(None);
    let open_key = key.as_str().to_owned();
    // The id goes on the canvas, here, rather than being left to the app: what the app can reach
    // is the `zstack` this returns, and an id on that tags a layout wrapper the toolkit never
    // realizes; a dayscript `tap:` against it would resolve to nothing while every step still
    // reported ✓. The route key doubles as the id, so one name identifies the well both ways.
    let cw = color.clone();
    let well = canvas(move |d, size| {
        let c = cw.read();
        let r = Rect::new(0.0, 0.0, size.width, size.height);
        if c.a < 1.0 {
            checkerboard(d, size);
        }
        d.fill(Shape::RoundedRect(r, 7.0), c);
        d.stroke(
            Shape::RoundedRect(r.inset(0.5), 7.0),
            Color::BLACK.with_alpha(0.25),
            1.0,
        );
        d.text(
            &c.to_hex_string(),
            Point::new(size.width / 2.0, size.height / 2.0),
            TextStyle {
                size: 12.0,
                // Day's own readable-on-a-fill rule, the one `Button::tint` uses.
                color: day_spec::props::ButtonStyleSpec::on_tint(c),
                anchor: TextAnchor::CENTERED,
                ..Default::default()
            },
        );
    })
    .on_tap(move || open.set(Some(open_key.clone())))
    .a11y(|a| {
        a.role(Role::Button)
            .label(day_localization::t(day_localization::key::day_color))
    })
    .id(key.clone())
    .frame(well_width(alpha), WELL_H);
    zstack((
        well,
        cover(open, move |_| {
            panel(color.clone(), alpha, title.clone(), presets.clone(), open)
        })
        // The panel's ground. `cover` paints this edge-to-edge (under the status bar and home
        // indicator); the emulated tier forces it opaque, so a dim scrim would read as flat gray
        // on six of the nine targets. A near-black ground that the card sits on reads the same
        // everywhere instead.
        .background(|_: &String| Color::rgba(0.06, 0.07, 0.09, 0.92))
        // Kept out of the app's route space. A routed cover over the untyped `String` route
        // claims every segment (`String::from_key` accepts anything), so mounting this picker
        // would have made the host app's next `navigate("settings")` present a color panel keyed
        // "settings" instead of going to settings. A chooser is not a destination; the piece
        // opens and closes it, and Android's system back still dismisses it (that path is the
        // cover's own `NavBack` handler, not the route adapter).
        .unrouted(),
    ))
    .any()
}

/// The presented panel: the card, centered on the cover's surface.
fn panel<C: Binding<Color>>(
    color: C,
    alpha: bool,
    title: String,
    presets: Vec<Color>,
    open: Signal<Option<String>>,
) -> AnyPiece {
    // The panel's state is HSV, not the bound color. Deriving hue from RGB on every
    // change would lose it the moment brightness reached zero (black has no hue), so the sliders
    // would jump back to red as the user dragged into the corner. Seeded once per presentation.
    let entry = color.peek();
    let (h0, s0, v0) = entry.to_hsv();
    let hue = Signal::new(h0);
    let sat = Signal::new(s0);
    let val = Signal::new(v0);
    let opacity = Signal::new(entry.a);

    // HSV → the bound color, live: the app sees the tint move as the user drags, the same as
    // every native chooser reports continuously. `watch` skips the initial run, so opening the
    // panel does not rewrite the signal with a round-tripped copy of what it already holds.
    watch(
        move || {
            Color::hsva(
                hue.get(),
                sat.get(),
                val.get(),
                if alpha { opacity.get() } else { 1.0 },
            )
        },
        {
            let color = color.clone();
            move |c: &Color, _| color.write(*c)
        },
    );

    let current = move || {
        Color::hsva(
            hue.get(),
            sat.get(),
            val.get(),
            if alpha { opacity.get() } else { 1.0 },
        )
    };

    let close = move || open.set(None);
    let cancel = move || {
        color.write(entry);
        open.set(None);
    };

    let mut rows: Vec<AnyPiece> = Vec::new();
    if !title.is_empty() {
        rows.push(label(title).font(Font::Headline).color(PANEL_TEXT).any());
    }
    rows.push(shade_field(hue, sat, val));
    rows.push(hue_strip(hue));
    if alpha {
        rows.push(opacity_strip(current, opacity));
    }
    rows.push(readout(current));
    if !presets.is_empty() {
        rows.push(preset_row(presets, hue, sat, val, opacity, alpha));
    }
    rows.push(
        row((
            button(day_localization::t(day_localization::key::day_cancel))
                .action(cancel)
                .id(day_core::id!(color_picker_cancel)),
            spacer(),
            button(day_localization::t(day_localization::key::day_done))
                .action(close)
                .id(day_core::id!(color_picker_done)),
        ))
        .grow_w()
        .any(),
    );

    let card = column(rows)
        .spacing(12.0)
        .align(HAlign::Leading)
        .padding(CARD_PAD)
        .background(PANEL_SURFACE)
        .corner_radius(16.0)
        .width(CARD_W)
        .id(day_core::id!(color_picker_panel));

    // Centered both ways: spacers above and below push it to the middle vertically, and the
    // column's own cross-axis alignment centers it horizontally.
    //
    // Not `row((spacer(), card, spacer()))` for the horizontal half, which is the obvious
    // spelling and the wrong one: a cover lays its content out once before the backend reports
    // the surface's size, so that row measures a 300pt card against a 0pt width and Day's
    // overflow diagnostic fires, naming this panel's ids on every single open. A cross-aligned
    // column has nothing to overflow.
    column((spacer(), card, spacer()))
        .align(HAlign::Center)
        .grow()
        .any()
}

/// The saturation/brightness field for the current hue: the pure hue, washed to white across and
/// to black down. Three fills, which is exactly how every native picker draws the same square,
/// and it stays crisp at any size because two of them are gradients rather than a bitmap.
fn shade_field(hue: Signal<f64>, sat: Signal<f64>, val: Signal<f64>) -> AnyPiece {
    let pick = move |p: Point| {
        sat.set((p.x / FIELD_W).clamp(0.0, 1.0));
        val.set(1.0 - (p.y / FIELD_H).clamp(0.0, 1.0));
    };
    canvas(move |d, size| {
        let r = Rect::new(0.0, 0.0, size.width, size.height);
        d.clipped(Shape::RoundedRect(r, FIELD_R), |d| {
            d.fill(Shape::Rect(r), Color::hsv(hue.get(), 1.0, 1.0));
            d.fill(
                Shape::Rect(r),
                LinearGradient::horizontal(Color::WHITE, Color::WHITE.with_alpha(0.0)),
            );
            d.fill(
                Shape::Rect(r),
                LinearGradient::vertical(Color::BLACK.with_alpha(0.0), Color::BLACK),
            );
            marker(
                d,
                Point::new(sat.get() * size.width, (1.0 - val.get()) * size.height),
                7.0,
            );
        });
    })
    // Gestures go on the canvas, before any wrapper: `Event::Tap`/`Event::Drag` report a point in
    // the node's own space, and a wrapper's space is not the canvas's. Both are wired because a
    // press that never moves is a tap on some backends and a zero-length drag on others; they
    // write the same two values, so a backend that reports both costs nothing.
    .on_drag(move |drag| pick(drag.location))
    .on_tap_at(pick)
    .a11y(|a| a.label(day_localization::t(day_localization::key::day_color_shade)))
    // The id before the frame, so it names the canvas the gestures live on.
    .id(day_core::id!(color_picker_shade))
    .frame(FIELD_W, FIELD_H)
    .any()
}

/// The hue strip: one linear gradient through the six primaries and back to red.
fn hue_strip(hue: Signal<f64>) -> AnyPiece {
    let pick = move |p: Point| hue.set((p.x / FIELD_W).clamp(0.0, 1.0) * 360.0);
    canvas(move |d, size| {
        let r = Rect::new(0.0, 0.0, size.width, size.height);
        let stops: Vec<(f64, Color)> = (0..=6)
            .map(|i| (i as f64 / 6.0, Color::hsv(i as f64 * 60.0, 1.0, 1.0)))
            .collect();
        d.fill(
            Shape::RoundedRect(r, size.height / 2.0),
            LinearGradient::new(UnitPoint::LEADING, UnitPoint::TRAILING, stops),
        );
        slider_thumb(d, hue.get() / 360.0, size);
    })
    .on_drag(move |drag| pick(drag.location))
    .on_tap_at(pick)
    .a11y(|a| a.label(day_localization::t(day_localization::key::day_color_hue)))
    // The id before the frame, so it names the canvas the gestures live on.
    .id(day_core::id!(color_picker_hue))
    .frame(FIELD_W, STRIP_H)
    .any()
}

/// The opacity strip: the current color faded across, over the checkerboard that is the only way
/// to tell "transparent" from "white" on a light ground.
fn opacity_strip(current: impl Fn() -> Color + 'static, opacity: Signal<f64>) -> AnyPiece {
    let pick = move |p: Point| opacity.set((p.x / FIELD_W).clamp(0.0, 1.0));
    canvas(move |d, size| {
        let r = Rect::new(0.0, 0.0, size.width, size.height);
        // The checkerboard is the one thing on a strip that has to be CLIPPED: it is a grid of
        // squares and the pill's caps have to cut it. Everything over it is a rounded fill of
        // that same pill, which needs no clip of its own.
        d.clipped(Shape::RoundedRect(r, size.height / 2.0), |d| {
            checkerboard(d, size)
        });
        let opaque = current().with_alpha(1.0);
        d.fill(
            Shape::RoundedRect(r, size.height / 2.0),
            LinearGradient::horizontal(opaque.with_alpha(0.0), opaque),
        );
        slider_thumb(d, opacity.get(), size);
    })
    .on_drag(move |drag| pick(drag.location))
    .on_tap_at(pick)
    .a11y(|a| {
        a.label(day_localization::t(
            day_localization::key::day_color_opacity,
        ))
    })
    // The id before the frame, so it names the canvas the gestures live on.
    .id(day_core::id!(color_picker_opacity))
    .frame(FIELD_W, STRIP_H)
    .any()
}

/// A wrapping row of preset swatches. `RowFit::Wrap` rather than a grid: the palette is however
/// many colors the app passed, and the row is however wide the card is (docs/size-classes.md).
fn preset_row(
    presets: Vec<Color>,
    hue: Signal<f64>,
    sat: Signal<f64>,
    val: Signal<f64>,
    opacity: Signal<f64>,
    alpha: bool,
) -> AnyPiece {
    let swatches: Vec<AnyPiece> = presets
        .into_iter()
        .map(|c| {
            canvas(move |d, size| {
                let r = Rect::new(0.0, 0.0, size.width, size.height);
                if c.a < 1.0 {
                    checkerboard(d, size);
                }
                d.fill(Shape::RoundedRect(r, 6.0), c);
                d.stroke(
                    Shape::RoundedRect(r.inset(0.5), 6.0),
                    Color::WHITE.with_alpha(0.22),
                    1.0,
                );
            })
            .on_tap(move || {
                let (h, s, v) = c.to_hsv();
                hue.set(h);
                sat.set(s);
                val.set(v);
                if alpha {
                    opacity.set(c.a);
                }
            })
            .frame(SWATCH, SWATCH)
            .any()
        })
        .collect();
    row(swatches)
        .spacing(6.0)
        .fit(RowFit::Wrap { run_spacing: 6.0 })
        .grow_w()
        .id(day_core::id!(color_picker_presets))
        .any()
}

/// The current color, large, over a checkerboard, with its hex beside it.
fn readout(current: impl Fn() -> Color + Copy + 'static) -> AnyPiece {
    row((
        canvas(move |d, size| {
            checkerboard(d, size);
            d.fill(
                Shape::RoundedRect(Rect::new(0.0, 0.0, size.width, size.height), 7.0),
                current(),
            );
        })
        .frame(44.0, 28.0),
        label(move || current().to_hex_string())
            .monospace()
            .color(PANEL_TEXT)
            .id(day_core::id!(color_picker_value)),
    ))
    .spacing(10.0)
    .align(VAlign::Center)
    .any()
}

/// The ring that marks the picked point in the shade field: white over a dark halo, so it stays
/// visible against both ends of the field it sits on.
fn marker(d: &mut Draw, at: Point, radius: f64) {
    let ring = |r: f64| Shape::Ellipse(Rect::new(at.x - r, at.y - r, r * 2.0, r * 2.0));
    d.stroke(ring(radius + 1.0), Color::BLACK.with_alpha(0.45), 3.0);
    d.stroke(ring(radius), Color::WHITE, 2.0);
}

/// The thumb on a strip: a full-height capsule, so it reads at 20 points tall. `t` is the value
/// it marks, as a fraction of the track.
///
/// Its center is held half a thumb-width inside each end, because the strips draw their gradient
/// as a rounded fill rather than a rect behind a rounded clip, so nothing would cut a thumb that
/// ran off the cap. Clipping is what used to hide it, at the cost of showing half a thumb at
/// either extreme; holding it whole reads better and asks the rasterizer for one shape fewer.
fn slider_thumb(d: &mut Draw, t: f64, size: Size) {
    const W: f64 = 8.0;
    // A cover lays its content out once before the surface reports its size, so a strip is drawn
    // at zero width first (the same first pass `panel` centers its card around). The travel has
    // to collapse to the middle there rather than invert: `clamp` panics on crossed bounds, and
    // that panic is contained but leaves the whole panel half-built.
    let half = W / 2.0;
    let (lo, hi) = if size.width >= W {
        (half, size.width - half)
    } else {
        (size.width / 2.0, size.width / 2.0)
    };
    let x = (t.clamp(0.0, 1.0) * size.width).clamp(lo, hi);
    let r = Rect::new(x - W / 2.0, 0.0, W, size.height);
    d.fill(Shape::RoundedRect(r, W / 2.0), Color::WHITE);
    // The inset rect is a thumb-width minus one, so its corner radius has to come down with it:
    // the old 4.0 against a 7.0-wide rect asked for radii wider than the shape they round.
    d.stroke(
        Shape::RoundedRect(r.inset(0.5), W / 2.0 - 0.5),
        Color::BLACK.with_alpha(0.4),
        1.0,
    );
}

/// The transparency checkerboard every color tool draws behind a partly transparent swatch.
fn checkerboard(d: &mut Draw, size: Size) {
    const CELL: f64 = 6.0;
    d.fill(
        Shape::Rect(Rect::new(0.0, 0.0, size.width, size.height)),
        Color::rgb(0.86, 0.86, 0.88),
    );
    let cols = (size.width / CELL).ceil() as i64;
    let rows = (size.height / CELL).ceil() as i64;
    for row_i in 0..rows {
        for col in 0..cols {
            if (row_i + col) % 2 == 0 {
                continue;
            }
            let x = col as f64 * CELL;
            let y = row_i as f64 * CELL;
            d.fill(
                Shape::Rect(Rect::new(
                    x,
                    y,
                    CELL.min(size.width - x),
                    CELL.min(size.height - y),
                )),
                Color::rgb(0.66, 0.66, 0.69),
            );
        }
    }
}

// ---------------------------------------------------------------------------
// Per-toolkit native renderers, one file per backend, for the six toolkits that have a color
// chooser. Every module registers a `Renderer` into its backend's `RENDERERS` slice link-time;
// `dom` registers at runtime from `color_picker`. android-material and harmony-arkui carry no arm at
// all: the composed panel above is their picker.
// ---------------------------------------------------------------------------

// --- Typed builders, forwarded through `Decorated` (docs/api-style.md) ---

/// The color picker's conformance cases (docs/testing.md): the composed panel, identical on
/// every toolkit.
#[cfg(feature = "conformance")]
pub mod conformance {
    use crate::*;
    use day_core::conformance::{Case, Drive};
    use day_reactive::Signal;
    use day_spec::Color;

    use super::{KIND, color_picker};

    fn hex(c: Color) -> String {
        let ch = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
        format!("#{:02x}{:02x}{:02x}", ch(c.r), ch(c.g), ch(c.b))
    }

    /// The well opens the panel on the current color; a press in the shade field's white corner
    /// picks white, and Done writes it back.
    #[day_macros::test(day_core)]
    fn colorpicker_composed_picks() -> Case {
        Case::new()
            .proves(KIND)
            .page(|| {
                let tint = Signal::new(Color::rgb(232.0 / 255.0, 106.0 / 255.0, 60.0 / 255.0));
                column((
                    label(move || format!("tint {}", hex(tint.get())))
                        .id(day_core::id!(colorpicker::tint_value)),
                    color_picker(tint)
                        .composed()
                        .id(day_core::id!(colorpicker::tint)),
                ))
            })
            .drive(|d: Drive| async move {
                d.tap("colorpicker::tint").await?;
                d.wait_idle().await?;
                d.assert_visible("color_picker_panel").await?;
                d.assert_text("color_picker_value", "#e86a3c").await?;
                d.tap_at("color_picker_shade", 0.0, 0.0).await?;
                d.assert_text("color_picker_value", "#ffffff").await?;
                d.tap("color_picker_done").await?;
                d.wait_idle().await?;
                d.assert_text("colorpicker::tint_value", "tint #ffffff")
                    .await
            })
    }
}