1
0
mirror of https://github.com/makew0rld/amfora.git synced 2025-01-03 14:56:27 -05:00
amfora/display/display.go
makeworld 90654cd2cf Replace left margin spaces with an empty primitive
This commit introduced flashing issues in the tab row when reformatting,
and even can panic in certain reformatting situations. These bugs have
been logged in the inital comment of the PR.
2020-12-29 17:35:29 -05:00

586 lines
16 KiB
Go

package display
import (
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"github.com/gdamore/tcell/v2"
"github.com/makeworld-the-better-one/amfora/cache"
"github.com/makeworld-the-better-one/amfora/config"
"github.com/makeworld-the-better-one/amfora/renderer"
"github.com/makeworld-the-better-one/amfora/structs"
"github.com/makeworld-the-better-one/go-gemini"
"github.com/spf13/viper"
"gitlab.com/tslocum/cview"
)
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 user input and URL display bar at the bottom
var bottomBar = cview.NewInputField()
// When the bottom bar string has a space, this regex decides whether it's
// a non-encoded URL or a search string.
// See this comment for details:
// https://github.com/makeworld-the-better-one/amfora/issues/138#issuecomment-740961292
var hasSpaceisURL = regexp.MustCompile(`[^ ]+\.[^ ].*/.`)
// Viewer for the tab primitives
// Pages are named as strings of tab numbers - so the textview for the first tab
// is held in the page named "0".
// The only pages that don't confine to this scheme are those named after modals,
// which are used to draw modals on top the current tab.
// Ex: "info", "error", "input", "yesno"
var panels = cview.NewPanels()
// Tabbed viewer for primitives
// Panels are named as strings of tab numbers - so the textview for the first tab
// is held in the page named "0".
var browser = cview.NewTabbedPanels()
// Root layout
var layout = cview.NewFlex()
var newTabPage structs.Page
var versionPage structs.Page
// Global mutex for changing the size of the left margin on all tabs.
var reformatMu = sync.Mutex{}
var App = cview.NewApplication()
func Init(version, commit, builtBy string) {
versionContent := fmt.Sprintf(
"# Amfora Version Info\n\nAmfora: %s\nCommit: %s\nBuilt by: %s",
version, commit, builtBy,
)
renderVersionContent, versionLinks := renderer.RenderGemini(versionContent, textWidth(), false)
versionPage = structs.Page{
Raw: versionContent,
Content: renderVersionContent,
Links: versionLinks,
URL: "about:version",
Width: -1, // Force reformatting on first display
Mediatype: structs.TextGemini,
}
App.EnableMouse(false)
App.SetRoot(layout, true)
App.SetAfterResizeFunc(func(width int, height int) {
// Store for calculations
termW = width
termH = height
// Make sure the current tab content is reformatted when the terminal size changes
go func(t *tab) {
reformatMu.Lock()
// Lock the app to prevent screen updates until this is done, because calling
// browser.AddTab updates the display
for i := range tabs {
// Overwrite tabs with a new, differently sized, left margin
browser.AddTab(strconv.Itoa(i), makeTabLabel(strconv.Itoa(i+1)), makeContentLayout(tabs[i].view))
if tabs[i] == t {
// Reformat page ASAP, in the middle of loop
// TODO The per-tab mutext is unecessary if the global one is used
t.reformatMu.Lock() // Only one reformat job per tab
reformatPageAndSetView(t, t.page)
t.reformatMu.Unlock()
}
}
App.Draw()
reformatMu.Unlock()
}(tabs[curTab])
})
panels.AddPanel("browser", browser, true, true)
helpInit()
layout.SetDirection(cview.FlexRow)
layout.AddItem(panels, 0, 1, true)
layout.AddItem(bottomBar, 1, 1, false)
if viper.GetBool("a-general.color") {
layout.SetBackgroundColor(config.GetColor("bg"))
bottomBar.SetBackgroundColor(config.GetColor("bottombar_bg"))
bottomBar.SetLabelColor(config.GetColor("bottombar_label"))
bottomBar.SetFieldBackgroundColor(config.GetColor("bottombar_bg"))
bottomBar.SetFieldTextColor(config.GetColor("bottombar_text"))
browser.SetTabBackgroundColor(config.GetColor("bg"))
browser.SetTabBackgroundColorFocused(config.GetColor("tab_num"))
browser.SetTabTextColor(config.GetColor("tab_num"))
browser.SetTabTextColorFocused(config.GetColor("bg"))
browser.SetTabSwitcherDivider(
"",
fmt.Sprintf("[%s:%s]|[-]", config.GetColorString("tab_divider"), config.GetColorString("bg")),
fmt.Sprintf("[%s:%s]|[-]", config.GetColorString("tab_divider"), config.GetColorString("bg")),
)
browser.Switcher.SetBackgroundColor(config.GetColor("bg"))
} else {
bottomBar.SetBackgroundColor(tcell.ColorWhite)
bottomBar.SetLabelColor(tcell.ColorBlack)
bottomBar.SetFieldBackgroundColor(tcell.ColorWhite)
bottomBar.SetFieldTextColor(tcell.ColorBlack)
browser.SetTabBackgroundColor(tcell.ColorBlack)
browser.SetTabBackgroundColorFocused(tcell.ColorWhite)
browser.SetTabTextColor(tcell.ColorWhite)
browser.SetTabTextColorFocused(tcell.ColorBlack)
browser.SetTabSwitcherDivider("", "|", "|")
}
bottomBar.SetDoneFunc(func(key tcell.Key) {
tab := curTab
tabs[tab].saveScroll()
// 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)
}
//nolint:exhaustive
switch key {
case tcell.KeyEnter:
// Figure out whether it's a URL, link number, or search
// And send out a request
query := bottomBar.GetText()
if strings.TrimSpace(query) == "" {
// Ignore
reset()
return
}
if query[0] == '.' && tabs[tab].hasContent() {
// Relative url
current, err := url.Parse(tabs[tab].page.URL)
if err != nil {
// This shouldn't occur
return
}
if query == ".." && tabs[tab].page.URL[len(tabs[tab].page.URL)-1] != '/' {
// Support what ".." used to work like
// If on /dir/doc.gmi, got to /dir/
query = "./"
}
target, err := current.Parse(query)
if err != nil {
// Invalid relative url
return
}
URL(target.String())
return
}
i, err := strconv.Atoi(query)
if err != nil {
if strings.HasPrefix(query, "new:") && len(query) > 4 {
// 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(tabs[tab].page.Links) && i > 0 {
// Open new tab and load link
oldTab := tab
NewTab()
// Resolve and follow link manually
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())
return
}
} else {
// It's a full URL or search term
// Detect if it's a search or URL
if (strings.Contains(query, " ") && !hasSpaceisURL.MatchString(query)) ||
(!strings.HasPrefix(query, "//") && !strings.Contains(query, "://") &&
!strings.Contains(query, ".")) {
// Has a space and follows regex, OR
// doesn't start with "//", contain "://", and doesn't have a dot either.
// Then it's a search
u := viper.GetString("a-general.search") + "?" + gemini.QueryEscape(query)
// Don't use the cached version of the search
cache.RemovePage(normalizeURL(u))
URL(u)
} else {
// Full URL
// Don't use cached version for manually entered URL
cache.RemovePage(normalizeURL(fixUserURL(query)))
URL(query)
}
return
}
}
if i <= len(tabs[tab].page.Links) && i > 0 {
// It's a valid link number
followLink(tabs[tab], tabs[tab].page.URL, tabs[tab].page.Links[i-1])
return
}
// Invalid link number, don't do anything
reset()
return
case tcell.KeyEsc:
// Set back to what it was
reset()
return
}
// Other potential keys are Tab and Backtab, they are ignored
})
// Render the default new tab content ONCE and store it for later
// This code is repeated in Reload()
newTabContent := getNewTabContent()
renderedNewTabContent, newTabLinks := renderer.RenderGemini(newTabContent, textWidth(), false)
newTabPage = structs.Page{
Raw: newTabContent,
Content: renderedNewTabContent,
Links: newTabLinks,
URL: "about:newtab",
Width: -1, // Force reformatting on first display
Mediatype: structs.TextGemini,
}
modalInit()
// Setup map of keys to functions here
// Changing tabs, new tab, etc
App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
_, ok := App.GetFocus().(*cview.Button)
if ok {
// It's focused on a modal right now, nothing should interrupt
return event
}
_, ok = App.GetFocus().(*cview.InputField)
if ok {
// An InputField is in focus, nothing should interrupt
return event
}
_, ok = App.GetFocus().(*cview.Modal)
if ok {
// It's focused on a modal right now, nothing should interrupt
return event
}
_, ok = App.GetFocus().(*cview.Table)
if ok {
// It's focused on help right now
return event
}
// To add a configurable global key command, you'll need to update one of
// the two switch statements here. You'll also need to add an enum entry in
// config/keybindings.go, update KeyInit() in config/keybindings.go, add a default
// keybinding in config/config.go and update the help panel in display/help.go
cmd := config.TranslateKeyEvent(event)
if tabs[curTab].mode == tabModeDone {
// All the keys and operations that can only work while NOT loading
//nolint:exhaustive
switch cmd {
case config.CmdReload:
Reload()
return nil
case config.CmdHome:
URL(viper.GetString("a-general.home"))
return nil
case config.CmdBookmarks:
Bookmarks(tabs[curTab])
tabs[curTab].addToHistory("about:bookmarks")
return nil
case config.CmdAddBookmark:
go addBookmark()
return nil
case config.CmdPgup:
tabs[curTab].pageUp()
return nil
case config.CmdPgdn:
tabs[curTab].pageDown()
return nil
case config.CmdSave:
if tabs[curTab].hasContent() {
savePath, err := downloadPage(tabs[curTab].page)
if err != nil {
Error("Download Error", fmt.Sprintf("Error saving page content: %v", err))
} else {
Info(fmt.Sprintf("Page content saved to %s. ", savePath))
}
} else {
Info("The current page has no content, so it couldn't be downloaded.")
}
return nil
case config.CmdBottom:
// 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 config.CmdEdit:
// Letter e allows to edit current URL
bottomBar.SetLabel("[::b]Edit URL: [::-]")
bottomBar.SetText(tabs[curTab].page.URL)
App.SetFocus(bottomBar)
return nil
case config.CmdBack:
histBack(tabs[curTab])
return nil
case config.CmdForward:
histForward(tabs[curTab])
return nil
case config.CmdSub:
Subscriptions(tabs[curTab], "about:subscriptions")
tabs[curTab].addToHistory("about:subscriptions")
return nil
case config.CmdAddSub:
go addSubscription()
return nil
}
// Number key: 1-9, 0, LINK1-LINK10
if cmd >= config.CmdLink1 && cmd <= config.CmdLink0 {
if int(cmd) <= len(tabs[curTab].page.Links) {
// It's a valid link number
followLink(tabs[curTab], tabs[curTab].page.URL, tabs[curTab].page.Links[cmd-1])
return nil
}
}
}
// All the keys and operations that can work while a tab IS loading
//nolint:exhaustive
switch cmd {
case config.CmdNewTab:
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 config.CmdCloseTab:
CloseTab()
return nil
case config.CmdQuit:
Stop()
return nil
case config.CmdPrevTab:
// Wrap around, allow for modulo with negative numbers
n := NumTabs()
SwitchTab((((curTab - 1) % n) + n) % n)
return nil
case config.CmdNextTab:
SwitchTab((curTab + 1) % NumTabs())
return nil
case config.CmdHelp:
Help()
return nil
}
if cmd >= config.CmdTab1 && cmd <= config.CmdTab0 {
if cmd == config.CmdTab0 {
// Zero key goes to the last tab
SwitchTab(NumTabs() - 1)
} else {
SwitchTab(int(cmd - config.CmdTab1))
}
return nil
}
// Let another element handle the event, it's not a special global key
return event
})
}
// Stop stops the app gracefully.
// In the future it will handle things like ongoing downloads, etc
func Stop() {
App.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 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
// Process current tab before making a new one
if curTab > -1 {
// Turn off link selecting mode in the current tab
tabs[curTab].view.Highlight("")
// Save bottomBar state
tabs[curTab].saveBottomBar()
tabs[curTab].saveScroll()
}
curTab = NumTabs()
tabs = append(tabs, makeNewTab())
temp := newTabPage // Copy
setPage(tabs[curTab], &temp)
tabs[curTab].addToHistory("about:newtab")
tabs[curTab].history.pos = 0 // Manually set as first page
browser.AddTab(strconv.Itoa(curTab), makeTabLabel(strconv.Itoa(curTab+1)), makeContentLayout(tabs[curTab].view))
browser.SetCurrentTab(strconv.Itoa(curTab))
App.SetFocus(tabs[curTab].view)
bottomBar.SetLabel("")
bottomBar.SetText("")
tabs[curTab].saveBottomBar()
// Draw just in case
App.Draw()
}
// CloseTab closes the current tab and switches to the one to its left.
func CloseTab() {
// Basically the NewTab() func inverted
// TODO: Support closing middle tabs, by renumbering all the maps
// So that tabs to the right of the closed tabs point to the right places
// For now you can only close the right-most tab
if curTab != NumTabs()-1 {
return
}
if NumTabs() <= 1 {
// There's only one tab open, close the app instead
Stop()
return
}
tabs = tabs[:len(tabs)-1]
browser.RemoveTab(strconv.Itoa(curTab))
if curTab <= 0 {
curTab = NumTabs() - 1
} else {
curTab--
}
browser.SetCurrentTab(strconv.Itoa(curTab)) // Go to previous page
// Restore previous tab's state
tabs[curTab].applyAll()
App.SetFocus(tabs[curTab].view)
// Just in case
App.Draw()
}
// SwitchTab switches to a specific tab, using its number, 0-indexed.
// The tab numbers are clamped to the end, so for example numbers like -5 and 1000 are still valid.
// This means that calling something like SwitchTab(curTab - 1) will never cause an error.
func SwitchTab(tab int) {
if tab < 0 {
tab = 0
}
if tab > NumTabs()-1 {
tab = NumTabs() - 1
}
// Save current tab attributes
if curTab > -1 {
// Save bottomBar state
tabs[curTab].saveBottomBar()
tabs[curTab].saveScroll()
}
curTab = tab % NumTabs()
// Display tab
reformatPageAndSetView(tabs[curTab], tabs[curTab].page)
browser.SetCurrentTab(strconv.Itoa(curTab))
tabs[curTab].applyAll()
App.SetFocus(tabs[curTab].view)
// Just in case
App.Draw()
}
func Reload() {
if tabs[curTab].page.URL == "about:newtab" && config.CustomNewTab {
// Re-render new tab, similar to Init()
newTabContent := getNewTabContent()
tmpTermW := termW
renderedNewTabContent, newTabLinks := renderer.RenderGemini(newTabContent, textWidth(), false)
newTabPage = structs.Page{
Raw: newTabContent,
Content: renderedNewTabContent,
Links: newTabLinks,
URL: "about:newtab",
Width: tmpTermW,
Mediatype: structs.TextGemini,
}
temp := newTabPage // Copy
setPage(tabs[curTab], &temp)
return
}
if !tabs[curTab].hasContent() {
return
}
parsed, _ := url.Parse(tabs[curTab].page.URL)
go func(t *tab) {
cache.RemovePage(tabs[curTab].page.URL)
cache.RemoveFavicon(parsed.Host)
handleURL(t, t.page.URL, 0) // 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.
// It should be an absolute URL.
func URL(u string) {
t := tabs[curTab]
if strings.HasPrefix(u, "about:") {
if final, ok := handleAbout(t, u); ok {
t.addToHistory(final)
}
return
}
go goURL(t, fixUserURL(u))
}
func NumTabs() int {
return len(tabs)
}