chore: refactor TUIs to use bubbletea standards d7e56ea6
Steve Simkins · 2026-05-17 16:50 24 file(s) · +1348 −1024
apps/jotts-go/go.mod +1 −0
51 51
	github.com/ncruces/go-strftime v0.1.9 // indirect
52 52
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
53 53
	github.com/rivo/uniseg v0.4.7 // indirect
54 +
	github.com/sahilm/fuzzy v0.1.1 // indirect
54 55
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
55 56
	github.com/yuin/goldmark-emoji v1.0.6 // indirect
56 57
	golang.org/x/crypto v0.39.0 // indirect
apps/jotts-go/go.sum +4 −0
54 54
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
55 55
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
56 56
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
57 +
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
58 +
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
57 59
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
58 60
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
59 61
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
72 74
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
73 75
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
74 76
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
77 +
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
78 +
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
75 79
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
76 80
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
77 81
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
apps/jotts-go/tui/commands.go (added) +62 −0
1 +
package tui
2 +
3 +
import (
4 +
	"strings"
5 +
6 +
	tea "charm.land/bubbletea/v2"
7 +
	"github.com/atotto/clipboard"
8 +
)
9 +
10 +
func loadNotesCmd(b Backend) tea.Cmd {
11 +
	return func() tea.Msg {
12 +
		notes, err := b.List()
13 +
		return notesLoadedMsg{notes: notes, err: err}
14 +
	}
15 +
}
16 +
17 +
func saveNoteCmd(b Backend, shortID, title, content string) tea.Cmd {
18 +
	return func() tea.Msg {
19 +
		var (
20 +
			note *Note
21 +
			err  error
22 +
		)
23 +
		if shortID == "" {
24 +
			note, err = b.Create(title, content)
25 +
		} else {
26 +
			note, err = b.Update(shortID, title, content)
27 +
		}
28 +
		return noteSavedMsg{note: note, err: err}
29 +
	}
30 +
}
31 +
32 +
func deleteNoteCmd(b Backend, shortID string) tea.Cmd {
33 +
	return func() tea.Msg {
34 +
		_, err := b.Delete(shortID)
35 +
		return noteDeletedMsg{shortID: shortID, err: err}
36 +
	}
37 +
}
38 +
39 +
func copyToClipboardCmd(text, okStatus string) tea.Cmd {
40 +
	return func() tea.Msg {
41 +
		if err := clipboard.WriteAll(text); err != nil {
42 +
			return statusMsg{text: "clipboard: " + err.Error(), ok: false}
43 +
		}
44 +
		return statusMsg{text: okStatus, ok: true}
45 +
	}
46 +
}
47 +
48 +
func openURLCmd(url string) tea.Cmd {
49 +
	return func() tea.Msg {
50 +
		if err := openURL(url); err != nil {
51 +
			return statusMsg{text: "open: " + err.Error(), ok: false}
52 +
		}
53 +
		return statusMsg{text: "opened " + url, ok: true}
54 +
	}
55 +
}
56 +
57 +
func noteLinkURL(remoteBase, shortID string) string {
58 +
	if remoteBase == "" {
59 +
		return ""
60 +
	}
61 +
	return strings.TrimRight(remoteBase, "/") + "/notes/" + shortID
62 +
}
apps/jotts-go/tui/content_model.go (added) +89 −0
1 +
package tui
2 +
3 +
import (
4 +
	"charm.land/bubbles/v2/viewport"
5 +
	tea "charm.land/bubbletea/v2"
6 +
)
7 +
8 +
type contentModel struct {
9 +
	vp       viewport.Model
10 +
	renderer *mdRenderer
11 +
	wrap     bool
12 +
13 +
	shortID string
14 +
	title   string
15 +
	body    string
16 +
}
17 +
18 +
func newContentModel() contentModel {
19 +
	return contentModel{vp: viewport.New(), wrap: true}
20 +
}
21 +
22 +
func (c contentModel) Update(msg tea.Msg) (contentModel, tea.Cmd) {
23 +
	var cmd tea.Cmd
24 +
	c.vp, cmd = c.vp.Update(msg)
25 +
	return c, cmd
26 +
}
27 +
28 +
func (c *contentModel) SetSize(w, h int) {
29 +
	c.vp.SetWidth(w)
30 +
	c.vp.SetHeight(h)
31 +
	if c.renderer == nil {
32 +
		c.renderer = newRenderer(w)
33 +
	} else {
34 +
		c.renderer.resize(w)
35 +
	}
36 +
	c.refresh()
37 +
}
38 +
39 +
func (c *contentModel) SetNote(n *Note) {
40 +
	if n == nil {
41 +
		c.shortID, c.title, c.body = "", "", ""
42 +
		c.vp.SetContent("")
43 +
		c.vp.GotoTop()
44 +
		return
45 +
	}
46 +
	c.shortID = n.ShortID
47 +
	c.title = n.Title
48 +
	c.body = n.Content
49 +
	c.vp.GotoTop()
50 +
	c.refresh()
51 +
}
52 +
53 +
func (c *contentModel) ToggleWrap() {
54 +
	c.wrap = !c.wrap
55 +
	c.refresh()
56 +
}
57 +
58 +
func (c *contentModel) Wrap() bool { return c.wrap }
59 +
60 +
func (c *contentModel) Invalidate(shortID string) {
61 +
	if c.renderer != nil {
62 +
		c.renderer.invalidate(shortID)
63 +
	}
64 +
}
65 +
66 +
func (c *contentModel) refresh() {
67 +
	if c.body == "" {
68 +
		c.vp.SetContent("")
69 +
		return
70 +
	}
71 +
	if !c.wrap || c.renderer == nil {
72 +
		c.vp.SetContent(c.body)
73 +
		return
74 +
	}
75 +
	c.vp.SetContent(c.renderer.render(c.shortID, c.body))
76 +
}
77 +
78 +
func (c contentModel) Title() string { return c.title }
79 +
func (c contentModel) View() string  { return c.vp.View() }
80 +
81 +
func (c contentModel) ScrollUp(n int) contentModel {
82 +
	c.vp.ScrollUp(n)
83 +
	return c
84 +
}
85 +
86 +
func (c contentModel) ScrollDown(n int) contentModel {
87 +
	c.vp.ScrollDown(n)
88 +
	return c
89 +
}
apps/jotts-go/tui/form_model.go (added) +136 −0
1 +
package tui
2 +
3 +
import (
4 +
	"strings"
5 +
6 +
	"charm.land/bubbles/v2/key"
7 +
	"charm.land/bubbles/v2/textarea"
8 +
	"charm.land/bubbles/v2/textinput"
9 +
	tea "charm.land/bubbletea/v2"
10 +
)
11 +
12 +
type formField uint8
13 +
14 +
const (
15 +
	formFieldTitle formField = iota
16 +
	formFieldContent
17 +
)
18 +
19 +
type formModel struct {
20 +
	title   textinput.Model
21 +
	content textarea.Model
22 +
23 +
	field    formField
24 +
	shortID  string
25 +
	isCreate bool
26 +
27 +
	keys formKeys
28 +
}
29 +
30 +
type formKeys struct {
31 +
	Save        key.Binding
32 +
	Cancel      key.Binding
33 +
	SwitchField key.Binding
34 +
}
35 +
36 +
func defaultFormKeys() formKeys {
37 +
	return formKeys{
38 +
		Save:        key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")),
39 +
		Cancel:      key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
40 +
		SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")),
41 +
	}
42 +
}
43 +
44 +
func newFormModel() formModel {
45 +
	ti := textinput.New()
46 +
	ti.Placeholder = "Title"
47 +
	ti.Prompt = ""
48 +
	ti.CharLimit = 200
49 +
50 +
	ta := textarea.New()
51 +
	ta.Placeholder = "Write markdown..."
52 +
	ta.ShowLineNumbers = false
53 +
	ta.Prompt = ""
54 +
55 +
	return formModel{title: ti, content: ta, keys: defaultFormKeys()}
56 +
}
57 +
58 +
func (f *formModel) StartCreate() {
59 +
	f.shortID = ""
60 +
	f.isCreate = true
61 +
	f.title.SetValue("")
62 +
	f.content.SetValue("")
63 +
	f.field = formFieldTitle
64 +
	f.applyFocus()
65 +
}
66 +
67 +
func (f *formModel) StartEdit(n Note) {
68 +
	f.shortID = n.ShortID
69 +
	f.isCreate = false
70 +
	f.title.SetValue(n.Title)
71 +
	f.content.SetValue(n.Content)
72 +
	f.field = formFieldTitle
73 +
	f.applyFocus()
74 +
}
75 +
76 +
func (f *formModel) SetContent(s string) { f.content.SetValue(s) }
77 +
78 +
func (f *formModel) Blur() {
79 +
	f.title.Blur()
80 +
	f.content.Blur()
81 +
}
82 +
83 +
func (f *formModel) applyFocus() {
84 +
	switch f.field {
85 +
	case formFieldTitle:
86 +
		f.title.Focus()
87 +
		f.content.Blur()
88 +
	case formFieldContent:
89 +
		f.content.Focus()
90 +
		f.title.Blur()
91 +
	}
92 +
}
93 +
94 +
func (f *formModel) SetSize(w, h int) {
95 +
	f.title.SetWidth(max(w-4, 1))
96 +
	f.content.SetWidth(max(w-2, 1))
97 +
	f.content.SetHeight(max(h-6, 1))
98 +
}
99 +
100 +
func (f formModel) Update(msg tea.Msg) (formModel, tea.Cmd) {
101 +
	if km, ok := msg.(tea.KeyPressMsg); ok {
102 +
		switch {
103 +
		case key.Matches(km, f.keys.Cancel):
104 +
			f.Blur()
105 +
			return f, func() tea.Msg { return cancelFormMsg{} }
106 +
		case key.Matches(km, f.keys.Save):
107 +
			title := strings.TrimSpace(f.title.Value())
108 +
			if title == "" {
109 +
				return f, func() tea.Msg { return statusMsg{text: "title required", ok: false} }
110 +
			}
111 +
			return f, func() tea.Msg {
112 +
				return submitFormMsg{shortID: f.shortID, title: title, content: f.content.Value()}
113 +
			}
114 +
		case key.Matches(km, f.keys.SwitchField):
115 +
			if f.field == formFieldTitle {
116 +
				f.field = formFieldContent
117 +
			} else {
118 +
				f.field = formFieldTitle
119 +
			}
120 +
			f.applyFocus()
121 +
			return f, nil
122 +
		}
123 +
	}
124 +
125 +
	var cmd tea.Cmd
126 +
	switch f.field {
127 +
	case formFieldTitle:
128 +
		f.title, cmd = f.title.Update(msg)
129 +
	case formFieldContent:
130 +
		f.content, cmd = f.content.Update(msg)
131 +
	}
132 +
	return f, cmd
133 +
}
134 +
135 +
func (f formModel) ActiveField() formField { return f.field }
136 +
func (f formModel) IsCreate() bool         { return f.isCreate }
apps/jotts-go/tui/keys.go +11 −20
3 3
import "charm.land/bubbles/v2/key"
4 4
5 5
type keyMap struct {
6 -
	Up          key.Binding
7 -
	Down        key.Binding
8 6
	Open        key.Binding
9 7
	Back        key.Binding
10 8
	Quit        key.Binding
15 13
	Copy        key.Binding
16 14
	CopyLink    key.Binding
17 15
	OpenBrowser key.Binding
18 -
	Search      key.Binding
19 16
	Refresh     key.Binding
20 17
	Help        key.Binding
21 -
	Save        key.Binding
22 18
	ToggleWrap  key.Binding
23 -
	SwitchField key.Binding
24 -
	Cancel      key.Binding
19 +
	ScrollUp    key.Binding
20 +
	ScrollDown  key.Binding
25 21
}
26 22
27 23
func defaultKeys() keyMap {
28 24
	return keyMap{
29 -
		Up:          key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
30 -
		Down:        key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
31 25
		Open:        key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("⏎/l", "open")),
32 -
		Back:        key.NewBinding(key.WithKeys("h", "esc", " "), key.WithHelp("h/␣", "back")),
33 -
		Quit:        key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
26 +
		Back:        key.NewBinding(key.WithKeys("h", "esc"), key.WithHelp("h/esc", "back")),
27 +
		Quit:        key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
34 28
		Create:      key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")),
35 29
		Edit:        key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
36 30
		ExtEdit:     key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "$EDITOR")),
38 32
		Copy:        key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy text")),
39 33
		CopyLink:    key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy link")),
40 34
		OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")),
41 -
		Search:      key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
42 35
		Refresh:     key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
43 36
		Help:        key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
44 -
		Save:        key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")),
45 37
		ToggleWrap:  key.NewBinding(key.WithKeys("ctrl+w"), key.WithHelp("⌃w", "wrap")),
46 -
		SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")),
47 -
		Cancel:      key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
38 +
		ScrollUp:    key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
39 +
		ScrollDown:  key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
48 40
	}
49 41
}
50 42
51 43
func (k keyMap) ShortHelp() []key.Binding {
52 -
	return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Search, k.Help, k.Quit}
44 +
	return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Help, k.Quit}
53 45
}
54 46
55 47
func (k keyMap) FullHelp() [][]key.Binding {
56 48
	return [][]key.Binding{
57 -
		{k.Up, k.Down, k.Open, k.Back},
58 -
		{k.Create, k.Edit, k.ExtEdit, k.Delete},
59 -
		{k.Copy, k.CopyLink, k.OpenBrowser, k.Search},
60 -
		{k.Refresh, k.Help, k.Save, k.ToggleWrap},
61 -
		{k.SwitchField, k.Cancel, k.Quit},
49 +
		{k.Open, k.Back, k.Create, k.Edit},
50 +
		{k.ExtEdit, k.Delete, k.Copy, k.CopyLink},
51 +
		{k.OpenBrowser, k.Refresh, k.ToggleWrap, k.Help},
52 +
		{k.ScrollUp, k.ScrollDown, k.Quit},
62 53
	}
63 54
}
apps/jotts-go/tui/list_model.go (added) +96 −0
1 +
package tui
2 +
3 +
import (
4 +
	"charm.land/bubbles/v2/list"
5 +
	tea "charm.land/bubbletea/v2"
6 +
	"charm.land/lipgloss/v2"
7 +
)
8 +
9 +
func ansiListDelegate() list.DefaultDelegate {
10 +
	d := list.NewDefaultDelegate()
11 +
	d.ShowDescription = false
12 +
	d.SetSpacing(0)
13 +
	d.Styles.NormalTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Padding(0, 0, 0, 2)
14 +
	d.Styles.SelectedTitle = lipgloss.NewStyle().
15 +
		Foreground(lipgloss.Color("3")).
16 +
		Bold(true).
17 +
		Border(lipgloss.NormalBorder(), false, false, false, true).
18 +
		BorderForeground(lipgloss.Color("3")).
19 +
		Padding(0, 0, 0, 1)
20 +
	d.Styles.DimmedTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2)
