Skip to main content

logicaffeine_web/ui/components/
symbol_pad.rs

1//! The symbol pad: a button for every symbol a formula needs, beside a typed answer.
2//!
3//! A click types its symbol at the caret of the field it names (`target`), replacing a selection,
4//! and leaves the caret after it with the field still focused, so the learner goes on typing. A
5//! pointer press never takes the focus from the field; a keyboard press (Enter or Space on a focused
6//! button) types the symbol and hands the focus back. The buttons and their meanings come from
7//! [`crate::composer`].
8
9use dioxus::prelude::*;
10use crate::composer::symbol_buttons;
11
12const SYMBOL_PAD_STYLE: &str = r#"
13.symbol-pad {
14    display: grid;
15    grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
16    gap: var(--spacing-sm);
17    margin-top: var(--spacing-md);
18}
19
20.symbol-key {
21    display: flex;
22    flex-direction: column;
23    align-items: center;
24    justify-content: center;
25    gap: 2px;
26    min-height: 48px;
27    padding: 6px 5px 5px;
28    border-radius: var(--radius-md);
29    border: 1px solid rgba(var(--accent-primary-rgb), 0.22);
30    background: rgba(var(--accent-primary-rgb), 0.05);
31    color: var(--text-primary);
32    font: inherit;
33    cursor: pointer;
34    touch-action: manipulation;
35    transition: background 0.15s ease, border-color 0.15s ease;
36}
37
38.symbol-key:hover {
39    background: rgba(var(--accent-primary-rgb), 0.12);
40    border-color: rgba(var(--accent-primary-rgb), 0.5);
41}
42
43.symbol-key:active {
44    background: rgba(var(--accent-primary-rgb), 0.2);
45}
46
47.symbol-key:focus-visible {
48    outline: 2px solid var(--accent-primary);
49    outline-offset: 2px;
50}
51
52.symbol-key-glyph {
53    font-family: var(--font-mono);
54    font-size: 20px;
55    line-height: 1;
56    color: var(--accent-primary);
57}
58
59.symbol-key-name {
60    font-size: 11px;
61    line-height: 1.2;
62    color: var(--text-tertiary);
63    text-align: center;
64}
65
66@media (max-width: 640px) {
67    .symbol-pad {
68        grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
69    }
70    .symbol-key {
71        min-height: 52px;
72    }
73}
74"#;
75
76/// Type `symbol` into the field `target` at its caret, and return the field's new text.
77#[cfg(target_arch = "wasm32")]
78fn type_into(target: &str, symbol: &str) -> Option<String> {
79    use wasm_bindgen::JsCast;
80    let field = web_sys::window()?.document()?.get_element_by_id(target)?.dyn_into::<web_sys::HtmlInputElement>().ok()?;
81    let text = field.value();
82    let end_of_text = text.encode_utf16().count() as u32;
83    let start = field.selection_start().ok().flatten().unwrap_or(end_of_text);
84    let end = field.selection_end().ok().flatten().unwrap_or(start);
85    let (typed, caret) = crate::composer::insert_at(&text, start as usize, end as usize, symbol);
86    field.set_value(&typed);
87    let _ = field.focus();
88    // The page then re-renders the field with this same text, which leaves the caret where it is
89    // set here. Setting it again later would undo any keys pressed in between.
90    let _ = field.set_selection_range(caret as u32, caret as u32);
91    Some(typed)
92}
93
94#[cfg(not(target_arch = "wasm32"))]
95fn type_into(target: &str, symbol: &str) -> Option<String> {
96    let _ = (target, symbol);
97    None
98}
99
100/// A button for every symbol, typing into the field whose id is `target`. `on_input` hears the
101/// field's new text, the same way the field's own `oninput` does.
102#[component]
103pub fn SymbolPad(target: String, on_input: EventHandler<String>) -> Element {
104    rsx! {
105        style { "{SYMBOL_PAD_STYLE}" }
106        div { class: "symbol-pad", role: "group", aria_label: "Symbols",
107            for button in symbol_buttons() {
108                {
109                    let field = target.clone();
110                    rsx! {
111                        button {
112                            key: "{button.symbol}",
113                            class: "symbol-key",
114                            r#type: "button",
115                            "data-symbol": "{button.symbol}",
116                            aria_label: "{button.label()}",
117                            aria_controls: "{target}",
118                            title: "{button.meaning}",
119                            onmousedown: move |e: Event<MouseData>| e.prevent_default(),
120                            onclick: move |_| {
121                                if let Some(text) = type_into(&field, button.symbol) {
122                                    on_input.call(text);
123                                }
124                            },
125                            span { class: "symbol-key-glyph", aria_hidden: "true", "{button.symbol}" }
126                            span { class: "symbol-key-name", aria_hidden: "true", "{button.name}" }
127                        }
128                    }
129                }
130            }
131        }
132    }
133}