1
0
mirror of https://github.com/makew0rld/amfora.git synced 2024-12-04 14:46:29 -05:00
amfora/display/display.go

583 lines
16 KiB
Go
Raw Normal View History

2020-06-18 16:54:48 -04:00
package display
import (
"fmt"
"net/url"
2020-12-20 15:13:26 -05:00
"regexp"
2020-06-18 16:54:48 -04:00
"strconv"
"strings"
"github.com/gdamore/tcell"
"github.com/makeworld-the-better-one/amfora/cache"
2020-07-28 16:58:32 -04:00
"github.com/makeworld-the-better-one/amfora/config"
"github.com/makeworld-the-better-one/amfora/renderer"
2020-06-18 16:54:48 -04:00
"github.com/makeworld-the-better-one/amfora/structs"
2020-08-27 20:04:02 -04:00
"github.com/makeworld-the-better-one/go-gemini"
"github.com/spf13/viper"
2020-06-18 16:54:48 -04:00
"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)
2020-06-18 16:54:48 -04:00
2020-06-29 15:01:41 -04:00
// Terminal dimensions
var termW int
2020-06-29 15:01:41 -04:00
var termH int
2020-06-18 16:54:48 -04:00
// The user input and URL display bar at the bottom
2020-07-28 16:58:32 -04:00
var bottomBar = cview.NewInputField()
2020-06-18 16:54:48 -04:00
2020-12-20 15:13:26 -05:00
// 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(`[^ ]+\.[^ ].*/.`)
2020-06-18 16:54:48 -04:00
// 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.
2020-06-18 16:54:48 -04:00
// Ex: "info", "error", "input", "yesno"
var tabPages = cview.NewPages()
2020-06-18 16:54:48 -04:00
// The tabs at the top with titles
var tabRow = cview.NewTextView().
SetDynamicColors(true).
SetRegions(true).
SetScrollable(true).
SetWrap(false).
SetHighlightedFunc(func(added, removed, remaining []string) {
// There will always only be one string in added - never multiple highlights
// Remaining should always be empty
i, _ := strconv.Atoi(added[0])
tabPages.SwitchToPage(strconv.Itoa(i)) // Tab names are just numbers, zero-indexed
})
// Root layout
var layout = cview.NewFlex().
2020-07-28 16:58:32 -04:00
SetDirection(cview.FlexRow)
2020-06-18 16:54:48 -04:00
var newTabPage structs.Page
2020-12-20 16:39:33 -05:00
var versionPage structs.Page
2020-07-01 20:37:34 -04:00
var App = cview.NewApplication().
EnableMouse(false).
SetRoot(layout, true).
SetAfterResizeFunc(func(width int, height int) {
// Store for calculations
termW = width
2020-06-29 15:01:41 -04:00
termH = height
// Make sure the current tab content is reformatted when the terminal size changes
go func(t *tab) {
2020-07-28 16:58:32 -04:00
t.reformatMu.Lock() // Only one reformat job per tab
defer t.reformatMu.Unlock()
2020-07-03 12:22:44 -04:00
// Use the current tab, but don't affect other tabs if the user switches tabs
reformatPageAndSetView(t, t.page)
}(tabs[curTab])
})
2020-06-18 16:54:48 -04:00
2020-12-20 16:39:33 -05:00
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(), leftMargin(), false)
versionPage = structs.Page{
Raw: versionContent,
Content: renderVersionContent,
Links: versionLinks,
URL: "about:version",
Width: -1, // Force reformatting on first display
Mediatype: structs.TextGemini,
}
2020-06-18 16:54:48 -04:00
tabRow.SetChangedFunc(func() {
App.Draw()
})
2020-07-01 13:39:13 -04:00
helpInit()
2020-06-18 16:54:48 -04:00
2020-07-28 16:58:32 -04:00
layout.
AddItem(tabRow, 1, 1, false).
AddItem(nil, 1, 1, false). // One line of empty space above the page
AddItem(tabPages, 0, 1, true).
AddItem(nil, 1, 1, false). // One line of empty space before bottomBar
AddItem(bottomBar, 1, 1, false)
if viper.GetBool("a-general.color") {
2020-07-28 16:58:32 -04:00
layout.SetBackgroundColor(config.GetColor("bg"))
tabRow.SetBackgroundColor(config.GetColor("bg"))
bottomBar.SetBackgroundColor(config.GetColor("bottombar_bg"))
bottomBar.
SetLabelColor(config.GetColor("bottombar_label")).
SetFieldBackgroundColor(config.GetColor("bottombar_bg")).
SetFieldTextColor(config.GetColor("bottombar_text"))
} else {
2020-07-28 16:58:32 -04:00
bottomBar.SetBackgroundColor(tcell.ColorWhite)
bottomBar.
SetLabelColor(tcell.ColorBlack).
SetFieldBackgroundColor(tcell.ColorWhite).
SetFieldTextColor(tcell.ColorBlack)
}
2020-06-18 16:54:48 -04:00
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
2020-06-18 16:54:48 -04:00
switch key {
case tcell.KeyEnter:
// Figure out whether it's a URL, link number, or search
// And send out a request
2020-06-18 16:54:48 -04:00
2020-06-20 17:17:34 -04:00
query := bottomBar.GetText()
if strings.TrimSpace(query) == "" {
2020-06-18 16:54:48 -04:00
// Ignore
reset()
2020-06-18 16:54:48 -04:00
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 = "./"
}
2020-08-18 17:32:53 -04:00
target, err := current.Parse(query)
if err != nil {
// Invalid relative url
return
}
2020-08-18 17:32:53 -04:00
URL(target.String())
return
}
2020-06-18 16:54:48 -04:00
2020-06-20 17:17:34 -04:00
i, err := strconv.Atoi(query)
2020-06-18 16:54:48 -04:00
if err != nil {
2020-07-01 13:39:13 -04:00
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()
2020-07-01 13:39:13 -04:00
return
}
if i <= len(tabs[tab].page.Links) && i > 0 {
2020-07-01 13:39:13 -04:00
// Open new tab and load link
oldTab := tab
2020-07-01 13:39:13 -04:00
NewTab()
// Resolve and follow link manually
prevParsed, _ := url.Parse(tabs[oldTab].page.URL)
nextParsed, err := url.Parse(tabs[oldTab].page.Links[i-1])
2020-07-01 13:39:13 -04:00
if err != nil {
Error("URL Error", "link URL could not be parsed")
reset()
2020-07-01 13:39:13 -04:00
return
}
URL(prevParsed.ResolveReference(nextParsed).String())
return
}
2020-06-20 17:17:34 -04:00
} else {
2020-07-01 13:39:13 -04:00
// 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
2020-08-27 20:04:02 -04:00
u := viper.GetString("a-general.search") + "?" + gemini.QueryEscape(query)
// Don't use the cached version of the search
cache.RemovePage(normalizeURL(u))
URL(u)
2020-07-01 13:39:13 -04:00
} else {
// Full URL
// Don't use cached version for manually entered URL
cache.RemovePage(normalizeURL(fixUserURL(query)))
2020-07-01 13:39:13 -04:00
URL(query)
}
return
2020-06-20 17:17:34 -04:00
}
2020-06-18 16:54:48 -04:00
}
if i <= len(tabs[tab].page.Links) && i > 0 {
2020-06-20 17:17:34 -04:00
// It's a valid link number
followLink(tabs[tab], tabs[tab].page.URL, tabs[tab].page.Links[i-1])
2020-06-18 16:54:48 -04:00
return
}
2020-07-01 13:39:13 -04:00
// Invalid link number, don't do anything
reset()
return
2020-06-18 16:54:48 -04:00
case tcell.KeyEsc:
2020-06-18 16:54:48 -04:00
// Set back to what it was
reset()
return
2020-06-18 16:54:48 -04:00
}
// 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()
2020-09-01 13:55:09 -04:00
newTabContent := getNewTabContent()
renderedNewTabContent, newTabLinks := renderer.RenderGemini(newTabContent, textWidth(), leftMargin(), false)
newTabPage = structs.Page{
Raw: newTabContent,
Content: renderedNewTabContent,
Links: newTabLinks,
URL: "about:newtab",
Width: -1, // Force reformatting on first display
Mediatype: structs.TextGemini,
}
2020-06-18 16:54:48 -04:00
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
}
2020-06-18 16:54:48 -04:00
// 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:
2020-07-09 19:28:39 -04:00
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:
2020-12-06 20:57:57 -05:00
Subscriptions(tabs[curTab], "about:subscriptions")
2020-11-27 17:01:29 -05:00
tabs[curTab].addToHistory("about:subscriptions")
return nil
case config.CmdAddSub:
2020-11-27 17:01:29 -05:00
go addSubscription()
return nil
}
2020-07-19 11:27:39 -04:00
// 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
2020-07-19 11:27:39 -04:00
}
}
}
// All the keys and operations that can work while a tab IS loading
//nolint:exhaustive
switch cmd {
case config.CmdNewTab:
2020-07-28 17:16:57 -04:00
if tabs[curTab].page.Mode == structs.ModeLinkSelect {
next, err := resolveRelLink(tabs[curTab], tabs[curTab].page.URL, tabs[curTab].page.Selected)
2020-07-28 17:16:57 -04:00
if err != nil {
Error("URL Error", err.Error())
return nil
}
NewTab()
URL(next)
} else {
NewTab()
}
return nil
case config.CmdCloseTab:
2020-06-18 16:54:48 -04:00
CloseTab()
return nil
case config.CmdQuit:
2020-07-26 11:21:33 -04:00
Stop()
return nil
case config.CmdPrevTab:
2020-08-06 15:08:19 -04:00
// Wrap around, allow for modulo with negative numbers
n := NumTabs()
SwitchTab((((curTab - 1) % n) + n) % n)
return nil
case config.CmdNextTab:
2020-08-06 15:08:19 -04:00
SwitchTab((curTab + 1) % NumTabs())
return nil
case config.CmdHelp:
Help()
return nil
}
2020-08-06 13:55:43 -04:00
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))
2020-06-18 16:54:48 -04:00
}
return nil
2020-06-18 16:54:48 -04:00
}
// Let another element handle the event, it's not a special global key
2020-06-18 16:54:48 -04:00
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()
2020-06-18 16:54:48 -04:00
// 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()
}
2020-06-18 16:54:48 -04:00
curTab = NumTabs()
tabs = append(tabs, makeNewTab())
temp := newTabPage // Copy
setPage(tabs[curTab], &temp)
2020-12-19 20:19:47 -05:00
tabs[curTab].addToHistory("about:newtab")
tabs[curTab].history.pos = 0 // Manually set as first page
2020-06-18 16:54:48 -04:00
tabPages.AddAndSwitchToPage(strconv.Itoa(curTab), tabs[curTab].view, true)
App.SetFocus(tabs[curTab].view)
2020-06-18 16:54:48 -04:00
// 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
if viper.GetBool("a-general.color") {
2020-07-28 16:58:32 -04:00
fmt.Fprintf(tabRow, `["%d"][%s] %d [%s][""]|`,
curTab,
config.GetColorString("tab_num"),
curTab+1,
config.GetColorString("tab_divider"),
)
} else {
fmt.Fprintf(tabRow, `["%d"] %d [""]|`, curTab, curTab+1)
}
2020-06-18 16:54:48 -04:00
tabRow.Highlight(strconv.Itoa(curTab)).ScrollToHighlight()
bottomBar.SetLabel("")
bottomBar.SetText("")
tabs[curTab].saveBottomBar()
2020-06-18 16:54:48 -04:00
2020-06-21 23:39:33 -04:00
// Draw just in case
2020-06-18 16:54:48 -04:00
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]
2020-06-18 16:54:48 -04:00
tabPages.RemovePage(strconv.Itoa(curTab))
if curTab <= 0 {
curTab = NumTabs() - 1
} else {
curTab--
}
tabPages.SwitchToPage(strconv.Itoa(curTab)) // Go to previous page
2020-08-05 13:31:59 -04:00
rewriteTabRow()
// Restore previous tab's state
tabs[curTab].applyAll()
App.SetFocus(tabs[curTab].view)
2020-06-18 16:54:48 -04:00
// 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()
}
2020-06-18 16:54:48 -04:00
curTab = tab % NumTabs()
// Display tab
reformatPageAndSetView(tabs[curTab], tabs[curTab].page)
2020-06-18 16:54:48 -04:00
tabPages.SwitchToPage(strconv.Itoa(curTab))
tabRow.Highlight(strconv.Itoa(curTab)).ScrollToHighlight()
tabs[curTab].applyAll()
2020-06-18 16:54:48 -04:00
App.SetFocus(tabs[curTab].view)
2020-06-18 16:54:48 -04:00
// Just in case
App.Draw()
}
func Reload() {
2020-09-01 13:55:09 -04:00
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(), leftMargin(), false)
2020-09-01 13:55:09 -04:00
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() {
2020-07-01 13:39:13 -04:00
return
}
parsed, _ := url.Parse(tabs[curTab].page.URL)
go func(t *tab) {
cache.RemovePage(tabs[curTab].page.URL)
2020-08-05 13:31:59 -04:00
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])
2020-06-18 16:54:48 -04:00
}
// 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:") {
2020-12-06 20:57:57 -05:00
if final, ok := handleAbout(t, u); ok {
t.addToHistory(final)
}
return
}
go goURL(t, fixUserURL(u))
2020-06-18 16:54:48 -04:00
}
func NumTabs() int {
return len(tabs)
2020-06-18 16:54:48 -04:00
}