Skip to main content

blockworx_web/
control.rs

1//! The chrome's one control vocabulary.
2//!
3//! Every pressable thing in the chrome is drawn from here, so a press looks
4//! and behaves the same wherever it is made: a 44 px target (the spec's
5//! minimum on every platform), the same hover and press, and a control the
6//! session is not offering drawn **dead rather than hidden** — a hole teaches
7//! nothing, and invariant 8 wants the reason on the control itself.
8//!
9//! A control is a `div`, not a `<button>`: a press hands the chrome neither
10//! the focus nor a ring to show it, so the keyboard stays the drawing's.
11//!
12//! The hover text is a `title`, not a floating element: a withheld control
13//! must still say why it is withheld, and the browser's own tooltip does.
14
15use blockworx_kernel::Event;
16use blockworx_tools::commands::CommandId;
17use dioxus::prelude::*;
18
19use crate::chrome::Availability;
20use crate::icons::Icon;
21use crate::shell::Shell;
22
23/// A 44 px icon target, as the top bar and the overlay wear it.
24pub const TAP: &str = "relative grid size-target place-items-center rounded-xl text-zinc-700 \
25     transition duration-150 ease-out live:hover:bg-zinc-950/6 live:active:scale-90 \
26     dead:opacity-30 dark:text-zinc-200 dark:live:hover:bg-white/10 \
27     [&_svg]:size-5 [&_svg]:stroke-[1.8]";
28
29/// One cell of the floating rail: larger than a bar button, and the one place
30/// a control is drawn filled while it is armed.
31pub const CELL: &str = "relative grid size-13 place-items-center rounded-cell text-zinc-600 \
32     transition duration-150 ease-out live:hover:bg-zinc-950/6 live:active:scale-90 \
33     dead:opacity-30 dark:text-zinc-300 dark:live:hover:bg-white/10 \
34     [&_svg]:size-6 [&_svg]:stroke-[1.75]";
35
36/// The armed cell, which is the only filled control in the chrome.
37pub const ARMED: &str = "relative grid size-13 place-items-center rounded-cell \
38     bg-sky-600 text-white transition duration-150 ease-out live:active:scale-90 \
39     dark:bg-sky-500 dark:text-zinc-950 [&_svg]:size-6 [&_svg]:stroke-[1.75]";
40
41/// One control that raises one command.
42///
43/// It raises the command's *name*: what that comes to is resolved by the call
44/// it reaches, against the session as it then stands. `says` is the control's
45/// own sentence; the keys that do the same thing are read off the binding
46/// table and appended, so a tooltip cannot promise a key nothing binds.
47///
48/// `raises` is for the one control whose press means something other than
49/// what it stands for — the armed rail cell, which is still *labelled* the
50/// tool it holds while a press of it returns to Select.
51#[component]
52pub fn Press(
53    shell: Shell,
54    id: CommandId,
55    raises: Option<CommandId>,
56    says: String,
57    icon: Option<Icon>,
58    class: String,
59    /// Words drawn beside the face, for a control that carries its name.
60    #[props(default)]
61    children: Element,
62) -> Element {
63    let chrome = shell.chrome();
64    let offered = chrome
65        .read()
66        .commands
67        .face(id)
68        .map(|face| face.availability);
69    // A control the session will not run still says why, which is the whole
70    // of invariant 8: a hole teaches nothing and neither does a grey square.
71    let hint = match offered {
72        Some(Availability::Withheld) => format!(
73            "{says} \u{2014} {}",
74            crate::chrome::Withholding::of(
75                chrome.read().top_bar.lens.viewing,
76                chrome.read().writable,
77            )
78            .says(),
79        ),
80        _ => crate::chords::hinted(shell.token(), &says, id),
81    };
82    let pressed = raises.unwrap_or(id);
83    let shell = use_hook(|| CopyValue::new(shell));
84    rsx! {
85        Pressable {
86            class: "{class}",
87            title: "{hint}",
88            "aria-label": "{says}",
89            // The command's stable spelling, so a script — and a snapshot —
90            // can name a control without reading its words.
91            "data-cmd": "{id.name()}",
92            pressing: if offered.is_none_or(Availability::disabled) {
93                Pressing::Dead
94            } else {
95                Pressing::Live
96            },
97            onpress: move |()| {
98                let shell = shell.read();
99                shell.works();
100                shell.say(Event::Command(pressed));
101            },
102            if let Some(icon) = icon {
103                Face { icon }
104            }
105            {children}
106        }
107    }
108}
109
110/// Whether a control answers a press.
111#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
112pub enum Pressing {
113    #[default]
114    Live,
115    Dead,
116}
117
118/// Something to press, wearing whatever class its caller gives it.
119#[component]
120pub fn Pressable(
121    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
122    #[props(default)] pressing: Pressing,
123    onpress: EventHandler<()>,
124    children: Element,
125) -> Element {
126    let dead = if pressing == Pressing::Dead {
127        "true"
128    } else {
129        "false"
130    };
131    rsx! {
132        div {
133            role: "button",
134            "aria-disabled": dead,
135            "data-disabled": dead,
136            onclick: move |_| {
137                if pressing == Pressing::Live {
138                    onpress.call(());
139                }
140            },
141            ..attributes,
142            {children}
143        }
144    }
145}
146
147/// One face, taking the colour of the button it sits in.
148#[component]
149pub fn Face(icon: Icon) -> Element {
150    rsx! {
151        span { class: "contents", dangerous_inner_html: icon.inked() }
152    }
153}