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 }