commit 0f66fbd9d8125f53577ec607b57d8bb583fc55b2 Author: Levi Woodard Date: Sun May 17 13:51:17 2026 -0600 Initial commit for i0T.app and migration here with a fresh repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d5868d --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Go build artifacts +/go/bin/ +/go/pkg/ +*.exe +*.test +*.out + +# Editor / OS +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo + +# Claude Code workspace (per-user, holds local permission grants) +.claude/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..d0231f0 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,296 @@ +# Architecture + +This document covers the design decisions, the wire protocol, and how the +internal packages fit together. If you just want to use `printcontrol`, +`README.md` is enough. + +## Goals and non-goals + +**Goals** + +- Single static binary, zero runtime dependencies (no Python, no venv). +- Read-mostly UX: the printer is in charge during an SD print, we observe. +- Theme-aware, omarchy-style TUI — let the terminal palette do the work. +- Backend-agnostic at the seams: USB serial today, Octoprint / Moonraker later. +- Crash-tolerant: dropping serial mid-print should not kill the program; the + printer keeps printing from its SD card regardless. + +**Non-goals (for now)** + +- Slicer integration. +- Multi-printer farms. +- Web UI / remote access. Use Octoprint or Mainsail for that. +- Persistent print history / analytics. + +## Process model + +There are three logical actors: + +``` +┌────────────┐ Events ┌─────────────┐ bytes ┌────────────┐ +│ Bubble Tea │ ◄────────── │ Printer │ ◄──────► │ /dev/tty* │ +│ model │ │ goroutine │ │ │ +│ (UI) │ Send(cmd) │ │ │ (Marlin/ │ +└────────────┘ ─────────────► │ │ Klipper) │ + └─────────────┘ └────────────┘ +``` + +1. **UI goroutine** — Bubble Tea's event loop. Owns the model and the screen. + Reads from `printer.Events()` via a `tea.Cmd` that blocks on the channel. +2. **Printer goroutine** (`internal/printer`) — owns the serial handle. + Reads incoming lines from a dedicated reader goroutine and writes outbound + G-code itself. This makes the serial handle single-owner and removes the + need for write-side locking on the port. +3. **Reader goroutine** — spawned by the printer goroutine for the lifetime + of the connection. Sits in `bufio.ReadString('\n')` and forwards complete + lines via a buffered channel. + +The UI never touches the serial port; the printer never touches the screen. +All cross-goroutine communication is via channels. + +## Package layout + +``` +internal/printer/ + state.go Printer state model. Pure data + helpers (Progress, ETA). + parse.go Line parser. Stateless except for mutating *State. + printer.go Lifecycle (Connect/Disconnect), serial I/O, polling. + Exposes high-level commands (SetHotend, Pause, ...). + parse_test.go + +internal/tui/ + style.go Lipgloss styles, adaptive colours. + model.go Bubble Tea Model: Init / Update / View, key handling. + +cmd/printcontrol/ + main.go Flags + tea.NewProgram. +``` + +The boundary that matters is the one between `internal/printer` and the rest. +A future Octoprint backend implements the same shape — `Events() <-chan Event` +plus the high-level command methods — and the TUI doesn't care. + +## State model + +```go +type State struct { + Connected, Online bool + Firmware string + Port string + Baud int + + PrintState PrintState // Idle | SDPrinting | HostPrinting | Paused | Error + ErrorMsg string + + Temps map[string]TempPair // "T", "T0", "B", "C", ... + + SDByte, SDTotal int64 + SDFilename string + SDStart time.Time // for ETA + + FeedratePct, FlowPct, FanPct int + BabystepZ float64 + + SDFiles []SDFile // most recent M20 listing + SDListing bool // true while M20 output is being collected +} +``` + +State is mutated only inside the printer goroutine, then snapshotted into +each `Event`. The UI receives a value copy and reads it freely — no shared +mutable state across goroutines. + +The two booleans `Connected` and `Online` are distinct on purpose: +- `Connected` — the serial port is open. +- `Online` — the firmware has spoken to us (we've seen `ok`, `start`, or + `Marlin`). Some operations (priming auto-report, sending commands) wait + for this. + +## Wire protocol + +Marlin / RepRap / Klipper all speak the same surface-level G-code dialect +over serial. We only use a small subset. + +### Reads + +| What we look for | Regex | +|---------------------------------|--------------------------------------------------| +| Temperatures | `(T\d*|B|C):\s*\s*/\s*` | +| SD print progress | `SD printing byte /` | +| Not currently SD printing | `Not SD printing` | +| SD print finished | `Done printing file` | +| File opened (printer reports it)| `File opened: ` | +| Firmware identification | `FIRMWARE_NAME: SOURCE_CODE_URL:...` | +| SD listing start | `Begin file list` | +| SD listing entry | ` ` (optional `"long name"`) | +| SD listing end | `End file list` | + +Online detection: any line starting with `ok`, or containing `start` / +`Marlin`, flips `state.Online = true`. Lines starting with `Error` or `!!` +flip the print state to `Error` and surface the message in the footer. + +### Writes + +| Action | G-code | +|-------------------------|-----------------------------------------| +| Identify firmware | `M115` | +| Request temps once | `M105` | +| Request SD status once | `M27` | +| Disable auto temp report| `M155 S0` *(prime block)* | +| Disable auto SD report | `M27 S0` *(prime block)* | +| Mount SD card | `M21` | +| List SD files | `M20` | +| Select SD file | `M23 ` | +| Start / resume SD print | `M24` | +| Set hotend N | `M104 T S` | +| Set bed | `M140 S` | +| Set fan (0–100%) | `M106 S<0..255>` / `M107` | +| Feedrate % | `M220 S` | +| Flow % | `M221 S` | +| Babystep Z | `M290 Z` *(Marlin only)* | +| Pause SD print | `M25` | +| Cancel SD print | `M524` *(Marlin 2.0+)* | + +### Polling + +We send `M105` every 2 s and `M27` every 5 s. **Auto-reporting is +deliberately turned off** in the prime block (`M155 S0` / `M27 S0`) — when +both auto-reports and our own polls are active, Marlin can interleave them +character-by-character, producing mangled output like `TT::57.4957.49`. +Owning the polling cadence is simpler than racing the firmware for the +serial line. + +### Baud + port auto-detection + +`Connect("", 0)` triggers a full scan: every `/dev/ttyACM*` and +`/dev/ttyUSB*` is probed against the candidate baud list +`[250000, 115200, 230400, 500000, 57600]`. For each (port, baud) pair we: + +1. Open the device. +2. Sleep ~400 ms so DTR-resetting boards finish booting. +3. Write `M115`, wait ~1.2 s, write `M115` + `M105` again. +4. Read for up to 1.5 s, looking for an *unambiguous* match: + `Marlin` / `FIRMWARE_NAME` / `RepRapFirmware` / `Klipper` / a + well-formed `T:N/N` or `B:N/N` line. + +Two-letter tokens like `ok` and short words like `start` were tried first +and abandoned: they appear too often in misaligned-baud garbage and led to +false-positive locks. The strict matcher trades ~3 s per failed probe for +a guaranteed-correct result. + +First match wins. Subsequent ports / bauds are skipped. Pass `-port` +and/or `-baud` to bypass detection entirely. + +### What we are not (yet) doing + +**Host-side print streaming.** Marlin's host-mode streaming uses +checksummed line-numbered commands: + +``` +N * +``` + +…and supports resend requests when the printer drops a line. We don't +implement this because the MVP is monitor-an-SD-print. When we add it, it +goes in `internal/printer` behind a `StartHostPrint(io.Reader)` method, +keeping the UI ignorant of the protocol detail. + +## Lifecycle + +Connect: + +1. Resolve the candidate port list (caller-specified, or every + `ttyACM*` / `ttyUSB*` device). +2. If `baud == 0`, run the auto-detect probe (see *Baud + port + auto-detection* below). Otherwise just open the first responsive port + at the explicit baud. +3. Apply a 500 ms read timeout on the chosen handle. +4. Start the printer goroutine, which spawns its reader goroutine. +5. Two seconds later, send the priming block: `M115`, `M155 S0`, `M27 S0`, + `M105`, `M27`. The `S0` lines actively disable any auto-reporting + Marlin may have enabled from a previous session — we own polling + ourselves so the two streams can't collide. + +Disconnect: + +1. Cancel the printer goroutine's context. +2. Wait for it to drain (`<-p.stopped`). +3. Close the port. Reader goroutine exits on the next read error. + +Crash safety: if the program panics or the user kills it, the printer keeps +printing from its SD card. No cleanup is required on the printer side. The +next `printcontrol` invocation reconnects and re-discovers state via the +periodic `M27` / `M105`. + +## TUI design + +`internal/tui` is a small Bubble Tea model. Two `tea.Cmd` sources drive it: + +- `tickCmd()` — 1 Hz `time.Tick`, used to refresh the ETA display. +- `waitEvent(printer)` — blocks on `printer.Events()`. After each event the + command returns and is re-scheduled. + +Styling is in `internal/tui/style.go` using `lipgloss.AdaptiveColor` against +the ANSI 16-colour palette. This is deliberate: by referring to colour `5` +("magenta") rather than `#bb9af7`, we let the user's terminal theme define +what magenta actually looks like. Tokyo Night, Catppuccin, Gruvbox, Nord — +they all just work without code changes. + +The layout is four rows tall: + +1. Two side-by-side panels: Status (left), Temperatures (right). +2. A full-width Controls panel. +3. A full-width Log panel — its height is computed from `m.height` minus + the rows the other panels need, so the footer is always reserved. +4. The footer key hints. + +Below ~60 columns, the top row collapses to a single column. We don't +attempt fancy responsive layouts; this is a control surface, not a website. + +### SD card picker + +Pressing `s` sends `M21` + `M20` and sets `sdPickerWait = true`. The parser +collects the `Begin file list … End file list` block into `State.SDFiles`; +when the listing completes (and `SDListing` flips back to `false`), the +event handler copies it into the model and switches focus to +`focusSDPicker`. While the picker has focus it overlays the Log panel +(arrows / `j` / `k` to navigate, `Enter` to send `M23 ` + `M24`, +`Esc` to dismiss). The rest of the UI continues rendering normally so +temperatures and progress remain visible during selection. + +## Adding a new backend + +To swap in Octoprint or Moonraker: + +1. Implement a type with these methods: + ```go + Events() <-chan printer.Event + Connect(...) error + Disconnect() + Snapshot() printer.State + SetHotend, SetBed, SetFan, SetFeedrate, SetFlow, + BabystepZ, ResetBabystep, Pause, Resume, Cancel, Send + ``` +2. Wire it in `cmd/printcontrol/main.go` behind a `-backend` flag. +3. The TUI model only stores `*printer.Printer` today — promote that to an + interface, or split the model to accept any implementor. + +The state model is intentionally a superset of what serial gives us so that +backends with richer data (Octoprint knows the slicer-reported time +estimate, for example) can populate more fields without new code paths in +the UI. + +## Known limitations + +- **Klipper babystep**: we send `M290`, Klipper doesn't implement it. Needs + a backend probe (`M115` reports `Klipper` in `FIRMWARE_NAME`) and a switch + to `SET_GCODE_OFFSET Z=`. +- **No reconnect loop**: if the printer reboots mid-session, the user must + press `c` again. Cheap to add but easy to get wrong (auto-reconnect that + spams writes during firmware boot can corrupt the print). +- **Events channel is bounded (128)**. Under sustained event storms (e.g. + Marlin's `M155 S1`) the printer goroutine drops events rather than + blocking. The UI catches up on the next event, so the worst-case symptom + is a momentarily stale display. +- **No tests for the TUI.** Bubble Tea models are testable via `teatest`; + worth adding once the key bindings stabilise. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f91c0e3 --- /dev/null +++ b/README.md @@ -0,0 +1,224 @@ +# printcontrol + +A keyboard-driven TUI for monitoring and controlling a 3D printer over USB +serial. One static Go binary, zero runtime dependencies, theme-aware via +your terminal's 16-colour palette — designed to feel at home on Omarchy +alongside `wiremix`, `lazygit`, `lazydocker`, and friends. + +The model: **the printer is in charge during an SD print, we observe and +adjust.** Start a print from the LCD, the SD card, or printcontrol's own +SD picker — then live-monitor temps, progress, and ETA, and tweak feedrate, +flow, fan, hotend / bed setpoints, or babystep Z without ever leaving the +keyboard. Quit and come back later; the printer keeps printing and +printcontrol rediscovers the in-progress job on reconnect. + +``` +┌─ printcontrol · 3D printer TUI ────────────────────────────────────────┐ +│ ● Status │ ● Temperatures │ +│ Connection: /dev/ttyUSB1 @ 115200 │ Hotend: 201.3 / 210.0 °C │ +│ · Marlin 2.1.2 │ Bed: 60.2 / 60.0 °C │ +│ File: benchy.gcode SD PRINT │ │ +│ Progress: [██████████··········] │ set hotend: [h] │ +│ ETA: 1h 12m │ set bed: [b] │ +├─────────────────────────────────────┴────────────────────────────────────┤ +│ ● Controls │ +│ Feedrate: 110% [-/+] [0] Flow: 100% [ [ / ] ] Fan: 30% [f/F] │ +│ Babystep Z: -0.050 [{ / }] [Z] │ +│ press [g] to send raw G-code │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ● Log │ +│ > M105 │ +│ < ok T:201.3 /210.0 B:60.2 /60.0 @:127 B@:0 │ +├──────────────────────────────────────────────────────────────────────────┤ +│ [c] connect [d] disconnect [s] sd start [p] pause [r] resume [x] cancel │ +│ [h] hotend [b] bed [g] gcode [q] quit │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +## Install + +Build from source — needs Go 1.22+. The repo's canonical install target is +`~/.local/bin` (matches Omarchy's PATH); adjust `GOBIN` if you want it +elsewhere. + +```bash +cd go +GOBIN=$HOME/.local/bin go install ./cmd/printcontrol +``` + +You need permission to open the serial port. On Arch / Omarchy that means +membership in the `uucp` group: + +```bash +groups | grep -q uucp || sudo usermod -aG uucp "$USER" # then log out and back in +``` + +Verify the printer is visible: + +```bash +ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null +``` + +## Run + +```bash +printcontrol # auto-detect port AND baud +printcontrol -port /dev/ttyUSB1 # lock the port, still auto-baud +printcontrol -port /dev/ttyUSB1 -baud 250000 +printcontrol -no-connect # launch without touching serial +``` + +### Flags + +| Flag | Default | Description | +|---------------|----------|----------------------------------------------------------| +| `-port` | *(auto)* | Serial device path. Scans `/dev/ttyACM*` + `/dev/ttyUSB*` if empty. | +| `-baud` | `0` | Baud rate. `0` = auto-detect from `[250000, 115200, 230400, 500000, 57600]`. | +| `-no-connect` | `false` | Skip auto-connect on launch. | + +### Auto-detect + +With no flags, printcontrol scans every plausible serial device and probes +each candidate baud until something replies with an unmistakably +printer-shaped line (`Marlin`, `FIRMWARE_NAME`, `RepRapFirmware`, `Klipper`, +or a `T:.../...` temperature report). Two-letter tokens like `"ok"` are +*not* accepted as a match because they appear too easily in +misaligned-baud garbage. Worst-case probe time is ~15 s; first match wins +and the rest are skipped. + +The connect flash at the bottom of the screen reports the chosen +combination — e.g. `connected: /dev/ttyUSB1 @ 115200`. + +### Keys (top-level) + +| Key | Action | +|-----------|-----------------------------------------| +| `c` | Connect / reconnect | +| `d` | Disconnect | +| `s` | List SD card and pick a file to print | +| `p` | Pause SD print (`M25`) | +| `r` | Resume (`M24`) | +| `x` | Cancel SD print (`M524`) | +| `h` | Focus hotend setpoint input | +| `b` | Focus bed setpoint input | +| `g` | Focus raw G-code input | +| `+` / `-` | Feedrate ±10% | +| `0` | Feedrate back to 100% | +| `[` / `]` | Flow ±5% (`M221`) | +| `f` / `F` | Fan ±10% | +| `{` / `}` | Babystep Z ∓0.05 mm (`M290`, Marlin) | +| `Z` | Reset babystep to zero | +| `q` | Quit | + +When a text input is focused: type the value, `Enter` to commit, `Esc` to +cancel. + +### SD start workflow (key: `s`) + +1. Press `s`. printcontrol sends `M21` (mount SD) and `M20` (list files). +2. A picker appears in place of the Log panel. +3. `↑`/`↓` (or `k`/`j`) to highlight, `Enter` to start the print, `Esc` to + dismiss without printing. +4. Selecting a file sends `M23 ` then `M24`. The print runs from + the SD card, autonomously of printcontrol. +5. You can `q` immediately after — the printer doesn't care. Reconnect any + time; the `M27` poll discovers the in-progress print within ~5 seconds + and Status / Progress / ETA repopulate. + +Notes: +- The picker shows the long filename if the firmware reports one, but + always passes the **short 8.3 name** to `M23` (that's what Marlin + requires). +- Nested folders are listed but can't currently be entered. File a request + if you need it. + +## Register with the Omarchy launcher + +Once you're happy with it, drop a desktop entry so SUPER+SPACE finds it: + +```bash +omarchy-tui-install printcontrol printcontrol float +``` + +`float` makes Hyprland treat it as a floating window — usually the right +call for a single-pane TUI. Switch to `tile` if you'd rather have it in +your tiling layout. + +## What works · what doesn't + +**Works today** + +- Auto-detect serial port and baud rate +- Connect / disconnect over USB serial (multi-port scan) +- Live temperatures (hotend, bed, chamber if present) every 2 s +- SD-card print detection, progress %, ETA +- SD card file listing (`M20`) and start (`M23` + `M24`) from the TUI +- Pause / resume / cancel (`M25` / `M24` / `M524`) +- Temperature setpoints (`M104` / `M140`) +- Fan, feedrate, flow controls (`M106` / `M220` / `M221`) +- Babystep Z (`M290` — Marlin) +- Raw G-code entry +- Firmware identification (`M115`) +- Adaptive log panel (footer stays visible on short terminals) + +**Not yet** + +- Host-side print streaming (sending a `.gcode` file from the computer). + Needs the line-numbered protocol with checksum + resend handling. +- Klipper-specific babystep (`SET_GCODE_OFFSET`). Sends `M290` today, which + Klipper rejects. +- SD folder navigation (`M20 `). +- Octoprint / Moonraker backends. Architecture supports it; see + `ARCHITECTURE.md`. +- Persisted profiles (per-printer last setpoints, preferred port). + +## Troubleshooting + +**"no responsive printer on \[...]"** — Auto-detect probed every +`ttyACM`/`ttyUSB` device at every common baud and didn't find a printer +signature. Most often this means another program (OctoPrint, Klipper's +`klippy`, `pronsole`) already owns the port. Quit it first, or pass +`-port` explicitly. If you have an unusual baud (e.g. `1000000`), pass +`-baud` to override the probe. + +**"could not auto-detect baud rate"** — printcontrol *opened* the port but +saw no recognisable reply at any candidate baud. Confirm the device is +actually a printer (`screen /dev/ttyUSB1 115200`, then send `M115`); also +common: the printer is mid-firmware-flash, or the USB cable is power-only. + +**Temps show but progress stays at 0%** — `M27` only reports SD progress. +If you started the print from a host (OctoPrint, etc.) progress won't +appear here. + +**Babystep does nothing** — You're likely on Klipper, which doesn't +implement `M290`. Klipper support is on the roadmap. + +**Mangled lines like `TT::57.4957.49`** — This was the symptom of Marlin's +auto-temperature report colliding with our explicit `M105` poll. Fixed +upstream: the prime block now sends `M155 S0` / `M27 S0` to disable +auto-reports. + +## Layout + +``` +printcontrol/ +├── go/ +│ ├── cmd/printcontrol/main.go # entry point, flag parsing +│ ├── internal/printer/ # serial driver + G-code state machine +│ │ ├── state.go # State / PrintState / TempPair / SDFile +│ │ ├── parse.go # line parsing (M105, M27, M115, M20, ...) +│ │ ├── printer.go # Connect/Disconnect, owning goroutine, probe +│ │ └── parse_test.go +│ └── internal/tui/ # Bubble Tea model + Lipgloss styling +│ ├── model.go # Model, key handling, SD picker, layout +│ └── style.go +├── README.md +└── ARCHITECTURE.md # protocol + design notes +``` + +See `ARCHITECTURE.md` for the wire protocol, the state model, the +auto-detect probe strategy, and why things are wired the way they are. + +## License + +GPL-3.0-or-later (matches Printrun, which inspired the protocol layer). diff --git a/go/cmd/printcontrol/main.go b/go/cmd/printcontrol/main.go new file mode 100644 index 0000000..fbefb1c --- /dev/null +++ b/go/cmd/printcontrol/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "flag" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/lwoodard/printcontrol/internal/tui" +) + +func main() { + port := flag.String("port", "", "Serial port (auto-detected if empty)") + baud := flag.Int("baud", 0, "Baud rate (0 = auto-detect)") + noConnect := flag.Bool("no-connect", false, "Don't auto-connect on launch") + flag.Parse() + + m := tui.New(tui.Config{ + Port: *port, + Baud: *baud, + AutoConnect: !*noConnect, + }) + + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, "printcontrol:", err) + os.Exit(1) + } +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..0a76536 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,30 @@ +module github.com/lwoodard/printcontrol + +go 1.26.3 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + go.bug.st/serial v1.6.4 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/creack/goselect v0.1.2 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..33cebe5 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,55 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= +github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= +go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/internal/printer/parse.go b/go/internal/printer/parse.go new file mode 100644 index 0000000..6580e73 --- /dev/null +++ b/go/internal/printer/parse.go @@ -0,0 +1,142 @@ +package printer + +import ( + "regexp" + "strconv" + "strings" +) + +var ( + tempRe = regexp.MustCompile(`(T\d*|B|C):\s*(-?\d+(?:\.\d+)?)\s*/\s*(-?\d+(?:\.\d+)?)`) + sdRe = regexp.MustCompile(`(?i)SD printing byte\s+(\d+)\s*/\s*(\d+)`) + notSDRe = regexp.MustCompile(`(?i)Not SD printing`) + sdDoneRe = regexp.MustCompile(`(?i)Done printing file`) + fileOpen = regexp.MustCompile(`(?i)File opened:\s*(\S+)`) + sdListStart = regexp.MustCompile(`(?i)^Begin file list`) + sdListEnd = regexp.MustCompile(`(?i)^End file list`) + sdListLongRe = regexp.MustCompile(`"([^"]+)"`) +) + +// Parse one line of printer output and mutate s. Returns true if anything changed. +func (s *State) Parse(line string) bool { + line = strings.TrimSpace(line) + if line == "" { + return false + } + changed := false + + for _, m := range tempRe.FindAllStringSubmatch(line, -1) { + tool := m[1] + cur, _ := strconv.ParseFloat(m[2], 64) + tgt, _ := strconv.ParseFloat(m[3], 64) + if s.Temps == nil { + s.Temps = map[string]TempPair{} + } + s.Temps[tool] = TempPair{Current: cur, Target: tgt} + changed = true + } + + if s.Firmware == "" { + if idx := strings.Index(line, "FIRMWARE_NAME:"); idx >= 0 { + rest := line[idx+len("FIRMWARE_NAME:"):] + // Cut at the next CAPS_TOKEN: marker that Marlin emits after FIRMWARE_NAME + for _, sep := range []string{" SOURCE_CODE_URL:", " PROTOCOL_VERSION:", " MACHINE_TYPE:", " EXTRUDER_COUNT:"} { + if i := strings.Index(rest, sep); i >= 0 { + rest = rest[:i] + } + } + s.Firmware = strings.TrimSpace(rest) + if len(s.Firmware) > 48 { + s.Firmware = s.Firmware[:48] + } + if s.Firmware != "" { + changed = true + } + } + } + + if m := fileOpen.FindStringSubmatch(line); m != nil { + s.SDFilename = m[1] + changed = true + } + + // SD card directory listing (M20). The firmware emits "Begin file list", + // one entry per line, then "End file list". While we're inside that + // block, every non-marker line is a file entry; we accumulate into + // SDFiles. When the block ends we flip SDListing off and leave the + // completed slice in place for the UI to consume. + if sdListStart.MatchString(line) { + s.SDListing = true + s.SDFiles = s.SDFiles[:0] + changed = true + } else if sdListEnd.MatchString(line) { + if s.SDListing { + s.SDListing = false + changed = true + } + } else if s.SDListing { + if f, ok := parseSDFileEntry(line); ok { + s.SDFiles = append(s.SDFiles, f) + changed = true + } + } + + if m := sdRe.FindStringSubmatch(line); m != nil { + byteN, _ := strconv.ParseInt(m[1], 10, 64) + total, _ := strconv.ParseInt(m[2], 10, 64) + s.SDByte = byteN + s.SDTotal = total + if byteN > 0 && total > 0 { + if s.PrintState != StateSDPrinting && s.PrintState != StatePaused { + s.PrintState = StateSDPrinting + if s.SDStart.IsZero() { + s.SDStart = nowFn() + } + } else if s.SDStart.IsZero() { + s.SDStart = nowFn() + } + } + changed = true + } else if notSDRe.MatchString(line) { + if s.PrintState == StateSDPrinting { + s.PrintState = StateIdle + s.SDByte, s.SDTotal = 0, 0 + s.SDStart = zeroTime + changed = true + } + } else if sdDoneRe.MatchString(line) { + s.PrintState = StateIdle + s.SDByte = s.SDTotal + changed = true + } + + return changed +} + +// parseSDFileEntry tries to read a single line from inside the +// "Begin file list" / "End file list" block. Marlin emits +// "FILENAME.GCO 123456" or "FILENAME.GCO 123456 \"Long Name.gcode\"". +// Directories are reported with a trailing "/" — we surface them as +// entries too so the user can spot them, even though M23 won't enter them. +func parseSDFileEntry(line string) (SDFile, bool) { + line = strings.TrimSpace(line) + if line == "" { + return SDFile{}, false + } + // Strip a trailing long-filename if present, capture it. + long := "" + if m := sdListLongRe.FindStringSubmatch(line); m != nil { + long = m[1] + line = strings.TrimSpace(line[:strings.Index(line, "\"")]) + } + fields := strings.Fields(line) + if len(fields) == 0 { + return SDFile{}, false + } + name := fields[0] + var size int64 + if len(fields) >= 2 { + size, _ = strconv.ParseInt(fields[len(fields)-1], 10, 64) + } + return SDFile{Name: name, LongName: long, Size: size}, true +} diff --git a/go/internal/printer/parse_test.go b/go/internal/printer/parse_test.go new file mode 100644 index 0000000..3de902d --- /dev/null +++ b/go/internal/printer/parse_test.go @@ -0,0 +1,81 @@ +package printer + +import "testing" + +func TestParseTemps(t *testing.T) { + s := NewState() + s.Parse("ok T:201.3 /210.0 B:60.2 /60.0 @:127 B@:0") + if got := s.Temps["T"]; got.Current != 201.3 || got.Target != 210.0 { + t.Fatalf("hotend got %+v", got) + } + if got := s.Temps["B"]; got.Current != 60.2 || got.Target != 60.0 { + t.Fatalf("bed got %+v", got) + } +} + +func TestParseSDProgress(t *testing.T) { + s := NewState() + s.Parse("SD printing byte 12345/100000") + if s.SDByte != 12345 || s.SDTotal != 100000 { + t.Fatalf("got byte=%d total=%d", s.SDByte, s.SDTotal) + } + if s.PrintState != StateSDPrinting { + t.Fatalf("expected SD printing state, got %v", s.PrintState) + } + if p := s.Progress(); p < 0.12 || p > 0.13 { + t.Fatalf("progress %f", p) + } +} + +func TestParseNotSDPrinting(t *testing.T) { + s := NewState() + s.SDByte, s.SDTotal = 1, 2 + s.PrintState = StateSDPrinting + s.Parse("Not SD printing") + if s.PrintState != StateIdle { + t.Fatalf("expected idle, got %v", s.PrintState) + } +} + +func TestParseFirmware(t *testing.T) { + s := NewState() + s.Parse("FIRMWARE_NAME:Marlin 2.1.2 SOURCE_CODE_URL:github.com/foo") + if s.Firmware != "Marlin 2.1.2" { + t.Fatalf("got firmware %q", s.Firmware) + } +} + +func TestParseFileOpened(t *testing.T) { + s := NewState() + s.Parse("File opened: benchy.gcode Size: 1234567") + if s.SDFilename != "benchy.gcode" { + t.Fatalf("got %q", s.SDFilename) + } +} + +func TestParseSDFileListing(t *testing.T) { + s := NewState() + for _, line := range []string{ + "Begin file list", + "BENCHY.GCO 1234567", + "XYZ_CAL.GCO 50000 \"XYZ Calibration.gcode\"", + "End file list", + "ok", + } { + s.Parse(line) + } + if s.SDListing { + t.Fatalf("expected SDListing false after End") + } + if len(s.SDFiles) != 2 { + t.Fatalf("got %d files: %+v", len(s.SDFiles), s.SDFiles) + } + if s.SDFiles[0].Name != "BENCHY.GCO" || s.SDFiles[0].Size != 1234567 { + t.Fatalf("entry 0: %+v", s.SDFiles[0]) + } + if s.SDFiles[1].Name != "XYZ_CAL.GCO" || + s.SDFiles[1].Size != 50000 || + s.SDFiles[1].LongName != "XYZ Calibration.gcode" { + t.Fatalf("entry 1: %+v", s.SDFiles[1]) + } +} diff --git a/go/internal/printer/printer.go b/go/internal/printer/printer.go new file mode 100644 index 0000000..1ef0fb3 --- /dev/null +++ b/go/internal/printer/printer.go @@ -0,0 +1,529 @@ +package printer + +import ( + "bufio" + "context" + "fmt" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "go.bug.st/serial" +) + +// Event is a state snapshot published whenever the printer state changes, +// plus a raw recv line (RecvLine != "" means "log this; State unchanged is allowed"). +type Event struct { + State State + RecvLine string + SentLine string +} + +type Printer struct { + mu sync.Mutex + state State + port serial.Port + events chan Event + writes chan string // outbound G-code (best-effort, non-blocking) + cancel context.CancelFunc + stopped chan struct{} +} + +func New() *Printer { + return &Printer{ + state: NewState(), + events: make(chan Event, 128), + writes: make(chan string, 64), + } +} + +func (p *Printer) Events() <-chan Event { return p.events } + +func (p *Printer) Snapshot() State { + p.mu.Lock() + defer p.mu.Unlock() + return p.state +} + +// ListPorts returns plausible printer serial devices. +func ListPorts() []string { + out := []string{} + for _, pat := range []string{"/dev/ttyACM*", "/dev/ttyUSB*"} { + m, _ := filepath.Glob(pat) + out = append(out, m...) + } + return out +} + +// CommonBauds is the ordered list of baud rates tried during auto-detection. +// 250000 is Marlin's historical AVR default; 115200 covers STM32 builds, +// Klipper-prep firmwares, and RepRapFirmware. +var CommonBauds = []int{250000, 115200, 230400, 500000, 57600} + +// probeBaud opens the port at each candidate baud and looks for a +// recognisable Marlin/RepRap reply. Returns the working baud and an open +// port handle on success. +// +// We deliberately require an *unambiguous* match (firmware name or a +// well-formed temperature line). Short tokens like "ok" or "start" appear +// too easily in misaligned-baud garbage and would yield a false positive, +// so they are not accepted on their own. +func probeBaud(portName string, candidates []int) (int, serial.Port, error) { + tempLine := regexp.MustCompile(`(?i)(?:^|\s)(?:T\d*|B|C):\s*-?\d+(?:\.\d+)?\s*/\s*-?\d+`) + + for _, b := range candidates { + sp, err := serial.Open(portName, &serial.Mode{BaudRate: b}) + if err != nil { + continue + } + _ = sp.SetReadTimeout(100 * time.Millisecond) + + // Many boards reset on DTR assertion and need ~2 s to boot. + // Wait, then prod the firmware twice (once early in case the boot + // already finished, once later in case the first prod was eaten). + time.Sleep(400 * time.Millisecond) + _, _ = sp.Write([]byte("\nM115\n")) + time.Sleep(1200 * time.Millisecond) + _, _ = sp.Write([]byte("\nM115\nM105\n")) + + deadline := time.Now().Add(1500 * time.Millisecond) + buf := make([]byte, 256) + var acc []byte + ok := false + for time.Now().Before(deadline) { + n, _ := sp.Read(buf) + if n == 0 { + continue + } + acc = append(acc, buf[:n]...) + low := strings.ToLower(string(acc)) + if strings.Contains(low, "marlin") || + strings.Contains(low, "firmware_name") || + strings.Contains(low, "reprapfirmware") || + strings.Contains(low, "klipper") || + tempLine.MatchString(string(acc)) { + ok = true + break + } + // Cap accumulator so we don't grow unbounded on garbage. + if len(acc) > 4096 { + acc = acc[len(acc)-2048:] + } + } + if ok { + return b, sp, nil + } + _ = sp.Close() + } + return 0, nil, fmt.Errorf("could not auto-detect baud rate; tried %v", candidates) +} + +func (p *Printer) Connect(portName string, baud int) error { + p.Disconnect() + + // Candidate ports: caller-specified, or everything that looks like a + // printer. We probe each in turn so that systems with multiple ACM/USB + // devices (modems, debug probes, etc.) don't trap us on the wrong one. + var candidates []string + if portName != "" { + candidates = []string{portName} + } else { + candidates = ListPorts() + if len(candidates) == 0 { + return fmt.Errorf("no serial port found") + } + } + + var sp serial.Port + var chosenPort string + if baud <= 0 { + var lastErr error + for _, pn := range candidates { + detected, opened, err := probeBaud(pn, CommonBauds) + if err != nil { + lastErr = err + continue + } + sp = opened + baud = detected + chosenPort = pn + break + } + if sp == nil { + if lastErr != nil { + return fmt.Errorf("no responsive printer on %v: %w", candidates, lastErr) + } + return fmt.Errorf("no responsive printer on %v", candidates) + } + } else { + // Explicit baud: still try each candidate port until one opens and + // looks alive at this baud. + var lastErr error + for _, pn := range candidates { + opened, err := serial.Open(pn, &serial.Mode{BaudRate: baud}) + if err != nil { + lastErr = err + continue + } + sp = opened + chosenPort = pn + break + } + if sp == nil { + return fmt.Errorf("could not open any of %v: %w", candidates, lastErr) + } + } + portName = chosenPort + _ = sp.SetReadTimeout(500 * time.Millisecond) + + p.mu.Lock() + p.port = sp + p.state.Connected = true + p.state.Port = portName + p.state.Baud = baud + p.state.ErrorMsg = "" + snap := p.state + p.mu.Unlock() + p.publish(Event{State: snap}) + + ctx, cancel := context.WithCancel(context.Background()) + p.cancel = cancel + p.stopped = make(chan struct{}) + + go p.run(ctx) + return nil +} + +func (p *Printer) Disconnect() { + if p.cancel != nil { + p.cancel() + <-p.stopped + p.cancel = nil + } + p.mu.Lock() + if p.port != nil { + _ = p.port.Close() + p.port = nil + } + p.state.Connected = false + p.state.Online = false + p.state.PrintState = StateIdle + snap := p.state + p.mu.Unlock() + p.publish(Event{State: snap}) +} + +// run is the connection goroutine. It owns the serial port for the duration +// of the connection and serialises reads/writes through this single goroutine. +func (p *Printer) run(ctx context.Context) { + defer close(p.stopped) + + port := p.port + if port == nil { + return + } + + reader := bufio.NewReader(port) + lineCh := make(chan string, 32) + + // reader goroutine + go func() { + for { + line, err := reader.ReadString('\n') + if err != nil { + if ctx.Err() != nil { + close(lineCh) + return + } + // timeout — ReadString returns partial+err; just retry + if line == "" { + continue + } + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + continue + } + select { + case lineCh <- line: + case <-ctx.Done(): + close(lineCh) + return + } + } + }() + + // Probe — these prime auto-reporting on firmwares that support it. + primed := false + primeAfter := time.NewTimer(2 * time.Second) + defer primeAfter.Stop() + + pollTemp := time.NewTicker(2 * time.Second) + defer pollTemp.Stop() + pollSD := time.NewTicker(5 * time.Second) + defer pollSD.Stop() + + prime := func() { + if primed { + return + } + primed = true + p.writeRaw("M115") + // Disable any auto-reporting Marlin may have on — our own polling is + // the single source of truth. Otherwise the two streams collide and + // produce mangled output like "TT::57.4957.49". + p.writeRaw("M155 S0") + p.writeRaw("M27 S0") + // First explicit poll so users see data fast + p.writeRaw("M105") + p.writeRaw("M27") + } + + for { + select { + case <-ctx.Done(): + return + + case <-primeAfter.C: + // Some firmwares emit "start" / "Marlin" before being ready; just prime now. + prime() + // Mark online if we got *anything* by now + p.setOnline(true) + + case line, ok := <-lineCh: + if !ok { + return + } + // "start" / "Marlin" / "ok" gates online + low := strings.ToLower(line) + if strings.Contains(low, "start") || strings.HasPrefix(low, "marlin") { + prime() + p.setOnline(true) + } + if strings.HasPrefix(low, "ok") { + p.setOnline(true) + } + if strings.HasPrefix(low, "error") || strings.HasPrefix(low, "!!") { + p.setError(line) + } + + p.mu.Lock() + p.state.Parse(line) + snap := p.state + p.mu.Unlock() + + p.publish(Event{RecvLine: line, State: snap}) + + case <-pollTemp.C: + if !primed { + continue + } + p.writeRaw("M105") + + case <-pollSD.C: + if !primed { + continue + } + p.writeRaw("M27") + + case gcode := <-p.writes: + p.writeRaw(gcode) + } + } +} + +func (p *Printer) writeRaw(gcode string) { + p.mu.Lock() + port := p.port + p.mu.Unlock() + if port == nil { + return + } + line := strings.TrimSpace(gcode) + if line == "" { + return + } + _, err := port.Write([]byte(line + "\n")) + if err != nil { + p.setError(err.Error()) + return + } + p.publish(Event{SentLine: line, State: p.Snapshot()}) +} + +// Send enqueues a raw G-code line. Non-blocking; drops if buffer full. +func (p *Printer) Send(gcode string) { + select { + case p.writes <- gcode: + default: + } +} + +// -- high-level commands -------------------------------------------------- + +func (p *Printer) SetHotend(temp int, tool int) { p.Send(fmt.Sprintf("M104 T%d S%d", tool, temp)) } +func (p *Printer) SetBed(temp int) { p.Send(fmt.Sprintf("M140 S%d", temp)) } + +func (p *Printer) SetFan(pct int) { + pct = clamp(pct, 0, 100) + if pct == 0 { + p.Send("M107") + } else { + p.Send(fmt.Sprintf("M106 S%d", pct*255/100)) + } + p.mu.Lock() + p.state.FanPct = pct + snap := p.state + p.mu.Unlock() + p.publish(Event{State: snap}) +} + +func (p *Printer) SetFeedrate(pct int) { + pct = clamp(pct, 10, 300) + p.Send(fmt.Sprintf("M220 S%d", pct)) + p.mu.Lock() + p.state.FeedratePct = pct + snap := p.state + p.mu.Unlock() + p.publish(Event{State: snap}) +} + +func (p *Printer) SetFlow(pct int) { + pct = clamp(pct, 50, 150) + p.Send(fmt.Sprintf("M221 S%d", pct)) + p.mu.Lock() + p.state.FlowPct = pct + snap := p.state + p.mu.Unlock() + p.publish(Event{State: snap}) +} + +func (p *Printer) BabystepZ(delta float64) { + p.Send(fmt.Sprintf("M290 Z%.3f", delta)) + p.mu.Lock() + p.state.BabystepZ = roundTo(p.state.BabystepZ+delta, 3) + snap := p.state + p.mu.Unlock() + p.publish(Event{State: snap}) +} + +func (p *Printer) ResetBabystep() { + p.mu.Lock() + delta := -p.state.BabystepZ + p.state.BabystepZ = 0 + snap := p.state + p.mu.Unlock() + p.Send(fmt.Sprintf("M290 Z%.3f", delta)) + p.publish(Event{State: snap}) +} + +func (p *Printer) Pause() { + p.mu.Lock() + if p.state.PrintState == StateSDPrinting { + p.state.PrintState = StatePaused + } + snap := p.state + p.mu.Unlock() + p.Send("M25") + p.publish(Event{State: snap}) +} + +func (p *Printer) Resume() { + p.mu.Lock() + if p.state.PrintState == StatePaused && p.state.SDTotal > 0 { + p.state.PrintState = StateSDPrinting + } + snap := p.state + p.mu.Unlock() + p.Send("M24") + p.publish(Event{State: snap}) +} + +// ListSDFiles asks the printer to enumerate its SD card. The reply is +// parsed asynchronously; the next State snapshot whose SDListing flips +// false carries the complete listing in SDFiles. +func (p *Printer) ListSDFiles() { + p.Send("M21") // init / mount SD (no-op if already mounted) + p.Send("M20") +} + +// StartSDPrint selects the given filename on the SD card and starts it. +// M23 selects, M24 begins. Filename must be the 8.3 short name that +// Marlin's M20 listing reports. +func (p *Printer) StartSDPrint(filename string) { + filename = strings.TrimSpace(filename) + if filename == "" { + return + } + p.Send("M23 " + filename) + p.Send("M24") +} + +func (p *Printer) Cancel() { + p.Send("M524") + p.mu.Lock() + p.state.PrintState = StateIdle + p.state.SDByte, p.state.SDTotal = 0, 0 + p.state.SDStart = zeroTime + snap := p.state + p.mu.Unlock() + p.publish(Event{State: snap}) +} + +// -- internals ------------------------------------------------------------ + +func (p *Printer) setOnline(v bool) { + p.mu.Lock() + if p.state.Online == v { + p.mu.Unlock() + return + } + p.state.Online = v + snap := p.state + p.mu.Unlock() + p.publish(Event{State: snap}) +} + +func (p *Printer) setError(msg string) { + p.mu.Lock() + p.state.ErrorMsg = msg + p.state.PrintState = StateError + snap := p.state + p.mu.Unlock() + p.publish(Event{State: snap, RecvLine: "ERR " + msg}) +} + +func (p *Printer) publish(e Event) { + select { + case p.events <- e: + default: + // drop on full channel — UI will catch up on the next event + } +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func roundTo(v float64, places int) float64 { + pow := 1.0 + for i := 0; i < places; i++ { + pow *= 10 + } + return float64(int64(v*pow+0.5*sign(v))) / pow +} + +func sign(v float64) float64 { + if v < 0 { + return -1 + } + return 1 +} diff --git a/go/internal/printer/state.go b/go/internal/printer/state.go new file mode 100644 index 0000000..7cb7488 --- /dev/null +++ b/go/internal/printer/state.go @@ -0,0 +1,115 @@ +package printer + +import "time" + +type PrintState int + +const ( + StateIdle PrintState = iota + StateSDPrinting + StateHostPrinting + StatePaused + StateError +) + +func (p PrintState) String() string { + switch p { + case StateIdle: + return "IDLE" + case StateSDPrinting: + return "SD PRINTING" + case StateHostPrinting: + return "PRINTING" + case StatePaused: + return "PAUSED" + case StateError: + return "ERROR" + default: + return "?" + } +} + +type TempPair struct { + Current float64 + Target float64 +} + +type SDFile struct { + Name string // 8.3 short filename, what M23 takes + LongName string // long filename if firmware reports one + Size int64 +} + +type State struct { + Connected bool + Online bool + Firmware string + Port string + Baud int + + PrintState PrintState + ErrorMsg string + + Temps map[string]TempPair + + SDByte int64 + SDTotal int64 + SDFilename string + SDStart time.Time + + FeedratePct int + FlowPct int + FanPct int + BabystepZ float64 + + // SD card listing. SDListing is true while M20 output is being + // collected; SDFiles is the most recently completed listing. + SDFiles []SDFile + SDListing bool +} + +func NewState() State { + return State{ + Temps: map[string]TempPair{}, + FeedratePct: 100, + FlowPct: 100, + FanPct: 0, + PrintState: StateIdle, + } +} + +func (s *State) Progress() float64 { + if s.SDTotal <= 0 { + return 0 + } + p := float64(s.SDByte) / float64(s.SDTotal) + if p > 1 { + return 1 + } + return p +} + +func (s *State) ETA() (time.Duration, bool) { + if s.PrintState != StateSDPrinting || s.SDByte <= 0 || s.SDStart.IsZero() { + return 0, false + } + elapsed := nowFn().Sub(s.SDStart) + if elapsed < 5*time.Second { + return 0, false + } + rate := float64(s.SDByte) / elapsed.Seconds() + if rate <= 0 { + return 0, false + } + remaining := float64(s.SDTotal - s.SDByte) + if remaining < 0 { + remaining = 0 + } + return time.Duration(remaining/rate) * time.Second, true +} + +// indirection so tests can fix time +var ( + nowFn = time.Now + zeroTime = time.Time{} +) diff --git a/go/internal/tui/model.go b/go/internal/tui/model.go new file mode 100644 index 0000000..5a40b13 --- /dev/null +++ b/go/internal/tui/model.go @@ -0,0 +1,596 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/lwoodard/printcontrol/internal/printer" +) + +type Config struct { + Port string + Baud int + AutoConnect bool +} + +type focusArea int + +const ( + focusNone focusArea = iota + focusHotendInput + focusBedInput + focusGcodeInput + focusSDPicker +) + +type Model struct { + cfg Config + printer *printer.Printer + state printer.State + + width, height int + + hotendInput string + bedInput string + gcodeInput string + focus focusArea + + // SD picker state. Populated when the user opens it with [s]; + // refreshed whenever the printer reports a fresh listing. + sdPickerFiles []printer.SDFile + sdPickerIdx int + sdPickerWait bool // waiting for M20 reply + + log []string + logMax int + flashUntil time.Time + flashMsg string +} + +func New(cfg Config) *Model { + return &Model{ + cfg: cfg, + printer: printer.New(), + state: printer.NewState(), + logMax: 200, + } +} + +// -- bubbletea Model interface --------------------------------------------- + +func (m *Model) Init() tea.Cmd { + cmds := []tea.Cmd{tickCmd(), waitEvent(m.printer)} + if m.cfg.AutoConnect { + cmds = append(cmds, m.connectCmd()) + } + return tea.Batch(cmds...) +} + +type tickMsg time.Time +type eventMsg printer.Event +type connectResultMsg struct{ err error } + +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +func waitEvent(p *printer.Printer) tea.Cmd { + return func() tea.Msg { + evt, ok := <-p.Events() + if !ok { + return nil + } + return eventMsg(evt) + } +} + +func (m *Model) connectCmd() tea.Cmd { + return func() tea.Msg { + err := m.printer.Connect(m.cfg.Port, m.cfg.Baud) + return connectResultMsg{err: err} + } +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + + case tickMsg: + return m, tickCmd() + + case eventMsg: + evt := printer.Event(msg) + if evt.State.Port != "" || evt.State.Connected || evt.State.PrintState != 0 || len(evt.State.Temps) > 0 { + m.state = evt.State + } + if evt.RecvLine != "" { + m.appendLog("< " + evt.RecvLine) + } + if evt.SentLine != "" { + m.appendLog("> " + evt.SentLine) + } + // If we were waiting on an M20 listing and one just completed, + // populate the picker. + if m.sdPickerWait && !evt.State.SDListing && len(evt.State.SDFiles) > 0 { + m.sdPickerFiles = append(m.sdPickerFiles[:0], evt.State.SDFiles...) + m.sdPickerIdx = 0 + m.sdPickerWait = false + m.focus = focusSDPicker + } + return m, waitEvent(m.printer) + + case connectResultMsg: + if msg.err != nil { + m.flash("connect failed: " + msg.err.Error()) + } else { + snap := m.printer.Snapshot() + m.state = snap + m.flash(fmt.Sprintf("connected: %s @ %d", snap.Port, snap.Baud)) + } + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m *Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + // SD picker swallows all key input while open: arrows to navigate, + // Enter to start the highlighted print, Esc to dismiss. + if m.focus == focusSDPicker { + switch k.Type { + case tea.KeyEsc: + m.focus = focusNone + return m, nil + case tea.KeyUp: + if m.sdPickerIdx > 0 { + m.sdPickerIdx-- + } + return m, nil + case tea.KeyDown: + if m.sdPickerIdx < len(m.sdPickerFiles)-1 { + m.sdPickerIdx++ + } + return m, nil + case tea.KeyEnter: + if m.sdPickerIdx < len(m.sdPickerFiles) { + f := m.sdPickerFiles[m.sdPickerIdx] + m.printer.StartSDPrint(f.Name) + m.flash("start: " + f.Name) + } + m.focus = focusNone + return m, nil + } + // Letters j/k as a vim-style fallback for users without arrow keys. + switch k.String() { + case "k": + if m.sdPickerIdx > 0 { + m.sdPickerIdx-- + } + case "j": + if m.sdPickerIdx < len(m.sdPickerFiles)-1 { + m.sdPickerIdx++ + } + } + return m, nil + } + + // When a text input is focused, capture printable text + enter/esc + if m.focus != focusNone { + switch k.Type { + case tea.KeyEsc: + m.focus = focusNone + return m, nil + case tea.KeyEnter: + return m, m.commitInput() + case tea.KeyBackspace, tea.KeyDelete: + m.editInput(func(s string) string { + if len(s) == 0 { + return s + } + return s[:len(s)-1] + }) + return m, nil + case tea.KeyRunes, tea.KeySpace: + m.editInput(func(s string) string { return s + k.String() }) + return m, nil + } + return m, nil + } + + switch k.String() { + case "q", "ctrl+c": + m.printer.Disconnect() + return m, tea.Quit + case "c": + m.flash("connecting…") + return m, m.connectCmd() + case "d": + m.printer.Disconnect() + return m, nil + case "p": + m.printer.Pause() + case "r": + m.printer.Resume() + case "x": + m.printer.Cancel() + case "h": + m.focus = focusHotendInput + case "b": + m.focus = focusBedInput + case "g": + m.focus = focusGcodeInput + case "s": + m.printer.ListSDFiles() + m.sdPickerWait = true + m.flash("listing SD card…") + case "f": + m.printer.SetFan(m.state.FanPct + 10) + case "F": + m.printer.SetFan(m.state.FanPct - 10) + case "+", "=": + m.printer.SetFeedrate(m.state.FeedratePct + 10) + case "-", "_": + m.printer.SetFeedrate(m.state.FeedratePct - 10) + case "0": + m.printer.SetFeedrate(100) + case "[": + m.printer.SetFlow(m.state.FlowPct - 5) + case "]": + m.printer.SetFlow(m.state.FlowPct + 5) + case "{": + m.printer.BabystepZ(-0.05) + case "}": + m.printer.BabystepZ(0.05) + case "Z": + m.printer.ResetBabystep() + } + return m, nil +} + +func (m *Model) editInput(fn func(string) string) { + switch m.focus { + case focusHotendInput: + m.hotendInput = fn(m.hotendInput) + case focusBedInput: + m.bedInput = fn(m.bedInput) + case focusGcodeInput: + m.gcodeInput = fn(m.gcodeInput) + } +} + +func (m *Model) commitInput() tea.Cmd { + switch m.focus { + case focusHotendInput: + if v, err := strconv.Atoi(strings.TrimSpace(m.hotendInput)); err == nil { + m.printer.SetHotend(v, 0) + m.flash(fmt.Sprintf("hotend → %d °C", v)) + } + m.hotendInput = "" + case focusBedInput: + if v, err := strconv.Atoi(strings.TrimSpace(m.bedInput)); err == nil { + m.printer.SetBed(v) + m.flash(fmt.Sprintf("bed → %d °C", v)) + } + m.bedInput = "" + case focusGcodeInput: + if line := strings.TrimSpace(m.gcodeInput); line != "" { + m.printer.Send(line) + } + m.gcodeInput = "" + } + m.focus = focusNone + return nil +} + +func (m *Model) flash(msg string) { + m.flashMsg = msg + m.flashUntil = time.Now().Add(3 * time.Second) + m.appendLog("· " + msg) +} + +func (m *Model) appendLog(line string) { + m.log = append(m.log, line) + if len(m.log) > m.logMax { + m.log = m.log[len(m.log)-m.logMax:] + } +} + +// -- view ------------------------------------------------------------------ + +func (m *Model) View() string { + if m.width == 0 { + return "starting…" + } + + // Two columns (status+controls vs temps) above a wide log + leftW := (m.width - 4) / 2 + rightW := m.width - 4 - leftW + if leftW < 30 { + leftW = m.width - 4 + rightW = 0 + } + + status := panelStyle.Width(leftW).Render(m.renderStatus()) + temps := panelStyle.Width(rightW).Render(m.renderTemps()) + controls := panelStyle.Width(m.width - 4).Render(m.renderControls()) + + top := lipgloss.JoinHorizontal(lipgloss.Top, status, temps) + + header := titleStyle.Render(" printcontrol ") + dimStyle.Render(" · 3D printer TUI") + footer := m.renderFooter() + + // Reserve room for header + top + controls + footer (plus panel chrome). + // Whatever rows are left go to the log; if the terminal is short we keep + // the log to two lines so the footer never falls off. + reserved := lipgloss.Height(header) + + lipgloss.Height(top) + + lipgloss.Height(controls) + + lipgloss.Height(footer) + logRows := m.height - reserved - 3 // 3 = log panel border + title + if logRows < 2 { + logRows = 2 + } + if logRows > 12 { + logRows = 12 + } + logBox := panelStyle.Width(m.width - 4).Render(m.renderLog(logRows)) + + // SD picker takes over the log slot when open — keeps the rest of the + // status panels visible while the user is choosing. + if m.focus == focusSDPicker { + logBox = panelStyle.Width(m.width - 4).Render(m.renderSDPicker(logRows)) + } + + return lipgloss.JoinVertical(lipgloss.Left, header, top, controls, logBox, footer) +} + +func (m *Model) renderStatus() string { + var b strings.Builder + b.WriteString(titleStyle.Render("● Status") + "\n") + + conn := "disconnected" + if m.state.Connected { + conn = fmt.Sprintf("%s @ %d", m.state.Port, m.state.Baud) + if m.state.Firmware != "" { + conn += dimStyle.Render(" · " + m.state.Firmware) + } + } + b.WriteString(kv("Connection:", conn) + " " + statePill(m.state.PrintState) + "\n") + + file := m.state.SDFilename + if file == "" { + file = "—" + } + b.WriteString(kv("File:", file) + "\n") + + prog := int(m.state.Progress() * 100) + bar := progressBar(prog, 30) + b.WriteString(kv("Progress:", fmt.Sprintf("%s %3d%%", bar, prog)) + "\n") + + etaStr := "—" + if eta, ok := m.state.ETA(); ok { + etaStr = formatDuration(eta) + } + b.WriteString(kv("ETA:", etaStr)) + return b.String() +} + +func (m *Model) renderTemps() string { + var b strings.Builder + b.WriteString(titleStyle.Render("● Temperatures") + "\n") + + hot := m.state.Temps["T"] + if (hot == printer.TempPair{}) { + hot = m.state.Temps["T0"] + } + b.WriteString(kv("Hotend:", tempStr(hot)) + "\n") + + bed := m.state.Temps["B"] + b.WriteString(kv("Bed:", tempStr(bed)) + "\n") + + chamber, hasChamber := m.state.Temps["C"] + if hasChamber { + b.WriteString(kv("Chamber:", tempStr(chamber)) + "\n") + } + + b.WriteString("\n") + + // Input lines + hotPrompt := " set hotend: " + if m.focus == focusHotendInput { + hotPrompt += valueStyle.Render(m.hotendInput + "▏") + } else { + hotPrompt += dimStyle.Render("[h]") + } + b.WriteString(hotPrompt + "\n") + + bedPrompt := " set bed: " + if m.focus == focusBedInput { + bedPrompt += valueStyle.Render(m.bedInput + "▏") + } else { + bedPrompt += dimStyle.Render("[b]") + } + b.WriteString(bedPrompt) + return b.String() +} + +func (m *Model) renderControls() string { + var b strings.Builder + b.WriteString(titleStyle.Render("● Controls") + "\n") + b.WriteString(fmt.Sprintf(" Feedrate: %s %s Flow: %s %s Fan: %s %s Babystep Z: %s %s\n", + valueStyle.Render(fmt.Sprintf("%3d%%", m.state.FeedratePct)), + dimStyle.Render("[-/+] [0]"), + valueStyle.Render(fmt.Sprintf("%3d%%", m.state.FlowPct)), + dimStyle.Render("[ [ / ] ]"), + valueStyle.Render(fmt.Sprintf("%3d%%", m.state.FanPct)), + dimStyle.Render("[f/F]"), + valueStyle.Render(fmt.Sprintf("%+.3f", m.state.BabystepZ)), + dimStyle.Render("[{ / }] [Z]"), + )) + if m.focus == focusGcodeInput { + b.WriteString(" > " + valueStyle.Render(m.gcodeInput + "▏")) + } else { + b.WriteString(dimStyle.Render(" press [g] to send raw G-code")) + } + return b.String() +} + +func (m *Model) renderSDPicker(rows int) string { + title := titleStyle.Render("● SD card") + dimStyle.Render(" ↑/↓ select ⏎ print esc cancel") + if len(m.sdPickerFiles) == 0 { + return title + "\n" + dimStyle.Render(" (no files reported)") + } + if rows < 1 { + rows = 1 + } + // Scroll window around the selected index. + from := 0 + to := len(m.sdPickerFiles) + if to > rows { + from = m.sdPickerIdx - rows/2 + if from < 0 { + from = 0 + } + if from+rows > len(m.sdPickerFiles) { + from = len(m.sdPickerFiles) - rows + } + to = from + rows + } + var b strings.Builder + b.WriteString(title) + for i := from; i < to; i++ { + f := m.sdPickerFiles[i] + label := f.Name + if f.LongName != "" { + label = f.LongName + dimStyle.Render(" ("+f.Name+")") + } + size := "" + if f.Size > 0 { + size = dimStyle.Render(" " + humanSize(f.Size)) + } + row := " " + label + size + if i == m.sdPickerIdx { + row = valueStyle.Render("▸ ") + valueStyle.Render(label) + size + } + b.WriteString("\n" + row) + } + return b.String() +} + +func humanSize(n int64) string { + const k = 1024 + switch { + case n < k: + return fmt.Sprintf("%dB", n) + case n < k*k: + return fmt.Sprintf("%.1fK", float64(n)/k) + case n < k*k*k: + return fmt.Sprintf("%.1fM", float64(n)/(k*k)) + default: + return fmt.Sprintf("%.1fG", float64(n)/(k*k*k)) + } +} + +func (m *Model) renderLog(rows int) string { + title := titleStyle.Render("● Log") + if len(m.log) == 0 { + return title + "\n" + dimStyle.Render(" (no messages yet)") + } + if rows < 1 { + rows = 1 + } + from := 0 + if len(m.log) > rows { + from = len(m.log) - rows + } + body := strings.Join(m.log[from:], "\n") + return title + "\n" + dimStyle.Render(body) +} + +func (m *Model) renderFooter() string { + keys := []string{ + key("c") + " connect", + key("d") + " disconnect", + key("s") + " sd start", + key("p") + " pause", + key("r") + " resume", + key("x") + " cancel", + key("h") + " hotend", + key("b") + " bed", + key("g") + " gcode", + key("q") + " quit", + } + hints := keyHintStyle.Render(strings.Join(keys, " ")) + if time.Now().Before(m.flashUntil) && m.flashMsg != "" { + return hints + "\n" + valueStyle.Render(m.flashMsg) + } + if m.state.ErrorMsg != "" { + return hints + "\n" + pillError.Render("⚠ "+m.state.ErrorMsg) + } + return hints +} + +// -- view helpers ---------------------------------------------------------- + +func kv(label, value string) string { + return labelStyle.Render(fmt.Sprintf("%-12s", label)) + " " + valueStyle.Render(value) +} + +func key(k string) string { return keyStyle.Render("[" + k + "]") } + +func tempStr(p printer.TempPair) string { + if p.Current == 0 && p.Target == 0 { + return "—" + } + return fmt.Sprintf("%5.1f / %5.1f °C", p.Current, p.Target) +} + +func statePill(s printer.PrintState) string { + switch s { + case printer.StateSDPrinting, printer.StateHostPrinting: + return pillPrint.Render(s.String()) + case printer.StatePaused: + return pillPause.Render(s.String()) + case printer.StateError: + return pillError.Render(s.String()) + default: + return pillIdle.Render(s.String()) + } +} + +func progressBar(pct, width int) string { + if pct < 0 { + pct = 0 + } + if pct > 100 { + pct = 100 + } + filled := pct * width / 100 + return "[" + strings.Repeat("█", filled) + strings.Repeat("·", width-filled) + "]" +} + +func formatDuration(d time.Duration) string { + d = d.Round(time.Second) + h := d / time.Hour + d -= h * time.Hour + mm := d / time.Minute + d -= mm * time.Minute + s := d / time.Second + if h > 0 { + return fmt.Sprintf("%dh %02dm", h, mm) + } + if mm > 0 { + return fmt.Sprintf("%dm %02ds", mm, s) + } + return fmt.Sprintf("%ds", s) +} diff --git a/go/internal/tui/style.go b/go/internal/tui/style.go new file mode 100644 index 0000000..3587a68 --- /dev/null +++ b/go/internal/tui/style.go @@ -0,0 +1,32 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +// Colours are deliberately ANSI 16-colour where possible so the terminal theme +// (Tokyo Night / Catppuccin / whatever Omarchy is wearing today) drives the look. +var ( + colAccent = lipgloss.AdaptiveColor{Light: "5", Dark: "13"} // magenta + colOK = lipgloss.AdaptiveColor{Light: "2", Dark: "10"} // green + colWarn = lipgloss.AdaptiveColor{Light: "3", Dark: "11"} // yellow + colErr = lipgloss.AdaptiveColor{Light: "1", Dark: "9"} // red + colMuted = lipgloss.AdaptiveColor{Light: "8", Dark: "8"} // bright black + colInfo = lipgloss.AdaptiveColor{Light: "4", Dark: "12"} // blue + + panelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colAccent). + Padding(0, 1) + + titleStyle = lipgloss.NewStyle().Foreground(colAccent).Bold(true) + labelStyle = lipgloss.NewStyle().Foreground(colMuted) + valueStyle = lipgloss.NewStyle().Bold(true) + dimStyle = lipgloss.NewStyle().Foreground(colMuted) + + pillIdle = lipgloss.NewStyle().Padding(0, 1).Foreground(colMuted) + pillPrint = lipgloss.NewStyle().Padding(0, 1).Foreground(colOK).Bold(true) + pillPause = lipgloss.NewStyle().Padding(0, 1).Foreground(colWarn).Bold(true) + pillError = lipgloss.NewStyle().Padding(0, 1).Foreground(colErr).Bold(true) + + keyHintStyle = lipgloss.NewStyle().Foreground(colMuted) + keyStyle = lipgloss.NewStyle().Foreground(colInfo).Bold(true) +)