lite layout

This commit is contained in:
2023-10-31 18:22:03 +01:00
parent fc86b31337
commit a7b49de6ac
7 changed files with 469 additions and 10 deletions

View File

@@ -1,15 +1,15 @@
{
"name": "amacc1ng",
"name": "charachorder-device-manager",
"version": "0.6.5",
"license": "AGPL-3.0-or-later",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/Theaninova/amacc1ng.git"
"url": "https://github.com/CharaChorder/DeviceManager.git"
},
"homepage": "https://github.com/Theaninova/amacc1ng",
"homepage": "https://github.com/CharaChorder/DeviceManager",
"bugs": {
"url": "https://github.com/Theaninova/amacc1ng/issues"
"url": "https://github.com/CharaChorder/DeviceManager/issues"
},
"scripts": {
"dev": "npm-run-all --parallel vite typesafe-i18n",
@@ -81,4 +81,4 @@
"vitest": "^0.34.4"
},
"type": "module"
}
}

View File

@@ -0,0 +1,87 @@
name: Lite
row:
- col:
- id: 53
- id: 54
- id: 55
- id: 56
- id: 57
- id: 58
- id: 59
- id: 60
- id: 61
- id: 62
- id: 63
- id: 64
- id: 65
- id: 66
size: [ 2, 1 ]
- col:
- id: 39
size: [ 1.5, 1 ]
- id: 40
- id: 41
- id: 42
- id: 43
- id: 44
- id: 45
- id: 46
- id: 47
- id: 48
- id: 49
- id: 50
- id: 51
- id: 52
size: [ 1.5, 1 ]
- col:
- id: 26
size: [ 1.75, 1 ]
- id: 27
- id: 28
- id: 29
- id: 30
- id: 31
- id: 32
- id: 33
- id: 34
- id: 35
- id: 36
- id: 37
- id: 38
size: [ 2.25, 1 ]
- col:
- id: 12
size: [ 2, 1 ]
- id: 13
- id: 14
- id: 15
- id: 16
- id: 17
- id: 18
- id: 19
- id: 20
- id: 21
- id: 22
- id: 23
- id: 24
- id: 25
- col:
- id: 0
- id: 1
size: [ 1.25, 1 ]
- id: 2
size: [ 1.25, 1 ]
- id: 3
size: [ 2, 1 ]
- id: 4
- id: 5
- id: 6
size: [ 2, 1 ]
- id: 7
size: [ 1.25, 1 ]
- id: 8
size: [ 1.25, 1 ]
- id: 9
- id: 10
- id: 11

View File

