mirror of https://github.com/jetkvm/kvm.git
Compare commits
7 Commits
1d4dd61d89
...
22469ca41e
| Author | SHA1 | Date |
|---|---|---|
|
|
22469ca41e | |
|
|
12d31a3d8e | |
|
|
cfd5e7cfab | |
|
|
67e9136b03 | |
|
|
d8f670fcba | |
|
|
b2b3ee40a7 | |
|
|
b8d4464904 |
74
jsonrpc.go
74
jsonrpc.go
|
|
@ -772,6 +772,8 @@ func rpcSetActiveExtension(extensionId string) error {
|
||||||
_ = unmountATXControl()
|
_ = unmountATXControl()
|
||||||
case "dc-power":
|
case "dc-power":
|
||||||
_ = unmountDCControl()
|
_ = unmountDCControl()
|
||||||
|
case "serial-buttons":
|
||||||
|
_ = unmountSerialButtons()
|
||||||
}
|
}
|
||||||
config.ActiveExtension = extensionId
|
config.ActiveExtension = extensionId
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
|
|
@ -782,6 +784,8 @@ func rpcSetActiveExtension(extensionId string) error {
|
||||||
_ = mountATXControl()
|
_ = mountATXControl()
|
||||||
case "dc-power":
|
case "dc-power":
|
||||||
_ = mountDCControl()
|
_ = mountDCControl()
|
||||||
|
case "serial-buttons":
|
||||||
|
_ = mountSerialButtons()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -816,6 +820,15 @@ func rpcGetATXState() (ATXState, error) {
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcSendCustomCommand(command string) error {
|
||||||
|
logger.Debug().Str("Command", command).Msg("JSONRPC: Sending custom serial command")
|
||||||
|
err := sendCustomCommand(command)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send custom command in jsonrpc: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type SerialSettings struct {
|
type SerialSettings struct {
|
||||||
BaudRate string `json:"baudRate"`
|
BaudRate string `json:"baudRate"`
|
||||||
DataBits string `json:"dataBits"`
|
DataBits string `json:"dataBits"`
|
||||||
|
|
@ -907,6 +920,64 @@ func rpcSetSerialSettings(settings SerialSettings) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QuickButton struct {
|
||||||
|
Id string `json:"id"` // uuid-ish
|
||||||
|
Label string `json:"label"` // shown on the button
|
||||||
|
Command string `json:"command"` // raw command to send (without auto-terminator)
|
||||||
|
Sort int `json:"sort"` // for stable ordering
|
||||||
|
}
|
||||||
|
|
||||||
|
type SerialButtonConfig struct {
|
||||||
|
Buttons []QuickButton `json:"buttons"` // slice of QuickButton
|
||||||
|
Terminator string `json:"terminator"` // CR/CRLF/None
|
||||||
|
HideSerialSettings bool `json:"hideSerialSettings"` // lowercase `bool`
|
||||||
|
HideSerialResponse bool `json:"hideSerialResponse"` // lowercase `bool`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetSerialButtonConfig() (SerialButtonConfig, error) {
|
||||||
|
config := SerialButtonConfig{
|
||||||
|
Buttons: []QuickButton{},
|
||||||
|
Terminator: "\r",
|
||||||
|
HideSerialSettings: false,
|
||||||
|
HideSerialResponse: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open("/userdata/serialButtons_config.json")
|
||||||
|
if err != nil {
|
||||||
|
logger.Debug().Msg("SerialButtons config file doesn't exist, using default")
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// load and merge the default config with the user config
|
||||||
|
var loadedConfig SerialButtonConfig
|
||||||
|
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("SerialButtons config file JSON parsing failed")
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadedConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetSerialButtonConfig(config SerialButtonConfig) error {
|
||||||
|
|
||||||
|
logger.Trace().Str("path", "/userdata/serialButtons_config.json").Msg("Saving config")
|
||||||
|
|
||||||
|
file, err := os.Create("/userdata/serialButtons_config.json")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create SerialButtons config file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(file)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(config); err != nil {
|
||||||
|
return fmt.Errorf("failed to encode SerialButtons config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcGetUsbDevices() (usbgadget.Devices, error) {
|
func rpcGetUsbDevices() (usbgadget.Devices, error) {
|
||||||
return *config.UsbDevices, nil
|
return *config.UsbDevices, nil
|
||||||
}
|
}
|
||||||
|
|
@ -1255,8 +1326,11 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
|
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
|
||||||
"getATXState": {Func: rpcGetATXState},
|
"getATXState": {Func: rpcGetATXState},
|
||||||
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
|
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
|
||||||
|
"sendCustomCommand": {Func: rpcSendCustomCommand, Params: []string{"command"}},
|
||||||
"getSerialSettings": {Func: rpcGetSerialSettings},
|
"getSerialSettings": {Func: rpcGetSerialSettings},
|
||||||
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
|
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
|
||||||
|
"getSerialButtonConfig": {Func: rpcGetSerialButtonConfig},
|
||||||
|
"setSerialButtonConfig": {Func: rpcSetSerialButtonConfig, Params: []string{"config"}},
|
||||||
"getUsbDevices": {Func: rpcGetUsbDevices},
|
"getUsbDevices": {Func: rpcGetUsbDevices},
|
||||||
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
|
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
|
||||||
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
|
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
|
||||||
|
|
|
||||||
78
serial.go
78
serial.go
|
|
@ -2,6 +2,7 @@ package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"encoding/base64"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -251,6 +252,83 @@ func setDCRestoreState(state int) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mountSerialButtons() error {
|
||||||
|
_ = port.SetMode(defaultMode)
|
||||||
|
startSerialButtonsRxLoop(currentSession)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmountSerialButtons() error {
|
||||||
|
stopSerialButtonsRxLoop()
|
||||||
|
_ = reopenSerialPort()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Serial Buttons RX fan-out (JSON-RPC events) ----
|
||||||
|
var serialButtonsRXStopCh chan struct{}
|
||||||
|
|
||||||
|
func startSerialButtonsRxLoop(session *Session) {
|
||||||
|
scopedLogger := serialLogger.With().Str("service", "custom_buttons_rx").Logger()
|
||||||
|
scopedLogger.Debug().Msg("Attempting to start RX reader.")
|
||||||
|
// Stop previous loop if running
|
||||||
|
if serialButtonsRXStopCh != nil {
|
||||||
|
close(serialButtonsRXStopCh)
|
||||||
|
}
|
||||||
|
serialButtonsRXStopCh = make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
scopedLogger.Debug().Msg("Starting loop")
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-serialButtonsRXStopCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
n, err := port.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
scopedLogger.Debug().Err(err).Msg("serial RX read error")
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n == 0 || currentSession == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Safe for any bytes: wrap in Base64
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(buf[:n])
|
||||||
|
writeJSONRPCEvent("serial.rx", map[string]any{
|
||||||
|
"base64": b64,
|
||||||
|
}, currentSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopSerialButtonsRxLoop() {
|
||||||
|
if serialButtonsRXStopCh != nil {
|
||||||
|
close(serialButtonsRXStopCh)
|
||||||
|
serialButtonsRXStopCh = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendCustomCommand(command string) error {
|
||||||
|
scopedLogger := serialLogger.With().Str("service", "custom_buttons_tx").Logger()
|
||||||
|
scopedLogger.Info().Str("Command", command).Msg("Sending custom command.")
|
||||||
|
_, err := port.Write([]byte("\n"))
|
||||||
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("Failed to send serial output \\n")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = port.Write([]byte(command))
|
||||||
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Str("line", command).Msg("Failed to send serial output")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var defaultMode = &serial.Mode{
|
var defaultMode = &serial.Mode{
|
||||||
BaudRate: 115200,
|
BaudRate: 115200,
|
||||||
DataBits: 8,
|
DataBits: 8,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,494 @@
|
||||||
|
import { LuPlus, LuTrash2, LuPencil, LuSettings2, LuEye, LuEyeOff, LuSave, LuArrowBigUp, LuArrowBigDown, LuCirclePause, LuCirclePlay } from "react-icons/lu";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@components/Button";
|
||||||
|
import Card from "@components/Card";
|
||||||
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
|
import { InputFieldWithLabel } from "@components/InputField";
|
||||||
|
import { TextAreaWithLabel } from "@components/TextArea";
|
||||||
|
|
||||||
|
/** ============== Types ============== */
|
||||||
|
|
||||||
|
interface SerialSettings {
|
||||||
|
baudRate: string;
|
||||||
|
dataBits: string;
|
||||||
|
stopBits: string;
|
||||||
|
parity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickButton {
|
||||||
|
id: string; // uuid-ish
|
||||||
|
label: string; // shown on the button
|
||||||
|
command: string; // raw command to send (without auto-terminator)
|
||||||
|
sort: number; // for stable ordering
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ButtonConfig {
|
||||||
|
buttons: QuickButton[];
|
||||||
|
terminator: string; // CR/CRLF/None
|
||||||
|
hideSerialSettings: boolean;
|
||||||
|
hideSerialResponse: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ============== Component ============== */
|
||||||
|
|
||||||
|
export function SerialButtons() {
|
||||||
|
// This will receive all JSON-RPC notifications (method + no id)
|
||||||
|
const { send } = useJsonRpc((payload) => {
|
||||||
|
if (payload.method !== "serial.rx") return;
|
||||||
|
if (paused) return;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const p = payload.params as any;
|
||||||
|
let chunk = "";
|
||||||
|
|
||||||
|
if (typeof p?.base64 === "string") {
|
||||||
|
try {
|
||||||
|
chunk = atob(p.base64);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed base64
|
||||||
|
}
|
||||||
|
} else if (typeof p?.data === "string") {
|
||||||
|
// fallback if you ever send plain text
|
||||||
|
chunk = p.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chunk) return;
|
||||||
|
|
||||||
|
// Normalize CRLF for display
|
||||||
|
chunk = chunk.replace(/\r\n/g, "\n");
|
||||||
|
|
||||||
|
setSerialResponse(prev => (prev + chunk).slice(-MAX_CHARS));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const MAX_CHARS = 50_000;
|
||||||
|
|
||||||
|
// serial settings (same as SerialConsole)
|
||||||
|
const [serialSettings, setSerialSettings] = useState<SerialSettings>({
|
||||||
|
baudRate: "9600",
|
||||||
|
dataBits: "8",
|
||||||
|
stopBits: "1",
|
||||||
|
parity: "none",
|
||||||
|
});
|
||||||
|
|
||||||
|
// extension config (buttons + prefs)
|
||||||
|
const [buttonConfig, setButtonConfig] = useState<ButtonConfig>({
|
||||||
|
buttons: [],
|
||||||
|
terminator: "",
|
||||||
|
hideSerialSettings: false,
|
||||||
|
hideSerialResponse: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// editor modal state
|
||||||
|
const [editorOpen, setEditorOpen] = useState<null | { id?: string }>(null);
|
||||||
|
const [draftLabel, setDraftLabel] = useState("");
|
||||||
|
const [draftCmd, setDraftCmd] = useState("");
|
||||||
|
const [serialResponse, setSerialResponse] = useState("");
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// load serial settings like SerialConsole
|
||||||
|
useEffect(() => {
|
||||||
|
send("getSerialSettings", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to get serial settings: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSerialSettings(resp.result as SerialSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
send("getSerialButtonConfig", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to get button config: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setButtonConfig(resp.result as ButtonConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSerialSettingChange = (setting: keyof SerialSettings, value: string) => {
|
||||||
|
const newSettings = { ...serialSettings, [setting]: value };
|
||||||
|
send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to update serial settings: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSerialSettings(newSettings);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSerialButtonConfigChange = (config: keyof ButtonConfig, value: unknown) => {
|
||||||
|
const newButtonConfig = { ...buttonConfig, [config]: value };
|
||||||
|
send("setSerialButtonConfig", { config: newButtonConfig }, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to update button config: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setButtonConfig(newButtonConfig);
|
||||||
|
});
|
||||||
|
setButtonConfig(newButtonConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (buttonConfig.hideSerialResponse) return;
|
||||||
|
const el = taRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [serialResponse, buttonConfig.hideSerialResponse]);
|
||||||
|
|
||||||
|
const onClickButton = (btn: QuickButton) => {
|
||||||
|
|
||||||
|
/** build final string to send:
|
||||||
|
* if the user's button command already contains a terminator, we don't append the selected terminator safely
|
||||||
|
*/
|
||||||
|
const raw = btn.command;
|
||||||
|
const t = buttonConfig.terminator ?? "";
|
||||||
|
const command = raw.endsWith("\r") || raw.endsWith("\n") ? raw : raw + t;
|
||||||
|
|
||||||
|
send("sendCustomCommand", { command }, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
/** CRUD helpers */
|
||||||
|
const addNew = () => {
|
||||||
|
setEditorOpen({ id: undefined });
|
||||||
|
setDraftLabel("");
|
||||||
|
setDraftCmd("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const editBtn = (btn: QuickButton) => {
|
||||||
|
setEditorOpen({ id: btn.id });
|
||||||
|
setDraftLabel(btn.label);
|
||||||
|
setDraftCmd(btn.command);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeBtn = (id: string) => {
|
||||||
|
const nextButtons = buttonConfig.buttons.filter(b => b.id !== id).map((b, i) => ({ ...b, sort: i })) ;
|
||||||
|
handleSerialButtonConfigChange("buttons", stableSort(nextButtons) );
|
||||||
|
setEditorOpen(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveUpBtn = (id: string) => {
|
||||||
|
// Make a copy so we don't mutate state directly
|
||||||
|
const newButtons = [...buttonConfig.buttons];
|
||||||
|
|
||||||
|
// Find the index of the button to move
|
||||||
|
const index = newButtons.findIndex(b => b.id === id);
|
||||||
|
|
||||||
|
if (index > 0) {
|
||||||
|
// Swap with the previous element
|
||||||
|
[newButtons[index - 1], newButtons[index]] = [
|
||||||
|
newButtons[index],
|
||||||
|
newButtons[index - 1],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-assign sort values
|
||||||
|
const nextButtons = newButtons.map((b, i) => ({ ...b, sort: i }));
|
||||||
|
handleSerialButtonConfigChange("buttons", stableSort(nextButtons) );
|
||||||
|
setEditorOpen(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveDownBtn = (id: string) => {
|
||||||
|
// Make a copy so we don't mutate state directly
|
||||||
|
const newButtons = [...buttonConfig.buttons];
|
||||||
|
|
||||||
|
// Find the index of the button to move
|
||||||
|
const index = newButtons.findIndex(b => b.id === id);
|
||||||
|
|
||||||
|
if (index >= 0 && index < newButtons.length - 1) {
|
||||||
|
// Swap with the next element
|
||||||
|
[newButtons[index], newButtons[index + 1]] = [
|
||||||
|
newButtons[index + 1],
|
||||||
|
newButtons[index],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-assign sort values
|
||||||
|
const nextButtons = newButtons.map((b, i) => ({ ...b, sort: i }));
|
||||||
|
handleSerialButtonConfigChange("buttons", stableSort(nextButtons) );
|
||||||
|
setEditorOpen(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDraft = () => {
|
||||||
|
const label = draftLabel.trim() || "Unnamed";
|
||||||
|
const command = draftCmd.trim();
|
||||||
|
if (!command) {
|
||||||
|
notifications.error("Command cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEdit = editorOpen?.id;
|
||||||
|
const nextButtons = isEdit
|
||||||
|
? buttonConfig.buttons.map(b => (b.id === isEdit ? { ...b, label, command } : b))
|
||||||
|
: [...buttonConfig.buttons, { id: genId(), label, command, sort: buttonConfig.buttons.length }];
|
||||||
|
|
||||||
|
handleSerialButtonConfigChange("buttons", stableSort(nextButtons) );
|
||||||
|
setEditorOpen(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** simple reordering: alphabetical by sort, then label */
|
||||||
|
const sortedButtons = useMemo(() => stableSort(buttonConfig.buttons), [buttonConfig.buttons]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsPageHeader
|
||||||
|
title="Serial Buttons"
|
||||||
|
description="Quick custom commands over the extension serial port"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="animate-fadeIn opacity-0">
|
||||||
|
<div className="space-y-4 p-3">
|
||||||
|
{/* Top actions */}
|
||||||
|
<div className="flex flex-wrap justify-around items-center gap-3">
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="primary"
|
||||||
|
LeadingIcon={buttonConfig.hideSerialSettings ? LuEye : LuEyeOff}
|
||||||
|
text={buttonConfig.hideSerialSettings ? "Show Settings" : "Hide Settings"}
|
||||||
|
onClick={() => handleSerialButtonConfigChange("hideSerialSettings", !buttonConfig.hideSerialSettings )}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="primary"
|
||||||
|
LeadingIcon={LuPlus}
|
||||||
|
text="Add Button"
|
||||||
|
onClick={addNew}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="primary"
|
||||||
|
LeadingIcon={buttonConfig.hideSerialResponse ? LuEye : LuEyeOff}
|
||||||
|
text={buttonConfig.hideSerialResponse ? "View RX" : "Hide RX"}
|
||||||
|
onClick={() => handleSerialButtonConfigChange("hideSerialResponse", !buttonConfig.hideSerialResponse )}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<hr className="border-slate-700/30 dark:border-slate-600/30" />
|
||||||
|
|
||||||
|
{/* Serial settings (collapsible) */}
|
||||||
|
{!buttonConfig.hideSerialSettings && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<SelectMenuBasic
|
||||||
|
label="Baud Rate"
|
||||||
|
options={[
|
||||||
|
{ label: "1200", value: "1200" },
|
||||||
|
{ label: "2400", value: "2400" },
|
||||||
|
{ label: "4800", value: "4800" },
|
||||||
|
{ label: "9600", value: "9600" },
|
||||||
|
{ label: "19200", value: "19200" },
|
||||||
|
{ label: "38400", value: "38400" },
|
||||||
|
{ label: "57600", value: "57600" },
|
||||||
|
{ label: "115200", value: "115200" },
|
||||||
|
]}
|
||||||
|
value={serialSettings.baudRate}
|
||||||
|
onChange={(e) => handleSerialSettingChange("baudRate", e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectMenuBasic
|
||||||
|
label="Data Bits"
|
||||||
|
options={[
|
||||||
|
{ label: "8", value: "8" },
|
||||||
|
{ label: "7", value: "7" },
|
||||||
|
]}
|
||||||
|
value={serialSettings.dataBits}
|
||||||
|
onChange={(e) => handleSerialSettingChange("dataBits", e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectMenuBasic
|
||||||
|
label="Stop Bits"
|
||||||
|
options={[
|
||||||
|
{ label: "1", value: "1" },
|
||||||
|
{ label: "1.5", value: "1.5" },
|
||||||
|
{ label: "2", value: "2" },
|
||||||
|
]}
|
||||||
|
value={serialSettings.stopBits}
|
||||||
|
onChange={(e) => handleSerialSettingChange("stopBits", e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectMenuBasic
|
||||||
|
label="Parity"
|
||||||
|
options={[
|
||||||
|
{ label: "None", value: "none" },
|
||||||
|
{ label: "Even", value: "even" },
|
||||||
|
{ label: "Odd", value: "odd" },
|
||||||
|
]}
|
||||||
|
value={serialSettings.parity}
|
||||||
|
onChange={(e) => handleSerialSettingChange("parity", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SelectMenuBasic
|
||||||
|
label="Line ending"
|
||||||
|
options={[
|
||||||
|
{ label: "None", value: "" },
|
||||||
|
{ label: "CR (\\r)", value: "\r" },
|
||||||
|
{ label: "CRLF (\\r\\n)", value: "\r\n" },
|
||||||
|
]}
|
||||||
|
value={buttonConfig.terminator}
|
||||||
|
onChange={(e) => handleSerialButtonConfigChange("terminator", e.target.value)}
|
||||||
|
/>
|
||||||
|
<hr className="border-slate-700/30 dark:border-slate-600/30" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||||
|
{sortedButtons.map((btn) => (
|
||||||
|
<div key={btn.id} className="flex items-stretch gap-2 min-w-0">
|
||||||
|
<div className=" flex-1 min-w-0 ">
|
||||||
|
<Button
|
||||||
|
size="MD"
|
||||||
|
fullWidth
|
||||||
|
className="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
theme="primary"
|
||||||
|
text={btn.label}
|
||||||
|
onClick={() => onClickButton(btn)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="MD"
|
||||||
|
theme="light"
|
||||||
|
className="shrink-0"
|
||||||
|
LeadingIcon={LuPencil}
|
||||||
|
onClick={() => editBtn(btn)}
|
||||||
|
aria-label={`Edit ${btn.label}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sortedButtons.length === 0 && (
|
||||||
|
<div className="col-span-2 text-sm text-black dark:text-slate-300">No buttons yet. Click “Add Button”.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor drawer/modal (inline lightweight) */}
|
||||||
|
{editorOpen && (
|
||||||
|
<div className="mt-4 border rounded-md p-3 bg-slate-50 dark:bg-slate-900/30">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<LuSettings2 className="h-3.5 text-white shrink-0 justify-start" />
|
||||||
|
<div className="font-medium text-black dark:text-white">{editorOpen.id ? "Edit Button" : "New Button"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<InputFieldWithLabel
|
||||||
|
size="SM"
|
||||||
|
type="text"
|
||||||
|
label="Label"
|
||||||
|
placeholder="New Command"
|
||||||
|
value={draftLabel}
|
||||||
|
onChange={e => {
|
||||||
|
setDraftLabel(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<InputFieldWithLabel
|
||||||
|
size="SM"
|
||||||
|
type="text"
|
||||||
|
label="Command"
|
||||||
|
placeholder="Command to send"
|
||||||
|
value={draftCmd}
|
||||||
|
onChange={e => {
|
||||||
|
setDraftCmd(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{buttonConfig.terminator != "" && (
|
||||||
|
<div className="text-xs text-white opacity-70 mt-1">
|
||||||
|
The selected line ending ({pretty(buttonConfig.terminator)}) will be appended when sent.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<Button size="SM" theme="primary" LeadingIcon={LuSave} text="Save" onClick={saveDraft} />
|
||||||
|
<Button size="SM" theme="primary" text="Cancel" onClick={() => setEditorOpen(null)} />
|
||||||
|
{editorOpen.id && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="danger"
|
||||||
|
LeadingIcon={LuTrash2}
|
||||||
|
text="Delete"
|
||||||
|
onClick={() => removeBtn(editorOpen.id!)}
|
||||||
|
aria-label={`Delete ${draftLabel}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="primary"
|
||||||
|
LeadingIcon={LuArrowBigUp}
|
||||||
|
onClick={() => moveUpBtn(editorOpen.id!)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="primary"
|
||||||
|
LeadingIcon={LuArrowBigDown}
|
||||||
|
onClick={() => moveDownBtn(editorOpen.id!)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Serial response (collapsible) */}
|
||||||
|
{!buttonConfig.hideSerialResponse && (
|
||||||
|
<>
|
||||||
|
<hr className="border-slate-700/30 dark:border-slate-600/30" />
|
||||||
|
<TextAreaWithLabel
|
||||||
|
ref={taRef}
|
||||||
|
readOnly
|
||||||
|
label="RX response from serial connection"
|
||||||
|
value={serialResponse|| ""}
|
||||||
|
rows={3}
|
||||||
|
onChange={e => setSerialResponse(e.target.value)}
|
||||||
|
placeholder="Will show the response recieved from the serial port."
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="primary"
|
||||||
|
text={paused ? "Resume" : "Pause"}
|
||||||
|
LeadingIcon={paused ? LuCirclePlay : LuCirclePause}
|
||||||
|
onClick={() => setPaused(p => !p)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="primary"
|
||||||
|
text="Clear"
|
||||||
|
LeadingIcon={LuTrash2}
|
||||||
|
onClick={() => setSerialResponse("")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ============== helpers ============== */
|
||||||
|
|
||||||
|
function pretty(s: string) {
|
||||||
|
return s.replace(/\r/g, "\\r").replace(/\n/g, "\\n");
|
||||||
|
}
|
||||||
|
function genId() {
|
||||||
|
return "b_" + Math.random().toString(36).slice(2, 10);
|
||||||
|
}
|
||||||
|
function stableSort(arr: QuickButton[]) {
|
||||||
|
return [...arr].sort((a, b) => (a.sort - b.sort) || a.label.localeCompare(b.label));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
|
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
|
||||||
import { DCPowerControl } from "@components/extensions/DCPowerControl";
|
import { DCPowerControl } from "@components/extensions/DCPowerControl";
|
||||||
import { SerialConsole } from "@components/extensions/SerialConsole";
|
import { SerialConsole } from "@components/extensions/SerialConsole";
|
||||||
|
import { SerialButtons } from "@components/extensions/SerialButtons";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
|
|
@ -36,6 +37,12 @@ const AVAILABLE_EXTENSIONS: Extension[] = [
|
||||||
description: "Access your serial console extension",
|
description: "Access your serial console extension",
|
||||||
icon: LuTerminal,
|
icon: LuTerminal,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "serial-buttons",
|
||||||
|
name: "Serial Buttons",
|
||||||
|
description: "Send custom serial signals by buttons",
|
||||||
|
icon: LuTerminal,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ExtensionPopover() {
|
export default function ExtensionPopover() {
|
||||||
|
|
@ -76,6 +83,8 @@ export default function ExtensionPopover() {
|
||||||
return <DCPowerControl />;
|
return <DCPowerControl />;
|
||||||
case "serial-console":
|
case "serial-console":
|
||||||
return <SerialConsole />;
|
return <SerialConsole />;
|
||||||
|
case "serial-buttons":
|
||||||
|
return <SerialButtons />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,6 @@ import { fr_FR } from "@/keyboardLayouts/fr_FR"
|
||||||
import { it_IT } from "@/keyboardLayouts/it_IT"
|
import { it_IT } from "@/keyboardLayouts/it_IT"
|
||||||
import { nb_NO } from "@/keyboardLayouts/nb_NO"
|
import { nb_NO } from "@/keyboardLayouts/nb_NO"
|
||||||
import { sv_SE } from "@/keyboardLayouts/sv_SE"
|
import { sv_SE } from "@/keyboardLayouts/sv_SE"
|
||||||
|
import { da_DK } from "@/keyboardLayouts/da_DK"
|
||||||
|
|
||||||
export const keyboards: KeyboardLayout[] = [ cs_CZ, de_CH, de_DE, en_UK, en_US, es_ES, fr_BE, fr_CH, fr_FR, it_IT, nb_NO, sv_SE ];
|
export const keyboards: KeyboardLayout[] = [ cs_CZ, de_CH, de_DE, en_UK, en_US, es_ES, fr_BE, fr_CH, fr_FR, it_IT, nb_NO, sv_SE, da_DK ];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||||
|
|
||||||
|
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
||||||
|
|
||||||
|
export const name = "Dansk";
|
||||||
|
const isoCode = "da-DK";
|
||||||
|
|
||||||
|
const keyTrema = { key: "BracketRight" }
|
||||||
|
const keyAcute = { key: "Equal", altRight: true }
|
||||||
|
const keyHat = { key: "BracketRight", shift: true }
|
||||||
|
const keyGrave = { key: "Equal", shift: true }
|
||||||
|
const keyTilde = { key: "BracketRight", altRight: true }
|
||||||
|
|
||||||
|
export const chars = {
|
||||||
|
A: { key: "KeyA", shift: true },
|
||||||
|
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
|
||||||
|
"Á": { key: "KeyA", shift: true, accentKey: keyAcute },
|
||||||
|
"Â": { key: "KeyA", shift: true, accentKey: keyHat },
|
||||||
|
"À": { key: "KeyA", shift: true, accentKey: keyGrave },
|
||||||
|
"Ã": { key: "KeyA", shift: true, accentKey: keyTilde },
|
||||||
|
B: { key: "KeyB", shift: true },
|
||||||
|
C: { key: "KeyC", shift: true },
|
||||||
|
D: { key: "KeyD", shift: true },
|
||||||
|
E: { key: "KeyE", shift: true },
|
||||||
|
"Ë": { key: "KeyE", shift: true, accentKey: keyTrema },
|
||||||
|
"É": { key: "KeyE", shift: true, accentKey: keyAcute },
|
||||||
|
"Ê": { key: "KeyE", shift: true, accentKey: keyHat },
|
||||||
|
"È": { key: "KeyE", shift: true, accentKey: keyGrave },
|
||||||
|
"Ẽ": { key: "KeyE", shift: true, accentKey: keyTilde },
|
||||||
|
F: { key: "KeyF", shift: true },
|
||||||
|
G: { key: "KeyG", shift: true },
|
||||||
|
H: { key: "KeyH", shift: true },
|
||||||
|
I: { key: "KeyI", shift: true },
|
||||||
|
"Ï": { key: "KeyI", shift: true, accentKey: keyTrema },
|
||||||
|
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
|
||||||
|
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
|
||||||
|
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
|
||||||
|
"Ĩ": { key: "KeyI", shift: true, accentKey: keyTilde },
|
||||||
|
J: { key: "KeyJ", shift: true },
|
||||||
|
K: { key: "KeyK", shift: true },
|
||||||
|
L: { key: "KeyL", shift: true },
|
||||||
|
M: { key: "KeyM", shift: true },
|
||||||
|
N: { key: "KeyN", shift: true },
|
||||||
|
O: { key: "KeyO", shift: true },
|
||||||
|
"Ö": { key: "KeyO", shift: true, accentKey: keyTrema },
|
||||||
|
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
|
||||||
|
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
|
||||||
|
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
|
||||||
|
"Õ": { key: "KeyO", shift: true, accentKey: keyTilde },
|
||||||
|
P: { key: "KeyP", shift: true },
|
||||||
|
Q: { key: "KeyQ", shift: true },
|
||||||
|
R: { key: "KeyR", shift: true },
|
||||||
|
S: { key: "KeyS", shift: true },
|
||||||
|
T: { key: "KeyT", shift: true },
|
||||||
|
U: { key: "KeyU", shift: true },
|
||||||
|
"Ü": { key: "KeyU", shift: true, accentKey: keyTrema },
|
||||||
|
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
|
||||||
|
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
|
||||||
|
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
|
||||||
|
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
|
||||||
|
V: { key: "KeyV", shift: true },
|
||||||
|
W: { key: "KeyW", shift: true },
|
||||||
|
X: { key: "KeyX", shift: true },
|
||||||
|
Y: { key: "KeyY", shift: true },
|
||||||
|
Z: { key: "KeyZ", shift: true },
|
||||||
|
a: { key: "KeyA" },
|
||||||
|
"ä": { key: "KeyA", accentKey: keyTrema },
|
||||||
|
"á": { key: "KeyA", accentKey: keyAcute },
|
||||||
|
"â": { key: "KeyA", accentKey: keyHat },
|
||||||
|
"à": { key: "KeyA", accentKey: keyGrave },
|
||||||
|
"ã": { key: "KeyA", accentKey: keyTilde },
|
||||||
|
b: { key: "KeyB" },
|
||||||
|
c: { key: "KeyC" },
|
||||||
|
d: { key: "KeyD" },
|
||||||
|
e: { key: "KeyE" },
|
||||||
|
"ë": { key: "KeyE", accentKey: keyTrema },
|
||||||
|
"é": { key: "KeyE", accentKey: keyAcute },
|
||||||
|
"ê": { key: "KeyE", accentKey: keyHat },
|
||||||
|
"è": { key: "KeyE", accentKey: keyGrave },
|
||||||
|
"ẽ": { key: "KeyE", accentKey: keyTilde },
|
||||||
|
"€": { key: "KeyE", altRight: true },
|
||||||
|
f: { key: "KeyF" },
|
||||||
|
g: { key: "KeyG" },
|
||||||
|
h: { key: "KeyH" },
|
||||||
|
i: { key: "KeyI" },
|
||||||
|
"ï": { key: "KeyI", accentKey: keyTrema },
|
||||||
|
"í": { key: "KeyI", accentKey: keyAcute },
|
||||||
|
"î": { key: "KeyI", accentKey: keyHat },
|
||||||
|
"ì": { key: "KeyI", accentKey: keyGrave },
|
||||||
|
"ĩ": { key: "KeyI", accentKey: keyTilde },
|
||||||
|
j: { key: "KeyJ" },
|
||||||
|
k: { key: "KeyK" },
|
||||||
|
l: { key: "KeyL" },
|
||||||
|
m: { key: "KeyM" },
|
||||||
|
n: { key: "KeyN" },
|
||||||
|
o: { key: "KeyO" },
|
||||||
|
"ö": { key: "KeyO", accentKey: keyTrema },
|
||||||
|
"ó": { key: "KeyO", accentKey: keyAcute },
|
||||||
|
"ô": { key: "KeyO", accentKey: keyHat },
|
||||||
|
"ò": { key: "KeyO", accentKey: keyGrave },
|
||||||
|
"õ": { key: "KeyO", accentKey: keyTilde },
|
||||||
|
p: { key: "KeyP" },
|
||||||
|
q: { key: "KeyQ" },
|
||||||
|
r: { key: "KeyR" },
|
||||||
|
s: { key: "KeyS" },
|
||||||
|
t: { key: "KeyT" },
|
||||||
|
u: { key: "KeyU" },
|
||||||
|
"ü": { key: "KeyU", accentKey: keyTrema },
|
||||||
|
"ú": { key: "KeyU", accentKey: keyAcute },
|
||||||
|
"û": { key: "KeyU", accentKey: keyHat },
|
||||||
|
"ù": { key: "KeyU", accentKey: keyGrave },
|
||||||
|
"ũ": { key: "KeyU", accentKey: keyTilde },
|
||||||
|
v: { key: "KeyV" },
|
||||||
|
w: { key: "KeyW" },
|
||||||
|
x: { key: "KeyX" },
|
||||||
|
y: { key: "KeyY" }, // <-- corrected
|
||||||
|
z: { key: "KeyZ" }, // <-- corrected
|
||||||
|
"½": { key: "Backquote" },
|
||||||
|
"§": { key: "Backquote", shift: true },
|
||||||
|
1: { key: "Digit1" },
|
||||||
|
"!": { key: "Digit1", shift: true },
|
||||||
|
2: { key: "Digit2" },
|
||||||
|
"\"": { key: "Digit2", shift: true },
|
||||||
|
"@": { key: "Digit2", altRight: true },
|
||||||
|
3: { key: "Digit3" },
|
||||||
|
"#": { key: "Digit3", shift: true },
|
||||||
|
"£": { key: "Digit3", altRight: true },
|
||||||
|
4: { key: "Digit4" },
|
||||||
|
"¤": { key: "Digit4", shift: true },
|
||||||
|
"$": { key: "Digit4", altRight: true },
|
||||||
|
5: { key: "Digit5" },
|
||||||
|
"%": { key: "Digit5", shift: true },
|
||||||
|
6: { key: "Digit6" },
|
||||||
|
"&": { key: "Digit6", shift: true },
|
||||||
|
7: { key: "Digit7" },
|
||||||
|
"/": { key: "Digit7", shift: true },
|
||||||
|
"{": { key: "Digit7", altRight: true },
|
||||||
|
8: { key: "Digit8" },
|
||||||
|
"(": { key: "Digit8", shift: true },
|
||||||
|
"[": { key: "Digit8", altRight: true },
|
||||||
|
9: { key: "Digit9" },
|
||||||
|
")": { key: "Digit9", shift: true },
|
||||||
|
"]": { key: "Digit9", altRight: true },
|
||||||
|
0: { key: "Digit0" },
|
||||||
|
"=": { key: "Digit0", shift: true },
|
||||||
|
"}": { key: "Digit0", altRight: true },
|
||||||
|
"+": { key: "Minus" },
|
||||||
|
"?": { key: "Minus", shift: true },
|
||||||
|
"\\": { key: "Equal" },
|
||||||
|
"å": { key: "BracketLeft" },
|
||||||
|
"Å": { key: "BracketLeft", shift: true },
|
||||||
|
"ø": { key: "Semicolon" },
|
||||||
|
"Ø": { key: "Semicolon", shift: true },
|
||||||
|
"æ": { key: "Quote" },
|
||||||
|
"Æ": { key: "Quote", shift: true },
|
||||||
|
"'": { key: "Backslash" },
|
||||||
|
"*": { key: "Backslash", shift: true },
|
||||||
|
",": { key: "Comma" },
|
||||||
|
";": { key: "Comma", shift: true },
|
||||||
|
".": { key: "Period" },
|
||||||
|
":": { key: "Period", shift: true },
|
||||||
|
"-": { key: "Slash" },
|
||||||
|
"_": { key: "Slash", shift: true },
|
||||||
|
"<": { key: "IntlBackslash" },
|
||||||
|
">": { key: "IntlBackslash", shift: true },
|
||||||
|
"~": { key: "BracketRight", deadKey: true, altRight: true },
|
||||||
|
"^": { key: "BracketRight", deadKey: true, shift: true },
|
||||||
|
"¨": { key: "BracketRight", deadKey: true, },
|
||||||
|
"|": { key: "Equal", deadKey: true, altRight: true},
|
||||||
|
"`": { key: "Equal", deadKey: true, shift: true, },
|
||||||
|
"´": { key: "Equal", deadKey: true, },
|
||||||
|
" ": { key: "Space" },
|
||||||
|
"\n": { key: "Enter" },
|
||||||
|
Enter: { key: "Enter" },
|
||||||
|
Tab: { key: "Tab" },
|
||||||
|
|
||||||
|
} as Record<string, KeyCombo>;
|
||||||
|
|
||||||
|
export const da_DK: KeyboardLayout = {
|
||||||
|
isoCode: isoCode,
|
||||||
|
name: name,
|
||||||
|
chars: chars,
|
||||||
|
// TODO need to localize these maps and layouts
|
||||||
|
keyDisplayMap: en_US.keyDisplayMap,
|
||||||
|
modifierDisplayMap: en_US.modifierDisplayMap,
|
||||||
|
virtualKeyboard: en_US.virtualKeyboard
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue