import { useState, type ReactNode } from "react";
import type { Control } from "../useControl";
import type { PrinterStatus } from "../types";
// Map the active speed tier (`spd_lvl`) to the command name the buttons send.
const SPEED_BY_LVL: Record<number, string> = { 1: "silent", 2: "standard", 3: "sport", 4: "ludicrous" };
// The chamber light's authoritative state, read from `lights_report`.
function chamberLight(s: PrinterStatus): "on" | "off" | "unknown" {
const l = s.lights?.find((x) => x.node === "chamber_light" || x.node === "chamber");
return l?.mode === "on" ? "on" : l?.mode === "off" ? "off" : "unknown";
}
// Which job actions the current gcode state actually allows.
function jobAvail(state: string | null) {
const s = (state ?? "").toUpperCase();
return {
canPause: s === "RUNNING",
canResume: s === "PAUSE",
canStop: s === "RUNNING" || s === "PAUSE" || s === "PREPARE" || s === "SLICING",
};
}
export function Controls({
control,
status,
children,
}: {
control: Control;
status: PrinterStatus;
// Extra controls folded into this same panel (the machine/motion section), so
// the right column reads as one "controls" section rather than two boxes.
children?: ReactNode;
}) {
const b = control.busy;
const [line, setLine] = useState("");
const [force, setForce] = useState(false);
const sendGcode = () => {
const l = line.trim();
if (l) void control.gcode(l, force);
};
const light = chamberLight(status);
const lightBusy = b?.startsWith("light") ?? false;
const lightLabel = light === "on" ? "light on" : light === "off" ? "light off" : "light —";
const activeSpeed = SPEED_BY_LVL[status.spd_lvl ?? 0];
return (
<section className="panel">
<div className="lbl">controls</div>
<div className="btns">
<button
className={`btn light-toggle${light === "on" ? " is-on" : ""}`}
role="switch"
aria-checked={light === "on"}
aria-busy={lightBusy}
disabled={!!b}
data-testid="light-toggle"
data-state={light}
title={
light === "unknown"
? "light state unknown — click to turn on"
: `chamber light ${light}`
}
onClick={() => void control.light(light !== "on")}
>
<span className="dot" aria-hidden="true" />
{lightLabel}
</button>
</div>
<div className="btns">
{["silent", "standard", "sport", "ludicrous"].map((l) => {
const active = l === activeSpeed;
return (
<button
key={l}
className={`btn btn--sm${active ? " is-active" : ""}`}
disabled={!!b}
aria-pressed={active}
data-testid={`speed-${l}`}
onClick={() => void control.speed(l)}
>
{l}
</button>
);
})}
</div>
<div className="gcoderow">
<span className="lbl">gcode</span>
<input
className="pw"
placeholder="e.g. G28"
value={line}
disabled={!!b}
onChange={(e) => setLine(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") sendGcode();
}}
data-testid="gcode-input"
/>
<button
className="btn btn--sm"
disabled={!!b || !line.trim()}
onClick={sendGcode}
data-testid="gcode-send"
>
send
</button>
<label className="startrow" title="override the safety blocklist">
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
<span className="lbl">force</span>
</label>
</div>
{control.needPassword && (
<label className="pwrow">
<span className="lbl">control password</span>
<input
type="password"
className="pw"
autoComplete="off"
value={control.password}
onChange={(e) => control.setPassword(e.target.value)}
placeholder="set, then retry"
data-testid="password"
/>
</label>
)}
{children}
</section>
);
}
/** The primary job controls (pause/resume/stop), surfaced up in the status band
* next to the big state word rather than buried in the controls panel — that's
* where you look when a print is running and want to act on it. pause/resume is
* ONE button that flips with the state (⏸ running ↔ ▶ paused, filled-accent
* while paused so an error-paused job has an obvious "carry on"); ■ stops. The
* word lives in title + aria-label since the face is an icon. */
export function JobControls({ control, status }: { control: Control; status: PrinterStatus }) {
const b = control.busy;
const { canPause, canResume, canStop } = jobAvail(status.gcode_state);
const stateName = status.gcode_state ?? "unknown";
return (
<div className="jobctl">
<button
className={`btn btn--job${canResume ? " btn--primary" : ""}`}
disabled={!!b || !(canPause || canResume)}
title={
canResume ? "resume" : canPause ? "pause" : `pause/resume unavailable while ${stateName}`
}
aria-label={canResume ? "resume" : "pause"}
data-testid={canResume ? "job-resume" : "job-pause"}
onClick={() => void (canResume ? control.resume() : control.pause())}
>
<span className="jobctl__icon" aria-hidden="true">
{canResume ? "▶" : "⏸"}
</span>
</button>
<button
className="btn btn--job btn--danger"
disabled={!!b || !canStop}
title={canStop ? "stop" : `stop unavailable while ${stateName}`}
aria-label="stop"
data-testid="job-stop"
onClick={() => control.requestStop()}
>
<span className="jobctl__icon" aria-hidden="true">
■
</span>
</button>
</div>
);
}
export function ConfirmDialog({
message,
onConfirm,
onCancel,
confirmLabel,
}: {
message: string;
onConfirm: () => void;
onCancel: () => void;
// Defaults preserve the original stop-print dialog (label + testid); machine
// actions pass their own label and reuse the generic confirm-ok testid.
confirmLabel?: string;
}) {
const isStop = confirmLabel == null;
return (
<div
className="modal"
role="dialog"
aria-modal="true"
data-testid="confirm"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
>
<div className="modal__box">
<p className="modal__msg">{message}</p>
<div className="btns">
<button className="btn" onClick={onCancel}>
cancel
</button>
<button
className={`btn ${isStop ? "btn--danger-solid" : "btn--primary"}`}
onClick={onConfirm}
data-testid={isStop ? "confirm-stop" : "confirm-ok"}
>
{isStop ? "stop print" : confirmLabel}
</button>
</div>
</div>
</div>
);
}