diff --git a/internal/tui/help.go b/internal/tui/help.go index ea3e080..35cdda0 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -50,6 +50,11 @@ func renderHelp(width, height int) string { {"enter", "copy resolved"}, {"esc", "cancel"}, }}, + {"Split View", [][2]string{ + {"l", "focus detail pane"}, + {"h", "focus list pane"}, + {"esc", "close detail / back"}, + }}, {"Global", [][2]string{ {"?", "toggle help"}, {"q / ctrl+c", "quit"}, diff --git a/internal/tui/input.go b/internal/tui/input.go index 8b46272..786b8eb 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -1,6 +1,9 @@ package tui import ( + "fmt" + "strings" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -16,8 +19,9 @@ type inputResult struct { } type inputModel struct { - ti textinput.Model - active bool + ti textinput.Model + active bool + preview *parse.Result } func newInputModel() inputModel { @@ -37,6 +41,7 @@ func (i *inputModel) reset() { i.active = false i.ti.SetValue("") i.ti.Blur() + i.preview = nil } func (i inputModel) submit() *inputResult { @@ -83,9 +88,103 @@ func (i inputModel) submit() *inputResult { func (i inputModel) updateKey(msg tea.KeyMsg) inputModel { i.ti, _ = i.ti.Update(msg) + val := i.ti.Value() + if val != "" { + parsed, err := parse.Parse(val) + if err == nil { + i.preview = parsed + } else { + i.preview = nil + } + } else { + i.preview = nil + } return i } func (i inputModel) view(width int) string { - return i.ti.View() + var b strings.Builder + b.WriteString(drawerBorderStyle.Render(strings.Repeat("─", width))) + b.WriteString("\n") + b.WriteString(i.ti.View()) + b.WriteString("\n") + b.WriteString(drawerHintsStyle.Render("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 { + if i.preview == nil { + return drawerPreviewStyle.Render("") + } + + p := i.preview + + if p.Query { + q := "?" + if p.Body != "" { + q += p.Body + } + for _, t := range p.FilterTags { + q += " #" + t + } + return drawerPreviewStyle.Render("search: " + q) + } + + glyph := glyphForParsed(p.Glyph) + body := p.Body + if p.Title != nil { + body = *p.Title + } + + var parts []string + parts = append(parts, glyph, body) + for _, t := range p.Tags { + parts = append(parts, tagStyle.Render("#"+t)) + } + if p.Pin { + parts = append(parts, pinnedStyle.Render("•")) + } + if p.CardSuffix != nil { + parts = append(parts, affordanceStyle.Render(*p.CardSuffix)) + } + + line := 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 { + switch glyph { + case "todo": + return "○" + case "event": + return "◇" + case "reminder": + return "△" + default: + return "—" + } +} + +func drawerLines() int { + return 3 +} + +// formatPreviewEntity builds a preview string showing how the entity will appear +func formatPreviewEntity(p *parse.Result) string { + if p == nil { + return "" + } + glyph := glyphForParsed(p.Glyph) + body := p.Body + if p.Title != nil { + body = *p.Title + } + return fmt.Sprintf("%s %s", glyph, body) } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index e755232..1bb1a31 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -3,59 +3,63 @@ package tui import "github.com/charmbracelet/bubbles/key" type keyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Back key.Binding - Add key.Binding - Delete key.Binding - Quit key.Binding - Help key.Binding - PageUp key.Binding - PageDn key.Binding - Top key.Binding - Bottom key.Binding - Todo key.Binding - Pin key.Binding - Filter key.Binding - Promote key.Binding - Demote key.Binding - Copy key.Binding - Edit key.Binding - Stream key.Binding - Cards key.Binding - Sort key.Binding - Intent key.Binding - Absorb key.Binding - Run key.Binding - Fill key.Binding + Up key.Binding + Down key.Binding + Enter key.Binding + Back key.Binding + Add key.Binding + Delete key.Binding + Quit key.Binding + Help key.Binding + PageUp key.Binding + PageDn key.Binding + Top key.Binding + Bottom key.Binding + Todo key.Binding + Pin key.Binding + Filter key.Binding + Promote key.Binding + Demote key.Binding + Copy key.Binding + Edit key.Binding + Stream key.Binding + Cards key.Binding + Sort key.Binding + Intent key.Binding + Absorb key.Binding + Run key.Binding + Fill key.Binding + FocusLeft key.Binding + FocusRight key.Binding } var keys = keyMap{ - Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), - 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")), - 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")), - PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")), - PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")), - Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")), - Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")), - Todo: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle todo")), - Pin: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "toggle pin")), - Filter: key.NewBinding(key.WithKeys("#"), key.WithHelp("#", "filter tag")), - Promote: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "promote")), - Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")), - Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")), - Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), - 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")), - 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")), + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + 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")), + 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")), + PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")), + PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")), + Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")), + Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")), + Todo: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle todo")), + Pin: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "toggle pin")), + Filter: key.NewBinding(key.WithKeys("#"), key.WithHelp("#", "filter tag")), + Promote: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "promote")), + Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")), + Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")), + Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), + 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")), + 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")), } diff --git a/internal/tui/list.go b/internal/tui/list.go index 6ba4a2c..8c95c96 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -93,6 +93,8 @@ func (l listModel) update(msg tea.KeyMsg) listModel { return l } +const dateGutterWidth = 9 + func (l listModel) view(width int) string { ents := l.displayEntities() if len(ents) == 0 { @@ -100,22 +102,24 @@ func (l listModel) view(width int) string { } groups := groupByDate(ents) + entityWidth := width - 4 - dateGutterWidth type displayLine struct { text string entityIdx int - isHeader bool } var lines []displayLine entityIdx := 0 for _, g := range groups { - lines = append(lines, displayLine{ - text: dateHeaderStyle.Render("── " + g.label + " ──"), - isHeader: true, - }) - for _, e := range g.entities { - line := renderEntity(e, width-4) + for i, e := range g.entities { + var gutter string + if i == 0 { + gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ") + } else { + gutter = gutterStyle.Render(" │ ") + } + line := gutter + renderEntity(e, entityWidth) lines = append(lines, displayLine{ text: line, entityIdx: entityIdx, @@ -124,21 +128,17 @@ func (l listModel) view(width int) string { } } - cursorLine := l.cursorDisplayLine(groups) visible := l.visibleCount() - offset := 0 - if cursorLine >= visible { - offset = cursorLine - visible + 1 + if l.cursor >= visible { + offset = l.cursor - visible + 1 } var b strings.Builder end := min(offset+visible, len(lines)) for i := offset; i < end; i++ { dl := lines[i] - if dl.isHeader { - b.WriteString(dl.text) - } else if dl.entityIdx == l.cursor { + if dl.entityIdx == l.cursor { b.WriteString(selectedItemStyle.Render(" " + dl.text)) } else { b.WriteString(listItemStyle.Render(dl.text)) @@ -151,22 +151,6 @@ func (l listModel) view(width int) string { return b.String() } -func (l listModel) cursorDisplayLine(groups []dateGroup) int { - line := 0 - entityIdx := 0 - for _, g := range groups { - line++ - for range g.entities { - if entityIdx == l.cursor { - return line - } - line++ - entityIdx++ - } - } - return 0 -} - func (l listModel) visibleCount() int { if l.height <= 0 { return 20 @@ -203,6 +187,14 @@ func formatDateLabel(t time.Time) string { return strings.ToLower(t.Format("Jan 2")) } +func padRight(s string, n int) string { + r := []rune(s) + if len(r) >= n { + return string(r[:n]) + } + return s + strings.Repeat(" ", n-len(r)) +} + func renderEntity(e *db.Entity, maxWidth int) string { glyphStr := display.DisplayGlyph(e.Glyph, e.CardType) style := glyphStyle diff --git a/internal/tui/model.go b/internal/tui/model.go index bf8c39a..a5e7c5b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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 { diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index 03eff2b..bdbfb09 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -47,7 +47,7 @@ func contextHints(m model) string { return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back" } case stateInput: - return "enter:submit esc:cancel" + return "" case stateTagFilter: return "j/k:nav enter:select esc:cancel" case stateConfirm: @@ -57,6 +57,12 @@ func contextHints(m model) string { case stateAbsorb: return "j/k:nav enter:absorb esc:cancel" default: + if m.splitDetail { + if m.focus == focusDetail { + return "h:list c:copy e:edit p:promote D:demote !:pin esc:back" + } + return "l:detail a:add d:del #:filter esc:close ?:help q:quit" + } if m.mode == modeCards { return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit" } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 9c896b8..ef837a2 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -105,4 +105,21 @@ var ( searchPillStyle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}). Bold(true) + + gutterStyle = lipgloss.NewStyle(). + Foreground(dim) + + drawerBorderStyle = lipgloss.NewStyle(). + Foreground(dim) + + drawerHintsStyle = lipgloss.NewStyle(). + Foreground(dim). + PaddingLeft(2) + + drawerPreviewStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#AAAAAA"}). + PaddingLeft(2) + + separatorStyle = lipgloss.NewStyle(). + Foreground(dim) )