199 lines
9.2 KiB
TypeScript
199 lines
9.2 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Radio, Square, Send, Plug, Power, RefreshCw, X, ChevronUp, ChevronDown } 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);
|
|
// Step the speed (compact +/- control replaces the old slider).
|
|
const changeSpeed = (delta: number) => {
|
|
const w = Math.max(5, Math.min(50, speed + delta));
|
|
setSpeed(w);
|
|
onSetSpeed(w);
|
|
};
|
|
|
|
// Keep the local speed 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-1.5 px-3 pb-2 min-h-0 overflow-y-auto">
|
|
{/* Live transmitted text (echoed by the keyer) + compact speed stepper. */}
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-xs w-8 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>
|
|
{/* Speed: number + up/down arrows (replaces the slider, saves height). */}
|
|
<div className="flex items-center gap-1 shrink-0 h-8 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="CW speed (WPM)">
|
|
<span className="font-mono text-sm font-bold tabular-nums">{speed}</span>
|
|
<span className="text-[9px] text-muted-foreground">wpm</span>
|
|
<div className="flex flex-col -my-0.5">
|
|
<button type="button" disabled={!connected} onClick={() => changeSpeed(+1)} title="Faster"
|
|
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronUp className="size-3.5" /></button>
|
|
<button type="button" disabled={!connected} onClick={() => changeSpeed(-1)} title="Slower"
|
|
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronDown className="size-3.5" /></button>
|
|
</div>
|
|
</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… — single-line (F-key + label) to keep the panel short. */}
|
|
<div className="grid grid-cols-3 gap-1">
|
|
{macros.map((m, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
onClick={() => onSendMacro(i)}
|
|
disabled={!connected}
|
|
title={m.text}
|
|
className={cn(
|
|
'flex items-center gap-1.5 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 shrink-0">F{i + 1}</span>
|
|
<span className="text-xs font-medium truncate">{m.label || `Macro ${i + 1}`}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{status.error && <div className="text-[11px] text-rose-600">{status.error}</div>}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|