• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

charmbracelet / glow / 14249847881

03 Apr 2025 06:10PM UTC coverage: 3.722% (-0.007%) from 3.729%
14249847881

Pull #735

github

andreynering
fix(lint): fix all linting issues
Pull Request #735: chore: do some basic repo maintenance (editorconfig, linting, etc)

0 of 86 new or added lines in 14 files covered. (0.0%)

1 existing line in 1 file now uncovered.

77 of 2069 relevant lines covered (3.72%)

0.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/ui/pager.go
1
package ui
2

3
import (
4
        "fmt"
5
        "math"
6
        "path/filepath"
7
        "strings"
8
        "time"
9

10
        "github.com/atotto/clipboard"
11
        "github.com/charmbracelet/bubbles/viewport"
12
        tea "github.com/charmbracelet/bubbletea"
13
        "github.com/charmbracelet/glamour"
14
        "github.com/charmbracelet/glow/v2/utils"
15
        "github.com/charmbracelet/lipgloss"
16
        "github.com/charmbracelet/log"
17
        "github.com/fsnotify/fsnotify"
18
        runewidth "github.com/mattn/go-runewidth"
19
        "github.com/muesli/reflow/ansi"
20
        "github.com/muesli/reflow/truncate"
21
        "github.com/muesli/termenv"
22
)
23

24
const (
25
        statusBarHeight = 1
26
        lineNumberWidth = 4
27
)
28

29
var (
30
        pagerHelpHeight int
31

32
        mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"}
33
        darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"}
34

35
        lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
36

37
        statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
38
        statusBarBg     = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"}
39

40
        statusBarScrollPosStyle = lipgloss.NewStyle().
41
                                Foreground(lipgloss.AdaptiveColor{Light: "#949494", Dark: "#5A5A5A"}).
42
                                Background(statusBarBg).
43
                                Render
44

45
        statusBarNoteStyle = lipgloss.NewStyle().
46
                                Foreground(statusBarNoteFg).
47
                                Background(statusBarBg).
48
                                Render
49

50
        statusBarHelpStyle = lipgloss.NewStyle().
51
                                Foreground(statusBarNoteFg).
52
                                Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}).
53
                                Render
54

55
        statusBarMessageStyle = lipgloss.NewStyle().
56
                                Foreground(mintGreen).
57
                                Background(darkGreen).
58
                                Render
59

60
        statusBarMessageScrollPosStyle = lipgloss.NewStyle().
61
                                        Foreground(mintGreen).
62
                                        Background(darkGreen).
63
                                        Render
64

65
        statusBarMessageHelpStyle = lipgloss.NewStyle().
66
                                        Foreground(lipgloss.Color("#B6FFE4")).
67
                                        Background(green).
68
                                        Render
69

70
        helpViewStyle = lipgloss.NewStyle().
71
                        Foreground(statusBarNoteFg).
72
                        Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}).
73
                        Render
74

75
        lineNumberStyle = lipgloss.NewStyle().
76
                        Foreground(lineNumberFg).
77
                        Render
78
)
79

80
type (
81
        contentRenderedMsg string
82
        reloadMsg          struct{}
83
)
84

85
type pagerState int
86

87
const (
88
        pagerStateBrowse pagerState = iota
89
        pagerStateStatusMessage
90
)
91

92
type pagerModel struct {
93
        common   *commonModel
94
        viewport viewport.Model
95
        state    pagerState
96
        showHelp bool
97

98
        statusMessage      string
99
        statusMessageTimer *time.Timer
100

101
        // Current document being rendered, sans-glamour rendering. We cache
102
        // it here so we can re-render it on resize.
103
        currentDocument markdown
104

105
        watcher *fsnotify.Watcher
106
}
107

108
func newPagerModel(common *commonModel) pagerModel {
×
109
        // Init viewport
×
110
        vp := viewport.New(0, 0)
×
111
        vp.YPosition = 0
×
112
        vp.HighPerformanceRendering = config.HighPerformancePager
×
113

×
114
        m := pagerModel{
×
115
                common:   common,
×
116
                state:    pagerStateBrowse,
×
117
                viewport: vp,
×
118
        }
×
119
        m.initWatcher()
×
120
        return m
×
121
}
×
122

123
func (m *pagerModel) setSize(w, h int) {
×
124
        m.viewport.Width = w
×
125
        m.viewport.Height = h - statusBarHeight
×
126

×
127
        if m.showHelp {
×
128
                if pagerHelpHeight == 0 {
×
129
                        pagerHelpHeight = strings.Count(m.helpView(), "\n")
×
130
                }
×
131
                m.viewport.Height -= (statusBarHeight + pagerHelpHeight)
×
132
        }
133
}
134

135
func (m *pagerModel) setContent(s string) {
×
136
        m.viewport.SetContent(s)
×
137
}
×
138

