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
+102 -3
View File
@@ -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)
}