Skip to content

Commit f557e06

Browse files
committed
implement status with help
1 parent 7de12fb commit f557e06

File tree

4 files changed

+411
-27
lines changed

4 files changed

+411
-27
lines changed

pkg/pager/err.go

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package pager
2+
3+
type errMsg struct{ err error }
4+
5+
func (e errMsg) Error() string { return e.err.Error() }

pkg/pager/model.go

+293-12
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,100 @@ package pager
22

33
import (
44
"fmt"
5+
"math"
6+
"regexp"
57
"strings"
8+
"time"
69

10+
"github.com/atotto/clipboard"
711
"github.com/charmbracelet/bubbles/viewport"
812
tea "github.com/charmbracelet/bubbletea"
913
"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"
1018
)
1119

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+
1299
var (
13100
titleStyle = func() lipgloss.Style {
14101
b := lipgloss.RoundedBorder()
@@ -28,12 +115,25 @@ type model struct {
28115
content string
29116
ready bool
30117
viewport viewport.Model
118+
common commonModel
119+
showHelp bool
120+
121+
state pagerState
122+
statusMessage string
123+
statusMessageTimer *time.Timer
31124
}
32125

33126
func (m *model) Init() tea.Cmd {
34127
return nil
35128
}
36129

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+
37137
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
38138
var (
39139
cmd tea.Cmd
@@ -42,14 +142,33 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
42142

43143
switch msg := msg.(type) {
44144
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":
46147
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()
47161
}
48162

163+
case statusMessageTimeoutMsg:
164+
m.state = pagerStateBrowse
165+
// Window size is received when starting up and on every resize
49166
case tea.WindowSizeMsg:
50-
headerHeight := lipgloss.Height(m.headerView())
167+
m.common.width = msg.Width
168+
m.common.height = msg.Height
169+
51170
footerHeight := lipgloss.Height(m.footerView())
52-
verticalMarginHeight := headerHeight + footerHeight
171+
verticalMarginHeight := footerHeight
53172

54173
if !m.ready {
55174
// 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) {
58177
// quickly, though asynchronously, which is why we wait for them
59178
// here.
60179
m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight)
61-
m.viewport.YPosition = headerHeight
180+
m.viewport.YPosition = 0
62181
m.viewport.SetContent(m.content)
63182
m.ready = true
64183
} else {
@@ -74,23 +193,104 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
74193
return m, tea.Batch(cmds...)
75194
}
76195

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+
77249
func (m *model) View() string {
78250
if !m.ready {
79251
return "\n Initializing..."
80252
}
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())
82254
}
83255

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)
88288
}
89289

90290
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()
94294
}
95295

96296
func max(a, b int) int {
@@ -99,3 +299,84 @@ func max(a, b int) int {
99299
}
100300
return b
101301
}
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+
}

pkg/pager/model_test.go

-15
Original file line numberDiff line numberDiff line change
@@ -104,21 +104,6 @@ func TestModel_View(t *testing.T) {
104104
})
105105
}
106106

107-
func TestModel_headerView(t *testing.T) {
108-
t.Run("NormalWidth", func(t *testing.T) {
109-
m := model{
110-
title: "Test",
111-
viewport: viewport.New(20, 10),
112-
}
113-
header := m.headerView()
114-
115-
expectedTitle := titleStyle.Render("Test")
116-
lineLength := 20 - lipgloss.Width(expectedTitle)
117-
assert.Contains(t, header, "Test", "Header should contain title")
118-
assert.Contains(t, header, strings.Repeat("─", lineLength), "Header should contain line")
119-
})
120-
}
121-
122107
func TestModel_footerView(t *testing.T) {
123108
t.Run("NormalWidth", func(t *testing.T) {
124109
vp := viewport.New(20, 10)

0 commit comments

Comments
 (0)