feat(tui): add bubbletea terminal UI with entity list, detail, and capture
Adds `nib tui` command and `make tui` target. Scrollable entity list with j/k navigation, enter for detail view, `a` to capture new entries using the existing parse grammar, and `d` to delete.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
type entitiesLoadedMsg struct {
|
||||
entities []*db.Entity
|
||||
}
|
||||
|
||||
type entityCreatedMsg struct {
|
||||
entity *db.Entity
|
||||
}
|
||||
|
||||
type entityDeletedMsg struct {
|
||||
id string
|
||||
}
|
||||
|
||||
type errMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
entities, err := store.List(params)
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return entitiesLoadedMsg{entities}
|
||||
}
|
||||
}
|
||||
|
||||
func createEntity(store *db.Store, e *db.Entity) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := store.Create(e); err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return entityCreatedMsg{e}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteEntity(store *db.Store, id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if _, err := store.SoftDelete(id); err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return entityDeletedMsg{id}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/display"
|
||||
)
|
||||
|
||||
type detailModel struct {
|
||||
entity *db.Entity
|
||||
scroll int
|
||||
height int
|
||||
width int
|
||||
}
|
||||
|
||||
func newDetailModel() detailModel {
|
||||
return detailModel{}
|
||||
}
|
||||
|
||||
func (d *detailModel) setEntity(e *db.Entity) {
|
||||
d.entity = e
|
||||
d.scroll = 0
|
||||
}
|
||||
|
||||
func (d *detailModel) setSize(width, height int) {
|
||||
d.width = width
|
||||
d.height = height
|
||||
}
|
||||
|
||||
func (d detailModel) update(msg tea.KeyMsg) detailModel {
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
if d.scroll > 0 {
|
||||
d.scroll--
|
||||
}
|
||||
case "down", "j":
|
||||
d.scroll++
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (d detailModel) view(width int) string {
|
||||
if d.entity == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
e := d.entity
|
||||
var b strings.Builder
|
||||
|
||||
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
|
||||
header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID))
|
||||
b.WriteString(detailHeaderStyle.Render(header))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if e.Title != nil {
|
||||
b.WriteString(detailBodyStyle.Render("title: " + *e.Title))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString(detailBodyStyle.Render(e.Body))
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(e.Tags) > 0 {
|
||||
tagParts := make([]string, len(e.Tags))
|
||||
for i, t := range e.Tags {
|
||||
tagParts[i] = tagStyle.Render("#" + t)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(detailBodyStyle.Render(strings.Join(tagParts, " ")))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
|
||||
if e.ModifiedAt != e.CreatedAt {
|
||||
meta += fmt.Sprintf("\nmodified %s", e.ModifiedAt.Format(time.DateTime))
|
||||
}
|
||||
if e.TimeAnchor != nil {
|
||||
meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor)
|
||||
}
|
||||
if e.Pinned {
|
||||
meta += "\npinned"
|
||||
}
|
||||
if e.CardType != nil {
|
||||
meta += fmt.Sprintf("\ncard %s", *e.CardType)
|
||||
}
|
||||
if e.CompletedAt != nil {
|
||||
meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime))
|
||||
}
|
||||
b.WriteString(idStyle.Render(meta))
|
||||
|
||||
lines := strings.Split(b.String(), "\n")
|
||||
if d.scroll > 0 && d.scroll < len(lines) {
|
||||
lines = lines[d.scroll:]
|
||||
}
|
||||
if d.height > 0 && len(lines) > d.height {
|
||||
lines = lines[:d.height]
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/parse"
|
||||
)
|
||||
|
||||
type inputModel struct {
|
||||
ti textinput.Model
|
||||
active bool
|
||||
}
|
||||
|
||||
func newInputModel() inputModel {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "capture a thought…"
|
||||
ti.Prompt = inputPromptStyle.Render("› ")
|
||||
ti.CharLimit = 500
|
||||
return inputModel{ti: ti}
|
||||
}
|
||||
|
||||
func (i *inputModel) focus() {
|
||||
i.active = true
|
||||
i.ti.Focus()
|
||||
}
|
||||
|
||||
func (i *inputModel) reset() {
|
||||
i.active = false
|
||||
i.ti.SetValue("")
|
||||
i.ti.Blur()
|
||||
}
|
||||
|
||||
func (i inputModel) submit() *db.Entity {
|
||||
val := i.ti.Value()
|
||||
if val == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := parse.Parse(val)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
e := &db.Entity{
|
||||
Body: parsed.Body,
|
||||
Title: parsed.Title,
|
||||
Glyph: db.Glyph(parsed.Glyph),
|
||||
Tags: parsed.Tags,
|
||||
}
|
||||
if parsed.TimeAnchor != nil {
|
||||
e.TimeAnchor = parsed.TimeAnchor
|
||||
}
|
||||
if parsed.CardSuffix != nil {
|
||||
ct := db.CardType(*parsed.CardSuffix)
|
||||
e.CardType = &ct
|
||||
}
|
||||
if parsed.Pin {
|
||||
e.Pinned = true
|
||||
}
|
||||
if parsed.Description != nil {
|
||||
e.Description = parsed.Description
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
|
||||
i.ti, _ = i.ti.Update(msg)
|
||||
return i
|
||||
}
|
||||
|
||||
func (i inputModel) view(width int) string {
|
||||
return i.ti.View()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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
|
||||
}
|
||||
|
||||
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")),
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/display"
|
||||
)
|
||||
|
||||
type listModel struct {
|
||||
entities []*db.Entity
|
||||
cursor int
|
||||
offset int
|
||||
height int
|
||||
width int
|
||||
}
|
||||
|
||||
func newListModel() listModel {
|
||||
return listModel{}
|
||||
}
|
||||
|
||||
func (l *listModel) setEntities(entities []*db.Entity) {
|
||||
l.entities = entities
|
||||
if l.cursor >= len(entities) {
|
||||
l.cursor = max(0, len(entities)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *listModel) setSize(width, height int) {
|
||||
l.width = width
|
||||
l.height = height
|
||||
}
|
||||
|
||||
func (l listModel) selected() *db.Entity {
|
||||
if len(l.entities) == 0 || l.cursor >= len(l.entities) {
|
||||
return nil
|
||||
}
|
||||
return l.entities[l.cursor]
|
||||
}
|
||||
|
||||
func (l listModel) update(msg tea.KeyMsg) listModel {
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
if l.cursor > 0 {
|
||||
l.cursor--
|
||||
if l.cursor < l.offset {
|
||||
l.offset = l.cursor
|
||||
}
|
||||
}
|
||||
case "down", "j":
|
||||
if l.cursor < len(l.entities)-1 {
|
||||
l.cursor++
|
||||
visible := l.visibleCount()
|
||||
if l.cursor >= l.offset+visible {
|
||||
l.offset = l.cursor - visible + 1
|
||||
}
|
||||
}
|
||||
case "home", "g":
|
||||
l.cursor = 0
|
||||
l.offset = 0
|
||||
case "end", "G":
|
||||
l.cursor = max(0, len(l.entities)-1)
|
||||
visible := l.visibleCount()
|
||||
if l.cursor >= visible {
|
||||
l.offset = l.cursor - visible + 1
|
||||
}
|
||||
case "pgup", "ctrl+u":
|
||||
l.cursor = max(0, l.cursor-l.visibleCount())
|
||||
if l.cursor < l.offset {
|
||||
l.offset = l.cursor
|
||||
}
|
||||
case "pgdown", "ctrl+d":
|
||||
l.cursor = min(len(l.entities)-1, l.cursor+l.visibleCount())
|
||||
visible := l.visibleCount()
|
||||
if l.cursor >= l.offset+visible {
|
||||
l.offset = l.cursor - visible + 1
|
||||
}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l listModel) view(width int) string {
|
||||
if len(l.entities) == 0 {
|
||||
return statusStyle.Render("no entities")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
visible := l.visibleCount()
|
||||
end := min(l.offset+visible, len(l.entities))
|
||||
|
||||
for i := l.offset; i < end; i++ {
|
||||
e := l.entities[i]
|
||||
line := renderEntity(e, width-4)
|
||||
|
||||
if i == l.cursor {
|
||||
b.WriteString(selectedItemStyle.Render(" " + line))
|
||||
} else {
|
||||
b.WriteString(listItemStyle.Render(line))
|
||||
}
|
||||
if i < end-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (l listModel) visibleCount() int {
|
||||
if l.height <= 0 {
|
||||
return 20
|
||||
}
|
||||
return l.height
|
||||
}
|
||||
|
||||
func renderEntity(e *db.Entity, maxWidth int) string {
|
||||
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
|
||||
|
||||
body := e.Body
|
||||
if e.Title != nil {
|
||||
body = *e.Title
|
||||
}
|
||||
|
||||
var tags string
|
||||
if len(e.Tags) > 0 {
|
||||
tagParts := make([]string, len(e.Tags))
|
||||
for i, t := range e.Tags {
|
||||
tagParts[i] = tagStyle.Render("#" + t)
|
||||
}
|
||||
tags = " " + strings.Join(tagParts, " ")
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
|
||||
|
||||
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||
body = truncate(body, maxWidth-20)
|
||||
line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if maxLen <= 3 {
|
||||
return "…"
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return string(runes[:maxLen-1]) + "…"
|
||||
}
|
||||
|
||||
func stripAnsi(s string) string {
|
||||
var b strings.Builder
|
||||
inEsc := false
|
||||
for _, r := range s {
|
||||
if r == '\x1b' {
|
||||
inEsc = true
|
||||
continue
|
||||
}
|
||||
if inEsc {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||
inEsc = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
type viewState int
|
||||
|
||||
const (
|
||||
stateList viewState = iota
|
||||
stateDetail
|
||||
stateInput
|
||||
)
|
||||
|
||||
type model struct {
|
||||
store *db.Store
|
||||
state viewState
|
||||
width int
|
||||
height int
|
||||
|
||||
list listModel
|
||||
detail detailModel
|
||||
input inputModel
|
||||
|
||||
status string
|
||||
err error
|
||||
}
|
||||
|
||||
func newModel(store *db.Store) model {
|
||||
return model{
|
||||
store: store,
|
||||
state: stateList,
|
||||
list: newListModel(),
|
||||
detail: newDetailModel(),
|
||||
input: newInputModel(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return loadEntities(m.store, db.DefaultListParams())
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.list.setSize(m.width, m.contentHeight())
|
||||
m.detail.setSize(m.width, m.contentHeight())
|
||||
return m, nil
|
||||
|
||||
case entitiesLoadedMsg:
|
||||
m.list.setEntities(msg.entities)
|
||||
m.status = ""
|
||||
m.err = nil
|
||||
return m, nil
|
||||
|
||||
case entityCreatedMsg:
|
||||
m.state = stateList
|
||||
m.input.reset()
|
||||
m.status = "created"
|
||||
return m, loadEntities(m.store, db.DefaultListParams())
|
||||
|
||||
case entityDeletedMsg:
|
||||
m.status = "deleted"
|
||||
return m, loadEntities(m.store, db.DefaultListParams())
|
||||
|
||||
case errMsg:
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.state == stateInput {
|
||||
return m.updateInput(msg)
|
||||
}
|
||||
return m.updateKeys(msg)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case msg.String() == "q" || msg.String() == "ctrl+c":
|
||||
return m, tea.Quit
|
||||
|
||||
case msg.String() == "a" && m.state == stateList:
|
||||
m.state = stateInput
|
||||
m.input.focus()
|
||||
return m, m.input.ti.Focus()
|
||||
|
||||
case msg.String() == "esc":
|
||||
if m.state == stateDetail {
|
||||
m.state = stateList
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case msg.String() == "enter" && m.state == stateList:
|
||||
if e := m.list.selected(); e != nil {
|
||||
m.detail.setEntity(e)
|
||||
m.state = stateDetail
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case msg.String() == "d" && m.state == stateList:
|
||||
if e := m.list.selected(); e != nil {
|
||||
return m, deleteEntity(m.store, e.ID)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case stateList:
|
||||
m.list = m.list.update(msg)
|
||||
case stateDetail:
|
||||
m.detail = m.detail.update(msg)
|
||||
}
|
||||
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()
|
||||
return m, nil
|
||||
case "enter":
|
||||
if e := m.input.submit(); e != nil {
|
||||
return m, createEntity(m.store, e)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.input = m.input.updateKey(msg)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
var content string
|
||||
|
||||
switch m.state {
|
||||
case stateList:
|
||||
content = m.list.view(m.width)
|
||||
case stateDetail:
|
||||
content = m.detail.view(m.width)
|
||||
case stateInput:
|
||||
content = m.list.view(m.width)
|
||||
}
|
||||
|
||||
header := titleStyle.Render("nib")
|
||||
footer := m.footerView()
|
||||
|
||||
return header + "\n" + content + "\n" + footer
|
||||
}
|
||||
|
||||
func (m model) footerView() string {
|
||||
if m.state == stateInput {
|
||||
return m.input.view(m.width)
|
||||
}
|
||||
|
||||
if m.err != nil {
|
||||
return errorStyle.Render("error: " + m.err.Error())
|
||||
}
|
||||
|
||||
if m.status != "" {
|
||||
return statusStyle.Render(m.status)
|
||||
}
|
||||
|
||||
return helpStyle.Render("a:add enter:view d:delete q:quit ?:help")
|
||||
}
|
||||
|
||||
func (m model) contentHeight() int {
|
||||
return m.height - 3
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
|
||||
highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
|
||||
dim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
|
||||
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(highlight).
|
||||
PaddingLeft(1)
|
||||
|
||||
statusStyle = lipgloss.NewStyle().
|
||||
Foreground(dim).
|
||||
PaddingLeft(1)
|
||||
|
||||
listItemStyle = lipgloss.NewStyle().
|
||||
PaddingLeft(2)
|
||||
|
||||
selectedItemStyle = lipgloss.NewStyle().
|
||||
PaddingLeft(1).
|
||||
Bold(true).
|
||||
Foreground(highlight).
|
||||
SetString("›")
|
||||
|
||||
glyphStyle = lipgloss.NewStyle().
|
||||
Width(2)
|
||||
|
||||
tagStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
|
||||
|
||||
idStyle = lipgloss.NewStyle().
|
||||
Foreground(dim)
|
||||
|
||||
inputPromptStyle = lipgloss.NewStyle().
|
||||
Foreground(highlight).
|
||||
Bold(true)
|
||||
|
||||
detailHeaderStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(highlight).
|
||||
MarginBottom(1)
|
||||
|
||||
detailBodyStyle = lipgloss.NewStyle().
|
||||
PaddingLeft(2).
|
||||
PaddingTop(1)
|
||||
|
||||
helpStyle = lipgloss.NewStyle().
|
||||
Foreground(dim).
|
||||
PaddingLeft(1)
|
||||
|
||||
errorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF0000")).
|
||||
PaddingLeft(1)
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
func Run(store *db.Store) error {
|
||||
m := newModel(store)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user