feat: profile support

This commit is contained in:
2025-07-11 16:27:19 +02:00
parent 782f1fc38b
commit 74ce6af318
20 changed files with 301 additions and 232 deletions

View File

@@ -53,6 +53,7 @@
"@tauri-apps/api": "^1.6.0", "@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "^1.6.0", "@tauri-apps/cli": "^1.6.0",
"@types/dom-view-transitions": "^1.0.6", "@types/dom-view-transitions": "^1.0.6",
"@types/semver": "^7.7.0",
"@types/w3c-web-serial": "^1.0.8", "@types/w3c-web-serial": "^1.0.8",
"@types/w3c-web-usb": "^1.0.10", "@types/w3c-web-usb": "^1.0.10",
"@types/wicg-file-system-access": "^2023.10.5", "@types/wicg-file-system-access": "^2023.10.5",

8
pnpm-lock.yaml generated
View File

@@ -65,6 +65,9 @@ importers:
'@types/dom-view-transitions': '@types/dom-view-transitions':
specifier: ^1.0.6 specifier: ^1.0.6
version: 1.0.6 version: 1.0.6
'@types/semver':
specifier: ^7.7.0
version: 7.7.0
'@types/w3c-web-serial': '@types/w3c-web-serial':
specifier: ^1.0.8 specifier: ^1.0.8
version: 1.0.8 version: 1.0.8
@@ -1488,6 +1491,9 @@ packages:
'@types/retry@0.12.0': '@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
'@types/semver@7.7.0':
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
'@types/sinonjs__fake-timers@8.1.1': '@types/sinonjs__fake-timers@8.1.1':
resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==} resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==}
@@ -5669,6 +5675,8 @@ snapshots:
'@types/retry@0.12.0': {} '@types/retry@0.12.0': {}
'@types/semver@7.7.0': {}
'@types/sinonjs__fake-timers@8.1.1': {} '@types/sinonjs__fake-timers@8.1.1': {}
'@types/sizzle@2.3.8': {} '@types/sizzle@2.3.8': {}

View File