@@ -165,6 +165,7 @@
.content {
position: relative;
transform-origin: top left;
overflow: hidden;
display: flex;

View File

@@ -0,0 +1,296 @@
<script lang="ts">
import rawLayout from "$lib/assets/layouts/lite.yml"
import {compileLayout} from "$lib/serialization/visual-layout"
import type {VisualLayout, CompiledLayoutKey} from "$lib/serialization/visual-layout"
import {changes, layout} from "$lib/serial/connection"
import type {Change} from "$lib/serial/connection"
import {dev} from "$app/environment"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes.js"
import type {KeyInfo} from "$lib/serial/keymap-codes.js"
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"
import {get} from "svelte/store"
import type {CharaLayout} from "$lib/serialization/layout"
const scale = 50
export let inactiveScale = 0.6
export let inactiveOpacity = 0.4
export let strokeWidth = 1
export let margin = 5
export let fontSize = 9
export let iconFontSize = 14
export let activeLayer: number
if (dev) {
// you have absolutely no idea what a difference this makes for performance
console.assert(scale % 1 === 0, "Scale must be an integer")
console.assert((scale / 2) % 1 === 0, "Scale must be divisible by 2")
console.assert(strokeWidth % 1 === 0, "Stroke must be an integer")
console.assert(margin % 1 === 0, "Margin must be an integer")
console.assert(fontSize % 1 === 0, "Font size must be an integer")
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer")
}
const layoutInfo = compileLayout(rawLayout as VisualLayout)
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2]
}
function getDistance(a: CompiledLayoutKey, b: CompiledLayoutKey) {
const x1 = a.pos[0] + margin
const y1 = a.pos[1] + margin
const x1b = x1 + a.size[0] - margin
const y1b = y1 + a.size[1] - margin
const x2 = b.pos[0] + margin
const y2 = b.pos[1] + margin
const x2b = x2 + b.size[0] - margin
const y2b = y2 + b.size[1] - margin
const left = x2b < x1
const right = x1b < x2
const bottom = y2b < y1
const top = y1b < y2
return top && left
? Math.sqrt((x1 - x2b) ** 2 + (y1b - y2) ** 2)
: left && bottom
? Math.sqrt((x1 - x2b) ** 2 + (y1 - y2b) ** 2)
: bottom && right
? Math.sqrt((x1b - x2) ** 2 + (y1 - y2b) ** 2)
: right && top
? Math.sqrt((x1b - x2) ** 2 + (y1b - y2) ** 2)
: left
? x1 - x2b
: right
? x2 - x1b
: bottom
? y1 - y2b
: top
? y2 - y1b
: 0
}
function navigate(event: KeyboardEvent) {
if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) return
let wantedAngle: number
const angleThreshold = Math.PI
if (event.key === "ArrowUp") wantedAngle = Math.PI
else if (event.key === "ArrowDown") wantedAngle = 0
else if (event.key === "ArrowRight") wantedAngle = Math.PI / 2
else if (event.key === "ArrowLeft") wantedAngle = -Math.PI / 2
else return
event.preventDefault()
if (!focusKey) (groupParent.firstChild as SVGGElement).focus()
const [focusX, focusY] = getCenter(focusKey)
let bestDistance = Infinity
let bestCandidate = 0
let isOptimalAngle = false
for (const [i, key] of layoutInfo.keys.entries()) {
if (key === focusKey) continue
const [keyX, keyY] = getCenter(key)
const deltaX = keyX - focusX
const deltaY = keyY - focusY
const angle = Math.atan2(deltaX, deltaY)
const distance = getDistance(key, focusKey)
const angleDelta = Math.abs(wantedAngle - angle)
if (isOptimalAngle ? angleDelta > Number.EPSILON : angleDelta >= angleThreshold) continue
if (distance > bestDistance) continue
bestDistance = distance
bestCandidate = i
isOptimalAngle = angleDelta <= Number.EPSILON
}
const node = groupParent.children.item(bestCandidate)
if (node instanceof SVGGElement) {
node.focus()
}
}
function getActions(layer: number, id: number, layout: CharaLayout, changes: Change[]): [KeyInfo, boolean] {
const actionId = layout?.[layer][id]
const changedId = changes.findLast(it => it?.layout?.[layer]?.[id] !== undefined)?.layout?.[layer]?.[id]
if (changedId !== undefined) {
return [KEYMAP_CODES[changedId], true]
} else {
return [KEYMAP_CODES[actionId], false]
}
}
function edit(index: number) {
const keyInfo = layoutInfo.keys[index]
const clickedGroup = groupParent.children.item(index) as SVGGElement
const component = new ActionSelector({
target: document.body,
props: {currentAction: get(layout)[activeLayer][keyInfo.id]},
})
const dialog = document.querySelector("dialog > div") as HTMLDivElement
const backdrop = document.querySelector("dialog") as HTMLDialogElement
const dialogRect = dialog.getBoundingClientRect()
const groupRect = clickedGroup.getBoundingClientRect()
const scale = 0.5
const dialogScale = `${1 - scale * (1 - groupRect.width / dialogRect.width)} ${
1 - scale * (1 - groupRect.height / dialogRect.height)
}`
const dialogTranslate = `${scale * (groupRect.x - dialogRect.x)}px ${
scale * (groupRect.y - dialogRect.y)
}px`
const duration = 150
const options = {duration, easing: "ease"}
const dialogAnimation = dialog.animate(
[
{scale: dialogScale, translate: dialogTranslate},
{translate: "0 0", scale: "1"},
],
options,
)
const backdropAnimation = backdrop.animate([{opacity: 0}, {opacity: 1}], options)
async function closed() {
dialogAnimation.reverse()
backdropAnimation.reverse()
await dialogAnimation.finished
component.$destroy()
}
component.$on("close", closed)
component.$on("select", ({detail}) => {
changes.update(changes => {
changes.push({layout: {[activeLayer]: {[keyInfo.id]: detail}}})
return changes
})
closed()
})
}
let focusKey: CompiledLayoutKey
let groupParent: SVGElement
</script>
<svelte:window on:keydown={navigate} />
<p>{layoutInfo.name}</p>
<svg viewBox="0 0 {layoutInfo.size[0] * scale} {layoutInfo.size[1] * scale}" bind:this={groupParent}>
{#each layoutInfo.keys as key, i}
{@const posX = key.pos[0] * scale}
{@const posY = key.pos[1] * scale}
{@const sizeX = key.size[0] * scale}
{@const sizeY = key.size[1] * scale}
{@const middleX = sizeX / 2}
{@const middleY = sizeY / 2}
<g
class="key-group"
on:click={() => edit(i)}
on:keypress={({key}) => {
if (key === "Enter") {
edit(i)
}
}}
on:focusin={() => (focusKey = key)}
role="button"
tabindex={i + 1}
>
<rect
x={posX + margin}
y={posY + margin}
rx={margin}
width={sizeX - margin * 2}
height={sizeY - margin * 2}
stroke="currentcolor"
stroke-width={strokeWidth}
/>
{#each [1, 2, 0] as layer, i}
{@const [action, changed] = getActions(layer, key.id, $layout, $changes)}
{@const isActive = layer === activeLayer}
{@const direction = [
(middleX - margin * 3) / (i % 2 === 0 ? -1 : 1),
(middleY - margin * 3) / (i < 2 ? -1 : 1),
]}
{@const layerFontSize = action?.icon ? iconFontSize : fontSize}
<g
style="transform: {isActive
? `translate3d(0, 0, 0) scale(1)`
: `translate3d(${direction[0]}px, ${direction[1]}px, 0) scale(${inactiveScale})`}"
>
<text
fill={changed ? "var(--md-sys-color-primary)" : "currentcolor"}
text-anchor="middle"
alignment-baseline="central"
x={posX + middleX + (changed ? fontSize / 3 : 0)}
y={posY + middleY}
font-size={layerFontSize}
font-family={action?.icon ? "Material Symbols Rounded" : undefined}
opacity={isActive ? 1 : inactiveOpacity}
>
{action?.icon || action?.id || action?.code || `{${key.id}}`}
{#if changed}
<tspan font-weight="bold"></tspan>
{/if}
</text>
</g>
{/each}
</g>
{/each}
</svg>
<style lang="scss">
$focus-transition: 10ms;
$transition: 200ms;
svg {
overflow: visible;
width: calc(min(100%, 35cm));
max-height: calc(100% - 170px);
}
text {
transition:
fill #{$focus-transition} ease,
opacity #{$transition} ease;
}
rect {
fill: var(--md-sys-color-background);
transition:
fill #{$focus-transition} ease,
stroke #{$focus-transition} ease,
fill-opacity #{$focus-transition} ease;
}
g {
transform-origin: center;
transform-box: fill-box;
transition:
fill #{$focus-transition} ease,
opacity #{$transition} ease,
transform #{$transition} ease;
}
.key-group:hover {
cursor: default;
opacity: 0.6;
transition: opacity #{$transition} ease;
}
.key-group:focus-within {
color: var(--md-sys-color-primary);
outline: none;
> rect {
outline: none;
fill: currentcolor;
fill-opacity: 0.2;
}
}
</style>

View File

@@ -2,6 +2,7 @@
import {serialPort} from "$lib/serial/connection"
import LayoutCC1 from "$lib/components/layout/LayoutCC1.svelte"
import {action} from "$lib/title"
import GenericLayout from "$lib/components/layout/GenericLayout.svelte"
$: device = $serialPort?.device ?? "ONE"
let activeLayer = 0
@@ -13,7 +14,7 @@
] as const
</script>
<div>
<div class="container">
<fieldset>
{#each layers as [title, icon, value]}
<button
@@ -28,13 +29,25 @@
</fieldset>
{#if device === "ONE"}
<LayoutCC1 bind:activeLayer />
<GenericLayout bind:activeLayer />
<!-- <LayoutCC1 bind:activeLayer /> -->
{:else}
<p>Unsupported device ({$serialPort?.device})</p>
{/if}
</div>
<style lang="scss">
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin-bottom: 96px;
}
fieldset {
position: relative;

View File

@@ -0,0 +1,58 @@
export interface VisualLayout {
name: string
row: VisualLayoutRow[]
}
export interface VisualLayoutRow {
col: VisualLayoutKey[]
}
export interface VisualLayoutKey {
id: number
size?: [number, number]
}
export interface CompiledLayout {
name: string
size: [number, number]
keys: CompiledLayoutKey[]
}
export interface CompiledLayoutKey {
id: number
type: "key" | "dpad"
size: [number, number]
pos: [number, number]
}
export function compileLayout(layout: VisualLayout): CompiledLayout {
const compiled: CompiledLayout = {
name: layout.name,
size: [0, 0],
keys: [],
}
let y = 0
for (const {col} of layout.row) {
let x = 0
let maxHeight = 0
for (const {id, size} of col) {
const [width, height] = size ?? [1, 1]
compiled.keys.push({
id,
type: "key",
size: [width, height],
pos: [x, y],
})
x += width
maxHeight = Math.max(maxHeight, height)
}
y += maxHeight
compiled.size[0] = Math.max(compiled.size[0], x)
}
compiled.size[1] = y
return compiled
}

View File

@@ -59,20 +59,24 @@
<svelte:window use:share={shareLayout} />
<section>
<label class="icon"
<!-- <label class="icon"
>upload_file<input
bind:this={fileInput}
on:input={importLayout}
type="file"
accept="text/csv, application/json"
/></label
>
> -->
<Layout />
</section>
<style lang="scss">
section {
margin: auto;
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
}
input[type="file"] {