diff --git a/src/App.tsx b/src/App.tsx index 5fa5fd5..ce76cb0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,20 +7,20 @@ * third argument is a lazy initialiser, which is how the stored blob is read * once at startup instead of on every render. */ -import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { DECKS, boxDistribution, deckPool, knownCount, pickCard } from "./domain/drill"; import type { DeckId, Direction, InputMode } from "./domain/drill"; -import { ENTRIES } from "./domain/entries"; +import { ENTRIES, layerKeyOf } from "./domain/entries"; import { controlName } from "./domain/format"; -import { CTX, CTXHINT } from "./domain/labels"; +import { CTX, CTXHINT, NOLATCH } from "./domain/labels"; import { entriesByTarget, entriesForLayer, layerCounts, orderLayerKeys, } from "./domain/layers"; -import type { ContextCode, ControlId, Entry } from "./domain/types"; import { searchEntries } from "./domain/search"; +import type { ContextCode, ControlId, Entry } from "./domain/types"; import { DrillCard } from "./ui/DrillCard"; import { DrillStrip } from "./ui/DrillStrip"; import { FindPanel } from "./ui/FindPanel"; @@ -29,6 +29,7 @@ import { Panel } from "./ui/Panel"; import { RulesPanel } from "./ui/RulesPanel"; import { Sheet, SheetOption } from "./ui/Sheet"; import { drillReducer, initialDrillState } from "./ui/drillReducer"; +import { exitApp, gradeFeedback, useBackButton } from "./ui/native"; import { loadState, saveState } from "./ui/persistedState"; import styles from "./App.module.css"; @@ -36,6 +37,9 @@ const TABS = ["Explore", "Drill", "Find", "Rules"] as const; type Tab = (typeof TABS)[number]; type OpenSheet = null | "mode" | "layer" | "settings"; +/** Longer than this and the press was a hold to peek, not a tap to lock. */ +const HOLD_MS = 350; + function Caret() { return (
@@ -181,7 +267,7 @@ export function App() {
@@ -189,8 +275,19 @@ export function App() { { + if (!isDrill) { + explorePress(id); + return; + } + if (drill.answered) deal(); + else if (drill.input === "tap") dispatch({ type: "press", id }); + }} + onRelease={(id) => { + if (!isDrill) exploreRelease(id); + }} onGestureEnd={(ids) => { // Hold-to-answer grades when the last finger lifts. Tap mode waits for // the Check button instead, and Explore has nothing to grade. @@ -198,20 +295,6 @@ export function App() { dispatch({ type: "release", chord: ids }); } }} - onPress={(id) => { - if (isDrill) { - if (drill.answered) deal(); - else if (drill.input === "tap") dispatch({ type: "press", id }); - return; - } - const here = exploreLit.get(id); - if (!here || here.length === 0) { - setSelected(null); - return; - } - const at = selected ? here.indexOf(selected) : -1; - setSelected(here[(at + 1) % here.length]!); - }} screen={ isDrill ? { @@ -222,7 +305,7 @@ export function App() { } : { top: CTX[ctx].toUpperCase(), - right: layerLabel(activeLayer), + right: layerLabel(heldKey), main: selected ? selected.short : plural(onLayer.length, "function"), chips: selected ? [] : onLayer.slice(0, 3).map((e) => e.short), } @@ -280,9 +363,13 @@ export function App() { key={key || "none"} label={layerLabel(key)} hint={plural(counts.get(key) ?? 0, "function")} - selected={key === activeLayer} + selected={key === heldKey} onSelect={() => { - setLayer(key); + // Picking a layer latches its modifiers, as if they were tapped. + setHeld(key === "" ? [] : key.split("+")); + downAt.current.clear(); + used.current.clear(); + pendingRelease.current.clear(); setSelected(null); setSheet(null); }} diff --git a/src/ui/Panel.module.css b/src/ui/Panel.module.css index 592125b..907dda3 100644 --- a/src/ui/Panel.module.css +++ b/src/ui/Panel.module.css @@ -45,7 +45,12 @@ padding: 2px 1px; border-radius: 3px; hyphens: manual; - overflow-wrap: break-word; + /* Lit keys show the entry's short label, which comes from the data and can be + one long unbreakable word — SHIFT + LOOP is PINGPONG. Soft hyphens only + cover the printed labels, so allow a break anywhere and clip what is left, + rather than letting it spill outside the key. */ + overflow-wrap: anywhere; + overflow: hidden; } /* Lit is an outline, not a fill. The SHIFT layer lights 35 of 54 controls, and diff --git a/src/ui/Panel.tsx b/src/ui/Panel.tsx index 9685b09..bc39d1b 100644 --- a/src/ui/Panel.tsx +++ b/src/ui/Panel.tsx @@ -11,6 +11,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { panelLabel } from "../domain/format"; import type { ControlId, Entry } from "../domain/types"; import { cx } from "./cx"; +import { tapFeedback } from "./native"; import { CONTROL_GROUPS, FX_LEFT, FX_RIGHT, KNOBS, PAD_ROWS } from "./panelLayout"; import styles from "./Panel.module.css"; @@ -28,6 +29,8 @@ export interface PanelProps { readonly screen: ScreenText; /** A control went down. */ readonly onPress?: ((id: ControlId) => void) | undefined; + /** That control's finger lifted. Explore's latch needs the timing per key. */ + readonly onRelease?: ((id: ControlId) => void) | undefined; /** The last finger lifted; every control touched during the gesture. */ readonly onGestureEnd?: ((ids: ControlId[]) => void) | undefined; } @@ -92,6 +95,7 @@ export function Panel({ selected, screen, onPress, + onRelease, onGestureEnd, }: PanelProps) { /** pointerId to the control it is holding. */ @@ -103,17 +107,23 @@ export function Panel({ // Kept in refs so the window listeners below can be attached once and still // call the current callbacks. const pressRef = useRef(onPress); + const releaseRef = useRef(onRelease); const endRef = useRef(onGestureEnd); pressRef.current = onPress; + releaseRef.current = onRelease; endRef.current = onGestureEnd; useEffect(() => { // Fingers often lift outside the key they went down on, so releases are // heard on the window rather than the button. function release(e: PointerEvent) { - if (!active.current.has(e.pointerId)) return; + const id = active.current.get(e.pointerId); + if (id === undefined) return; active.current.delete(e.pointerId); setDown(new Set(active.current.values())); + // Per-control, and before the gesture-wide callback: Explore's latch + // decides hold-versus-tap from how long this one key was down. + releaseRef.current?.(id); if (active.current.size > 0) return; const ids = [...gesture.current]; @@ -139,12 +149,15 @@ export function Panel({ active.current.set(e.pointerId, id); gesture.current.add(id); setDown(new Set(active.current.values())); + // Every key gets a tick, including the second and third finger of a chord. + tapFeedback(); pressRef.current?.(id); }, []); - /** Keyboard: press and end the gesture in one go. */ + /** Keyboard: a full press, release and gesture in one go, so taps latch. */ const onKey = useCallback((id: ControlId) => { pressRef.current?.(id); + releaseRef.current?.(id); endRef.current?.([id]); }, []); diff --git a/src/ui/native.ts b/src/ui/native.ts new file mode 100644 index 0000000..b5d158e --- /dev/null +++ b/src/ui/native.ts @@ -0,0 +1,43 @@ +/** + * The native bits Phase 1 did in native.js: haptics and the hardware back + * button. No-ops in a browser, so the dev server behaves normally. + */ +import { useEffect } from "react"; +import { App as CapacitorApp } from "@capacitor/app"; +import { Capacitor } from "@capacitor/core"; +import { Haptics, ImpactStyle, NotificationType } from "@capacitor/haptics"; + +const isNative = (): boolean => Capacitor.isNativePlatform(); + +/** A light tick under every key press, as the reference's buzz() did. */ +export function tapFeedback(): void { + if (!isNative()) return; + void Haptics.impact({ style: ImpactStyle.Light }).catch(() => {}); +} + +/** Shaped feedback on a graded answer: right and wrong should feel different. */ +export function gradeFeedback(correct: boolean): void { + if (!isNative()) return; + void Haptics.notification({ + type: correct ? NotificationType.Success : NotificationType.Error, + }).catch(() => {}); +} + +export function exitApp(): void { + if (!isNative()) return; + void CapacitorApp.exitApp(); +} + +/** + * Hardware back. Registering a listener suppresses Capacitor's default history + * navigation, so `onBack` owns the whole stack: sheet, then tab, then exit. + */ +export function useBackButton(onBack: () => void): void { + useEffect(() => { + if (!isNative()) return; + const handle = CapacitorApp.addListener("backButton", () => onBack()); + return () => { + void handle.then((h) => h.remove()); + }; + }, [onBack]); +}