~rafael/gembro

af806922084db15a040dc59a8f3666e60ac4b1f0 — RafaĆ«l L. Bekkema 2 years ago c6b8a21 master
Remember scroll position
6 files changed, 146 insertions(+), 49 deletions(-)

M go.mod
M go.sum
M internal/history/history.go
M main.go
M tab.go
M viewport.go
M go.mod => go.mod +10 -4
@@ 3,8 3,14 @@ module git.sr.ht/~rafael/gembro
go 1.15

require (
	github.com/charmbracelet/bubbles v0.7.6
	github.com/charmbracelet/bubbletea v0.13.1
	github.com/muesli/termenv v0.7.4
	golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3
	github.com/charmbracelet/bubbles v0.10.2
	github.com/charmbracelet/bubbletea v0.19.3
	github.com/containerd/console v1.0.3 // indirect
	github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f // indirect
	github.com/mattn/go-isatty v0.0.14 // indirect
	github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
	github.com/muesli/termenv v0.9.0
	golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce // indirect
	golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba
	golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect
)

M go.sum => go.sum +51 -0
@@ 1,37 1,71 @@
github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbles v0.7.6 h1:SCAp4ZEUf2tBNEsufo+Xxxu2dvbFhYSDPrX45toQZrM=
github.com/charmbracelet/bubbles v0.7.6/go.mod h1:0D4XRYK0tjo8JMvflz1obpVcOikNZSG46SFauoZj22s=
github.com/charmbracelet/bubbles v0.10.2 h1:VK1Q7nnBMDFTlrMmvBgE9nidtU5udsIcZvFXvjE2Cfk=
github.com/charmbracelet/bubbles v0.10.2/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
github.com/charmbracelet/bubbletea v0.12.2/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg=
github.com/charmbracelet/bubbletea v0.13.1 h1:huvX8mPaeMZ8DLulT50iEWRF+iitY5FNEDqDVLu69nM=
github.com/charmbracelet/bubbletea v0.13.1/go.mod h1:tp9tr9Dadh0PLhgiwchE5zZJXm5543JYjHG9oY+5qSg=
github.com/charmbracelet/bubbletea v0.19.3 h1:OKeO/Y13rQQqt4snX+lePB0QrnW80UdrMNolnCcmoAw=
github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g=
github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc=
github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA=
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8=
github.com/muesli/termenv v0.7.4 h1:/pBqvU5CpkY53tU0vVn+xgs2ZTX63aH5nY+SSps5Xa8=
github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc=
github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce h1:Roh6XWxHFKrPgC/EQhVubSAGQ6Ozk6IdxHSzt1mR0EI=
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba h1:6u6sik+bn/y7vILcYkK3iwTBWN7WtBvB0+SZswQnbf8=
golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=


@@ 39,5 73,22 @@ golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a h1:e3IU37lwO4aq3uoRKINC7JikojFmE5gO7xhfxs8VC34=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

M internal/history/history.go => internal/history/history.go +41 -14
@@ 7,9 7,14 @@ import (
	"sync"
)

type URL struct {
	url       string
	scrollPos int
}

type History struct {
	sync.Mutex
	urls []string
	urls []URL
	pos  int
}



@@ 19,37 24,47 @@ func (h *History) Add(surl string) {
	if len(h.urls) == 0 && h.pos == 0 {
		h.pos = -1
	}
	h.urls = append(h.urls[:h.pos+1], surl)
	h.urls = append(h.urls[:h.pos+1], URL{surl, 0})
	h.pos = len(h.urls) - 1
}

func (h *History) Back() (string, bool) {
func (h *History) Back() (string, int, bool) {
	h.Lock()
	defer h.Unlock()
	if h.pos > 0 {
		h.pos--
		return h.urls[h.pos], true
		u := h.urls[h.pos]
		return u.url, u.scrollPos, true
	}
	return "", 0, false
}

func (h *History) UpdateScroll(pos int) {
	if len(h.urls) == 0 {
		return
	}
	return "", false
	h.urls[h.pos].scrollPos = pos
}

func (h *History) Current() string {
func (h *History) Current() (string, int) {
	h.Lock()
	defer h.Unlock()
	if len(h.urls) == 0 {
		return ""
		return "", 0
	}
	return h.urls[h.pos]
	u := h.urls[h.pos]
	return u.url, u.scrollPos
}

func (h *History) Forward() (string, bool) {
func (h *History) Forward() (string, int, bool) {
	h.Lock()
	defer h.Unlock()
	if h.pos < len(h.urls)-1 {
		h.pos++
		return h.urls[h.pos], true
		u := h.urls[h.pos]
		return u.url, u.scrollPos, true
	}
	return "", false
	return "", 0, false
}

func (h *History) Status() string {


@@ 58,15 73,23 @@ func (h *History) Status() string {
	return fmt.Sprintf("Count=%d, Pos=%d", len(h.urls), h.pos)
}

type jsonURL struct {
	URL       string
	ScrollPos int
}

type jsonData struct {
	URLs []string
	URLs []jsonURL
	Pos  int
}

func (h *History) ToJSON(out io.Writer) error {
	h.Lock()
	defer h.Unlock()
	j := jsonData{h.urls, h.pos}
	j := jsonData{Pos: h.pos}
	for _, u := range h.urls {
		j.URLs = append(j.URLs, jsonURL{u.url, u.scrollPos})
	}
	return json.NewEncoder(out).Encode(&j)
}



@@ 81,7 104,11 @@ func FromJSON(in io.Reader) ([]*History, error) {
			}
			return nil, err
		}
		hs = append(hs, &History{urls: j.URLs, pos: j.Pos})
		h := &History{pos: j.Pos}
		for _, u := range j.URLs {
			h.urls = append(h.urls, URL{u.URL, u.ScrollPos})
		}
		hs = append(hs, h)
	}
	return hs, nil
}

M main.go => main.go +13 -6
@@ 317,7 317,7 @@ func (m model) openNewTab(url string, switchTo bool) (model, tea.Cmd) {
	var cmd tea.Cmd
	if len(m.tabs) < 9 {
		m.sequenceID++
		m.tabs = append(m.tabs, NewTab(m.client, url, m.bookmarks, nil, m.sequenceID))
		m.tabs = append(m.tabs, NewTab(m.client, url, 0, m.bookmarks, nil, m.sequenceID))
		if switchTo {
			cmd = fireEvent(SelectTabEvent{Tab: len(m.tabs) - 1})
		}


@@ 327,6 327,7 @@ func (m model) openNewTab(url string, switchTo bool) (model, tea.Cmd) {

type LoadURLEvent struct {
	URL        string
	ScrollPos int
	AddHistory bool
}



@@ 355,7 356,9 @@ func (m model) saveHistory(path string) error {
	}
	defer f.Close()
	for i := range m.tabs {
		if err := m.tabs[i].history.ToJSON(f); err != nil {
		tab := &m.tabs[i]
		tab.history.UpdateScroll(tab.viewport.viewport.YOffset)
		if err := tab.history.ToJSON(f); err != nil {
			return fmt.Errorf("could not write history: %w", err)
		}
	}


@@ 368,7 371,7 @@ func loadTabs(historyPath string, client *gemini.Client, bs *bookmark.Store, sta
	if err != nil {
		if os.IsNotExist(err) {
			return []Tab{
				NewTab(client, startURL, bs, nil, seqID),
				NewTab(client, startURL, 0, bs, nil, seqID),
			}, seqID + 1, nil
		}
		return nil, 0, fmt.Errorf("could not load history file: %w", err)


@@ 377,15 380,19 @@ func loadTabs(historyPath string, client *gemini.Client, bs *bookmark.Store, sta

	hs, err := history.FromJSON(f)
	if err != nil {
		return nil, 0, fmt.Errorf("could not decode history: %w", err)
		log.Printf("Incompatible history file. Ignoring it.")
		return []Tab{
			NewTab(client, startURL, 0, bs, nil, seqID),
		}, seqID + 1, nil
	}
	var tabs []Tab
	for _, h := range hs {
		u := startURL
		scrollPos := 0
		if u == "" {
			u = h.Current()
			u, scrollPos = h.Current()
		}
		tab := NewTab(client, u, bs, h, seqID)
		tab := NewTab(client, u, scrollPos, bs, h, seqID)
		tabs = append(tabs, tab)
		seqID++
	}

M tab.go => tab.go +18 -16
@@ 57,7 57,7 @@ type Tab struct {
	specialPages map[string]func(Tab) string
}

func NewTab(client *gemini.Client, startURL string, bs *bookmark.Store, h *history.History, id tabID) Tab {
func NewTab(client *gemini.Client, startURL string, scrollPos int, bs *bookmark.Store, h *history.History, id tabID) Tab {
	ti := textinput.NewModel()
	ti.Placeholder = ""
	ti.CharLimit = 255


@@ 71,7 71,7 @@ func NewTab(client *gemini.Client, startURL string, bs *bookmark.Store, h *histo
		client:    client,
		history:   h,
		input:     NewInput(),
		viewport:  NewViewport(startURL, h),
		viewport:  NewViewport(startURL, scrollPos, h),
		message:   Message{},
		bookmarks: bs,
		specialPages: map[string]func(Tab) string{


@@ 119,7 119,7 @@ func (tab Tab) Update(msg tea.Msg) (Tab, tea.Cmd) {
			}
		case messageForceCert:
			if msg.Response {
				return tab.loadURL(msg.Payload, true, 1, true)
				return tab.loadURL(msg.Payload, 0, true, 1, true)
			}
		}
	case ShowMessageEvent:


@@ 131,9 131,9 @@ func (tab Tab) Update(msg tea.Msg) (Tab, tea.Cmd) {
		switch msg.Type {
		case inputQuery:
			url := fmt.Sprintf("%s?%s", msg.Payload, neturl.QueryEscape(msg.Value))
			return tab.loadURL(url, true, 1, false)
			return tab.loadURL(url, 0, true, 1, false)
		case inputNav:
			return tab.loadURL(msg.Value, true, 1, false)
			return tab.loadURL(msg.Value, 0, true, 1, false)
		case inputBookmark:
			if err := tab.bookmarks.Add(msg.Payload, msg.Value); err != nil {
				log.Print(err)


@@ 146,14 146,14 @@ func (tab Tab) Update(msg tea.Msg) (Tab, tea.Cmd) {
	case ShowInputEvent:
		return tab.showInput(msg.Message, msg.Value, msg.Payload, msg.Type)
	case LoadURLEvent:
		return tab.loadURL(msg.URL, msg.AddHistory, 1, false)
		return tab.loadURL(msg.URL, msg.ScrollPos, msg.AddHistory, 1, false)
	case GoBackEvent:
		if url, ok := tab.history.Back(); ok {
			return tab.loadURL(url, false, 1, false)
		if url, pos, ok := tab.history.Back(); ok {
			return tab.loadURL(url, pos, false, 1, false)
		}
	case GoForwardEvent:
		if url, ok := tab.history.Forward(); ok {
			return tab.loadURL(url, false, 1, false)
		if url, pos, ok := tab.history.Forward(); ok {
			return tab.loadURL(url, pos, false, 1, false)
		}
	case ToggleBookmarkEvent:
		if tab.bookmarks.Contains(msg.URL) {


@@ 285,8 285,9 @@ type ServerResponse interface {

type GeminiResponse struct {
	*gemini.Response
	level int
	tab   tabID
	level     int
	scrollPos int
	tab       tabID
}

func (gr GeminiResponse) Tab() tabID {


@@ 334,7 335,7 @@ func (tab Tab) handleResponse(resp ServerResponse) (Tab, tea.Cmd) {
			if resp.level > 5 {
				return tab.showMessage("Too many redirects. Welcome to the Web from Hell.", "", messagePlain, false)
			}
			return tab.loadURL(resp.Header.Meta, true, resp.level+1, false)
			return tab.loadURL(resp.Header.Meta, resp.scrollPos, true, resp.level+1, false)
		case 4, 5, 6:
			return tab.showMessage(fmt.Sprintf("Error: %s", resp.Header.Meta), "", messagePlain, false)
		case 2:


@@ 344,7 345,7 @@ func (tab Tab) handleResponse(resp ServerResponse) (Tab, tea.Cmd) {
				return tab, nil
			}
			tab.lastResponse = resp
			tab.viewport = tab.viewport.SetGeminiContent(body, resp.URL, resp.Header.Meta)
			tab.viewport = tab.viewport.SetGeminiContent(body, resp.URL, resp.Header.Meta, resp.scrollPos)
			return tab, nil
		default:
			log.Print(resp.Header)


@@ 354,7 355,7 @@ func (tab Tab) handleResponse(resp ServerResponse) (Tab, tea.Cmd) {
	return tab, nil
}

func (tab Tab) loadURL(url string, addHist bool, level int, skipVerify bool) (Tab, tea.Cmd) {
func (tab Tab) loadURL(url string, scrollPos int, addHist bool, level int, skipVerify bool) (Tab, tea.Cmd) {
	if !strings.Contains(url, "://") {
		url = fmt.Sprintf("gemini://%s", url)
	}


@@ 371,6 372,7 @@ func (tab Tab) loadURL(url string, addHist bool, level int, skipVerify bool) (Ta
	tab.viewport.loading = true

	cmd := func() tea.Msg {
		tab.history.UpdateScroll(tab.viewport.viewport.YOffset)
		defer cancel()

		if isSpecial {


@@ 406,7 408,7 @@ func (tab Tab) loadURL(url string, addHist bool, level int, skipVerify bool) (Ta
			if addHist && resp.Header.Status == 2 {
				tab.history.Add(u.String())
			}
			return GeminiResponse{Response: resp, level: level, tab: tab.id}
			return GeminiResponse{Response: resp, level: level, tab: tab.id, scrollPos: scrollPos}
		}
	}
	return tab, tea.Batch(cmd, spinner.Tick)

M viewport.go => viewport.go +13 -9
@@ 36,6 36,7 @@ type Viewport struct {
	ready          bool
	loading        bool
	URL, MediaType string
	startScroll    int

	title     string
	links     text.Links


@@ 44,15 45,16 @@ type Viewport struct {
	digits    string
}

func NewViewport(startURL string, h *history.History) Viewport {
func NewViewport(startURL string, scrollPos int, h *history.History) Viewport {
	s := spinner.NewModel()
	s.Spinner = spinner.Points
	// footerLead := "Back (RMB) Forward (->) Home (h) Bookmark (b) Download (d) Close tab (q) Quit (ctrl+c) "
	return Viewport{
		URL:     startURL,
		spinner: s,
		history: h,
		footer:  NewFooter(buttonBack, buttonFwd, buttonHome, buttonBookmark, buttonDownload, buttonHelp, buttonQuit),
		URL:         startURL,
		startScroll: scrollPos,
		spinner:     s,
		history:     h,
		footer:      NewFooter(buttonBack, buttonFwd, buttonHome, buttonBookmark, buttonDownload, buttonHelp, buttonQuit),
	}
}



@@ 75,7 77,7 @@ func (v Viewport) SetGoperContent(data []byte, url string, typ byte) Viewport {
	return v
}

func (v Viewport) SetGeminiContent(content, url, mediaType string) Viewport {
func (v Viewport) SetGeminiContent(content, url, mediaType string, scrollPos int) Viewport {
	v.URL = url
	v.MediaType = mediaType
	u, _ := neturl.Parse(url)


@@ 95,7 97,7 @@ func (v Viewport) SetGeminiContent(content, url, mediaType string) Viewport {
	}

	v.viewport.SetContent(s)
	v.viewport.GotoTop()
	v.viewport.SetYOffset(scrollPos)
	return v
}



@@ 116,7 118,9 @@ func (v Viewport) Update(msg tea.Msg) (Viewport, tea.Cmd) {
			if startURL == "" {
				startURL = homeURL
			}
			return v, fireEvent(LoadURLEvent{URL: startURL, AddHistory: v.history.Current() != startURL})
			hist, _ := v.history.Current()
			return v, fireEvent(LoadURLEvent{URL: startURL, ScrollPos: v.startScroll,
				AddHistory: hist != startURL})
		} else {
			v.viewport.Width = msg.Width
			v.viewport.Height = msg.Height - verticalMargins


@@ 205,7 209,7 @@ func (v Viewport) handleButtonClick(btn string) tea.Cmd {
			Type:  inputDownloadSrc})
	case buttonGoto:
		var val string
		if cur := v.history.Current(); cur != homeURL && cur != helpURL {
		if cur, _ := v.history.Current(); cur != homeURL && cur != helpURL {
			val = cur
		}
		return fireEvent(ShowInputEvent{Message: "Go to", Type: inputNav, Payload: "", Value: val})