@@ -21,7 +21,7 @@ const de = {
AUTO_BACKUP: "Auto-backup", AUTO_BACKUP: "Auto-backup",
DISCLAIMER: DISCLAIMER:
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.", "Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
DOWNLOAD: "Alles", DOWNLOAD: "Komplettes Profil",
RESTORE: "Wiederherstellen", RESTORE: "Wiederherstellen",
}, },
modal: { modal: {

View File

@@ -17,7 +17,7 @@ const en = {
AUTO_BACKUP: "Auto-backup", AUTO_BACKUP: "Auto-backup",
DISCLAIMER: DISCLAIMER:
"Whenever you connect this device to browser, a backup is made locally and kept only on your computer.", "Whenever you connect this device to browser, a backup is made locally and kept only on your computer.",
DOWNLOAD: "Everything", DOWNLOAD: "Full profile",
RESTORE: "Restore", RESTORE: "Restore",
}, },
sync: { sync: {

View File

@@ -14,7 +14,7 @@ import {
settings, settings,
} from "$lib/undo-redo.js"; } from "$lib/undo-redo.js";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { serialPort } from "../serial/connection"; import { activeProfile, serialPort } from "../serial/connection";
import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout"; import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout";
import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords"; import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
@@ -50,11 +50,9 @@ export function createLayoutBackup(): CharaLayoutFile {
charaVersion: 1, charaVersion: 1,
type: "layout", type: "layout",
device: get(serialPort)?.device, device: get(serialPort)?.device,
layout: get(layout).map((it) => it.map((it) => it.action)) as [ layout: (get(layout)[get(activeProfile)]?.map((it) =>
number[], it.map((it) => it.action),
number[], ) ?? []) as [number[], number[], number[]],
number[],
],
}; };
} }
@@ -70,7 +68,7 @@ export function createSettingsBackup(): CharaSettingsFile {
return { return {
charaVersion: 1, charaVersion: 1,
type: "settings", type: "settings",
settings: get(settings).map((it) => it.value), settings: get(settings)[get(activeProfile)]?.map((it) => it.value) ?? [],
}; };
} }
@@ -97,9 +95,11 @@ export function restoreFromFile(
const recent = file.history[0]; const recent = file.history[0];
if (!recent) return; if (!recent) return;
let backupDevice = recent[1].device; let backupDevice = recent[1].device;
if (backupDevice === "TWO") backupDevice = "ONE"; if (backupDevice === "TWO" || backupDevice === "M4G")
backupDevice = "ONE";
let currentDevice = get(serialPort)?.device; let currentDevice = get(serialPort)?.device;
if (currentDevice === "TWO") currentDevice = "ONE"; if (currentDevice === "TWO" || backupDevice === "M4G")
currentDevice = "ONE";
if (backupDevice !== currentDevice) { if (backupDevice !== currentDevice) {
alert("Backup is incompatible with this device"); alert("Backup is incompatible with this device");
@@ -167,12 +167,13 @@ export function getChangesFromChordFile(file: CharaChordFile) {
export function getChangesFromSettingsFile(file: CharaSettingsFile) { export function getChangesFromSettingsFile(file: CharaSettingsFile) {
const changes: Change[] = []; const changes: Change[] = [];
for (const [id, value] of file.settings.entries()) { for (const [id, value] of file.settings.entries()) {
const setting = get(settings)[id]; const setting = get(settings)[get(activeProfile)]?.[id];
if (setting !== undefined && setting.value !== value) { if (setting !== undefined && setting.value !== value) {
changes.push({ changes.push({
type: ChangeType.Setting, type: ChangeType.Setting,
id, id,
setting: value, setting: value,
profile: get(activeProfile),
}); });
} }
} }
@@ -183,12 +184,13 @@ export function getChangesFromLayoutFile(file: CharaLayoutFile) {
const changes: Change[] = []; const changes: Change[] = [];
for (const [layer, keys] of file.layout.entries()) { for (const [layer, keys] of file.layout.entries()) {
for (const [id, action] of keys.entries()) { for (const [id, action] of keys.entries()) {
if (get(layout)[layer]?.[id]?.action !== action) { if (get(layout)[get(activeProfile)]?.[layer]?.[id]?.action !== action) {
changes.push({ changes.push({
type: ChangeType.Layout, type: ChangeType.Layout,
layer, layer,
id, id,
action, action,
profile: get(activeProfile),
}); });
} }
} }

View File

@@ -8,17 +8,16 @@
import { dev } from "$app/environment"; import { dev } from "$app/environment";
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"; import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { Writable } from "svelte/store";
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte"; import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
import { getContext, mount, unmount } from "svelte"; import { getContext, mount, unmount } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js"; import type { VisualLayoutConfig } from "./visual-layout.js";
import { changes, ChangeType, layout } from "$lib/undo-redo"; import { changes, ChangeType, layout } from "$lib/undo-redo";
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import { expoOut } from "svelte/easing"; import { expoOut } from "svelte/easing";
import { activeLayer, activeProfile } from "$lib/serial/connection";
const { scale, margin, strokeWidth, fontSize, iconFontSize } = const { scale, margin, strokeWidth, fontSize, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config"); getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer");
if (dev) { if (dev) {
// you have absolutely no idea what a difference this makes for performance // you have absolutely no idea what a difference this makes for performance
@@ -125,8 +124,10 @@
const keyInfo = layoutInfo.keys[index]; const keyInfo = layoutInfo.keys[index];
if (!keyInfo) return; if (!keyInfo) return;
const clickedGroup = groupParent.children.item(index) as SVGGElement; const clickedGroup = groupParent.children.item(index) as SVGGElement;
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id]; const nextAction =
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id]; get(layout)[get(activeProfile)][get(activeLayer)]?.[keyInfo.id];
const currentAction =
get(deviceLayout)[get(activeProfile)][get(activeLayer)]?.[keyInfo.id];
const component = mount(ActionSelector, { const component = mount(ActionSelector, {
target: document.body, target: document.body,
props: { props: {

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout"; import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout"; import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
@@ -7,10 +6,11 @@
import { osLayout } from "$lib/os-layout.js"; import { osLayout } from "$lib/os-layout.js";
import { KEYMAP_CODES } from "$lib/serial/keymap-codes"; import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { action } from "$lib/title"; import { action } from "$lib/title";
import { activeProfile, activeLayer } from "$lib/serial/connection";
import { getContext } from "svelte";
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } = const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config"); getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer");
const currentAction = getContext<Writable<Set<number>> | undefined>( const currentAction = getContext<Writable<Set<number>> | undefined>(
"highlight-action", "highlight-action",
); );
@@ -33,7 +33,9 @@
</script> </script>
{#each positions as position, layer} {#each positions as position, layer}
{@const { action: actionId, isApplied } = $layout[layer]?.[key.id] ?? { {@const { action: actionId, isApplied } = $layout[$activeProfile]?.[layer]?.[
key.id
] ?? {
action: 0, action: 0,
isApplied: true, isApplied: true,
}} }}

View File

@@ -2,21 +2,11 @@
import { deviceMeta, serialPort } from "$lib/serial/connection"; import { deviceMeta, serialPort } from "$lib/serial/connection";
import { action } from "$lib/title"; import { action } from "$lib/title";
import GenericLayout from "$lib/components/layout/GenericLayout.svelte"; import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
import { getContext } from "svelte"; import { activeProfile, activeLayer } from "$lib/serial/connection";
import type { Writable } from "svelte/store";
import type { VisualLayout } from "$lib/serialization/visual-layout"; import type { VisualLayout } from "$lib/serialization/visual-layout";
import { fade, fly } from "svelte/transition"; import { fade, fly } from "svelte/transition";
import { restoreFromFile } from "$lib/backup/backup"; import { restoreFromFile } from "$lib/backup/backup";
let device = $derived($serialPort?.device);
const activeLayer = getContext<Writable<number>>("active-layer");
const layers = [
["Numeric Layer", "123", 1],
["Primary Layer", "abc", 0],
["Function Layer", "function", 2],
] as const;
const layouts = { const layouts = {
ONE: () => ONE: () =>
import("$lib/assets/layouts/one.yml").then( import("$lib/assets/layouts/one.yml").then(
@@ -46,19 +36,25 @@
</script> </script>
<div class="container"> <div class="container">
{#if device} {#if $serialPort}
{#await layouts[device]() then visualLayout} {#await layouts[$serialPort.device]() then visualLayout}
<fieldset transition:fade> <fieldset transition:fade>
{#each layers as [title, icon, value]} <div class="layers">
<button {#each Array.from({ length: $serialPort.layerCount }, (_, i) => i) as layer}
class="icon" <label>
use:action={{ title, shortcut: `alt+${value + 1}` }} <input
onclick={() => ($activeLayer = value)} type="radio"
class:active={$activeLayer === value} onclick={() => ($activeLayer = layer)}
> name="layer"
{icon} value={layer}
</button> checked={$activeLayer === layer}
/>
{String.fromCodePoint(
"A".codePointAt(0)! + $activeProfile,
)}{layer + 1}
</label>
{/each} {/each}
</div>
{#if $deviceMeta?.factoryDefaults?.layout} {#if $deviceMeta?.factoryDefaults?.layout}
<button <button
use:action={{ title: "Reset Layout" }} use:action={{ title: "Reset Layout" }}
@@ -100,60 +96,13 @@
border: none; border: none;
} }
button.icon { .layers {
cursor: pointer; display: flex;
align-items: center;
justify-content: center;
z-index: 1; margin-inline: auto;
font-size: 24px; gap: 2px;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
border: none;
transition: all 250ms ease;
&:nth-child(2) {
z-index: 2;
aspect-ratio: 1;
font-size: 32px;
border-radius: 50%;
}
&:first-child,
&:nth-child(3) {
aspect-ratio: unset;
height: unset;
}
&:first-child {
margin-inline-end: -8px;
padding-inline: 4px 24px;
border-radius: 16px 0 0 16px;
}
&:nth-child(3) {
margin-inline-start: -8px;
padding-inline: 24px 4px;
border-radius: 0 16px 16px 0;
}
&.reset-layout {
position: absolute;
top: 50%;
right: 0;
transform: translate(100%, -50%);
background: none;
font-size: 24px;
}
&.active {
font-weight: 900;
color: var(--md-sys-color-on-tertiary);
background: var(--md-sys-color-tertiary);
}
} }
</style> </style>

View File

@@ -29,21 +29,24 @@ export const deviceChords = persistentWritable<Chord[]>(
/** /**
* Layout as read from the device * Layout as read from the device
*/ */
export const deviceLayout = persistentWritable<CharaLayout>( export const deviceLayout = persistentWritable<CharaLayout[]>(
"layout", "layout-profiles",
[[], [], []], [],
() => get(userPreferences).backup, () => get(userPreferences).backup,
); );
/** /**
* Settings as read from the device * Settings as read from the device
*/ */
export const deviceSettings = persistentWritable<number[]>( export const deviceSettings = persistentWritable<number[][]>(
"device-settings", "settings-profiles",
[], [],
() => get(userPreferences).backup, () => get(userPreferences).backup,
); );
export const activeProfile = persistentWritable<number>("active-profile", 0);
export const activeLayer = persistentWritable<number>("active-profile", 0);
export const syncStatus: Writable< export const syncStatus: Writable<
"done" | "error" | "downloading" | "uploading" "done" | "error" | "downloading" | "uploading"
> = writable("done"); > = writable("done");
@@ -80,32 +83,51 @@ export async function sync() {
.map((it) => it.items.length) .map((it) => it.items.length)
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
const max = maxSettings + device.keyCount * 3 + chordCount; const max =
(maxSettings + device.keyCount * device.layerCount) * device.profileCount +
chordCount;
let current = 0; let current = 0;
activeProfile.update((it) => Math.min(it, device.profileCount - 1));
activeLayer.update((it) => Math.min(it, device.layerCount - 1));
syncProgress.set({ max, current }); syncProgress.set({ max, current });
function progressTick() { function progressTick() {
current++; current++;
syncProgress.set({ max, current }); syncProgress.set({ max, current });
} }
const parsedSettings: number[] = []; const parsedSettings: number[][] = Array.from(
{ length: device.profileCount },
() => [],
);
for (const [profile, settings] of parsedSettings.entries()) {
for (const category of meta.settings) { for (const category of meta.settings) {
for (const setting of category.items) { for (const setting of category.items) {
try { try {
parsedSettings[setting.id] = await device.getSetting(setting.id); settings[setting.id] = await device.getSetting(profile, setting.id);
} catch {} } catch {}
} }
progressTick(); progressTick();
} }
}
deviceSettings.set(parsedSettings); deviceSettings.set(parsedSettings);
const parsedLayout: CharaLayout = [[], [], []]; const parsedLayout: CharaLayout[] = Array.from(
for (let layer = 1; layer <= 3; layer++) { { length: device.profileCount },
for (let i = 0; i < device.keyCount; i++) { () =>
parsedLayout[layer - 1]![i] = await device.getLayoutKey(layer, i); Array.from({ length: device.layerCount }, () =>
Array.from({ length: device.keyCount }, () => 0),
),
);
for (const [profile, layout] of parsedLayout.entries()) {
for (const [layer, keys] of layout.entries()) {
for (let i = 0; i < keys.length; i++) {
try {
keys[i] = await device.getLayoutKey(profile, layer + 1, i);
} catch {}
progressTick(); progressTick();
} }
} }
}
deviceLayout.set(parsedLayout); deviceLayout.set(parsedLayout);
const chordInfo = []; const chordInfo = [];

View File

@@ -1,7 +1,6 @@
import { LineBreakTransformer } from "$lib/serial/line-break-transformer"; import { LineBreakTransformer } from "$lib/serial/line-break-transformer";
import { serialLog } from "$lib/serial/connection"; import { serialLog } from "$lib/serial/connection";
import type { Chord } from "$lib/serial/chord"; import type { Chord } from "$lib/serial/chord";
import { SemVer } from "$lib/serial/sem-ver";
import { import {
parseChordActions, parseChordActions,
parsePhrase, parsePhrase,
@@ -10,6 +9,7 @@ import {
} from "$lib/serial/chord"; } from "$lib/serial/chord";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog"; import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import semverGte from "semver/functions/gte";
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([ const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }], ["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
@@ -100,11 +100,13 @@ export class CharaDevice {
private readonly suspendDebounce = 100; private readonly suspendDebounce = 100;
private suspendDebounceId?: number; private suspendDebounceId?: number;
version!: SemVer; version!: string;
company!: "CHARACHORDER" | "FORGE"; company!: "CHARACHORDER" | "FORGE";
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G"; device!: "ONE" | "TWO" | "LITE" | "X" | "M4G";
chipset!: "M0" | "S2" | "S3"; chipset!: "M0" | "S2" | "S3";
keyCount!: 90 | 67 | 256; keyCount!: 90 | 67 | 256;
layerCount = 3;
profileCount = 1;
get portInfo() { get portInfo() {
return this.port.getInfo(); return this.port.getInfo();
@@ -135,9 +137,13 @@ export class CharaDevice {
}); });
await this.port.close(); await this.port.close();
this.version = new SemVer( this.version = await this.send(1, ["VERSION"]).then(
await this.send(1, ["VERSION"]).then(([version]) => version), ([version]) => version,
); );
// TODO: beta.3
if (semverGte(this.version, "2.2.0-beta.3")) {
this.profileCount = 3;
}
const [company, device, chipset] = await this.send(3, ["ID"]); const [company, device, chipset] = await this.send(3, ["ID"]);
this.company = company as typeof this.company; this.company = company as typeof this.company;
this.device = device as typeof this.device; this.device = device as typeof this.device;
@@ -369,11 +375,16 @@ export class CharaDevice {
* @param id id of the key, refer to the individual device for where each key is * @param id id of the key, refer to the individual device for where each key is
* @param action the assigned action id * @param action the assigned action id
*/ */
async setLayoutKey(layer: number, id: number, action: number) { async setLayoutKey(
profile: number,
layer: number,
id: number,
action: number,
) {
const [status] = await this.send(1, [ const [status] = await this.send(1, [
"VAR", "VAR",
"B4", "B4",
`A${layer}`, `${String.fromCodePoint("A".codePointAt(0)! + profile)}${layer}`,
id.toString(), id.toString(),
action.toString(), action.toString(),
]); ]);
@@ -386,11 +397,11 @@ export class CharaDevice {
* @param id id of the key, refer to the individual device for where each key is * @param id id of the key, refer to the individual device for where each key is
* @returns the assigned action id * @returns the assigned action id
*/ */
async getLayoutKey(layer: number, id: number) { async getLayoutKey(profile: number, layer: number, id: number) {
const [position, status] = await this.send(2, [ const [position, status] = await this.send(2, [
"VAR", "VAR",
"B3", "B3",
`A${layer}`, `${String.fromCodePoint("A".codePointAt(0)! + profile)}${layer}`,
id.toString(), id.toString(),
]); ]);
if (status !== "0") throw new Error(`Failed with status ${status}`); if (status !== "0") throw new Error(`Failed with status ${status}`);
@@ -415,11 +426,11 @@ export class CharaDevice {
* Settings are applied until the next reboot or loss of power. * Settings are applied until the next reboot or loss of power.
* To permanently store the settings, you *must* call commit. * To permanently store the settings, you *must* call commit.
*/ */
async setSetting(id: number, value: number) { async setSetting(profile: number, id: number, value: number) {
const [status] = await this.send(1, [ const [status] = await this.send(1, [
"VAR", "VAR",
"B2", "B2",
id.toString(16).toUpperCase(), (id + profile * 0x100).toString(16).toUpperCase(),
value.toString(), value.toString(),
]); ]);
if (status !== "0") throw new Error(`Failed with status ${status}`); if (status !== "0") throw new Error(`Failed with status ${status}`);
@@ -428,11 +439,11 @@ export class CharaDevice {
/** /**
* Retrieves a setting from the device * Retrieves a setting from the device
*/ */
async getSetting(id: number): Promise<number> { async getSetting(profile: number, id: number): Promise<number> {
const [value, status] = await this.send(2, [ const [value, status] = await this.send(2, [
"VAR", "VAR",
"B1", "B1",
id.toString(16).toUpperCase(), (id + profile * 0x100).toString(16).toUpperCase(),
]); ]);
if (status !== "0") if (status !== "0")
throw new Error( throw new Error(

View File

@@ -1,32 +0,0 @@
export class SemVer {
major = 0;
minor = 0;
patch = 0;
preRelease?: string;
meta?: string;
constructor(versionString: string) {
const result =
/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+))?$/.exec(
versionString,
);
if (!result) {
console.error("Invalid version string:", versionString);
} else {
const [, major, minor, patch, preRelease, meta] = result;
this.major = Number.parseInt(major ?? "NaN");
this.minor = Number.parseInt(minor ?? "NaN");
this.patch = Number.parseInt(patch ?? "NaN");
if (preRelease) this.preRelease = preRelease;
if (meta) this.meta = meta;
}
}
toString() {
return (
`${this.major}.${this.minor}.${this.patch}` +
(this.preRelease ? `-${this.preRelease}` : "") +
(this.meta ? `+${this.meta}` : "")
);
}
}

View File

@@ -4,13 +4,10 @@ import { fromBase64, toBase64 } from "$lib/serialization/base64";
export interface NewCharaLayout { export interface NewCharaLayout {
charaLayoutVersion: 1; charaLayoutVersion: 1;
device: "one" | "lite" | string; device: "one" | "lite" | string;
/** layers: number[][];
* Layers A1-A3, with numeric action codes on each
*/
layers: [number[], number[], number[]];
} }
export type CharaLayout = [number[], number[], number[]]; export type CharaLayout = number[][];
/** /**
* Serialize a layout into a micro package * Serialize a layout into a micro package

View File

@@ -1,5 +1,8 @@
import type { Action } from "svelte/action"; import type { Action } from "svelte/action";
import { changes, ChangeType, settings } from "$lib/undo-redo"; import { changes, ChangeType, settings } from "$lib/undo-redo";
import { activeProfile } from "./serial/connection";
import { combineLatest, map } from "rxjs";
import { fromReadable } from "./util/from-readable";
/** /**
* https://gist.github.com/mjackson/5311256 * https://gist.github.com/mjackson/5311256
@@ -103,7 +106,12 @@ export const setting: Action<
? Number(node.getAttribute("max")) ? Number(node.getAttribute("max"))
: undefined; : undefined;
const unsubscribe = settings.subscribe(async (settings) => { const subscription = combineLatest([
fromReadable(settings),
fromReadable(activeProfile),
])
.pipe(map(([settings, profile]) => settings[profile]!))
.subscribe(async (settings) => {
if (id in settings) { if (id in settings) {
const { value, isApplied } = settings[id]!; const { value, isApplied } = settings[id]!;
if (isNumeric) { if (isNumeric) {
@@ -186,7 +194,7 @@ export const setting: Action<
return { return {
destroy() { destroy() {
node.removeEventListener("change", listener); node.removeEventListener("change", listener);
unsubscribe(); subscription.unsubscribe();
}, },
}; };
}; };

View File

@@ -0,0 +1,36 @@
label:has(input[type="radio"]) {
cursor: pointer;
z-index: 1;
aspect-ratio: unset;
height: 1.5em;
padding-inline: 12px;
border: none;
border-radius: 0;
font-size: 16px;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
transition: all 250ms ease;
> input[type="radio"] {
display: none;
}
&:first-child {
border-radius: 16px 0 0 16px;
}
&:last-child {
border-radius: 0 16px 16px 0;
}
&:has(:checked) {
font-weight: 900;
color: var(--md-sys-color-on-tertiary);
background: var(--md-sys-color-tertiary);
}
}

View File

@@ -2,6 +2,7 @@
@use "form/button"; @use "form/button";
@use "form/toggle"; @use "form/toggle";
@use "form/radio";
@use "kbd"; @use "kbd";
@use "print"; @use "print";

View File

@@ -19,6 +19,7 @@ export interface LayoutChange {
id: number; id: number;
layer: number; layer: number;
action: number; action: number;
profile?: number;
} }
export interface ChordChange { export interface ChordChange {
@@ -33,6 +34,7 @@ export interface SettingChange {
type: ChangeType.Setting; type: ChangeType.Setting;
id: number; id: number;
setting: number; setting: number;
profile?: number;
} }
export interface ChangeInfo { export interface ChangeInfo {
@@ -45,23 +47,29 @@ export type Change = LayoutChange | ChordChange | SettingChange;
export const changes = persistentWritable<Change[][]>("changes", []); export const changes = persistentWritable<Change[][]>("changes", []);
export interface Overlay { export interface Overlay {
layout: [Map<number, number>, Map<number, number>, Map<number, number>]; layout: Array<Array<Map<number, number> | undefined> | undefined>;
chords: Map<string, Chord & { deleted: boolean }>; chords: Map<string, Chord & { deleted: boolean }>;
settings: Map<number, number>; settings: Array<Map<number, number> | undefined>;
} }
export const overlay = derived(changes, (changes) => { export const overlay = derived(changes, (changes) => {
const overlay: Overlay = { const overlay: Overlay = {
layout: [new Map(), new Map(), new Map()], layout: [],
chords: new Map(), chords: new Map(),
settings: new Map(), settings: [],
}; };
for (const changeset of changes) { for (const changeset of changes) {
for (const change of changeset) { for (const change of changeset) {
switch (change.type) { switch (change.type) {
case ChangeType.Layout: case ChangeType.Layout:
overlay.layout[change.layer]?.set(change.id, change.action); change.profile ??= 0;
overlay.layout[change.profile] ??= [];
overlay.layout[change.profile]![change.layer] ??= new Map();
overlay.layout[change.profile]![change.layer]!.set(
change.id,
change.action,
);
break; break;
case ChangeType.Chord: case ChangeType.Chord:
overlay.chords.set(JSON.stringify(change.id), { overlay.chords.set(JSON.stringify(change.id), {
@@ -71,7 +79,9 @@ export const overlay = derived(changes, (changes) => {
}); });
break; break;
case ChangeType.Setting: case ChangeType.Setting:
overlay.settings.set(change.id, change.setting); change.profile ??= 0;
overlay.settings[change.profile] ??= new Map();
overlay.settings[change.profile]!.set(change.id, change.setting);
break; break;
} }
} }
@@ -82,22 +92,26 @@ export const overlay = derived(changes, (changes) => {
export const settings = derived( export const settings = derived(
[overlay, deviceSettings], [overlay, deviceSettings],
([overlay, settings]) => ([overlay, profiles]) =>
profiles.map((settings, profile) =>
settings.map<{ value: number } & ChangeInfo>((value, id) => ({ settings.map<{ value: number } & ChangeInfo>((value, id) => ({
value: overlay.settings.get(id) ?? value, value: overlay.settings[profile]?.get(id) ?? value,
isApplied: !overlay.settings.has(id), isApplied: !overlay.settings[profile]?.has(id),
})), })),
),
); );
export type KeyInfo = { action: number } & ChangeInfo; export type KeyInfo = { action: number } & ChangeInfo;
export const layout = derived([overlay, deviceLayout], ([overlay, layout]) => export const layout = derived([overlay, deviceLayout], ([overlay, profiles]) =>
profiles.map((layout, profile) =>
layout.map( layout.map(
(actions, layer) => (actions, layer) =>
actions.map<KeyInfo>((action, id) => ({ actions.map<KeyInfo>((action, id) => ({
action: overlay.layout[layer]?.get(id) ?? action, action: overlay.layout[profile]?.[layer]?.get(id) ?? action,
isApplied: !overlay.layout[layer]?.has(id), isApplied: !overlay.layout[profile]?.[layer]?.has(id),
})) as [KeyInfo, KeyInfo, KeyInfo], })) as [KeyInfo, KeyInfo, KeyInfo],
), ),
),
); );
export type ChordInfo = Chord & export type ChordInfo = Chord &

View File

@@ -0,0 +1,10 @@
import { Observable } from "rxjs";
import type { Readable } from "svelte/store";
export function fromReadable<T>(store: Readable<T>): Observable<T> {
return new Observable((subscriber) =>
store.subscribe((value) => {
subscriber.next(value);
}),
);
}

View File

@@ -48,10 +48,14 @@
$syncStatus = "uploading"; $syncStatus = "uploading";
const layoutChanges = $overlay.layout.reduce( const layoutChanges = $overlay.layout.reduce(
(acc, layer) => acc + layer.size, (acc, profile) =>
acc + profile.reduce((acc, layer) => acc + layer.size, 0),
0,
);
const settingChanges = $overlay.settings.reduce(
(acc, profile) => acc + profile.size,
0, 0,
); );
const settingChanges = $overlay.settings.size;
const chordChanges = $overlay.chords.size; const chordChanges = $overlay.chords.size;
const needsCommit = settingChanges > 0 || layoutChanges > 0; const needsCommit = settingChanges > 0 || layoutChanges > 0;
const progressMax = layoutChanges + settingChanges + chordChanges; const progressMax = layoutChanges + settingChanges + chordChanges;
@@ -63,6 +67,8 @@
current: progressCurrent, current: progressCurrent,
}); });
console.log($overlay);
for (const [id, chord] of $overlay.chords) { for (const [id, chord] of $overlay.chords) {
if (!chord.deleted) { if (!chord.deleted) {
if (id !== JSON.stringify(chord.actions)) { if (id !== JSON.stringify(chord.actions)) {
@@ -105,23 +111,32 @@
}); });
} }
for (const [layer, actions] of $overlay.layout.entries()) { for (const [profile, layout] of $overlay.layout.entries()) {
if (layout === undefined) continue;
for (const [layer, actions] of layout.entries()) {
if (actions === undefined) continue;
for (const [id, action] of actions) { for (const [id, action] of actions) {
await port.setLayoutKey(layer + 1, id, action); if (action === undefined) continue;
await port.setLayoutKey(profile, layer + 1, id, action);
syncProgress.set({ syncProgress.set({
max: progressMax, max: progressMax,
current: progressCurrent++, current: progressCurrent++,
}); });
} }
} }
}
for (const [id, setting] of $overlay.settings) { for (const [profile, settings] of $overlay.settings.entries()) {
await port.setSetting(id, setting); if (settings === undefined) continue;
for (const [id, setting] of settings.entries()) {
if (setting === undefined) continue;
await port.setSetting(profile, id, setting);
syncProgress.set({ syncProgress.set({
max: progressMax, max: progressMax,
current: progressCurrent++, current: progressCurrent++,
}); });
} }
}
// Yes, this is a completely arbitrary and unnecessary delay. // Yes, this is a completely arbitrary and unnecessary delay.
// The only purpose of it is to create a sense of weight, // The only purpose of it is to create a sense of weight,
@@ -134,13 +149,15 @@
await port.commit(); await port.commit();
} }
$deviceLayout = $layout.map((layer) => $deviceLayout = $layout.map((profile) =>
layer.map<number>(({ action }) => action), profile.map((layer) => layer.map<number>(({ action }) => action)),
) as [number[], number[], number[]]; );
$deviceChords = $chords $deviceChords = $chords
.filter(({ deleted }) => !deleted) .filter(({ deleted }) => !deleted)
.map(({ actions, phrase }) => ({ actions, phrase })); .map(({ actions, phrase }) => ({ actions, phrase }));
$deviceSettings = $settings.map(({ value }) => value); $deviceSettings = $settings.map((profile) =>
profile.map(({ value }) => value),
);
$changes = []; $changes = [];
} catch (e) { } catch (e) {
alert(e); alert(e);

View File

@@ -2,6 +2,7 @@
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import { canShare, triggerShare } from "$lib/share"; import { canShare, triggerShare } from "$lib/share";
import { action } from "$lib/title"; import { action } from "$lib/title";
import { activeProfile, serialPort } from "$lib/serial/connection";
import LL from "$i18n/i18n-svelte"; import LL from "$i18n/i18n-svelte";
import EditActions from "./EditActions.svelte"; import EditActions from "./EditActions.svelte";
</script> </script>
@@ -11,6 +12,23 @@
<EditActions /> <EditActions />
</div> </div>
<div class="profiles">
{#if $serialPort}
{#if $serialPort.profileCount > 1}
{#each Array.from({ length: $serialPort.profileCount }, (_, i) => i) as profile}
<label
><input
type="radio"
name="profile"
checked={profile == $activeProfile}
onclick={() => ($activeProfile = profile)}
/>{String.fromCodePoint("A".codePointAt(0)! + profile)}</label
>
{/each}
{/if}
{/if}
</div>
<div class="actions"> <div class="actions">
{#if $canShare} {#if $canShare}
<button <button
@@ -37,7 +55,7 @@
<style lang="scss"> <style lang="scss">
nav { nav {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr auto 1fr;
width: calc(min(100%, 28cm)); width: calc(min(100%, 28cm));
margin-block: 8px; margin-block: 8px;
@@ -76,6 +94,13 @@
} }
} }
.profiles {
display: flex;
gap: 2px;
align-items: center;
justify-content: center;
}
:disabled { :disabled {
pointer-events: none; pointer-events: none;
opacity: 0.5; opacity: 0.5;

View File

@@ -6,7 +6,6 @@
import { charaFileToUriComponent } from "$lib/share/share-url"; import { charaFileToUriComponent } from "$lib/share/share-url";
import SharePopup from "../SharePopup.svelte"; import SharePopup from "../SharePopup.svelte";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout"; import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import { writable } from "svelte/store";
import { layout } from "$lib/undo-redo"; import { layout } from "$lib/undo-redo";
async function shareLayout(event: Event) { async function shareLayout(event: Event) {
@@ -49,8 +48,6 @@
fontSize: 9, fontSize: 9,
iconFontSize: 14, iconFontSize: 14,
}); });
setContext("active-layer", writable(0));
</script> </script>
<svelte:head> <svelte:head>