Skip to main content

blockworx_web/
settings.rs

1//! The preferences, as the sidebar's Settings section.
2//!
3//! Four choices and nothing else, each shown as what it does: which end of the
4//! light/dark axis the page is drawn at, which base16 scheme the diagram is
5//! painted in — each card drawing a block and a wire in that scheme's own
6//! colours — which face it is lettered in, each card lettered in it, and who
7//! its commits are attributed to.
8
9use blockworx_canvas2d::css_color;
10use blockworx_paint::palette::Base;
11use blockworx_paint::{FontChoice, Luminance, Mode, Scheme};
12use dioxus::prelude::*;
13
14use crate::control::{Face, Pressable};
15use crate::icons;
16use crate::mode::Prefers;
17use crate::prefs::Preferences;
18use crate::shell::Shell;
19
20const HEADING: &str = "text-xs font-medium uppercase tracking-wide text-zinc-500 \
21     dark:text-zinc-400";
22
23/// The mode switch's cells: the taken one raised off the track.
24const SEGMENT: &str = "flex h-[34px] flex-1 items-center justify-center gap-1.5 rounded-[9px] \
25     text-[13px] text-zinc-500 transition-colors ease-out hover:text-zinc-800 \
26     dark:text-zinc-400 dark:hover:text-zinc-200 [&_svg]:size-4";
27const TAKEN_SEGMENT: &str = "flex h-[34px] flex-1 items-center justify-center gap-1.5 \
28     rounded-[9px] bg-white text-[13px] font-medium text-zinc-900 shadow-sm \
29     dark:bg-zinc-600 dark:text-zinc-100 dark:shadow-none dark:ring-1 dark:ring-white/10 \
30     [&_svg]:size-4";
31
32/// A choice shown as a card: a sample of what it does over its name.
33const CARD: &str = "rounded-xl border border-zinc-200 bg-white p-1.5 transition-colors ease-out \
34     hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-800 dark:hover:border-zinc-600";
35const TAKEN_CARD: &str = "rounded-xl border-2 border-sky-500 bg-white p-[5px] dark:bg-zinc-800";
36
37/// The mark on the taken card.
38const TICK: &str = "ml-auto grid place-items-center text-sky-600 dark:text-sky-400 \
39     [&_svg]:size-3.5 [&_svg]:stroke-[2.5]";
40
41/// Whether a choice is the one the preferences hold.
42#[derive(Clone, Copy, PartialEq, Eq, Debug)]
43enum Holding {
44    Taken,
45    Offered,
46}
47
48impl Holding {
49    fn card(self) -> &'static str {
50        match self {
51            Holding::Taken => TAKEN_CARD,
52            Holding::Offered => CARD,
53        }
54    }
55
56    fn of<T: PartialEq + Copy>(choice: T, taken: T) -> Self {
57        if choice == taken {
58            Holding::Taken
59        } else {
60            Holding::Offered
61        }
62    }
63}
64
65#[component]
66pub fn Settings(shell: Shell, prefs: Signal<Preferences>, prefers: Signal<Prefers>) -> Element {
67    // The cards sample the schemes at the end of the axis the page is drawn
68    // at, worked out from the same two facts the page itself is: what the
69    // preferences say and what the browser prefers. Reading the theme the
70    // diagram is wearing would be a fact of the editor rather than of a
71    // signal, and the cards would keep the old end of the axis until
72    // something else re-rendered them.
73    let luminance = prefs.read().luminance(prefers());
74    let held = prefs.read().clone();
75    rsx! {
76        div { class: "flex flex-col gap-5",
77                Section { heading: "Appearance",
78                    div { class: "flex gap-0.5 rounded-xl bg-zinc-950/5 p-[3px] dark:bg-white/5",
79                        for mode in Mode::ALL {
80                            ModeCell { key: "{mode:?}", mode, holding: Holding::of(mode, held.mode), prefs }
81                        }
82                    }
83                }
84                Section { heading: "Diagram colours",
85                    div { class: "grid grid-cols-2 gap-2",
86                        for scheme in Scheme::ALL {
87                            SchemeCard {
88                                key: "{scheme:?}",
89                                scheme,
90                                luminance,
91                                holding: Holding::of(scheme, held.scheme),
92                                prefs,
93                            }
94                        }
95                    }
96                }
97                Section { heading: "Lettering",
98                    div { class: "grid grid-cols-2 gap-2",
99                        for font in FontChoice::ALL {
100                            FontCard { key: "{font:?}", font, holding: Holding::of(font, held.font), prefs }
101                        }
102                    }
103                }
104                Profile { shell, prefs }
105        }
106    }
107}
108
109/// One heading and what sits under it.
110#[component]
111fn Section(heading: &'static str, children: Element) -> Element {
112    rsx! {
113        div { class: "flex flex-col gap-2",
114            span { class: HEADING, "{heading}" }
115            {children}
116        }
117    }
118}
119
120#[component]
121fn ModeCell(mode: Mode, holding: Holding, prefs: Signal<Preferences>) -> Element {
122    let face = match mode {
123        Mode::Light => icons::SUN,
124        Mode::Dark => icons::MOON,
125        Mode::System => icons::MONITOR,
126    };
127    rsx! {
128        Pressable {
129            class: if holding == Holding::Taken { TAKEN_SEGMENT } else { SEGMENT },
130            "aria-pressed": if holding == Holding::Taken { "true" } else { "false" },
131            "data-pref": "mode-{mode.display_name()}",
132            onpress: move |()| prefs.write().mode = mode,
133            Face { icon: face }
134            span { "{mode.display_name()}" }
135        }
136    }
137}
138
139/// A scheme as what it paints: two blocks and the wire between them, in the
140/// scheme's own ground, ink and accents.
141#[component]
142fn SchemeCard(
143    scheme: Scheme,
144    luminance: Luminance,
145    holding: Holding,
146    prefs: Signal<Preferences>,
147) -> Element {
148    let palette = scheme.palette(luminance);
149    let [ground, ink, red, yellow, green, blue, purple] = [
150        Base::B00,
151        Base::B05,
152        Base::B08,
153        Base::B0A,
154        Base::B0B,
155        Base::B0D,
156        Base::B0E,
157    ]
158    .map(|base| css_color(palette.get(base)));
159    let card = holding.card();
160    rsx! {
161        Pressable {
162            class: "{card} flex flex-col gap-1.5",
163            "aria-pressed": if holding == Holding::Taken { "true" } else { "false" },
164            "data-pref": "scheme-{scheme.display_name()}",
165            onpress: move |()| prefs.write().scheme = scheme,
166            svg {
167                class: "block h-auto w-full",
168                view_box: "0 0 126 46",
169                "aria-hidden": "true",
170                rect { x: "0", y: "0", width: "126", height: "46", rx: "8", fill: "{ground}" }
171                rect { x: "12", y: "11", width: "30", height: "24", rx: "3", fill: "none", stroke: "{ink}", stroke_width: "1.5" }
172                rect { x: "84", y: "11", width: "30", height: "24", rx: "3", fill: "none", stroke: "{ink}", stroke_width: "1.5" }
173                path { d: "M42 18 H63 V28 H84", fill: "none", stroke: "{blue}", stroke_width: "1.5" }
174                circle { cx: "42", cy: "18", r: "2", fill: "{blue}" }
175                circle { cx: "84", cy: "28", r: "2", fill: "{blue}" }
176                rect { x: "16", y: "15", width: "10", height: "3", rx: "1.5", fill: "{red}" }
177                rect { x: "88", y: "15", width: "12", height: "3", rx: "1.5", fill: "{purple}" }
178                circle { cx: "58", cy: "39", r: "2.5", fill: "{yellow}" }
179                circle { cx: "66", cy: "39", r: "2.5", fill: "{green}" }
180            }
181            div { class: "flex h-[18px] items-center px-0.5",
182                Name { holding, "{scheme.display_name()}" }
183            }
184        }
185    }
186}
187
188/// A face as what it letters.
189#[component]
190fn FontCard(font: FontChoice, holding: Holding, prefs: Signal<Preferences>) -> Element {
191    let card = holding.card();
192    rsx! {
193        Pressable {
194            class: "{card} flex items-center gap-2.5 px-3 py-2",
195            "aria-pressed": if holding == Holding::Taken { "true" } else { "false" },
196            "data-pref": "font-{font.display_name()}",
197            onpress: move |()| prefs.write().font = font,
198            span {
199                class: "w-[34px] text-2xl leading-7",
200                style: "font-family:'{font.family_name()}'",
201                "Aa"
202            }
203            Name { holding, "{font.display_name()}" }
204        }
205    }
206}
207
208/// A card's name, weighted and ticked when it is the one held.
209#[component]
210fn Name(holding: Holding, children: Element) -> Element {
211    rsx! {
212        span {
213            class: if holding == Holding::Taken { "text-[13px] font-semibold" } else { "text-[13px]" },
214            {children}
215        }
216        if holding == Holding::Taken {
217            span { class: TICK,
218                Face { icon: icons::CHECK }
219            }
220        }
221    }
222}
223
224/// Who this session's work is attributed to. It reaches the session the
225/// moment it is typed — the identity is stamped on the *next* commit, not on
226/// the ones already written — so nothing has to be restarted for it to take.
227#[component]
228fn Profile(shell: Shell, prefs: Signal<Preferences>) -> Element {
229    let typed = prefs.read().profile.clone().unwrap_or_default();
230    let attributed = shell.attributed();
231    rsx! {
232        Section { heading: "Your name",
233            input {
234                class: "h-[38px] rounded-[10px] bg-zinc-100 px-3 text-sm outline-none \
235                        placeholder:text-zinc-500 focus:outline-2 focus:outline-sky-500 \
236                        dark:bg-zinc-900 dark:placeholder:text-zinc-400",
237                value: "{typed}",
238                placeholder: "{attributed}",
239                "aria-label": "Your name",
240                oninput: move |event| {
241                    let typed = event.value();
242                    prefs.write().profile = (!typed.trim().is_empty()).then_some(typed);
243                },
244            }
245            span { class: "text-xs text-zinc-500 dark:text-zinc-400",
246                "Stamped on every change you make from now on."
247            }
248        }
249    }
250}