139
func (m *pagerModel) toggleHelp() {
×
140
        m.showHelp = !m.showHelp
×
141
        m.setSize(m.common.width, m.common.height)
×
142
        if m.viewport.PastBottom() {
×
143
                m.viewport.GotoBottom()
×
144
        }
×
145
}
146

147
type pagerStatusMessage struct {
148
        message string
149
        isError bool
150
}
151

152
// Perform stuff that needs to happen after a successful markdown stash. Note
153
// that the the returned command should be sent back the through the pager
154
// update function.
155
func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
×
156
        // Show a success message to the user
×
157
        m.state = pagerStateStatusMessage
×
158
        m.statusMessage = msg.message
×
159
        if m.statusMessageTimer != nil {
×
160
                m.statusMessageTimer.Stop()
×
161
        }
×
162
        m.statusMessageTimer = time.NewTimer(statusMessageTimeout)
×
163

×
164
        return waitForStatusMessageTimeout(pagerContext, m.statusMessageTimer)
×
165
}
166

167
func (m *pagerModel) unload() {
×
168
        log.Debug("unload")
×
169
        if m.showHelp {
×
170
                m.toggleHelp()
×
171
        }
×
172
        if m.statusMessageTimer != nil {
×
173
                m.statusMessageTimer.Stop()
×
174
        }
×
175
        m.state = pagerStateBrowse
×
176
        m.viewport.SetContent("")
×
177
        m.viewport.YOffset = 0
×
178
        m.unwatchFile()
×
179
}
180

181
func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
×
182
        var (
×
183
                cmd  tea.Cmd
×
184
                cmds []tea.Cmd
×
185
        )
×
186

×
187
        switch msg := msg.(type) {
×
188
        case tea.KeyMsg:
×
189
                switch msg.String() {
×
190
                case "q", keyEsc:
×
191
                        if m.state != pagerStateBrowse {
×
192
                                m.state = pagerStateBrowse
×
193
                                return m, nil
×
194
                        }
×
195
                case "home", "g":
×
196
                        m.viewport.GotoTop()
×
197
                        if m.viewport.HighPerformanceRendering {
×
198
                                cmds = append(cmds, viewport.Sync(m.viewport))
×
199
                        }
×
200
                case "end", "G":
×
201
                        m.viewport.GotoBottom()
×
202
                        if m.viewport.HighPerformanceRendering {
×
203
                                cmds = append(cmds, viewport.Sync(m.viewport))
×
204
                        }
×
205

206
                case "e":
×
207
                        lineno := int(math.RoundToEven(float64(m.viewport.TotalLineCount()) * m.viewport.ScrollPercent()))
×
208
                        if m.viewport.AtTop() {
×
209
                                lineno = 0
×
210
                        }
×
211
                        log.Info(
×
212
                                "opening editor",
×
213
                                "file", m.currentDocument.localPath,
×
214
                                "line", fmt.Sprintf("%d/%d", lineno, m.viewport.TotalLineCount()),
×
215
                        )
×
216
                        return m, openEditor(m.currentDocument.localPath, lineno)
×
217

218
                case "c":
×
219
                        // Copy using OSC 52
×
220
                        termenv.Copy(m.currentDocument.Body)
×
221
                        // Copy using native system clipboard
×
222
                        _ = clipboard.WriteAll(m.currentDocument.Body)
×
223
                        cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false}))
×
224

225
                case "r":
×
226
                        return m, loadLocalMarkdown(&m.currentDocument)
×
227

228
                case "?":
×
229
                        m.toggleHelp()
×
230
                        if m.viewport.HighPerformanceRendering {
×
231
                                cmds = append(cmds, viewport.Sync(m.viewport))
×
232
                        }
×
233
                }
234

235
        // Glow has rendered the content
236
        case contentRenderedMsg:
×
237
                log.Info("content rendered", "state", m.state)
×
238

×
239
                m.setContent(string(msg))
×
240
                if m.viewport.HighPerformanceRendering {
×
241
                        cmds = append(cmds, viewport.Sync(m.viewport))
×
242
                }
×
243
                cmds = append(cmds, m.watchFile)
×
244

245
        // The file was changed on disk and we're reloading it
246
        case reloadMsg:
×
247
                return m, loadLocalMarkdown(&m.currentDocument)
×
248

249
        // We've finished editing the document, potentially making changes. Let's
250
        // retrieve the latest version of the document so that we display
251
        // up-to-date contents.
252
        case editorFinishedMsg:
×
253
                return m, loadLocalMarkdown(&m.currentDocument)
×
254

255
        // We've received terminal dimensions, either for the first time or
256
        // after a resize
257
        case tea.WindowSizeMsg:
×
258
                return m, renderWithGlamour(m, m.currentDocument.Body)
×
259

