Fixes merge conflicts and removes dead files

This commit is contained in:
sloumdrone 2019-09-23 09:02:48 -07:00
commit e68dc3b414
33 changed files with 2230 additions and 1467 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
bombadillo
*.asciinema

53
LICENSE
View File

@ -619,56 +619,3 @@ Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

201
bombadillo.1 Normal file
View File

@ -0,0 +1,201 @@
." Text automatically generated by txt2man
.TH "bombadillo" 1 "21 SEP 2019" "" "General Opperation Manual"
.SH NAME
\fBbombadillo \fP- a non-web client
.SH SYNOPSIS
.nf
.fam C
\fBbombadillo\fP [\fB-v\fP] [\fB-h\fP] [\fIurl\fP]
.fam T
.fi
.SH DESCRIPTION
\fBbombadillo\fP is a terminal based client for a number of internet protocols, including gopher and gemini. \fBbombadillo\fP will also connect links to a user's default web browser or telnet client. Commands input is loosely based on Vi and Less and is comprised of two modes: key and line input mode.
.SH OPTIONS
.TP
.B
\fB-v\fP
Display the version number of \fBbombadillo\fP.
.TP
.B
\fB-h\fP
Usage help. Displays all command line options with a short description.
.SH COMMANDS
.SS KEY COMMANDS
These commands work as a single keypress anytime \fBbombadillo\fP is not taking in a line based command. This is the default command mode of \fBbombadillo\fP.
.TP
.B
b
Navigate back one place in your document history.
.TP
.B
B
Toggle the bookmarks panel open/closed.
.TP
.B
d
Scroll down an amount corresponding to 75% of your terminal window height in the current document.
.TP
.B
f
Navigate forward one place in your document history.
.TP
.B
g
Scroll to the top of the current document.
.TP
.B
G
Scroll to the bottom of the current document.
.TP
.B
j
Scroll down a single line in the current document.
.TP
.B
k
Scroll up a single line.
.TP
.B
q
Quit \fBbombadillo\fP.
.TP
.B
u
Scroll up an amount corresponding to 75% of your terminal window height in the current document.
.TP
.B
<tab>
Toggle the scroll focus between the bookmarks panel and the document panel. Only has an effect if the bookmarks panel is open.
.TP
.B
<spc>
Enter line command mode. Once a line command is input, the mode will automatically revert to key command mode.
.TP
.B
:
Alias for <space>. Enter line command mode.
.SS LINE COMMANDS
These commands are typed in by the user to perform an action of some sort. As listed in KEY COMMANDS, this mode is initiated by pressing : or <space>. The command names themselves are not case sensitive, though the arguments supplied to them may be.
.SS NAVIGATION
.TP
.B
[url]
Navigates to the requested url.
.TP
.B
[link id]
Follows a link on the current document with the given number.
.TP
.B
bookmarks [bookmark id]
Navigates to the url represented by the bookmark matching bookmark id. \fIb\fP can be entered, rather than the full \fIbookmarks\fP.
.TP
.B
home
Navigates to the document set by the \fIhomeurl\fP setting. \fIh\fP can be entered, rather than the full \fIhome\fP.
.TP
.B
search [keywords\.\.\.]
Submits a search to the search engine set by the \fIsearchengine\fP setting, with the query being the provided keyword(s).
.TP
.B
search
Queries the user for search terms and submits a search to the search engine set by the \fIsearchengine\fP setting.
.TP
.B
write [url]
Writes data from a given url to a file. The file is named by the last component of the url path. If the last component is blank or \fI/\fP a default name will be used. The file saves to the folder set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
write [url] [filename\.\.\.]
Writes data from a given url to a file. The file is named by the filename argument should should not include a leading \fI/\fP. The file saves to the folder set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
write [link id]]
Writes data from a given link id in the current document to a file. The file is named by the last component of the url path. If the last component is blank or \fI/\fP a default name will be used. The file saves to the folder set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
write [link id] [filename\.\.\.]
Writes data from a given link id in the current document to a file. The file is named by the filename argument should should not include a leading \fI/\fP. The file saves to the folder set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
write .
Writes the current document to a file. The file is named by the last component of the url path. If the last component is blank or \fI/\fP a default name will be used. The file saves to the folder set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
write . [filename\.\.\.]
Writes the current document to a file. The file is named by the filename argument should should not include a leading \fI/\fP. The file saves to the folder set by the \fIsavelocation\fP setting. \fIw\fP can be entered rather than the full \fIwrite\fP.
.TP
.B
help
Navigates to the gopher based help page for \fBbombadillo\fP. \fI?\fP can be used instead of the full \fIhelp\fP.
.SS BOOKMARKS
.TP
.B
bookmarks
Toggles the bookmarks panel open/closed. Alias for KEY COMMAND \fIB\fP. \fIb\fP can be used instead of the full \fIbookmarks\fP.
.TP
.B
add [url] [name\.\.\.]
Adds the url as a bookmarks labeled by name. \fIa\fP can be used instead of the full \fIadd\fP.
.TP
.B
add [link id] [name\.\.\.]
Adds the url represented by the link id within the current document as a bookmark labeled by name. \fIa\fP can be used instead of the full \fIadd\fP.
.TP
.B
add [.] [name\.\.\.]
Adds the current document's url as a bookmark labeled by name. \fIa\fP can be used instead of the full \fIadd\fP.
.TP
.B
delete [bookmark id]]
Deletes the bookmark matching the bookmark id. \fId\fP can be used instead of the full \fIdelete\fP.
.SS MISC
.TP
.B
check [link id]
Displays the url corresponding to a given link id for the current document. \fIc\fP can be used instead of the full \fIcheck\fP.
.TP
.B
check [setting name]
Displays the current value for a given configuration setting. \fIc\fP can be used instead of the full \fIcheck\fP.
.TP
.B
set [setting name]
Sets the value for a given configuration setting. \fIs\fP can be used instead of the full \fIset\fP.
.TP
.B
quit
Quits \fBbombadillo\fP. Alias for KEY COMMAND \fIq\fP. \fIq\fP can be used instead of the full \fIquit\fP.
.SH FILES
\fBbombadillo\fP keeps a hidden configuration file in a user's home directory. The file is a simplified ini file titled '.bombadillo.ini'. It is generated when a user first loads \fBbombadillo\fP and is updated with bookmarks and settings as a user adds them. The file can be directly edited, but it is best to use the SET command to update settings whenever possible. To return to the state of a fresh install, simply remove the file and a new one will be generated with the \fBbombadillo\fP defaults.
.SH SETTINGS
The following is a list of the settings that \fBbombadillo\fP recognizes, as well as a description of their valid values.
.TP
.B
homeurl
The url that \fBbombadillo\fP navigates to when the program loads or when the \fIhome\fP or \fIh\fP LINE COMMAND is issued. This should be a valid url. If a scheme/protocol is not included, gopher will be assumed.
.TP
.B
savelocation
The path to the folder that \fBbombadillo\fP should write files to. This should be a valid filepath for the system and should end in a \fI/\fP. Defaults to a user's home directory.
.TP
.B
searchengine
The url to use for the LINE COMMANDs \fI?\fP and \fIsearch\fP. Should be a valid search path that terms may be appended to. Defaults to \fIgopher://gopher.floodgap.com:70/7/v2/vs\fP.
.TP
.B
openhttp
Tells the client whether or not to try to follow web (http/https) links. If set to \fItrue\fP, \fBbombadillo\fP will try to open a user's default web browser to the link in question. Any value other than \fItrue\fP is considered false. Defaults to \fIfalse\fP.
.TP
.B
telnetcommand
Tells the client what command to use to start a telnet session. Should be a valid command, including any flags. The address being navigated to will be added to the end of the command. Defaults to \fItelnet\fP.
.TP
.B
theme
Can toggle between visual modes. Valid values are \fInormal\fP and \fIinverse\fP. When set to ivnerse, the terminal color mode is inversed. Defaults to \fInormal\fP.
.TP
.B
terminalonly
Sets whether or not to try to open non-text files served via gemini in gui programs or not. If set to \fItrue\fP, bombdaillo will only attempt to use terminal programs to open files. If set to anything else, \fBbombadillo\fP may choose graphical and terminal programs. Defaults to \fItrue\fP.

