commit eaadb2b22565b1309bbb1da42690c96cf9ac367e
parent 6d450c89b392408099ccdb693d6b3693e88d6f4c
Author: Andrew Laack <andrew@laack.co>
Date: Fri, 8 May 2026 02:53:11 -0500
complete refactor of the browser to fix some weird state choices I made
Diffstat:
| M | go.mod | | | 2 | +- |
| M | main.go | | | 380 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ |
| D | parse.go | | | 102 | ------------------------------------------------------------------------------- |
3 files changed, 295 insertions(+), 189 deletions(-)
diff --git a/go.mod b/go.mod
@@ -3,13 +3,13 @@ module gemini-browser
go 1.26.2
require (
+ github.com/gdamore/tcell/v2 v2.8.1
github.com/makeworld-the-better-one/go-gemini v0.13.1
github.com/rivo/tview v0.42.0
)
require (
github.com/gdamore/encoding v1.0.1 // indirect
- github.com/gdamore/tcell/v2 v2.8.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
diff --git a/main.go b/main.go
@@ -3,142 +3,350 @@ package main
import (
"io"
"fmt"
- "os"
+ "net/url"
"strconv"
- "time"
+ "strings"
"github.com/gdamore/tcell/v2"
"github.com/makeworld-the-better-one/go-gemini"
"github.com/rivo/tview"
)
-func main(){
+type Node struct {
+ next *Node
+ prior *Node
+ url string
+}
+
+type Site struct {
+ statusCode int
+ siteContent string
+ url string
+}
+
+type Link struct {
+ address string
+ plaintext string
+}
+
+var (
+ // TODO: Possibly replace this with a cache; it will be annoying to persist the page position
+ // but it's nice to have that when traversing links
+
+ // TODO: There also seems to be some other form of state that messes with history traversal.
+
+ history *Node
+ linkSelectionText string
+ links []Link
+ site Site
+ linkFollowMode bool
+ mainArea *tview.Flex
+ mainText *tview.TextView
+ entryText *tview.TextView
+ app *tview.Application
+)
- // TODO: Forward and backwards between sites.
- // I want a dynamic array for current site / next site
- // backward changes index into array, going to a new site chops sites higher than current, replaces next with the next site to visit.
+func stripLeadingWhiteSpace(text string) string {
- // TODO: Open websites in correct xdg-open things.
+ for len(text) > 0 {
+ if text[0] == ' ' || text[0] == '\t' {
+ if len(text) > 1 {
+ text = text[1:]
+ } else {
+ text = ""
+ return text
+ }
+ } else {
+ return text
+ }
+ }
- client := &gemini.Client{ConnectTimeout: 5 * time.Second}
- currentUrl := os.Args[1]
- resp, err := client.Fetch(currentUrl)
+ return text
+}
+
+// TODO: Handle redirects
+// seems like most clients prompt before redirects
+// I'd want prompts prior to redirects cross-origin
+
+func updateSite(newUrl string) (error){
+
+ client := &gemini.Client{}
+
+ resp, err := client.Fetch(newUrl)
if err != nil {
- panic(err)
+ return err
}
+ defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
- resp.Body.Close()
- text := string(bodyBytes)
+ if err != nil {
+ return err
+ }
- app := tview.NewApplication()
+ body := string(bodyBytes)
- textView := tview.NewTextView()
- textView.SetBackgroundColor(tcell.ColorDefault)
- textView.SetText(text)
- lowerTextView := tview.NewTextView()
- screen := tview.NewFlex().SetDirection(tview.FlexRow).AddItem(textView, 0,1,true).AddItem(lowerTextView,1,0,false)
-
- selectionMode := false
- currentUrlSelection := ""
-
- // TODO: the management of state is quite annoying here, I should find a more consistent way to do this.
-
- textView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-
- switch event.Rune() {
- case ' ':
- selectionMode = !selectionMode
- go func() {
- app.QueueUpdateDraw(func() {
- if selectionMode {
- lowerTextView.SetText("Select Link To Follow: ")
- } else{
- lowerTextView.SetText("")
- }
+ newNode := &Node{url: newUrl}
+
+ if history != nil {
+ history.next = newNode
+ newNode.prior = history
+ }
+
+ history = newNode
+
+
+ // TODO: Should be done once the site text is updated
+ site.url = newUrl
+
+
+ totalLinkCount := CountLinks(body)
+ lines := strings.Split(body, "\n")
+
+ escaped := false
+ escape := "```"
+
+ result := ""
+ linkCount := 0
+
+
+ links = []Link{}
+
+ // this should never happen because we already
+ // loaded this site and all that
+ u, err := url.Parse(newUrl)
+
+ if err != nil {
+ panic(err)
+ }
+
+ for _, item := range lines {
+
+ inserted := false
+
+ if len(item) >= 3 && strings.Compare(escape, item[:3]) == 0 {
+ escaped = !escaped
+ }
+
+
+ if len(item) > 3 && !escaped {
+ if item[0] == '=' && item[1] == '>' {
+
+ link := Link{}
+ s := stripLeadingWhiteSpace(item[2:])
+
+ parts := strings.FieldsFunc(s, func(r rune) bool {
+ return r == ' ' || r == '\t'
})
- }()
+ address := ""
+ text := ""
+
+ if len(parts) > 0 {
+ address = parts[0]
+ }
+ if len(parts) > 1 {
+ text = strings.Join(parts[1:], " ")
+ }
+
+ newLn, err := url.Parse(address)
+
+ if err != nil {
+ // invalid url on this site...
+ // that's their fucking fault, leave it as is
+ link.address = address
+ } else {
+ link.address = u.ResolveReference(newLn).String()
+ }
+
+ link.plaintext = strings.TrimSpace(sanitized(text))
+
+ links = append(links, link)
- return nil
- case '1', '2','3','4','5','6','7','8','9','0':
- if selectionMode {
- // TODO: Why does the update part have to be wrapped in a go-routine?
- go func() {
+ inserted = true
- currentUrlSelection += string(event.Rune())
+ spacer := " "
+ if totalLinkCount >= 10 && linkCount < 10 {
+ spacer = " "
+ }
- app.QueueUpdateDraw(func() {
- currentText := lowerTextView.GetText(false)
- currentText = currentText + string(event.Rune())
- lowerTextView.SetText(currentText)
- })
+ if link.plaintext != "" {
+ result += fmt.Sprintf("[%d]%s=> %s", linkCount, spacer, link.plaintext)
+ } else {
+ // grr.... sanitizing the link fucks stuff up in terms of conssitency
+ result += fmt.Sprintf("[%d]%s=> %s", linkCount, spacer, sanitized(link.address))
+ }
- }()
+ linkCount += 1
}
+ }
+ if !inserted {
+ result += sanitized(item)
}
- switch event.Key() {
- case tcell.KeyEnter:
- if selectionMode {
- selectionMode = false
- go func() {
- client := &gemini.Client{ConnectTimeout: 5 * time.Second}
+ result += "\n"
- links := ParseLinks(text, currentUrl)
+ }
- currentSelectionNum, err := strconv.Atoi(currentUrlSelection)
- currentUrlSelection = ""
+ site.siteContent = result
+ site.statusCode = resp.Status
+ return nil
+}
- if err != nil {
- panic(err)
- }
+// This will only render ascii. Anything beyond this shouldn't be assumed to work in a conventional terminal
+// Emojis don't work in st.
+
+// Yes, this does break international support; consider patching if you want, but this broke st trying to
+// re-render stuff, could be a limit of the combination of st and tview.
- if len(links) > currentSelectionNum {
- currentUrl = links[currentSelectionNum]
- resp, err := client.Fetch(currentUrl)
+func sanitized(s string) string {
+ var b strings.Builder
- if err != nil {
- panic(err)
- }
+ for _, r := range s {
+ if r <= 0x7F {
+ b.WriteRune(r)
+ }
+ }
- bodyBytes, err := io.ReadAll(resp.Body)
- if err != nil {
- panic(err)
- }
+ return b.String()
- resp.Body.Close()
+}
- text = string(bodyBytes)
+func initApplication() *tview.Application {
+ app := tview.NewApplication()
+ return app
+}
- app.QueueUpdateDraw(func() {
- textView.SetText(text)
- lowerTextView.SetText(currentUrl)
- })
- } else {
- app.QueueUpdateDraw(func() {
- lowerTextView.SetText(fmt.Sprintf("Links: %v", links))
- })
+func repaint() {
+ app.QueueUpdateDraw(func() {
+ mainArea.SetTitle(site.url)
+ mainText.SetText(site.siteContent)
+ if linkFollowMode {
+ entryText.SetText("Link to follow: "+linkSelectionText)
+ } else {
+ entryText.SetText("")
+ }
+ })
+}
+func main(){
+
+ app = initApplication()
+ mainText = tview.NewTextView()
+ mainText.SetBackgroundColor(tcell.ColorDefault)
+ entryText = tview.NewTextView()
+
+ mainArea = tview.NewFlex().SetDirection(tview.FlexRow)
+ mainArea.SetBorder(true)
+
+ mainArea.AddItem(mainText, 0, 1, true)
+ mainArea.AddItem(entryText, 1, 0, false)
+ entryText.SetText("")
+
+ go func() {
+ err := updateSite("gemini://tlgs.one/known-hosts")
+
+ if err != nil {
+ app.Stop()
+ panic(err)
+ }
+
+ repaint()
+ }()
+
+ mainArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ go func() {
+
+ if event.Key() == tcell.KeyEnter {
+ if linkFollowMode {
+
+ selection, err := strconv.Atoi(linkSelectionText)
+
+ if err == nil {
+ if selection < len(links) {
+ // TODO: Handle this possible error
+ updateSite(links[selection].address)
}
+ }
- }()
+ linkFollowMode = false
+ linkSelectionText = ""
+ }
+ }
- return nil
+ if event.Key() == tcell.KeyEsc {
+ if linkFollowMode {
+ linkFollowMode = false
+ linkSelectionText = ""
+
+ }
+ }
+
+ r := event.Rune()
+ if r == '0' || r == '1' || r == '2' || r == '3' || r == '4' || r == '5' || r == '6' || r == '7' || r == '8' || r == '9'{
+ if linkFollowMode {
+ linkSelectionText += string(r)
}
}
+ if r == 'b' {
+ if history != nil && history.prior != nil && history.prior.url != "" {
+ history = history.prior
+ target := history.url
+
+ // pop again to jump forwards correctly again when calling update site
+ history = history.prior
+
+ // TODO: handle this possible error
+ updateSite(target)
+
+ }
+ }
+ // TODO: Add forwards link traversal. This is slightly more difficult to not mess
+ // up the history.
+
+ if event.Rune() == ' '{
+ linkFollowMode = true
+ }
+
+ // It's always safe to repaint; the entire state is stored globally, nothing is
+ // stored in components
+
+ repaint()
+ }()
+
return event
})
+ app.SetRoot(mainArea, true).SetFocus(mainArea)
+ err := app.Run()
-
- if err := app.SetRoot(screen, true).SetFocus(textView).Run(); err != nil {
+ if err != nil {
panic(err)
}
+}
+
+func CountLinks(body string) int {
+ lines := strings.Split(body, "\n")
+ count := 0
+
+ escaped := false
+ escape := "```"
+ for _, item := range lines {
+ if len(item) >= 3 && strings.Compare(escape, item[:3]) == 0 {
+ escaped = !escaped
+ }
+
+ if len(item) > 3 && !escaped {
+ if item[0] == '=' && item[1] == '>' {
+ count += 1
+ }
+ }
+
+ }
+ return count
}
diff --git a/parse.go b/parse.go
@@ -1,102 +0,0 @@
-package main
-
-import (
- "fmt"
- "net/url"
- "strings"
-)
-
-func stripLeadingWhiteSpace(text string) string {
-
- for len(text) > 0 {
- if text[0] == ' ' || text[0] == '\t' {
- if len(text) > 1 {
- text = text[1:]
- } else {
- text = ""
- return text
- }
- } else {
- return text
- }
- }
-
- return text
-}
-
-func ParseLinks(body string, currentUrl string) []string {
-
- base, err := url.Parse(currentUrl)
-
- if err != nil {
- panic(err)
- }
-
- lines := strings.Split(body, "\n")
-
- links := []string{}
-
- escaped := false
- escape := "```"
-
- for _, item := range lines {
-
- // must start with escape characters, the rest doesn't matter to us
- // > Any line whose first three characters are "```" (...) are preformatted toggle lines
- if len(item) >= 3 && strings.Compare(escape, item[:3]) == 0 {
- escaped = !escaped
- }
-
- if len(item) > 3 && !escaped {
- if item[0] == '=' && item[1] == '>' {
-
- // sometimes links end with a \r, but that isn't valid so we won't allow it
- links = append(links, stripLeadingWhiteSpace(item[2:]))
- }
- }
-
- }
-
- geminiLinks := []string{}
-
- for _, item := range links {
-
- // this is for finding the text associated with the link
-
- indexOfSpace := strings.Index(item, " ")
- indexOfTab := strings.Index(item, "\t")
-
- // default if there aren't any
- indexOfSpaceOrTab := len(item)
-
- if indexOfSpace != -1 {
- indexOfSpaceOrTab = indexOfSpace
- }
-
- if indexOfTab != -1 {
- if indexOfTab < indexOfSpace || indexOfSpace == -1 {
- indexOfSpaceOrTab = indexOfTab
- }
- }
-
- if len(item) >= 10 && strings.Compare(item[:9], "gemini://") == 0 {
- geminiLinks = append(geminiLinks, item[0:indexOfSpaceOrTab])
- }
-
- // there are urls that aren't relative that don't have // like like mailto: and monero:
- if strings.Contains(item, ":") == false {
- // relative link
-
- u, err := url.Parse(item[0:indexOfSpaceOrTab])
-
- if err != nil {
- fmt.Printf("Unable to parse link: %s, %s\n", item, err)
- continue
- }
-
- geminiLinks = append(geminiLinks, base.ResolveReference(u).String())
- }
- }
-
- return geminiLinks
-}