gemini-browser

A text-based gemini browser
git clone git://git.laack.co/gemini-browser.git
Log | Files | Refs | README

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:
Mgo.mod | 2+-
Mmain.go | 380+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Dparse.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 -}