feat: chord editing prototype

feat: lazy device connections
feat: backup docs
feat: chord library pagination
This commit is contained in:
2023-10-27 19:39:26 +02:00
parent d8f0679233
commit fc86b31337
13 changed files with 306 additions and 60 deletions

64
docs/BACKUP.md Normal file
View File

@@ -0,0 +1,64 @@
# Chara Backup Format, Version 1
JSON Schema files: TBD
Chara backups are serialized using JSON, in this general format:
```json
{
"charaVersion": 1,
"type": "..."
}
```
The presence of the key `charaVersion` uniquely identifies the JSON file as a chara backup file and serves
as a discriminator against other generic JSON files. This key is mandatory for that reason.
## Type `layout`
```json
{
"charaVersion": 1,
"type": "layout",
"device": "one",
"layers": [[], [], []]
}
```
Devices at the current point in time may be identified as either `lite` or `one`, more to come in the future.
Layers are serialized as an array of `[layer1, layer2, layer3]` in the internal order of the key, each specifying
an action code. Action codes of `0` are considered unassigned.
## Type `chords`
```json
{
"charaVersion": 1,
"type": "chords",
"chords": [
[
[1, 2, 3],
[3, 4, 5]
],
[
[6, 7, 8],
[9, 10, 11]
]
]
}
```
Chords are serialized using a key-value mapping of chord action codes to actions.
## Type `settings`
```json
{
"charaVersion": 1,
"type": "settings",
"settings": [0, 1, 3, 6]
}
```
Settings are serialized as an array of the values in the way they appear on the device.

View File

