From 543d15abfc354950e58945e22c5006ff5f245cb1 Mon Sep 17 00:00:00 2001 From: makeworld Date: Tue, 7 Jul 2020 21:13:45 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=80=20Refactor=20to=20use=20tab=20stru?= =?UTF-8?q?ct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed commit of the following: commit 72f36afc9ea51b1d4fc6e24895bbc32b8b8cd872 Author: makeworld Date: Tue Jul 7 16:15:45 2020 -0400 🚧 Scroll is applied correctly when navigating around commit 4b8982723f60294977f5e9d213fa3c8a51362356 Author: makeworld Date: Tue Jul 7 15:34:45 2020 -0400 🚧 Fix bottomBar code Make sure it always resets to a selected link if one was selected before commit be09ffcf913662983a0362431d8e4765d06b4187 Author: makeworld Date: Mon Jul 6 20:30:54 2020 -0400 🚧 Switch to using tab pointers instead of ints Almost finished overall work. commit ef8ab3da39eb2681f439337354b6b8f9fed74f15 Author: makeworld Date: Mon Jul 6 12:10:50 2020 -0400 🚧 Fixed some bugs, major ones remain commit d3d47a344d54a9aac66acddd6ea9ca893ecc30df Author: makeworld Date: Sat Jul 4 20:58:46 2020 -0400 🚧 Everything uses tab struct, no compile errors, untested commit 44bf54c12f379524fe95073ca4eb1f3fb2c7195a Author: makeworld Date: Sat Jul 4 13:24:49 2020 -0400 🚧 Initial work on tab struct --- CHANGELOG.md | 6 + NOTES.md | 9 +- cache/cache.go | 2 +- display/bookmarks.go | 13 +- display/display.go | 348 ++++++++++++++++++---------------------- display/history.go | 43 ++--- display/private.go | 248 ++++++++++++++-------------- display/tab.go | 230 ++++++++++++++++++++++++++ structs/structs.go | 28 ++-- structs/structs_test.go | 17 -- 10 files changed, 559 insertions(+), 385 deletions(-) create mode 100644 display/tab.go delete mode 100644 structs/structs_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 33cb96b..7e2a8d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Link and heading lines are wrapped just like regular text lines - Wrapped list items are indented to stay behind the bullet (#35) - Certificate expiry date is stored when the cert IDs match (#39) +- What link was selected is remembered as you browse through history ### Changed - Pages are rewrapped dynamically, whenever the terminal size changes (#33) +### Fixed +- Many potential network and display race conditions eliminated +- Whether a tab is loading stays indicated when you switch away from it and go back +- Plain text documents are displayed faithfully (there were some edge conditions) + ## [1.2.0] - 2020-07-02 ### Added - Alt-Left and Alt-Right for history navigation (#23) diff --git a/NOTES.md b/NOTES.md index 73b998b..4e714b6 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1,9 +1,10 @@ # Notes -- Simplify into one struct - - All the maps and stuff could be replaced with a `Tab` struct - - And then just one single map of tab number to `Tab` - - URL for each tab should not be stored as a string - in the current code there's lots of reparsing the URL +- URL for each tab should not be stored as a string - in the current code there's lots of reparsing the URL + +## Issues +- Can't go back or do other things while page is loading - need a way to stop `handleURL` +- Change renderer to start style tags on each new line of wrapped link, to prevent left margin from being highlighted ## Upstream Bugs - Wrapping messes up on brackets diff --git a/cache/cache.go b/cache/cache.go index 4bc092f..833b95a 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -111,7 +111,7 @@ func NumPages() int { } // Get returns the page struct, and a bool indicating if the page was in the cache or not. -// An empty page struct is returned if the page isn't in the cache +// An empty page struct is returned if the page isn't in the cache. func Get(url string) (*structs.Page, bool) { lock.RLock() defer lock.RUnlock() diff --git a/display/bookmarks.go b/display/bookmarks.go index 76a857f..a690cb1 100644 --- a/display/bookmarks.go +++ b/display/bookmarks.go @@ -96,7 +96,7 @@ func openBkmkModal(name string, exists bool) (string, int) { } // Bookmarks displays the bookmarks page on the current tab. -func Bookmarks() { +func Bookmarks(t *tab) { // Gather bookmarks rawContent := "# Bookmarks\r\n\r\n" m, keys := bookmarks.All() @@ -113,27 +113,28 @@ func Bookmarks() { Width: termW, Mediatype: structs.TextGemini, } - setPage(&page) + setPage(t, &page) + t.applyBottomBar() } // addBookmark goes through the process of adding a bookmark for the current page. // It is the high-level way of doing it. It should be called in a goroutine. // It can also be called to edit an existing bookmark. func addBookmark() { - if !strings.HasPrefix(tabMap[curTab].Url, "gemini://") { + if !strings.HasPrefix(tabs[curTab].page.Url, "gemini://") { // Can't make bookmarks for other kinds of URLs return } - name, exists := bookmarks.Get(tabMap[curTab].Url) + name, exists := bookmarks.Get(tabs[curTab].page.Url) // Open a bookmark modal with the current name of the bookmark, if it exists newName, action := openBkmkModal(name, exists) switch action { case 1: // Add/change the bookmark - bookmarks.Set(tabMap[curTab].Url, newName) + bookmarks.Set(tabs[curTab].page.Url, newName) case -1: - bookmarks.Remove(tabMap[curTab].Url) + bookmarks.Remove(tabs[curTab].page.Url) } // Other case is action = 0, meaning "Cancel", so nothing needs to happen } diff --git a/display/display.go b/display/display.go index 6f9e6a7..db41fe9 100644 --- a/display/display.go +++ b/display/display.go @@ -6,7 +6,6 @@ import ( "path" "strconv" "strings" - "sync" "github.com/gdamore/tcell" "github.com/makeworld-the-better-one/amfora/cache" @@ -16,19 +15,13 @@ import ( "gitlab.com/tslocum/cview" ) -var curTab = -1 // What number tab is currently visible, -1 means there are no tabs at all -var tabMap = make(map[int]*structs.Page) // Map of tab number to page -// Holds the actual tab primitives -var tabViews = make(map[int]*cview.TextView) +var tabs []*tab // Slice of all the current browser tabs +var curTab = -1 // What tab is currently visible - index for the tabs slice (-1 means there are no tabs) // Terminal dimensions var termW int var termH int -// The link currently selected when in link selection mode -// Set to "" when not in that mode -var selectedLink string - // The user input and URL display bar at the bottom var bottomBar = cview.NewInputField(). SetFieldBackgroundColor(tcell.ColorWhite). @@ -72,9 +65,7 @@ var layout = cview.NewFlex(). var renderedNewTabContent string var newTabLinks []string -var newTabPage *structs.Page - -var reformatMuts = make(map[int]*sync.Mutex) // Mutex for each tab +var newTabPage structs.Page var App = cview.NewApplication(). EnableMouse(false). @@ -85,12 +76,12 @@ var App = cview.NewApplication(). termH = height // Make sure the current tab content is reformatted when the terminal size changes - go func(tab int) { - reformatMuts[tab].Lock() // Only one reformat job per tab - defer reformatMuts[tab].Unlock() + go func(t *tab) { + t.reformatMut.Lock() // Only one reformat job per tab + defer t.reformatMut.Unlock() // Use the current tab, but don't affect other tabs if the user switches tabs - reformatPageAndSetView(tab, tabMap[tab]) - }(curTab) + reformatPageAndSetView(t, t.page) + }(tabs[curTab]) }) func Init() { @@ -107,7 +98,15 @@ func Init() { } bottomBar.SetBackgroundColor(tcell.ColorWhite) bottomBar.SetDoneFunc(func(key tcell.Key) { - defer bottomBar.SetLabel("") + tab := curTab + + // Reset func to set the bottomBar back to what it was before + // Use for errors. + reset := func() { + bottomBar.SetLabel("") + tabs[tab].applyAll() + App.SetFocus(tabs[tab].view) + } switch key { case tcell.KeyEnter: @@ -118,21 +117,19 @@ func Init() { if strings.TrimSpace(query) == "" { // Ignore - bottomBar.SetText(tabMap[curTab].Url) - App.SetFocus(tabViews[curTab]) + reset() return } - if query == ".." && tabHasContent() { + if query == ".." && tabs[tab].hasContent() { // Go up a directory - parsed, err := url.Parse(tabMap[curTab].Url) + parsed, err := url.Parse(tabs[tab].page.Url) if err != nil { // This shouldn't occur return } if parsed.Path == "/" { // Can't go up further - bottomBar.SetText(tabMap[curTab].Url) - App.SetFocus(tabViews[curTab]) + reset() return } @@ -152,17 +149,19 @@ func Init() { // They're trying to open a link number in a new tab i, err = strconv.Atoi(query[4:]) if err != nil { + reset() return } - if i <= len(tabMap[curTab].Links) && i > 0 { + if i <= len(tabs[tab].page.Links) && i > 0 { // Open new tab and load link - oldTab := curTab + oldTab := tab NewTab() // Resolve and follow link manually - prevParsed, _ := url.Parse(tabMap[oldTab].Url) - nextParsed, err := url.Parse(tabMap[oldTab].Links[i-1]) + prevParsed, _ := url.Parse(tabs[oldTab].page.Url) + nextParsed, err := url.Parse(tabs[oldTab].page.Links[i-1]) if err != nil { Error("URL Error", "link URL could not be parsed") + reset() return } URL(prevParsed.ResolveReference(nextParsed).String()) @@ -172,7 +171,7 @@ func Init() { // It's a full URL or search term // Detect if it's a search or URL if strings.Contains(query, " ") || (!strings.Contains(query, "//") && !strings.Contains(query, ".") && !strings.HasPrefix(query, "about:")) { - u := viper.GetString("a-general.search") + "?" + pathEscape(query) + u := viper.GetString("a-general.search") + "?" + queryEscape(query) cache.Remove(u) // Don't use the cached version of the search URL(u) } else { @@ -183,26 +182,26 @@ func Init() { return } } - if i <= len(tabMap[curTab].Links) && i > 0 { + if i <= len(tabs[tab].page.Links) && i > 0 { // It's a valid link number - followLink(tabMap[curTab].Url, tabMap[curTab].Links[i-1]) + followLink(tabs[tab], tabs[tab].page.Url, tabs[tab].page.Links[i-1]) return } // Invalid link number, don't do anything - bottomBar.SetText(tabMap[curTab].Url) - App.SetFocus(tabViews[curTab]) + reset() + return case tcell.KeyEscape: // Set back to what it was - bottomBar.SetText(tabMap[curTab].Url) - App.SetFocus(tabViews[curTab]) + reset() + return } // Other potential keys are Tab and Backtab, they are ignored }) // Render the default new tab content ONCE and store it for later renderedNewTabContent, newTabLinks = renderer.RenderGemini(newTabContent, textWidth(), leftMargin()) - newTabPage = &structs.Page{ + newTabPage = structs.Page{ Raw: newTabContent, Content: renderedNewTabContent, Links: newTabLinks, @@ -227,87 +226,100 @@ func Init() { return event } - // History arrow keys - if event.Modifiers() == tcell.ModAlt { - if event.Key() == tcell.KeyLeft { - histBack() - return nil - } - if event.Key() == tcell.KeyRight { - histForward() - return nil - } - } + if tabs[curTab].mode == tabModeDone { + // All the keys and operations that can only work while NOT loading - switch event.Key() { - case tcell.KeyCtrlT: - if selectedLink == "" { - NewTab() - } else { - next, err := resolveRelLink(tabMap[curTab].Url, selectedLink) - if err != nil { - Error("URL Error", err.Error()) + // History arrow keys + if event.Modifiers() == tcell.ModAlt { + if event.Key() == tcell.KeyLeft { + histBack(tabs[curTab]) + return nil + } + if event.Key() == tcell.KeyRight { + histForward(tabs[curTab]) return nil } - NewTab() - URL(next) } - return nil + + switch event.Key() { + case tcell.KeyCtrlT: + if tabs[curTab].page.Mode == structs.ModeLinkSelect { + next, err := resolveRelLink(tabs[curTab], tabs[curTab].page.Url, tabs[curTab].page.Selected) + if err != nil { + Error("URL Error", err.Error()) + return nil + } + NewTab() + URL(next) + } else { + NewTab() + } + return nil + case tcell.KeyCtrlR: + Reload() + return nil + case tcell.KeyCtrlH: + URL(viper.GetString("a-general.home")) + return nil + case tcell.KeyCtrlB: + Bookmarks(tabs[curTab]) + tabs[curTab].addToHistory("about:bookmarks") + return nil + case tcell.KeyCtrlD: + go addBookmark() + return nil + case tcell.KeyPgUp: + tabs[curTab].pageUp() + return nil + case tcell.KeyPgDn: + tabs[curTab].pageDown() + return nil + case tcell.KeyRune: + // Regular key was sent + switch string(event.Rune()) { + case " ": + // Space starts typing, like Bombadillo + bottomBar.SetLabel("[::b]URL/Num./Search: [::-]") + bottomBar.SetText("") + // Don't save bottom bar, so that whenever you switch tabs, it's not in that mode + App.SetFocus(bottomBar) + return nil + case "R": + Reload() + return nil + case "b": + histBack(tabs[curTab]) + return nil + case "f": + histForward(tabs[curTab]) + return nil + case "u": + tabs[curTab].pageUp() + return nil + case "d": + tabs[curTab].pageDown() + return nil + } + } + } + // All the keys and operations that can work while a tab IS loading + + switch event.Key() { case tcell.KeyCtrlW: CloseTab() return nil - case tcell.KeyCtrlR: - Reload() - return nil - case tcell.KeyCtrlH: - URL(viper.GetString("a-general.home")) - return nil case tcell.KeyCtrlQ: Stop() return nil - case tcell.KeyCtrlB: - Bookmarks() - addToHist("about:bookmarks") - return nil - case tcell.KeyCtrlD: - go addBookmark() - return nil - case tcell.KeyPgUp: - pageUp() - return nil - case tcell.KeyPgDn: - pageDown() - return nil case tcell.KeyRune: // Regular key was sent switch string(event.Rune()) { - case " ": - // Space starts typing, like Bombadillo - bottomBar.SetLabel("[::b]URL/Num./Search: [::-]") - bottomBar.SetText("") - App.SetFocus(bottomBar) - return nil case "q": Stop() return nil - case "R": - Reload() - return nil - case "b": - histBack() - return nil - case "f": - histForward() - return nil case "?": Help() return nil - case "u": - pageUp() - return nil - case "d": - pageDown() - return nil // Shift+NUMBER keys, for switching to a specific tab case "!": @@ -342,6 +354,8 @@ func Init() { return nil } } + + // Let another element handle the event, it's not a special global key return event }) } @@ -355,87 +369,31 @@ func Stop() { // NewTab opens a new tab and switches to it, displaying the // the default empty content because there's no URL. func NewTab() { - // Create TextView in tabViews and change curTab - // Set the textView options, and the changed func to App.Draw() + // Create TextView and change curTab + // Set the TextView options, and the changed func to App.Draw() // SetDoneFunc to do link highlighting // Add view to pages and switch to it - // But first, turn off link selecting mode in the current tab + // Process current tab before making a new one if curTab > -1 { - tabViews[curTab].Highlight("") + // Turn off link selecting mode in the current tab + tabs[curTab].view.Highlight("") + // Save bottomBar state + tabs[curTab].saveBottomBar() } - selectedLink = "" curTab = NumTabs() - reformatPage(newTabPage) - tabMap[curTab] = newTabPage - reformatMuts[curTab] = &sync.Mutex{} - tabViews[curTab] = cview.NewTextView(). - SetDynamicColors(true). - SetRegions(true). - SetScrollable(true). - SetWrap(false). - SetText(tabMap[curTab].Content). - ScrollToBeginning(). - SetChangedFunc(func() { - App.Draw() - }). - SetDoneFunc(func(key tcell.Key) { - // Altered from: https://gitlab.com/tslocum/cview/-/blob/master/demos/textview/main.go - // Handles being able to select and "click" links with the enter and tab keys - if key == tcell.KeyEsc { - // Stop highlighting - tabViews[curTab].Highlight("") - bottomBar.SetLabel("") - bottomBar.SetText(tabMap[curTab].Url) - selectedLink = "" - } + tabs = append(tabs, makeNewTab()) + temp := newTabPage // Copy + setPage(tabs[curTab], &temp) - currentSelection := tabViews[curTab].GetHighlights() - numSelections := len(tabMap[curTab].Links) - - if key == tcell.KeyEnter { - if len(currentSelection) > 0 && len(tabMap[curTab].Links) > 0 { - // A link was selected, "click" it and load the page it's for - bottomBar.SetLabel("") - selectedLink = "" - linkN, _ := strconv.Atoi(currentSelection[0]) - followLink(tabMap[curTab].Url, tabMap[curTab].Links[linkN]) - return - } else { - tabViews[curTab].Highlight("0").ScrollToHighlight() - // Display link URL in bottomBar - bottomBar.SetLabel("[::b]Link: [::-]") - bottomBar.SetText(tabMap[curTab].Links[0]) - selectedLink = tabMap[curTab].Links[0] - } - } else if len(currentSelection) > 0 { - // There's still a selection, but a different key was pressed, not Enter - - index, _ := strconv.Atoi(currentSelection[0]) - if key == tcell.KeyTab { - index = (index + 1) % numSelections - } else if key == tcell.KeyBacktab { - index = (index - 1 + numSelections) % numSelections - } else { - return - } - tabViews[curTab].Highlight(strconv.Itoa(index)).ScrollToHighlight() - // Display link URL in bottomBar - bottomBar.SetLabel("[::b]Link: [::-]") - bottomBar.SetText(tabMap[curTab].Links[index]) - selectedLink = tabMap[curTab].Links[index] - } - }) - - tabHist[curTab] = []string{} // Can't go backwards, but this isn't the first page either. // The first page will be the next one the user goes to. - tabHistPos[curTab] = -1 + tabs[curTab].history.pos = -1 - tabPages.AddAndSwitchToPage(strconv.Itoa(curTab), tabViews[curTab], true) - App.SetFocus(tabViews[curTab]) + tabPages.AddAndSwitchToPage(strconv.Itoa(curTab), tabs[curTab].view, true) + App.SetFocus(tabs[curTab].view) // Add tab number to the actual place where tabs are show on the screen // Tab regions are 0-indexed but text displayed on the screen starts at 1 @@ -448,6 +406,7 @@ func NewTab() { bottomBar.SetLabel("") bottomBar.SetText("") + tabs[curTab].saveBottomBar() // Draw just in case App.Draw() @@ -470,13 +429,8 @@ func CloseTab() { return } - delete(tabMap, curTab) + tabs = tabs[:len(tabs)-1] tabPages.RemovePage(strconv.Itoa(curTab)) - delete(tabViews, curTab) - delete(reformatMuts, curTab) - - delete(tabHist, curTab) - delete(tabHistPos, curTab) if curTab <= 0 { curTab = NumTabs() - 1 @@ -498,8 +452,10 @@ func CloseTab() { } tabRow.Highlight(strconv.Itoa(curTab)).ScrollToHighlight() - bottomBar.SetLabel("") - bottomBar.SetText(tabMap[curTab].Url) + // Restore previous tab's state + tabs[curTab].applyAll() + + App.SetFocus(tabs[curTab].view) // Just in case App.Draw() @@ -516,25 +472,39 @@ func SwitchTab(tab int) { tab = NumTabs() - 1 } + // Save current tab attributes + if curTab > -1 { + // Save bottomBar state + tabs[curTab].saveBottomBar() + } + curTab = tab % NumTabs() - reformatPageAndSetView(curTab, tabMap[curTab]) + + // Display tab + reformatPageAndSetView(tabs[curTab], tabs[curTab].page) tabPages.SwitchToPage(strconv.Itoa(curTab)) tabRow.Highlight(strconv.Itoa(curTab)).ScrollToHighlight() + tabs[curTab].applyAll() - bottomBar.SetLabel("") - bottomBar.SetText(tabMap[curTab].Url) + App.SetFocus(tabs[curTab].view) // Just in case App.Draw() } func Reload() { - if !tabHasContent() { + if !tabs[curTab].hasContent() { return } - cache.Remove(tabMap[curTab].Url) - go handleURL(tabMap[curTab].Url) + go cache.Remove(tabs[curTab].page.Url) + go func(t *tab) { + handleURL(t, t.page.Url) // goURL is not used bc history shouldn't be added to + if t == tabs[curTab] { + // Display the bottomBar state that handleURL set + t.applyBottomBar() + } + }(tabs[curTab]) } // URL loads and handles the provided URL for the current tab. @@ -543,12 +513,13 @@ func URL(u string) { // Some code is copied in followLink() if u == "about:bookmarks" { - Bookmarks() - addToHist("about:bookmarks") + Bookmarks(tabs[curTab]) + tabs[curTab].addToHistory("about:bookmarks") return } if u == "about:newtab" { - setPage(newTabPage) + temp := newTabPage // Copy + setPage(tabs[curTab], &temp) return } if strings.HasPrefix(u, "about:") { @@ -556,14 +527,9 @@ func URL(u string) { return } - go func() { - final, displayed := handleURL(u) - if displayed { - addToHist(final) - } - }() + go goURL(tabs[curTab], u) } func NumTabs() int { - return len(tabViews) + return len(tabs) } diff --git a/display/history.go b/display/history.go index 4f21296..df60641 100644 --- a/display/history.go +++ b/display/history.go @@ -1,44 +1,25 @@ package display -// Tab number mapped to list of URLs ordered from first to most recent. -var tabHist = make(map[int][]string) - -// Tab number mapped to where in its history you are. -// The value is a valid index of the string slice above. -var tabHistPos = make(map[int]int) - -// addToHist adds the given URL to history. -// It assumes the URL is currently being loaded and displayed on the page. -func addToHist(u string) { - if tabHistPos[curTab] < len(tabHist[curTab])-1 { - // We're somewhere in the middle of the history instead, with URLs ahead and behind. - // The URLs ahead need to be removed so this new URL is the most recent item in the history - tabHist[curTab] = tabHist[curTab][:tabHistPos[curTab]+1] - } - tabHist[curTab] = append(tabHist[curTab], u) - tabHistPos[curTab]++ +// applyHist is a history.go internal function, to load a URL in the history. +func applyHist(t *tab) { + handleURL(t, t.history.urls[t.history.pos]) // Load that position in history + t.applyAll() } -func histForward() { - if tabHistPos[curTab] >= len(tabHist[curTab])-1 { +func histForward(t *tab) { + if t.history.pos >= len(t.history.urls)-1 { // Already on the most recent URL in the history return } - tabHistPos[curTab]++ - go func() { - handleURL(tabHist[curTab][tabHistPos[curTab]]) - applyScroll() - }() + t.history.pos++ + go applyHist(t) } -func histBack() { - if tabHistPos[curTab] <= 0 { +func histBack(t *tab) { + if t.history.pos <= 0 { // First tab in history return } - tabHistPos[curTab]-- - go func() { - handleURL(tabHist[curTab][tabHistPos[curTab]]) - applyScroll() - }() + t.history.pos-- + go applyHist(t) } diff --git a/display/private.go b/display/private.go index 7a1480e..d67831f 100644 --- a/display/private.go +++ b/display/private.go @@ -18,16 +18,15 @@ import ( // This file contains the functions that aren't part of the public API. -// pageUp scrolls up 75% of the height of the terminal, like Bombadillo. -func pageUp() { - row, col := tabViews[curTab].GetScrollOffset() - tabViews[curTab].ScrollTo(row-(termH/4)*3, col) -} - -// pageDown scrolls down 75% of the height of the terminal, like Bombadillo. -func pageDown() { - row, col := tabViews[curTab].GetScrollOffset() - tabViews[curTab].ScrollTo(row+(termH/4)*3, col) +// isValidTab indicates whether the passed tab is still being used, even if it's not currently displayed. +func isValidTab(t *tab) bool { + tempTabs := tabs + for i := range tempTabs { + if tempTabs[i] == t { + return true + } + } + return false } func leftMargin() int { @@ -54,55 +53,17 @@ func textWidth() int { return viper.GetInt("a-general.max_width") } -// pathEscape is the same as url.PathEscape, but it also replaces the +. -func pathEscape(path string) string { +// queryEscape is the same as url.PathEscape, but it also replaces the +. +// This is because Gemini requires percent-escaping for queries. +func queryEscape(path string) string { return strings.ReplaceAll(url.PathEscape(path), "+", "%2B") } -// tabHasContent returns true when the current tab has a page being displayed. -// The most likely situation where false would be returned is when the default -// new tab content is being displayed. -func tabHasContent() bool { - if curTab < 0 { - return false - } - if len(tabViews) < curTab { - // There isn't a TextView for the current tab number - return false - } - if tabMap[curTab].Url == "" { - // Likely the default content page - return false - } - if strings.HasPrefix(tabMap[curTab].Url, "about:") { - return false - } - - _, ok := tabMap[curTab] - return ok // If there's a page, return true -} - -// saveScroll saves where in the page the user was. -// It should be used whenever moving from one page to another. -func saveScroll() { - // It will also be saved in the cache because the cache uses the same pointer - row, col := tabViews[curTab].GetScrollOffset() - tabMap[curTab].Row = row - tabMap[curTab].Column = col -} - -// applyScroll applies the saved scroll values to the current page and tab. -// It should only be used when going backward and forward, not when -// loading a new page (that might have scroll vals cached anyway). -func applyScroll() { - tabViews[curTab].ScrollTo(tabMap[curTab].Row, tabMap[curTab].Column) -} - // resolveRelLink returns an absolute link for the given absolute link and relative one. // It also returns an error if it could not resolve the links, which should be displayed // to the user. -func resolveRelLink(prev, next string) (string, error) { - if !tabHasContent() { +func resolveRelLink(t *tab, prev, next string) (string, error) { + if !t.hasContent() { return next, nil } @@ -116,12 +77,13 @@ func resolveRelLink(prev, next string) (string, error) { // followLink should be used when the user "clicks" a link on a page. // Not when a URL is opened on a new tab for the first time. -func followLink(prev, next string) { +// It will handle setting the bottomBar. +func followLink(t *tab, prev, next string) { // Copied from URL() if next == "about:bookmarks" { - Bookmarks() - addToHist("about:bookmarks") + Bookmarks(t) + t.addToHistory("about:bookmarks") return } if strings.HasPrefix(next, "about:") { @@ -129,19 +91,14 @@ func followLink(prev, next string) { return } - if tabHasContent() { - saveScroll() // Likely called later on, it's here just in case - nextURL, err := resolveRelLink(prev, next) + if t.hasContent() { + t.saveScroll() // Likely called later on, it's here just in case + nextURL, err := resolveRelLink(t, prev, next) if err != nil { Error("URL Error", err.Error()) return } - go func() { - final, displayed := handleURL(nextURL) - if displayed { - addToHist(final) - } - }() + go goURL(t, nextURL) return } // No content on current tab, so the "prev" URL is not valid. @@ -151,12 +108,7 @@ func followLink(prev, next string) { Error("URL Error", "Link URL could not be parsed") return } - go func() { - final, displayed := handleURL(next) - if displayed { - addToHist(final) - } - }() + go goURL(t, next) } // reformatPage will take the raw page content and reformat it according to the current terminal dimensions. @@ -185,30 +137,54 @@ func reformatPage(p *structs.Page) { // reformatPageAndSetView is for reformatting a page that is already being displayed. // setPage should be used when a page is being loaded for the first time. -func reformatPageAndSetView(tab int, p *structs.Page) { - saveScroll() +func reformatPageAndSetView(t *tab, p *structs.Page) { + t.saveScroll() reformatPage(p) - tabViews[tab].SetText(p.Content) - applyScroll() // Go back to where you were, roughly + t.view.SetText(p.Content) + t.applyScroll() // Go back to where you were, roughly } -// setPage displays a Page on the current tab. -func setPage(p *structs.Page) { - saveScroll() // Save the scroll of the previous page +// setPage displays a Page on the passed tab number. +// The bottomBar is not actually changed in this func +func setPage(t *tab, p *structs.Page) { + if !isValidTab(t) { + // Don't waste time reformatting an invalid tab + return + } + + t.saveScroll() // Save the scroll of the previous page // Make sure the page content is fitted to the terminal every time it's displayed reformatPage(p) // Change page on screen - tabMap[curTab] = p - tabViews[curTab].SetText(p.Content) - tabViews[curTab].Highlight("") // Turn off highlights - tabViews[curTab].ScrollToBeginning() + t.page = p + t.view.SetText(p.Content) + t.view.Highlight("") // Turn off highlights, other funcs may restore if necessary + t.view.ScrollToBeginning() // Setup display - App.SetFocus(tabViews[curTab]) - bottomBar.SetLabel("") - bottomBar.SetText(p.Url) + App.SetFocus(t.view) + + // Save bottom bar for the tab - TODO: other funcs will apply/display it + t.barLabel = "" + t.barText = p.Url +} + +// goURL is like handleURL, but takes care of history and the bottomBar. +// It should be preferred over handleURL in most cases. +// It has no return values to be processed. +// +// It should be called in a goroutine. +func goURL(t *tab, u string) { + final, displayed := handleURL(t, u) + if displayed { + t.addToHistory(final) + } + if t == tabs[curTab] { + // Display the bottomBar state that handleURL set + t.applyBottomBar() + } } // handleURL displays whatever action is needed for the provided URL, @@ -220,15 +196,36 @@ func setPage(p *structs.Page) { // If there is some error, it will return "". // The second returned item is a bool indicating if page content was displayed. // It returns false for Errors, other protocols, etc. -func handleURL(u string) (string, bool) { +// +// The bottomBar is not actually changed in this func, except during loading. +// The func that calls this one should apply the bottomBar values if necessary. +func handleURL(t *tab, u string) (string, bool) { defer App.Draw() // Just in case - App.SetFocus(tabViews[curTab]) + // Save for resetting on error + oldLable := t.barLabel + oldText := t.barText + + // Custom return function + ret := func(s string, b bool) (string, bool) { + if !b { + // Reset bottomBar if page wasn't loaded + t.barLabel = oldLable + t.barText = oldText + } + t.mode = tabModeDone + return s, b + } + + t.barLabel = "" + bottomBar.SetLabel("") + + App.SetFocus(t.view) // To allow linking to the bookmarks page, and history browsing if u == "about:bookmarks" { - Bookmarks() - return "about:bookmarks", true + Bookmarks(t) + return ret("about:bookmarks", true) } u = normalizeURL(u) @@ -236,8 +233,7 @@ func handleURL(u string) (string, bool) { parsed, err := url.Parse(u) if err != nil { Error("URL Error", err.Error()) - bottomBar.SetText(tabMap[curTab].Url) - return "", false + return ret("", false) } if strings.HasPrefix(u, "http") { @@ -259,27 +255,33 @@ func handleURL(u string) (string, bool) { Error("HTTP Error", "Error executing custom browser command: "+err.Error()) } } - bottomBar.SetText(tabMap[curTab].Url) - return "", false + return ret("", false) } if !strings.HasPrefix(u, "gemini") { Error("Protocol Error", "Only gemini and HTTP are supported. URL was "+u) - bottomBar.SetText(tabMap[curTab].Url) - return "", false + return ret("", false) } // Gemini URL // Load page from cache if possible page, ok := cache.Get(u) if ok { - setPage(page) - return u, true + setPage(t, page) + return ret(u, true) } // Otherwise download it bottomBar.SetText("Loading...") + t.barText = "Loading..." // Save it too, in case the tab switches during loading + t.mode = tabModeLoading App.Draw() res, err := client.Fetch(u) + + // Loading may have taken a while, make sure tab is still valid + if !isValidTab(t) { + return ret("", false) + } + if err == client.ErrTofu { if Tofu(parsed.Host) { // They want to continue anyway @@ -287,36 +289,31 @@ func handleURL(u string) (string, bool) { // Response can be used further down, no need to reload } else { // They don't want to continue - // Set the bar back to original URL - bottomBar.SetText(tabMap[curTab].Url) - return "", false + return ret("", false) } } else if err != nil { Error("URL Fetch Error", err.Error()) - // Set the bar back to original URL - bottomBar.SetText(tabMap[curTab].Url) - return "", false + return ret("", false) } if renderer.CanDisplay(res) { page, err := renderer.MakePage(u, res, textWidth(), leftMargin()) + // Rendering may have taken a while, make sure tab is still valid + if !isValidTab(t) { + return ret("", false) + } + page.Width = termW if err != nil { Error("Page Error", "Issuing creating page: "+err.Error()) - // Set the bar back to original URL - bottomBar.SetText(tabMap[curTab].Url) - return "", false + return ret("", false) } - cache.Add(page) - setPage(page) - return u, true + go cache.Add(page) + setPage(t, page) + return ret(u, true) } // Not displayable // Could be a non 20 (or 21) status code, or a different kind of document - // Set the bar back to original URL - bottomBar.SetText(tabMap[curTab].Url) - App.Draw() - // Handle each status code switch gemini.SimplifyStatus(res.Status) { case 10: @@ -324,36 +321,35 @@ func handleURL(u string) (string, bool) { if ok { // Make another request with the query string added // + chars are replaced because PathEscape doesn't do that - parsed.RawQuery = pathEscape(userInput) + parsed.RawQuery = queryEscape(userInput) if len(parsed.String()) > 1024 { // 1024 is the max size for URLs in the spec Error("Input Error", "URL for that input would be too long.") - return "", false + return ret("", false) } - return handleURL(parsed.String()) + return ret(handleURL(t, parsed.String())) } - return "", false + return ret("", false) case 30: parsedMeta, err := url.Parse(res.Meta) if err != nil { Error("Redirect Error", "Invalid URL: "+err.Error()) - return "", false + return ret("", false) } redir := parsed.ResolveReference(parsedMeta).String() - if YesNo("Follow redirect?\n" + redir) { - return handleURL(redir) + return handleURL(t, redir) } - return "", false + return ret("", false) case 40: - Error("Temporary Failure", cview.Escape(res.Meta)) // Escaped just in case, to not allow malicious meta strings - return "", false + Error("Temporary Failure", cview.Escape(res.Meta)) + return ret("", false) case 50: Error("Permanent Failure", cview.Escape(res.Meta)) - return "", false + return ret("", false) case 60: Info("The server requested a certificate. Cert handling is coming to Amfora soon!") - return "", false + return ret("", false) } // Status code 20, but not a document that can be displayed yes := YesNo("This type of file can't be displayed. Downloading will be implemented soon. Would like to open the file in a HTTPS proxy for now?") @@ -376,7 +372,7 @@ func handleURL(u string) (string, bool) { } App.Draw() } - return "", false + return ret("", false) } // normalizeURL attempts to make URLs that are different strings diff --git a/display/tab.go b/display/tab.go new file mode 100644 index 0000000..78d34f6 --- /dev/null +++ b/display/tab.go @@ -0,0 +1,230 @@ +package display + +import ( + "strconv" + "strings" + "sync" + + "github.com/gdamore/tcell" + "github.com/makeworld-the-better-one/amfora/structs" + "gitlab.com/tslocum/cview" +) + +type tabMode int + +const ( + tabModeDone tabMode = iota + tabModeLoading +) + +type tabHistory struct { + urls []string + pos int // Position: where in the list of URLs we are +} + +// tab hold the information needed for each browser tab. +type tab struct { + page *structs.Page + view *cview.TextView + history *tabHistory + mode tabMode + reformatMut *sync.Mutex // Mutex for reformatting, so there's only one reformat job at once + barLabel string // The bottomBar label for the tab + barText string // The bottomBar text for the tab +} + +// makeNewTab initializes an tab struct with no content. +func makeNewTab() *tab { + t := tab{ + page: &structs.Page{Mode: structs.ModeOff}, + view: cview.NewTextView(). + SetDynamicColors(true). + SetRegions(true). + SetScrollable(true). + SetWrap(false). + SetChangedFunc(func() { + App.Draw() + }), + history: &tabHistory{}, + reformatMut: &sync.Mutex{}, + mode: tabModeDone, + } + t.view.SetDoneFunc(func(key tcell.Key) { + // Altered from: https://gitlab.com/tslocum/cview/-/blob/master/demos/textview/main.go + // Handles being able to select and "click" links with the enter and tab keys + + tab := curTab // Don't let it change in the middle of the code + + if key == tcell.KeyEsc && tabs[tab].mode == tabModeDone { + // Stop highlighting + bottomBar.SetLabel("") + bottomBar.SetText(tabs[tab].page.Url) + tabs[tab].clearSelected() + tabs[tab].saveBottomBar() + return + } + + if len(tabs[tab].page.Links) <= 0 { + // No links on page + return + } + + currentSelection := tabs[tab].view.GetHighlights() + numSelections := len(tabs[tab].page.Links) + + if key == tcell.KeyEnter { + if len(currentSelection) > 0 { + // A link was selected, "click" it and load the page it's for + bottomBar.SetLabel("") + linkN, _ := strconv.Atoi(currentSelection[0]) + tabs[tab].page.Selected = tabs[tab].page.Links[linkN] + tabs[tab].page.SelectedID = currentSelection[0] + followLink(tabs[tab], tabs[tab].page.Url, tabs[tab].page.Links[linkN]) + return + } else { + // They've started link highlighting + tabs[tab].page.Mode = structs.ModeLinkSelect + + tabs[tab].view.Highlight("0").ScrollToHighlight() + // Display link URL in bottomBar + bottomBar.SetLabel("[::b]Link: [::-]") + bottomBar.SetText(tabs[tab].page.Links[0]) + tabs[tab].saveBottomBar() + tabs[tab].page.Selected = tabs[tab].page.Links[0] + tabs[tab].page.SelectedID = "0" + } + } else if len(currentSelection) > 0 { + // There's still a selection, but a different key was pressed, not Enter + + index, _ := strconv.Atoi(currentSelection[0]) + if key == tcell.KeyTab { + index = (index + 1) % numSelections + } else if key == tcell.KeyBacktab { + index = (index - 1 + numSelections) % numSelections + } else { + return + } + tabs[tab].view.Highlight(strconv.Itoa(index)).ScrollToHighlight() + // Display link URL in bottomBar + bottomBar.SetLabel("[::b]Link: [::-]") + bottomBar.SetText(tabs[tab].page.Links[index]) + tabs[tab].saveBottomBar() + tabs[tab].page.Selected = tabs[tab].page.Links[index] + tabs[tab].page.SelectedID = strconv.Itoa(index) + } + }) + + return &t +} + +// addToHistory adds the given URL to history. +// It assumes the URL is currently being loaded and displayed on the page. +func (t *tab) addToHistory(u string) { + if t.history.pos < len(t.history.urls)-1 { + // We're somewhere in the middle of the history instead, with URLs ahead and behind. + // The URLs ahead need to be removed so this new URL is the most recent item in the history + t.history.urls = t.history.urls[:t.history.pos+1] + } + t.history.urls = append(t.history.urls, u) + t.history.pos++ +} + +// pageUp scrolls up 75% of the height of the terminal, like Bombadillo. +func (t *tab) pageUp() { + row, col := t.view.GetScrollOffset() + t.view.ScrollTo(row-(termH/4)*3, col) +} + +// pageDown scrolls down 75% of the height of the terminal, like Bombadillo. +func (t *tab) pageDown() { + row, col := t.view.GetScrollOffset() + t.view.ScrollTo(row+(termH/4)*3, col) +} + +// hasContent returns true when the tab has a page that could be displayed. +// The most likely situation where false would be returned is when the default +// new tab content is being displayed. +func (t *tab) hasContent() bool { + if t.page == nil || t.view == nil { + return false + } + if t.page.Url == "" { + return false + } + if strings.HasPrefix(t.page.Url, "about:") { + return false + } + if t.page.Content == "" { + return false + } + return true +} + +// saveScroll saves where in the page the user was. +// It should be used whenever moving from one page to another. +func (t *tab) saveScroll() { + // It will also be saved in the cache because the cache uses the same pointer + row, col := t.view.GetScrollOffset() + t.page.Row = row + t.page.Column = col +} + +// applyScroll applies the saved scroll values to the page and tab. +// It should only be used when going backward and forward. +func (t *tab) applyScroll() { + t.view.ScrollTo(t.page.Row, t.page.Column) +} + +// saveBottomBar saves the current bottomBar values in the tab. +func (t *tab) saveBottomBar() { + t.barLabel = bottomBar.GetLabel() + t.barText = bottomBar.GetText() +} + +// applyBottomBar sets the bottomBar using the stored tab values +func (t *tab) applyBottomBar() { + bottomBar.SetLabel(t.barLabel) + bottomBar.SetText(t.barText) +} + +// clearSelected turns off any selection that was going on. +// It does not affect the bottomBar. +func (t *tab) clearSelected() { + t.page.Mode = structs.ModeOff + t.page.Selected = "" + t.page.SelectedID = "" + t.view.Highlight("") +} + +// applySelected selects whatever is stored as the selected element in the struct, +// and sets the mode accordingly. +// It is safe to call if nothing was selected previously. +// +// applyBottomBar should be called after, as this func might set some bottomBar values. +func (t *tab) applySelected() { + if t.page.Mode == structs.ModeOff { + // Just in case + t.page.Selected = "" + t.page.SelectedID = "" + t.view.Highlight("") + return + } else if t.page.Mode == structs.ModeLinkSelect { + t.view.Highlight(t.page.SelectedID) + + if t.mode == tabModeDone { + // Page is not loading so bottomBar can change + t.barLabel = "[::b]Link: [::-]" + t.barText = t.page.Selected + } + } +} + +// applyAll uses applyScroll and applySelected to put a tab's TextView back the way it was. +// It also uses applyBottomBar if this is the current tab. +func (t *tab) applyAll() { + t.applySelected() + t.applyScroll() + if t == tabs[curTab] { + t.applyBottomBar() + } +} diff --git a/structs/structs.go b/structs/structs.go index adc507a..426081b 100644 --- a/structs/structs.go +++ b/structs/structs.go @@ -7,21 +7,31 @@ const ( TextPlain Mediatype = "text/plain" ) +type PageMode int + +const ( + ModeOff PageMode = iota // Regular mode + ModeLinkSelect // When the enter key is pressed, allow for tab-based link navigation +) + // Page is for storing UTF-8 text/gemini pages, as well as text/plain pages. type Page struct { - Url string - Mediatype Mediatype - Raw string // The raw response, as received over the network - Content string // The processed content, NOT raw. Uses cview colour tags. All link/link texts must have region tags. It will also have a left margin. - Links []string // URLs, for each region in the content. - Row int // Scroll position - Column int // ditto - Width int // The width of the terminal at the time when the Content was set. This is to know when reformatting should happen. + Url string + Mediatype Mediatype + Raw string // The raw response, as received over the network + Content string // The processed content, NOT raw. Uses cview colour tags. All link/link texts must have region tags. It will also have a left margin. + Links []string // URLs, for each region in the content. + Row int // Scroll position + Column int // ditto + Width int // The width of the terminal at the time when the Content was set. This is to know when reformatting should happen. + Selected string // The current text or link selected + SelectedID string // The cview region ID for the selected text/link + Mode PageMode } // Size returns an approx. size of a Page in bytes. func (p *Page) Size() int { - b := len(p.Raw) + len(p.Content) + len(p.Url) + b := len(p.Raw) + len(p.Content) + len(p.Url) + len(p.Selected) + len(p.SelectedID) for i := range p.Links { b += len(p.Links[i]) } diff --git a/structs/structs_test.go b/structs/structs_test.go deleted file mode 100644 index fe18f40..0000000 --- a/structs/structs_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package structs - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSize(t *testing.T) { - p := Page{ - Url: "12345", - Raw: "12345", - Content: "12345", - Links: []string{"1", "2", "3", "4", "5"}, - } - assert.Equal(t, 20, p.Size(), "sizes should be equal") -}