velxio/frontend/src/components/simulator/SerialMonitor.tsx

371 lines
11 KiB
TypeScript
Raw Normal View History

/**
* Serial Monitor multi-board tabbed view.
* Each board has its own tab with serial output, input, and clear button.
*/
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { useSimulatorStore, getBoardSimulator } from '../../store/useSimulatorStore';
import { RP2040Simulator } from '../../simulation/RP2040Simulator';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
// Short labels for tabs
const BOARD_SHORT_LABEL: Record<BoardKind, string> = {
'arduino-uno': 'Uno',
'arduino-nano': 'Nano',
'arduino-mega': 'Mega',
'raspberry-pi-pico': 'Pico',
'raspberry-pi-3': 'Pi 3B',
'esp32': 'ESP32',
'esp32-s3': 'ESP32-S3',
'esp32-c3': 'ESP32-C3',
};
const BOARD_ICON: Record<BoardKind, string> = {
'arduino-uno': '⬤',
'arduino-nano': '▪',
'arduino-mega': '▬',
'raspberry-pi-pico': '◆',
'raspberry-pi-3': '⬛',
'esp32': '⬡',
'esp32-s3': '⬡',
'esp32-c3': '⬡',
};
const BOARD_COLOR: Record<BoardKind, string> = {
'arduino-uno': '#4fc3f7',
'arduino-nano': '#4fc3f7',
'arduino-mega': '#4fc3f7',
'raspberry-pi-pico': '#ce93d8',
'raspberry-pi-3': '#ef9a9a',
'esp32': '#a5d6a7',
'esp32-s3': '#a5d6a7',
'esp32-c3': '#a5d6a7',
};
export const SerialMonitor: React.FC = () => {
const boards = useSimulatorStore((s) => s.boards);
const activeBoardId = useSimulatorStore((s) => s.activeBoardId);
const serialWriteToBoard = useSimulatorStore((s) => s.serialWriteToBoard);
const clearBoardSerialOutput = useSimulatorStore((s) => s.clearBoardSerialOutput);
// Active tab defaults to activeBoardId, updates when active board changes
const [activeTabId, setActiveTabId] = useState<string | null>(null);
// Track last-seen serial output length per board for unread dots
const [lastSeenLen, setLastSeenLen] = useState<Record<string, number>>({});
const [inputValue, setInputValue] = useState('');
const [lineEnding, setLineEnding] = useState<'none' | 'nl' | 'cr' | 'both'>('nl');
const [autoscroll, setAutoscroll] = useState(true);
const outputRef = useRef<HTMLPreElement>(null);
// Sync active tab to activeBoardId when it changes
useEffect(() => {
if (activeBoardId) setActiveTabId(activeBoardId);
}, [activeBoardId]);
// Fallback: if activeTab is gone, pick first board
const resolvedTabId = (boards.find((b) => b.id === activeTabId) ? activeTabId : boards[0]?.id) ?? null;
const activeBoard = boards.find((b) => b.id === resolvedTabId);
// Mark tab as read when it becomes active
useEffect(() => {
if (resolvedTabId && activeBoard) {
setLastSeenLen((prev) => ({ ...prev, [resolvedTabId]: activeBoard.serialOutput.length }));
}
}, [resolvedTabId, activeBoard?.serialOutput.length]);
// Auto-scroll when output changes on the visible tab
useEffect(() => {
if (autoscroll && outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [activeBoard?.serialOutput, autoscroll]);
const handleSend = useCallback(() => {
if (!resolvedTabId) return;
if (!inputValue && lineEnding === 'none') return;
let text = inputValue;
switch (lineEnding) {
case 'nl': text += '\n'; break;
case 'cr': text += '\r'; break;
case 'both': text += '\r\n'; break;
}
serialWriteToBoard(resolvedTabId, text);
setInputValue('');
}, [resolvedTabId, inputValue, lineEnding, serialWriteToBoard]);
const isMicroPython = activeBoard?.languageMode === 'micropython';
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSend();
return;
}
// MicroPython REPL control characters
if (resolvedTabId && e.ctrlKey) {
const sim = getBoardSimulator(resolvedTabId);
if (sim instanceof RP2040Simulator && sim.isMicroPythonMode()) {
if (e.key === 'c' || e.key === 'C') {
e.preventDefault();
sim.serialWriteByte(0x03); // Ctrl+C — keyboard interrupt
} else if (e.key === 'd' || e.key === 'D') {
e.preventDefault();
sim.serialWriteByte(0x04); // Ctrl+D — soft reset
}
}
}
}, [handleSend, resolvedTabId]);
const handleTabClick = (boardId: string) => {
setActiveTabId(boardId);
const board = boards.find((b) => b.id === boardId);
if (board) {
setLastSeenLen((prev) => ({ ...prev, [boardId]: board.serialOutput.length }));
}
};
if (boards.length === 0) {
return (
<div style={styles.container}>
<div style={styles.header}>
<span style={styles.title}>Serial Monitor</span>
</div>
<pre style={styles.output}>Add a board to start the serial monitor.</pre>
</div>
);
}
return (
<div style={styles.container}>
{/* Tab strip */}
<div style={styles.tabStrip}>
{boards.map((board) => {
const isActive = board.id === resolvedTabId;
const color = BOARD_COLOR[board.boardKind];
const hasUnread = (board.serialOutput.length) > (lastSeenLen[board.id] ?? 0);
return (
<button
key={board.id}
style={{
...styles.tab,
...(isActive ? { ...styles.tabActive, borderBottomColor: color, color } : {}),
}}
onClick={() => handleTabClick(board.id)}
title={BOARD_KIND_LABELS[board.boardKind]}
>
<span style={{ fontSize: 9, marginRight: 3, color: isActive ? color : '#888' }}>
{BOARD_ICON[board.boardKind]}
</span>
{BOARD_SHORT_LABEL[board.boardKind]}
{hasUnread && !isActive && <span style={styles.unreadDot} />}
</button>
);
})}
{/* Right-side controls */}
<div style={styles.tabControls}>
{isMicroPython && (
<span style={{ color: '#ce93d8', fontSize: 11, fontWeight: 600 }}>MicroPython REPL</span>
)}
{activeBoard?.serialBaudRate != null && activeBoard.serialBaudRate > 0 && !isMicroPython && (
<span style={styles.baudRate}>{activeBoard.serialBaudRate.toLocaleString()} baud</span>
)}
<label style={styles.autoscrollLabel}>
<input
type="checkbox"
checked={autoscroll}
onChange={(e) => setAutoscroll(e.target.checked)}
style={styles.checkbox}
/>
Autoscroll
</label>
<button
onClick={() => resolvedTabId && clearBoardSerialOutput(resolvedTabId)}
style={styles.clearBtn}
title="Clear output for this board"
>
Clear
</button>
</div>
</div>
{/* Output area */}
<pre ref={outputRef} style={styles.output}>
{activeBoard?.serialOutput ||
(activeBoard?.running ? 'Waiting for serial data...\n' : 'Start simulation to see serial output.\n')}
</pre>
{/* Input row */}
<div style={styles.inputRow}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isMicroPython ? 'Type Python expression... (Ctrl+C to interrupt)' : 'Type message to send...'}
style={styles.input}
disabled={!activeBoard?.running}
/>
<select
value={lineEnding}
onChange={(e) => setLineEnding(e.target.value as typeof lineEnding)}
style={styles.select}
>
<option value="none">No line ending</option>
<option value="nl">Newline</option>
<option value="cr">Carriage return</option>
<option value="both">Both NL &amp; CR</option>
</select>
<button onClick={handleSend} disabled={!activeBoard?.running} style={styles.sendBtn}>
Send
</button>
</div>
</div>
);
};
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
flexDirection: 'column',
height: '100%',
background: '#1e1e1e',
borderTop: '1px solid #333',
fontFamily: 'monospace',
fontSize: 13,
minHeight: 0,
},
tabStrip: {
display: 'flex',
alignItems: 'center',
background: '#252526',
borderBottom: '1px solid #333',
minHeight: 32,
flexShrink: 0,
overflow: 'hidden',
},
tab: {
background: 'none',
border: 'none',
borderBottom: '2px solid transparent',
color: '#999',
padding: '5px 12px',
cursor: 'pointer',
fontSize: 11,
fontWeight: 600,
fontFamily: 'inherit',
display: 'flex',
alignItems: 'center',
gap: 2,
whiteSpace: 'nowrap',
position: 'relative',
},
tabActive: {
background: 'rgba(255,255,255,0.04)',
},
tabControls: {
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
gap: 8,
paddingRight: 8,
flexShrink: 0,
},
unreadDot: {
width: 6,
height: 6,
borderRadius: '50%',
background: '#4fc3f7',
flexShrink: 0,
marginLeft: 3,
},
title: {
color: '#cccccc',
fontWeight: 600,
fontSize: 12,
},
baudRate: {
color: '#569cd6',
fontSize: 11,
fontFamily: 'monospace',
background: '#1e1e1e',
border: '1px solid #3a3a3a',
borderRadius: 3,
padding: '1px 6px',
},
autoscrollLabel: {
color: '#999',
fontSize: 11,
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
},
checkbox: {
margin: 0,
cursor: 'pointer',
},
clearBtn: {
background: 'transparent',
border: '1px solid #555',
color: '#ccc',
padding: '2px 8px',
borderRadius: 3,
cursor: 'pointer',
fontSize: 11,
},
output: {
flex: 1,
margin: 0,
padding: 8,
color: '#00ff41',
background: '#0a0a0a',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
minHeight: 0,
fontSize: 13,
lineHeight: '1.4',
},
inputRow: {
display: 'flex',
gap: 4,
padding: 4,
background: '#252526',
borderTop: '1px solid #333',
flexShrink: 0,
},
input: {
flex: 1,
background: '#1e1e1e',
border: '1px solid #444',
color: '#ccc',
padding: '4px 8px',
borderRadius: 3,
fontFamily: 'monospace',
fontSize: 12,
outline: 'none',
},
select: {
background: '#1e1e1e',
border: '1px solid #444',
color: '#ccc',
padding: '4px',
borderRadius: 3,
fontSize: 11,
outline: 'none',
},
sendBtn: {
background: '#0e639c',
border: 'none',
color: '#fff',
padding: '4px 12px',
borderRadius: 3,
cursor: 'pointer',
fontSize: 12,
fontWeight: 600,
},
};