diff --git a/internal/tui/help.go b/internal/tui/help.go index 35cdda0..1aea60c 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -7,21 +7,31 @@ func renderHelp(width, height int) string { title 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{ {"j/k ↑/↓", "move cursor"}, {"g/G home/end", "top / bottom"}, {"pgup/pgdn", "page up / down"}, {"enter", "view detail"}, - {"esc", "back / clear filter"}, }}, {"Views", [][2]string{ {"1", "stream view"}, {"2", "cards view"}, {"s", "cycle sort (cards)"}, - {"tab", "cycle intent (cards)"}, + {"i", "cycle intent (cards)"}, }}, {"Actions", [][2]string{ - {"a", "add entity (or ?query to search)"}, {"d", "delete (with confirm)"}, {"x", "toggle todo completion"}, {"!", "toggle pin"}, diff --git a/internal/tui/input.go b/internal/tui/input.go index 5e38948..c8e6abd 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -19,7 +19,6 @@ type inputResult struct { type inputModel struct { ti textinput.Model - active bool preview *parse.Result } @@ -31,15 +30,8 @@ func newInputModel() inputModel { return inputModel{ti: ti} } -func (i *inputModel) focus() { - i.active = true - i.ti.Focus() -} - -func (i *inputModel) reset() { - i.active = false +func (i *inputModel) clearText() { i.ti.SetValue("") - i.ti.Blur() i.preview = nil } @@ -101,33 +93,21 @@ func (i inputModel) updateKey(msg tea.KeyMsg) inputModel { return i } -func (i inputModel) view(width int) string { - var b strings.Builder - label := "capture" - prefix := "── " - suffix := " " - dashCount := width - len(prefix) - len(label) - len(suffix) - if dashCount < 0 { - dashCount = 0 +func (i inputModel) viewBar(width int, focused bool) string { + tiView := i.ti.View() + if focused { + return tiView } - b.WriteString(drawerBorderStyle.Render(prefix) + - hintDescStyle.Render(label) + - drawerBorderStyle.Render(suffix+strings.Repeat("─", dashCount))) - b.WriteString("\n") - b.WriteString(i.ti.View()) - 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() + val := i.ti.Value() + if val != "" { + return hintDescStyle.Render("› " + val) + } + return hintDescStyle.Render("› capture a thought…") } -func (i inputModel) renderPreview(width int) string { +func (i inputModel) previewText() string { if i.preview == nil { - return drawerPreviewStyle.Render("") + return "" } p := i.preview @@ -140,7 +120,7 @@ func (i inputModel) renderPreview(width int) string { for _, t := range p.FilterTags { q += " #" + t } - return drawerPreviewStyle.Render("search: " + q) + return "search: " + q } glyph := glyphForParsed(p.Glyph) @@ -152,22 +132,16 @@ func (i inputModel) renderPreview(width int) string { var parts []string parts = append(parts, glyph, body) for _, t := range p.Tags { - parts = append(parts, tagStyle.Render("#"+t)) + parts = append(parts, "#"+t) } if p.Pin { - parts = append(parts, pinnedStyle.Render("•")) + parts = append(parts, "•") } if p.CardSuffix != nil { - parts = append(parts, affordanceStyle.Render(*p.CardSuffix)) + parts = append(parts, *p.CardSuffix) } - line := strings.Join(parts, " ") - maxW := width - 4 - if maxW > 0 && len(stripAnsi(line)) > maxW { - line = truncate(line, maxW) - } - - return drawerPreviewStyle.Render(line) + return strings.Join(parts, " ") } func glyphForParsed(glyph string) string { @@ -182,7 +156,3 @@ func glyphForParsed(glyph string) string { return "—" } } - -func drawerLines() int { - return 3 -} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 1bb1a31..3f215fb 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -7,7 +7,7 @@ type keyMap struct { Down key.Binding Enter key.Binding Back key.Binding - Add key.Binding + Capture key.Binding Delete key.Binding Quit key.Binding Help key.Binding @@ -31,6 +31,7 @@ type keyMap struct { Fill key.Binding FocusLeft key.Binding FocusRight key.Binding + Tab key.Binding } var keys = keyMap{ @@ -38,7 +39,7 @@ var keys = keyMap{ Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")), 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")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), 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")), Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")), 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")), Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")), Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")), FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")), FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")), + Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")), } diff --git a/internal/tui/model.go b/internal/tui/model.go index 18c2bb5..f1c816c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -18,7 +18,6 @@ type viewState int const ( stateList viewState = iota stateDetail - stateInput stateTagFilter stateConfirm statePromote @@ -65,7 +64,8 @@ func (s cardsSort) next() cardsSort { type focusPane int const ( - focusList focusPane = iota + focusCapture focusPane = iota + focusList focusDetail ) @@ -100,18 +100,30 @@ type model struct { } func newModel(store *db.Store) model { + inp := newInputModel() + inp.ti.Focus() return model{ store: store, state: stateList, mode: modeStream, + focus: focusCapture, list: newListModel(), cards: newCardsModel(), detail: newDetailModel(), - input: newInputModel(), + input: inp, 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 { m.statusSeq++ m.status = msg @@ -119,7 +131,7 @@ func (m *model) setStatus(msg string) 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 { @@ -192,9 +204,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case entityCreatedMsg: - m.state = stateList - m.input.reset() - m.recalcSizes() + m.input.clearText() return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("created")) case entityDeletedMsg: @@ -268,8 +278,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: m.err = nil switch m.state { - case stateInput: - return m.updateInput(msg) case stateTagFilter: return m.updateTagFilter(msg) case stateConfirm: @@ -281,6 +289,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { default: 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 @@ -294,8 +309,58 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 { switch msg.String() { + case "tab": + if m.focus == focusList { + m.focus = focusDetail + return m, nil + } + cmd := m.setFocus(focusCapture) + return m, cmd case "l": if m.focus == focusList { m.focus = focusDetail @@ -311,9 +376,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.focus = focusList return m, nil } - m.splitDetail = false - m.recalcSizes() - return m, nil } if m.focus == focusDetail { @@ -380,9 +442,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch msg.String() { - case "ctrl+c": - return m, tea.Quit - case "q": if m.state == stateList { return m, tea.Quit @@ -418,7 +477,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil - case "tab": + case "i": if m.mode == modeCards && m.state == stateList { m.cards.setIntent(m.cards.intent.next()) if m.hasSearch() { @@ -428,12 +487,14 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil + case "tab": + cmd := m.setFocus(focusCapture) + return m, cmd + case "a": if m.state == stateList { - m.state = stateInput - m.input.focus() - m.recalcSizes() - return m, m.input.ti.Focus() + cmd := m.setFocus(focusCapture) + return m, cmd } case "esc": @@ -488,6 +549,10 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.status = "" return m, loadEntities(m.store, m.listParams()) } + if m.state == stateList { + cmd := m.setFocus(focusCapture) + return m, cmd + } return m, nil case "d": @@ -630,37 +695,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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) { switch msg.String() { case "esc", "q": @@ -729,7 +763,7 @@ func (m model) View() string { var content string switch m.state { - case stateList, stateInput, stateConfirm: + case stateList, stateConfirm: listContent := m.listContent() if m.splitDetail { lw, rw := m.splitWidths() @@ -752,11 +786,12 @@ func (m model) View() string { } 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) - return header + "\n" + content + "\n" + footer + return header + "\n" + content + "\n" + captureBar + "\n" + statusLine } func (m model) listContent() string { @@ -806,11 +841,7 @@ func (m model) headerView() string { return header } -func (m model) footerView() string { - if m.state == stateInput { - return m.input.view(m.width) - } - +func (m model) statusLine() string { if m.state == stateConfirm { return renderConfirm(m.confirmID) } @@ -823,14 +854,7 @@ func (m model) footerView() string { } func (m model) contentHeight() int { - return m.height - 3 - m.drawerHeight() -} - -func (m model) drawerHeight() int { - if m.state == stateInput { - return drawerLines() - } - return 0 + return m.height - 4 } func (m *model) recalcSizes() { diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index 07ecf72..7d1ea45 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -30,12 +30,10 @@ func renderTab(label, key string, active bool) string { func renderStatusBar(m model, width int) string { var leftParts []string - if m.state == stateList { - leftParts = append(leftParts, renderTab("capture", "a", false)) - } - if 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 { leftParts = append(leftParts, statusStyle.Render(countText(m))) } @@ -76,8 +74,6 @@ func contextHints(m model) []hint { default: return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}} } - case stateInput: - return nil case stateTagFilter: return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} case stateConfirm: @@ -86,16 +82,23 @@ func contextHints(m model) []hint { return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} case stateAbsorb: 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: if m.splitDetail { - if m.focus == focusDetail { - 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"}} + return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}} } 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"}} } }