feat(tui): always-visible capture bar with focus cycling #34

Merged
lerko merged 1 commits from feat/capture-first into main 2026-05-20 18:25:55 +00:00
5 changed files with 141 additions and 132 deletions
Showing only changes of commit a2dac64d1f - Show all commits
+13 -3
View File
@@ -7,21 +7,31 @@ func renderHelp(width, height int) string {
title string title string
binds [][2]string binds [][2]string
}{ }{
{"Focus", [][2]string{
{"tab", "cycle focus: capture → list → detail"},
{"esc", "back / clear filter / to capture"},
{"a", "focus capture bar"},
}},
{"Capture Bar", [][2]string{
{"enter", "submit (or browse if empty)"},
{"?…", "search (type ?query)"},
{"-", "todo prefix"},
{"@", "event prefix"},
{"!", "reminder prefix"},
}},
{"Navigation", [][2]string{ {"Navigation", [][2]string{
{"j/k ↑/↓", "move cursor"}, {"j/k ↑/↓", "move cursor"},
{"g/G home/end", "top / bottom"}, {"g/G home/end", "top / bottom"},
{"pgup/pgdn", "page up / down"}, {"pgup/pgdn", "page up / down"},
{"enter", "view detail"}, {"enter", "view detail"},
{"esc", "back / clear filter"},
}}, }},
{"Views", [][2]string{ {"Views", [][2]string{
{"1", "stream view"}, {"1", "stream view"},
{"2", "cards view"}, {"2", "cards view"},
{"s", "cycle sort (cards)"}, {"s", "cycle sort (cards)"},
{"tab", "cycle intent (cards)"}, {"i", "cycle intent (cards)"},
}}, }},
{"Actions", [][2]string{ {"Actions", [][2]string{
{"a", "add entity (or ?query to search)"},
{"d", "delete (with confirm)"}, {"d", "delete (with confirm)"},
{"x", "toggle todo completion"}, {"x", "toggle todo completion"},
{"!", "toggle pin"}, {"!", "toggle pin"},
+17 -47
View File
@@ -19,7 +19,6 @@ type inputResult struct {
type inputModel struct { type inputModel struct {
ti textinput.Model ti textinput.Model
active bool
preview *parse.Result preview *parse.Result
} }
@@ -31,15 +30,8 @@ func newInputModel() inputModel {
return inputModel{ti: ti} return inputModel{ti: ti}
} }
func (i *inputModel) focus() { func (i *inputModel) clearText() {
i.active = true
i.ti.Focus()
}
func (i *inputModel) reset() {
i.active = false
i.ti.SetValue("") i.ti.SetValue("")
i.ti.Blur()
i.preview = nil i.preview = nil
} }
@@ -101,33 +93,21 @@ func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
return i return i
} }
func (i inputModel) view(width int) string { func (i inputModel) viewBar(width int, focused bool) string {
var b strings.Builder tiView := i.ti.View()
label := "capture" if focused {
prefix := "── " return tiView
suffix := " "
dashCount := width - len(prefix) - len(label) - len(suffix)
if dashCount < 0 {
dashCount = 0
} }
b.WriteString(drawerBorderStyle.Render(prefix) + val := i.ti.Value()
hintDescStyle.Render(label) + if val != "" {
drawerBorderStyle.Render(suffix+strings.Repeat("─", dashCount))) return hintDescStyle.Render(" " + val)
b.WriteString("\n") }
b.WriteString(i.ti.View()) return hintDescStyle.Render(" capture a thought…")
b.WriteString("\n")
b.WriteString(drawerHintsStyle.Render(renderHints([]hint{
{"enter", "submit"}, {"esc", "cancel"}, {"?", "search"},
{"-", "todo"}, {"@", "event"}, {"!", "reminder"},
})))
b.WriteString("\n")
b.WriteString(i.renderPreview(width))
return b.String()
} }
func (i inputModel) renderPreview(width int) string { func (i inputModel) previewText() string {
if i.preview == nil { if i.preview == nil {
return drawerPreviewStyle.Render("") return ""
} }
p := i.preview p := i.preview
@@ -140,7 +120,7 @@ func (i inputModel) renderPreview(width int) string {
for _, t := range p.FilterTags { for _, t := range p.FilterTags {
q += " #" + t q += " #" + t
} }
return drawerPreviewStyle.Render("search: " + q) return "search: " + q
} }
glyph := glyphForParsed(p.Glyph) glyph := glyphForParsed(p.Glyph)
@@ -152,22 +132,16 @@ func (i inputModel) renderPreview(width int) string {
var parts []string var parts []string
parts = append(parts, glyph, body) parts = append(parts, glyph, body)
for _, t := range p.Tags { for _, t := range p.Tags {
parts = append(parts, tagStyle.Render("#"+t)) parts = append(parts, "#"+t)
} }
if p.Pin { if p.Pin {
parts = append(parts, pinnedStyle.Render("•")) parts = append(parts, "•")
} }
if p.CardSuffix != nil { if p.CardSuffix != nil {
parts = append(parts, affordanceStyle.Render(*p.CardSuffix)) parts = append(parts, *p.CardSuffix)
} }
line := strings.Join(parts, " ") return strings.Join(parts, " ")
maxW := width - 4
if maxW > 0 && len(stripAnsi(line)) > maxW {
line = truncate(line, maxW)
}
return drawerPreviewStyle.Render(line)
} }
func glyphForParsed(glyph string) string { func glyphForParsed(glyph string) string {
@@ -182,7 +156,3 @@ func glyphForParsed(glyph string) string {
return "—" return "—"
} }
} }
func drawerLines() int {
return 3
}
+5 -3
View File
@@ -7,7 +7,7 @@ type keyMap struct {
Down key.Binding Down key.Binding
Enter key.Binding Enter key.Binding
Back key.Binding Back key.Binding
Add key.Binding Capture key.Binding
Delete key.Binding Delete key.Binding
Quit key.Binding Quit key.Binding
Help key.Binding Help key.Binding
@@ -31,6 +31,7 @@ type keyMap struct {
Fill key.Binding Fill key.Binding
FocusLeft key.Binding FocusLeft key.Binding
FocusRight key.Binding FocusRight key.Binding
Tab key.Binding
} }
var keys = keyMap{ var keys = keyMap{
@@ -38,7 +39,7 @@ var keys = keyMap{
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")), Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")),
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), Capture: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "capture")),
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
@@ -56,10 +57,11 @@ var keys = keyMap{
Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")), Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")),
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")), Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")), Intent: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "intent")),
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")), Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")), Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")), Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")), FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")), FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")),
} }
+91 -67
View File
@@ -18,7 +18,6 @@ type viewState int
const ( const (
stateList viewState = iota stateList viewState = iota
stateDetail stateDetail
stateInput
stateTagFilter stateTagFilter
stateConfirm stateConfirm
statePromote statePromote
@@ -65,7 +64,8 @@ func (s cardsSort) next() cardsSort {
type focusPane int type focusPane int
const ( const (
focusList focusPane = iota focusCapture focusPane = iota
focusList
focusDetail focusDetail
) )
@@ -100,18 +100,30 @@ type model struct {
} }
func newModel(store *db.Store) model { func newModel(store *db.Store) model {
inp := newInputModel()
inp.ti.Focus()
return model{ return model{
store: store, store: store,
state: stateList, state: stateList,
mode: modeStream, mode: modeStream,
focus: focusCapture,
list: newListModel(), list: newListModel(),
cards: newCardsModel(), cards: newCardsModel(),
detail: newDetailModel(), detail: newDetailModel(),
input: newInputModel(), input: inp,
filter: newFilterModel(), filter: newFilterModel(),
} }
} }
func (m *model) setFocus(f focusPane) tea.Cmd {
m.focus = f
if f == focusCapture {
return m.input.ti.Focus()
}
m.input.ti.Blur()
return nil
}
func (m *model) setStatus(msg string) tea.Cmd { func (m *model) setStatus(msg string) tea.Cmd {
m.statusSeq++ m.statusSeq++
m.status = msg m.status = msg
@@ -119,7 +131,7 @@ func (m *model) setStatus(msg string) tea.Cmd {
} }
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {
return loadEntities(m.store, m.listParams()) return tea.Batch(loadEntities(m.store, m.listParams()), m.input.ti.Focus())
} }
func (m model) listParams() db.ListParams { func (m model) listParams() db.ListParams {
@@ -192,9 +204,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case entityCreatedMsg: case entityCreatedMsg:
m.state = stateList m.input.clearText()
m.input.reset()
m.recalcSizes()
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("created")) return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("created"))
case entityDeletedMsg: case entityDeletedMsg:
@@ -268,8 +278,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
m.err = nil m.err = nil
switch m.state { switch m.state {
case stateInput:
return m.updateInput(msg)
case stateTagFilter: case stateTagFilter:
return m.updateTagFilter(msg) return m.updateTagFilter(msg)
case stateConfirm: case stateConfirm:
@@ -281,6 +289,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
default: default:
return m.updateKeys(msg) return m.updateKeys(msg)
} }
default:
if m.focus == focusCapture {
var cmd tea.Cmd
m.input.ti, cmd = m.input.ti.Update(msg)
return m, cmd
}
} }
return m, nil return m, nil
@@ -294,8 +309,58 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
if m.focus == focusCapture {
return m.updateCapture(msg)
}
return m.updateBrowse(msg)
}
func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
val := m.input.ti.Value()
if val == "" {
cmd := m.setFocus(focusList)
return m, cmd
}
result := m.input.submit()
if result == nil {
return m, nil
}
if result.query {
m.searchQuery = result.body
m.searchTags = result.tags
m.input.clearText()
m.applySearch()
cmd := m.setFocus(focusList)
return m, cmd
}
if result.entity != nil {
return m, createEntity(m.store, result.entity)
}
return m, nil
case "esc", "tab":
cmd := m.setFocus(focusList)
return m, cmd
}
m.input = m.input.updateKey(msg)
return m, nil
}
func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.splitDetail && m.state == stateList { if m.splitDetail && m.state == stateList {
switch msg.String() { switch msg.String() {
case "tab":
if m.focus == focusList {
m.focus = focusDetail
return m, nil
}
cmd := m.setFocus(focusCapture)
return m, cmd
case "l": case "l":
if m.focus == focusList { if m.focus == focusList {
m.focus = focusDetail m.focus = focusDetail
@@ -311,9 +376,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.focus = focusList m.focus = focusList
return m, nil return m, nil
} }
m.splitDetail = false
m.recalcSizes()
return m, nil
} }
if m.focus == focusDetail { if m.focus == focusDetail {
@@ -380,9 +442,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
switch msg.String() { switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "q": case "q":
if m.state == stateList { if m.state == stateList {
return m, tea.Quit return m, tea.Quit
@@ -418,7 +477,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case "tab": case "i":
if m.mode == modeCards && m.state == stateList { if m.mode == modeCards && m.state == stateList {
m.cards.setIntent(m.cards.intent.next()) m.cards.setIntent(m.cards.intent.next())
if m.hasSearch() { if m.hasSearch() {
@@ -428,12 +487,14 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case "tab":
cmd := m.setFocus(focusCapture)
return m, cmd
case "a": case "a":
if m.state == stateList { if m.state == stateList {
m.state = stateInput cmd := m.setFocus(focusCapture)
m.input.focus() return m, cmd
m.recalcSizes()
return m, m.input.ti.Focus()
} }
case "esc": case "esc":
@@ -488,6 +549,10 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.status = "" m.status = ""
return m, loadEntities(m.store, m.listParams()) return m, loadEntities(m.store, m.listParams())
} }
if m.state == stateList {
cmd := m.setFocus(focusCapture)
return m, cmd
}
return m, nil return m, nil
case "d": case "d":
@@ -630,37 +695,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil 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()
m.recalcSizes()
return m, nil
case "enter":
result := m.input.submit()
if result == nil {
return m, nil
}
if result.query {
m.searchQuery = result.body
m.searchTags = result.tags
m.state = stateList
m.input.reset()
m.recalcSizes()
m.applySearch()
return m, nil
}
if result.entity != nil {
return m, createEntity(m.store, result.entity)
}
return m, nil
}
m.input = m.input.updateKey(msg)
return m, nil
}
func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "esc", "q": case "esc", "q":
@@ -729,7 +763,7 @@ func (m model) View() string {
var content string var content string
switch m.state { switch m.state {
case stateList, stateInput, stateConfirm: case stateList, stateConfirm:
listContent := m.listContent() listContent := m.listContent()
if m.splitDetail { if m.splitDetail {
lw, rw := m.splitWidths() lw, rw := m.splitWidths()
@@ -752,11 +786,12 @@ func (m model) View() string {
} }
header := m.headerView() header := m.headerView()
footer := m.footerView() captureBar := m.input.viewBar(m.width, m.focus == focusCapture)
statusLine := m.statusLine()
content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content) content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content)
return header + "\n" + content + "\n" + footer return header + "\n" + content + "\n" + captureBar + "\n" + statusLine
} }
func (m model) listContent() string { func (m model) listContent() string {
@@ -806,11 +841,7 @@ func (m model) headerView() string {
return header return header
} }
func (m model) footerView() string { func (m model) statusLine() string {
if m.state == stateInput {
return m.input.view(m.width)
}
if m.state == stateConfirm { if m.state == stateConfirm {
return renderConfirm(m.confirmID) return renderConfirm(m.confirmID)
} }
@@ -823,14 +854,7 @@ func (m model) footerView() string {
} }
func (m model) contentHeight() int { func (m model) contentHeight() int {
return m.height - 3 - m.drawerHeight() return m.height - 4
}
func (m model) drawerHeight() int {
if m.state == stateInput {
return drawerLines()
}
return 0
} }
func (m *model) recalcSizes() { func (m *model) recalcSizes() {
+15 -12
View File
@@ -30,12 +30,10 @@ func renderTab(label, key string, active bool) string {
func renderStatusBar(m model, width int) string { func renderStatusBar(m model, width int) string {
var leftParts []string var leftParts []string
if m.state == stateList {
leftParts = append(leftParts, renderTab("capture", "a", false))
}
if m.status != "" { if m.status != "" {
leftParts = append(leftParts, statusStyle.Render(m.status)) leftParts = append(leftParts, statusStyle.Render(m.status))
} else if preview := m.input.previewText(); m.focus == focusCapture && preview != "" {
leftParts = append(leftParts, drawerPreviewStyle.Render(preview))
} else { } else {
leftParts = append(leftParts, statusStyle.Render(countText(m))) leftParts = append(leftParts, statusStyle.Render(countText(m)))
} }
@@ -76,8 +74,6 @@ func contextHints(m model) []hint {
default: default:
return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}} return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}}
} }
case stateInput:
return nil
case stateTagFilter: case stateTagFilter:
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
case stateConfirm: case stateConfirm:
@@ -86,16 +82,23 @@ func contextHints(m model) []hint {
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
case stateAbsorb: case stateAbsorb:
return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}} return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}}
}
switch m.focus {
case focusCapture:
return []hint{{"enter", "submit"}, {"esc", "browse"}, {"?…", "search"}, {"-", "todo"}, {"@", "event"}}
case focusDetail:
if m.splitDetail {
return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}}
}
return []hint{{"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"esc", "back"}}
default: default:
if m.splitDetail { if m.splitDetail {
if m.focus == focusDetail { return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"D", "demote"}, {"!", "pin"}, {"esc", "back"}}
}
return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"esc", "close"}, {"?", "help"}, {"q", "quit"}}
} }
if m.mode == modeCards { if m.mode == modeCards {
return []hint{{"s", "sort"}, {"tab", "intent"}, {"?", "help"}, {"q", "quit"}} return []hint{{"s", "sort"}, {"i", "intent"}, {"tab", "capture"}, {"?", "help"}}
} }
return []hint{{"?", "search"}, {"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"?", "help"}, {"q", "quit"}} return []hint{{"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
} }
} }