21 +
	d.Styles.FilterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3"))
22 +
	return d
23 +
}
24 +
25 +
func ansiListStyles() list.Styles {
26 +
	s := list.DefaultStyles(true)
27 +
	s.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")).Padding(0, 1)
28 +
	s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 0)
29 +
	s.NoItems = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2)
30 +
	s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3"))
31 +
	return s
32 +
}
33 +
34 +
type noteItem struct {
35 +
	note Note
36 +
}
37 +
38 +
func (n noteItem) Title() string       { return n.note.Title }
39 +
func (n noteItem) Description() string { return "" }
40 +
func (n noteItem) FilterValue() string { return n.note.Title }
41 +
42 +
type listModel struct {
43 +
	inner list.Model
44 +
}
45 +
46 +
func newListModel(notes []Note) listModel {
47 +
	items := make([]list.Item, 0, len(notes))
48 +
	for _, n := range notes {
49 +
		items = append(items, noteItem{note: n})
50 +
	}
51 +
52 +
	l := list.New(items, ansiListDelegate(), 0, 0)
53 +
	l.Title = "notes"
54 +
	l.Styles = ansiListStyles()
55 +
	l.SetShowStatusBar(false)
56 +
	l.SetShowPagination(false)
57 +
	l.SetShowHelp(false)
58 +
	l.SetFilteringEnabled(true)
59 +
	l.DisableQuitKeybindings()
60 +
61 +
	return listModel{inner: l}
62 +
}
63 +
64 +
func (l listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
65 +
	var cmd tea.Cmd
66 +
	l.inner, cmd = l.inner.Update(msg)
67 +
	return l, cmd
68 +
}
69 +
70 +
func (l listModel) View() string { return l.inner.View() }
71 +
72 +
func (l *listModel) SetSize(w, h int) { l.inner.SetSize(w, h) }
73 +
74 +
func (l *listModel) SetNotes(notes []Note) tea.Cmd {
75 +
	items := make([]list.Item, 0, len(notes))
76 +
	for _, n := range notes {
77 +
		items = append(items, noteItem{note: n})
78 +
	}
79 +
	return l.inner.SetItems(items)
80 +
}
81 +
82 +
func (l listModel) Selected() (Note, bool) {
83 +
	it := l.inner.SelectedItem()
84 +
	if it == nil {
85 +
		return Note{}, false
86 +
	}
87 +
	n, ok := it.(noteItem)
88 +
	if !ok {
89 +
		return Note{}, false
90 +
	}
91 +
	return n.note, true
92 +
}
93 +
94 +
func (l listModel) IsFiltering() bool {
95 +
	return l.inner.SettingFilter()
96 +
}
apps/jotts-go/tui/messages.go +8 −0
27 27
}
28 28
29 29
type clearStatusMsg struct{}
30 +
31 +
type submitFormMsg struct {
32 +
	shortID string
33 +
	title   string
34 +
	content string
35 +
}
36 +
37 +
type cancelFormMsg struct{}
apps/jotts-go/tui/model.go +25 −109
1 1
package tui
2 2
3 3
import (
4 -
	"strings"
5 4
	"time"
6 5
7 6
	"charm.land/bubbles/v2/help"
8 -
	"charm.land/bubbles/v2/textarea"
9 -
	"charm.land/bubbles/v2/textinput"
10 -
	"charm.land/bubbles/v2/viewport"
11 7
	tea "charm.land/bubbletea/v2"
12 8
)
13 9
14 -
type Focus int
10 +
type sessionState uint8
15 11
16 12
const (
17 -
	FocusList Focus = iota
18 -
	FocusContent
19 -
	FocusCreateTitle
20 -
	FocusCreateContent
21 -
	FocusEditTitle
22 -
	FocusEditContent
23 -
	FocusSearch
13 +
	stateList sessionState = iota
14 +
	stateContent
15 +
	stateForm
24 16
)
25 17
26 18
type Model struct {
27 19
	backend  Backend
28 20
	isRemote bool
29 21
30 -
	notes    []Note
31 -
	filtered []int
32 -
	cursor   int
22 +
	state sessionState
23 +
	list  listModel
24 +
	cont  contentModel
25 +
	form  formModel
26 +
27 +
	width, height int
28 +
	ready         bool
33 29
34 -
	focus         Focus
35 30
	showHelp      bool
36 31
	confirmDelete bool
37 32
38 -
	titleInput  textinput.Model
39 -
	contentArea textarea.Model
40 -
	searchInput textinput.Model
41 -
	contentVP   viewport.Model
42 -
	help        help.Model
43 -
	keys        keyMap
44 -
45 -
	renderer *mdRenderer
46 -
	wrap     bool
47 -
48 -
	editShortID string
49 -
50 33
	status      string
51 34
	statusOK    bool
52 35
	statusUntil time.Time
53 36
54 -
	width, height int
55 -
	ready         bool
56 -
	err           error
37 +
	help help.Model
38 +
	keys keyMap
57 39
}
58 40
59 41
func newModel(backend Backend, notes []Note, width, height int) Model {
60 -
	ti := textinput.New()
61 -
	ti.Placeholder = "Title"
62 -
	ti.Prompt = ""
63 -
	ti.CharLimit = 200
64 -
65 -
	ta := textarea.New()
66 -
	ta.Placeholder = "Write markdown..."
67 -
	ta.ShowLineNumbers = false
68 -
	ta.Prompt = ""
69 -
70 -
	si := textinput.New()
71 -
	si.Placeholder = "search titles"
72 -
	si.Prompt = "/ "
73 -
74 -
	vp := viewport.New()
75 -
76 42
	m := Model{
77 -
		backend:     backend,
78 -
		isRemote:    backend.RemoteURL() != "",
79 -
		notes:       notes,
80 -
		focus:       FocusList,
81 -
		titleInput:  ti,
82 -
		contentArea: ta,
83 -
		searchInput: si,
84 -
		contentVP:   vp,
85 -
		help:        help.New(),
86 -
		keys:        defaultKeys(),
87 -
		wrap:        true,
88 -
		width:       width,
89 -
		height:      height,
90 -
		ready:       true,
43 +
		backend:  backend,
44 +
		isRemote: backend.RemoteURL() != "",
45 +
		state:    stateList,
46 +
		list:     newListModel(notes),
47 +
		cont:     newContentModel(),
48 +
		form:     newFormModel(),
49 +
		help:     help.New(),
50 +
		keys:     defaultKeys(),
51 +
		width:    width,
52 +
		height:   height,
53 +
		ready:    true,
91 54
	}
92 -
	m.resizePanes()
55 +
	m.applyLayout()
93 56
	return m
94 57
}
95 58
96 59
func (m Model) Init() tea.Cmd {
97 60
	return tea.RequestWindowSize
98 61
}
99 -
100 -
func (m *Model) visibleNotes() []Note {
101 -
	if m.filtered == nil {
102 -
		return m.notes
103 -
	}
104 -
	out := make([]Note, 0, len(m.filtered))
105 -
	for _, i := range m.filtered {
106 -
		out = append(out, m.notes[i])
107 -
	}
108 -
	return out
109 -
}
110 -
111 -
func (m *Model) currentNote() *Note {
112 -
	notes := m.visibleNotes()
113 -
	if m.cursor < 0 || m.cursor >= len(notes) {
114 -
		return nil
115 -
	}
116 -
	return &notes[m.cursor]
117 -
}
118 -
119 -
func (m *Model) applyFilter(q string) {
120 -
	q = strings.TrimSpace(strings.ToLower(q))
121 -
	if q == "" {
122 -
		m.filtered = nil
123 -
		if m.cursor >= len(m.notes) {
124 -
			m.cursor = 0
125 -
		}
126 -
		return
127 -
	}
128 -
	idx := []int{}
129 -
	for i, n := range m.notes {
130 -
		if strings.Contains(strings.ToLower(n.Title), q) {
131 -
			idx = append(idx, i)
132 -
		}
133 -
	}
134 -
	m.filtered = idx
135 -
	if m.cursor >= len(idx) {
136 -
		m.cursor = 0
137 -
	}
138 -
}
139 -
140 -
func (m *Model) setStatus(text string, ok bool) tea.Cmd {
141 -
	m.status = text
142 -
	m.statusOK = ok
143 -
	m.statusUntil = time.Now().Add(2 * time.Second)
144 -
	return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} })
145 -
}
apps/jotts-go/tui/render_md.go +26 −1
7 7
	"charm.land/glamour/v2/ansi"
8 8
)
9 9
10 +
const mdCacheMax = 64
11 +
10 12
type mdRenderer struct {
11 13
	r     *glamour.TermRenderer
12 14
	width int
13 15
	cache map[string]string
16 +
	order []string
14 17
}
15 18
16 19
func sp(s string) *string { return &s }
92 95
	return &mdRenderer{r: r, width: width, cache: map[string]string{}}
93 96
}
94 97
98 +
func (m *mdRenderer) store(key, value string) {
99 +
	if _, ok := m.cache[key]; !ok {
100 +
		m.order = append(m.order, key)
101 +
		if len(m.order) > mdCacheMax {
102 +
			drop := m.order[0]
103 +
			m.order = m.order[1:]
104 +
			delete(m.cache, drop)
105 +
		}
106 +
	}
107 +
	m.cache[key] = value
108 +
}
109 +
95 110
func (m *mdRenderer) resize(width int) {
96 111
	if width == m.width || width < 20 {
97 112
		return
104 119
	m.r = r
105 120
	m.width = width
106 121
	m.cache = map[string]string{}
122 +
	m.order = nil
107 123
}
108 124
109 125
func (m *mdRenderer) render(key, body string) string {
117 133
	if err != nil {
118 134
		out = fmt.Sprintf("render error: %v\n\n%s", err, body)
119 135
	}
120 -
	m.cache[key] = out
136 +
	m.store(key, out)
121 137
	return out
122 138
}
123 139
124 140
func (m *mdRenderer) invalidate(key string) {
141 +
	if _, ok := m.cache[key]; !ok {
142 +
		return
143 +
	}
125 144
	delete(m.cache, key)
145 +
	for i, k := range m.order {
146 +
		if k == key {
147 +
			m.order = append(m.order[:i], m.order[i+1:]...)
148 +
			break
149 +
		}
150 +
	}
126 151
}
apps/jotts-go/tui/update.go +148 −261
2 2
3 3
import (
4 4
	"strings"
5 +
	"time"
5 6
6 -
	"github.com/atotto/clipboard"
7 7
	"charm.land/bubbles/v2/key"
8 8
	tea "charm.land/bubbletea/v2"
9 9
)
10 10
11 -
func loadNotesCmd(b Backend) tea.Cmd {
12 -
	return func() tea.Msg {
13 -
		notes, err := b.List()
14 -
		return notesLoadedMsg{notes: notes, err: err}
15 -
	}
16 -
}
17 -
18 -
func saveNoteCmd(b Backend, shortID, title, content string) tea.Cmd {
19 -
	return func() tea.Msg {
20 -
		var (
21 -
			note *Note
22 -
			err  error
23 -
		)
24 -
		if shortID == "" {
25 -
			note, err = b.Create(title, content)
26 -
		} else {
27 -
			note, err = b.Update(shortID, title, content)
28 -
		}
29 -
		return noteSavedMsg{note: note, err: err}
30 -
	}
31 -
}
32 -
33 -
func deleteNoteCmd(b Backend, shortID string) tea.Cmd {
34 -
	return func() tea.Msg {
35 -
		_, err := b.Delete(shortID)
36 -
		return noteDeletedMsg{shortID: shortID, err: err}
37 -
	}
38 -
}
39 -
40 11
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
41 12
	switch msg := msg.(type) {
42 13
43 14
	case tea.WindowSizeMsg:
44 15
		m.width, m.height = msg.Width, msg.Height
45 16
		m.ready = true
46 -
		m.resizePanes()
17 +
		m.applyLayout()
47 18
		return m, nil
48 19
49 20
	case notesLoadedMsg:
50 21
		if msg.err != nil {
51 -
			cmd := m.setStatus("load: "+msg.err.Error(), false)
52 -
			return m, cmd
22 +
			return m, m.setStatus("load: "+msg.err.Error(), false)
53 23
		}
54 -
		m.notes = msg.notes
55 -
		m.applyFilter(m.searchInput.Value())
56 -
		m.refreshPreview()
57 -
		return m, nil
24 +
		cmd := m.list.SetNotes(msg.notes)
25 +
		if n, ok := m.list.Selected(); ok {
26 +
			m.cont.SetNote(&n)
27 +
		} else {
28 +
			m.cont.SetNote(nil)
29 +
		}
30 +
		return m, cmd
58 31
59 32
	case noteSavedMsg:
60 33
		if msg.err != nil {
61 34
			return m, m.setStatus("save: "+msg.err.Error(), false)
62 35
		}
63 -
		if m.renderer != nil && msg.note != nil {
64 -
			m.renderer.invalidate(msg.note.ShortID)
36 +
		if msg.note != nil {
37 +
			m.cont.Invalidate(msg.note.ShortID)
65 38
		}
66 -
		m.focus = FocusList
67 -
		m.titleInput.Reset()
68 -
		m.contentArea.Reset()
69 -
		m.editShortID = ""
70 -
		return m, loadNotesCmd(m.backend)
39 +
		m.state = stateList
40 +
		m.form.Blur()
41 +
		return m, tea.Batch(loadNotesCmd(m.backend), m.setStatus("saved", true))
71 42
72 43
	case noteDeletedMsg:
73 44
		if msg.err != nil {
74 45
			return m, m.setStatus("delete: "+msg.err.Error(), false)
75 46
		}
76 -
		if m.renderer != nil {
77 -
			m.renderer.invalidate(msg.shortID)
78 -
		}
47 +
		m.cont.Invalidate(msg.shortID)
48 +
		m.state = stateList
79 49
		return m, tea.Batch(loadNotesCmd(m.backend), m.setStatus("deleted", true))
80 50
81 51
	case editorFinishedMsg:
83 53
			return m, m.setStatus("editor: "+msg.err.Error(), false)
84 54
		}
85 55
		if msg.shortID == "" {
86 -
			m.contentArea.SetValue(msg.content)
56 +
			m.form.SetContent(msg.content)
87 57
			return m, nil
88 58
		}
89 59
		var orig *Note
90 -
		for i := range m.notes {
91 -
			if m.notes[i].ShortID == msg.shortID {
92 -
				orig = &m.notes[i]
60 +
		for _, it := range m.list.inner.Items() {
61 +
			ni, ok := it.(noteItem)
62 +
			if ok && ni.note.ShortID == msg.shortID {
63 +
				n := ni.note
64 +
				orig = &n
93 65
				break
94 66
			}
95 67
		}
98 70
		}
99 71
		return m, saveNoteCmd(m.backend, msg.shortID, orig.Title, msg.content)
100 72
73 +
	case submitFormMsg:
74 +
		return m, saveNoteCmd(m.backend, msg.shortID, msg.title, msg.content)
75 +
76 +
	case cancelFormMsg:
77 +
		m.state = stateList
78 +
		return m, nil
79 +
101 80
	case statusMsg:
102 81
		return m, m.setStatus(msg.text, msg.ok)
103 82
104 83
	case clearStatusMsg:
84 +
		if time.Now().Before(m.statusUntil) {
85 +
			return m, nil
86 +
		}
105 87
		m.status = ""
106 88
		return m, nil
107 89
112 94
	return m, nil
113 95
}
114 96
115 -
func (m *Model) resizePanes() {
116 -
	if !m.ready {
117 -
		return
118 -
	}
119 -
120 -
	_, contentOuterW := splitWidths(m.width)
121 -
	bodyOuterH := splitBodyHeight(m.height)
122 -
	contentInnerW := maxInt(contentOuterW-paneFrameWidth(), 20)
123 -
	contentInnerH := maxInt(bodyOuterH-paneFrameHeight(), 3)
124 -
125 -
	m.contentVP.SetWidth(maxInt(contentInnerW, 1))
126 -
	m.contentVP.SetHeight(maxInt(contentInnerH-1, 1))
127 -
128 -
	m.titleInput.SetWidth(maxInt(contentInnerW-4, 1))
129 -
	m.contentArea.SetWidth(maxInt(contentInnerW-2, 1))
130 -
	m.contentArea.SetHeight(maxInt(contentInnerH-6, 1))
131 -
132 -
	listOuterW, _ := splitWidths(m.width)
133 -
	listInnerW := maxInt(listOuterW-paneFrameWidth(), 1)
134 -
	m.searchInput.SetWidth(maxInt(listInnerW-2, 1))
135 -
136 -
	if m.renderer == nil {
137 -
		m.renderer = newRenderer(contentInnerW)
138 -
	} else {
139 -
		m.renderer.resize(contentInnerW)
140 -
	}
141 -
	m.refreshPreview()
142 -
}
143 -
144 -
func (m *Model) refreshPreview() {
145 -
	if m.renderer == nil {
146 -
		return
147 -
	}
148 -
	n := m.currentNote()
149 -
	if n == nil {
150 -
		m.contentVP.SetContent("")
151 -
		return
152 -
	}
153 -
	body := n.Content
154 -
	if !m.wrap {
155 -
		// raw view: no rendering
156 -
		m.contentVP.SetContent(body)
157 -
		return
158 -
	}
159 -
	m.contentVP.SetContent(m.renderer.render(n.ShortID, body))
160 -
}
161 -
162 97
func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
163 98
	if m.confirmDelete {
164 99
		switch msg.String() {
165 100
		case "y", "Y":
166 -
			n := m.currentNote()
167 101
			m.confirmDelete = false
168 -
			if n == nil {
102 +
			n, ok := m.list.Selected()
103 +
			if !ok {
169 104
				return m, nil
170 105
			}
171 106
			return m, deleteNoteCmd(m.backend, n.ShortID)
172 107
		case "n", "N", "esc", "q":
173 108
			m.confirmDelete = false
174 -
			return m, nil
175 109
		}
176 110
		return m, nil
177 111
	}
183 117
		return m, nil
184 118
	}
185 119
186 -
	switch m.focus {
187 -
	case FocusList:
188 -
		return m.keyList(msg)
189 -
	case FocusContent:
190 -
		return m.keyContent(msg)
191 -
	case FocusCreateTitle, FocusCreateContent, FocusEditTitle, FocusEditContent:
192 -
		return m.keyForm(msg)
193 -
	case FocusSearch:
194 -
		return m.keySearch(msg)
120 +
	switch m.state {
121 +
	case stateList:
122 +
		return m.handleListKey(msg)
123 +
	case stateContent:
124 +
		return m.handleContentKey(msg)
125 +
	case stateForm:
126 +
		var cmd tea.Cmd
127 +
		m.form, cmd = m.form.Update(msg)
128 +
		return m, cmd
195 129
	}
196 130
	return m, nil
197 131
}
198 132
199 -
func (m Model) keyList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
200 -
	notes := m.visibleNotes()
133 +
func (m Model) handleListKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
134 +
	// While the list is in filter-entry mode, route every key to it
135 +
	// so typing and esc/enter behave as expected.
136 +
	if m.list.IsFiltering() {
137 +
		var cmd tea.Cmd
138 +
		m.list, cmd = m.list.Update(msg)
139 +
		m.refreshContentFromSelection()
140 +
		return m, cmd
141 +
	}
142 +
201 143
	switch {
202 144
	case key.Matches(msg, m.keys.Quit):
203 145
		return m, tea.Quit
204 -
	case key.Matches(msg, m.keys.Down):
205 -
		if m.cursor < len(notes)-1 {
206 -
			m.cursor++
207 -
			m.refreshPreview()
208 -
		}
209 -
	case key.Matches(msg, m.keys.Up):
210 -
		if m.cursor > 0 {
211 -
			m.cursor--
212 -
			m.refreshPreview()
213 -
		}
214 146
	case key.Matches(msg, m.keys.Open):
215 -
		if len(notes) > 0 {
216 -
			m.focus = FocusContent
217 -
			m.contentVP.GotoTop()
147 +
		if n, ok := m.list.Selected(); ok {
148 +
			m.cont.SetNote(&n)
149 +
			m.state = stateContent
218 150
		}
151 +
		return m, nil
219 152
	case key.Matches(msg, m.keys.Create):
220 -
		m.focus = FocusCreateTitle
221 -
		m.editShortID = ""
222 -
		m.titleInput.SetValue("")
223 -
		m.contentArea.SetValue("")
224 -
		m.titleInput.Focus()
225 -
		m.contentArea.Blur()
153 +
		m.form.StartCreate()
154 +
		m.state = stateForm
155 +
		return m, nil
226 156
	case key.Matches(msg, m.keys.Edit):
227 -
		n := m.currentNote()
228 -
		if n != nil {
229 -
			m.focus = FocusEditTitle
230 -
			m.editShortID = n.ShortID
231 -
			m.titleInput.SetValue(n.Title)
232 -
			m.contentArea.SetValue(n.Content)
233 -
			m.titleInput.Focus()
234 -
			m.contentArea.Blur()
157 +
		if n, ok := m.list.Selected(); ok {
158 +
			m.form.StartEdit(n)
159 +
			m.state = stateForm
235 160
		}
161 +
		return m, nil
236 162
	case key.Matches(msg, m.keys.ExtEdit):
237 -
		n := m.currentNote()
238 -
		if n != nil {
163 +
		if n, ok := m.list.Selected(); ok {
239 164
			return m, openExternalEditor(n.ShortID, n.Content)
240 165
		}
166 +
		return m, nil
241 167
	case key.Matches(msg, m.keys.Delete):
242 -
		if m.currentNote() != nil {
168 +
		if _, ok := m.list.Selected(); ok {
243 169
			m.confirmDelete = true
244 170
		}
171 +
		return m, nil
245 172
	case key.Matches(msg, m.keys.Copy):
246 -
		n := m.currentNote()
247 -
		if n != nil {
248 -
			if err := clipboard.WriteAll(n.Content); err != nil {
249 -
				return m, m.setStatus("clipboard: "+err.Error(), false)
250 -
			}
251 -
			return m, m.setStatus("copied text", true)
173 +
		if n, ok := m.list.Selected(); ok {
174 +
			return m, copyToClipboardCmd(n.Content, "copied text")
252 175
		}
176 +
		return m, nil
253 177
	case key.Matches(msg, m.keys.CopyLink):
254 -
		n := m.currentNote()
255 -
		if n != nil && m.isRemote {
256 -
			link := strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID
257 -
			if err := clipboard.WriteAll(link); err != nil {
258 -
				return m, m.setStatus("clipboard: "+err.Error(), false)
259 -
			}
260 -
			return m, m.setStatus("copied link", true)
178 +
		if !m.isRemote {
179 +
			return m, m.setStatus("local mode: no link", false)
180 +
		}
181 +
		if n, ok := m.list.Selected(); ok {
182 +
			return m, copyToClipboardCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID), "copied link")
261 183
		}
262 -
		return m, m.setStatus("local mode: no link", false)
184 +
		return m, nil
263 185
	case key.Matches(msg, m.keys.OpenBrowser):
264 -
		n := m.currentNote()
265 -
		if n != nil && m.isRemote {
266 -
			link := strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID
267 -
			if err := openURL(link); err != nil {
268 -
				return m, m.setStatus("open: "+err.Error(), false)
269 -
			}
270 -
			return m, m.setStatus("opened "+link, true)
186 +
		if !m.isRemote {
187 +
			return m, nil
188 +
		}
189 +
		if n, ok := m.list.Selected(); ok {
190 +
			return m, openURLCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID))
271 191
		}
272 -
	case key.Matches(msg, m.keys.Search):
273 -
		m.focus = FocusSearch
274 -
		m.searchInput.Focus()
192 +
		return m, nil
275 193
	case key.Matches(msg, m.keys.Refresh):
276 194
		if m.isRemote {
277 195
			return m, loadNotesCmd(m.backend)
278 196
		}
197 +
		return m, nil
279 198
	case key.Matches(msg, m.keys.Help):
280 199
		m.showHelp = true
200 +
		return m, nil
281 201
	}
282 -
	return m, nil
202 +
203 +
	var cmd tea.Cmd
204 +
	m.list, cmd = m.list.Update(msg)
205 +
	m.refreshContentFromSelection()
206 +
	return m, cmd
283 207
}
284 208
285 -
func (m Model) keyContent(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
209 +
func (m Model) handleContentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
286 210
	switch {
287 211
	case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Back):
288 -
		m.focus = FocusList
212 +
		m.state = stateList
213 +
		return m, nil
214 +
	case key.Matches(msg, m.keys.ScrollDown):
215 +
		m.cont = m.cont.ScrollDown(1)
289 216
		return m, nil
290 -
	case key.Matches(msg, m.keys.Down):
291 -
		m.contentVP.ScrollDown(1)
292 -
	case key.Matches(msg, m.keys.Up):
293 -
		m.contentVP.ScrollUp(1)
217 +
	case key.Matches(msg, m.keys.ScrollUp):
218 +
		m.cont = m.cont.ScrollUp(1)
219 +
		return m, nil
294 220
	case key.Matches(msg, m.keys.Edit):
295 -
		n := m.currentNote()
296 -
		if n != nil {
297 -
			m.focus = FocusEditTitle
298 -
			m.editShortID = n.ShortID
299 -
			m.titleInput.SetValue(n.Title)
300 -
			m.contentArea.SetValue(n.Content)
301 -
			m.titleInput.Focus()
221 +
		if n, ok := m.list.Selected(); ok {
222 +
			m.form.StartEdit(n)
223 +
			m.state = stateForm
302 224
		}
225 +
		return m, nil
303 226
	case key.Matches(msg, m.keys.ExtEdit):
304 -
		n := m.currentNote()
305 -
		if n != nil {
227 +
		if n, ok := m.list.Selected(); ok {
306 228
			return m, openExternalEditor(n.ShortID, n.Content)
307 229
		}
230 +
		return m, nil
308 231
	case key.Matches(msg, m.keys.Copy):
309 -
		n := m.currentNote()
310 -
		if n != nil {
311 -
			clipboard.WriteAll(n.Content)
312 -
			return m, m.setStatus("copied text", true)
232 +
		if n, ok := m.list.Selected(); ok {
233 +
			return m, copyToClipboardCmd(n.Content, "copied text")
313 234
		}
235 +
		return m, nil
314 236
	case key.Matches(msg, m.keys.CopyLink):
315 -
		n := m.currentNote()
316 -
		if n != nil && m.isRemote {
317 -
			link := strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID
318 -
			clipboard.WriteAll(link)
319 -
			return m, m.setStatus("copied link", true)
237 +
		if !m.isRemote {
238 +
			return m, m.setStatus("local mode: no link", false)
320 239
		}
321 -
	case key.Matches(msg, m.keys.OpenBrowser):
322 -
		n := m.currentNote()
323 -
		if n != nil && m.isRemote {
324 -
			openURL(strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID)
240 +
		if n, ok := m.list.Selected(); ok {
241 +
			return m, copyToClipboardCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID), "copied link")
325 242
		}
326 -
	case key.Matches(msg, m.keys.Help):
327 -
		m.showHelp = true
328 -
	case key.Matches(msg, m.keys.ToggleWrap):
329 -
		m.wrap = !m.wrap
330 -
		m.refreshPreview()
331 -
	}
332 -
	var cmd tea.Cmd
333 -
	m.contentVP, cmd = m.contentVP.Update(msg)
334 -
	return m, cmd
335 -
}
336 -
337 -
func (m Model) keyForm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
338 -
	switch {
339 -
	case key.Matches(msg, m.keys.Cancel):
340 -
		m.focus = FocusList
341 -
		m.titleInput.Blur()
342 -
		m.contentArea.Blur()
343 243
		return m, nil
344 -
	case key.Matches(msg, m.keys.Save):
345 -
		title := strings.TrimSpace(m.titleInput.Value())
346 -
		if title == "" {
347 -
			return m, m.setStatus("title required", false)
244 +
	case key.Matches(msg, m.keys.OpenBrowser):
245 +
		if !m.isRemote {
246 +
			return m, nil
348 247
		}
349 -
		return m, saveNoteCmd(m.backend, m.editShortID, title, m.contentArea.Value())
350 -
	case key.Matches(msg, m.keys.SwitchField):
351 -
		switch m.focus {
352 -
		case FocusCreateTitle:
353 -
			m.focus = FocusCreateContent
354 -
		case FocusCreateContent:
355 -
			m.focus = FocusCreateTitle
356 -
		case FocusEditTitle:
357 -
			m.focus = FocusEditContent
358 -
		case FocusEditContent:
359 -
			m.focus = FocusEditTitle
248 +
		if n, ok := m.list.Selected(); ok {
249 +
			return m, openURLCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID))
360 250
		}
361 -
		m.applyFormFocus()
362 251
		return m, nil
363 252
	case key.Matches(msg, m.keys.ToggleWrap):
364 -
		m.wrap = !m.wrap
253 +
		m.cont.ToggleWrap()
254 +
		return m, nil
255 +
	case key.Matches(msg, m.keys.Help):
256 +
		m.showHelp = true
365 257
		return m, nil
366 258
	}
367 259
368 260
	var cmd tea.Cmd
369 -
	switch m.focus {
370 -
	case FocusCreateTitle, FocusEditTitle:
371 -
		m.titleInput, cmd = m.titleInput.Update(msg)
372 -
	case FocusCreateContent, FocusEditContent:
373 -
		m.contentArea, cmd = m.contentArea.Update(msg)
374 -
	}
261 +
	m.cont, cmd = m.cont.Update(msg)
375 262
	return m, cmd
376 263
}
377 264
378 -
func (m *Model) applyFormFocus() {
379 -
	switch m.focus {
380 -
	case FocusCreateTitle, FocusEditTitle:
381 -
		m.titleInput.Focus()
382 -
		m.contentArea.Blur()
383 -
	case FocusCreateContent, FocusEditContent:
384 -
		m.contentArea.Focus()
385 -
		m.titleInput.Blur()
265 +
func (m *Model) refreshContentFromSelection() {
266 +
	if n, ok := m.list.Selected(); ok {
267 +
		m.cont.SetNote(&n)
268 +
	} else {
269 +
		m.cont.SetNote(nil)
386 270
	}
387 271
}
388 272
389 -
func (m Model) keySearch(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
390 -
	switch msg.String() {
391 -
	case "esc":
392 -
		m.searchInput.SetValue("")
393 -
		m.searchInput.Blur()
394 -
		m.focus = FocusList
395 -
		m.applyFilter("")
396 -
		m.refreshPreview()
397 -
		return m, nil
398 -
	case "enter":
399 -
		m.searchInput.Blur()
400 -
		m.focus = FocusList
401 -
		return m, nil
273 +
func (m *Model) applyLayout() {
274 +
	if !m.ready {
275 +
		return
402 276
	}
403 -
	var cmd tea.Cmd
404 -
	m.searchInput, cmd = m.searchInput.Update(msg)
405 -
	m.applyFilter(m.searchInput.Value())
406 -
	m.refreshPreview()
407 -
	return m, cmd
277 +
	listW, contentW := splitWidths(m.width)
278 +
	bodyH := splitBodyHeight(m.height)
279 +
280 +
	listInnerW := max(listW-paneFrameWidth(), 1)
281 +
	listInnerH := max(bodyH-paneFrameHeight(), 1)
282 +
	m.list.SetSize(listInnerW, listInnerH)
283 +
284 +
	contentInnerW := max(contentW-paneFrameWidth(), 20)
285 +
	contentInnerH := max(bodyH-paneFrameHeight(), 3)
286 +
	m.cont.SetSize(contentInnerW, max(contentInnerH-1, 1))
287 +
	m.form.SetSize(contentInnerW, contentInnerH)
288 +
}
289 +
290 +
func (m *Model) setStatus(text string, ok bool) tea.Cmd {
291 +
	m.status = text
292 +
	m.statusOK = ok
293 +
	m.statusUntil = time.Now().Add(2 * time.Second)
294 +
	return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} })
408 295
}
apps/jotts-go/tui/view.go +60 −117
2 2
3 3
import (
4 4
	"fmt"
5 -
	"strings"
6 5
7 6
	tea "charm.land/bubbletea/v2"
8 7
	"charm.land/lipgloss/v2"
19 18
			Bold(true).
20 19
			Foreground(lipgloss.Color("3")).
21 20
			Padding(0, 1)
22 -
	itemStyle    = lipgloss.NewStyle().Padding(0, 1)
23 -
	itemSelected = lipgloss.NewStyle().
24 -
			Padding(0, 1).
25 -
			Bold(true).
26 -
			Foreground(lipgloss.Color("3"))
27 -
	statusOK = lipgloss.NewStyle().
21 +
	statusOKStyle = lipgloss.NewStyle().
28 22
			Foreground(lipgloss.Color("2")).
29 23
			Bold(true)
30 -
	statusErr = lipgloss.NewStyle().
24 +
	statusErrStyle = lipgloss.NewStyle().
31 25
			Foreground(lipgloss.Color("1")).
32 26
			Bold(true)
33 -
	hintStyle = lipgloss.NewStyle().
34 -
			Foreground(lipgloss.Color("8"))
35 27
	modalStyle = lipgloss.NewStyle().
36 28
			Border(lipgloss.RoundedBorder()).
37 29
			BorderForeground(lipgloss.Color("3")).
42 34
	listW, contentW := splitWidths(m.width)
43 35
	bodyH := splitBodyHeight(m.height)
44 36
45 -
	left := m.renderList(listW, bodyH)
46 -
	right := m.renderRight(contentW, bodyH)
37 +
	left := m.renderListPane(listW, bodyH)
38 +
	right := m.renderRightPane(contentW, bodyH)
47 39
48 40
	base := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
49 41
53 45
			modalStyle.Render(m.help.FullHelpView(m.keys.FullHelp())), 1))
54 46
	}
55 47
	if m.confirmDelete {
56 -
		n := m.currentNote()
57 48
		title := ""
58 -
		if n != nil {
49 +
		if n, ok := m.list.Selected(); ok {
59 50
			title = n.Title
60 51
		}
61 52
		overlays = append(overlays, centerLayer(m.width, m.height,
62 53
			modalStyle.Render(fmt.Sprintf("Delete %q?\n\ny / n", title)), 2))
63 54
	}
64 55
	if m.status != "" {
65 -
		st := statusOK
56 +
		st := statusOKStyle
66 57
		if !m.statusOK {
67 -
			st = statusErr
58 +
			st = statusErrStyle
68 59
		}
69 60
		overlays = append(overlays, bottomCenterLayer(m.width, m.height,
70 61
			modalStyle.Render(st.Render(m.status)), 3))
81 72
	return tea.View{Content: content, AltScreen: true}
82 73
}
83 74
84 -
func centerLayer(w, h int, content string, z int) *lipgloss.Layer {
85 -
	cw, ch := lipgloss.Width(content), lipgloss.Height(content)
86 -
	x := (w - cw) / 2
87 -
	y := (h - ch) / 2
88 -
	if x < 0 {
89 -
		x = 0
90 -
	}
91 -
	if y < 0 {
92 -
		y = 0
93 -
	}
94 -
	return lipgloss.NewLayer(content).X(x).Y(y).Z(z)
95 -
}
96 -
97 -
func bottomCenterLayer(w, h int, content string, z int) *lipgloss.Layer {
98 -
	cw, ch := lipgloss.Width(content), lipgloss.Height(content)
99 -
	x := (w - cw) / 2
100 -
	y := h - ch - 1
101 -
	if x < 0 {
102 -
		x = 0
103 -
	}
104 -
	if y < 0 {
105 -
		y = 0
106 -
	}
107 -
	return lipgloss.NewLayer(content).X(x).Y(y).Z(z)
108 -
}
109 -
110 -
func (m Model) renderList(w, h int) string {
75 +
func (m Model) renderListPane(w, h int) string {
111 76
	style := borderStyle
112 -
	if m.focus == FocusList || m.focus == FocusSearch {
77 +
	if m.state == stateList {
113 78
		style = borderActive
114 79
	}
115 -
116 -
	notes := m.visibleNotes()
117 -
	rows := make([]string, 0, len(notes)+2)
118 -
	rows = append(rows, titleStyle.Render("notes"))
119 -
	if len(notes) == 0 {
120 -
		rows = append(rows, hintStyle.Render("  (empty — press c)"))
121 -
	}
122 -
	for i, n := range notes {
123 -
		line := truncate(n.Title, maxInt(w-paneFrameWidth()-4, 1))
124 -
		if i == m.cursor {
125 -
			rows = append(rows, itemSelected.Render("▶ "+line))
126 -
		} else {
127 -
			rows = append(rows, itemStyle.Render("  "+line))
128 -
		}
129 -
	}
130 -
131 -
	if m.focus == FocusSearch || m.searchInput.Value() != "" {
132 -
		rows = append(rows, "", hintStyle.Render(m.searchInput.View()))
133 -
	}
134 -
135 -
	content := strings.Join(rows, "\n")
136 80
	return style.
137 -
		Width(maxInt(w-paneFrameWidth(), 1)).
138 -
		Height(maxInt(h-paneFrameHeight(), 1)).
139 -
		Render(content)
81 +
		Width(max(w-paneFrameWidth(), 1)).
82 +
		Height(max(h-paneFrameHeight(), 1)).
83 +
		Render(m.list.View())
140 84
}
141 85
142 -
func (m Model) renderRight(w, h int) string {
143 -
	switch m.focus {
144 -
	case FocusCreateTitle, FocusCreateContent, FocusEditTitle, FocusEditContent:
86 +
func (m Model) renderRightPane(w, h int) string {
87 +
	if m.state == stateForm {
145 88
		return m.renderForm(w, h)
146 89
	}
147 90
	return m.renderContent(w, h)
149 92
150 93
func (m Model) renderContent(w, h int) string {
151 94
	style := borderStyle
152 -
	if m.focus == FocusContent {
95 +
	if m.state == stateContent {
153 96
		style = borderActive
154 97
	}
155 98
	header := "preview"
156 -
	n := m.currentNote()
157 -
	if n != nil {
158 -
		header = n.Title
99 +
	if t := m.cont.Title(); t != "" {
100 +
		header = t
159 101
	}
160 -
	body := m.contentVP.View()
161 -
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), body)
102 +
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), m.cont.View())
162 103
	return style.
163 -
		Width(maxInt(w-paneFrameWidth(), 1)).
164 -
		Height(maxInt(h-paneFrameHeight(), 1)).
104 +
		Width(max(w-paneFrameWidth(), 1)).
105 +
		Height(max(h-paneFrameHeight(), 1)).
165 106
		Render(inner)
166 107
}
167 108
168 109
func (m Model) renderForm(w, h int) string {
169 110
	header := "new note"
170 -
	if m.editShortID != "" {
111 +
	if !m.form.IsCreate() {
171 112
		header = "edit"
172 113
	}
173 -
	title := m.titleInput.View()
174 -
	if m.focus == FocusCreateTitle || m.focus == FocusEditTitle {
175 -
		title = borderActive.Render(title)
114 +
115 +
	titleField := m.form.title.View()
116 +
	if m.form.ActiveField() == formFieldTitle {
117 +
		titleField = borderActive.Render(titleField)
176 118
	} else {
177 -
		title = borderStyle.Render(title)
119 +
		titleField = borderStyle.Render(titleField)
178 120
	}
179 121
180 -
	body := m.contentArea.View()
181 -
	if m.focus == FocusCreateContent || m.focus == FocusEditContent {
122 +
	body := m.form.content.View()
123 +
	if m.form.ActiveField() == formFieldContent {
182 124
		body = borderActive.Render(body)
183 125
	} else {
184 126
		body = borderStyle.Render(body)
185 127
	}
186 128
187 -
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), title, body)
188 -
	return borderStyle.
189 -
		Width(maxInt(w-paneFrameWidth(), 1)).
190 -
		Height(maxInt(h-paneFrameHeight(), 1)).
129 +
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), titleField, body)
130 +
	return borderActive.
131 +
		Width(max(w-paneFrameWidth(), 1)).
132 +
		Height(max(h-paneFrameHeight(), 1)).
191 133
		Render(inner)
192 134
}
193 135
136 +
func centerLayer(w, h int, content string, z int) *lipgloss.Layer {
137 +
	cw, ch := lipgloss.Width(content), lipgloss.Height(content)
138 +
	x := (w - cw) / 2
139 +
	y := (h - ch) / 2
140 +
	if x < 0 {
141 +
		x = 0
142 +
	}
143 +
	if y < 0 {
144 +
		y = 0
145 +
	}
146 +
	return lipgloss.NewLayer(content).X(x).Y(y).Z(z)
147 +
}
148 +
149 +
func bottomCenterLayer(w, h int, content string, z int) *lipgloss.Layer {
150 +
	cw, ch := lipgloss.Width(content), lipgloss.Height(content)
151 +
	x := (w - cw) / 2
152 +
	y := h - ch - 1
153 +
	if x < 0 {
154 +
		x = 0
155 +
	}
156 +
	if y < 0 {
157 +
		y = 0
158 +
	}
159 +
	return lipgloss.NewLayer(content).X(x).Y(y).Z(z)
160 +
}
161 +
194 162
func splitWidths(total int) (int, int) {
195 163
	if total < 44 {
196 164
		return total / 2, total - (total / 2)
215 183
	return total
216 184
}
217 185
218 -
func paneFrameWidth() int {
219 -
	return borderStyle.GetHorizontalFrameSize()
220 -
}
221 -
222 -
func paneFrameHeight() int {
223 -
	return borderStyle.GetVerticalFrameSize()
224 -
}
225 -
226 -
func maxInt(a, b int) int {
227 -
	if a > b {
228 -
		return a
229 -
	}
230 -
	return b
231 -
}
232 -
233 -
func truncate(s string, n int) string {
234 -
	if n < 1 {
235 -
		return ""
236 -
	}
237 -
	if len(s) <= n {
238 -
		return s
239 -
	}
240 -
	if n <= 1 {
241 -
		return "…"
242 -
	}
243 -
	return s[:n-1] + "…"
244 -
}
186 +
func paneFrameWidth() int  { return borderStyle.GetHorizontalFrameSize() }
187 +
func paneFrameHeight() int { return borderStyle.GetVerticalFrameSize() }
apps/sipp-go/go.mod +1 −0
35 35
	github.com/ncruces/go-strftime v0.1.9 // indirect
36 36
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
37 37
	github.com/rivo/uniseg v0.4.7 // indirect
38 +
	github.com/sahilm/fuzzy v0.1.1 // indirect
38 39
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
39 40
	golang.org/x/crypto v0.39.0 // indirect
40 41
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
apps/sipp-go/go.sum +4 −0
46 46
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
47 47
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
48 48
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
49 +
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
50 +
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
49 51
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
50 52
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
51 53
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
60 62
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
61 63
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
62 64
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
65 +
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
66 +
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
63 67
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
64 68
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
65 69
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
apps/sipp-go/tui/commands.go (added) +62 −0
1 +
package tui
2 +
3 +
import (
4 +
	"strings"
5 +
6 +
	tea "charm.land/bubbletea/v2"
7 +
	"github.com/atotto/clipboard"
8 +
)
9 +
10 +
func loadSnippetsCmd(b Backend) tea.Cmd {
11 +
	return func() tea.Msg {
12 +
		list, err := b.List()
13 +
		return snippetsLoadedMsg{snippets: list, err: err}
14 +
	}
15 +
}
16 +
17 +
func saveSnippetCmd(b Backend, shortID, name, content string) tea.Cmd {
18 +
	return func() tea.Msg {
19 +
		var (
20 +
			s   *Snippet
21 +
			err error
22 +
		)
23 +
		if shortID == "" {
24 +
			s, err = b.Create(name, content)
25 +
		} else {
26 +
			s, err = b.Update(shortID, name, content)
27 +
		}
28 +
		return snippetSavedMsg{snippet: s, err: err}
29 +
	}
30 +
}
31 +
32 +
func deleteSnippetCmd(b Backend, shortID string) tea.Cmd {
33 +
	return func() tea.Msg {
34 +
		_, err := b.Delete(shortID)
35 +
		return snippetDeletedMsg{shortID: shortID, err: err}
36 +
	}
37 +
}
38 +
39 +
func copyToClipboardCmd(text, okStatus string) tea.Cmd {
40 +
	return func() tea.Msg {
41 +
		if err := clipboard.WriteAll(text); err != nil {
42 +
			return statusMsg{text: "clipboard: " + err.Error(), ok: false}
43 +
		}
44 +
		return statusMsg{text: okStatus, ok: true}
45 +
	}
46 +
}
47 +
48 +
func openURLCmd(url string) tea.Cmd {
49 +
	return func() tea.Msg {
50 +
		if err := openURL(url); err != nil {
51 +
			return statusMsg{text: "open: " + err.Error(), ok: false}
52 +
		}
53 +
		return statusMsg{text: "opened " + url, ok: true}
54 +
	}
55 +
}
56 +
57 +
func shareLinkURL(remoteBase, shortID string) string {
58 +
	if remoteBase == "" {
59 +
		return ""
60 +
	}
61 +
	return strings.TrimRight(remoteBase, "/") + "/s/" + shortID
62 +
}
apps/sipp-go/tui/content_model.go (added) +95 −0
1 +
package tui
2 +
3 +
import (
4 +
	"charm.land/bubbles/v2/viewport"
5 +
	tea "charm.land/bubbletea/v2"
6 +
	"charm.land/lipgloss/v2"
7 +
)
8 +
9 +
type contentModel struct {
10 +
	vp          viewport.Model
11 +
	highlighter *highlighter
12 +
	wrap        bool
13 +
14 +
	shortID string
15 +
	name    string
16 +
	body    string
17 +
}
18 +
19 +
func newContentModel() contentModel {
20 +
	return contentModel{vp: viewport.New(), highlighter: newHighlighter(), wrap: true}
21 +
}
22 +
23 +
func (c contentModel) Update(msg tea.Msg) (contentModel, tea.Cmd) {
24 +
	var cmd tea.Cmd
25 +
	c.vp, cmd = c.vp.Update(msg)
26 +
	return c, cmd
27 +
}
28 +
29 +
func (c *contentModel) SetSize(w, h int) {
30 +
	c.vp.SetWidth(w)
31 +
	c.vp.SetHeight(h)
32 +
	c.refresh()
33 +
}
34 +
35 +
func (c *contentModel) SetSnippet(s *Snippet) {
36 +
	if s == nil {
37 +
		c.shortID, c.name, c.body = "", "", ""
38 +
		c.vp.SetContent("")
39 +
		c.vp.GotoTop()
40 +
		return
41 +
	}
42 +
	c.shortID = s.ShortID
43 +
	c.name = s.Name
44 +
	c.body = s.Content
45 +
	c.vp.GotoTop()
46 +
	c.refresh()
47 +
}
48 +
49 +
func (c *contentModel) ToggleWrap() {
50 +
	c.wrap = !c.wrap
51 +
	c.vp.GotoTop()
52 +
	c.refresh()
53 +
}
54 +
55 +
func (c *contentModel) Wrap() bool { return c.wrap }
56 +
57 +
func (c *contentModel) Invalidate(shortID string) {
58 +
	if c.highlighter != nil {
59 +
		c.highlighter.invalidate(shortID)
60 +
	}
61 +
}
62 +
63 +
func (c *contentModel) refresh() {
64 +
	if c.body == "" {
65 +
		c.vp.SetContent("")
66 +
		return
67 +
	}
68 +
	body := c.body
69 +
	if c.highlighter != nil {
70 +
		body = c.highlighter.render(c.shortID, c.name, c.body)
71 +
	}
72 +
	if c.wrap && c.vp.Width() > 0 {
73 +
		body = lipgloss.NewStyle().Width(c.vp.Width()).Render(body)
74 +
	}
75 +
	c.vp.SetContent(body)
76 +
}
77 +
78 +
func (c contentModel) Header() string {
79 +
	if c.name != "" {
80 +
		return c.name
81 +
	}
82 +
	return c.shortID
83 +
}
84 +
85 +
func (c contentModel) View() string { return c.vp.View() }
86 +
87 +
func (c contentModel) ScrollUp(n int) contentModel {
88 +
	c.vp.ScrollUp(n)
89 +
	return c
90 +
}
91 +
92 +
func (c contentModel) ScrollDown(n int) contentModel {
93 +
	c.vp.ScrollDown(n)
94 +
	return c
95 +
}
apps/sipp-go/tui/form_model.go (added) +140 −0
1 +
package tui
2 +
3 +
import (
4 +
	"strings"
5 +
6 +
	"charm.land/bubbles/v2/key"
7 +
	"charm.land/bubbles/v2/textarea"
8 +
	"charm.land/bubbles/v2/textinput"
9 +
	tea "charm.land/bubbletea/v2"
10 +
)
11 +
12 +
type formField uint8
13 +
14 +
const (
15 +
	formFieldName formField = iota
16 +
	formFieldContent
17 +
)
18 +
19 +
type formModel struct {
20 +
	name    textinput.Model
21 +
	content textarea.Model
22 +
23 +
	field    formField
24 +
	shortID  string
25 +
	isCreate bool
26 +
27 +
	keys formKeys
28 +
}
29 +
30 +
type formKeys struct {
31 +
	Save        key.Binding
32 +
	Cancel      key.Binding
33 +
	SwitchField key.Binding
34 +
}
35 +
36 +
func defaultFormKeys() formKeys {
37 +
	return formKeys{
38 +
		Save:        key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")),
39 +
		Cancel:      key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
40 +
		SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")),
41 +
	}
42 +
}
43 +
44 +
func newFormModel() formModel {
45 +
	ti := textinput.New()
46 +
	ti.Placeholder = "name.ext"
47 +
	ti.Prompt = ""
48 +
	ti.CharLimit = 200
49 +
50 +
	ta := textarea.New()
51 +
	ta.Placeholder = "Paste code..."
52 +
	ta.ShowLineNumbers = true
53 +
	ta.Prompt = ""
54 +
55 +
	return formModel{name: ti, content: ta, keys: defaultFormKeys()}
56 +
}
57 +
58 +
func (f *formModel) StartCreate() {
59 +
	f.shortID = ""
60 +
	f.isCreate = true
61 +
	f.name.SetValue("")
62 +
	f.content.SetValue("")
63 +
	f.field = formFieldName
64 +
	f.applyFocus()
65 +
}
66 +
67 +
func (f *formModel) StartEdit(s Snippet) {
68 +
	f.shortID = s.ShortID
69 +
	f.isCreate = false
70 +
	f.name.SetValue(s.Name)
71 +
	f.content.SetValue(s.Content)
72 +
	f.field = formFieldName
73 +
	f.applyFocus()
74 +
}
75 +
76 +
func (f *formModel) SetContent(s string) { f.content.SetValue(s) }
77 +
78 +
func (f *formModel) Blur() {
79 +
	f.name.Blur()
80 +
	f.content.Blur()
81 +
}
82 +
83 +
func (f *formModel) applyFocus() {
84 +
	switch f.field {
85 +
	case formFieldName:
86 +
		f.name.Focus()
87 +
		f.content.Blur()
88 +
	case formFieldContent:
89 +
		f.content.Focus()
90 +
		f.name.Blur()
91 +
	}
92 +
}
93 +
94 +
func (f *formModel) SetSize(w, h int) {
95 +
	f.name.SetWidth(max(w-4, 1))
96 +
	f.content.SetWidth(max(w-2, 1))
97 +
	f.content.SetHeight(max(h-5, 1))
98 +
}
99 +
100 +
func (f formModel) Update(msg tea.Msg) (formModel, tea.Cmd) {
101 +
	if km, ok := msg.(tea.KeyPressMsg); ok {
102 +
		switch {
103 +
		case key.Matches(km, f.keys.Cancel):
104 +
			f.Blur()
105 +
			return f, func() tea.Msg { return cancelFormMsg{} }
106 +
		case key.Matches(km, f.keys.Save):
107 +
			name := strings.TrimSpace(f.name.Value())
108 +
			if name == "" {
109 +
				return f, func() tea.Msg { return statusMsg{text: "name required", ok: false} }
110 +
			}
111 +
			content := f.content.Value()
112 +
			if strings.TrimSpace(content) == "" {
113 +
				return f, func() tea.Msg { return statusMsg{text: "content required", ok: false} }
114 +
			}
115 +
			return f, func() tea.Msg {
116 +
				return submitFormMsg{shortID: f.shortID, name: name, content: content}
117 +
			}
118 +
		case key.Matches(km, f.keys.SwitchField):
119 +
			if f.field == formFieldName {
120 +
				f.field = formFieldContent
121 +
			} else {
122 +
				f.field = formFieldName
123 +
			}
124 +
			f.applyFocus()
125 +
			return f, nil
126 +
		}
127 +
	}
128 +
129 +
	var cmd tea.Cmd
130 +
	switch f.field {
131 +
	case formFieldName:
132 +
		f.name, cmd = f.name.Update(msg)
133 +
	case formFieldContent:
134 +
		f.content, cmd = f.content.Update(msg)
135 +
	}
136 +
	return f, cmd
137 +
}
138 +
139 +
func (f formModel) ActiveField() formField { return f.field }
140 +
func (f formModel) IsCreate() bool         { return f.isCreate }
apps/sipp-go/tui/highlight.go +20 −2
10 10
	"github.com/alecthomas/chroma/v2/styles"
11 11
)
12 12
13 +
const highlightCacheMax = 64
14 +
13 15
type highlighter struct {
14 16
	cache map[string]string
17 +
	order []string
15 18
}
16 19
17 20
func newHighlighter() *highlighter {
24 27
	}
25 28
	out := highlightCode(name, content)
26 29
	h.cache[shortID] = out
30 +
	h.order = append(h.order, shortID)
31 +
	if len(h.order) > highlightCacheMax {
32 +
		drop := h.order[0]
33 +
		h.order = h.order[1:]
34 +
		delete(h.cache, drop)
35 +
	}
27 36
	return out
28 37
}
29 38
30 39
func (h *highlighter) invalidate(shortID string) {
40 +
	if _, ok := h.cache[shortID]; !ok {
41 +
		return
42 +
	}
31 43
	delete(h.cache, shortID)
44 +
	for i, k := range h.order {
45 +
		if k == shortID {
46 +
			h.order = append(h.order[:i], h.order[i+1:]...)
47 +
			break
48 +
		}
49 +
	}
32 50
}
33 51
34 52
func highlightCode(name, content string) string {
42 60
	if lexer == nil {
43 61
		lexer = lexers.Fallback
44 62
	}
45 -
	style := styles.Get("monokai")
63 +
	style := styles.Get("vim")
46 64
	if style == nil {
47 65
		style = styles.Fallback
48 66
	}
49 -
	formatter := formatters.Get("terminal256")
67 +
	formatter := formatters.Get("terminal16")
50 68
	if formatter == nil {
51 69
		formatter = formatters.Fallback
52 70
	}
apps/sipp-go/tui/keys.go +10 −19
3 3
import "charm.land/bubbles/v2/key"
4 4
5 5
type keyMap struct {
6 -
	Up          key.Binding
7 -
	Down        key.Binding
8 6
	Open        key.Binding
9 7
	Back        key.Binding
10 8
	Quit        key.Binding
15 13
	Copy        key.Binding
16 14
	CopyLink    key.Binding
17 15
	OpenBrowser key.Binding
18 -
	Search      key.Binding
19 16
	Refresh     key.Binding
20 17
	WrapToggle  key.Binding
21 18
	Help        key.Binding
22 -
	Save        key.Binding
23 -
	SwitchField key.Binding
24 -
	Cancel      key.Binding
19 +
	ScrollUp    key.Binding
20 +
	ScrollDown  key.Binding
25 21
}
26 22
27 23
func defaultKeys() keyMap {
28 24
	return keyMap{
29 -
		Up:          key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
30 -
		Down:        key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
31 25
		Open:        key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("⏎/l", "open")),
32 26
		Back:        key.NewBinding(key.WithKeys("h", "esc"), key.WithHelp("h/esc", "back")),
33 -
		Quit:        key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
27 +
		Quit:        key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
34 28
		Create:      key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")),
35 29
		Edit:        key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
36 30
		ExtEdit:     key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "$EDITOR")),
38 32
		Copy:        key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy text")),
39 33
		CopyLink:    key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy link")),
40 34
		OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")),
41 -
		Search:      key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
42 35
		Refresh:     key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
43 36
		WrapToggle:  key.NewBinding(key.WithKeys("ctrl+w"), key.WithHelp("⌃w", "wrap")),
44 37
		Help:        key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
45 -
		Save:        key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")),
46 -
		SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")),
47 -
		Cancel:      key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
38 +
		ScrollUp:    key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
39 +
		ScrollDown:  key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
48 40
	}
49 41
}
50 42
51 43
func (k keyMap) ShortHelp() []key.Binding {
52 -
	return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Search, k.Help, k.Quit}
44 +
	return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Help, k.Quit}
53 45
}
54 46
55 47
func (k keyMap) FullHelp() [][]key.Binding {
56 48
	return [][]key.Binding{
57 -
		{k.Up, k.Down, k.Open, k.Back},
58 -
		{k.Create, k.Edit, k.ExtEdit, k.Delete},
59 -
		{k.Copy, k.CopyLink, k.OpenBrowser, k.Search},
60 -
		{k.Refresh, k.WrapToggle, k.Help, k.Save, k.SwitchField},
61 -
		{k.Cancel, k.Quit},
49 +
		{k.Open, k.Back, k.Create, k.Edit},
50 +
		{k.ExtEdit, k.Delete, k.Copy, k.CopyLink},
51 +
		{k.OpenBrowser, k.Refresh, k.WrapToggle, k.Help},
52 +
		{k.ScrollUp, k.ScrollDown, k.Quit},
62 53
	}
63 54
}
apps/sipp-go/tui/list_model.go (added) +110 −0
1 +
package tui
2 +
3 +
import (
4 +
	"charm.land/bubbles/v2/list"
5 +
	tea "charm.land/bubbletea/v2"
6 +
	"charm.land/lipgloss/v2"
7 +
)
8 +
9 +
var listIDStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
10 +
11 +
func ansiListDelegate() list.DefaultDelegate {
12 +
	d := list.NewDefaultDelegate()
13 +
	d.ShowDescription = false
14 +
	d.SetSpacing(0)
15 +
	d.Styles.NormalTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Padding(0, 0, 0, 2)
16 +
	d.Styles.SelectedTitle = lipgloss.NewStyle().
17 +
		Foreground(lipgloss.Color("3")).
18 +
		Bold(true).
19 +
		Border(lipgloss.NormalBorder(), false, false, false, true).
20 +
		BorderForeground(lipgloss.Color("3")).
21 +
		Padding(0, 0, 0, 1)
22 +
	d.Styles.DimmedTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2)
23 +
	d.Styles.FilterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3"))
24 +
	return d
25 +
}
26 +
27 +
func ansiListStyles() list.Styles {
28 +
	s := list.DefaultStyles(true)
29 +
	s.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")).Padding(0, 1)
30 +
	s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 0)
31 +
	s.NoItems = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2)
32 +
	s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3"))
33 +
	return s
34 +
}
35 +
36 +
type snippetItem struct {
37 +
	snippet Snippet
38 +
}
39 +
40 +
func (s snippetItem) Title() string {
41 +
	label := s.snippet.Name
42 +
	if label == "" {
43 +
		label = s.snippet.ShortID
44 +
		return label
45 +
	}
46 +
	return label + listIDStyle.Render(" "+s.snippet.ShortID)
47 +
}
48 +
func (s snippetItem) Description() string { return "" }
49 +
func (s snippetItem) FilterValue() string {
50 +
	if s.snippet.Name != "" {
51 +
		return s.snippet.Name + " " + s.snippet.ShortID
52 +
	}
53 +
	return s.snippet.ShortID
54 +
}
55 +
56 +
type listModel struct {
57 +
	inner list.Model
58 +
}
59 +
60 +
func newListModel(snippets []Snippet) listModel {
61 +
	items := make([]list.Item, 0, len(snippets))
62 +
	for _, s := range snippets {
63 +
		items = append(items, snippetItem{snippet: s})
64 +
	}
65 +
66 +
	l := list.New(items, ansiListDelegate(), 0, 0)
67 +
	l.Title = "snippets"
68 +
	l.Styles = ansiListStyles()
69 +
	l.SetShowStatusBar(false)
70 +
	l.SetShowPagination(false)
71 +
	l.SetShowHelp(false)
72 +
	l.SetFilteringEnabled(true)
73 +
	l.DisableQuitKeybindings()
74 +
75 +
	return listModel{inner: l}
76 +
}
77 +
78 +
func (l listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
79 +
	var cmd tea.Cmd
80 +
	l.inner, cmd = l.inner.Update(msg)
81 +
	return l, cmd
82 +
}
83 +
84 +
func (l listModel) View() string { return l.inner.View() }
85 +
86 +
func (l *listModel) SetSize(w, h int) { l.inner.SetSize(w, h) }
87 +
88 +
func (l *listModel) SetSnippets(snippets []Snippet) tea.Cmd {
89 +
	items := make([]list.Item, 0, len(snippets))
90 +
	for _, s := range snippets {
91 +
		items = append(items, snippetItem{snippet: s})
92 +
	}
93 +
	return l.inner.SetItems(items)
94 +
}
95 +
96 +
func (l listModel) Selected() (Snippet, bool) {
97 +
	it := l.inner.SelectedItem()
98 +
	if it == nil {
99 +
		return Snippet{}, false
100 +
	}
101 +
	s, ok := it.(snippetItem)
102 +
	if !ok {
103 +
		return Snippet{}, false
104 +
	}
105 +
	return s.snippet, true
106 +
}
107 +
108 +
func (l listModel) IsFiltering() bool {
109 +
	return l.inner.SettingFilter()
110 +
}
apps/sipp-go/tui/messages.go +8 −0
27 27
}
28 28
29 29
type clearStatusMsg struct{}
30 +
31 +
type submitFormMsg struct {
32 +
	shortID string
33 +
	name    string
34 +
	content string
35 +
}
36 +
37 +
type cancelFormMsg struct{}
apps/sipp-go/tui/model.go +23 −111
1 1
package tui
2 2
3 3
import (
4 -
	"strings"
5 4
	"time"
6 5
7 6
	"charm.land/bubbles/v2/help"
8 -
	"charm.land/bubbles/v2/textarea"
9 -
	"charm.land/bubbles/v2/textinput"
10 -
	"charm.land/bubbles/v2/viewport"
11 7
	tea "charm.land/bubbletea/v2"
12 8
)
13 9
14 -
type Focus int
10 +
type sessionState uint8
15 11
16 12
const (
17 -
	FocusList Focus = iota
18 -
	FocusContent
19 -
	FocusCreateName
20 -
	FocusCreateContent
21 -
	FocusEditName
22 -
	FocusEditContent
23 -
	FocusSearch
13 +
	stateList sessionState = iota
14 +
	stateContent
15 +
	stateForm
24 16
)
25 17
26 18
type Model struct {
27 19
	backend  Backend
28 20
	isRemote bool
29 21
30 -
	snippets []Snippet
31 -
	filtered []int
32 -
	cursor   int
22 +
	state sessionState
23 +
	list  listModel
24 +
	cont  contentModel
25 +
	form  formModel
26 +
27 +
	width, height int
28 +
	ready         bool
29 +
	loading       bool
33 30
34 -
	focus         Focus
35 31
	showHelp      bool
36 32
	confirmDelete bool
37 -
	wrapContent   bool
38 -
39 -
	nameInput   textinput.Model
40 -
	contentArea textarea.Model
41 -
	searchInput textinput.Model
42 -
	contentVP   viewport.Model
43 -
	help        help.Model
44 -
	keys        keyMap
45 -
46 -
	highlighter *highlighter
47 -
48 -
	editShortID string
49 33
50 34
	status      string
51 35
	statusOK    bool
52 36
	statusUntil time.Time
53 37
54 -
	width, height int
55 -
	ready         bool
56 -
	loading       bool
38 +
	help help.Model
39 +
	keys keyMap
57 40
}
58 41
59 42
func newModel(backend Backend) Model {
60 -
	ti := textinput.New()
61 -
	ti.Placeholder = "name.ext"
62 -
	ti.Prompt = ""
63 -
	ti.CharLimit = 200
64 -
65 -
	ta := textarea.New()
66 -
	ta.Placeholder = "Paste code..."
67 -
	ta.ShowLineNumbers = true
68 -
	ta.Prompt = ""
69 -
70 -
	si := textinput.New()
71 -
	si.Placeholder = "search names"
72 -
	si.Prompt = "/ "
73 -
74 -
	vp := viewport.New()
75 -
76 43
	return Model{
77 -
		backend:     backend,
78 -
		isRemote:    backend.RemoteURL() != "",
79 -
		focus:       FocusList,
80 -
		nameInput:   ti,
81 -
		contentArea: ta,
82 -
		searchInput: si,
83 -
		contentVP:   vp,
84 -
		help:        help.New(),
85 -
		keys:        defaultKeys(),
86 -
		highlighter: newHighlighter(),
44 +
		backend:  backend,
45 +
		isRemote: backend.RemoteURL() != "",
46 +
		state:    stateList,
47 +
		list:     newListModel(nil),
48 +
		cont:     newContentModel(),
49 +
		form:     newFormModel(),
50 +
		help:     help.New(),
51 +
		keys:     defaultKeys(),
52 +
		loading:  true,
87 53
	}
88 54
}
89 55
90 56
func (m Model) Init() tea.Cmd {
91 57
	return tea.Batch(tea.RequestWindowSize, loadSnippetsCmd(m.backend))
92 58
}
93 -
94 -
func (m *Model) visible() []Snippet {
95 -
	if m.filtered == nil {
96 -
		return m.snippets
97 -
	}
98 -
	out := make([]Snippet, 0, len(m.filtered))
99 -
	for _, i := range m.filtered {
100 -
		out = append(out, m.snippets[i])
101 -
	}
102 -
	return out
103 -
}
104 -
105 -
func (m *Model) current() *Snippet {
106 -
	list := m.visible()
107 -
	if m.cursor < 0 || m.cursor >= len(list) {
108 -
		return nil
109 -
	}
110 -
	return &list[m.cursor]
111 -
}
112 -
113 -
func (m *Model) applyFilter(q string) {
114 -
	q = strings.TrimSpace(strings.ToLower(q))
115 -
	if q == "" {
116 -
		m.filtered = nil
117 -
		if m.cursor >= len(m.snippets) {
118 -
			m.cursor = 0
119 -
		}
120 -
		return
121 -
	}
122 -
	idx := []int{}
123 -
	for i, s := range m.snippets {
124 -
		if strings.Contains(strings.ToLower(s.Name), q) || strings.Contains(strings.ToLower(s.ShortID), q) {
125 -
			idx = append(idx, i)
126 -
		}
127 -
	}
128 -
	m.filtered = idx
129 -
	if m.cursor >= len(idx) {
130 -
		m.cursor = 0
131 -
	}
132 -
}
133 -
134 -
func (m *Model) setStatus(text string, ok bool) tea.Cmd {
135 -
	m.status = text
136 -
	m.statusOK = ok
137 -
	m.statusUntil = time.Now().Add(2 * time.Second)
138 -
	return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} })
139 -
}
140 -
141 -
func (m *Model) shareURL(shortID string) string {
142 -
	if m.backend.RemoteURL() == "" {
143 -
		return ""
144 -
	}
145 -
	return strings.TrimRight(m.backend.RemoteURL(), "/") + "/s/" + shortID
146 -
}
apps/sipp-go/tui/update.go +145 −272
2 2
3 3
import (
4 4
	"strings"
5 +
	"time"
5 6
6 -
	"github.com/atotto/clipboard"
7 7
	"charm.land/bubbles/v2/key"
8 8
	tea "charm.land/bubbletea/v2"
9 -
	"charm.land/lipgloss/v2"
10 9
)
11 10
12 -
func loadSnippetsCmd(b Backend) tea.Cmd {
13 -
	return func() tea.Msg {
14 -
		list, err := b.List()
15 -
		return snippetsLoadedMsg{snippets: list, err: err}
16 -
	}
17 -
}
18 -
19 -
func saveSnippetCmd(b Backend, shortID, name, content string) tea.Cmd {
20 -
	return func() tea.Msg {
21 -
		var (
22 -
			s   *Snippet
23 -
			err error
24 -
		)
25 -
		if shortID == "" {
26 -
			s, err = b.Create(name, content)
27 -
		} else {
28 -
			s, err = b.Update(shortID, name, content)
29 -
		}
30 -
		return snippetSavedMsg{snippet: s, err: err}
31 -
	}
32 -
}
33 -
34 -
func deleteSnippetCmd(b Backend, shortID string) tea.Cmd {
35 -
	return func() tea.Msg {
36 -
		_, err := b.Delete(shortID)
37 -
		return snippetDeletedMsg{shortID: shortID, err: err}
38 -
	}
39 -
}
40 -
41 11
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
42 12
	switch msg := msg.(type) {
43 13
44 14
	case tea.WindowSizeMsg:
45 15
		m.width, m.height = msg.Width, msg.Height
46 16
		m.ready = true
47 -
		m.resizePanes()
17 +
		m.applyLayout()
48 18
		return m, nil
49 19
50 20
	case snippetsLoadedMsg:
52 22
		if msg.err != nil {
53 23
			return m, m.setStatus("load: "+msg.err.Error(), false)
54 24
		}
55 -
		m.snippets = msg.snippets
56 -
		m.applyFilter(m.searchInput.Value())
57 -
		m.refreshPreview()
58 -
		return m, nil
25 +
		cmd := m.list.SetSnippets(msg.snippets)
26 +
		m.refreshContentFromSelection()
27 +
		return m, cmd
59 28
60 29
	case snippetSavedMsg:
61 30
		if msg.err != nil {
62 31
			return m, m.setStatus("save: "+msg.err.Error(), false)
63 32
		}
64 -
		if msg.snippet != nil && m.highlighter != nil {
65 -
			m.highlighter.invalidate(msg.snippet.ShortID)
33 +
		if msg.snippet != nil {
34 +
			m.cont.Invalidate(msg.snippet.ShortID)
66 35
		}
67 -
		m.focus = FocusList
68 -
		m.nameInput.Reset()
69 -
		m.contentArea.Reset()
70 -
		m.editShortID = ""
36 +
		m.state = stateList
37 +
		m.form.Blur()
71 38
		return m, tea.Batch(loadSnippetsCmd(m.backend), m.setStatus("saved", true))
72 39
73 40
	case snippetDeletedMsg:
74 41
		if msg.err != nil {
75 42
			return m, m.setStatus("delete: "+msg.err.Error(), false)
76 43
		}
77 -
		if m.highlighter != nil {
78 -
			m.highlighter.invalidate(msg.shortID)
79 -
		}
44 +
		m.cont.Invalidate(msg.shortID)
45 +
		m.state = stateList
80 46
		return m, tea.Batch(loadSnippetsCmd(m.backend), m.setStatus("deleted", true))
81 47
82 48
	case editorFinishedMsg:
84 50
			return m, m.setStatus("editor: "+msg.err.Error(), false)
85 51
		}
86 52
		if msg.shortID == "" {
87 -
			m.contentArea.SetValue(msg.content)
53 +
			m.form.SetContent(msg.content)
88 54
			return m, nil
89 55
		}
90 56
		var orig *Snippet
91 -
		for i := range m.snippets {
92 -
			if m.snippets[i].ShortID == msg.shortID {
93 -
				orig = &m.snippets[i]
57 +
		for _, it := range m.list.inner.Items() {
58 +
			si, ok := it.(snippetItem)
59 +
			if ok && si.snippet.ShortID == msg.shortID {
60 +
				s := si.snippet
61 +
				orig = &s
94 62
				break
95 63
			}
96 64
		}
99 67
		}
100 68
		return m, saveSnippetCmd(m.backend, msg.shortID, orig.Name, msg.content)
101 69
70 +
	case submitFormMsg:
71 +
		return m, saveSnippetCmd(m.backend, msg.shortID, msg.name, msg.content)
72 +
73 +
	case cancelFormMsg:
74 +
		m.state = stateList
75 +
		return m, nil
76 +
102 77
	case statusMsg:
103 78
		return m, m.setStatus(msg.text, msg.ok)
104 79
105 80
	case clearStatusMsg:
81 +
		if time.Now().Before(m.statusUntil) {
82 +
			return m, nil
83 +
		}
106 84
		m.status = ""
107 85
		return m, nil
108 86
113 91
	return m, nil
114 92
}
115 93
116 -
func (m *Model) resizePanes() {
117 -
	if !m.ready {
118 -
		return
119 -
	}
120 -
	listW := m.width * 30 / 100
121 -
	if listW < 24 {
122 -
		listW = 24
123 -
	}
124 -
	contentW := m.width - listW - 2
125 -
	if contentW < 20 {
126 -
		contentW = 20
127 -
	}
128 -
	bodyH := m.height - 2
129 -
	if bodyH < 5 {
130 -
		bodyH = 5
131 -
	}
132 -
133 -
	m.contentVP.SetWidth(contentW - 2)
134 -
	m.contentVP.SetHeight(bodyH - 2)
135 -
136 -
	m.nameInput.SetWidth(contentW - 4)
137 -
	if m.wrapContent {
138 -
		m.contentArea.SetWidth(contentW - 2)
139 -
	} else {
140 -
		m.contentArea.SetWidth(10000)
141 -
	}
142 -
	m.contentArea.SetHeight(bodyH - 5)
143 -
144 -
	m.searchInput.SetWidth(listW - 4)
145 -
146 -
	m.refreshPreview()
147 -
}
148 -
149 -
func (m *Model) refreshPreview() {
150 -
	s := m.current()
151 -
	if s == nil {
152 -
		m.contentVP.SetContent("")
153 -
		return
154 -
	}
155 -
	body := s.Content
156 -
	if m.highlighter != nil {
157 -
		body = m.highlighter.render(s.ShortID, s.Name, s.Content)
158 -
	}
159 -
	if m.wrapContent && m.contentVP.Width() > 0 {
160 -
		body = lipgloss.NewStyle().Width(m.contentVP.Width()).Render(body)
161 -
	}
162 -
	m.contentVP.SetContent(body)
163 -
}
164 -
165 94
func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
166 95
	if msg.String() == "ctrl+c" {
167 96
		return m, tea.Quit
170 99
	if m.confirmDelete {
171 100
		switch msg.String() {
172 101
		case "y", "Y":
173 -
			s := m.current()
174 102
			m.confirmDelete = false
175 -
			if s == nil {
103 +
			s, ok := m.list.Selected()
104 +
			if !ok {
176 105
				return m, nil
177 106
			}
178 107
			return m, deleteSnippetCmd(m.backend, s.ShortID)
179 108
		case "n", "N", "esc", "q":
180 109
			m.confirmDelete = false
181 -
			return m, nil
182 110
		}
183 111
		return m, nil
184 112
	}
190 118
		return m, nil
191 119
	}
192 120
193 -
	switch m.focus {
194 -
	case FocusList:
195 -
		return m.keyList(msg)
196 -
	case FocusContent:
197 -
		return m.keyContent(msg)
198 -
	case FocusCreateName, FocusCreateContent, FocusEditName, FocusEditContent:
199 -
		return m.keyForm(msg)
200 -
	case FocusSearch:
201 -
		return m.keySearch(msg)
121 +
	switch m.state {
122 +
	case stateList:
123 +
		return m.handleListKey(msg)
124 +
	case stateContent:
125 +
		return m.handleContentKey(msg)
126 +
	case stateForm:
127 +
		var cmd tea.Cmd
128 +
		m.form, cmd = m.form.Update(msg)
129 +
		return m, cmd
202 130
	}
203 131
	return m, nil
204 132
}
205 133
206 -
func (m Model) keyList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
207 -
	list := m.visible()
134 +
func (m Model) handleListKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
135 +
	if m.list.IsFiltering() {
136 +
		var cmd tea.Cmd
137 +
		m.list, cmd = m.list.Update(msg)
138 +
		m.refreshContentFromSelection()
139 +
		return m, cmd
140 +
	}
141 +
208 142
	switch {
209 143
	case key.Matches(msg, m.keys.Quit):
210 144
		return m, tea.Quit
211 -
	case key.Matches(msg, m.keys.Down):
212 -
		if m.cursor < len(list)-1 {
213 -
			m.cursor++
214 -
			m.refreshPreview()
215 -
		}
216 -
	case key.Matches(msg, m.keys.Up):
217 -
		if m.cursor > 0 {
218 -
			m.cursor--
219 -
			m.refreshPreview()
220 -
		}
221 145
	case key.Matches(msg, m.keys.Open):
222 -
		if len(list) > 0 {
223 -
			m.focus = FocusContent
224 -
			m.contentVP.GotoTop()
146 +
		if s, ok := m.list.Selected(); ok {
147 +
			m.cont.SetSnippet(&s)
148 +
			m.state = stateContent
225 149
		}
150 +
		return m, nil
226 151
	case key.Matches(msg, m.keys.Create):
227 -
		m.focus = FocusCreateName
228 -
		m.editShortID = ""
229 -
		m.nameInput.SetValue("")
230 -
		m.contentArea.SetValue("")
231 -
		m.nameInput.Focus()
232 -
		m.contentArea.Blur()
152 +
		m.form.StartCreate()
153 +
		m.state = stateForm
154 +
		return m, nil
233 155
	case key.Matches(msg, m.keys.Edit):
234 -
		s := m.current()
235 -
		if s != nil {
236 -
			m.focus = FocusEditName
237 -
			m.editShortID = s.ShortID
238 -
			m.nameInput.SetValue(s.Name)
239 -
			m.contentArea.SetValue(s.Content)
240 -
			m.nameInput.Focus()
241 -
			m.contentArea.Blur()
156 +
		if s, ok := m.list.Selected(); ok {
157 +
			m.form.StartEdit(s)
158 +
			m.state = stateForm
242 159
		}
160 +
		return m, nil
243 161
	case key.Matches(msg, m.keys.ExtEdit):
244 -
		s := m.current()
245 -
		if s != nil {
162 +
		if s, ok := m.list.Selected(); ok {
246 163
			return m, openExternalEditor(s.ShortID, s.Name, s.Content)
247 164
		}
165 +
		return m, nil
248 166
	case key.Matches(msg, m.keys.Delete):
249 -
		if m.current() != nil {
167 +
		if _, ok := m.list.Selected(); ok {
250 168
			m.confirmDelete = true
251 169
		}
170 +
		return m, nil
252 171
	case key.Matches(msg, m.keys.Copy):
253 -
		s := m.current()
254 -
		if s != nil {
255 -
			if err := clipboard.WriteAll(s.Content); err != nil {
256 -
				return m, m.setStatus("clipboard: "+err.Error(), false)
257 -
			}
258 -
			return m, m.setStatus("copied text", true)
172 +
		if s, ok := m.list.Selected(); ok {
173 +
			return m, copyToClipboardCmd(s.Content, "copied text")
259 174
		}
175 +
		return m, nil
260 176
	case key.Matches(msg, m.keys.CopyLink):
261 -
		s := m.current()
262 -
		if s != nil && m.isRemote {
263 -
			link := m.shareURL(s.ShortID)
264 -
			if err := clipboard.WriteAll(link); err != nil {
265 -
				return m, m.setStatus("clipboard: "+err.Error(), false)
266 -
			}
267 -
			return m, m.setStatus("copied link", true)
177 +
		if !m.isRemote {
178 +
			return m, m.setStatus("local mode: no link", false)
268 179
		}
269 -
		return m, m.setStatus("local mode: no link", false)
180 +
		if s, ok := m.list.Selected(); ok {
181 +
			return m, copyToClipboardCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID), "copied link")
182 +
		}
183 +
		return m, nil
270 184
	case key.Matches(msg, m.keys.OpenBrowser):
271 -
		s := m.current()
272 -
		if s != nil && m.isRemote {
273 -
			link := m.shareURL(s.ShortID)
274 -
			if err := openURL(link); err != nil {
275 -
				return m, m.setStatus("open: "+err.Error(), false)
276 -
			}
277 -
			return m, m.setStatus("opened "+link, true)
185 +
		if !m.isRemote {
186 +
			return m, nil
278 187
		}
279 -
	case key.Matches(msg, m.keys.Search):
280 -
		m.focus = FocusSearch
281 -
		m.searchInput.Focus()
188 +
		if s, ok := m.list.Selected(); ok {
189 +
			return m, openURLCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID))
190 +
		}
191 +
		return m, nil
282 192
	case key.Matches(msg, m.keys.Refresh):
283 193
		m.loading = true
284 194
		return m, loadSnippetsCmd(m.backend)
285 195
	case key.Matches(msg, m.keys.Help):
286 196
		m.showHelp = true
197 +
		return m, nil
287 198
	}
288 -
	return m, nil
199 +
200 +
	var cmd tea.Cmd
201 +
	m.list, cmd = m.list.Update(msg)
202 +
	m.refreshContentFromSelection()
203 +
	return m, cmd
289 204
}
290 205
291 -
func (m Model) keyContent(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
206 +
func (m Model) handleContentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
292 207
	switch {
293 208
	case key.Matches(msg, m.keys.WrapToggle):
294 -
		m.wrapContent = !m.wrapContent
295 -
		m.contentVP.GotoTop()
296 -
		m.refreshPreview()
297 -
		if m.wrapContent {
209 +
		m.cont.ToggleWrap()
210 +
		if m.cont.Wrap() {
298 211
			return m, m.setStatus("wrap on", true)
299 212
		}
300 213
		return m, m.setStatus("wrap off", true)
301 214
	case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Back):
302 -
		m.focus = FocusList
215 +
		m.state = stateList
303 216
		return m, nil
304 -
	case key.Matches(msg, m.keys.Down):
305 -
		m.contentVP.ScrollDown(1)
306 -
	case key.Matches(msg, m.keys.Up):
307 -
		m.contentVP.ScrollUp(1)
217 +
	case key.Matches(msg, m.keys.ScrollDown):
218 +
		m.cont = m.cont.ScrollDown(1)
219 +
		return m, nil
220 +
	case key.Matches(msg, m.keys.ScrollUp):
221 +
		m.cont = m.cont.ScrollUp(1)
222 +
		return m, nil
308 223
	case key.Matches(msg, m.keys.Edit):
309 -
		s := m.current()
310 -
		if s != nil {
311 -
			m.focus = FocusEditName
312 -
			m.editShortID = s.ShortID
313 -
			m.nameInput.SetValue(s.Name)
314 -
			m.contentArea.SetValue(s.Content)
315 -
			m.nameInput.Focus()
224 +
		if s, ok := m.list.Selected(); ok {
225 +
			m.form.StartEdit(s)
226 +
			m.state = stateForm
316 227
		}
228 +
		return m, nil
317 229
	case key.Matches(msg, m.keys.ExtEdit):
318 -
		s := m.current()
319 -
		if s != nil {
230 +
		if s, ok := m.list.Selected(); ok {
320 231
			return m, openExternalEditor(s.ShortID, s.Name, s.Content)
321 232
		}
233 +
		return m, nil
322 234
	case key.Matches(msg, m.keys.Copy):
323 -
		s := m.current()
324 -
		if s != nil {
325 -
			clipboard.WriteAll(s.Content)
326 -
			return m, m.setStatus("copied text", true)
235 +
		if s, ok := m.list.Selected(); ok {
236 +
			return m, copyToClipboardCmd(s.Content, "copied text")
327 237
		}
238 +
		return m, nil
328 239
	case key.Matches(msg, m.keys.CopyLink):
329 -
		s := m.current()
330 -
		if s != nil && m.isRemote {
331 -
			clipboard.WriteAll(m.shareURL(s.ShortID))
332 -
			return m, m.setStatus("copied link", true)
333 -
		}
334 -
	case key.Matches(msg, m.keys.OpenBrowser):
335 -
		s := m.current()
336 -
		if s != nil && m.isRemote {
337 -
			openURL(m.shareURL(s.ShortID))
240 +
		if !m.isRemote {
241 +
			return m, m.setStatus("local mode: no link", false)
338 242
		}
339 -
	case key.Matches(msg, m.keys.Help):
340 -
		m.showHelp = true
341 -
	}
342 -
	var cmd tea.Cmd
343 -
	m.contentVP, cmd = m.contentVP.Update(msg)
344 -
	return m, cmd
345 -
}
346 -
347 -
func (m Model) keyForm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
348 -
	switch {
349 -
	case key.Matches(msg, m.keys.WrapToggle):
350 -
		m.wrapContent = !m.wrapContent
351 -
		if m.wrapContent {
352 -
			m.contentArea.SetWidth(m.contentVP.Width())
353 -
			return m, m.setStatus("wrap on", true)
243 +
		if s, ok := m.list.Selected(); ok {
244 +
			return m, copyToClipboardCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID), "copied link")
354 245
		}
355 -
		m.contentArea.SetWidth(10000)
356 -
		return m, m.setStatus("wrap off", true)
357 -
	case key.Matches(msg, m.keys.Cancel):
358 -
		m.focus = FocusList
359 -
		m.nameInput.Blur()
360 -
		m.contentArea.Blur()
361 246
		return m, nil
362 -
	case key.Matches(msg, m.keys.Save):
363 -
		name := strings.TrimSpace(m.nameInput.Value())
364 -
		if name == "" {
365 -
			return m, m.setStatus("name required", false)
247 +
	case key.Matches(msg, m.keys.OpenBrowser):
248 +
		if !m.isRemote {
249 +
			return m, nil
366 250
		}
367 -
		content := m.contentArea.Value()
368 -
		if strings.TrimSpace(content) == "" {
369 -
			return m, m.setStatus("content required", false)
370 -
		}
371 -
		return m, saveSnippetCmd(m.backend, m.editShortID, name, content)
372 -
	case key.Matches(msg, m.keys.SwitchField):
373 -
		switch m.focus {
374 -
		case FocusCreateName:
375 -
			m.focus = FocusCreateContent
376 -
		case FocusCreateContent:
377 -
			m.focus = FocusCreateName
378 -
		case FocusEditName:
379 -
			m.focus = FocusEditContent
380 -
		case FocusEditContent:
381 -
			m.focus = FocusEditName
251 +
		if s, ok := m.list.Selected(); ok {
252 +
			return m, openURLCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID))
382 253
		}
383 -
		m.applyFormFocus()
254 +
		return m, nil
255 +
	case key.Matches(msg, m.keys.Help):
256 +
		m.showHelp = true
384 257
		return m, nil
385 258
	}
386 259
387 260
	var cmd tea.Cmd
388 -
	switch m.focus {
389 -
	case FocusCreateName, FocusEditName:
390 -
		m.nameInput, cmd = m.nameInput.Update(msg)
391 -
	case FocusCreateContent, FocusEditContent:
392 -
		m.contentArea, cmd = m.contentArea.Update(msg)
393 -
	}
261 +
	m.cont, cmd = m.cont.Update(msg)
394 262
	return m, cmd
395 263
}
396 264
397 -
func (m *Model) applyFormFocus() {
398 -
	switch m.focus {
399 -
	case FocusCreateName, FocusEditName:
400 -
		m.nameInput.Focus()
401 -
		m.contentArea.Blur()
402 -
	case FocusCreateContent, FocusEditContent:
403 -
		m.contentArea.Focus()
404 -
		m.nameInput.Blur()
265 +
func (m *Model) refreshContentFromSelection() {
266 +
	if s, ok := m.list.Selected(); ok {
267 +
		m.cont.SetSnippet(&s)
268 +
	} else {
269 +
		m.cont.SetSnippet(nil)
405 270
	}
406 271
}
407 272
408 -
func (m Model) keySearch(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
409 -
	switch msg.String() {
410 -
	case "esc":
411 -
		m.searchInput.SetValue("")
412 -
		m.searchInput.Blur()
413 -
		m.focus = FocusList
414 -
		m.applyFilter("")
415 -
		m.refreshPreview()
416 -
		return m, nil
417 -
	case "enter":
418 -
		m.searchInput.Blur()
419 -
		m.focus = FocusList
420 -
		return m, nil
273 +
func (m *Model) applyLayout() {
274 +
	if !m.ready {
275 +
		return
421 276
	}
422 -
	var cmd tea.Cmd
423 -
	m.searchInput, cmd = m.searchInput.Update(msg)
424 -
	m.applyFilter(m.searchInput.Value())
425 -
	m.refreshPreview()
426 -
	return m, cmd
277 +
	listW := m.width * 30 / 100
278 +
	if listW < 24 {
279 +
		listW = 24
280 +
	}
281 +
	contentW := m.width - listW - 2
282 +
	if contentW < 20 {
283 +
		contentW = 20
284 +
	}
285 +
	bodyH := m.height - 2
286 +
	if bodyH < 5 {
287 +
		bodyH = 5
288 +
	}
289 +
290 +
	m.list.SetSize(max(listW-paneFrameWidth(), 1), max(bodyH-paneFrameHeight(), 1))
291 +
	m.cont.SetSize(max(contentW-paneFrameWidth(), 1), max(bodyH-paneFrameHeight()-1, 1))
292 +
	m.form.SetSize(max(contentW-paneFrameWidth(), 1), max(bodyH-paneFrameHeight(), 1))
293 +
}
294 +
295 +
func (m *Model) setStatus(text string, ok bool) tea.Cmd {
296 +
	m.status = text
297 +
	m.statusOK = ok
298 +
	m.statusUntil = time.Now().Add(2 * time.Second)
299 +
	return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} })
427 300
}
apps/sipp-go/tui/view.go +64 −112
2 2
3 3
import (
4 4
	"fmt"
5 -
	"strings"
6 5
7 6
	tea "charm.land/bubbletea/v2"
8 7
	"charm.land/lipgloss/v2"
11 10
var (
12 11
	borderStyle = lipgloss.NewStyle().
13 12
			Border(lipgloss.RoundedBorder()).
14 -
			BorderForeground(lipgloss.Color("240"))
13 +
			BorderForeground(lipgloss.Color("8"))
15 14
	borderActive = lipgloss.NewStyle().
16 15
			Border(lipgloss.RoundedBorder()).
17 -
			BorderForeground(lipgloss.Color("214"))
16 +
			BorderForeground(lipgloss.Color("3"))
18 17
	titleStyle = lipgloss.NewStyle().
19 18
			Bold(true).
20 -
			Foreground(lipgloss.Color("214")).
19 +
			Foreground(lipgloss.Color("3")).
21 20
			Padding(0, 1)
22 -
	itemStyle    = lipgloss.NewStyle().Padding(0, 1)
23 -
	itemSelected = lipgloss.NewStyle().
24 -
			Padding(0, 1).
25 -
			Bold(true).
26 -
			Foreground(lipgloss.Color("214"))
27 -
	dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
28 -
	statusOK = lipgloss.NewStyle().
29 -
			Foreground(lipgloss.Color("82")).
21 +
	statusOKStyle = lipgloss.NewStyle().
22 +
			Foreground(lipgloss.Color("2")).
30 23
			Bold(true)
31 -
	statusErr = lipgloss.NewStyle().
32 -
			Foreground(lipgloss.Color("196")).
24 +
	statusErrStyle = lipgloss.NewStyle().
25 +
			Foreground(lipgloss.Color("1")).
33 26
			Bold(true)
34 27
	hintStyle = lipgloss.NewStyle().
35 -
			Foreground(lipgloss.Color("244"))
28 +
			Foreground(lipgloss.Color("8"))
36 29
	modalStyle = lipgloss.NewStyle().
37 30
			Border(lipgloss.RoundedBorder()).
38 -
			BorderForeground(lipgloss.Color("214")).
39 -
			Padding(1, 2).
40 -
			Background(lipgloss.Color("236"))
31 +
			BorderForeground(lipgloss.Color("3")).
32 +
			Padding(1, 2)
41 33
)
42 34
43 35
func (m Model) View() tea.View {
52 44
	contentW := m.width - listW - 2
53 45
	bodyH := m.height - 2
54 46
55 -
	left := m.renderList(listW, bodyH)
56 -
	right := m.renderRight(contentW, bodyH)
47 +
	left := m.renderListPane(listW, bodyH)
48 +
	right := m.renderRightPane(contentW, bodyH)
57 49
58 50
	body := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
59 51
	footer := m.renderFooter()
60 -
61 52
	base := lipgloss.JoinVertical(lipgloss.Left, body, footer)
62 53
63 54
	var overlays []*lipgloss.Layer
66 57
			modalStyle.Render(m.help.FullHelpView(m.keys.FullHelp())), 1))
67 58
	}
68 59
	if m.confirmDelete {
69 -
		s := m.current()
70 60
		name := ""
71 -
		if s != nil {
61 +
		if s, ok := m.list.Selected(); ok {
72 62
			name = s.Name
63 +
			if name == "" {
64 +
				name = s.ShortID
65 +
			}
73 66
		}
74 67
		overlays = append(overlays, centerLayer(m.width, m.height,
75 68
			modalStyle.Render(fmt.Sprintf("Delete %q?\n\ny / n", name)), 2))
76 69
	}
77 70
	if m.status != "" {
78 -
		st := statusOK
71 +
		st := statusOKStyle
79 72
		if !m.statusOK {
80 -
			st = statusErr
73 +
			st = statusErrStyle
81 74
		}
82 75
		overlays = append(overlays, bottomCenterLayer(m.width, m.height,
83 76
			modalStyle.Render(st.Render(m.status)), 3))
94 87
	return tea.View{Content: content, AltScreen: true}
95 88
}
96 89
97 -
func centerLayer(w, h int, content string, z int) *lipgloss.Layer {
98 -
	cw, ch := lipgloss.Width(content), lipgloss.Height(content)
99 -
	x := (w - cw) / 2
100 -
	y := (h - ch) / 2
101 -
	if x < 0 {
102 -
		x = 0
103 -
	}
104 -
	if y < 0 {
105 -
		y = 0
106 -
	}
107 -
	return lipgloss.NewLayer(content).X(x).Y(y).Z(z)
108 -
}
109 -
110 -
func bottomCenterLayer(w, h int, content string, z int) *lipgloss.Layer {
111 -
	cw, ch := lipgloss.Width(content), lipgloss.Height(content)
112 -
	x := (w - cw) / 2
113 -
	y := h - ch - 1
114 -
	if x < 0 {
115 -
		x = 0
116 -
	}
117 -
	if y < 0 {
118 -
		y = 0
119 -
	}
120 -
	return lipgloss.NewLayer(content).X(x).Y(y).Z(z)
121 -
}
122 -
123 -
func (m Model) renderList(w, h int) string {
90 +
func (m Model) renderListPane(w, h int) string {
124 91
	style := borderStyle
125 -
	if m.focus == FocusList || m.focus == FocusSearch {
92 +
	if m.state == stateList {
126 93
		style = borderActive
127 94
	}
128 -
129 -
	list := m.visible()
130 -
	rows := make([]string, 0, len(list)+2)
131 -
	rows = append(rows, titleStyle.Render("snippets"))
132 -
	if len(list) == 0 {
133 -
		rows = append(rows, hintStyle.Render("  (empty — press c)"))
134 -
	}
135 -
	for i, s := range list {
136 -
		label := s.Name
137 -
		if label == "" {
138 -
			label = s.ShortID
139 -
		}
140 -
		line := truncate(label, w-6)
141 -
		id := dimStyle.Render(" " + s.ShortID)
142 -
		if i == m.cursor {
143 -
			rows = append(rows, itemSelected.Render("▶ "+line)+id)
144 -
		} else {
145 -
			rows = append(rows, itemStyle.Render("  "+line)+id)
146 -
		}
147 -
	}
148 -
149 -
	if m.focus == FocusSearch || m.searchInput.Value() != "" {
150 -
		rows = append(rows, "", hintStyle.Render(m.searchInput.View()))
151 -
	}
152 -
153 -
	content := strings.Join(rows, "\n")
154 -
	return style.Width(w).Height(h).Render(content)
95 +
	return style.Width(w).Height(h).Render(m.list.View())
155 96
}
156 97
157 -
func (m Model) renderRight(w, h int) string {
158 -
	switch m.focus {
159 -
	case FocusCreateName, FocusCreateContent, FocusEditName, FocusEditContent:
98 +
func (m Model) renderRightPane(w, h int) string {
99 +
	if m.state == stateForm {
160 100
		return m.renderForm(w, h)
161 101
	}
162 102
	return m.renderContent(w, h)
164 104
165 105
func (m Model) renderContent(w, h int) string {
166 106
	style := borderStyle
167 -
	if m.focus == FocusContent {
107 +
	if m.state == stateContent {
168 108
		style = borderActive
169 109
	}
170 110
	header := "preview"
171 -
	if m.wrapContent {
111 +
	if m.cont.Wrap() {
172 112
		header += " (wrap)"
173 113
	}
174 -
	s := m.current()
175 -
	if s != nil {
176 -
		header = s.Name
177 -
		if header == "" {
178 -
			header = s.ShortID
114 +
	if t := m.cont.Header(); t != "" {
115 +
		header = t
116 +
		if m.cont.Wrap() {
117 +
			header += " (wrap)"
179 118
		}
180 119
	}
181 -
	body := m.contentVP.View()
182 -
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), body)
120 +
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), m.cont.View())
183 121
	return style.Width(w).Height(h).Render(inner)
184 122
}
185 123
186 124
func (m Model) renderForm(w, h int) string {
187 125
	header := "new snippet"
188 -
	if m.editShortID != "" {
126 +
	if !m.form.IsCreate() {
189 127
		header = "edit"
190 128
	}
191 -
	if m.wrapContent {
192 -
		header += " (wrap)"
193 -
	}
194 -
	name := m.nameInput.View()
195 -
	if m.focus == FocusCreateName || m.focus == FocusEditName {
196 -
		name = borderActive.Render(name)
129 +
130 +
	nameField := m.form.name.View()
131 +
	if m.form.ActiveField() == formFieldName {
132 +
		nameField = borderActive.Render(nameField)
197 133
	} else {
198 -
		name = borderStyle.Render(name)
134 +
		nameField = borderStyle.Render(nameField)
199 135
	}
200 136
201 -
	body := m.contentArea.View()
202 -
	if m.focus == FocusCreateContent || m.focus == FocusEditContent {
137 +
	body := m.form.content.View()
138 +
	if m.form.ActiveField() == formFieldContent {
203 139
		body = borderActive.Render(body)
204 140
	} else {
205 141
		body = borderStyle.Render(body)
206 142
	}
207 143
208 -
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), name, body)
209 -
	return borderStyle.Width(w).Height(h).Render(inner)
144 +
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), nameField, body)
145 +
	return borderActive.Width(w).Height(h).Render(inner)
210 146
}
211 147
212 148
func (m Model) renderFooter() string {
218 154
	return hintStyle.Render(fmt.Sprintf("[%s] %s", mode, help))
219 155
}
220 156
221 -
func truncate(s string, n int) string {
222 -
	if n < 1 {
223 -
		return ""
157 +
func centerLayer(w, h int, content string, z int) *lipgloss.Layer {
158 +
	cw, ch := lipgloss.Width(content), lipgloss.Height(content)
159 +
	x := (w - cw) / 2
160 +
	y := (h - ch) / 2
161 +
	if x < 0 {
162 +
		x = 0
224 163
	}
225 -
	if len(s) <= n {
226 -
		return s
164 +
	if y < 0 {
165 +
		y = 0
227 166
	}
228 -
	if n <= 1 {
229 -
		return "…"
167 +
	return lipgloss.NewLayer(content).X(x).Y(y).Z(z)
168 +
}
169 +
170 +
func bottomCenterLayer(w, h int, content string, z int) *lipgloss.Layer {
171 +
	cw, ch := lipgloss.Width(content), lipgloss.Height(content)
172 +
	x := (w - cw) / 2
173 +
	y := h - ch - 1
174 +
	if x < 0 {
175 +
		x = 0
176 +
	}
177 +
	if y < 0 {
178 +
		y = 0
230 179
	}
231 -
	return s[:n-1] + "…"
180 +
	return lipgloss.NewLayer(content).X(x).Y(y).Z(z)
232 181
}
182 +
183 +
func paneFrameWidth() int  { return borderStyle.GetHorizontalFrameSize() }
184 +
func paneFrameHeight() int { return borderStyle.GetVerticalFrameSize() }