Files
OpsLog/frontend/src/components/WinkeyerPanel.tsx
T

255 lines
13 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';
import { useI18n } from '@/lib/i18n';
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
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
onSetBreakIn?: (mode: number) => void;
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
autoCall: boolean; // repeat the clicked macro on a timer
autoCallSecs: number; // gap (s) after the message before repeating
onToggleAutoCall: (on: boolean) => void;
onSetAutoCallSecs: (n: number) => void;
}
// 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, source, breakIn = 0, onSetBreakIn,
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
onSend, onSendMacro, onStop, onClose,
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
}: Props) {
const { t } = useI18n();
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" />
{/* CW output engine (chosen in Settings → CW Keyer). */}
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
</span>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
<div className="flex-1" />
{source === 'icom' ? (
<span className="text-[11px] font-medium text-muted-foreground">
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
</span>
) : !connected ? (
<>
<Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}>
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder={t('wkp.comPort')} /></SelectTrigger>
<SelectContent>
{ports.length === 0 && <SelectItem value="_" disabled>{t('wkp.noPorts')}</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={t('wkp.refreshPorts')} onClick={onRefreshPorts}>
<RefreshCw className="size-3.5" />
</Button>
<Button size="sm" className="h-7" onClick={onConnect} disabled={!port}>
<Plug className="size-3.5" /> {t('wkp.connect')}
</Button>
</>
) : (
<Button variant="outline" size="sm" className="h-7" onClick={onDisconnect}>
<Power className="size-3.5" /> {t('wkp.disconnect')}
</Button>
)}
<Button variant="ghost" size="sm" className="h-7 px-1.5" title={t('wkp.hide')} 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-success' : '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={t('wkp.cwSpeed')}>
<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={t('wkp.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={t('wkp.slower')}
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronDown className="size-3.5" /></button>
</div>
</div>
</div>
{/* Break-in (Icom only): the rig's 0x17 keyer only transmits when break-in
is on — SEMI or FULL. OFF keys the sidetone but stays in RX. */}
{source === 'icom' && (
<div className="flex items-center gap-2">
<Label className="text-xs w-16 shrink-0" title={t('wkp.breakInHint')}>{t('wkp.breakIn')}</Label>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{[{ v: 0, l: t('wkp.bkOff') }, { v: 1, l: 'SEMI' }, { v: 2, l: 'FULL' }].map((o) => (
<button key={o.v} type="button" disabled={!connected} onClick={() => onSetBreakIn?.(o.v)}
className={cn('px-2.5 py-1 text-[11px] font-bold tracking-wide border-l border-border first:border-l-0 transition-colors disabled:opacity-40',
breakIn === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{o.l}
</button>
))}
</div>
{breakIn === 0 && <span className="text-[10px] text-warning">{t('wkp.bkOffWarn')}</span>}
</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">
{t('wkp.cwText')}
{source === 'winkeyer' && (
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
title={t('wkp.sendOnTypeHint')}>
<input type="checkbox" className="accent-primary" checked={sendOnType}
onChange={(e) => onToggleSendOnType(e.target.checked)} />
{t('wkp.sendOnType')}
</label>
)}
</Label>
<Input
value={cwText}
onChange={(e) => onCwChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
placeholder={sendOnType ? t('wkp.phLive') : t('wkp.phEnter')}
disabled={!connected}
className="font-mono uppercase"
/>
</div>
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
<Send className="size-3.5" /> {sendOnType ? t('wkp.clear') : t('wkp.send')}
</Button>
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title={t('wkp.abort')}>
<Square className="size-3.5" /> {t('wkp.stop')}
</Button>
</div>
{/* Auto-call: repeat the clicked macro (e.g. F1 CQ) automatically until
someone answers. The seconds box is the gap AFTER the message. */}
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
title={t('wkp.autoCallHint')}>
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
onChange={(e) => onToggleAutoCall(e.target.checked)} />
{t('wkp.autoCall')}
</label>
<span className="text-[11px] text-muted-foreground">{t('wkp.gap')}</span>
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title={t('wkp.gapHint')}>
<input type="number" min={0} max={120}
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
<span className="text-[9px] text-muted-foreground">sec</span>
</div>
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
</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 || t('wkp.macroN', { n: i + 1 })}</span>
</button>
))}
</div>
{status.error && <div className="text-[11px] text-danger">{status.error}</div>}
</div>
</section>
);
}