Skip to content

Commit

Permalink
feat(examples): add rdom-key-sequences FSM example
Browse files Browse the repository at this point in the history
  • Loading branch information
postspectacular committed Aug 22, 2023
1 parent 55faa71 commit 25bce63
Show file tree
Hide file tree
Showing 8 changed files with 315 additions and 0 deletions.
Binary file added assets/examples/rdom-key-sequences.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions examples/rdom-key-sequences/README.md
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
29 changes: 29 additions & 0 deletions examples/rdom-key-sequences/index.html
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>
33 changes: 33 additions & 0 deletions examples/rdom-key-sequences/package.json
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"
}
}
218 changes: 218 additions & 0 deletions examples/rdom-key-sequences/src/index.ts
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")!);
1 change: 1 addition & 0 deletions examples/rdom-key-sequences/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
6 changes: 6 additions & 0 deletions examples/rdom-key-sequences/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "../tsconfig.json",
"include": ["src/**/*"],
"compilerOptions": {
}
}
13 changes: 13 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1440,6 +1440,19 @@ __metadata:
languageName: unknown
linkType: soft

"@example/rdom-key-sequences@workspace:examples/rdom-key-sequences":
version: 0.0.0-use.local
resolution: "@example/rdom-key-sequences@workspace:examples/rdom-key-sequences"
dependencies:
"@thi.ng/hiccup-html": "workspace:^"
"@thi.ng/rdom": "workspace:^"
"@thi.ng/rstream": "workspace:^"
"@thi.ng/transducers": "workspace:^"
typescript: ^5.1.6
vite: ^4.4.9
languageName: unknown
linkType: soft

"@example/rdom-lissajous@workspace:examples/rdom-lissajous":
version: 0.0.0-use.local
resolution: "@example/rdom-lissajous@workspace:examples/rdom-lissajous"
Expand Down

0 comments on commit 25bce63

Please sign in to comment.