diff --git a/console/src/main.tsx b/console/src/main.tsx index 4711ae65ab..0a3c703e83 100644 --- a/console/src/main.tsx +++ b/console/src/main.tsx @@ -67,8 +67,9 @@ const PREVENT_DEFAULT_TRIGGERS: Triggers.Trigger[] = [ ["Control", "W"], ]; -const triggersProps: Triggers.ProviderProps = { +const TRIGGERS_PROVIDER_PROPS: Triggers.ProviderProps = { preventDefaultOn: PREVENT_DEFAULT_TRIGGERS, + preventDefaultOptions: { double: true }, }; const client = new QueryClient(); @@ -107,7 +108,7 @@ const MainUnderContext = (): ReactElement => { workerEnabled connParams={cluster?.props} workerURL={WorkerURL} - triggers={triggersProps} + triggers={TRIGGERS_PROVIDER_PROPS} haul={{ useState: useHaulState }} alamos={{ level: "debug", diff --git a/pluto/src/align/Pack.tsx b/pluto/src/align/Pack.tsx index 8f75486d00..09f3b6d4dd 100644 --- a/pluto/src/align/Pack.tsx +++ b/pluto/src/align/Pack.tsx @@ -13,7 +13,6 @@ import { type ForwardedRef, forwardRef, type ReactElement } from "react"; import { Space, type SpaceElementType, type SpaceProps } from "@/align/Space"; import { CSS } from "@/css"; -import { Text } from "@/text"; /** Props for the {@link Pack} component. */ export type PackProps = Omit< @@ -21,7 +20,6 @@ export type PackProps = Omit< "empty" > & { shadow?: boolean; - borderShade?: Text.Shade; }; const CorePack = ( diff --git a/pluto/src/align/Space.tsx b/pluto/src/align/Space.tsx index c955932370..149e533b22 100644 --- a/pluto/src/align/Space.tsx +++ b/pluto/src/align/Space.tsx @@ -66,6 +66,7 @@ export interface SpaceExtensionProps { wrap?: boolean; el?: SpaceElementType; bordered?: boolean; + borderShade?: Theming.Shade; rounded?: boolean; background?: Theming.Shade; } @@ -93,6 +94,7 @@ const CoreSpace = ( direction: direction_ = "y", wrap = false, bordered = false, + borderShade, rounded = false, el = "div", background, diff --git a/pluto/src/list/Hover.tsx b/pluto/src/list/Hover.tsx index abcbc3fc28..bf18399611 100644 --- a/pluto/src/list/Hover.tsx +++ b/pluto/src/list/Hover.tsx @@ -85,9 +85,9 @@ export const Hover = = Keyed>({ } const move = () => { const data = getData(); - if (Triggers.match(triggers, [UP_TRIGGER], true)) + if (Triggers.match(triggers, [UP_TRIGGER], { loose: true })) setHover((pos) => (pos <= 0 ? data.length - 1 : pos - 1)); - else if (Triggers.match(triggers, [DOWN_TRIGGER], true)) + else if (Triggers.match(triggers, [DOWN_TRIGGER], { loose: true })) setHover((pos) => (pos >= data.length - 1 ? 0 : pos + 1)); }; move(); diff --git a/pluto/src/triggers/Provider.tsx b/pluto/src/triggers/Provider.tsx index 9a938fc657..17c297fc19 100644 --- a/pluto/src/triggers/Provider.tsx +++ b/pluto/src/triggers/Provider.tsx @@ -24,6 +24,7 @@ import { type Callback, eventKey, match, + MatchOptions, MOUSE_KEYS, type MouseKey, type Trigger, @@ -59,6 +60,7 @@ const EXCLUDE_TRIGGERS = ["CapsLock"]; export interface ProviderProps extends PropsWithChildren { preventDefaultOn?: Trigger[]; + preventDefaultOptions?: MatchOptions; } const shouldNotTriggerOnKeyDown = (key: string, e: KeyboardEvent): boolean => { @@ -73,6 +75,7 @@ const shouldNotTriggerOnKeyDown = (key: string, e: KeyboardEvent): boolean => { export const Provider = ({ children, preventDefaultOn, + preventDefaultOptions, }: ProviderProps): ReactElement => { // We track mouse movement to allow for cursor position on keybord events; const cursor = useRef(xy.ZERO); @@ -115,7 +118,8 @@ export const Provider = ({ prev: prev.next, last: new TimeStamp(), }; - if (shouldPreventDefault(next, preventDefaultOn)) e.preventDefault(); + if (shouldPreventDefault(next, preventDefaultOn, preventDefaultOptions)) + e.preventDefault(); updateListeners(nextState, e.target as HTMLElement); return nextState; }); @@ -132,12 +136,9 @@ export const Provider = ({ (k) => k !== key && !MOUSE_KEYS.includes(k as MouseKey), ); const prev = prevS.next; - const nextS: RefState = { - ...prevS, - next, - prev, - }; - if (shouldPreventDefault(next, preventDefaultOn)) e.preventDefault(); + const nextS: RefState = { ...prevS, next, prev }; + if (shouldPreventDefault(next, preventDefaultOn, preventDefaultOptions)) + e.preventDefault(); updateListeners(nextS, e.target as HTMLElement); return nextS; }); @@ -151,11 +152,7 @@ export const Provider = ({ const handlePageVisibility = useCallback((event: Event): void => { setCurr((prevS) => { const prev = prevS.next; - const nextS: RefState = { - ...prevS, - next: [], - prev, - }; + const nextS: RefState = { ...prevS, next: [], prev }; updateListeners(nextS, event.target as HTMLElement); return nextS; }); @@ -190,5 +187,9 @@ export const Provider = ({ return {children}; }; -const shouldPreventDefault = (t: Trigger, preventDefaultOn?: Trigger[]): boolean => - preventDefaultOn != null && match([t], preventDefaultOn); +const shouldPreventDefault = ( + t: Trigger, + preventDefaultOn?: Trigger[], + preventDefaultOptions?: MatchOptions, +): boolean => + preventDefaultOn != null && match([t], preventDefaultOn, preventDefaultOptions); diff --git a/pluto/src/triggers/Triggers.spec.tsx b/pluto/src/triggers/Triggers.spec.tsx index d5d9a2db46..3543e1db6b 100644 --- a/pluto/src/triggers/Triggers.spec.tsx +++ b/pluto/src/triggers/Triggers.spec.tsx @@ -66,14 +66,14 @@ describe("Triggers", () => { ["A", "C"], ], [["A", "D"]], - true, + { loose: true }, ), ).toEqual([]); }); it("Should return a list of triggers that match", () => { - expect(Triggers.filter([["A"], ["A", "C"]], [["A", "B"]], true)).toEqual([ - ["A"], - ]); + expect( + Triggers.filter([["A"], ["A", "C"]], [["A", "B"]], { loose: true }), + ).toEqual([["A"]]); }); it("should return an empty list when no triggers match", () => { expect( @@ -83,7 +83,7 @@ describe("Triggers", () => { ["A", "C"], ], [["A"]], - true, + { loose: true }, ), ).toEqual([]); }); @@ -141,10 +141,12 @@ describe("Triggers", () => { a: [["Shift"]], b: [["Shift", "Control"]], }; - expect(Triggers.determineMode(config, [["Shift", "Control"]], true)).toEqual( - "b", + expect( + Triggers.determineMode(config, [["Shift", "Control"]], { loose: true }), + ).toEqual("b"); + expect(Triggers.determineMode(config, [["Shift"]], { loose: true })).toEqual( + "a", ); - expect(Triggers.determineMode(config, [["Shift"]], true)).toEqual("a"); }); }); }); diff --git a/pluto/src/triggers/hooks.tsx b/pluto/src/triggers/hooks.tsx index 751510f36a..e4b13baf58 100644 --- a/pluto/src/triggers/hooks.tsx +++ b/pluto/src/triggers/hooks.tsx @@ -19,7 +19,14 @@ import { import { useStateRef } from "@/hooks/ref"; import { useMemoCompare } from "@/memo"; import { useContext } from "@/triggers/Provider"; -import { diff, filter, purge, type Stage, type Trigger } from "@/triggers/triggers"; +import { + diff, + filter, + MatchOptions, + purge, + type Stage, + type Trigger, +} from "@/triggers/triggers"; export interface UseEvent { target: HTMLElement; @@ -28,14 +35,19 @@ export interface UseEvent { cursor: xy.XY; } -export interface UseProps { +export interface UseProps extends MatchOptions { triggers?: Trigger[]; region?: RefObject; - loose?: boolean; callback?: (e: UseEvent) => void; } -export const use = ({ triggers, callback: f, region, loose }: UseProps): void => { +export const use = ({ + triggers, + callback: f, + region, + loose, + double, +}: UseProps): void => { const { listen } = useContext(); const memoTriggers = useMemoCompare( () => triggers, @@ -50,8 +62,8 @@ export const use = ({ triggers, callback: f, region, loose }: UseProps): void => useEffect(() => { if (memoTriggers == null || memoTriggers.length === 0) return; return listen((e) => { - const prevMatches = filter(memoTriggers, e.prev, /* loose */ loose); - const nextMatches = filter(memoTriggers, e.next, /* loose */ loose); + const prevMatches = filter(memoTriggers, e.prev, { loose, double }); + const nextMatches = filter(memoTriggers, e.next, { loose, double }); const res = diff(nextMatches, prevMatches); let added = res[0]; const removed = res[1]; @@ -61,7 +73,7 @@ export const use = ({ triggers, callback: f, region, loose }: UseProps): void => if (added.length > 0) f?.({ ...base, stage: "start", triggers: added }); if (removed.length > 0) f?.({ ...base, stage: "end", triggers: removed }); }); - }, [f, memoTriggers, listen, loose, region]); + }, [f, memoTriggers, listen, loose, region, double]); }; const filterInRegion = ( diff --git a/pluto/src/triggers/triggers.ts b/pluto/src/triggers/triggers.ts index 6ae19c9d4c..4afe36509b 100644 --- a/pluto/src/triggers/triggers.ts +++ b/pluto/src/triggers/triggers.ts @@ -223,6 +223,21 @@ const MOUSE_BUTTONS: Record = { */ export const mouseKey = (button: number): Key => MOUSE_BUTTONS[button] ?? "MouseLeft"; +export interface MatchOptions { + /** + * If true, triggers in actual that are a superset of those in expected will still be + * considered a match i.e. if expected is [["Control"]] and actual is [["Control", "A"]], + * then match will return true. + */ + loose?: boolean; + /** + * If true, triggers in actual that are a double press of those in expected will still + * be considered a match i.e. if expected is [["Control", "W"]] and actual is + * [["Control", "W", "W"]], then match will return true. + */ + double?: boolean; +} + /** * Match compares the expected triggers against the actual triggers. * @@ -234,8 +249,11 @@ export const mouseKey = (button: number): Key => MOUSE_BUTTONS[button] ?? "Mouse * @returns true if any triggers in expected match those in actual. * */ -export const match = (expected: Trigger[], actual: Trigger[], loose = false): boolean => - filter(expected, actual, loose).length > 0; +export const match = ( + expected: Trigger[], + actual: Trigger[], + opts?: MatchOptions, +): boolean => filter(expected, actual, opts).length > 0; export const matchCallback = ( @@ -259,9 +277,9 @@ export const matchCallback = export const filter = ( expected: Trigger[], actual: Trigger[], - loose = false, + opts?: MatchOptions, ): Trigger[] => { - const f = compareF(loose); + const f = compareF(opts); return expected.filter((o) => actual.some((t) => f(o, t) === compare.EQUAL)); }; @@ -287,7 +305,7 @@ export const purge = (source: Trigger[], toPurge: Trigger[]): Trigger[] => * and the second element is the triggers in b that are not in a. */ export const diff = (a: Trigger[], b: Trigger[]): [Trigger[], Trigger[]] => { - const f = compareF(false); + const f = compareF(); const added = a.filter((ta) => !b.some((tb) => f(ta, tb) === compare.EQUAL)); const removed = b.filter((tb) => !a.some((ta) => f(tb, ta) === compare.EQUAL)); return [added, removed]; @@ -299,8 +317,11 @@ export const diff = (a: Trigger[], b: Trigger[]): [Trigger[], Trigger[]] => { * the triggers will be considered equal. * @returns a comparison function that determines if two triggers are semantically equal. */ -const compareF = (loose: boolean): compare.CompareF => - loose ? _looseCompare : compare.unorderedPrimitiveArrays; +const compareF = (opts?: MatchOptions): compare.CompareF => { + if (opts?.loose === true) return _looseCompare; + if (opts?.double === true) return compare.uniqueUnorderedPrimitiveArrays; + return compare.unorderedPrimitiveArrays; +}; const _looseCompare: compare.CompareF = (a, b) => a.every((k) => b.includes(k)) ? compare.EQUAL : compare.LESS_THAN; @@ -324,14 +345,14 @@ export type ModeConfig = Record( config: ModeConfig, triggers: Trigger[], - loose = false, + opts?: MatchOptions, ): K => { const e = Object.entries(config).filter( ([k]) => k !== "defaultMode", ) as unknown as Array<[K, Trigger[]]>; const flat = e.map(([k, v]) => v.map((t) => [k, t])).flat() as Array<[K, Trigger]>; const complexitySorted = flat.sort(([, a], [, b]) => b.length - a.length); - const match_ = complexitySorted.find(([, v]) => match([v], triggers, loose)); + const match_ = complexitySorted.find(([, v]) => match([v], triggers, opts)); if (match_ != null) return match_[0]; return config.defaultMode; }; diff --git a/pluto/src/vis/measure/Measure.tsx b/pluto/src/vis/measure/Measure.tsx index cb7553aa4c..ac2c5121cd 100644 --- a/pluto/src/vis/measure/Measure.tsx +++ b/pluto/src/vis/measure/Measure.tsx @@ -28,7 +28,6 @@ const MEASURE_TRIGGERS: Triggers.ModeConfig = { const REDUCED_MEASURE_TRIGGERS = Triggers.flattenConfig(MEASURE_TRIGGERS); - export interface MeasureProps {} export const Measure = Aether.wrap("Measure", ({ aetherKey }) => { @@ -56,7 +55,7 @@ export const Measure = Aether.wrap("Measure", ({ aetherKey }) => { const measureMode = Triggers.determineMode( MEASURE_TRIGGERS, triggers.current.triggers, - true, + { loose: true }, ); if (["one", "two"].includes(measureMode)) return setState((p) => ({ ...p, [measureMode]: cursor })); diff --git a/x/ts/src/compare/compare.ts b/x/ts/src/compare/compare.ts index c1df72c5ad..945d36ff94 100644 --- a/x/ts/src/compare/compare.ts +++ b/x/ts/src/compare/compare.ts @@ -9,6 +9,7 @@ import { isStringer, type Primitive } from "@/primitive"; import { type spatial } from "@/spatial"; +import { unique } from "@/unique"; export type CompareF = (a: T, b: T) => number; @@ -94,6 +95,15 @@ export const unorderedPrimitiveArrays = ( return aSorted.every((v, i) => v === bSorted[i]) ? 0 : -1; }; +export const uniqueUnorderedPrimitiveArrays = ( + a: readonly T[] | T[], + b: readonly T[] | T[], +): number => { + const uniqueA = unique(a); + const uniqueB = unique(b); + return unorderedPrimitiveArrays(uniqueA, uniqueB); +}; + export const order = (a: spatial.Order, b: spatial.Order): number => { if (a === b) return 0; if (a === "first" && b === "last") return 1;