feat(tui): add bubbletea terminal UI #30
@@ -50,6 +50,11 @@ func renderHelp(width, height int) string {
|
|||||||
{"enter", "copy resolved"},
|
{"enter", "copy resolved"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
}},
|
}},
|
||||||
|
{"Split View", [][2]string{
|
||||||
|
{"l", "focus detail pane"},
|
||||||
|
{"h", "focus list pane"},
|
||||||
|
{"esc", "close detail / back"},
|
||||||
|
}},
|
||||||
{"Global", [][2]string{
|
{"Global", [][2]string{
|
||||||
{"?", "toggle help"},
|
{"?", "toggle help"},
|
||||||
{"q / ctrl+c", "quit"},
|
{"q / ctrl+c", "quit"},
|
||||||
|
|||||||
+100
-1
@@ -1,6 +1,9 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
@@ -18,6 +21,7 @@ type inputResult struct {
|
|||||||
type inputModel struct {
|
type inputModel struct {
|
||||||
ti textinput.Model
|
ti textinput.Model
|
||||||
active bool
|
active bool
|
||||||
|
preview *parse.Result
|
||||||
}
|
}
|
||||||
|
|
||||||
func newInputModel() inputModel {
|
func newInputModel() inputModel {
|
||||||
@@ -37,6 +41,7 @@ func (i *inputModel) reset() {
|
|||||||
i.active = false
|
i.active = false
|
||||||
i.ti.SetValue("")
|
i.ti.SetValue("")
|
||||||
i.ti.Blur()
|
i.ti.Blur()
|
||||||
|
i.preview = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i inputModel) submit() *inputResult {
|
func (i inputModel) submit() *inputResult {
|
||||||
@@ -83,9 +88,103 @@ func (i inputModel) submit() *inputResult {
|
|||||||
|
|
||||||
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
|
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
|
||||||
i.ti, _ = i.ti.Update(msg)
|
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
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i inputModel) view(width int) string {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type keyMap struct {
|
|||||||
Absorb key.Binding
|
Absorb key.Binding
|
||||||
Run key.Binding
|
Run key.Binding
|
||||||
Fill key.Binding
|
Fill key.Binding
|
||||||
|
FocusLeft key.Binding
|
||||||
|
FocusRight key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var keys = keyMap{
|
var keys = keyMap{
|
||||||
@@ -58,4 +60,6 @@ var keys = keyMap{
|
|||||||
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")),
|
||||||
|
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-30
@@ -93,6 +93,8 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
|
|||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dateGutterWidth = 9
|
||||||
|
|
||||||
func (l listModel) view(width int) string {
|
func (l listModel) view(width int) string {
|
||||||
ents := l.displayEntities()
|
ents := l.displayEntities()
|
||||||
if len(ents) == 0 {
|
if len(ents) == 0 {
|
||||||
@@ -100,22 +102,24 @@ func (l listModel) view(width int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
groups := groupByDate(ents)
|
groups := groupByDate(ents)
|
||||||
|
entityWidth := width - 4 - dateGutterWidth
|
||||||
|
|
||||||
type displayLine struct {
|
type displayLine struct {
|
||||||
text string
|
text string
|
||||||
entityIdx int
|
entityIdx int
|
||||||
isHeader bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var lines []displayLine
|
var lines []displayLine
|
||||||
entityIdx := 0
|
entityIdx := 0
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
lines = append(lines, displayLine{
|
for i, e := range g.entities {
|
||||||
text: dateHeaderStyle.Render("── " + g.label + " ──"),
|
var gutter string
|
||||||
isHeader: true,
|
if i == 0 {
|
||||||
})
|
gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
|
||||||
for _, e := range g.entities {
|
} else {
|
||||||
line := renderEntity(e, width-4)
|
gutter = gutterStyle.Render(" │ ")
|
||||||
|
}
|
||||||
|
line := gutter + renderEntity(e, entityWidth)
|
||||||
lines = append(lines, displayLine{
|
lines = append(lines, displayLine{
|
||||||
text: line,
|
text: line,
|
||||||
entityIdx: entityIdx,
|
entityIdx: entityIdx,
|
||||||
@@ -124,21 +128,17 @@ func (l listModel) view(width int) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cursorLine := l.cursorDisplayLine(groups)
|
|
||||||
visible := l.visibleCount()
|
visible := l.visibleCount()
|
||||||
|
|
||||||
offset := 0
|
offset := 0
|
||||||
if cursorLine >= visible {
|
if l.cursor >= visible {
|
||||||
offset = cursorLine - visible + 1
|
offset = l.cursor - visible + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
end := min(offset+visible, len(lines))
|
end := min(offset+visible, len(lines))
|
||||||
for i := offset; i < end; i++ {
|
for i := offset; i < end; i++ {
|
||||||
dl := lines[i]
|
dl := lines[i]
|
||||||
if dl.isHeader {
|
if dl.entityIdx == l.cursor {
|
||||||
b.WriteString(dl.text)
|
|
||||||
} else if dl.entityIdx == l.cursor {
|
|
||||||
b.WriteString(selectedItemStyle.Render(" " + dl.text))
|
b.WriteString(selectedItemStyle.Render(" " + dl.text))
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(listItemStyle.Render(dl.text))
|
b.WriteString(listItemStyle.Render(dl.text))
|
||||||
@@ -151,22 +151,6 @@ func (l listModel) view(width int) string {
|
|||||||
return b.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 {
|
func (l listModel) visibleCount() int {
|
||||||
if l.height <= 0 {
|
if l.height <= 0 {
|
||||||
return 20
|
return 20
|
||||||
@@ -203,6 +187,14 @@ func formatDateLabel(t time.Time) string {
|
|||||||
return strings.ToLower(t.Format("Jan 2"))
|
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 {
|
func renderEntity(e *db.Entity, maxWidth int) string {
|
||||||
glyphStr := display.DisplayGlyph(e.Glyph, e.CardType)
|
glyphStr := display.DisplayGlyph(e.Glyph, e.CardType)
|
||||||
style := glyphStyle
|
style := glyphStyle
|
||||||
|
|||||||
+203
-8
@@ -2,8 +2,10 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"github.com/lerko/nib/internal/db"
|
"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 {
|
type model struct {
|
||||||
store *db.Store
|
store *db.Store
|
||||||
state viewState
|
state viewState
|
||||||
@@ -73,6 +82,9 @@ type model struct {
|
|||||||
absorb absorbModel
|
absorb absorbModel
|
||||||
showHelp bool
|
showHelp bool
|
||||||
|
|
||||||
|
focus focusPane
|
||||||
|
splitDetail bool
|
||||||
|
|
||||||
filterTag string
|
filterTag string
|
||||||
confirmID string
|
confirmID string
|
||||||
cardsSort cardsSort
|
cardsSort cardsSort
|
||||||
@@ -155,10 +167,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
m.list.setSize(m.width, m.contentHeight())
|
if !m.isSplit() && m.splitDetail {
|
||||||
m.cards.setSize(m.width, m.contentHeight())
|
m.state = stateDetail
|
||||||
m.detail.setSize(m.width, m.contentHeight())
|
m.splitDetail = false
|
||||||
m.filter.setHeight(m.contentHeight())
|
m.focus = focusList
|
||||||
|
}
|
||||||
|
m.recalcSizes()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case entitiesLoadedMsg:
|
case entitiesLoadedMsg:
|
||||||
@@ -176,6 +190,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case entityCreatedMsg:
|
case entityCreatedMsg:
|
||||||
m.state = stateList
|
m.state = stateList
|
||||||
m.input.reset()
|
m.input.reset()
|
||||||
|
m.recalcSizes()
|
||||||
m.status = "created"
|
m.status = "created"
|
||||||
return m, loadEntities(m.store, m.listParams())
|
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
|
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() {
|
switch msg.String() {
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -333,6 +433,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if m.state == stateList {
|
if m.state == stateList {
|
||||||
m.state = stateInput
|
m.state = stateInput
|
||||||
m.input.focus()
|
m.input.focus()
|
||||||
|
m.recalcSizes()
|
||||||
return m, m.input.ti.Focus()
|
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())
|
cmd = persistSteps(m.store, m.detail.run.entityID, m.detail.run.stepsJSON())
|
||||||
}
|
}
|
||||||
m.detail.mode = detailPreview
|
m.detail.mode = detailPreview
|
||||||
|
if m.isSplit() {
|
||||||
|
m.state = stateList
|
||||||
|
m.splitDetail = true
|
||||||
|
m.focus = focusList
|
||||||
|
m.recalcSizes()
|
||||||
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
if m.detail.mode == detailFill {
|
if m.detail.mode == detailFill {
|
||||||
m.detail.mode = detailPreview
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
m.state = stateList
|
m.state = stateList
|
||||||
@@ -482,9 +602,15 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if m.state == stateList {
|
if m.state == stateList {
|
||||||
if e := m.selectedEntity(); e != nil {
|
if e := m.selectedEntity(); e != nil {
|
||||||
m.detail.setEntity(e)
|
m.detail.setEntity(e)
|
||||||
|
if m.isSplit() {
|
||||||
|
m.splitDetail = true
|
||||||
|
m.focus = focusDetail
|
||||||
|
m.recalcSizes()
|
||||||
|
} else {
|
||||||
m.state = stateDetail
|
m.state = stateDetail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +621,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
m.list = m.list.update(msg)
|
m.list = m.list.update(msg)
|
||||||
}
|
}
|
||||||
|
if m.splitDetail {
|
||||||
|
if e := m.selectedEntity(); e != nil {
|
||||||
|
m.detail.setEntity(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
case stateDetail:
|
case stateDetail:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.detail, cmd = m.detail.update(msg)
|
m.detail, cmd = m.detail.update(msg)
|
||||||
@@ -508,6 +639,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "esc":
|
case "esc":
|
||||||
m.state = stateList
|
m.state = stateList
|
||||||
m.input.reset()
|
m.input.reset()
|
||||||
|
m.recalcSizes()
|
||||||
return m, nil
|
return m, nil
|
||||||
case "enter":
|
case "enter":
|
||||||
result := m.input.submit()
|
result := m.input.submit()
|
||||||
@@ -519,6 +651,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.searchTags = result.tags
|
m.searchTags = result.tags
|
||||||
m.state = stateList
|
m.state = stateList
|
||||||
m.input.reset()
|
m.input.reset()
|
||||||
|
m.recalcSizes()
|
||||||
m.applySearch()
|
m.applySearch()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -601,10 +734,16 @@ func (m model) View() string {
|
|||||||
var content string
|
var content string
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case stateList, stateInput, stateConfirm:
|
case stateList, stateInput, stateConfirm:
|
||||||
if m.mode == modeCards {
|
listContent := m.listContent()
|
||||||
content = m.cards.view(m.width)
|
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 {
|
} else {
|
||||||
content = m.list.view(m.width)
|
content = listContent
|
||||||
}
|
}
|
||||||
case stateDetail:
|
case stateDetail:
|
||||||
content = m.detail.view(m.width)
|
content = m.detail.view(m.width)
|
||||||
@@ -622,6 +761,21 @@ func (m model) View() string {
|
|||||||
return header + "\n" + content + "\n" + footer
|
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 {
|
func (m model) headerView() string {
|
||||||
header := titleStyle.Render("nib")
|
header := titleStyle.Render("nib")
|
||||||
|
|
||||||
@@ -678,7 +832,48 @@ func (m model) footerView() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m model) contentHeight() int {
|
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 {
|
func (m model) selectedEntity() *db.Entity {
|
||||||
|
|||||||
@@ -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"
|
return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back"
|
||||||
}
|
}
|
||||||
case stateInput:
|
case stateInput:
|
||||||
return "enter:submit esc:cancel"
|
return ""
|
||||||
case stateTagFilter:
|
case stateTagFilter:
|
||||||
return "j/k:nav enter:select esc:cancel"
|
return "j/k:nav enter:select esc:cancel"
|
||||||
case stateConfirm:
|
case stateConfirm:
|
||||||
@@ -57,6 +57,12 @@ func contextHints(m model) string {
|
|||||||
case stateAbsorb:
|
case stateAbsorb:
|
||||||
return "j/k:nav enter:absorb esc:cancel"
|
return "j/k:nav enter:absorb esc:cancel"
|
||||||
default:
|
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 {
|
if m.mode == modeCards {
|
||||||
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit"
|
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,4 +105,21 @@ var (
|
|||||||
searchPillStyle = lipgloss.NewStyle().
|
searchPillStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}).
|
Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}).
|
||||||
Bold(true)
|
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)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user