feat(tui): add split-pane detail, compact date headers, and input drawer

Three layout improvements for better space utilization:

- Compact date headers: date labels render as left gutter column instead
  of standalone lines, saving one line per date group in stream view
- Input drawer: capture bar expands to 4-line drawer with border, hints,
  and live preview of parsed entity/search query
- Split-pane detail: wide terminals (>=100 cols) show list and detail
  side-by-side with h/l focus switching, falling back to full-screen
  detail on narrow terminals
This commit is contained in:
2026-05-19 19:55:37 -04:00
parent e09919b679
commit f89ca8acb9
7 changed files with 413 additions and 95 deletions
+204 -9
View File
@@ -2,8 +2,10 @@ package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lerko/nib/internal/db"
)
@@ -57,6 +59,13 @@ func (s cardsSort) next() cardsSort {
}
}
type focusPane int
const (
focusList focusPane = iota
focusDetail
)
type model struct {
store *db.Store
state viewState
@@ -73,6 +82,9 @@ type model struct {
absorb absorbModel
showHelp bool
focus focusPane
splitDetail bool
filterTag string
confirmID string
cardsSort cardsSort
@@ -155,10 +167,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.list.setSize(m.width, m.contentHeight())
m.cards.setSize(m.width, m.contentHeight())
m.detail.setSize(m.width, m.contentHeight())
m.filter.setHeight(m.contentHeight())
if !m.isSplit() && m.splitDetail {
m.state = stateDetail
m.splitDetail = false
m.focus = focusList
}
m.recalcSizes()
return m, nil
case entitiesLoadedMsg:
@@ -176,6 +190,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case entityCreatedMsg:
m.state = stateList
m.input.reset()
m.recalcSizes()
m.status = "created"
return m, loadEntities(m.store, m.listParams())
@@ -279,6 +294,91 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
if m.splitDetail && m.state == stateList {
switch msg.String() {
case "l":
if m.focus == focusList {
m.focus = focusDetail
return m, nil
}
case "h":
if m.focus == focusDetail {
m.focus = focusList
return m, nil
}
case "esc":
if m.focus == focusDetail {
m.focus = focusList
return m, nil
}
m.splitDetail = false
m.recalcSizes()
return m, nil
}
if m.focus == focusDetail {
switch msg.String() {
case "j", "k", "up", "down", "pgup", "pgdown", "ctrl+u", "ctrl+d":
var cmd tea.Cmd
m.detail, cmd = m.detail.update(msg)
return m, cmd
case "c":
if m.detail.entity != nil {
return m, copyToClipboard(m.store, m.detail.entity)
}
return m, nil
case "e":
if m.detail.entity != nil && m.detail.mode == detailPreview {
return m, editInEditor(m.store, m.detail.entity)
}
return m, nil
case "p":
if m.detail.entity != nil && m.detail.entity.CardType == nil {
m.promote = newPromoteModel(m.detail.entity.ID, m.detail.entity.Body)
m.state = statePromote
m.splitDetail = false
m.recalcSizes()
return m, nil
}
return m, nil
case "D":
if m.detail.entity != nil && m.detail.entity.CardType != nil {
return m, demoteEntity(m.store, m.detail.entity.ID)
}
return m, nil
case "!":
if m.detail.entity != nil {
return m, pinEntity(m.store, m.detail.entity)
}
return m, nil
case "r":
if m.detail.entity != nil && m.detail.mode == detailPreview {
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist {
m.detail.run = newRunModel(m.detail.entity.ID, m.detail.entity.CardData)
m.detail.mode = detailRun
m.splitDetail = false
m.state = stateDetail
m.recalcSizes()
return m, nil
}
}
return m, nil
case "f":
if m.detail.entity != nil && m.detail.mode == detailPreview {
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardTemplate {
m.detail.fill = newFillModel(m.detail.entity.ID, m.detail.entity.Body)
m.detail.mode = detailFill
m.splitDetail = false
m.state = stateDetail
m.recalcSizes()
return m, m.detail.fill.ti.Focus()
}
}
return m, nil
}
}
}
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
@@ -333,6 +433,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.state == stateList {
m.state = stateInput
m.input.focus()
m.recalcSizes()
return m, m.input.ti.Focus()
}
@@ -344,10 +445,29 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
cmd = persistSteps(m.store, m.detail.run.entityID, m.detail.run.stepsJSON())
}
m.detail.mode = detailPreview
if m.isSplit() {
m.state = stateList
m.splitDetail = true
m.focus = focusList
m.recalcSizes()
}
return m, cmd
}
if m.detail.mode == detailFill {
m.detail.mode = detailPreview
if m.isSplit() {
m.state = stateList
m.splitDetail = true
m.focus = focusList
m.recalcSizes()
}
return m, nil
}
if m.isSplit() {
m.state = stateList
m.splitDetail = true
m.focus = focusList
m.recalcSizes()
return m, nil
}
m.state = stateList
@@ -482,7 +602,13 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.state == stateList {
if e := m.selectedEntity(); e != nil {
m.detail.setEntity(e)
m.state = stateDetail
if m.isSplit() {
m.splitDetail = true
m.focus = focusDetail
m.recalcSizes()
} else {
m.state = stateDetail
}
}
}
return m, nil
@@ -495,6 +621,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} else {
m.list = m.list.update(msg)
}
if m.splitDetail {
if e := m.selectedEntity(); e != nil {
m.detail.setEntity(e)
}
}
case stateDetail:
var cmd tea.Cmd
m.detail, cmd = m.detail.update(msg)
@@ -508,6 +639,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "esc":
m.state = stateList
m.input.reset()
m.recalcSizes()
return m, nil
case "enter":
result := m.input.submit()
@@ -519,6 +651,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.searchTags = result.tags
m.state = stateList
m.input.reset()
m.recalcSizes()
m.applySearch()
return m, nil
}
@@ -601,10 +734,16 @@ func (m model) View() string {
var content string
switch m.state {
case stateList, stateInput, stateConfirm:
if m.mode == modeCards {
content = m.cards.view(m.width)
listContent := m.listContent()
if m.splitDetail {
lw, rw := m.splitWidths()
ch := m.contentHeight()
left := lipgloss.NewStyle().Width(lw).Height(ch).Render(listContent)
sep := m.renderSeparator()
right := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.detail.view(rw))
content = lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)
} else {
content = m.list.view(m.width)
content = listContent
}
case stateDetail:
content = m.detail.view(m.width)
@@ -622,6 +761,21 @@ func (m model) View() string {
return header + "\n" + content + "\n" + footer
}
func (m model) listContent() string {
if m.mode == modeCards {
lw := m.width
if m.splitDetail {
lw, _ = m.splitWidths()
}
return m.cards.view(lw)
}
lw := m.width
if m.splitDetail {
lw, _ = m.splitWidths()
}
return m.list.view(lw)
}
func (m model) headerView() string {
header := titleStyle.Render("nib")
@@ -678,7 +832,48 @@ func (m model) footerView() string {
}
func (m model) contentHeight() int {
return m.height - 3
return m.height - 3 - m.drawerHeight()
}
func (m model) drawerHeight() int {
if m.state == stateInput {
return drawerLines()
}
return 0
}
func (m *model) recalcSizes() {
ch := m.contentHeight()
if m.isSplit() && m.splitDetail {
lw, rw := m.splitWidths()
m.list.setSize(lw, ch)
m.cards.setSize(lw, ch)
m.detail.setSize(rw, ch)
} else {
m.list.setSize(m.width, ch)
m.cards.setSize(m.width, ch)
m.detail.setSize(m.width, ch)
}
m.filter.setHeight(ch)
}
func (m model) isSplit() bool {
return m.width >= 100
}
func (m model) splitWidths() (int, int) {
left := m.width * 40 / 100
right := m.width - left - 1
return left, right
}
func (m model) renderSeparator() string {
ch := m.contentHeight()
lines := make([]string, ch)
for i := range lines {
lines[i] = "│"
}
return separatorStyle.Render(strings.Join(lines, "\n"))
}
func (m model) selectedEntity() *db.Entity {