@@ -67,6 +67,8 @@ const config: IconsConfig = {
"bolt",
"undo",
"redo",
"navigate_before",
"navigate_next",
],
codePoints: {
speed: "e9e4",

View File

@@ -62,6 +62,14 @@ const de = {
INFO_BROWSER_SUFFIX: " sich bewusst dazu entschieden die API zu deaktivieren.",
DOWNLOAD_APP: "Desktop-app herunterladen",
},
changes: {
TITLE: "Änderungen anwenden",
CHORD_ADD: "{0} Akkord{{|e}} hinzugefügt",
CHORD_EDIT: "{0} Akkord{{|e}} bearbeitet",
CHORD_DELETE: "{0} Akkord{{|e}} entfernt",
SETTING_CHANGE: "{0} Einstellung{{|en}} geändert",
LAYOUT_CHANGE: "{0} Layout-belegung{{|en}} geändert",
},
configure: {
chords: {
TITLE: "Akkorde",

View File

@@ -60,6 +60,14 @@ const en = {
INFO_BROWSER_SUFFIX: ".",
DOWNLOAD_APP: "Download the desktop app",
},
changes: {
TITLE: "Apply changes",
CHORD_ADD: "{0} chord{{|s}} added",
CHORD_EDIT: "{0} chord{{|s}} edited",
CHORD_DELETE: "{0} chord{{|s}} deleted",
SETTING_CHANGE: "{0} setting{{|s}} changed",
LAYOUT_CHANGE: "{0} layout key{{|s}} changed",
},
configure: {
chords: {
TITLE: "Chords",

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import {KEYMAP_CODES, KEYMAP_IDS, specialKeycodes} from "$lib/serial/keymap-codes"
import {onDestroy, onMount} from "svelte"
import type ActionSelector from "$lib/components/layout/ActionSelector.svelte"
export let actions: number[]
onMount(() => {
document.addEventListener("selectionchange", select)
})
onDestroy(() => {
document.removeEventListener("selectionchange", select)
})
function input(event: KeyboardEvent) {
switch (event.key) {
case "ArrowLeft":
case "ArrowRight":
case "Escape":
case "Tab": {
return
}
case "Backspace": {
caretPosition--
if (caretPosition >= 0) actions = actions.toSpliced(caretPosition, 1)
else caretPosition = 0
break
}
case "Delete": {
if (caretPosition < actions.length) actions = actions.toSpliced(caretPosition, 1)
break
}
default: {
if (specialKeycodes.has(event.key)) {
actions = actions.toSpliced(caretPosition, 0, 32)
} else if (KEYMAP_IDS.has(event.key)) {
actions = actions.toSpliced(caretPosition, 0, KEYMAP_IDS.get(event.key)!.code)
} else {
break
}
break
}
}
event.preventDefault()
console.log(event.key)
}
function select() {
const selection = document.getSelection()
if (!selection || !selection.containsNode(field, true)) return
let node = selection.anchorNode?.parentNode
let i = 0
while (node) {
i++
node = node.previousSibling
}
const range = selection.getRangeAt(0)
const clonedRange = range.cloneRange()
clonedRange.selectNodeContents(field)
clonedRange.setEnd(range.endContainer, range.endOffset)
caretPosition = (i - 1) / 2 + clonedRange.endOffset
console.log(caretPosition)
}
let editDialog: ActionSelector
let caretPosition: number
let field: HTMLSpanElement
</script>
<svelte:window on:selectionchange={select} />
<span
bind:this={field}
contenteditable
on:keydown={input}
spellcheck="false"
on:select|preventDefault={select}
role="textbox"
tabindex="0"
>
{#each actions as char}
{@const action = KEYMAP_CODES[char]}
{#if action?.id && /^\w$/.test(action.id)}
<span data-action={char}>{KEYMAP_CODES[char].id}</span>
{:else if action}
<kbd data-action={char} title={action.title} class:icon={!!action.icon}>{action.icon || action.id}</kbd>
{:else}
<kbd data-action={char}>{action}</kbd>
{/if}
{/each}
<!-- <kbd class="icon" style="background: red">abc</kbd> -->
</span>
<style lang="scss">
kbd {
min-width: 24px;
height: 24px;
margin-inline-end: 4px;
}
:not(kbd) + kbd {
margin-inline-start: 4px;
}
span[contenteditable]:focus-within {
outline-offset: 4px;
}
</style>

View File

@@ -4,7 +4,7 @@
</script>
{#if title}
<p>{title}</p>
<p>{@html title}</p>
{/if}
{#if shortcut}

View File

@@ -25,7 +25,7 @@ export const layout = persistentWritable<CharaLayout>(
export interface Change {
layout?: Record<number, Record<number, number>>
chords?: never
chords?: Array<Record<"delete" | "edit" | "add", Chord>>
settings?: Record<number, number>
}

View File

@@ -42,6 +42,7 @@ export class CharaDevice {
!manual && ports.length === 1
? ports[0]
: await navigator.serial.requestPort({filters: [{usbVendorId: VENDOR_ID}]})
await this.port.open({baudRate: this.baudRate})
const info = this.port.getInfo()
serialLog.update(it => {
@@ -53,7 +54,27 @@ export class CharaDevice {
})
return it
})
await this.port.close()
const [version] = await this.send("VERSION")
this.version = version.split(".").map(Number) as [number, number, number]
const [company, device, chipset] = await this.send("ID")
this.company = company as "CHARACHORDER"
this.device = device as "ONE" | "LITE"
this.chipset = chipset as "M0" | "S2"
}
private async suspend() {
await this.reader.cancel()
await this.streamClosed.catch(() => {
/** noop */
})
this.reader.releaseLock()
await this.port.close()
}
private async wake() {
await this.port.open({baudRate: this.baudRate})
const decoderStream = new TextDecoderStream()
this.streamClosed = this.port.readable!.pipeTo(decoderStream.writable, {
signal: this.abortController1.signal,
@@ -64,13 +85,6 @@ export class CharaDevice {
signal: this.abortController2.signal,
})
.getReader()
const [version] = await this.send("VERSION")
this.version = version.split(".").map(Number) as [number, number, number]
const [company, device, chipset] = await this.send("ID")
this.company = company as "CHARACHORDER"
this.device = device as "ONE" | "LITE"
this.chipset = chipset as "M0" | "S2"
}
private async internalRead() {
@@ -105,19 +119,9 @@ export class CharaDevice {
}
async forget() {
await this.disconnect()
await this.port.forget()
}
async disconnect() {
await this.reader.cancel()
await this.streamClosed.catch(() => {
/** noop */
})
this.reader.releaseLock()
await this.port.close()
}
/**
* Read/write to serial port
*/
@@ -132,8 +136,10 @@ export class CharaDevice {
const exec = new Promise<T>(async resolve => {
let result!: T
try {
await this.wake()
result = await callback(send, read)
} finally {
await this.suspend()
this.lock = undefined
resolve(result)
}
@@ -252,7 +258,6 @@ export class CharaDevice {
*/
async reboot() {
await this.send("RST")
await this.disconnect()
// TODO: reconnect
}
@@ -261,7 +266,6 @@ export class CharaDevice {
*/
async bootloader() {
await this.send("RST BOOTLOADER")
await this.disconnect()
// TODO: more...
}

View File

@@ -19,3 +19,17 @@ export const KEYMAP_CODES: Record<number, KeyInfo> = Object.fromEntries(
]),
),
)
export const KEYMAP_IDS: Map<string, KeyInfo> = new Map(
keymaps
.flatMap(category =>
Object.entries(category.actions).map(
([code, action]) => [action.id!, {...action, code: Number(code), category}] as const,
),
)
.filter(([id]) => id !== undefined),
)
export const specialKeycodes = new Map([
[" ", 32], // Space
])

View File

@@ -9,7 +9,11 @@ export interface CharaLayoutFile extends CharaFile<"layout"> {
}
export interface CharaChordFile extends CharaFile<"chords"> {
chords: [number[], number[]]
chords: [number[], number[]][]
}
export interface CharaChordSettings extends CharaFile<"settings"> {
settings: number[]
}
export type CharaFiles = CharaLayoutFile | CharaChordFile

View File

@@ -28,6 +28,8 @@ button {
transition: all 250ms ease;
&.icon {
display: inline-flex;
aspect-ratio: 1;
padding-block: 0;
padding-inline: 0;
@@ -41,6 +43,10 @@ button {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
&.compact {
height: 32px;
}
}
label:has(input):hover,

View File

@@ -40,8 +40,14 @@
<div class="separator" />
<button use:action={{title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s"}} class="icon">save</button>
{#if $changes.length !== 0}
<button class="click-me" transition:fly={{x: 8}} use:action={{shortcut: "ctrl+s"}}
><span class="icon">bolt</span>{$LL.saveActions.APPLY()}</button
<button
class="click-me"
transition:fly={{x: 8}}
on:click={apply}
use:action={{
title: $LL.changes.TITLE(),
shortcut: "ctrl+s",
}}><span class="icon">bolt</span>{$LL.saveActions.APPLY()}</button
>
{/if}

View File

@@ -1,9 +1,29 @@
<script lang="ts">
import {chords} from "$lib/serial/connection"
import {changes, chords} from "$lib/serial/connection"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import Index from "flexsearch"
import type {Chord} from "$lib/serial/chord"
import LL from "../../../i18n/i18n-svelte"
import {action} from "$lib/title"
import {onDestroy, onMount} from "svelte"
import ActionStringEdit from "$lib/components/ActionStringEdit.svelte"
const resultSize = 38
let results: HTMLElement
let pageSize: number
let resizeObserver: ResizeObserver
onMount(() => {
resizeObserver = new ResizeObserver(() => {
pageSize = Math.floor(results.clientHeight / resultSize)
})
pageSize = Math.floor(results.clientHeight / resultSize)
resizeObserver.observe(results)
})
onDestroy(() => {
resizeObserver?.disconnect()
})
$: searchIndex = $chords?.length > 0 ? buildIndex($chords) : undefined
@@ -23,6 +43,9 @@
}
$: items = searchFilter?.map(it => [$chords[it], it] as const) ?? $chords.map((it, i) => [it, i] as const)
$: lastPage = Math.ceil(items.length / pageSize) - 1
let page = 0
</script>
<svelte:head>
@@ -35,35 +58,32 @@
placeholder={$LL.configure.chords.search.PLACEHOLDER($chords.length)}
on:input={search}
/>
<span>{page + 1} / {lastPage + 1}</span>
<button class="icon" on:click={() => (page = Math.max(page - 1, 0))} use:action={{shortcut: "ctrl+left"}}
>navigate_before</button
>
<button
class="icon"
on:click={() => (page = Math.min(page + 1, lastPage))}
use:action={{shortcut: "ctrl+right"}}>navigate_next</button
>
</div>
<!--
{#if searchIndex}
<input
on:input={search}
type="search"
/>
{/if}-->
<section>
<section bind:this={results}>
<table>
{#each items.slice(0, 50) as [{ phrase, actions }, i]}
<tr style="view-transition-name: chord-{i}">
{#each items.slice(page * pageSize, (page + 1) * pageSize) as [chord]}
<tr>
<th>
{#each phrase as char}
{KEYMAP_CODES[char].id}
{/each}
<ActionStringEdit actions={chord.phrase} />
</th>
<td>
{#each actions as action}
{@const keyInfo = KEYMAP_CODES[action]}
{#if keyInfo}
<abbr title={keyInfo.title} class:icon={!!keyInfo.icon}>{keyInfo.icon || keyInfo.id}</abbr>
{:else}
<pre>{action}</pre>
{/if}
{/each}
<ActionStringEdit actions={chord.actions} />
</td>
<td class="table-buttons">
<button class="icon compact">share</button>
<button class="icon compact" on:click={() => $changes.push({chords: [{delete: chord}]})}
>delete</button
>
</td>
</tr>
{/each}
@@ -98,16 +118,11 @@
}
section {
--scrollbar-color: var(--md-sys-color-surface-variant);
scrollbar-gutter: stable;
position: relative;
overflow-x: hidden;
overflow-y: auto;
display: flex;
overflow: hidden;
height: 100%;
padding-inline: 8px;
border-radius: 16px;
@@ -143,10 +158,12 @@
text-align: start;
}
td {
display: flex;
gap: 4px;
align-items: stretch;
justify-content: flex-end;
.table-buttons {
opacity: 0;
transition: opacity 75ms ease;
}
tr:hover > .table-buttons {
opacity: 1;
}
</style>