-
-
Notifications
You must be signed in to change notification settings - Fork 153
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(examples): add rdom-key-sequences FSM example
- Loading branch information
1 parent
55faa71
commit 25bce63
Showing
8 changed files
with
315 additions
and
0 deletions.
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
# rdom-key-sequences | ||
|
||
![screenshot](https://raw.githubusercontent.com/thi-ng/umbrella/develop/assets/examples/rdom-key-sequences.jpg) | ||
|
||
[Live demo](http://demo.thi.ng/umbrella/rdom-key-sequences/) | ||
|
||
Please refer to the [example build instructions](https://github.com/thi-ng/umbrella/wiki/Example-build-instructions) on the wiki. | ||
|
||
## Authors | ||
|
||
- Karsten Schmidt | ||
|
||
## License | ||
|
||
© 2023 Karsten Schmidt // Apache Software License 2.0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
<head> | ||
<link | ||
rel="icon" | ||
href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">⛱️</text></svg>' | ||
/> | ||
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8" /> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
<meta http-equiv="X-UA-Compatible" content="ie=edge" /> | ||
<title>rdom-key-sequences · @thi.ng/umbrella</title> | ||
<link | ||
href="https://unpkg.com/tachyons@4/css/tachyons.min.css" | ||
rel="stylesheet" | ||
/> | ||
<style></style> | ||
</head> | ||
<body class="ma3 sans-serif"> | ||
<div id="app"></div> | ||
<div> | ||
<a | ||
class="link" | ||
href="https://github.com/thi-ng/umbrella/tree/develop/examples/rdom-key-sequences" | ||
>Source code</a | ||
> | ||
</div> | ||
<script type="module" src="/src/index.ts"></script> | ||
</body> | ||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
{ | ||
"name": "@example/rdom-key-sequences", | ||
"version": "0.0.1", | ||
"private": true, | ||
"description": "rstream & transducer-based FSM for converting key event sequences into high-level commands", | ||
"repository": "https://github.com/thi-ng/umbrella", | ||
"author": "Karsten Schmidt <k+npm@thi.ng>", | ||
"license": "Apache-2.0", | ||
"scripts": { | ||
"start": "vite --host --open", | ||
"build": "tsc && vite build --base='./'", | ||
"preview": "vite preview --host --open" | ||
}, | ||
"devDependencies": { | ||
"typescript": "^5.1.6", | ||
"vite": "^4.4.9" | ||
}, | ||
"dependencies": { | ||
"@thi.ng/associative": "workspace:^", | ||
"@thi.ng/hiccup-html": "workspace:^", | ||
"@thi.ng/rdom": "workspace:^", | ||
"@thi.ng/rstream": "workspace:^", | ||
"@thi.ng/transducers": "workspace:^", | ||
"@thi.ng/transducers-fsm": "workspace:^" | ||
}, | ||
"browser": { | ||
"process": false | ||
}, | ||
"thi.ng": { | ||
"readme": true, | ||
"screenshot": "examples/rdom-key-sequences.jpg" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,218 @@ | ||
import { peek } from "@thi.ng/arrays"; | ||
import { objectFromKeys } from "@thi.ng/associative"; | ||
import { isString } from "@thi.ng/checks"; | ||
import { div, li, span, ul } from "@thi.ng/hiccup-html"; | ||
import { $compile, $replace } from "@thi.ng/rdom"; | ||
import { fromDOMEvent, merge, reactive, trigger } from "@thi.ng/rstream"; | ||
import { capitalize } from "@thi.ng/strings"; | ||
import { | ||
comp, | ||
filter, | ||
map, | ||
reducer, | ||
scan, | ||
sideEffect, | ||
} from "@thi.ng/transducers"; | ||
import { fsm } from "@thi.ng/transducers-fsm"; | ||
|
||
// list of keys we're interested in | ||
const MODIFIERS = ["shift", "control"]; | ||
const ALL_KEYS = ["w", "a", "s", "d", "z", "x", "k", ...MODIFIERS]; | ||
|
||
// object type for tracking multiple pressed keys | ||
type KeyStates = Record<string, boolean>; | ||
|
||
// type for finite state machine (further below) | ||
type KeySeqState = { state: string; choices: Trie }; | ||
|
||
// recursive type definition for multi-key command sequences | ||
// https://en.wikipedia.org/wiki/Trie | ||
type Trie = { [id: string]: TrieData }; | ||
type TrieData = string | Trie; | ||
|
||
// command sequences as nested data structure: | ||
// - nested objects represent key sub-sequences/choices | ||
// - string values are command IDs | ||
const COMMANDS: Trie = { | ||
control: { | ||
a: "select-all", | ||
d: "duplicate", | ||
k: { w: "close-all", s: "save-all", control: { x: "open-explorer" } }, | ||
z: "undo", | ||
shift: { z: "redo" }, | ||
}, | ||
shift: { control: { z: "redo" } }, | ||
}; | ||
|
||
// FSM initial state | ||
const FSM_INIT: KeySeqState = { state: "seq", choices: COMMANDS }; | ||
|
||
// create stream of key states from merging DOM event streams and attaching a | ||
// transducer (`xform`) to transform the raw events into a stream of | ||
// `KeyStates` objects... | ||
const keys = merge({ | ||
// source streams to merge | ||
src: [fromDOMEvent(window, "keydown"), fromDOMEvent(window, "keyup")], | ||
// composes transducer (to transform incoming values/events) | ||
xform: comp( | ||
// disable event propagation | ||
sideEffect((e) => e.preventDefault()), | ||
// skip key repeats & non-configured keys | ||
filter( | ||
(e: KeyboardEvent) => | ||
!e.repeat && ALL_KEYS.includes(e.key.toLowerCase()) | ||
), | ||
// scan is a higher-order transducer to build stepwise reductions | ||
// in this case, the "reductions" are objects keeping track of pressed keys | ||
// see: https://docs.thi.ng/umbrella/transducers/functions/scan.html | ||
// see: https://is.gd/sqJjQm (blog post section) | ||
scan( | ||
reducer<KeyStates, KeyboardEvent>( | ||
// initialize all key states to false | ||
() => objectFromKeys(ALL_KEYS, false), | ||
// state update function (triggered for each key event) | ||
(acc, e) => ({ | ||
...acc, | ||
[e.key.toLowerCase()]: e.type !== "keyup", | ||
}) | ||
) | ||
) | ||
), | ||
}); | ||
|
||
// dummy subscription for debugging | ||
keys.subscribe({ next: console.log }); | ||
// { w: true, a: false, s: false, d: false, z: false, x: false, ... } | ||
|
||
// transform key states with finite state machine to extract command sequences | ||
// if a sequence has been matched, this stream will emit respective command IDs | ||
const commands = keys.transform( | ||
fsm<KeySeqState, KeyStates, string>({ | ||
states: { | ||
// default FSM state: checks all possible next keys according to | ||
// current command trie branch | ||
seq: (fsmState, keys) => { | ||
for (let k in fsmState.choices) { | ||
if (!keys[k]) continue; | ||
const next = fsmState.choices[k]; | ||
if (typeof next === "string") { | ||
// switch the "release" state | ||
fsmState.state = "release"; | ||
// reset trie to root (and send to keyChoices stream [below]) | ||
fsmState.choices = COMMANDS; | ||
// demo only: emit state for UI inspection | ||
fsmDebug.next(fsmState); | ||
// emit command ID downstream | ||
return [next]; | ||
} else { | ||
// wait for key release, unless curr key is modifier | ||
if (!MODIFIERS.includes(k)) fsmState.state = "release"; | ||
// select next trie branch for new key choices | ||
fsmState.choices = next; | ||
// demo only: emit state for UI inspection | ||
fsmDebug.next(fsmState); | ||
return; | ||
} | ||
} | ||
// no key matched, reset to beginning | ||
fsmState.choices = COMMANDS; | ||
// demo only: emit state for UI inspection | ||
fsmDebug.next(fsmState); | ||
}, | ||
release: (state, curr) => { | ||
// wait for all keys to be released | ||
if (Object.values(curr).every((x) => !x)) { | ||
state.state = "seq"; | ||
fsmDebug.next(state); | ||
} | ||
}, | ||
// required, but unused | ||
never: () => {}, | ||
}, | ||
// FSM initialization handler (returns initial state) | ||
init: () => FSM_INIT, | ||
// FSM terminates when this state is reached (here: never) | ||
terminate: "never", | ||
}) | ||
); | ||
|
||
// stream to display internal FSM state incl. next possible keys (of a sequence) | ||
const fsmDebug = reactive(FSM_INIT).map((x) => | ||
[ | ||
"state:", | ||
x.state, | ||
x.state === "seq" | ||
? `/ key choices: ${Object.keys(x.choices).sort()}` | ||
: "/ waiting for key release", | ||
].join(" ") | ||
); | ||
|
||
// UI component function to visualize key states as color coded <span>s | ||
const keyStatesWidget = (state: KeyStates) => | ||
div( | ||
{}, | ||
...map( | ||
(key) => | ||
span( | ||
// uses https://tachyons.io/ CSS for simplicity | ||
{ | ||
class: | ||
"dib w3 pv3 mr2 tc bg-" + | ||
(state[key] ? "gold" : "light-blue"), | ||
}, | ||
key | ||
), | ||
ALL_KEYS | ||
) | ||
); | ||
|
||
// recursively converts command trie into flat array of formatted, | ||
// human-readable key command sequences | ||
const formatCommands = ( | ||
trie: Trie, | ||
acc: string[] = [], | ||
prefix: string[] = [] | ||
) => | ||
Object.entries(trie).reduce((acc, [k, v]) => { | ||
const curr = prefix.concat( | ||
prefix.length | ||
? !MODIFIERS.includes(peek(prefix)) | ||
? " " | ||
: "+" | ||
: "", | ||
k | ||
); | ||
isString(v) | ||
? acc.push(curr.map(capitalize).join("") + " : " + v) | ||
: formatCommands(v, acc, curr); | ||
return acc; | ||
}, acc); | ||
|
||
// compile & mount reactive UI/DOM | ||
$compile( | ||
div( | ||
{}, | ||
// thi.ng/rdom component wrapper which also subscribes to above `keys` stream | ||
// and re-renders DOM component for every change | ||
$replace( | ||
// create another stream merge | ||
// the dummy`trigger()` is needed to show the UI component before the 1st key press | ||
merge<KeyStates, KeyStates>({ src: [keys, trigger({})] }).map( | ||
keyStatesWidget | ||
) | ||
), | ||
div( | ||
".bg-light-yellow.mv3.pa3", | ||
{}, | ||
// internal FSM state inspection | ||
div({}, fsmDebug), | ||
// recognized command IDs | ||
div(".b", {}, "command: ", commands) | ||
), | ||
div( | ||
{}, | ||
"Available command sequences:", | ||
ul({}, ...formatCommands(COMMANDS).map((x) => li({}, x))) | ||
) | ||
) | ||
).mount(document.getElementById("app")!); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
/// <reference types="vite/client" /> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
{ | ||
"extends": "../tsconfig.json", | ||
"include": ["src/**/*"], | ||
"compilerOptions": { | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters