cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm
leaflet
readability
golang
1package ui
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "unicode/utf8"
9
10 "github.com/charmbracelet/bubbles/help"
11 "github.com/charmbracelet/bubbles/key"
12 "github.com/charmbracelet/bubbles/viewport"
13 tea "github.com/charmbracelet/bubbletea"
14 "github.com/charmbracelet/glamour"
15 "github.com/charmbracelet/lipgloss"
16 "github.com/stormlightlabs/noteleaf/internal/models"
17)
18
19// PublicationViewOptions configures the publication view UI behavior
20type PublicationViewOptions struct {
21 Output io.Writer
22 Input io.Reader
23 Static bool
24 Width int
25 Height int
26}
27
28// PublicationView handles publication viewing UI with pager
29type PublicationView struct {
30 note *models.Note
31 opts PublicationViewOptions
32}
33
34// NewPublicationView creates a new publication view UI component
35func NewPublicationView(note *models.Note, opts PublicationViewOptions) *PublicationView {
36 if opts.Output == nil {
37 opts.Output = os.Stdout
38 }
39 if opts.Input == nil {
40 opts.Input = os.Stdin
41 }
42 if opts.Width == 0 {
43 opts.Width = 80
44 }
45 if opts.Height == 0 {
46 opts.Height = 24
47 }
48 return &PublicationView{note: note, opts: opts}
49}
50
51// Publication view specific key bindings
52type publicationViewKeyMap struct {
53 Up key.Binding
54 Down key.Binding
55 PageUp key.Binding
56 PageDown key.Binding
57 Top key.Binding
58 Bottom key.Binding
59 Quit key.Binding
60 Back key.Binding
61 Help key.Binding
62}
63
64func (k publicationViewKeyMap) ShortHelp() []key.Binding {
65 return []key.Binding{k.Up, k.Down, k.Back, k.Help, k.Quit}
66}
67
68func (k publicationViewKeyMap) FullHelp() [][]key.Binding {
69 return [][]key.Binding{
70 {k.Up, k.Down, k.PageUp, k.PageDown},
71 {k.Top, k.Bottom},
72 {k.Help, k.Back, k.Quit},
73 }
74}
75
76var publicationViewKeys = publicationViewKeyMap{
77 Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "scroll up")),
78 Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "scroll down")),
79 PageUp: key.NewBinding(key.WithKeys("pgup", "b"), key.WithHelp("pgup/b", "page up")),
80 PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("pgdown/f", "page down")),
81 Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home/g", "go to top")),
82 Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "go to bottom")),
83 Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
84 Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")),
85 Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
86}
87
88type publicationViewModel struct {
89 note *models.Note
90 viewport viewport.Model
91 keys publicationViewKeyMap
92 help help.Model
93 showingHelp bool
94 opts PublicationViewOptions
95 ready bool
96}
97
98func (m publicationViewModel) Init() tea.Cmd {
99 return nil
100}
101
102func (m publicationViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103 var cmd tea.Cmd
104
105 switch msg := msg.(type) {
106 case tea.KeyMsg:
107 if m.showingHelp {
108 switch {
109 case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help):
110 m.showingHelp = false
111 return m, nil
112 }
113 return m, nil
114 }
115
116 switch {
117 case key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Back):
118 return m, tea.Quit
119 case key.Matches(msg, m.keys.Help):
120 m.showingHelp = true
121 return m, nil
122 case key.Matches(msg, m.keys.Up):
123 m.viewport.ScrollUp(1)
124 case key.Matches(msg, m.keys.Down):
125 m.viewport.ScrollDown(1)
126 case key.Matches(msg, m.keys.PageUp):
127 m.viewport.HalfPageUp()
128 case key.Matches(msg, m.keys.PageDown):
129 m.viewport.HalfPageDown()
130 case key.Matches(msg, m.keys.Top):
131 m.viewport.GotoTop()
132 case key.Matches(msg, m.keys.Bottom):
133 m.viewport.GotoBottom()
134 }
135
136 case tea.WindowSizeMsg:
137 headerHeight := 3
138 footerHeight := 3
139 verticalMarginHeight := headerHeight + footerHeight
140
141 if !m.opts.Static {
142 m.viewport.Width = msg.Width - 2
143 m.viewport.Height = msg.Height - verticalMarginHeight
144 }
145
146 if !m.ready {
147 m.ready = true
148 }
149 }
150
151 m.viewport, cmd = m.viewport.Update(msg)
152 return m, cmd
153}
154
155func (m publicationViewModel) View() string {
156 if m.showingHelp {
157 return m.help.View(m.keys)
158 }
159
160 status := "published"
161 if m.note.IsDraft {
162 status = "draft"
163 }
164
165 title := TableTitleStyle.Render(fmt.Sprintf("%s (%s)", m.note.Title, status))
166 content := m.viewport.View()
167 help := MutedStyle.Render(m.help.View(m.keys))
168
169 if !m.ready {
170 return "\n Initializing..."
171 }
172
173 return lipgloss.JoinVertical(lipgloss.Left, title, "", content, "", help)
174}
175
176// formatPublicationContent renders markdown with glamour for viewport display
177func formatPublicationContent(note *models.Note) (string, error) {
178 markdown := buildPublicationMarkdown(note)
179
180 renderer, err := glamour.NewTermRenderer(
181 glamour.WithAutoStyle(),
182 glamour.WithStandardStyle("tokyo-night"),
183 glamour.WithPreservedNewLines(),
184 glamour.WithWordWrap(79),
185 )
186 if err != nil {
187 return markdown, fmt.Errorf("failed to create renderer: %w", err)
188 }
189
190 rendered, err := renderer.Render(markdown)
191 if err != nil {
192 return markdown, fmt.Errorf("failed to render markdown: %w", err)
193 }
194
195 return rendered, nil
196}
197
198// Show displays the publication in interactive mode with pager
199func (pv *PublicationView) Show(ctx context.Context) error {
200 if pv.opts.Static {
201 return pv.staticShow(ctx)
202 }
203
204 content, err := formatPublicationContent(pv.note)
205 if err != nil {
206 return err
207 }
208
209 vp := viewport.New(pv.opts.Width-2, pv.opts.Height-6)
210 vp.SetContent(content)
211
212 model := publicationViewModel{
213 note: pv.note,
214 viewport: vp,
215 keys: publicationViewKeys,
216 help: help.New(),
217 opts: pv.opts,
218 }
219
220 program := tea.NewProgram(
221 model,
222 tea.WithInput(pv.opts.Input),
223 tea.WithOutput(pv.opts.Output),
224 tea.WithAltScreen(),
225 tea.WithMouseCellMotion(),
226 )
227
228 _, err = program.Run()
229 return err
230}
231
232func (pv *PublicationView) staticShow(context.Context) error {
233 content, err := formatPublicationContent(pv.note)
234 if err != nil {
235 return err
236 }
237
238 fmt.Fprint(pv.opts.Output, content)
239 return nil
240}
241
242// ObfuscateMiddle returns a string where the middle portion is replaced by "..."
243// TODO: move to package utils or shared
244func ObfuscateMiddle(s string, left, right int) string {
245 if s == "" {
246 return s
247 }
248 if left < 0 {
249 left = 0
250 }
251 if right < 0 {
252 right = 0
253 }
254
255 n := utf8.RuneCountInString(s)
256 if left+right >= n {
257 return s
258 }
259
260 var (
261 prefixRunes = make([]rune, 0, left)
262 suffixRunes = make([]rune, 0, right)
263 )
264 i := 0
265 for _, r := range s {
266 if i >= left {
267 break
268 }
269 prefixRunes = append(prefixRunes, r)
270 i++
271 }
272
273 if right > 0 {
274 allRunes := []rune(s)
275 start := max(n-right, 0)
276 suffixRunes = append(suffixRunes, allRunes[start:]...)
277 }
278
279 const repl = "..."
280 if right == 0 {
281 return string(prefixRunes) + repl
282 }
283 return string(prefixRunes) + repl + string(suffixRunes)
284}