260
        case statusMessageTimeoutMsg:
×
261
                m.state = pagerStateBrowse
×
262
        }
263

264
        m.viewport, cmd = m.viewport.Update(msg)
×
265
        cmds = append(cmds, cmd)
×
266

×
267
        return m, tea.Batch(cmds...)
×
268
}
269

270
func (m pagerModel) View() string {
×
271
        var b strings.Builder
×
272
        fmt.Fprint(&b, m.viewport.View()+"\n")
×
273

×
274
        // Footer
×
275
        m.statusBarView(&b)
×
276

×
277
        if m.showHelp {
×
278
                fmt.Fprint(&b, "\n"+m.helpView())
×
279
        }
×
280

281
        return b.String()
×
282
}
283

284
func (m pagerModel) statusBarView(b *strings.Builder) {
×
285
        const (
×
286
                minPercent               float64 = 0.0
×
287
                maxPercent               float64 = 1.0
×
288
                percentToStringMagnitude float64 = 100.0
×
289
        )
×
290

×
291
        showStatusMessage := m.state == pagerStateStatusMessage
×
292

×
293
        // Logo
×
294
        logo := glowLogoView()
×
295

×
296
        // Scroll percent
×
297
        percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
×
298
        scrollPercent := fmt.Sprintf(" %3.f%% ", percent*percentToStringMagnitude)
×
299
        if showStatusMessage {
×
300
                scrollPercent = statusBarMessageScrollPosStyle(scrollPercent)
×
301
        } else {
×
302
                scrollPercent = statusBarScrollPosStyle(scrollPercent)
×
303
        }
×
304

305
        // "Help" note
306
        var helpNote string
×
307
        if showStatusMessage {
×
308
                helpNote = statusBarMessageHelpStyle(" ? Help ")
×
309
        } else {
×
310
                helpNote = statusBarHelpStyle(" ? Help ")
×
311
        }
×
312

313
        // Note
314
        var note string
×
315
        if showStatusMessage {
×
316
                note = m.statusMessage
×
317
        } else {
×
318
                note = m.currentDocument.Note
×
319
        }
×
NEW
320
        note = truncate.StringWithTail(" "+note+" ", uint(max(0, //nolint:gosec
×
321
                m.common.width-
×
322
                        ansi.PrintableRuneWidth(logo)-
×
323
                        ansi.PrintableRuneWidth(scrollPercent)-
×
324
                        ansi.PrintableRuneWidth(helpNote),
×
325
        )), ellipsis)
×
326
        if showStatusMessage {
×
327
                note = statusBarMessageStyle(note)
×
328
        } else {
×
329
                note = statusBarNoteStyle(note)
×
330
        }
×
331

332
        // Empty space
333
        padding := max(0,
×
334
                m.common.width-
×
335
                        ansi.PrintableRuneWidth(logo)-
×
336
                        ansi.PrintableRuneWidth(note)-
×
337
                        ansi.PrintableRuneWidth(scrollPercent)-
×
338
                        ansi.PrintableRuneWidth(helpNote),
×
339
        )
×
340
        emptySpace := strings.Repeat(" ", padding)
×
341
        if showStatusMessage {
×
342
                emptySpace = statusBarMessageStyle(emptySpace)
×
343
        } else {
×
344
                emptySpace = statusBarNoteStyle(emptySpace)
×
345
        }
×
346

347
        fmt.Fprintf(b, "%s%s%s%s%s",
×
348
                logo,
×
349
                note,
×
350
                emptySpace,
×
351
                scrollPercent,
×
352
                helpNote,
×
353
        )
×
354
}
355

356
func (m pagerModel) helpView() (s string) {
×
357
        col1 := []string{
×
358
                "g/home  go to top",
×
359
                "G/end   go to bottom",
×
360
                "c       copy contents",
×
361
                "e       edit this document",
×
362
                "r       reload this document",
×
363
                "esc     back to files",
×
364
                "q       quit",
×
365
        }
×
366

×
367
        s += "\n"
×
368
        s += "k/↑      up                  " + col1[0] + "\n"
×
369
        s += "j/↓      down                " + col1[1] + "\n"
×
370
        s += "b/pgup   page up             " + col1[2] + "\n"
×
371
        s += "f/pgdn   page down           " + col1[3] + "\n"
×
372
        s += "u        ½ page up           " + col1[4] + "\n"
×
373
        s += "d        ½ page down         "
×
374

×
375
        if len(col1) > 5 {
×
376
                s += col1[5]
×
377
        }
×
378

379
        s = indent(s, 2)
×
380

×
381
        // Fill up empty cells with spaces for background coloring
×
382
        if m.common.width > 0 {
×
383
                lines := strings.Split(s, "\n")
×
384
                for i := 0; i < len(lines); i++ {
×
385
                        l := runewidth.StringWidth(lines[i])
×
386
                        n := max(m.common.width-l, 0)
×
387
                        lines[i] += strings.Repeat(" ", n)
×
388
                }
×
389

390
                s = strings.Join(lines, "\n")
×
391
        }
392

393
        return helpViewStyle(s)
×
394
}
395

