feat: Winkeyer

This commit is contained in:
2026-06-02 01:17:26 +02:00
parent 2eb77370e4
commit 2b4326b553
26 changed files with 3125 additions and 645 deletions
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useState } from 'react';
import { Radio, Square, Send, Plug, Power, RefreshCw, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
export interface WKMacro { label: string; text: string }
export interface WKStatus {
connected: boolean;
busy: boolean;
wpm: number;
version: number;
port: string;
error?: string;
}
interface Props {
status: WKStatus;
ports: string[];
port: string;
wpm: number;
macros: WKMacro[];
sent: string; // text echoed back by the keyer as it transmits
onSelectPort: (p: string) => void;
onRefreshPorts: () => void;
onConnect: () => void;
onDisconnect: () => void;
onSetSpeed: (wpm: number) => void;
onSend: (text: string) => void; // raw text (App resolves variables)
onSendMacro: (index: number) => void; // App resolves the macro + sends
onStop: () => void;
onClose: () => void; // disable the keyer (hide the panel)
sendOnType: boolean;
onToggleSendOnType: (on: boolean) => void;
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
onBackspace: () => void; // remove last not-yet-keyed char
}
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
// reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer
// hardware: free-text CW, one-click macros (F1…), live speed, and abort.
export function WinkeyerPanel({
status, ports, port, wpm, macros, sent,
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
onSend, onSendMacro, onStop, onClose,
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
}: Props) {
const [cwText, setCwText] = useState('');
const [speed, setSpeed] = useState(wpm);
// Keep the local speed slider in sync when the device/config changes it.
useEffect(() => { setSpeed(status.connected ? status.wpm || wpm : wpm); }, [status.wpm, status.connected, wpm]);
const connected = status.connected;
function sendText() {
const t = cwText.trim();
if (t && !sendOnType) onSend(t); // in send-on-type the text already went out
setCwText('');
}
// In "send on type" mode, key each newly-typed char immediately, and send a
// WinKeyer backspace for each deleted char (removes it from the buffer if it
// hasn't been keyed yet). Only end-of-string edits are mirrored live.
function onCwChange(v: string) {
if (sendOnType && connected) {
const old = cwText;
if (v.length > old.length && v.startsWith(old)) {
onSendRaw(v.slice(old.length));
} else if (v.length < old.length && old.startsWith(v)) {
for (let i = 0; i < old.length - v.length; i++) onBackspace();
}
}
setCwText(v);
}
return (
<section className="flex flex-col gap-2 h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
{/* Header / connection bar */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Radio className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">WinKeyer</span>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500') : 'bg-muted-foreground/40')}
title={connected ? (status.busy ? 'Sending…' : `Connected (v${status.version})`) : 'Disconnected'} />
<div className="flex-1" />
{!connected ? (
<>
<Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}>
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder="COM port" /></SelectTrigger>
<SelectContent>
{ports.length === 0 && <SelectItem value="_" disabled>No ports</SelectItem>}
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Refresh ports" onClick={onRefreshPorts}>
<RefreshCw className="size-3.5" />
</Button>
<Button size="sm" className="h-7" onClick={onConnect} disabled={!port}>
<Plug className="size-3.5" /> Connect
</Button>
</>
) : (
<Button variant="outline" size="sm" className="h-7" onClick={onDisconnect}>
<Power className="size-3.5" /> Disconnect
</Button>
)}
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Hide / disable WinKeyer" onClick={onClose}>
<X className="size-3.5" />
</Button>
</div>
<div className="flex flex-col gap-2 px-3 pb-2 min-h-0 overflow-y-auto">
{/* Speed */}
<div className="flex items-center gap-2">
<Label className="text-xs w-12 shrink-0">Speed</Label>
<input
type="range" min={5} max={50} value={speed}
onChange={(e) => setSpeed(parseInt(e.target.value, 10))}
onMouseUp={() => onSetSpeed(speed)}
onTouchEnd={() => onSetSpeed(speed)}
disabled={!connected}
className="flex-1 accent-primary"
/>
<span className="font-mono text-sm font-bold w-14 text-right">{speed} wpm</span>
</div>
{/* Live transmitted text (echoed by the keyer as it sends). */}
<div className="flex items-center gap-2">
<Label className="text-xs w-12 shrink-0">TX</Label>
<div className={cn(
'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate',
status.busy ? 'text-emerald-700' : 'text-muted-foreground',
)}>
{sent || <span className="opacity-50"></span>}
{status.busy && <span className="ml-0.5 animate-pulse"></span>}
</div>
</div>
{/* CW text */}
<div className="flex items-end gap-2">
<div className="flex flex-col flex-1 min-w-0">
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
CW text
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
title="Key each character live as you type (backspace removes un-sent chars)">
<input type="checkbox" className="accent-primary" checked={sendOnType}
onChange={(e) => onToggleSendOnType(e.target.checked)} />
send on type
</label>
</Label>
<Input
value={cwText}
onChange={(e) => onCwChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
placeholder={sendOnType ? 'Type — sent live…' : 'Type and press Enter to send…'}
disabled={!connected}
className="font-mono uppercase"
/>
</div>
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
<Send className="size-3.5" /> {sendOnType ? 'Clear' : 'Send'}
</Button>
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title="Abort (clear keyer buffer)">
<Square className="size-3.5" /> Stop
</Button>
</div>
{/* Macro buttons F1… */}
<div className="grid grid-cols-3 gap-1.5">
{macros.map((m, i) => (
<button
key={i}
type="button"
onClick={() => onSendMacro(i)}
disabled={!connected}
title={m.text}
className={cn(
'flex flex-col items-start rounded-md border border-border px-2 py-1 text-left transition-colors',
connected ? 'hover:border-primary/60 hover:bg-accent/40' : 'opacity-50 cursor-not-allowed',
)}
>
<span className="text-[10px] font-mono text-primary font-semibold">F{i + 1}</span>
<span className="text-xs font-medium truncate w-full">{m.label || `Macro ${i + 1}`}</span>
</button>
))}
</div>
{status.error && <div className="text-[11px] text-rose-600">{status.error}</div>}
</div>
</section>
);
}