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) }