feat(tui): add bubbletea terminal UI with entity list, detail, and capture
Adds `nib tui` command and `make tui` target. Scrollable entity list with j/k navigation, enter for detail view, `a` to capture new entries using the existing parse grammar, and `d` to delete.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
type viewState int
|
||||
|
||||
const (
|
||||
stateList viewState = iota
|
||||
stateDetail
|
||||
stateInput
|
||||
)
|
||||
|
||||
type model struct {
|
||||
store *db.Store
|
||||
state viewState
|
||||
width int
|
||||
height int
|
||||
|
||||
list listModel
|
||||
detail detailModel
|
||||
input inputModel
|
||||
|
||||
status string
|
||||
err error
|
||||
}
|
||||
|
||||
func newModel(store *db.Store) model {
|
||||
return model{
|
||||
store: store,
|
||||
state: stateList,
|
||||
list: newListModel(),
|
||||
detail: newDetailModel(),
|
||||
input: newInputModel(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return loadEntities(m.store, db.DefaultListParams())
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.list.setSize(m.width, m.contentHeight())
|
||||
m.detail.setSize(m.width, m.contentHeight())
|
||||
return m, nil
|
||||
|
||||
case entitiesLoadedMsg:
|
||||
m.list.setEntities(msg.entities)
|
||||
m.status = ""
|
||||
m.err = nil
|
||||
return m, nil
|
||||
|
||||
case entityCreatedMsg:
|
||||
m.state = stateList
|
||||
m.input.reset()
|
||||
m.status = "created"
|
||||
return m, loadEntities(m.store, db.DefaultListParams())
|
||||
|
||||
case entityDeletedMsg:
|
||||
m.status = "deleted"
|
||||
return m, loadEntities(m.store, db.DefaultListParams())
|
||||
|
||||
case errMsg:
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.state == stateInput {
|
||||
return m.updateInput(msg)
|
||||
}
|
||||
return m.updateKeys(msg)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case msg.String() == "q" || msg.String() == "ctrl+c":
|
||||
return m, tea.Quit
|
||||
|
||||
case msg.String() == "a" && m.state == stateList:
|
||||
m.state = stateInput
|
||||
m.input.focus()
|
||||
return m, m.input.ti.Focus()
|
||||
|
||||
case msg.String() == "esc":
|
||||
if m.state == stateDetail {
|
||||
m.state = stateList
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case msg.String() == "enter" && m.state == stateList:
|
||||
if e := m.list.selected(); e != nil {
|
||||
m.detail.setEntity(e)
|
||||
m.state = stateDetail
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case msg.String() == "d" && m.state == stateList:
|
||||
if e := m.list.selected(); e != nil {
|
||||
return m, deleteEntity(m.store, e.ID)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case stateList:
|
||||
m.list = m.list.update(msg)
|
||||
case stateDetail:
|
||||
m.detail = m.detail.update(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.state = stateList
|
||||
m.input.reset()
|
||||
return m, nil
|
||||
case "enter":
|
||||
if e := m.input.submit(); e != nil {
|
||||
return m, createEntity(m.store, e)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.input = m.input.updateKey(msg)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
var content string
|
||||
|
||||
switch m.state {
|
||||
case stateList:
|
||||
content = m.list.view(m.width)
|
||||
case stateDetail:
|
||||
content = m.detail.view(m.width)
|
||||
case stateInput:
|
||||
content = m.list.view(m.width)
|
||||
}
|
||||
|
||||
header := titleStyle.Render("nib")
|
||||
footer := m.footerView()
|
||||
|
||||
return header + "\n" + content + "\n" + footer
|
||||
}
|
||||
|
||||
func (m model) footerView() string {
|
||||
if m.state == stateInput {
|
||||
return m.input.view(m.width)
|
||||
}
|
||||
|
||||
if m.err != nil {
|
||||
return errorStyle.Render("error: " + m.err.Error())
|
||||
}
|
||||
|
||||
if m.status != "" {
|
||||
return statusStyle.Render(m.status)
|
||||
}
|
||||
|
||||
return helpStyle.Render("a:add enter:view d:delete q:quit ?:help")
|
||||
}
|
||||
|
||||
func (m model) contentHeight() int {
|
||||
return m.height - 3
|
||||
}
|
||||
Reference in New Issue
Block a user