diff --git a/Cargo.lock b/Cargo.lock index 3387cea6..01b35944 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -833,6 +833,15 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "emojis" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1514ced566c94991ed9563258ecf61e311fd773bf0a3f20606830386bf66e3" +dependencies = [ + "phf", +] + [[package]] name = "enumset" version = "1.1.14" @@ -1629,6 +1638,24 @@ dependencies = [ "ctutils", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -1764,8 +1791,10 @@ name = "polyvisor-visor" version = "0.1.0" dependencies = [ "dioxus", + "emojis", "stream-dom-dioxus", "stream-dom-guest", + "unicode-segmentation", "wit-bindgen", ] diff --git a/e2e/run.ts b/e2e/run.ts index ef13c1e4..449617b0 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -416,7 +416,11 @@ async function toAppSheet(page: Page): Promise { * The app sheet uses the same action bar as device settings. Save there and * wait for the clean Close action, which means the kernel accepted the map. */ -async function setAppGlyph(page: Page, glyph: string): Promise { +async function setAppGlyph( + page: Page, + glyph: string, + expected = glyph, +): Promise { await toAppSheet(page); // `^glyph$`: the settings sheet's field is "your glyph", and this is the // app sheet's. @@ -434,7 +438,7 @@ async function setAppGlyph(page: Page, glyph: string): Promise { for (;;) { await field.fill(glyph); await page.waitForTimeout(300); - if (await field.inputValue() === glyph) break; + if (await field.inputValue() === expected) break; check( performance.now() < deadline, "the app sheet's glyph field would not hold a value", @@ -451,7 +455,7 @@ async function setAppGlyph(page: Page, glyph: string): Promise { | undefined; return input?.value === want; }, - glyph, + expected, { timeout: 15_000 }, ); } @@ -504,7 +508,14 @@ async function probeIcon(page: Page, src: string): Promise<{ const type = res.headers.get("content-type"); const none = [0, 0, 0] as [number, number, number]; if (!res.ok) { - return { status: res.status, type, width: 0, height: 0, ink: 0, corner: none }; + return { + status: res.status, + type, + width: 0, + height: 0, + ink: 0, + corner: none, + }; } const bitmap = await createImageBitmap(await res.blob()); const canvas = document.createElement("canvas"); @@ -685,7 +696,9 @@ function inkContrast( ) { const text = (el.textContent ?? "").trim(); if (text === "" || el.querySelector("*") !== null) continue; - if (el.closest("[inert]") !== null || el.getClientRects().length === 0) continue; + if (el.closest("[inert]") !== null || el.getClientRects().length === 0) { + continue; + } const style = getComputedStyle(el); if (style.visibility === "hidden") continue; const bg = behind(el); @@ -693,7 +706,8 @@ function inkContrast( const hi = Math.max(lum(fg), lum(bg)) + 0.05; const lo = Math.min(lum(fg), lum(bg)) + 0.05; const cls = String(el.className).trim() || "-"; - out[`${el.tagName.toLowerCase()}.${cls} "${text.slice(0, 24)}"`] = hi / lo; + out[`${el.tagName.toLowerCase()}.${cls} "${text.slice(0, 24)}"`] = hi / + lo; } return out; }, hue); @@ -1723,11 +1737,48 @@ const scenarios: Scenario[] = [ check(res.ok(), `static icon ${icon.src} is not served`); } - // "★" and not an emoji: a colour emoji font ignores the white fill - // the pixel checks below look for. Two typed, one drawn — the icon - // must agree with the strip's first-`char` rule. - await setAppGlyph(page, "★x"); - + // The reusable picker is also the app glyph control. Its selection is + // only a draft: installing before Save must still use static icons, + // and Revert must clear it. + await toAppSheet(page); + const appGlyph = drawer(page).locator("label").filter({ + hasText: /^glyph$/, + }).locator("input"); + await drawer(page).getByRole("button", { name: "Choose emoji" }).click(); + const appPicker = drawer(page).locator(".glyph-picker"); + await appPicker.getByRole("searchbox", { name: "Search emoji" }).fill( + "rocket", + ); + await appPicker.getByRole("button", { name: "rocket", exact: true }) + .click(); + eq(await appGlyph.inputValue(), "🚀", "app picker chose the wrong glyph"); + check( + (await drawer(page).locator("#visor-actions").textContent())?.includes( + "Save", + ), + "an app picker choice did not remain an unsaved draft", + ); + await drawer(page).getByRole("button", { name: "Revert", exact: true }) + .click(); + eq(await appGlyph.inputValue(), "", "Revert kept an app picker choice"); + + // A combining sequence and not an emoji: a colour emoji font ignores + // the white fill the pixel checks below look for. Rust keeps the whole + // grapheme and drops the following suffix. + const paintedGlyph = "e\u0301"; + await setAppGlyph(page, paintedGlyph + "x", paintedGlyph); + + await page.evaluate(() => { + const proto = CanvasRenderingContext2D.prototype; + const original = proto.fillText; + (globalThis as Record).__paintedGlyphs = []; + proto.fillText = function (text, x, y, maxWidth) { + ((globalThis as Record).__paintedGlyphs as string[]) + .push(String(text)); + if (maxWidth === undefined) original.call(this, text, x, y); + else original.call(this, text, x, y, maxWidth); + }; + }); const manifest = await installAndReadManifest(page); const startUrl = await page.evaluate( (b) => new URL("#launch/todomvc", b).href, @@ -1770,11 +1821,23 @@ const scenarios: Scenario[] = [ "the manifest must name both launcher sizes", ); check( - !icons.some((i) => i.src.includes("★")), + !icons.some((i) => i.src.includes(paintedGlyph)), "the glyph must not appear literally in an icon URL", ); const bySize = new Map(icons.map((i) => [i.sizes, i.src])); + // Pixel ink only proves that something rendered; this proves the + // complete Rust-normalized grapheme crossed the TypeScript glue. + const paintedText = await page.evaluate(() => + (globalThis as Record).__paintedGlyphs + ) as string[]; + check( + paintedText.length === 2 && + paintedText.every((g) => g === paintedGlyph), + `canvas received ${ + JSON.stringify(paintedText) + }, not the whole grapheme`, + ); const big = await probeIcon(page, bySize.get("512x512")!); const small = await probeIcon(page, bySize.get("192x192")!); eq( @@ -2517,7 +2580,9 @@ const scenarios: Scenario[] = [ await focusIn(page, "#visor-actions") && await drawer(page).getByRole("button", { name: "Close", exact: true }) .count() === 1, - `bar Revert did not focus its replacement Close; focus is ${await focused(page)}`, + `bar Revert did not focus its replacement Close; focus is ${await focused( + page, + )}`, ); await shot(page, "desktop-action-bar-clean"); await field.fill("half typed"); @@ -2554,7 +2619,9 @@ const scenarios: Scenario[] = [ await focusIn(page, "#visor-actions") && await drawer(page).getByRole("button", { name: "Close", exact: true }) .count() === 1, - `bar Save did not focus its replacement Close; focus is ${await focused(page)}`, + `bar Save did not focus its replacement Close; focus is ${await focused( + page, + )}`, ); // Same-task input makes this deterministic without a permanent mock: @@ -2575,14 +2642,21 @@ const scenarios: Scenario[] = [ ); }); await page.waitForFunction( - () => document.querySelector("#visor-actions")?.textContent?.includes("Save"), + () => + document.querySelector("#visor-actions")?.textContent?.includes( + "Save", + ), ); eq( await drawer(page).locator(".pane").getAttribute("aria-label"), "settings", "navigation escaped while Save was in flight", ); - eq(await confirm.count(), 0, "navigation opened confirmation during Save"); + eq( + await confirm.count(), + 0, + "navigation opened confirmation during Save", + ); eq(await field.inputValue(), "newer text", "Save lost newer field text"); await drawer(page).getByRole("button", { name: "Revert", exact: true }) .click(); @@ -2593,7 +2667,9 @@ const scenarios: Scenario[] = [ ); await page.reload(); await visorReady(page); - await strip(page).getByText("first snapshot").waitFor({ timeout: 15_000 }); + await strip(page).getByText("first snapshot").waitFor({ + timeout: 15_000, + }); await openSettingsSheet(page); eq( await field.inputValue(), @@ -2602,21 +2678,128 @@ const scenarios: Scenario[] = [ ); // The user's own labels ride in the same draft and land on the strip: - // the petname in the right half's top line, and the glyph — of which - // only the first character is ever drawn — in the circle. - await drawer(page).locator("label").filter({ hasText: /^your petname$/ }) - .locator("input").fill("ada"); - await drawer(page).locator("label").filter({ hasText: /^your glyph$/ }) - .locator("input").fill("🜁x"); + // the petname in the right half's top line, and one whole extended + // grapheme in the circle. + const userPetname = drawer(page).locator("label").filter({ + hasText: /^your petname$/, + }).locator("input"); + await userPetname.fill("ada"); + const glyph = drawer(page).locator("label").filter({ + hasText: /^your glyph$/, + }).locator("input"); + await glyph.fill(" 👩🏽‍💻x"); + eq(await glyph.inputValue(), "👩🏽‍💻", "paste was not normalized"); + // The normalized draft is already this value. A controlled input must + // still rewrite the DOM when another pasted suffix normalizes to it. + await glyph.evaluate((input) => { + (input as HTMLInputElement).value = "👩🏽‍💻suffix"; + input.dispatchEvent(new InputEvent("input", { bubbles: true })); + }); + eq( + await glyph.inputValue(), + "👩🏽‍💻", + "an unchanged signal left a pasted suffix in the DOM", + ); + check( + await glyph.evaluate((input) => input === document.activeElement), + "normalizing an unchanged glyph moved focus", + ); + await glyph.press("ControlOrMeta+A"); + await glyph.pressSequentially("Z"); + eq( + await glyph.inputValue(), + "Z", + "typing did not continue after normalization", + ); + // Composition owns its incomplete text until compositionend commits. + await glyph.evaluate((input) => { + input.dispatchEvent( + new CompositionEvent("compositionstart", { + bubbles: true, + }), + ); + // An IME may expose only a partial grapheme while composing. + (input as HTMLInputElement).value = "e"; + input.dispatchEvent( + new InputEvent("input", { + bubbles: true, + isComposing: true, + }), + ); + }); + eq(await glyph.inputValue(), "e", "composition was changed early"); + await glyph.dispatchEvent("compositionend"); + await glyph.evaluate((input) => { + // Browsers commonly send the final input after compositionend. + (input as HTMLInputElement).value = "e\u0301x"; + input.dispatchEvent(new InputEvent("input", { bubbles: true })); + }); + eq( + await glyph.inputValue(), + "é", + "composition was not normalized at end", + ); + + // Picker choices are drafts too: browse by shortcode, select a + // modifier, then prove neither selection nor Revert persisted it. + await drawer(page).getByRole("button", { name: "Choose emoji" }).click(); + const picker = drawer(page).locator(".glyph-picker"); + await page.waitForFunction(() => + document.activeElement?.getAttribute("type") === "search" + ); + await picker.getByRole("searchbox", { name: "Search emoji" }).fill( + "technologist", + ); + await shot(page, "desktop-glyph-picker"); + await picker.getByRole("button", { + name: "woman technologist: medium skin tone", + }) + .click(); + eq(await glyph.inputValue(), "👩🏽‍💻", "picker chose the wrong grapheme"); + check( + await glyph.evaluate((input) => input === document.activeElement), + "picker selection did not return focus to the glyph input", + ); + await drawer(page).getByRole("button", { name: "Revert", exact: true }) + .click(); + eq(await glyph.inputValue(), "", "Revert kept a picker choice"); + // Revert covered the whole draft, including the sibling petname. + await userPetname.fill("ada"); + + // Escape closes the inline chooser, not its drawer. + await drawer(page).getByRole("button", { name: "Choose emoji" }).click(); + await picker.getByRole("searchbox", { name: "Search emoji" }).press( + "Escape", + ); + eq(await picker.count(), 0, "Escape kept the glyph picker open"); + check( + await glyph.evaluate((input) => input === document.activeElement), + "Escape did not return focus to the glyph input", + ); + eq( + await drawer(page).locator(".pane").getAttribute("aria-label"), + "settings", + "Escape closed the drawer with the picker", + ); + await glyph.fill(" 👩🏽‍💻x"); await saveDraft(page); await page.waitForFunction( - () => document.querySelector("#visor-circle")?.textContent === "🜁", + () => document.querySelector("#visor-circle")?.textContent === "👩🏽‍💻", undefined, { timeout: 10_000 }, ); await page.locator("#visor-self").getByText("ada").waitFor({ timeout: 10_000, }); + await page.reload(); + await visorReady(page); + await page.waitForFunction( + () => document.querySelector("#visor-circle")?.textContent === "👩🏽‍💻", + ); + await page.setViewportSize({ width: 390, height: 780 }); + await openSettingsSheet(page); + await drawer(page).getByRole("button", { name: "Choose emoji" }).click(); + await shot(page, "mobile-glyph-picker"); }, }, @@ -3020,7 +3203,8 @@ const scenarios: Scenario[] = [ }).click(); await shot(page, "mobile-action-bar-clean"); } - const action = await drawer(page).locator("#visor-actions").boundingBox(); + const action = await drawer(page).locator("#visor-actions") + .boundingBox(); const anchor = await strip(page).boundingBox(); check( action !== null && anchor !== null && @@ -3059,7 +3243,9 @@ const scenarios: Scenario[] = [ const wheel: Array = ["unclaimed"]; for (let h = 0; h < 360; h++) wheel.push(h); for (const hue of wheel) { - for (const [what, ratio] of Object.entries(await inkContrast(page, hue))) { + for ( + const [what, ratio] of Object.entries(await inkContrast(page, hue)) + ) { const key = `${what} @${hue}`; if (ratio < (worst.get(key) ?? Infinity)) worst.set(key, ratio); } @@ -3070,7 +3256,9 @@ const scenarios: Scenario[] = [ check( failing.length === 0, `${failing.length} text(s) below 4.5:1, worst ${ - failing.slice(0, 4).map(([k, r]) => `${k} = ${r.toFixed(2)}`).join("; ") + failing.slice(0, 4).map(([k, r]) => `${k} = ${r.toFixed(2)}`).join( + "; ", + ) }`, ); await page.locator("#visor-root").evaluate( @@ -3078,11 +3266,17 @@ const scenarios: Scenario[] = [ painted, ); let small = await undersizedControls(page); - check(small.length === 0, `mobile under touch floor: ${small.join("; ")}`); + check( + small.length === 0, + `mobile under touch floor: ${small.join("; ")}`, + ); await page.setViewportSize({ width: 1280, height: 800 }); await page.waitForTimeout(300); small = await undersizedControls(page); - check(small.length === 0, `desktop under touch floor: ${small.join("; ")}`); + check( + small.length === 0, + `desktop under touch floor: ${small.join("; ")}`, + ); }, }, diff --git a/visor/Cargo.toml b/visor/Cargo.toml index 4ed9af8f..1df4927d 100644 --- a/visor/Cargo.toml +++ b/visor/Cargo.toml @@ -15,6 +15,8 @@ crate-type = ["cdylib"] # "hooks" on top of the workspace features: the visor's UI is signal-based # (m1-context.md "Rust facts"). dioxus = { workspace = true, features = ["hooks"] } +emojis = "0.9" stream-dom-dioxus.workspace = true stream-dom-guest.workspace = true +unicode-segmentation = "1.13" wit-bindgen.workspace = true diff --git a/visor/src/glyph.rs b/visor/src/glyph.rs new file mode 100644 index 00000000..80af1a62 --- /dev/null +++ b/visor/src/glyph.rs @@ -0,0 +1,42 @@ +//! The one spelling of a glyph throughout the visor. + +use unicode_segmentation::UnicodeSegmentation; + +/// Strip leading Unicode whitespace and retain one extended grapheme. +/// +/// There is deliberately no normalization and no trailing trim: the first +/// grapheme is copied byte-for-byte from what the user supplied. +pub(crate) fn normalize_glyph(value: &str) -> &str { + value.trim_start().graphemes(true).next().unwrap_or("") +} + +#[cfg(test)] +mod tests { + use super::normalize_glyph; + + #[test] + fn keeps_one_extended_grapheme_after_leading_space() { + let cases = [ + ("", ""), + (" \u{a0}x", "x"), + (" \u{a0}\t", ""), + ("x trailing", "x"), + ("e\u{301}x", "e\u{301}"), + ( + "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}x", + "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}", + ), + ( + "\u{1f469}\u{1f3fd}\u{200d}\u{1f4bb}x", + "\u{1f469}\u{1f3fd}\u{200d}\u{1f4bb}", + ), + ("\u{1f44d}\u{1f3ff}x", "\u{1f44d}\u{1f3ff}"), + ("\u{1f1f3}\u{1f1ff}x", "\u{1f1f3}\u{1f1ff}"), + ("1\u{fe0f}\u{20e3}x", "1\u{fe0f}\u{20e3}"), + ("\u{2708}\u{fe0f}x", "\u{2708}\u{fe0f}"), + ]; + for (input, expected) in cases { + assert_eq!(normalize_glyph(input), expected, "input {input:?}"); + } + } +} diff --git a/visor/src/lib.rs b/visor/src/lib.rs index 213da930..e22cdc55 100644 --- a/visor/src/lib.rs +++ b/visor/src/lib.rs @@ -13,6 +13,8 @@ // tests — the UI that uses them in earnest is wasm-only — so dead-code // analysis has nothing to see there. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) mod glyph; +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) mod state; #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) mod style; diff --git a/visor/src/style.rs b/visor/src/style.rs index 106cc2a1..1bb5bd62 100644 --- a/visor/src/style.rs +++ b/visor/src/style.rs @@ -279,6 +279,11 @@ pub(crate) const CSS: &str = r#" is harder to read, not easier. */ min-width: 0; max-width: min(24rem, 100%); } +#visor-root input[type="search"] { + font: inherit; color: inherit; background: var(--field); + border: 1px solid var(--edge); border-radius: 6px; + padding: 8px; min-height: 44px; min-width: 0; box-sizing: border-box; +} #visor-root label input { flex: 1 1 16ch; } #visor-root .sheet > input { align-self: stretch; } #visor-root label { @@ -287,6 +292,24 @@ pub(crate) const CSS: &str = r#" align-self: stretch; } #visor-root label > span:first-child { flex: 0 0 auto; } +#visor-root .glyph-input { + display: flex; flex-direction: column; align-items: stretch; gap: 8px; + align-self: flex-start; width: min(32rem, 100%); margin-bottom: 12px; +} +#visor-root .glyph-control-row { display: flex; align-items: center; gap: 8px; } +#visor-root .glyph-control-row > label { flex: 1 1 auto; margin: 0; min-width: 0; } +#visor-root .glyph-control-row > button { flex: none; } +#visor-root .glyph-picker { + display: flex; flex-direction: column; align-items: stretch; gap: 8px; + padding: 8px; border: 1px solid var(--edge); + border-radius: 6px; background: var(--field); +} +#visor-root .glyph-picker label { margin: 0; } +#visor-root .glyph-results { + display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); + gap: 4px; max-height: min(16rem, 36vh); overflow-y: auto; +} +#visor-root .glyph-results button { padding: 4px; font-size: 24px; } /* Rows wrap rather than overlap: a name and some framework-voice facts about it do not fit on one 320px line, so the facts follow under the name. */ diff --git a/visor/src/ui.rs b/visor/src/ui.rs index 0f5d5f8a..1f9e84c8 100644 --- a/visor/src/ui.rs +++ b/visor/src/ui.rs @@ -29,6 +29,7 @@ use dioxus::html::Key; use dioxus::prelude::*; +use crate::glyph::normalize_glyph; use crate::kernel::{ self, App, Binding, Entry, Event, InstallOutcome, Member, Meta, MetaScope, Peer, SessionId, Status, @@ -46,17 +47,18 @@ use crate::voice::{AppText, AppVoice, Voice, coarse_age}; const PETNAME: &str = "petname"; const GLYPH: &str = "glyph"; -/// A glyph as the strip draws it: the first `char` of the field, or -/// nothing. `chars().next()` and not a byte slice — the field is free text -/// and an emoji is the likely case, so slicing would panic on exactly what -/// users type. fn glyph_of(meta: &Meta) -> String { meta.get(GLYPH) - .and_then(|s| s.chars().next()) - .map(String::from) + .map(|s| normalize_glyph(s).to_string()) .unwrap_or_default() } +fn normalize_meta_glyph(meta: &mut Meta) { + if let Some(value) = meta.get(GLYPH) { + set_field(meta, GLYPH, normalize_glyph(value).to_string()); + } +} + fn petname_of(meta: &Meta) -> String { meta.get(PETNAME).cloned().unwrap_or_default() } @@ -103,7 +105,7 @@ struct Draft { /// notice, not a silent revert. #[allow(clippy::too_many_arguments)] async fn save_draft( - draft: Signal, + mut draft: Signal, mut seed: Signal, mut status: Signal>, mut user_meta: Signal, @@ -112,7 +114,11 @@ async fn save_draft( app_id: Option, mut status_gate: CopyValue, ) -> bool { - let (was, now) = (seed(), draft()); + let was = seed(); + let mut now = draft(); + normalize_meta_glyph(&mut now.user); + normalize_meta_glyph(&mut now.app); + draft.set(now.clone()); status_gate.write().bump(); let mut failed = false; let mut fail = |e: String, failed: &mut bool| { @@ -208,6 +214,8 @@ enum FocusWant { self_half: bool, }, Confirm, + Glyph, + GlyphSearch, } /// Which half of the strip a tenant belongs to, and so which half the @@ -637,7 +645,7 @@ pub(crate) fn Visor() -> Element { // half-typed field is never taken away from the user who typed it. let seed_draft = use_callback(move |()| { let identity = status.read(); - let next = Draft { + let mut next = Draft { name: identity .as_ref() .map(|s| s.name.clone()) @@ -646,6 +654,8 @@ pub(crate) fn Visor() -> Element { user: user_meta(), app: app_meta(), }; + normalize_meta_glyph(&mut next.user); + normalize_meta_glyph(&mut next.app); drop(identity); seed.set(next.clone()); draft.set(next); @@ -1100,6 +1110,8 @@ pub(crate) fn Visor() -> Element { let focus_pane = focus_tag(FocusWant::Pane); let focus_bar = focus_tag(FocusWant::Bar); let focus_confirm = focus_tag(FocusWant::Confirm); + let focus_glyph = focus_tag(FocusWant::Glyph); + let focus_glyph_search = focus_tag(FocusWant::GlyphSearch); let focus_app_half = focus_tag(FocusWant::Strip { self_half: false }); let focus_self_half = focus_tag(FocusWant::Strip { self_half: true }); @@ -1216,16 +1228,17 @@ pub(crate) fn Visor() -> Element { }, } } - label { - span { class: "{Voice::Framework.class()}", "glyph" } - input { - r#type: "text", - value: "{info_glyph}", - oninput: move |e| { - let mut d = draft.write(); - set_field(&mut d.app, GLYPH, e.value()); - }, - } + GlyphInput { + label: "glyph", + value: info_glyph, + focus_return: current.then(|| focus_glyph.clone()).flatten(), + focus_search: current.then(|| focus_glyph_search.clone()).flatten(), + onchange: move |value| { + let mut d = draft.write(); + set_field(&mut d.app, GLYPH, value); + }, + onreturn: move |_| ask_focus.call(FocusWant::Glyph), + onsearch: move |_| ask_focus.call(FocusWant::GlyphSearch), } if let Some(id) = live_id { button { @@ -1281,6 +1294,10 @@ pub(crate) fn Visor() -> Element { on_refresh_devices: refresh_devices, on_kept, on_devices: show_devices, + focus_glyph: current.then(|| focus_glyph.clone()).flatten(), + focus_glyph_search: current.then(|| focus_glyph_search.clone()).flatten(), + on_glyph_return: move |_| ask_focus.call(FocusWant::Glyph), + on_glyph_search: move |_| ask_focus.call(FocusWant::GlyphSearch), } }, } @@ -2184,6 +2201,173 @@ fn EraseControl() -> Element { } } +const GLYPH_PAGE: usize = 96; + +/// Free text plus the bundled, searchable Unicode emoji catalogue. +/// +/// Composition events are supported by Dioxus 0.7.10 +/// (`dioxus-html/src/events/generated.rs:33`). While one is active the DOM's +/// in-progress text is left alone; the first complete grapheme is committed +/// at composition end. Ordinary input is normalized immediately, including +/// paste, so a controlled field also removes a suffix when the normalized +/// signal value happens to be unchanged. +#[component] +fn GlyphInput( + label: &'static str, + value: String, + onchange: EventHandler, + focus_return: Option, + focus_search: Option, + onreturn: EventHandler<()>, + onsearch: EventHandler<()>, +) -> Element { + let mut open = use_signal(|| false); + let mut query = use_signal(String::new); + let mut limit = use_signal(|| GLYPH_PAGE); + let mut composing = use_signal(|| false); + let mut raw = use_signal(|| value.clone()); + // A changing ordinary attribute makes Dioxus revisit this controlled + // input even when normalization produces the existing value. Unlike a + // keyed replacement, this keeps the same DOM node, focus and selection. + let mut input_revision = use_signal(|| 0u32); + if !composing() && raw.peek().as_str() != value.as_str() { + raw.set(value.clone()); + } + + let commit = use_callback(move |text: String| { + let normalized = normalize_glyph(&text).to_string(); + raw.set(normalized.clone()); + onchange.call(normalized); + input_revision += 1; + }); + let needle = query().trim().to_lowercase(); + let mut matches = Vec::new(); + let cap = limit().saturating_add(1); + 'emoji: for emoji in emojis::iter().take_while(|_| open()) { + let base_found = needle.is_empty() + || emoji.name().contains(&needle) + || emoji.shortcodes().any(|code| code.contains(&needle)); + if let Some(tones) = emoji.skin_tones() { + for variant in tones { + let found = base_found + || variant.name().contains(&needle) + || variant.shortcodes().any(|code| code.contains(&needle)); + if found { + matches.push(variant); + } + if matches.len() == cap { + break 'emoji; + } + } + } else if base_found { + matches.push(emoji); + if matches.len() == cap { + break; + } + } + } + let more = matches.len() > limit(); + matches.truncate(limit()); + let empty = matches.is_empty(); + + rsx! { + div { + class: "glyph-input", + onkeydown: move |e: KeyboardEvent| { + if open() && e.key() == Key::Escape { + e.stop_propagation(); + open.set(false); + onreturn.call(()); + } + }, + div { class: "glyph-control-row", + label { + span { class: "{Voice::Framework.class()}", "{label}" } + input { + r#type: "text", + value: "{raw}", + "data-glyph-revision": "{input_revision}", + "data-visor-focus": focus_return, + oncompositionstart: move |_| composing.set(true), + oncompositionend: move |_| { + composing.set(false); + commit.call(raw()); + }, + oninput: move |e| { + raw.set(e.value()); + if !composing() { + commit.call(e.value()); + } + }, + onblur: move |_| { + if composing() { + composing.set(false); + commit.call(raw()); + } + }, + } + } + button { + r#type: "button", + aria_expanded: "{open}", + onclick: move |_| { + if open() { + open.set(false); + onreturn.call(()); + } else { + open.set(true); + limit.set(GLYPH_PAGE); + onsearch.call(()); + } + }, + "Choose emoji" + } + } + if open() { + div { class: "glyph-picker", + label { + span { class: "{Voice::Framework.class()}", "Search emoji" } + input { + r#type: "search", + value: "{query}", + "data-visor-focus": focus_search, + oninput: move |e| { + query.set(e.value()); + limit.set(GLYPH_PAGE); + }, + } + } + div { class: "glyph-results", + for emoji in matches { + button { + r#type: "button", + title: "{emoji.name()}", + aria_label: "{emoji.name()}", + onclick: move |_| { + commit.call(emoji.as_str().to_string()); + open.set(false); + onreturn.call(()); + }, + "{emoji.as_str()}" + } + } + } + if more { + button { + r#type: "button", + onclick: move |_| limit += GLYPH_PAGE, + "Show more" + } + } + if empty { + span { class: "{Voice::Framework.class()}", "no emoji found" } + } + } + } + } + } +} + /// Everything about this device, and the user, that is a field rather /// than a ceremony. /// @@ -2211,6 +2395,10 @@ fn SettingsSheet( on_refresh_devices: EventHandler<()>, on_kept: EventHandler, on_devices: EventHandler<()>, + focus_glyph: Option, + focus_glyph_search: Option, + on_glyph_return: EventHandler<()>, + on_glyph_search: EventHandler<()>, ) -> Element { let mut draft = draft; // Read out rather than held: the field values are wanted here, and a @@ -2261,16 +2449,17 @@ fn SettingsSheet( }, } } - label { - span { class: "{Voice::Framework.class()}", "your glyph" } - input { - r#type: "text", - value: "{user_glyph}", - oninput: move |e| { - let mut d = draft.write(); - set_field(&mut d.user, GLYPH, e.value()); - }, - } + GlyphInput { + label: "your glyph", + value: user_glyph, + focus_return: focus_glyph, + focus_search: focus_glyph_search, + onchange: move |value| { + let mut d = draft.write(); + set_field(&mut d.user, GLYPH, value); + }, + onreturn: move |_| on_glyph_return.call(()), + onsearch: move |_| on_glyph_search.call(()), } label { span { class: "{Voice::Framework.class()}", "word" } diff --git a/web/boot.ts b/web/boot.ts index 0eaa9444..f4609a51 100644 --- a/web/boot.ts +++ b/web/boot.ts @@ -740,9 +740,10 @@ async function paintIcon( ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = `${Math.round(size * 0.62)}px system-ui, sans-serif`; - // The first `char` only, as the strip draws it (visor/src/ui.rs - // `glyph_of`). - ctx.fillText([...glyph][0] ?? "", size / 2, size / 2); + // Rust has already reduced this to one extended grapheme. Keep the page + // glue byte-for-byte passive; a second segmentation algorithm here would + // eventually disagree with the visor. + ctx.fillText(glyph, size / 2, size / 2); const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png") );