144
bookmarks.go Normal file
View File

@ -0,0 +1,144 @@
package main
import (
"fmt"
"strings"
"tildegit.org/sloum/bombadillo/cui"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
type Bookmarks struct {
IsOpen bool
IsFocused bool
Position int
Length int
Titles []string
Links []string
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
func (b *Bookmarks) Add(v []string) (string, error) {
if len(v) < 2 {
return "", fmt.Errorf("Received %d arguments, expected 2+", len(v))
}
b.Titles = append(b.Titles, strings.Join(v[1:], " "))
b.Links = append(b.Links, v[0])
b.Length = len(b.Titles)
return "Bookmark added successfully", nil
}
func (b *Bookmarks) Delete(i int) (string, error) {
if i < len(b.Titles) && len(b.Titles) == len(b.Links) {
b.Titles = append(b.Titles[:i], b.Titles[i+1:]...)
b.Links = append(b.Links[:i], b.Links[i+1:]...)
b.Length = len(b.Titles)
return "Bookmark deleted successfully", nil
}
return "", fmt.Errorf("Bookmark %d does not exist", i)
}
func (b *Bookmarks) ToggleOpen() {
b.IsOpen = !b.IsOpen
if b.IsOpen {
b.IsFocused = true
} else {
b.IsFocused = false
cui.Clear("screen")
}
}
func (b *Bookmarks) ToggleFocused() {
if b.IsOpen {
b.IsFocused = !b.IsFocused
}
}
func (b Bookmarks) IniDump() string {
if len(b.Titles) < 0 {
return ""
}
out := "[BOOKMARKS]\n"
for i := 0; i < len(b.Titles); i++ {
out += b.Titles[i]
out += "="
out += b.Links[i]
out += "\n"
}
return out
}
// Get a list, including link nums, of bookmarks
// as a string slice
func (b Bookmarks) List() []string {
var out []string
for i, t := range b.Titles {
out = append(out, fmt.Sprintf("[%d] %s", i, t))
}
return out
}
func (b Bookmarks) Render(termwidth, termheight int) []string {
width := 40
termheight -= 3
var walll, wallr, floor, ceil, tr, tl, br, bl string
if termwidth < 40 {
width = termwidth
}
if b.IsFocused {
walll = cui.Shapes["awalll"]
wallr = cui.Shapes["awallr"]
ceil = cui.Shapes["aceiling"]
floor = cui.Shapes["afloor"]
tr = cui.Shapes["atr"]
br = cui.Shapes["abr"]
tl = cui.Shapes["atl"]
bl = cui.Shapes["abl"]
} else {
walll = cui.Shapes["walll"]
wallr = cui.Shapes["wallr"]
ceil = cui.Shapes["ceiling"]
floor = cui.Shapes["floor"]
tr = cui.Shapes["tr"]
br = cui.Shapes["br"]
tl = cui.Shapes["tl"]
bl = cui.Shapes["bl"]
}
out := make([]string, 0, 5)
contentWidth := width - 2
top := fmt.Sprintf("%s%s%s", tl, strings.Repeat(ceil, contentWidth), tr)
out = append(out, top)
marks := b.List()
for i := 0; i < termheight - 2; i++ {
if i + b.Position >= len(b.Titles) {
out = append(out, fmt.Sprintf("%s%-*.*s%s", walll, contentWidth, contentWidth, "", wallr))
} else {
out = append(out, fmt.Sprintf("%s%-*.*s%s", walll, contentWidth, contentWidth, marks[i + b.Position], wallr))
}
}
bottom := fmt.Sprintf("%s%s%s", bl, strings.Repeat(floor, contentWidth), br)
out = append(out, bottom)
return out
}
// TODO handle scrolling of the bookmarks list
// either here with a scroll up/down or in the client
// code for scroll
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
func MakeBookmarks() Bookmarks {
return Bookmarks{false, false, 0, 0, make([]string, 0), make([]string, 0)}
}

961
client.go Normal file
View File

@ -0,0 +1,961 @@
package main
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"tildegit.org/sloum/bombadillo/cmdparse"
"tildegit.org/sloum/bombadillo/cui"
"tildegit.org/sloum/bombadillo/gemini"
"tildegit.org/sloum/bombadillo/gopher"
"tildegit.org/sloum/bombadillo/http"
"tildegit.org/sloum/bombadillo/telnet"
// "tildegit.org/sloum/mailcap"
)
//------------------------------------------------\\
// + + + T Y P E S + + + \\
//--------------------------------------------------\\
type client struct {
Height int
Width int
Options map[string]string
Message string
MessageIsErr bool
PageState Pages
BookMarks Bookmarks
TopBar Headbar
FootBar Footbar
}
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
func (c *client) GetSizeOnce() {
cmd := exec.Command("stty", "size")
cmd.Stdin = os.Stdin
out, err := cmd.Output()
if err != nil {
fmt.Println("Fatal error: Unable to retrieve terminal size")
os.Exit(5)
}
var h, w int
fmt.Sscan(string(out), &h, &w)
c.Height = h
c.Width = w
}
func (c *client) GetSize() {
c.GetSizeOnce()
c.SetMessage("Loading...", false)
c.Draw()
for {
cmd := exec.Command("stty", "size")
cmd.Stdin = os.Stdin
out, err := cmd.Output()
if err != nil {
fmt.Println("Fatal error: Unable to retrieve terminal size")
os.Exit(5)
}
var h, w int
fmt.Sscan(string(out), &h, &w)
if h != c.Height || w != c.Width {
c.Height = h
c.Width = w
c.SetPercentRead()
c.Draw()
}
time.Sleep(500 * time.Millisecond)
}
}
func (c *client) Draw() {
var screen strings.Builder
screen.Grow(c.Height * c.Width + c.Width)
screen.WriteString("\033[0m")
screen.WriteString(c.TopBar.Render(c.Width, c.Options["theme"]))
screen.WriteString("\n")
pageContent := c.PageState.Render(c.Height, c.Width - 1)
if c.Options["theme"] == "inverse" {
screen.WriteString("\033[7m")
}
if c.BookMarks.IsOpen {
bm := c.BookMarks.Render(c.Width, c.Height)
bmWidth := len([]rune(bm[0]))
for i := 0; i < c.Height - 3; i++ {
if c.Width > bmWidth {
contentWidth := c.Width - bmWidth
if i < len(pageContent) {
screen.WriteString(fmt.Sprintf("%-*.*s", contentWidth, contentWidth, pageContent[i]))
} else {
screen.WriteString(fmt.Sprintf("%-*.*s", contentWidth, contentWidth, " "))
}
screen.WriteString("\033[500C\033[39D")
}
if c.Options["theme"] == "inverse" && !c.BookMarks.IsFocused {
screen.WriteString("\033[2;7m")
} else if !c.BookMarks.IsFocused {
screen.WriteString("\033[2m")
}
screen.WriteString(bm[i])
if c.Options["theme"] == "inverse" && !c.BookMarks.IsFocused {
screen.WriteString("\033[7;22m")
} else if !c.BookMarks.IsFocused {
screen.WriteString("\033[0m")
}
screen.WriteString("\n")
}
} else {
for i := 0; i < c.Height - 3; i++ {
if i < len(pageContent) {
screen.WriteString(fmt.Sprintf("%-*.*s", c.Width - 1, c.Width - 1, pageContent[i]))
screen.WriteString("\n")
} else {
screen.WriteString(fmt.Sprintf("%-*.*s", c.Width, c.Width, " "))
screen.WriteString("\n")
}
}
}
screen.WriteString("\033[0m")
// TODO using message here breaks on resize, must regenerate
screen.WriteString(c.RenderMessage())
screen.WriteString("\n") // for the input line
screen.WriteString(c.FootBar.Render(c.Width, c.PageState.Position, c.Options["theme"]))
// cui.Clear("screen")
cui.MoveCursorTo(0,0)
fmt.Print(screen.String())
}
func (c *client) TakeControlInput() {
input := cui.Getch()
switch input {
case 'j', 'J':
// scroll down one line
c.ClearMessage()
c.Scroll(1)
case 'k', 'K':
// scroll up one line
c.ClearMessage()
c.Scroll(-1)
case 'q', 'Q':
// quite bombadillo
cui.Exit()
case 'g':
// scroll to top
c.ClearMessage()
c.Scroll(-len(c.PageState.History[c.PageState.Position].WrappedContent))
case 'G':
// scroll to bottom
c.ClearMessage()
c.Scroll(len(c.PageState.History[c.PageState.Position].WrappedContent))
case 'd':
// scroll down 75%
c.ClearMessage()
distance := c.Height - c.Height / 4
c.Scroll(distance)
case 'u':
// scroll up 75%
c.ClearMessage()
distance := c.Height - c.Height / 4
c.Scroll(-distance)
case 'b':
// go back
c.ClearMessage()
err := c.PageState.NavigateHistory(-1)
if err != nil {
c.SetMessage(err.Error(), false)
c.DrawMessage()
} else {
c.SetHeaderUrl()
c.SetPercentRead()
c.Draw()
}
case 'B':
// open the bookmarks browser
c.BookMarks.ToggleOpen()
c.Draw()
case 'f', 'F':
// go forward
c.ClearMessage()
err := c.PageState.NavigateHistory(1)
if err != nil {
c.SetMessage(err.Error(), false)
c.DrawMessage()
} else {
c.SetHeaderUrl()
c.SetPercentRead()
c.Draw()
}
case '\t':
// Toggle bookmark browser focus on/off
c.BookMarks.ToggleFocused()
c.Draw()
case ':', ' ':
// Process a command
c.ClearMessage()
c.ClearMessageLine()
if c.Options["theme"] == "normal" {
fmt.Printf("\033[7m%*.*s\r", c.Width, c.Width, "")
}
entry, err := cui.GetLine()
c.ClearMessageLine()
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
break
} else if strings.TrimSpace(entry) == "" {
c.DrawMessage()
break
}
parser := cmdparse.NewParser(strings.NewReader(entry))
p, err := parser.Parse()
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
} else {
err := c.routeCommandInput(p)
if err != nil {
c.SetMessage(err.Error(), true)
c.Draw()
}
}
}
}
func (c *client) routeCommandInput(com *cmdparse.Command) error {
var err error
switch com.Type {
case cmdparse.SIMPLE:
c.simpleCommand(com.Action)
case cmdparse.GOURL:
c.goToURL(com.Target)
case cmdparse.GOLINK:
c.goToLink(com.Target)
case cmdparse.DO:
c.doCommand(com.Action, com.Value)
case cmdparse.DOLINK:
c.doLinkCommand(com.Action, com.Target)
case cmdparse.DOAS:
c.doCommandAs(com.Action, com.Value)
case cmdparse.DOLINKAS:
c.doLinkCommandAs(com.Action, com.Target, com.Value)
default:
return fmt.Errorf("Unknown command entry!")
}
return err
}
func (c *client) simpleCommand(action string) {
action = strings.ToUpper(action)
switch action {
case "Q", "QUIT":
cui.Exit()
case "H", "HOME":
if c.Options["homeurl"] != "unset" {
go c.Visit(c.Options["homeurl"])
} else {
c.SetMessage(fmt.Sprintf("No home address has been set"), false)
c.DrawMessage()
}
case "B", "BOOKMARKS":
c.BookMarks.ToggleOpen()
c.Draw()
case "SEARCH":
c.search("", "", "?")
case "HELP", "?":
go c.Visit(helplocation)
default:
c.SetMessage(fmt.Sprintf("Unknown action %q", action), true)
c.DrawMessage()
}
}
func (c *client) doCommand(action string, values []string) {
if length := len(values); length != 1 {
c.SetMessage(fmt.Sprintf("Expected 1 argument, received %d", len(values)), true)
c.DrawMessage()
return
}
switch action {
case "CHECK", "C":
c.displayConfigValue(values[0])
case "SEARCH":
c.search(strings.Join(values, " "), "", "")
case "WRITE", "W":
if values[0] == "." {
values[0] = c.PageState.History[c.PageState.Position].Location.Full
}
u, err := MakeUrl(values[0])
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
fns := strings.Split(u.Resource, "/")
var fn string
if len(fns) > 0 {
fn = strings.Trim(fns[len(fns) - 1], "\t\r\n \a\f\v")
} else {
fn = "index"
}
if fn == "" {
fn = "index"
}
c.saveFile(u, fn)
default:
c.SetMessage(fmt.Sprintf("Unknown action %q", action), true)
c.DrawMessage()
}
}
func (c *client) doCommandAs(action string, values []string) {
if len(values) < 2 {
c.SetMessage(fmt.Sprintf("Expected 1 argument, received %d", len(values)), true)
c.DrawMessage()
return
}
if values[0] == "." {
values[0] = c.PageState.History[c.PageState.Position].Location.Full
}
switch action {
case "ADD", "A":
msg, err := c.BookMarks.Add(values)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
} else {
c.SetMessage(msg, false)
c.DrawMessage()
}
err = saveConfig()
if err != nil {
c.SetMessage("Error saving bookmark to file", true)
c.DrawMessage()
}
if c.BookMarks.IsOpen {
c.Draw()
}
case "WRITE", "W":
u, err := MakeUrl(values[0])
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
fileName := strings.Join(values[1:], "-")
fileName = strings.Trim(fileName, " \t\r\n\a\f\v")
c.saveFile(u, fileName)
case "SET", "S":
if _, ok := c.Options[values[0]]; ok {
val := strings.Join(values[1:], " ")
if values[0] == "theme" && val != "normal" && val != "inverse" {
c.SetMessage("Theme can only be set to 'normal' or 'inverse'", true)
c.DrawMessage()
return
}
c.Options[values[0]] = val
err := saveConfig()
if err != nil {
c.SetMessage("Value set, but error saving config to file", true)
c.DrawMessage()
} else {
c.SetMessage(fmt.Sprintf("%s is now set to %q", values[0], c.Options[values[0]]), false)
c.Draw()
}
return
}
c.SetMessage(fmt.Sprintf("Unable to set %s, it does not exist", values[0]), true)
c.DrawMessage()
return
}
c.SetMessage(fmt.Sprintf("Unknown command structure"), true)
}
func (c *client) doLinkCommandAs(action, target string, values []string) {
num, err := strconv.Atoi(target)
if err != nil {
c.SetMessage(fmt.Sprintf("Expected link number, got %q", target), true)
c.DrawMessage()
return
}
num -= 1
links := c.PageState.History[c.PageState.Position].Links
if num >= len(links) || num < 0 {
c.SetMessage(fmt.Sprintf("Invalid link id: %s", target), true)
c.DrawMessage()
return
}
switch action {
case "ADD", "A":
bm := make([]string, 0, 5)
bm = append(bm, links[num])
bm = append(bm, values...)
msg, err := c.BookMarks.Add(bm)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
} else {
c.SetMessage(msg, false)
c.DrawMessage()
}
err = saveConfig()
if err != nil {
c.SetMessage("Error saving bookmark to file", true)
c.DrawMessage()
}
if c.BookMarks.IsOpen {
c.Draw()
}
case "WRITE", "W":
out := make([]string, 0, len(values) + 1)
out = append(out, links[num])
out = append(out, values...)
c.doCommandAs(action, out)
default:
c.SetMessage(fmt.Sprintf("Unknown command structure"), true)
}
}
func (c *client) getCurrentPageUrl() (string, error) {
if c.PageState.Length < 1 {
return "", fmt.Errorf("There are no pages in history")
}
return c.PageState.History[c.PageState.Position].Location.Full, nil
}
func (c *client) getCurrentPageRawData() (string, error) {
if c.PageState.Length < 1 {
return "", fmt.Errorf("There are no pages in history")
}
return c.PageState.History[c.PageState.Position].RawContent, nil
}
func (c *client) saveFile(u Url, name string) {
var file []byte
var err error
c.SetMessage(fmt.Sprintf("Saving %s ...", name), false)
c.DrawMessage()
switch u.Scheme {
case "gopher":
file, err = gopher.Retrieve(u.Host, u.Port, u.Resource)
case "gemini":
file, err = gemini.Fetch(u.Host, u.Port, u.Resource)
default:
c.SetMessage(fmt.Sprintf("Saving files over %s is not supported", u.Scheme), true)
c.DrawMessage()
return
}
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
savePath := c.Options["savelocation"] + name
err = ioutil.WriteFile(savePath, file, 0644)
if err != nil {
c.SetMessage("Error writing file to disk", true)
c.DrawMessage()
return
}
c.SetMessage(fmt.Sprintf("File saved to: %s", savePath), false)
c.DrawMessage()
}
func (c *client) saveFileFromData(d, name string) {
data := []byte(d)
c.SetMessage(fmt.Sprintf("Saving %s ...", name), false)
c.DrawMessage()
savePath := c.Options["savelocation"] + name
err := ioutil.WriteFile(savePath, data, 0644)
if err != nil {
c.SetMessage("Error writing file to disk", true)
c.DrawMessage()
return
}
c.SetMessage(fmt.Sprintf("File saved to: %s", savePath), false)
c.DrawMessage()
}
func (c *client) doLinkCommand(action, target string) {
num, err := strconv.Atoi(target)
if err != nil {
c.SetMessage(fmt.Sprintf("Expected number, got %q", target), true)
c.DrawMessage()
}
switch action {
case "DELETE", "D":
msg, err := c.BookMarks.Delete(num)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
} else {
c.SetMessage(msg, false)
c.DrawMessage()
}
err = saveConfig()
if err != nil {
c.SetMessage("Error saving bookmark deletion to file", true)
c.DrawMessage()
}
if c.BookMarks.IsOpen {
c.Draw()
}
case "BOOKMARKS", "B":
if num > len(c.BookMarks.Links)-1 {
c.SetMessage(fmt.Sprintf("There is no bookmark with ID %d", num), true)
c.DrawMessage()
return
}
c.Visit(c.BookMarks.Links[num])
case "CHECK", "C":
num -= 1
links := c.PageState.History[c.PageState.Position].Links
if num >= len(links) || num < 0 {
c.SetMessage(fmt.Sprintf("Invalid link id: %s", target), true)
c.DrawMessage()
return
}
link := links[num]
c.SetMessage(fmt.Sprintf("[%d] %s", num + 1, link), false)
c.DrawMessage()
case "WRITE", "W":
links := c.PageState.History[c.PageState.Position].Links
if len(links) < num || num < 1 {
c.SetMessage("Invalid link ID", true)
c.DrawMessage()
return
}
u, err := MakeUrl(links[num-1])
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
fns := strings.Split(u.Resource, "/")
var fn string
if len(fns) > 0 {
fn = strings.Trim(fns[len(fns) - 1], "\t\r\n \a\f\v")
} else {
fn = "index"
}
if fn == "" {
fn = "index"
}
c.saveFile(u, fn)
default:
c.SetMessage(fmt.Sprintf("Action %q does not exist for target %q", action, target), true)
c.DrawMessage()
}
}
func (c *client) search(query, url, question string) {
var entry string
var err error
if query == "" {
c.ClearMessage()
c.ClearMessageLine()
if c.Options["theme"] == "normal" {
fmt.Printf("\033[7m%*.*s\r", c.Width, c.Width, "")
}
fmt.Print(question)
entry, err = cui.GetLine()
c.ClearMessageLine()
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
} else if strings.TrimSpace(entry) == "" {
return
}
} else {
entry = query
}
if url == "" {
url = c.Options["searchengine"]
}
u, err := MakeUrl(url)
if err != nil {
c.SetMessage("The search url is not a valid url", true)
c.DrawMessage()
return
}
switch u.Scheme {
case "gopher":
go c.Visit(fmt.Sprintf("%s\t%s",u.Full,entry))
case "gemini":
// TODO url escape the entry variable
escapedEntry := entry
go c.Visit(fmt.Sprintf("%s?%s",u.Full,escapedEntry))
case "http", "https":
c.Visit(u.Full)
default:
c.SetMessage(fmt.Sprintf("%q is not a supported protocol", u.Scheme), true)
c.DrawMessage()
}
}
func (c *client) Scroll(amount int) {
if c.BookMarks.IsFocused {
bottom := len(c.BookMarks.Titles) - c.Height + 5 // 3 for the three bars: top, msg, bottom
if amount < 0 && c.BookMarks.Position == 0 {
c.SetMessage("The bookmark ladder does not go up any further", false)
c.DrawMessage()
fmt.Print("\a")
return
} else if (amount > 0 && c.BookMarks.Position == bottom) || bottom < 0 {
c.SetMessage("Feel the ground beneath your bookmarks", false)
c.DrawMessage()
fmt.Print("\a")
return
}
newScrollPosition := c.BookMarks.Position + amount
if newScrollPosition < 0 {
newScrollPosition = 0
} else if newScrollPosition > bottom {
newScrollPosition = bottom
}
c.BookMarks.Position = newScrollPosition
} else {
var percentRead int
page := c.PageState.History[c.PageState.Position]
bottom := len(page.WrappedContent) - c.Height + 3 // 3 for the three bars: top, msg, bottom
if amount < 0 && page.ScrollPosition == 0 {
c.SetMessage("You are already at the top", false)
c.DrawMessage()
fmt.Print("\a")
return
} else if (amount > 0 && page.ScrollPosition == bottom) || bottom < 0 {
c.FootBar.SetPercentRead(100)
c.SetMessage("You are already at the bottom", false)
c.DrawMessage()
fmt.Print("\a")
return
}
newScrollPosition := page.ScrollPosition + amount
if newScrollPosition < 0 {
newScrollPosition = 0
} else if newScrollPosition > bottom {
newScrollPosition = bottom
}
c.PageState.History[c.PageState.Position].ScrollPosition = newScrollPosition
if len(page.WrappedContent) < c.Height - 3 {
percentRead = 100
} else {
percentRead = int(float32(newScrollPosition + c.Height - 3) / float32(len(page.WrappedContent)) * 100.0)
}
c.FootBar.SetPercentRead(percentRead)
}
c.Draw()
}
func (c *client) SetPercentRead() {
page := c.PageState.History[c.PageState.Position]
var percentRead int
if len(page.WrappedContent) < c.Height - 3 {
percentRead = 100
} else {
percentRead = int(float32(page.ScrollPosition + c.Height - 3) / float32(len(page.WrappedContent)) * 100.0)
}
c.FootBar.SetPercentRead(percentRead)
}
func (c *client) displayConfigValue(setting string) {
if val, ok := c.Options[setting]; ok {
c.SetMessage(fmt.Sprintf("%s is set to: %q", setting, val), false)
c.DrawMessage()
} else {
c.SetMessage(fmt.Sprintf("Invalid: %q does not exist", setting), true)
c.DrawMessage()
}
}
func (c *client) SetMessage(msg string, isError bool) {
c.MessageIsErr = isError
c.Message = strings.ReplaceAll(msg, "\t", "%09")
}
func (c *client) DrawMessage() {
cui.MoveCursorTo(c.Height-1, 0)
fmt.Print(c.RenderMessage())
}
func (c *client) RenderMessage() string {
leadIn, leadOut := "", ""
if c.Options["theme"] == "normal" {
leadIn = "\033[7m"
leadOut = "\033[0m"
}
if c.MessageIsErr {
leadIn = "\033[31;1m"
leadOut = "\033[0m"
if c.Options["theme"] == "normal" {
leadIn = "\033[41;1;7m"
}
}
return fmt.Sprintf("%s%-*.*s%s", leadIn, c.Width, c.Width, c.Message, leadOut)
}
func (c *client) ClearMessage() {
c.SetMessage("", false)
}
func (c *client) ClearMessageLine() {
cui.MoveCursorTo(c.Height-1, 0)
cui.Clear("line")
}
func (c *client) goToURL(u string) {
if num, _ := regexp.MatchString(`^-?\d+.?\d*$`, u); num {
c.goToLink(u)
return
}
go c.Visit(u)
}
func (c *client) goToLink(l string) {
if num, _ := regexp.MatchString(`^-?\d+$`, l); num && c.PageState.Length > 0 {
linkcount := len(c.PageState.History[c.PageState.Position].Links)
item, err := strconv.Atoi(l)
if err != nil {
c.SetMessage(fmt.Sprintf("Invalid link id: %s", l), true)
c.DrawMessage()
return
}
if item <= linkcount && item > 0 {
linkurl := c.PageState.History[c.PageState.Position].Links[item-1]
c.Visit(linkurl)
} else {
c.SetMessage(fmt.Sprintf("Invalid link id: %s", l), true)
c.DrawMessage()
return
}
}
}
func (c *client) SetHeaderUrl() {
if c.PageState.Length > 0 {
u := c.PageState.History[c.PageState.Position].Location.Full
c.TopBar.url = strings.ReplaceAll(u, "\t", "%09")
} else {
c.TopBar.url = ""
}
}
func (c *client) Visit(url string) {
c.SetMessage("Loading...", false)
c.DrawMessage()
url = strings.ReplaceAll(url, "%09", "\t")
u, err := MakeUrl(url)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
switch u.Scheme {
case "gopher":
if u.DownloadOnly {
nameSplit := strings.Split(u.Resource, "/")
filename := nameSplit[len(nameSplit) - 1]
filename = strings.Trim(filename, " \t\r\n\v\f\a")
if filename == "" {
filename = "gopherfile"
}
c.saveFile(u, filename)
} else if u.Mime == "7" {
c.search("", u.Full, "?")
} else {
content, links, err := gopher.Visit(u.Mime, u.Host, u.Port, u.Resource)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
pg := MakePage(u, content, links)
pg.WrapContent(c.Width - 1)
c.PageState.Add(pg)
c.SetPercentRead()
c.ClearMessage()
c.SetHeaderUrl()
c.Draw()
}
case "gemini":
capsule, err := gemini.Visit(u.Host, u.Port, u.Resource)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
switch capsule.Status {
case 1:
c.search("", u.Full, capsule.Content)
case 2:
if capsule.MimeMaj == "text" {
pg := MakePage(u, capsule.Content, capsule.Links)
pg.WrapContent(c.Width - 1)
c.PageState.Add(pg)
c.SetPercentRead()
c.ClearMessage()
c.SetHeaderUrl()
c.Draw()
} else {
c.SetMessage("The file is non-text: (o)pen or (w)rite to disk", false)
c.DrawMessage()
var ch rune
for {
ch = cui.Getch()
if ch == 'o' || ch == 'w' {
break
}
}
switch ch {
case 'o':
mime := fmt.Sprintf("%s/%s", capsule.MimeMaj, capsule.MimeMin)
var term bool
if c.Options["terminalonly"] == "true" {
term = true
} else {
term = false
}
mcEntry, err := mc.FindMatch(mime, "view", term)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
return
}
file, err := ioutil.TempFile("/tmp/", "bombadillo-*.tmp")
if err != nil {
c.SetMessage("Unable to create temporary file for opening, aborting file open", true)
c.DrawMessage()
return
}
// defer os.Remove(file.Name())
file.Write([]byte(capsule.Content))
com, e := mcEntry.Command(file.Name())
if e != nil {
c.SetMessage(e.Error(), true)
c.DrawMessage()
return
}
com.Stdin = os.Stdin
com.Stdout = os.Stdout
com.Stderr = os.Stderr
if c.Options["terminalonly"] == "true" {
cui.Clear("screen")
}
com.Run()
c.SetMessage("File opened by an appropriate program", true)
c.DrawMessage()
c.Draw()
case 'w':
nameSplit := strings.Split(u.Resource, "/")
filename := nameSplit[len(nameSplit) - 1]
c.saveFileFromData(capsule.Content, filename)
}
}
case 3:
c.SetMessage("[3] Redirect. Follow redirect? y or any other key for no", false)
c.DrawMessage()
ch := cui.Getch()
if ch == 'y' || ch == 'Y' {
c.Visit(capsule.Content)
} else {
c.SetMessage("Redirect aborted", false)
c.DrawMessage()
}
}
case "telnet":
c.SetMessage("Attempting to start telnet session", false)
c.DrawMessage()
msg, err := telnet.StartSession(u.Host, u.Port)
if err != nil {
c.SetMessage(err.Error(), true)
c.DrawMessage()
} else {
c.SetMessage(msg, true)
c.DrawMessage()
}
c.Draw()
case "http", "https":
c.SetMessage("Attempting to open in web browser", false)
c.DrawMessage()
if strings.ToUpper(c.Options["openhttp"]) == "TRUE" {
msg, err := http.OpenInBrowser(u.Full)
if err != nil {
c.SetMessage(err.Error(), true)
} else {
c.SetMessage(msg, false)
}
c.DrawMessage()
} else {
c.SetMessage("'openhttp' is not set to true, cannot open web link", false)
c.DrawMessage()
}
default:
c.SetMessage(fmt.Sprintf("%q is not a supported protocol", u.Scheme), true)
c.DrawMessage()
}
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
func MakeClient(name string) *client {
c := client{0, 0, defaultOptions, "", false, MakePages(), MakeBookmarks(), MakeHeadbar(name), MakeFootbar()}
return &c
}

