@@ -2,13 +2,100 @@ package pager
2
2
3
3
import (
4
4
"fmt"
5
+ "math"
6
+ "regexp"
5
7
"strings"
8
+ "time"
6
9
10
+ "github.com/atotto/clipboard"
7
11
"github.com/charmbracelet/bubbles/viewport"
8
12
tea "github.com/charmbracelet/bubbletea"
9
13
"github.com/charmbracelet/lipgloss"
14
+ "github.com/mattn/go-runewidth"
15
+ "github.com/muesli/ansi"
16
+ "github.com/muesli/reflow/truncate"
17
+ "github.com/muesli/termenv"
10
18
)
11
19
20
+ const (
21
+ minPercent float64 = 0.0
22
+ maxPercent float64 = 1.0
23
+ percentToStringMagnitude float64 = 100.0
24
+
25
+ statusMessageTimeout = time .Second * 3 // how long to show status messages like "stashed!"
26
+ ellipsis = "…"
27
+ statusBarHeight = 1
28
+ )
29
+
30
+ type pagerState int
31
+
32
+ const (
33
+ pagerStateBrowse pagerState = iota
34
+ pagerStateStatusMessage
35
+ )
36
+
37
+ type pagerStatusMessage struct {
38
+ message string
39
+ isError bool
40
+ }
41
+
42
+ var (
43
+ pagerHelpHeight int
44
+
45
+ // Logo
46
+ logo = statusBarHelpStyle (" \U0001F47D " )
47
+
48
+ mintGreen = lipgloss.AdaptiveColor {Light : "#89F0CB" , Dark : "#89F0CB" }
49
+ darkGreen = lipgloss.AdaptiveColor {Light : "#1C8760" , Dark : "#1C8760" }
50
+
51
+ lineNumberFg = lipgloss.AdaptiveColor {Light : "#656565" , Dark : "#7D7D7D" }
52
+
53
+ statusBarNoteFg = lipgloss.AdaptiveColor {Light : "#656565" , Dark : "#7D7D7D" }
54
+ statusBarBg = lipgloss.AdaptiveColor {Light : "#E6E6E6" , Dark : "#242424" }
55
+
56
+ statusBarNoteStyle = lipgloss .NewStyle ().
57
+ Foreground (statusBarNoteFg ).
58
+ Background (statusBarBg ).
59
+ Render
60
+
61
+ statusBarHelpStyle = lipgloss .NewStyle ().
62
+ Foreground (statusBarNoteFg ).
63
+ Background (lipgloss.AdaptiveColor {Light : "#DCDCDC" , Dark : "#323232" }).
64
+ Render
65
+
66
+ statusBarMessageStyle = lipgloss .NewStyle ().
67
+ Foreground (mintGreen ).
68
+ Background (darkGreen ).
69
+ Render
70
+
71
+ statusBarMessageScrollPosStyle = lipgloss .NewStyle ().
72
+ Foreground (mintGreen ).
73
+ Background (darkGreen ).
74
+ Render
75
+
76
+ statusBarMessageHelpStyle = lipgloss .NewStyle ().
77
+ Foreground (lipgloss .Color ("#B6FFE4" )).
78
+ Background (green ).
79
+ Render
80
+
81
+ helpViewStyle = lipgloss .NewStyle ().
82
+ Foreground (statusBarNoteFg ).
83
+ Background (lipgloss.AdaptiveColor {Light : "#f2f2f2" , Dark : "#1B1B1B" }).
84
+ Render
85
+
86
+ lineNumberStyle = lipgloss .NewStyle ().
87
+ Foreground (lineNumberFg ).
88
+ Render
89
+ green = lipgloss .Color ("#04B575" )
90
+ )
91
+
92
+ // Common stuff we'll need to access in all models.
93
+ type commonModel struct {
94
+ cwd string
95
+ width int
96
+ height int
97
+ }
98
+
12
99
var (
13
100
titleStyle = func () lipgloss.Style {
14
101
b := lipgloss .RoundedBorder ()
@@ -28,12 +115,25 @@ type model struct {
28
115
content string
29
116
ready bool
30
117
viewport viewport.Model
118
+ common commonModel
119
+ showHelp bool
120
+
121
+ state pagerState
122
+ statusMessage string
123
+ statusMessageTimer * time.Timer
31
124
}
32
125
33
126
func (m * model ) Init () tea.Cmd {
34
127
return nil
35
128
}
36
129
130
+ var ansiRegex = regexp .MustCompile (`\x1b\[[0-9;?]*[ -/]*[@-~]` )
131
+
132
+ // StripANSI removes ANSI escape sequences (like terminal colors) from input.
133
+ func StripANSI (input string ) string {
134
+ return ansiRegex .ReplaceAllString (input , "" )
135
+ }
136
+
37
137
func (m * model ) Update (msg tea.Msg ) (tea.Model , tea.Cmd ) {
38
138
var (
39
139
cmd tea.Cmd
@@ -42,14 +142,33 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
42
142
43
143
switch msg := msg .(type ) {
44
144
case tea.KeyMsg :
45
- if k := msg .String (); k == "ctrl+c" || k == "q" || k == "esc" {
145
+ switch msg .String () {
146
+ case "q" , "esc" , "ctrl+c" :
46
147
return m , tea .Quit
148
+ case "home" , "g" :
149
+ m .viewport .GotoTop ()
150
+ case "end" , "G" :
151
+ m .viewport .GotoBottom ()
152
+ case "c" :
153
+ // Copy using OSC 52
154
+ termenv .Copy (StripANSI (m .content ))
155
+ // Copy using native system clipboard
156
+ _ = clipboard .WriteAll (StripANSI (m .content ))
157
+ cmds = append (cmds , m .showStatusMessage (pagerStatusMessage {"Copied contents" , false }))
158
+
159
+ case "?" :
160
+ m .toggleHelp ()
47
161
}
48
162
163
+ case statusMessageTimeoutMsg :
164
+ m .state = pagerStateBrowse
165
+ // Window size is received when starting up and on every resize
49
166
case tea.WindowSizeMsg :
50
- headerHeight := lipgloss .Height (m .headerView ())
167
+ m .common .width = msg .Width
168
+ m .common .height = msg .Height
169
+
51
170
footerHeight := lipgloss .Height (m .footerView ())
52
- verticalMarginHeight := headerHeight + footerHeight
171
+ verticalMarginHeight := footerHeight
53
172
54
173
if ! m .ready {
55
174
// Since this program is using the full size of the viewport we
@@ -58,7 +177,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
58
177
// quickly, though asynchronously, which is why we wait for them
59
178
// here.
60
179
m .viewport = viewport .New (msg .Width , msg .Height - verticalMarginHeight )
61
- m .viewport .YPosition = headerHeight
180
+ m .viewport .YPosition = 0
62
181
m .viewport .SetContent (m .content )
63
182
m .ready = true
64
183
} else {
@@ -74,23 +193,104 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
74
193
return m , tea .Batch (cmds ... )
75
194
}
76
195
196
+ type statusMessageTimeoutMsg applicationContext
197
+
198
+ // applicationContext indicates the area of the application something applies
199
+ // to. Occasionally used as an argument to commands and messages.
200
+ type applicationContext int
201
+
202
+ const (
203
+ stashContext applicationContext = iota
204
+ pagerContext
205
+ )
206
+
207
+ // Perform stuff that needs to happen after a successful markdown stash. Note
208
+ // that the the returned command should be sent back the through the pager
209
+ // update function.
210
+ func (m * model ) showStatusMessage (msg pagerStatusMessage ) tea.Cmd {
211
+ // Show a success message to the user
212
+ m .state = pagerStateStatusMessage
213
+ m .statusMessage = msg .message
214
+ if m .statusMessageTimer != nil {
215
+ m .statusMessageTimer .Stop ()
216
+ }
217
+ m .statusMessageTimer = time .NewTimer (statusMessageTimeout )
218
+
219
+ return waitForStatusMessageTimeout (pagerContext , m .statusMessageTimer )
220
+ }
221
+
222
+ func waitForStatusMessageTimeout (appCtx applicationContext , t * time.Timer ) tea.Cmd {
223
+ return func () tea.Msg {
224
+ <- t .C
225
+ return statusMessageTimeoutMsg (appCtx )
226
+ }
227
+ }
228
+
229
+ func (m * model ) toggleHelp () {
230
+ m .showHelp = ! m .showHelp
231
+ m .setSize (m .common .width , m .common .height )
232
+ if m .viewport .PastBottom () {
233
+ m .viewport .GotoBottom ()
234
+ }
235
+ }
236
+
237
+ func (m * model ) setSize (w , h int ) {
238
+ m .viewport .Width = w
239
+ m .viewport .Height = h - statusBarHeight
240
+
241
+ if m .showHelp {
242
+ if pagerHelpHeight == 0 {
243
+ pagerHelpHeight = strings .Count (m .helpView (), "\n " )
244
+ }
245
+ m .viewport .Height -= (statusBarHeight + pagerHelpHeight )
246
+ }
247
+ }
248
+
77
249
func (m * model ) View () string {
78
250
if ! m .ready {
79
251
return "\n Initializing..."
80
252
}
81
- return fmt .Sprintf ("%s\n %s\n %s" , m . headerView () , m .viewport .View (), m .footerView ())
253
+ return fmt .Sprintf ("%s\n %s" , m .viewport .View (), m .footerView ())
82
254
}
83
255
84
- func (m * model ) headerView () string {
85
- title := titleStyle .Render (m .title )
86
- line := strings .Repeat ("─" , max (0 , m .viewport .Width - lipgloss .Width (title )))
87
- return lipgloss .JoinHorizontal (lipgloss .Center , title , line )
256
+ func (m model ) helpView () (s string ) {
257
+ col1 := []string {
258
+ "g/home go to top" ,
259
+ "G/end go to bottom" ,
260
+ "c copy contents" ,
261
+ "esc back to files" ,
262
+ "q quit" ,
263
+ }
264
+
265
+ s += "\n "
266
+ s += "k/↑ up " + col1 [0 ] + "\n "
267
+ s += "j/↓ down " + col1 [1 ] + "\n "
268
+ s += "b/pgup page up " + col1 [2 ] + "\n "
269
+ s += "f/pgdn page down " + col1 [3 ] + "\n "
270
+ s += "u ½ page up " + col1 [4 ] + "\n "
271
+ s += "d ½ page down "
272
+
273
+ s = indent (s , 2 )
274
+
275
+ // Fill up empty cells with spaces for background coloring
276
+ if m .common .width > 0 {
277
+ lines := strings .Split (s , "\n " )
278
+ for i := 0 ; i < len (lines ); i ++ {
279
+ l := runewidth .StringWidth (lines [i ])
280
+ n := max (m .common .width - l , 0 )
281
+ lines [i ] += strings .Repeat (" " , n )
282
+ }
283
+
284
+ s = strings .Join (lines , "\n " )
285
+ }
286
+
287
+ return helpViewStyle (s )
88
288
}
89
289
90
290
func (m * model ) footerView () string {
91
- info := infoStyle . Render ( fmt . Sprintf ( "%3.f%%" , m . viewport . ScrollPercent () * 100 ))
92
- line := strings . Repeat ( "─" , max ( 0 , m . viewport . Width - lipgloss . Width ( info )) )
93
- return lipgloss . JoinHorizontal ( lipgloss . Center , line , info )
291
+ b := & strings. Builder {}
292
+ m . statusBarView ( b )
293
+ return b . String ( )
94
294
}
95
295
96
296
func max (a , b int ) int {
@@ -99,3 +299,84 @@ func max(a, b int) int {
99
299
}
100
300
return b
101
301
}
302
+
303
+ func (m model ) statusBarView (b * strings.Builder ) {
304
+
305
+ showStatusMessage := m .state == pagerStateStatusMessage
306
+
307
+ // Scroll percent
308
+ percent := math .Max (minPercent , math .Min (maxPercent , m .viewport .ScrollPercent ()))
309
+ scrollPercent := fmt .Sprintf (" %3.f%% " , percent * percentToStringMagnitude )
310
+ if showStatusMessage {
311
+ scrollPercent = statusBarMessageScrollPosStyle (scrollPercent )
312
+ } else {
313
+ scrollPercent = statusBarNoteStyle (scrollPercent )
314
+ }
315
+
316
+ // Note
317
+ var note string
318
+ if showStatusMessage {
319
+ note = m .statusMessage
320
+ } else {
321
+ note = m .title
322
+ }
323
+ note = truncate .StringWithTail (" " + note + " " , uint (max (0 , //nolint:gosec
324
+ m .common .width -
325
+ ansi .PrintableRuneWidth (logo )-
326
+ ansi .PrintableRuneWidth (scrollPercent ),
327
+ )), ellipsis )
328
+
329
+ if showStatusMessage {
330
+ note = statusBarMessageStyle (note )
331
+ } else {
332
+ note = statusBarNoteStyle (note )
333
+ }
334
+
335
+ // "Help" note
336
+ var helpNote string
337
+ if showStatusMessage {
338
+ helpNote = statusBarMessageHelpStyle (" ? Help " )
339
+ } else {
340
+ helpNote = statusBarHelpStyle (" ? Help " )
341
+ }
342
+
343
+ // Empty space
344
+ padding := max (0 ,
345
+ m .common .width -
346
+ ansi .PrintableRuneWidth (logo )-
347
+ ansi .PrintableRuneWidth (note )-
348
+ ansi .PrintableRuneWidth (scrollPercent )-
349
+ ansi .PrintableRuneWidth (helpNote ),
350
+ )
351
+ emptySpace := strings .Repeat (" " , padding )
352
+ if showStatusMessage {
353
+ emptySpace = statusBarMessageStyle (emptySpace )
354
+ } else {
355
+ emptySpace = statusBarNoteStyle (emptySpace )
356
+ }
357
+
358
+ fmt .Fprintf (b , "%s%s%s%s%s" ,
359
+ logo ,
360
+ note ,
361
+ emptySpace ,
362
+ scrollPercent ,
363
+ helpNote ,
364
+ )
365
+ if m .showHelp {
366
+ fmt .Fprint (b , "\n " + m .helpView ())
367
+ }
368
+ }
369
+
370
+ // Lightweight version of reflow's indent function.?
371
+ func indent (s string , n int ) string {
372
+ if n <= 0 || s == "" {
373
+ return s
374
+ }
375
+ l := strings .Split (s , "\n " )
376
+ b := strings.Builder {}
377
+ i := strings .Repeat (" " , n )
378
+ for _ , v := range l {
379
+ fmt .Fprintf (& b , "%s%s\n " , i , v )
380
+ }
381
+ return b .String ()
382
+ }
0 commit comments