feat: Winkeyer
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user