cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm leaflet readability golang
at main 284 lines 6.8 kB view raw
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}