package display import ( "fmt" "net/url" "path" "strconv" "strings" "code.rocketnine.space/tslocum/cview" "github.com/atotto/clipboard" "github.com/gdamore/tcell/v2" "github.com/makeworld-the-better-one/amfora/config" "github.com/makeworld-the-better-one/amfora/structs" ) type tabMode int const ( tabModeDone tabMode = iota tabModeLoading ) // tabHistoryPageCache is fields from the Page struct, cached here to solve #122 // See structs/structs.go for an explanation of the fields. type tabHistoryPageCache struct { row int column int selected string selectedID string mode structs.PageMode } type tabHistory struct { urls []string pos int // Position: where in the list of URLs we are pageCache []*tabHistoryPageCache } // tab hold the information needed for each browser tab. type tab struct { page *structs.Page view *cview.TextView history *tabHistory mode tabMode barLabel string // The bottomBar label for the tab barText string // The bottomBar text for the tab preferURLHandler bool // For #143, use URL handler over proxy } // makeNewTab initializes an tab struct with no content. func makeNewTab() *tab { t := tab{ page: &structs.Page{Mode: structs.ModeOff}, view: cview.NewTextView(), history: &tabHistory{}, mode: tabModeDone, } t.view.SetDynamicColors(true) t.view.SetRegions(true) t.view.SetScrollable(true) t.view.SetWrap(false) t.view.SetScrollBarVisibility(config.ScrollBar) t.view.SetScrollBarColor(config.GetColor("scrollbar")) t.view.SetChangedFunc(func() { App.Draw() }) t.view.SetDoneFunc(func(key tcell.Key) { // Altered from: // https://gitlab.com/tslocum/cview/-/blob/1f765c8695c3f4b35dae57f469d3aee0b1adbde7/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 tabs[tab].mode != tabModeDone { return } if key == tcell.KeyEsc { // 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 && len(currentSelection) > 0 { // A link is selected and enter was pressed: "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] tabs[tab].preferURLHandler = false // Reset in case go followLink(tabs[tab], tabs[tab].page.URL, tabs[tab].page.Links[linkN]) return } if len(currentSelection) == 0 && (key == tcell.KeyEnter || key == tcell.KeyTab) { // They've started link highlighting tabs[tab].page.Mode = structs.ModeLinkSelect tabs[tab].view.Highlight("0") tabs[tab].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" } 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)) tabs[tab].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) } }) t.view.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { // Capture scrolling and change the left margin size accordingly, see #197 // This was also touched by #222 // This also captures any tab-specific events now if t.mode != tabModeDone { // Any events that should be caught when the tab is loading is handled in display.go return nil } cmd := config.TranslateKeyEvent(event) // Cmds that aren't single row/column scrolling //nolint:exhaustive switch cmd { case config.CmdBookmarks: Bookmarks(&t) t.addToHistory("about:bookmarks") return nil case config.CmdAddBookmark: go addBookmark() return nil case config.CmdPgup: t.pageUp() return nil case config.CmdPgdn: t.pageDown() return nil case config.CmdSave: if t.hasContent() { savePath, err := downloadPage(t.page) if err != nil { go Error("Download Error", fmt.Sprintf("Error saving page content: %v", err)) } else { go Info(fmt.Sprintf("Page content saved to %s. ", savePath)) } } else { go Info("The current page has no content, so it couldn't be downloaded.") } return nil case config.CmdBack: histBack(&t) return nil case config.CmdForward: histForward(&t) return nil case config.CmdSub: Subscriptions(&t, "about:subscriptions") tabs[curTab].addToHistory("about:subscriptions") return nil case config.CmdCopyPageURL: currentURL := tabs[curTab].page.URL err := clipboard.WriteAll(currentURL) if err != nil { go Error("Copy Error", err.Error()) return nil } return nil case config.CmdCopyTargetURL: currentURL := t.page.URL selectedURL := t.highlightedURL() if selectedURL == "" { return nil } u, _ := url.Parse(currentURL) copiedURL, err := u.Parse(selectedURL) if err != nil { err := clipboard.WriteAll(selectedURL) if err != nil { go Error("Copy Error", err.Error()) return nil } return nil } err = clipboard.WriteAll(copiedURL.String()) if err != nil { go Error("Copy Error", err.Error()) return nil } return nil case config.CmdURLHandlerOpen: currentSelection := t.view.GetHighlights() t.preferURLHandler = true // Copied code from when enter key is pressed if len(currentSelection) > 0 { bottomBar.SetLabel("") linkN, _ := strconv.Atoi(currentSelection[0]) t.page.Selected = t.page.Links[linkN] t.page.SelectedID = currentSelection[0] go followLink(&t, t.page.URL, t.page.Links[linkN]) } return nil } // Number key: 1-9, 0, LINK1-LINK10 if cmd >= config.CmdLink1 && cmd <= config.CmdLink0 { if int(cmd) <= len(t.page.Links) { // It's a valid link number t.preferURLHandler = false // Reset in case go followLink(&t, t.page.URL, t.page.Links[cmd-1]) return nil } } // Scrolling stuff // Copied in scrollTo key := event.Key() mod := event.Modifiers() height, width := t.view.GetBufferSize() _, _, boxW, boxH := t.view.GetInnerRect() // Make boxW accurate by subtracting one if a scrollbar is covering the last // column of text if config.ScrollBar == cview.ScrollBarAlways || (config.ScrollBar == cview.ScrollBarAuto && height > boxH) { boxW-- } if cmd == config.CmdMoveRight || (key == tcell.KeyRight && mod == tcell.ModNone) { // Scrolling to the right if t.page.Column >= leftMargin() { // Scrolled right far enought that no left margin is needed if (t.page.Column-leftMargin())+boxW >= width { // And scrolled as far as possible to the right return nil } } else { // Left margin still exists if boxW-(leftMargin()-t.page.Column) >= width { // But still scrolled as far as possible return nil } } t.page.Column++ } else if cmd == config.CmdMoveLeft || (key == tcell.KeyLeft && mod == tcell.ModNone) { // Scrolling to the left if t.page.Column == 0 { // Can't scroll to the left anymore return nil } t.page.Column-- } else if cmd == config.CmdMoveUp || (key == tcell.KeyUp && mod == tcell.ModNone) { // Scrolling up if t.page.Row > 0 { t.page.Row-- } return event } else if cmd == config.CmdMoveDown || (key == tcell.KeyDown && mod == tcell.ModNone) { // Scrolling down if t.page.Row < height { t.page.Row++ } return event } else if cmd == config.CmdBeginning { t.page.Row = 0 // This is required because cview will also set the column (incorrectly) // if it handles this event itself t.applyScroll() App.Draw() return nil } else if cmd == config.CmdEnd { t.page.Row = height t.applyScroll() App.Draw() return nil } else { // Some other key, stop processing it return event } t.applyHorizontalScroll() App.Draw() return nil }) return &t } // historyCachePage caches certain info about the current page in the tab's history, // see #122 for details. func (t *tab) historyCachePage() { if t.page == nil || t.page.URL == "" || t.history.pageCache == nil || len(t.history.pageCache) == 0 { return } t.history.pageCache[t.history.pos] = &tabHistoryPageCache{ row: t.page.Row, column: t.page.Column, selected: t.page.Selected, selectedID: t.page.SelectedID, mode: t.page.Mode, } } // 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] // Same for page cache t.history.pageCache = t.history.pageCache[:t.history.pos+1] } t.history.urls = append(t.history.urls, u) t.history.pos++ // Cache page info for #122 t.history.pageCache = append(t.history.pageCache, &tabHistoryPageCache{}) // Add new spot t.historyCachePage() // Fill it with data } // pageUp scrolls up 75% of the height of the terminal, like Bombadillo. func (t *tab) pageUp() { t.page.Row -= termH / 2 if t.page.Row < 0 { t.page.Row = 0 } t.applyScroll() } // pageDown scrolls down 75% of the height of the terminal, like Bombadillo. func (t *tab) pageDown() { height, _ := t.view.GetBufferSize() t.page.Row += termH / 2 if t.page.Row > height { t.page.Row = height } t.applyScroll() } // hasContent returns false when the tab's page is malformed, // has no content or URL. func (t *tab) hasContent() bool { if t.page == nil || t.view == nil { return false } if t.page.URL == "" { return false } if t.page.Content == "" { return false } return true } // isAnAboutPage returns true when the tab's page is an about page func (t *tab) isAnAboutPage() bool { return strings.HasPrefix(t.page.URL, "about:") } // applyHorizontalScroll handles horizontal scroll logic including left margin resizing, // see #197 for details. Use applyScroll instead. // // In certain cases it will still use and apply the saved Row. func (t *tab) applyHorizontalScroll() { i := tabNumber(t) if i == -1 { // Tab is not actually being used and should not be (re)added to the browser return } if t.page.Column >= leftMargin() { // Scrolled to the right far enough that no left margin is needed browser.AddTab( strconv.Itoa(i), t.label(), makeContentLayout(t.view, 0), ) t.view.ScrollTo(t.page.Row, t.page.Column-leftMargin()) } else { // Left margin is still needed, but is not necessarily at the right size by default browser.AddTab( strconv.Itoa(i), t.label(), makeContentLayout(t.view, leftMargin()-t.page.Column), ) } } // 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, 0) t.applyHorizontalScroll() } // scrollTo scrolls the current tab to specified position. Like // cview.TextView.ScrollTo but using the custom scrolling logic required by #196. func (t *tab) scrollTo(row, col int) { height, width := t.view.GetBufferSize() // Keep row and col within limits if row < 0 { row = 0 } else if row > height { row = height } if col < 0 { col = 0 } else if col > width { col = width } t.page.Row = row t.page.Column = col t.applyScroll() App.Draw() } // scrollToHighlight scrolls the current tab to specified position. Like // cview.TextView.ScrollToHighlight but using the custom scrolling logic // required by #196. func (t *tab) scrollToHighlight() { t.view.ScrollToHighlight() App.Draw() t.scrollTo(t.view.GetScrollOffset()) } // 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() } } // highlightedURL returns the currently selected URL func (t *tab) highlightedURL() string { currentSelection := tabs[curTab].view.GetHighlights() if len(currentSelection) > 0 { linkN, _ := strconv.Atoi(currentSelection[0]) selectedURL := tabs[curTab].page.Links[linkN] return selectedURL } return "" } // label returns the label to use for the tab name func (t *tab) label() string { tn := tabNumber(t) if tn < 0 { // Invalid tab, shouldn't happen return "" } // Increment so there's no tab 0 in the label tn++ if t.page.URL == "" || t.page.URL == "about:newtab" { // Just use tab number // Spaces around to keep original Amfora look return fmt.Sprintf(" %d ", tn) } if strings.HasPrefix(t.page.URL, "about:") { // Don't look for domain, put the whole URL except query strings return strings.SplitN(t.page.URL, "?", 2)[0] } if strings.HasPrefix(t.page.URL, "file://") { // File URL, use file or folder as tab name return cview.Escape(path.Base(t.page.URL[7:])) } // Otherwise, it's a Gemini URL pu, err := url.Parse(t.page.URL) if err != nil { return fmt.Sprintf(" %d ", tn) } return pu.Host }