Files
parchment/tui.go
T
2026-01-28 11:44:15 -06:00

329 lines
8.5 KiB
Go

package main
import (
"fmt"
"log"
"os"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// AppStatus represents the current state of the application.
type AppStatus int
const (
StatusSearching AppStatus = iota
StatusFound
StatusConnecting
StatusConnected
StatusError
StatusUploading
StatusUploaded
StatusPromptUnpack
StatusUnpacking
StatusUnpacked
)
// UIModel is the Bubble Tea model for the application.
type UIModel struct {
Device *Device
Status AppStatus
ErrorMsg string
TextInput textinput.Model
Width int
Height int
}
// Custom messages for Bubble Tea commands
type connectMsg struct {
err error
}
type uploadMsg struct {
err error
}
type unpackMsg struct {
err error
}
// InitialModel creates the initial state of the model.
func InitialModel(device *Device) UIModel {
ti := textinput.New()
ti.Placeholder = "package-name"
ti.CharLimit = 256
ti.Width = 40
return UIModel{
Device: device,
Status: StatusSearching,
TextInput: ti,
}
}
// Init initializes the model.
func (m UIModel) Init() tea.Cmd {
return textinput.Blink
}
// Update handles messages and updates the model.
func (m UIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.Width = msg.Width
m.Height = msg.Height
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
// Ensure device is closed on exit
if m.Device != nil {
m.Device.Close()
}
return m, tea.Quit
}
}
// Handle input based on current status
if m.Status == StatusPromptUnpack {
return m.updatePromptUnpack(msg)
}
return m.updateMain(msg)
}
func (m UIModel) updatePromptUnpack(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
if m.Device.Connected {
m.Status = StatusConnected
} else {
m.Status = StatusFound
}
m.TextInput.Reset()
return m, nil
case "enter":
filename := m.TextInput.Value()
if filename != "" {
m.Status = StatusUnpacking
m.TextInput.Reset()
return m, debugUnpackCmd(filename)
}
}
}
var cmd tea.Cmd
m.TextInput, cmd = m.TextInput.Update(msg)
return m, cmd
}
func (m UIModel) updateMain(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "c", "enter":
if m.Status == StatusFound {
m.Status = StatusConnecting
return m, connectCmd(m.Device)
}
case "u":
if m.Status == StatusConnected {
m.Status = StatusUploading
return m, uploadCmd(m.Device)
}
case "d":
if m.Status == StatusFound || m.Status == StatusConnected || m.Status == StatusUploaded || m.Status == StatusUnpacked {
m.Status = StatusPromptUnpack
m.TextInput.Focus()
return m, textinput.Blink
}
}
case connectMsg:
if msg.err != nil {
m.Status = StatusError
m.ErrorMsg = msg.err.Error()
return m, nil
}
m.Status = StatusConnected
return m, nil
case uploadMsg:
if msg.err != nil {
m.Status = StatusError
m.ErrorMsg = msg.err.Error()
return m, nil
}
m.Status = StatusUploaded
return m, nil
case unpackMsg:
if msg.err != nil {
m.Status = StatusError
m.ErrorMsg = msg.err.Error()
return m, nil
}
m.Status = StatusUnpacked
return m, nil
}
return m, nil
}
// View renders the application UI.
func (m UIModel) View() string {
var s strings.Builder
// Styles
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1).
MarginBottom(1)
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Bold(true)
errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Bold(true)
successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")).Bold(true)
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Bold(true)
descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#A0A0A0"))
// Header
s.WriteString(titleStyle.Render("Parchment - Cardstock Dev Server"))
s.WriteString("\n")
// Content
content := strings.Builder{}
switch m.Status {
case StatusSearching:
content.WriteString(statusStyle.Render("⏳ Searching for Cardputer..."))
case StatusFound:
content.WriteString(successStyle.Render("✓ Cardputer found"))
content.WriteString(fmt.Sprintf(" at %s\n\n", m.Device.PortName))
content.WriteString(keyStyle.Render("[c] / [enter]") + descStyle.Render(" Connect and enter dev mode\n"))
content.WriteString(keyStyle.Render("[d]") + descStyle.Render(" Debug pack/unpack (local)\n"))
case StatusConnecting:
content.WriteString(statusStyle.Render("⏳ Connecting and requesting dev mode..."))
case StatusConnected:
content.WriteString(successStyle.Render("✓ Connected to Cardputer!"))
content.WriteString(fmt.Sprintf("\n\nPort: %s\n", m.Device.PortName))
content.WriteString("Device is in developer mode.\n\n")
content.WriteString(keyStyle.Render("[u]") + descStyle.Render(" Upload package from current directory\n"))
content.WriteString(keyStyle.Render("[d]") + descStyle.Render(" Debug pack/unpack (local)\n"))
case StatusUploading:
content.WriteString(statusStyle.Render("⏳ Uploading package to device..."))
case StatusUploaded:
content.WriteString(successStyle.Render("✓ Package uploaded successfully!"))
content.WriteString(fmt.Sprintf("\n\nPort: %s\n", m.Device.PortName))
content.WriteString("\n")
content.WriteString(keyStyle.Render("[d]") + descStyle.Render(" Debug pack/unpack\n"))
case StatusPromptUnpack:
content.WriteString(statusStyle.Render("Debug Unpack"))
content.WriteString("\n\nEnter the filename to unpack (without .cpkg extension):\n\n")
content.WriteString(m.TextInput.View())
content.WriteString("\n\n")
content.WriteString(keyStyle.Render("[enter]") + descStyle.Render(" Unpack\n"))
content.WriteString(keyStyle.Render("[esc]") + descStyle.Render(" Cancel\n"))
case StatusUnpacking:
content.WriteString(statusStyle.Render("⏳ Debug packing and unpacking..."))
case StatusUnpacked:
content.WriteString(successStyle.Render("✓ Debug pack/unpack completed!"))
content.WriteString("\n\nCheck debug.log for details.\n")
content.WriteString(keyStyle.Render("[d]") + descStyle.Render(" Run again\n"))
case StatusError:
content.WriteString(errorStyle.Render("✗ Error: "))
content.WriteString(m.ErrorMsg)
content.WriteString("\n\n")
if m.Device.Connected {
content.WriteString(keyStyle.Render("[u]") + descStyle.Render(" Retry upload\n"))
} else {
content.WriteString(keyStyle.Render("[c]") + descStyle.Render(" Retry connection\n"))
}
}
// Render content with some margin
s.WriteString(lipgloss.NewStyle().Margin(1, 0, 1, 0).Render(content.String()))
// Footer
s.WriteString("\n")
footer := lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render("Press [q] or [ctrl+c] to quit")
s.WriteString(footer)
return lipgloss.NewStyle().Padding(1, 2).Render(s.String())
}
// Commands
func connectCmd(d *Device) tea.Cmd {
return func() tea.Msg {
if err := d.Connect(); err != nil {
return connectMsg{err: err}
}
return connectMsg{err: nil}
}
}
func uploadCmd(d *Device) tea.Cmd {
return func() tea.Msg {
// Mock upload for now or implement actual upload logic using Device
// We'll use the existing logic from main.go adapted here
fsys := os.DirFS(".")
pkgData, err := loadPackageJSON(fsys)
if err != nil {
return uploadMsg{err: err}
}
if pkgData == nil {
return uploadMsg{err: fmt.Errorf("package.json not found")}
}
log.Println("Loading package data:", pkgData)
pkg, err := createCpkg(fsys, pkgData)
if err != nil {
return uploadMsg{err: err}
}
pkg.DebugPrint()
// TODO: Actually send the package over serial using d.Write()
// For now we just simulate success as per original code
return uploadMsg{err: nil}
}
}
func debugUnpackCmd(filename string) tea.Cmd {
return func() tea.Msg {
fsys := os.DirFS(".")
packageName := strings.TrimSuffix(filename, ".cpkg")
log.Printf("Attempting to unpack: %s.cpkg", packageName)
if _, err := os.Stat(packageName + ".cpkg"); os.IsNotExist(err) {
return unpackMsg{err: fmt.Errorf("file not found: %s.cpkg", packageName)}
}
unpackPkg := NewCpkg(packageName)
err := unpackPkg.Unpack(fsys)
if err != nil {
return unpackMsg{err: fmt.Errorf("failed to unpack: %w", err)}
}
log.Println("Debug unpack completed successfully")
return unpackMsg{err: nil}
}
}