View File

@ -4,7 +4,6 @@ import (
"fmt"
"io"
"strings"
"tildegit.org/sloum/bombadillo/gopher"
)
//------------------------------------------------\\
@ -21,7 +20,10 @@ type Parser struct {
}
type Config struct {
Bookmarks gopher.Bookmarks
// Bookmarks gopher.Bookmarks
Bookmarks struct {
Titles, Links []string
}
Colors []KeyValue
Settings []KeyValue
}
@ -86,10 +88,8 @@ func (p *Parser) Parse() (Config, error) {
}
switch section {
case "BOOKMARKS":
err := c.Bookmarks.Add([]string{keyval.Value, keyval.Key})
if err != nil {
return c, err
}
c.Bookmarks.Titles = append(c.Bookmarks.Titles, keyval.Value)
c.Bookmarks.Links = append(c.Bookmarks.Links, keyval.Key)
case "COLORS":
c.Colors = append(c.Colors, keyval)
case "SETTINGS":

View File

@ -2,30 +2,32 @@ package cui
import (
"bufio"
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
var shapes = map[string]string{
"wall": "╵",
"ceiling": "╴",
"tl": "┌",
"tr": "┐",
"bl": "└",
"br": "┘",
"awall": "║",
"aceiling": "═",
"atl": "╔",
"atr": "╗",
"abl": "╚",
"abr": "╝",
var Shapes = map[string]string{
"walll": "╎",
"wallr": " ",
"ceiling": " ",
"floor": " ",
"tl": "╎",
"tr": " ",
"bl": "╎",
"br": " ",
"awalll": "▌",
"awallr": "▐",
"aceiling": "▀",
"afloor": "▄",
"atl": "▞",
"atr": "▜",
"abl": "▚",
"abr": "▟",
}
func drawShape(shape string) {
if val, ok := shapes[shape]; ok {
if val, ok := Shapes[shape]; ok {
fmt.Printf("%s", val)
} else {
fmt.Print("x")
@ -61,7 +63,8 @@ func Exit() {
fmt.Print("\n")
fmt.Print("\033[?25h")
HandleAlternateScreen("rmcup")
Tput("smam") // turn off line wrap
Tput("rmcup") // use alternate screen
os.Exit(0)
}
@ -81,41 +84,6 @@ func Clear(dir string) {
}
// takes the document content (as a slice) and modifies any lines that are longer
// than the specified console width, splitting them over two lines. returns the
// amended document content as a slice.
// word wrapping uses a "greedy" algorithm
func wrapLines(s []string, consolewidth int) []string {
out := []string{}
for _, ln := range s {
if len(ln) <= consolewidth {
out = append(out, ln)
} else {
words := strings.SplitAfter(ln, " ")
var subout bytes.Buffer
for i, wd := range words {
sublen := subout.Len()
wdlen := len(wd)
if sublen+wdlen <= consolewidth {
subout.WriteString(wd)
if i == len(words)-1 {
out = append(out, subout.String())
}
} else {
out = append(out, subout.String())
subout.Reset()
subout.WriteString(wd)
if i == len(words)-1 {
out = append(out, subout.String())
subout.Reset()
}
}
}
}
}
return out
}
func Getch() rune {
reader := bufio.NewReader(os.Stdin)
char, _, err := reader.ReadRune()
@ -161,7 +129,7 @@ func SetLineMode() {
}
}
func HandleAlternateScreen(opt string) {
func Tput(opt string) {
cmd := exec.Command("tput", opt)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout