gemini-browser

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

main.go (10400B)


      1 package main
      2 
      3 import (
      4 	"fmt"
      5 	"github.com/gdamore/tcell/v2"
      6 	"github.com/makeworld-the-better-one/go-gemini"
      7 	"github.com/rivo/tview"
      8 	"io"
      9 	"net/url"
     10 	"os"
     11 	"strconv"
     12 	"strings"
     13 )
     14 
     15 type Node struct {
     16 	next  *Node
     17 	prior *Node
     18 	url   string
     19 }
     20 
     21 type Site struct {
     22 	statusCode  int
     23 	siteContent string
     24 	url         string
     25 }
     26 
     27 type Link struct {
     28 	address   string
     29 	plaintext string
     30 }
     31 
     32 var (
     33 	// TODO: Possibly replace this with a cache; it will be annoying to persist the page position
     34 	// but it's nice to have that when traversing links
     35 
     36 	// TODO: There also seems to be some other form of state that messes with history traversal. 
     37 
     38 	history           *Node
     39 	// TODO: Refactor to only store state in the container
     40 	linkSelectionText string
     41 	links             []Link
     42 	site              Site
     43 	linkFollowMode    bool
     44 	linkFollowRendered bool
     45 	showInputBox      bool
     46 	inputBoxRendered  bool
     47 	userInputBox      *tview.InputField
     48 	userInputPrefix	  string
     49 	mainArea          *tview.Flex
     50 	mainText          *tview.TextView
     51 	entryText         *tview.TextView
     52 	app               *tview.Application
     53 )
     54 
     55 func stripLeadingWhiteSpace(text string) string {
     56 
     57 	for len(text) > 0 {
     58 		if text[0] == ' ' || text[0] == '\t' {
     59 			if len(text) > 1 {
     60 				text = text[1:]
     61 			} else {
     62 				text = ""
     63 				return text
     64 			}
     65 		} else {
     66 			return text
     67 		}
     68 	}
     69 
     70 	return text
     71 }
     72 
     73 // TODO: Handle redirects
     74 // seems like most clients prompt before redirects
     75 // I'd want prompts prior to redirects cross-origin
     76 
     77 // TODO: Should reuse node pass in a node to remove side effect of global state?
     78 func updateSite(newUrl string, reuseNode bool) error {
     79 
     80 	client := &gemini.Client{}
     81 
     82 	resp, err := client.Fetch(newUrl)
     83 
     84 	if err != nil {
     85 		return err
     86 	}
     87 
     88 	defer resp.Body.Close()
     89 
     90 	bodyBytes, err := io.ReadAll(resp.Body)
     91 
     92 	if err != nil {
     93 		return err
     94 	}
     95 
     96 	site.statusCode = resp.Status
     97 
     98 	// if statuscode // 10 == 1; show popup for user interaction per 1x status code specification
     99 	// TODO: Make this better. Technically, the spec states that status code 10 is for 
    100 	// inputs and 11 **should** behave like a password field with input masked (emphasis on should)
    101 	// so per rfc specifications I don't have to.
    102 	// So maybe we should special case both of them to support this and make sure other status codes,
    103 	// once we have full coverage are errored?
    104 
    105 	// TODO: When giving an invalid link to tlgs.one to add to the index it seems to redirect
    106 	// to another input site which doesn't seem to work correctly. 
    107 
    108 	if site.statusCode >= 10 && site.statusCode < 20 {
    109 		showInputBox = true
    110 		site.url = newUrl
    111 		userInputPrefix = resp.Meta + ": "
    112 		return nil
    113 	} else {
    114 		showInputBox = false
    115 	}
    116 
    117 	body := string(bodyBytes)
    118 
    119 	if !reuseNode {
    120 		newNode := &Node{url: newUrl}
    121 		if history != nil {
    122 			history.next = newNode
    123 			newNode.prior = history
    124 		}
    125 
    126 		history = newNode
    127 	}
    128 
    129 	// TODO: Should be done once the site text is updated
    130 	site.url = newUrl
    131 
    132 	totalLinkCount := CountLinks(body)
    133 	lines := strings.Split(body, "\n")
    134 
    135 	escaped := false
    136 	escape := "```"
    137 
    138 	result := ""
    139 	linkCount := 0
    140 
    141 	links = []Link{}
    142 
    143 	// this should never happen because we already
    144 	// loaded this site and all that
    145 	u, err := url.Parse(newUrl)
    146 
    147 	if err != nil {
    148 		panic(err)
    149 	}
    150 
    151 	for _, item := range lines {
    152 
    153 		inserted := false
    154 
    155 		if len(item) >= 3 && strings.Compare(escape, item[:3]) == 0 {
    156 			escaped = !escaped
    157 		}
    158 
    159 		if len(item) > 3 && !escaped {
    160 			if item[0] == '=' && item[1] == '>' {
    161 
    162 				link := Link{}
    163 				s := stripLeadingWhiteSpace(item[2:])
    164 
    165 				parts := strings.FieldsFunc(s, func(r rune) bool {
    166 					return r == ' ' || r == '\t'
    167 				})
    168 
    169 				address := ""
    170 				text := ""
    171 
    172 				if len(parts) > 0 {
    173 					address = parts[0]
    174 				}
    175 				if len(parts) > 1 {
    176 					text = strings.Join(parts[1:], " ")
    177 				}
    178 
    179 				newLn, err := url.Parse(address)
    180 
    181 				if err != nil {
    182 					// invalid url on this site...
    183 					// that's their fucking fault, leave it as is
    184 					link.address = address
    185 				} else {
    186 					link.address = u.ResolveReference(newLn).String()
    187 				}
    188 
    189 				link.plaintext = strings.TrimSpace(sanitized(text))
    190 
    191 				links = append(links, link)
    192 
    193 				inserted = true
    194 
    195 				spacer := " "
    196 				if totalLinkCount >= 10 && linkCount < 10 {
    197 					spacer = "  "
    198 				}
    199 
    200 				if link.plaintext != "" {
    201 					result += fmt.Sprintf("[%d]%s=> %s", linkCount, spacer, link.plaintext)
    202 				} else {
    203 					// grr.... sanitizing the link fucks stuff up in terms of conssitency
    204 					result += fmt.Sprintf("[%d]%s=> %s", linkCount, spacer, sanitized(link.address))
    205 				}
    206 
    207 				linkCount += 1
    208 			}
    209 		}
    210 
    211 		if !inserted {
    212 			result += sanitized(item)
    213 		}
    214 
    215 		result += "\n"
    216 
    217 	}
    218 
    219 	site.siteContent = result
    220 	// TODO: Is there a way to persist this across history for niceness e.g. seek line you were on before?
    221 	mainText.ScrollToBeginning()
    222 	return nil
    223 }
    224 
    225 // This will only render ascii. Anything beyond this shouldn't be assumed to work in a conventional terminal
    226 // Emojis don't work in st.
    227 
    228 // Yes, this does break international support; consider patching if you want, but this broke st trying to
    229 // re-render stuff, could be a limit of the combination of st and tview.
    230 
    231 func sanitized(s string) string {
    232 	var b strings.Builder
    233 
    234 	for _, r := range s {
    235 		if r <= 0x7F {
    236 			b.WriteRune(r)
    237 		}
    238 	}
    239 
    240 	return b.String()
    241 
    242 }
    243 
    244 func initApplication() *tview.Application {
    245 	app := tview.NewApplication()
    246 	return app
    247 }
    248 
    249 func repaint() {
    250 	app.QueueUpdateDraw(func() {
    251 		mainArea.SetTitle(site.url)
    252 		mainText.SetText(site.siteContent)
    253 		
    254 		if linkFollowMode {
    255 			// TODO: Refactor to only store state in the container.
    256 			entryText.SetText("Link to follow: " + linkSelectionText)
    257 			if linkFollowRendered == false {
    258 				mainArea.AddItem(entryText, 1, 0, false)
    259 				linkFollowRendered = true
    260 			}
    261 		} else {
    262 			mainArea.RemoveItem(entryText)
    263 			linkFollowRendered = false
    264 
    265 			entryText.SetText("")
    266 		}
    267 
    268 		if showInputBox {
    269 
    270 			if inputBoxRendered == false {
    271 				mainArea.AddItem(userInputBox, 1, 0, false)
    272 				inputBoxRendered = true
    273 			}
    274 			app.SetFocus(userInputBox)
    275 
    276 			// TODO: This is janky. Would it be possible to just have another on screen element?
    277 			if !strings.HasPrefix(userInputBox.GetText(), userInputPrefix) {
    278 				userInputBox.SetText(userInputPrefix)
    279 			}
    280 
    281 		} else {
    282 			inputBoxRendered = false
    283 			mainArea.RemoveItem(userInputBox)
    284 			app.SetRoot(mainArea, true).SetFocus(mainArea)
    285 		}
    286 
    287 	})
    288 }
    289 
    290 func main() {
    291 
    292 	app = initApplication()
    293 	mainText = tview.NewTextView()
    294 	mainText.SetBackgroundColor(tcell.ColorDefault)
    295 
    296 	// TODO: This should be an input field probably
    297 	entryText = tview.NewTextView()
    298 
    299 	mainArea = tview.NewFlex().SetDirection(tview.FlexRow)
    300 	mainArea.SetBorder(true)
    301 
    302 	userInputBox = tview.NewInputField()
    303 	// this is to be consistent with the otherbackground thing. 
    304 	// TODO: Should this be a constant color definition?
    305 	userInputBox.SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
    306 
    307 	mainArea.AddItem(mainText, 0, 1, true)
    308 
    309 
    310 	entryText.SetText("")
    311 
    312 	initSite := "gemini://tlgs.one/known-hosts"
    313 
    314 	if len(os.Args) > 1 {
    315 		initSite = os.Args[1]
    316 	}
    317 
    318 	go func() {
    319 		err := updateSite(initSite, false)
    320 
    321 		if err != nil {
    322 			app.Stop()
    323 			panic(err)
    324 		}
    325 		repaint()
    326 	}()
    327 
    328 	mainArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
    329 		go func() {
    330 
    331 			// TODO: There is a really annoying bug where some stuff is slow to load
    332 			// and this will cause elements on screen to hide but not be obvious stuff is loading
    333 			// this messes with state expectations wherein multiple pages can be loaded from the same base. 
    334 			// presumably this could cause some weird races...
    335 			if event.Key() == tcell.KeyEnter {
    336 				if linkFollowMode {
    337 					selection, err := strconv.Atoi(linkSelectionText)
    338 
    339 					if err == nil {
    340 						if selection < len(links) {
    341 							// TODO: Handle this possible error
    342 							updateSite(links[selection].address, false)
    343 						}
    344 					}
    345 
    346 					linkFollowMode = false
    347 					linkSelectionText = ""
    348 				}
    349 
    350 				if showInputBox {
    351 					text := userInputBox.GetText()
    352 					text = strings.TrimPrefix(text, userInputPrefix)
    353 					if strings.Compare(text, "") != 0 {
    354 						// url parse
    355 						urlEncoded, _ := url.Parse(text)
    356 						text = urlEncoded.String()
    357 
    358 
    359 						// TODO: This is really janky.
    360 						// like the remove / add stuff is awful. Should manage state better.
    361 						inputBoxRendered = false
    362 						showInputBox = false
    363 
    364 						repaint()
    365 
    366 						updateSite(site.url + "?" + text, false)
    367 						userInputBox.SetText("")
    368 					}
    369 				}
    370 
    371 			}
    372 
    373 			if event.Key() == tcell.KeyBackspace || event.Key() == tcell.KeyBackspace2 {
    374 				if linkFollowMode {
    375 					if len(linkSelectionText) > 0 {
    376 						linkSelectionText = linkSelectionText[:len(linkSelectionText)-1]
    377 					}
    378 				}
    379 			}
    380 
    381 			if event.Key() == tcell.KeyEsc {
    382 				if linkFollowMode {
    383 					linkFollowMode = false
    384 					linkSelectionText = ""
    385 				}
    386 				if showInputBox {
    387 					showInputBox = false
    388 					inputBoxRendered = false
    389 					// Text tracks the state of the element.
    390 					userInputBox.SetText("")
    391 				}
    392 			
    393 			}
    394 
    395 			r := event.Rune()
    396 
    397 			// TODO: Refactor this. This is shit. Use the component to store the state with 
    398 			// gettext and settext.
    399 
    400 			if r == '0' || r == '1' || r == '2' || r == '3' || r == '4' || r == '5' || r == '6' || r == '7' || r == '8' || r == '9'{
    401 				if linkFollowMode {
    402 					linkSelectionText += string(r)
    403 				}
    404 			}
    405 
    406 			if r == 'b' {
    407 				if history != nil && history.prior != nil && history.prior.url != "" {
    408 					history = history.prior
    409 					target := history.url
    410 					// TODO: handle this possible error
    411 					updateSite(target, true)
    412 				}
    413 			}
    414 
    415 			if r == 'f' {
    416 				if history != nil && history.next != nil && history.next.url != "" {
    417 					history = history.next
    418 					target := history.url
    419 					// TODO: handle this possible error
    420 					updateSite(target, true)
    421 				}
    422 			}
    423 
    424 			if event.Rune() == ' ' && !showInputBox {
    425 				linkFollowMode = true
    426 			}
    427 
    428 			// It's always safe to repaint; the entire state is stored globally, nothing is
    429 			// stored in components
    430 
    431 			repaint()
    432 		}()
    433 
    434 		return event
    435 	})
    436 
    437 	app.SetRoot(mainArea, true).SetFocus(mainArea)
    438 
    439 	err := app.Run()
    440 
    441 	if err != nil {
    442 		panic(err)
    443 	}
    444 }
    445 
    446 func CountLinks(body string) int {
    447 	lines := strings.Split(body, "\n")
    448 	count := 0
    449 
    450 	escaped := false
    451 	escape := "```"
    452 
    453 	for _, item := range lines {
    454 		if len(item) >= 3 && strings.Compare(escape, item[:3]) == 0 {
    455 			escaped = !escaped
    456 		}
    457 
    458 		if len(item) > 3 && !escaped {
    459 			if item[0] == '=' && item[1] == '>' {
    460 				count += 1
    461 			}
    462 		}
    463 
    464 	}
    465 
    466 	return count
    467 }