396
// COMMANDS
397

398
func renderWithGlamour(m pagerModel, md string) tea.Cmd {
×
399
        return func() tea.Msg {
×
400
                s, err := glamourRender(m, md)
×
401
                if err != nil {
×
402
                        log.Error("error rendering with Glamour", "error", err)
×
403
                        return errMsg{err}
×
404
                }
×
405
                return contentRenderedMsg(s)
×
406
        }
407
}
408

409
// This is where the magic happens.
410
func glamourRender(m pagerModel, markdown string) (string, error) {
×
411
        trunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render
×
412

×
413
        if !config.GlamourEnabled {
×
414
                return markdown, nil
×
415
        }
×
416

417
        isCode := !utils.IsMarkdownFile(m.currentDocument.Note)
×
NEW
418
        width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width)) //nolint:gosec
×
419
        if isCode {
×
420
                width = 0
×
421
        }
×
422

423
        options := []glamour.TermRendererOption{
×
424
                utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode),
×
425
                glamour.WithWordWrap(width),
×
426
        }
×
427

×
428
        if m.common.cfg.PreserveNewLines {
×
429
                options = append(options, glamour.WithPreservedNewLines())
×
430
        }
×
431
        r, err := glamour.NewTermRenderer(options...)
×
432
        if err != nil {
×
NEW
433
                return "", fmt.Errorf("error creating glamour renderer: %w", err)
×
434
        }
×
435

436
        if isCode {
×
437
                markdown = utils.WrapCodeBlock(markdown, filepath.Ext(m.currentDocument.Note))
×
438
        }
×
439

440
        out, err := r.Render(markdown)
×
441
        if err != nil {
×
NEW
442
                return "", fmt.Errorf("error rendering markdown: %w", err)
×
443
        }
×
444

445
        if isCode {
×
446
                out = strings.TrimSpace(out)
×
447
        }
×
448

449
        // trim lines
450
        lines := strings.Split(out, "\n")
×
451

×
452
        var content strings.Builder
×
453
        for i, s := range lines {
×
454
                if isCode || m.common.cfg.ShowLineNumbers {
×
455
                        content.WriteString(lineNumberStyle(fmt.Sprintf("%"+fmt.Sprint(lineNumberWidth)+"d", i+1)))
×
456
                        content.WriteString(trunc(s))
×
457
                } else {
×
458
                        content.WriteString(s)
×
459
                }
×
460

461
                // don't add an artificial newline after the last split
462
                if i+1 < len(lines) {
×
463
                        content.WriteRune('\n')
×
464
                }
×
465
        }
466

467
        return content.String(), nil
×
468
}
469

470
func (m *pagerModel) initWatcher() {
×
471
        var err error
×
472
        m.watcher, err = fsnotify.NewWatcher()
×
473
        if err != nil {
×
474
                log.Error("error creating fsnotify watcher", "error", err)
×
475
        }
×
476
}
477

478
func (m *pagerModel) watchFile() tea.Msg {
×
479
        dir := m.localDir()
×
480

×
481
        if err := m.watcher.Add(dir); err != nil {
×
482
                log.Error("error adding dir to fsnotify watcher", "error", err)
×
483
                return nil
×
484
        }
×
485

486
        log.Info("fsnotify watching dir", "dir", dir)
×
487

×
488
        for {
×
489
                select {
×
490
                case event, ok := <-m.watcher.Events:
×
491
                        if !ok || event.Name != m.currentDocument.localPath {
×
492
                                continue
×
493
                        }
494

495
                        if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
×
496
                                continue
×
497
                        }
498

499
                        log.Debug("fsnotify event", "file", event.Name, "event", event.Op)
×
500
                        return reloadMsg{}
×
501
                case err, ok := <-m.watcher.Errors:
×
502
                        if !ok {
×
503
                                continue
×
504
                        }
505
                        log.Debug("fsnotify error", "dir", dir, "error", err)
×
506
                }
507
        }
508
}
509

510
func (m *pagerModel) unwatchFile() {
×
511
        dir := m.localDir()
×
512

×
513
        err := m.watcher.Remove(dir)
×
514
        if err == nil {
×
515
                log.Debug("fsnotify dir unwatched", "dir", dir)
×
516
        } else {
×
517
                log.Error("fsnotify fail to unwatch dir", "dir", dir, "error", err)
×
518
        }
×
519
}
520

521
func (m *pagerModel) localDir() string {
×
522
        return filepath.Dir(m.currentDocument.localPath)
×
523
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc