M browser.go => browser.go +26 -1
@@ 10,6 10,7 @@ import (
"net/url"
"os"
"path/filepath"
+ "strconv"
"strings"
"sync"
@@ 268,11 269,11 @@ func (b *Browser) Event(event tcell.Event) {
b.hint += string(r)
if hint, ok := b.hintMap[b.hint]; ok {
text.focus = hint.Index
+ b.Message("=> " + text.LinkURL(hint.Index))
b.mode = ModeNormal
b.hint = ""
b.hints = nil
b.hintMap = nil
- b.Message("=> " + text.LinkURL(hint.Index))
}
b.view.Invalidate()
@@ 660,6 661,30 @@ func (b *Browser) FollowMode() error {
return nil
}
+func (b *Browser) Jump() {
+ tab := b.tabs[b.tab]
+ text := tab.Text()
+ if text == nil {
+ return
+ }
+
+ toc := text.TableOfContents()
+ if toc == nil {
+ return
+ }
+ toc.action = func(action string) {
+ idx, err := strconv.Atoi(action)
+ if err != nil {
+ panic(err)
+ }
+ tab.setText(text)
+ tab.view.SetScrollY(text.pos[idx])
+ tab.view.Invalidate()
+ }
+ tab.setText(toc)
+ b.view.Invalidate()
+}
+
// Based on Qutebrowser's 'scattered' hints algorithm.
func makeHintLabels(chars string, length int) []string {
needed := ceilLog(length, len(chars))
M command.go => command.go +4 -0
@@ 23,6 23,10 @@ var commands = map[string]Command{
"follow": func(b *Browser, args ...string) error {
return b.FollowMode()
},
+ "jump": func(b *Browser, args ...string) error {
+ b.Jump()
+ return nil
+ },
"open": func(b *Browser, args ...string) error {
if len(args) == 0 {
return errors.New("usage: open <url>")
M text.go => text.go +60 -0
@@ 5,6 5,7 @@ import (
"astronaut/ui"
"bufio"
"io"
+ "strconv"
"strings"
"sync"
@@ 28,6 29,7 @@ type Text struct {
mu sync.RWMutex
lines []Line // parsed lines
tabindex []int // list of tabbable lines
+ headings []int // list of heading lines
processed int // number of processed lines
wrapped []Wrapped // wrapped lines
pos []int // list of line positions
@@ 68,6 70,9 @@ func (t *Text) Parse() error {
// Close stops the parsing of the text document.
func (t *Text) Close() error {
+ if t.rc == nil {
+ return nil
+ }
return t.rc.Close()
}
@@ 151,6 156,12 @@ func (t *Text) Update() {
t.view.Invalidate()
}
+ // Populate headings
+ switch line.Type {
+ case LineHeading1, LineHeading2, LineHeading3:
+ t.headings = append(t.headings, t.processed)
+ }
+
// Populate tab index
if line.Tabindex != 0 {
t.tabindex = append(t.tabindex, t.processed)
@@ 278,6 289,55 @@ func (t *Text) Draw() {
t.view.SetContentSize(w, len(t.wrapped)+h-1, true)
}
+// TableOfContents returns the table of contents of the document.
+// Returns nil if there are no headings in the document.
+func (t *Text) TableOfContents() *Text {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+
+ if len(t.headings) == 0 {
+ return nil
+ }
+
+ toc := &Text{
+ view: t.view,
+ cfg: t.cfg,
+ focus: -1,
+ }
+
+ toc.lines = append(toc.lines, Line{})
+ toc.lines = append(toc.lines, Line{
+ Type: LineHeading1,
+ Text: "Table of contents",
+ })
+ toc.lines = append(toc.lines, Line{})
+
+ tabindex := 1
+ for _, idx := range t.headings {
+ line := t.lines[idx]
+
+ var prefix string
+ switch line.Type {
+ case LineHeading1:
+ prefix = "# "
+ case LineHeading2:
+ prefix = "## "
+ case LineHeading3:
+ prefix = "### "
+ }
+
+ toc.lines = append(toc.lines, Line{
+ Type: LineLink,
+ URL: strconv.Itoa(idx),
+ Text: prefix + line.Text,
+ Tabindex: tabindex,
+ })
+ tabindex++
+ }
+
+ return toc
+}
+
// Invalidate invalidates the text document, forcing a redraw of all the text.
func (t *Text) Invalidate() {
t.wrapped = nil
M ui/view.go => ui/view.go +8 -0
@@ 175,6 175,14 @@ func (v *View) ScrollY() int {
return v.scrolly
}
+func (v *View) SetScrollX(scrollx int) {
+ v.scrollx = scrollx
+}
+
+func (v *View) SetScrollY(scrolly int) {
+ v.scrolly = scrolly
+}
+
// SetContentSize sets the size of the content area; this is used to limit
// scrolling and view moment. If locked is true, then the content size will
// not automatically grow even if content is placed outside of this area