597 lines
14 KiB
Go
597 lines
14 KiB
Go
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)
|
|
}
|