diff --git a/.gitignore b/.gitignore index 9fe1ace..cb9380e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ bombadillo +*.asciinema diff --git a/LICENSE b/LICENSE index f288702..810fce6 100644 --- a/LICENSE +++ b/LICENSE @@ -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. - - - Copyright (C) - - 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 . - -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: - - Copyright (C) - 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 -. - - 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 -. diff --git a/bombadillo.1 b/bombadillo.1 new file mode 100644 index 0000000..1d28a72 --- /dev/null +++ b/bombadillo.1 @@ -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 + +Toggle the scroll focus between the bookmarks panel and the document panel. Only has an effect if the bookmarks panel is open. +.TP +.B + +Enter line command mode. Once a line command is input, the mode will automatically revert to key command mode. +.TP +.B +: +Alias for . 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 . 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. diff --git a/bookmarks.go b/bookmarks.go new file mode 100644 index 0000000..cb2b773 --- /dev/null +++ b/bookmarks.go @@ -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)} +} + diff --git a/client.go b/client.go new file mode 100644 index 0000000..642d9f4 --- /dev/null +++ b/client.go @@ -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 +} + diff --git a/config/parser.go b/config/parser.go index 4661ac6..038c889 100644 --- a/config/parser.go +++ b/config/parser.go @@ -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": diff --git a/cui/cui.go b/cui/cui.go index 67d8b3a..a7b865d 100644 --- a/cui/cui.go +++ b/cui/cui.go @@ -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 diff --git a/cui/cui_test.go b/cui/cui_test.go index 1a286d3..941e6a6 100644 --- a/cui/cui_test.go +++ b/cui/cui_test.go @@ -1,52 +1,5 @@ package cui import ( - "reflect" - "testing" ) -// tests related to issue 31 -func Test_wrapLines_space_preservation(t *testing.T) { - tables := []struct { - testinput []string - expectedoutput []string - linelength int - }{ - { - //normal sentence - 20 characters - should not wrap - []string{"it is her fav thingy"}, - []string{"it is her fav thingy"}, - 20, - }, - { - //normal sentence - more than 20 characters - should wrap with a space at the end of the first line - []string{"it is her favourite thing in the world"}, - []string{ - "it is her favourite ", - "thing in the world", - }, - 20, - }, - } - - for _, table := range tables { - output := wrapLines(table.testinput, table.linelength) - - if !reflect.DeepEqual(output, table.expectedoutput) { - t.Errorf("Expected %v, got %v", table.expectedoutput, output) - } - } -} - -func Benchmark_wrapLines(b *testing.B) { - teststring := []string{ - "0123456789", - "a really long line that will prolly be wrapped", - "a l i n e w i t h a l o t o f w o r d s", - "onehugelongwordaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - } - b.ResetTimer() - for n := 0; n < b.N; n++ { - wrapLines(teststring, 20) - } -} diff --git a/cui/msgbar.go b/cui/msgbar.go deleted file mode 100644 index 7524780..0000000 --- a/cui/msgbar.go +++ /dev/null @@ -1,32 +0,0 @@ -package cui - -// MsgBar is a struct to represent a single row horizontal -// bar on the screen. -type MsgBar struct { - row int - title string - message string - showTitle bool -} - -// SetTitle sets the title for the MsgBar in question -func (m *MsgBar) SetTitle(s string) { - m.title = s -} - -// SetMessage sets the message for the MsgBar in question -func (m *MsgBar) SetMessage(s string) { - m.message = s -} - -// ClearAll clears all text from the message bar (title and message) -func (m MsgBar) ClearAll() { - MoveCursorTo(m.row, 1) - Clear("line") -} - -// ClearMessage clears all message text while leaving the title in place -func (m *MsgBar) ClearMessage() { - MoveCursorTo(m.row, len(m.title)+1) - Clear("right") -} diff --git a/cui/screen.go b/cui/screen.go deleted file mode 100644 index 75a4a2c..0000000 --- a/cui/screen.go +++ /dev/null @@ -1,143 +0,0 @@ -package cui - -import ( - "bytes" - "fmt" - "os" - "os/exec" - "strings" -) - -// screenInit records whether or not the screen has been initialized -// this is used to prevent more than one screen from being used -var screenInit bool = false - -// Screen represent the top level abstraction for a cui application. -// It takes up the full width and height of the terminal window and -// holds the various Windows and MsgBars for the application as well -// as a record of which window is active for control purposes. -type Screen struct { - Height int - Width int - Windows []*Window - Activewindow int - Bars []*MsgBar -} - -// AddWindow adds a new window to the Screen struct in question -func (s *Screen) AddWindow(r1, c1, r2, c2 int, scroll, border, show bool) { - w := Window{box{r1, c1, r2, c2}, scroll, 0, []string{}, border, false, show, 1} - s.Windows = append(s.Windows, &w) -} - -// AddMsgBar adds a new MsgBar to the Screen struct in question -func (s *Screen) AddMsgBar(row int, title, msg string, showTitle bool) { - b := MsgBar{row, title, msg, showTitle} - s.Bars = append(s.Bars, &b) -} - -// DrawAllWindows loops over every window in the Screen struct and -// draws it to screen in index order (smallest to largest) -func (s Screen) DrawAllWindows() { - for _, w := range s.Windows { - if w.Show { - w.DrawWindow() - } - } - MoveCursorTo(s.Height-1, 1) -} - -// Clear removes all content from the interior of the screen -func (s Screen) Clear() { - for i := 0; i <= s.Height; i++ { - MoveCursorTo(i, 0) - Clear("line") - } -} - -// Clears message/error/command area -func (s *Screen) ClearCommandArea() { - MoveCursorTo(s.Height-1, 1) - Clear("line") - MoveCursorTo(s.Height, 1) - Clear("line") - MoveCursorTo(s.Height-1, 1) -} - -// ReflashScreen checks for a screen resize and resizes windows if -// needed then redraws the screen. It takes a bool to decide whether -// to redraw the full screen or just the content. On a resize -// event, the full screen will always be redrawn. -func (s *Screen) ReflashScreen(clearScreen bool) { - s.DrawAllWindows() - - if clearScreen { - s.DrawMsgBars() - s.ClearCommandArea() - } -} - -// DrawMsgBars draws all MsgBars present in the Screen struct. -// All MsgBars are looped over and drawn in index order (sm - lg). -func (s *Screen) DrawMsgBars() { - for _, bar := range s.Bars { - fmt.Print("\033[7m") - var buf bytes.Buffer - title := bar.title - if len(bar.title) > s.Width { - title = string(bar.title[:s.Width-3]) + "..." - } - _, _ = buf.WriteString(title) - msg := bar.message - if len(bar.message) > s.Width-len(title) { - msg = string(bar.message[:s.Width-len(title)-3]) + "..." - } - _, _ = buf.WriteString(msg) - MoveCursorTo(bar.row, 1) - fmt.Print(strings.Repeat(" ", s.Width)) - fmt.Print("\033[0m") - MoveCursorTo(bar.row, 1) - fmt.Print("\033[7m") - fmt.Print(buf.String()) - MoveCursorTo(bar.row, s.Width) - fmt.Print("\033[0m") - } -} - -// GetSize retrieves the terminal size and sets the Screen -// width and height to that size -func (s *Screen) GetSize() { - 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(1) - } - var h, w int - fmt.Sscan(string(out), &h, &w) - s.Height = h - s.Width = w -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - -// NewScreen is a constructor function that returns a pointer -// to a Screen struct -func NewScreen() *Screen { - if screenInit { - fmt.Println("Fatal error: Cannot create multiple screens") - os.Exit(1) - } - var s Screen - s.GetSize() - for i := 0; i < s.Height; i++ { - fmt.Println() - } - - SetCharMode() - - Clear("screen") - screenInit = true - return &s -} diff --git a/cui/window.go b/cui/window.go deleted file mode 100644 index 9ab01bf..0000000 --- a/cui/window.go +++ /dev/null @@ -1,187 +0,0 @@ -package cui - -import ( - "fmt" - "strings" -) - -type box struct { - Row1 int - Col1 int - Row2 int - Col2 int -} - -// TODO add coloring -type Window struct { - Box box - Scrollbar bool - Scrollposition int - Content []string - drawBox bool - Active bool - Show bool - tempContentLen int -} - -func (w *Window) DrawWindow() { - w.DrawContent() - - if w.drawBox { - w.DrawBox() - } -} - -func (w *Window) DrawBox() { - lead := "" - if w.Active { - lead = "a" - } - moveThenDrawShape(w.Box.Row1, w.Box.Col1, lead+"tl") - moveThenDrawShape(w.Box.Row1, w.Box.Col2, lead+"tr") - moveThenDrawShape(w.Box.Row2, w.Box.Col1, lead+"bl") - moveThenDrawShape(w.Box.Row2, w.Box.Col2, lead+"br") - for i := w.Box.Col1 + 1; i < w.Box.Col2; i++ { - moveThenDrawShape(w.Box.Row1, i, lead+"ceiling") - moveThenDrawShape(w.Box.Row2, i, lead+"ceiling") - } - - for i := w.Box.Row1 + 1; i < w.Box.Row2; i++ { - moveThenDrawShape(i, w.Box.Col1, lead+"wall") - moveThenDrawShape(i, w.Box.Col2, lead+"wall") - } -} - -func (w *Window) DrawContent() { - var maxlines, borderThickness, contenth int - var short_content bool = false - - if w.drawBox { - borderThickness, contenth = -1, 1 - } else { - borderThickness, contenth = 1, 0 - } - - height := w.Box.Row2 - w.Box.Row1 + borderThickness - width := w.Box.Col2 - w.Box.Col1 + borderThickness - - content := wrapLines(w.Content, width) - w.tempContentLen = len(content) - - if w.Scrollposition > w.tempContentLen-height { - w.Scrollposition = w.tempContentLen - height - if w.Scrollposition < 0 { - w.Scrollposition = 0 - } - } - - if len(content) < w.Scrollposition+height { - maxlines = len(content) - short_content = true - } else { - maxlines = w.Scrollposition + height - } - - for i := w.Scrollposition; i < maxlines; i++ { - MoveCursorTo(w.Box.Row1+contenth+i-w.Scrollposition, w.Box.Col1+contenth) - fmt.Print(strings.Repeat(" ", width)) - MoveCursorTo(w.Box.Row1+contenth+i-w.Scrollposition, w.Box.Col1+contenth) - fmt.Print(content[i]) - } - if short_content { - for i := len(content); i <= height; i++ { - MoveCursorTo(w.Box.Row1+contenth+i-w.Scrollposition, w.Box.Col1+contenth) - fmt.Print(strings.Repeat(" ", width)) - } - } -} - -func (w *Window) ScrollDown() { - var borderThickness int - if w.drawBox { - borderThickness = -1 - } else { - borderThickness = 1 - } - - height := w.Box.Row2 - w.Box.Row1 + borderThickness - - if w.Scrollposition < w.tempContentLen-height { - w.Scrollposition++ - } else { - fmt.Print("\a") - } -} - -func (w *Window) ScrollUp() { - if w.Scrollposition > 0 { - w.Scrollposition-- - } else { - fmt.Print("\a") - } -} - -func (w *Window) PageDown() { - var borderThickness int - if w.drawBox { - borderThickness = -1 - } else { - borderThickness = 1 - } - - height := w.Box.Row2 - w.Box.Row1 + borderThickness - - if w.Scrollposition < w.tempContentLen-height { - w.Scrollposition += height - if w.Scrollposition > w.tempContentLen-height { - w.Scrollposition = w.tempContentLen - height - } - } else { - fmt.Print("\a") - } -} - -func (w *Window) PageUp() { - var borderThickness int - if w.drawBox { - borderThickness = -1 - } else { - borderThickness = 1 - } - - height := w.Box.Row2 - w.Box.Row1 + borderThickness - contentLength := len(w.Content) - if w.Scrollposition > 0 && height < contentLength { - w.Scrollposition -= height - if w.Scrollposition < 0 { - w.Scrollposition = 0 - } - } else { - fmt.Print("\a") - } -} - -func (w *Window) ScrollHome() { - if w.Scrollposition > 0 { - w.Scrollposition = 0 - } else { - fmt.Print("\a") - } -} - -func (w *Window) ScrollEnd() { - var borderThickness int - if w.drawBox { - borderThickness = -1 - } else { - borderThickness = 1 - } - - height := w.Box.Row2 - w.Box.Row1 + borderThickness - - if w.Scrollposition < w.tempContentLen-height { - w.Scrollposition = w.tempContentLen - height - } else { - fmt.Print("\a") - } -} diff --git a/defaults.go b/defaults.go new file mode 100644 index 0000000..690a403 --- /dev/null +++ b/defaults.go @@ -0,0 +1,25 @@ +package main + +import ( + "os/user" +) + +var userinfo, _ = user.Current() +var defaultOptions = map[string]string{ + // + // General configuration options + // + // Edit these values before compile to have different default values + // ... though they can always be edited from within bombadillo as well + // it just may take more time/work. + "homeurl": "gopher://colorfield.space:70/1/bombadillo-info", + "savelocation": userinfo.HomeDir, + "searchengine": "gopher://gopher.floodgap.com:70/7/v2/vs", + "openhttp": "false", + "httpbrowser": "lynx", + "telnetcommand": "telnet", + "configlocation": userinfo.HomeDir, + "theme": "normal", // "normal", "inverted" + "terminalonly": "true", +} + diff --git a/footbar.go b/footbar.go new file mode 100644 index 0000000..c2d2e27 --- /dev/null +++ b/footbar.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "strconv" +) + + +//------------------------------------------------\\ +// + + + T Y P E S + + + \\ +//--------------------------------------------------\\ + +type Footbar struct { + PercentRead string + PageType string +} + + +//------------------------------------------------\\ +// + + + R E C E I V E R S + + + \\ +//--------------------------------------------------\\ + +func (f *Footbar) SetPercentRead(p int) { + if p > 100 { + p = 100 + } else if p < 0 { + p = 0 + } + f.PercentRead = strconv.Itoa(p) + "%" +} + +func (f *Footbar) SetPageType(t string) { + f.PageType = t +} + +func (f *Footbar) Render(termWidth, position int, theme string) string { + pre := fmt.Sprintf("HST: (%2.2d) - - - %4s Read ", position + 1, f.PercentRead) + out := "\033[0m%*.*s " + if theme == "inverse" { + out = "\033[7m%*.*s \033[0m" + } + return fmt.Sprintf(out, termWidth - 1, termWidth - 1, pre) +} + + +//------------------------------------------------\\ +// + + + F U N C T I O N S + + + \\ +//--------------------------------------------------\\ + +func MakeFootbar() Footbar { + return Footbar{"---", "N/A"} +} + diff --git a/gemini/gemini.go b/gemini/gemini.go new file mode 100644 index 0000000..4d8b044 --- /dev/null +++ b/gemini/gemini.go @@ -0,0 +1,233 @@ +package gemini + +import ( + "crypto/tls" + "fmt" + "io/ioutil" + "strconv" + "strings" + + // "tildegit.org/sloum/mailcap" +) + +type Capsule struct { + MimeMaj string + MimeMin string + Status int + Content string + Links []string +} + +//------------------------------------------------\\ +// + + + F U N C T I O N S + + + \\ +//--------------------------------------------------\\ + +func Retrieve(host, port, resource string) (string, error) { + if host == "" || port == "" { + return "", fmt.Errorf("Incomplete request url") + } + + addr := host + ":" + port + + conf := &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, + } + + conn, err := tls.Dial("tcp", addr, conf) + if err != nil { + return "", err + } + + defer conn.Close() + + send := "gemini://" + addr + "/" + resource + "\r\n" + + _, err = conn.Write([]byte(send)) + if err != nil { + return "", err + } + + result, err := ioutil.ReadAll(conn) + if err != nil { + return "", err + } + + return string(result), nil +} + +func Fetch(host, port, resource string) ([]byte, error) { + rawResp, err := Retrieve(host, port, resource) + if err != nil { + return make([]byte, 0), err + } + + resp := strings.SplitN(rawResp, "\r\n", 2) + if len(resp) != 2 { + if err != nil { + return make([]byte, 0), fmt.Errorf("Invalid response from server") + } + } + header := strings.SplitN(resp[0], " ", 2) + if len([]rune(header[0])) != 2 { + header = strings.SplitN(resp[0], "\t", 2) + if len([]rune(header[0])) != 2 { + return make([]byte,0), fmt.Errorf("Invalid response format from server") + } + } + + // Get status code single digit form + status, err := strconv.Atoi(string(header[0][0])) + if err != nil { + return make([]byte, 0), fmt.Errorf("Invalid status response from server") + } + + if status != 2 { + switch status { + case 1: + return make([]byte, 0), fmt.Errorf("[1] Queries cannot be saved.") + case 3: + return make([]byte, 0), fmt.Errorf("[3] Redirects cannot be saved.") + case 4: + return make([]byte, 0), fmt.Errorf("[4] Temporary Failure.") + case 5: + return make([]byte, 0), fmt.Errorf("[5] Permanent Failure.") + case 6: + return make([]byte, 0), fmt.Errorf("[6] Client Certificate Required (Not supported by Bombadillo)") + default: + return make([]byte, 0), fmt.Errorf("Invalid response status from server") + } + } + + return []byte(resp[1]), nil + +} + +func Visit(host, port, resource string) (Capsule, error) { + capsule := MakeCapsule() + rawResp, err := Retrieve(host, port, resource) + if err != nil { + return capsule, err + } + + resp := strings.SplitN(rawResp, "\r\n", 2) + if len(resp) != 2 { + if err != nil { + return capsule, fmt.Errorf("Invalid response from server") + } + } + header := strings.SplitN(resp[0], " ", 2) + if len([]rune(header[0])) != 2 { + header = strings.SplitN(resp[0], "\t", 2) + if len([]rune(header[0])) != 2 { + return capsule, fmt.Errorf("Invalid response format from server") + } + } + + body := resp[1] + + // Get status code single digit form + capsule.Status, err = strconv.Atoi(string(header[0][0])) + if err != nil { + return capsule, fmt.Errorf("Invalid status response from server") + } + + // Parse the meta as needed + var meta string + + switch capsule.Status { + case 1: + capsule.Content = header[1] + return capsule, nil + case 2: + mimeAndCharset := strings.Split(header[1], ";") + meta = mimeAndCharset[0] + minMajMime := strings.Split(meta, "/") + if len(minMajMime) < 2 { + return capsule, fmt.Errorf("Improperly formatted mimetype received from server") + } + capsule.MimeMaj = minMajMime[0] + capsule.MimeMin = minMajMime[1] + if capsule.MimeMaj == "text" && capsule.MimeMin == "gemini" { + if len(resource) > 0 && resource[0] != '/' { + resource = fmt.Sprintf("/%s", resource) + } else if resource == "" { + resource = "/" + } + currentUrl := fmt.Sprintf("gemini://%s:%s%s", host, port, resource) + rootUrl := fmt.Sprintf("gemini://%s:%s", host, port) + capsule.Content, capsule.Links = parseGemini(body, rootUrl, currentUrl) + } else { + capsule.Content = body + } + return capsule, nil + case 3: + // The client will handle informing the user of a redirect + // and then request the new url + capsule.Content = header[1] + return capsule, nil + case 4: + return capsule, fmt.Errorf("[4] Temporary Failure. %s", header[1]) + case 5: + return capsule, fmt.Errorf("[5] Permanent Failure. %s", header[1]) + case 6: + return capsule, fmt.Errorf("[6] Client Certificate Required (Not supported by Bombadillo)") + default: + return capsule, fmt.Errorf("Invalid response status from server") + } +} + +func parseGemini(b, rootUrl, currentUrl string) (string, []string) { + splitContent := strings.Split(b, "\n") + links := make([]string, 0, 10) + + for i, ln := range splitContent { + splitContent[i] = strings.Trim(ln, "\r\n") + if len([]rune(ln)) > 3 && ln[:2] == "=>" { + var link, decorator string + subLn := strings.Trim(ln[2:], "\r\n\t \a") + splitPoint := strings.IndexAny(subLn, " \t") + + if splitPoint < 0 || len([]rune(subLn)) - 1 <= splitPoint { + link = subLn + decorator = subLn + } else { + link = strings.Trim(subLn[:splitPoint], "\t\n\r \a") + decorator = strings.Trim(subLn[splitPoint:], "\t\n\r \a") + } + + if strings.Index(link, "://") < 0 { + link = handleRelativeUrl(link, rootUrl, currentUrl) + } + + links = append(links, link) + linknum := fmt.Sprintf("[%d]", len(links)) + splitContent[i] = fmt.Sprintf("%-5s %s", linknum, decorator) + } + } + return strings.Join(splitContent, "\n"), links +} + +func handleRelativeUrl(u, root, current string) string { + if len(u) < 1 { + return u + } + + if u[0] == '/' { + return fmt.Sprintf("%s%s", root, u) + } + + ind := strings.LastIndex(current, "/") + if ind < 10 { + return fmt.Sprintf("%s/%s", root, u) + } + + current = current[:ind + 1] + return fmt.Sprintf("%s%s", current, u) +} + + +func MakeCapsule() Capsule { + return Capsule{"", "", 0, "", make([]string, 0, 5)} +} + diff --git a/gopher/bookmark.go b/gopher/bookmark.go deleted file mode 100644 index 24ac80f..0000000 --- a/gopher/bookmark.go +++ /dev/null @@ -1,65 +0,0 @@ -package gopher - -import ( - "fmt" - "strings" -) - -//------------------------------------------------\\ -// + + + T Y P E S + + + \\ -//--------------------------------------------------\\ - -//Bookmarks is a holder for titles and links that -//can be retrieved by index -type Bookmarks struct { - Titles []string - Links []string -} - -//------------------------------------------------\\ -// + + + R E C E I V E R S + + + \\ -//--------------------------------------------------\\ - -// Add adds a new title and link combination to the bookmarks -// struct. It takes as input a string slice in which the first -// element represents the link and all following items represent -// the title of the bookmark (they will be joined with spaces). -func (b *Bookmarks) Add(v []string) error { - if len(v) < 2 { - return fmt.Errorf("Received %d arguments, expected 2 or more", len(v)) - } - b.Titles = append(b.Titles, strings.Join(v[1:], " ")) - b.Links = append(b.Links, v[0]) - return nil -} - -func (b *Bookmarks) Del(i int) error { - if i < len(b.Titles) && i < len(b.Links) { - b.Titles = append(b.Titles[:i], b.Titles[i+1:]...) - b.Links = append(b.Links[:i], b.Links[i+1:]...) - return nil - } - return fmt.Errorf("Bookmark %d does not exist", i) -} - -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) 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 -} diff --git a/gopher/gopher.go b/gopher/gopher.go index 82bedea..3b15440 100644 --- a/gopher/gopher.go +++ b/gopher/gopher.go @@ -21,17 +21,21 @@ import ( var types = map[string]string{ "0": "TXT", "1": "MAP", - "h": "HTM", "3": "ERR", "4": "BIN", "5": "DOS", - "s": "SND", - "g": "GIF", - "I": "IMG", - "9": "BIN", - "7": "FTS", "6": "UUE", + "7": "FTS", + "8": "TEL", + "9": "BIN", + "g": "GIF", + "G": "GEM", + "h": "HTM", + "I": "IMG", "p": "PNG", + "s": "SND", + "S": "SSH", + "T": "TEL", } //------------------------------------------------\\ @@ -43,25 +47,22 @@ var types = map[string]string{ // available to use directly, but in most implementations // using the "Visit" receiver of the History struct will // be better. -func Retrieve(u Url) ([]byte, error) { +func Retrieve(host, port, resource string) ([]byte, error) { nullRes := make([]byte, 0) timeOut := time.Duration(5) * time.Second - if u.Host == "" || u.Port == "" { + if host == "" || port == "" { return nullRes, errors.New("Incomplete request url") } - addr := u.Host + ":" + u.Port + addr := host + ":" + port conn, err := net.DialTimeout("tcp", addr, timeOut) if err != nil { return nullRes, err } - send := u.Resource + "\n" - if u.Scheme == "http" || u.Scheme == "https" { - send = u.Gophertype - } + send := resource + "\n" _, err = conn.Write([]byte(send)) if err != nil { @@ -73,43 +74,30 @@ func Retrieve(u Url) ([]byte, error) { return nullRes, err } - return result, err + return result, nil } -// Visit is a high level combination of a few different -// types that makes it easy to create a Url, make a request -// to that Url, and add the response and Url to a View. -// Returns a copy of the view and an error (or nil). -func Visit(addr, openhttp string) (View, error) { - u, err := MakeUrl(addr) +// Visit handles the making of the request, parsing of maps, and returning +// the correct information to the client +func Visit(gophertype, host, port, resource string) (string, []string, error) { + resp, err := Retrieve(host, port, resource) if err != nil { - return View{}, err - } - - if u.Gophertype == "h" { - if res, tf := isWebLink(u.Resource); tf && strings.ToUpper(openhttp) == "TRUE" { - err := openBrowser(res) - if err != nil { - return View{}, err - } - - return View{}, fmt.Errorf("") - } + return "", []string{}, err } + + text := string(resp) + links := []string{} - text, err := Retrieve(u) - if err != nil { - return View{}, err + if IsDownloadOnly(gophertype) { + return text, []string{}, nil } - var pageContent []string - if u.IsBinary && u.Gophertype != "7" { - pageContent = []string{string(text)} - } else { - pageContent = strings.Split(string(text), "\n") + + if gophertype == "1" { + text, links = parseMap(text) } - return MakeView(u, pageContent), nil + return text, links, nil } func getType(t string) string { @@ -127,3 +115,70 @@ func isWebLink(resource string) (string, bool) { } return "", false } + +func parseMap(text string) (string, []string) { + splitContent := strings.Split(text, "\n") + links := make([]string, 0, 10) + + for i, e := range splitContent { + e = strings.Trim(e, "\r\n") + if e == "." { + splitContent[i] = "" + continue + } + + line := strings.Split(e, "\t") + var title string + + if len(line[0]) > 1 { + title = line[0][1:] + } else { + title = "" + } + + if len(line) > 1 && len(line[0]) > 0 && string(line[0][0]) == "i" { + splitContent[i] = " " + string(title) + } else if len(line) >= 4 { + link := buildLink(line[2], line[3], string(line[0][0]), line[1]) + links = append(links, link) + linktext := fmt.Sprintf("(%s) %2d %s", getType(string(line[0][0])), len(links), title) + splitContent[i] = linktext + } + } + return strings.Join(splitContent, "\n"), links +} + +// Returns false for all text formats (including html +// even though it may link out. Things like telnet +// should never make it into the retrieve call for +// this module, having been handled in the client +// based on their protocol. +func IsDownloadOnly(gophertype string) bool { + switch gophertype { + case "0", "1", "3", "7", "h": + return false + default: + return true + } +} + +func buildLink(host, port, gtype, resource string) string { + switch gtype { + case "8", "T": + return fmt.Sprintf("telnet://%s:%s", host, port) + case "G": + return fmt.Sprintf("gemini://%s:%s%s", host, port, resource) + case "h": + u, tf := isWebLink(resource) + if tf { + if strings.Index(u, "://") > 0 { + return u + } else { + return fmt.Sprintf("http://%s", u) + } + } + return fmt.Sprintf("gopher://%s:%s/h%s", host, port, resource) + default: + return fmt.Sprintf("gopher://%s:%s/%s%s", host, port, gtype, resource) + } +} diff --git a/gopher/history.go b/gopher/history.go deleted file mode 100644 index 5fdc439..0000000 --- a/gopher/history.go +++ /dev/null @@ -1,112 +0,0 @@ -package gopher - -import ( - "errors" - "fmt" -) - -//------------------------------------------------\\ -// + + + T Y P E S + + + \\ -//--------------------------------------------------\\ - -// The history struct represents the history of the browsing -// session. It contains the current history position, the -// length of the active history space (this can be different -// from the available capacity in the Collection), and a -// collection array containing View structs representing -// each page in the current history. In general usage this -// struct should be initialized via the MakeHistory function. -type History struct { - Position int - Length int - Collection [20]View -} - -//------------------------------------------------\\ -// + + + R E C E I V E R S + + + \\ -//--------------------------------------------------\\ - -// The "Add" receiver takes a view and adds it to -// the history struct that called it. "Add" returns -// nothing. "Add" will shift history down if the max -// history length would be exceeded, and will reset -// history length if something is added in the middle. -func (h *History) Add(v View) { - v.ParseMap() - if h.Position == h.Length-1 && h.Length < len(h.Collection) { - h.Collection[h.Length] = v - h.Length++ - h.Position++ - } else if h.Position == h.Length-1 && h.Length == 20 { - for x := 1; x < len(h.Collection); x++ { - h.Collection[x-1] = h.Collection[x] - } - h.Collection[len(h.Collection)-1] = v - } else { - h.Position += 1 - h.Length = h.Position + 1 - h.Collection[h.Position] = v - } -} - -// The "Get" receiver is called by a history struct -// and returns a View from the current position, will -// return an error if history is empty and there is -// nothing to get. -func (h History) Get() (*View, error) { - if h.Position < 0 { - return nil, errors.New("History is empty, cannot get item from empty history.") - } - - return &h.Collection[h.Position], nil -} - -// The "GoBack" receiver is called by a history struct. -// When called it decrements the current position and -// displays the content for the View in that position. -// If history is at position 0, no action is taken. -func (h *History) GoBack() bool { - if h.Position > 0 { - h.Position-- - return true - } - - fmt.Print("\a") - return false -} - -// The "GoForward" receiver is called by a history struct. -// When called it increments the current position and -// displays the content for the View in that position. -// If history is at position len - 1, no action is taken. -func (h *History) GoForward() bool { - if h.Position+1 < h.Length { - h.Position++ - return true - } - - fmt.Print("\a") - return false -} - -// The "DisplayCurrentView" receiver is called by a history -// struct. It calls the Display receiver for th view struct -// at the current history position. "DisplayCurrentView" does -// not return anything, and does nothing if position is less -// that 0. -func (h *History) DisplayCurrentView() { - h.Collection[h.Position].Display() -} - -//------------------------------------------------\\ -// + + + F U N C T I O N S + + + \\ -//--------------------------------------------------\\ - -// Constructor function for History struct. -// This is used to initialize history position -// as -1, which is needed. Returns a copy of -// initialized History struct (does NOT return -// a pointer to the struct). -func MakeHistory() History { - return History{-1, 0, [20]View{}} -} diff --git a/gopher/open_browser_darwin.go b/gopher/open_browser_darwin.go deleted file mode 100644 index edafe36..0000000 --- a/gopher/open_browser_darwin.go +++ /dev/null @@ -1,9 +0,0 @@ -// +build darwin - -package gopher - -import "os/exec" - -func openBrowser(url string) error { - return exec.Command("open", url).Start() -} diff --git a/gopher/open_browser_linux.go b/gopher/open_browser_linux.go deleted file mode 100644 index 2ce35c9..0000000 --- a/gopher/open_browser_linux.go +++ /dev/null @@ -1,9 +0,0 @@ -// +build linux - -package gopher - -import "os/exec" - -func openBrowser(url string) error { - return exec.Command("xdg-open", url).Start() -} diff --git a/gopher/open_browser_other.go b/gopher/open_browser_other.go deleted file mode 100644 index 1659ea3..0000000 --- a/gopher/open_browser_other.go +++ /dev/null @@ -1,11 +0,0 @@ -// +build !linux -// +build !darwin -// +build !windows - -package gopher - -import "fmt" - -func openBrowser(url string) error { - return fmt.Errorf("Unsupported os for browser detection") -} diff --git a/gopher/open_browser_windows.go b/gopher/open_browser_windows.go deleted file mode 100644 index b57c9d6..0000000 --- a/gopher/open_browser_windows.go +++ /dev/null @@ -1,9 +0,0 @@ -// +build windows - -package gopher - -import "os/exec" - -func openBrowser(url string) error { - return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() -} diff --git a/gopher/url.go b/gopher/url.go deleted file mode 100644 index c94f020..0000000 --- a/gopher/url.go +++ /dev/null @@ -1,94 +0,0 @@ -package gopher - -import ( - "errors" - "regexp" - "strings" -) - -//------------------------------------------------\\ -// + + + T Y P E S + + + \\ -//--------------------------------------------------\\ - -// The url struct represents a URL for the rest of the system. -// It includes component parts as well as a full URL string. -type Url struct { - Scheme string - Host string - Port string - Gophertype string - Resource string - Full string - IsBinary bool -} - -//------------------------------------------------\\ -// + + + F U N C T I O N S + + + \\ -//--------------------------------------------------\\ - -// MakeUrl is a Url constructor that takes in a string -// representation of a url and returns a Url struct and -// an error (or nil). -func MakeUrl(u string) (Url, error) { - var out Url - re := regexp.MustCompile(`^((?Pgopher|http|https|ftp|telnet):\/\/)?(?P[\w\-\.\d]+)(?::(?P\d+)?)?(?:/(?P[01345679gIhisp])?)?(?P.*)?$`) - match := re.FindStringSubmatch(u) - - if valid := re.MatchString(u); !valid { - return out, errors.New("Invalid URL or command character") - } - - for i, name := range re.SubexpNames() { - switch name { - case "scheme": - out.Scheme = match[i] - case "host": - out.Host = match[i] - case "port": - out.Port = match[i] - case "type": - out.Gophertype = match[i] - case "resource": - out.Resource = match[i] - } - } - - if out.Scheme == "" { - out.Scheme = "gopher" - } - - if out.Host == "" { - return out, errors.New("no host") - } - - if out.Scheme == "gopher" && out.Port == "" { - out.Port = "70" - } else if out.Scheme == "http" && out.Port == "" { - out.Port = "80" - } else if out.Scheme == "https" && out.Port == "" { - out.Port = "443" - } - - if out.Gophertype == "" && (out.Resource == "" || out.Resource == "/") { - out.Gophertype = "1" - } - - if out.Scheme == "gopher" && out.Gophertype == "" { - out.Gophertype = "0" - } - - if out.Gophertype == "7" && strings.Contains(out.Resource, "\t") { - out.Gophertype = "1" - } - - switch out.Gophertype { - case "1", "0", "h", "7": - out.IsBinary = false - default: - out.IsBinary = true - } - - out.Full = out.Scheme + "://" + out.Host + ":" + out.Port + "/" + out.Gophertype + out.Resource - - return out, nil -} diff --git a/gopher/view.go b/gopher/view.go deleted file mode 100644 index 813f4ca..0000000 --- a/gopher/view.go +++ /dev/null @@ -1,83 +0,0 @@ -package gopher - -import ( - "fmt" - "strings" -) - -//------------------------------------------------\\ -// + + + T Y P E S + + + \\ -//--------------------------------------------------\\ - -// View is a struct representing a gopher page. It contains -// the page content as a string slice, a list of link URLs -// as string slices, and the Url struct representing the page. -type View struct { - Content []string - Links []string - Address Url -} - -//------------------------------------------------\\ -// + + + R E C E I V E R S + + + \\ -//--------------------------------------------------\\ - -// ParseMap is called by a view struct to parse a gophermap. -// It checks if the view is for a gophermap. If not,it does -// nothing. If so, it parses the gophermap into comment lines -// and link lines. For link lines it adds a link to the links -// slice and changes the content value to just the printable -// string plus a gophertype indicator and a link number that -// relates to the link position in the links slice. This -// receiver does not return anything. -func (v *View) ParseMap() { - if v.Address.Gophertype == "1" || v.Address.Gophertype == "7" { - for i, e := range v.Content { - e = strings.Trim(e, "\r\n") - if e == "." { - v.Content[i] = " " - continue - } - - line := strings.Split(e, "\t") - var title string - if len(line[0]) > 1 { - title = line[0][1:] - } else { - title = "" - } - if len(line) > 1 && len(line[0]) > 0 && string(line[0][0]) == "i" { - v.Content[i] = " " + string(title) - } else if len(line) >= 4 { - fulllink := fmt.Sprintf("%s:%s/%s%s", line[2], line[3], string(line[0][0]), line[1]) - v.Links = append(v.Links, fulllink) - linktext := fmt.Sprintf("(%s) %2d %s", getType(string(line[0][0])), len(v.Links), title) - v.Content[i] = linktext - } - } - } -} - -// Display is called on a view struct to print the contents of the view. -// This receiver does not return anything. -func (v View) Display() { - fmt.Println() - for _, el := range v.Content { - fmt.Println(el) - } -} - -//------------------------------------------------\\ -// + + + F U N C T I O N S + + + \\ -//--------------------------------------------------\\ - -// MakeView creates and returns a new View struct from -// a Url and a string splice of content. This is used to -// initialize a View with a Url struct, links, and content. -// It takes a Url struct and a content []string and returns -// a View (NOT a pointer to a View). -func MakeView(url Url, content []string) View { - v := View{content, make([]string, 0), url} - v.ParseMap() - return v -} diff --git a/headbar.go b/headbar.go new file mode 100644 index 0000000..e6d43ef --- /dev/null +++ b/headbar.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" +) + +//------------------------------------------------\\ +// + + + T Y P E S + + + \\ +//--------------------------------------------------\\ + +type Headbar struct { + title string + url string +} + + +//------------------------------------------------\\ +// + + + R E C E I V E R S + + + \\ +//--------------------------------------------------\\ + +func (h *Headbar) Render(width int, theme string) string { + maxMsgWidth := width - len([]rune(h.title)) - 2 + if theme == "inverse" { + return fmt.Sprintf("\033[7m%s▟\033[27m %-*.*s\033[0m", h.title, maxMsgWidth, maxMsgWidth, h.url) + } else { + return fmt.Sprintf("%s▟\033[7m %-*.*s\033[0m", h.title, maxMsgWidth, maxMsgWidth, h.url) + } +} + + +//------------------------------------------------\\ +// + + + F U N C T I O N S + + + \\ +//--------------------------------------------------\\ + +func MakeHeadbar(title string) Headbar { + return Headbar{title, ""} +} + diff --git a/http/open_browser_darwin.go b/http/open_browser_darwin.go new file mode 100644 index 0000000..dd7da7a --- /dev/null +++ b/http/open_browser_darwin.go @@ -0,0 +1,13 @@ +// +build darwin + +package http + +import "os/exec" + +func OpenInBrowser(url string) (string, error) { + err := exec.Command("open", url).Start() + if err != nil { + return "", err + } + return "Opened in system default web browser", nil +} diff --git a/http/open_browser_linux.go b/http/open_browser_linux.go new file mode 100644 index 0000000..dc99845 --- /dev/null +++ b/http/open_browser_linux.go @@ -0,0 +1,13 @@ +// +build linux + +package http + +import "os/exec" + +func OpenInBrowser(url string) (string, error) { + err := exec.Command("xdg-open", url).Start() + if err != nil { + return "", err + } + return "Opened in system default web browser", nil +} diff --git a/http/open_browser_other.go b/http/open_browser_other.go new file mode 100644 index 0000000..c6e5342 --- /dev/null +++ b/http/open_browser_other.go @@ -0,0 +1,11 @@ +// +build !linux +// +build !darwin +// +build !windows + +package http + +import "fmt" + +func OpenInBrowser(url string) (string, error) { + return "", fmt.Errorf("Unsupported os for browser detection") +} diff --git a/http/open_browser_windows.go b/http/open_browser_windows.go new file mode 100644 index 0000000..0ddf6c7 --- /dev/null +++ b/http/open_browser_windows.go @@ -0,0 +1,13 @@ +// +build windows + +package http + +import "os/exec" + +func OpenInBrowser(url string) (string, error) { + err := exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + if err != nil { + return "", err + } + return "Opened in system default web browser", nil +} diff --git a/main.go b/main.go index 015fe78..ffe6eab 100644 --- a/main.go +++ b/main.go @@ -1,402 +1,63 @@ package main +// Bombadillo is a gopher and gemini client for the terminal of unix or unix-like systems. +// +// Copyright (C) 2019 Brian Evans +// +// 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 . + import ( + "flag" "fmt" "io/ioutil" "os" - "os/user" - "regexp" - "strconv" "strings" - "tildegit.org/sloum/bombadillo/cmdparse" "tildegit.org/sloum/bombadillo/config" "tildegit.org/sloum/bombadillo/cui" - "tildegit.org/sloum/bombadillo/gopher" + "tildegit.org/sloum/mailcap" ) +const version = "2.0.0" + +var bombadillo *client var helplocation string = "gopher://colorfield.space:70/1/bombadillo-info" -var history gopher.History = gopher.MakeHistory() -var screen *cui.Screen -var userinfo, _ = user.Current() var settings config.Config -var options = map[string]string{ - "homeurl": "gopher://colorfield.space:70/1/bombadillo-info", - "savelocation": userinfo.HomeDir, - "searchengine": "gopher://gopher.floodgap.com:70/7/v2/vs", - "openhttp": "false", - "httpbrowser": "lynx", -} - -func saveFile(address, name string) error { - quickMessage("Saving file...", false) - - url, err := gopher.MakeUrl(address) - if err != nil { - quickMessage("Saving file...", true) - return err - } - - data, err := gopher.Retrieve(url) - if err != nil { - quickMessage("Saving file...", true) - return err - } - - err = ioutil.WriteFile(options["savelocation"]+name, data, 0644) - if err != nil { - quickMessage("Saving file...", true) - return err - } - - quickMessage(fmt.Sprintf("Saved file to %s%s", options["savelocation"], name), false) - return nil -} - -func saveFileFromData(v gopher.View) error { - quickMessage("Saving file...", false) - urlsplit := strings.Split(v.Address.Full, "/") - filename := urlsplit[len(urlsplit)-1] - saveMsg := fmt.Sprintf("Saved file as %q", options["savelocation"]+filename) - err := ioutil.WriteFile(options["savelocation"]+filename, []byte(strings.Join(v.Content, "")), 0644) - if err != nil { - quickMessage("Saving file...", true) - return err - } - - quickMessage(saveMsg, false) - return nil -} - -func search(u string) error { - cui.MoveCursorTo(screen.Height-1, 0) - cui.Clear("line") - fmt.Print("Enter form input: ") - cui.MoveCursorTo(screen.Height-1, 17) - - entry, err := cui.GetLine() - if err != nil { - return err - } - - quickMessage("Searching...", false) - searchurl := fmt.Sprintf("%s\t%s", u, entry) - sv, err := gopher.Visit(searchurl, options["openhttp"]) - if err != nil { - quickMessage("Searching...", true) - return err - } - history.Add(sv) - quickMessage("Searching...", true) - updateMainContent() - screen.Windows[0].Scrollposition = 0 - screen.ReflashScreen(true) - return nil -} - -func routeInput(com *cmdparse.Command) error { - var err error - switch com.Type { - case cmdparse.SIMPLE: - err = simpleCommand(com.Action) - case cmdparse.GOURL: - err = goToURL(com.Target) - case cmdparse.GOLINK: - err = goToLink(com.Target) - case cmdparse.DO: - err = doCommand(com.Action, com.Value) - case cmdparse.DOLINK: - err = doLinkCommand(com.Action, com.Target) - case cmdparse.DOAS: - err = doCommandAs(com.Action, com.Value) - case cmdparse.DOLINKAS: - err = doLinkCommandAs(com.Action, com.Target, com.Value) - default: - return fmt.Errorf("Unknown command entry!") - } - - return err -} - -func toggleBookmarks() { - bookmarks := screen.Windows[1] - main := screen.Windows[0] - if bookmarks.Show { - bookmarks.Show = false - screen.Activewindow = 0 - main.Active = true - bookmarks.Active = false - } else { - bookmarks.Show = true - screen.Activewindow = 1 - main.Active = false - bookmarks.Active = true - } - - screen.ReflashScreen(false) -} - -func simpleCommand(a string) error { - a = strings.ToUpper(a) - switch a { - case "Q", "QUIT": - cui.Exit() - case "H", "HOME": - return goHome() - case "B", "BOOKMARKS": - toggleBookmarks() - case "SEARCH": - return search(options["searchengine"]) - case "HELP", "?": - return goToURL(helplocation) - - default: - return fmt.Errorf("Unknown action %q", a) - } - return nil -} - -func goToURL(u string) error { - if num, _ := regexp.MatchString(`^-?\d+.?\d*$`, u); num { - return goToLink(u) - } - quickMessage("Loading...", false) - v, err := gopher.Visit(u, options["openhttp"]) - if err != nil { - quickMessage("Loading...", true) - return err - } - quickMessage("Loading...", true) - - if v.Address.Gophertype == "7" { - err := search(v.Address.Full) - if err != nil { - return err - } - } else if v.Address.IsBinary { - return saveFileFromData(v) - } else { - history.Add(v) - } - updateMainContent() - screen.Windows[0].Scrollposition = 0 - screen.ReflashScreen(true) - return nil -} - -func goToLink(l string) error { - if num, _ := regexp.MatchString(`^-?\d+$`, l); num && history.Length > 0 { - linkcount := len(history.Collection[history.Position].Links) - item, _ := strconv.Atoi(l) - if item <= linkcount && item > 0 { - linkurl := history.Collection[history.Position].Links[item-1] - quickMessage("Loading...", false) - v, err := gopher.Visit(linkurl, options["openhttp"]) - if err != nil { - quickMessage("Loading...", true) - return err - } - quickMessage("Loading...", true) - - if v.Address.Gophertype == "7" { - err := search(linkurl) - if err != nil { - return err - } - } else if v.Address.IsBinary { - return saveFileFromData(v) - } else { - history.Add(v) - } - } else { - return fmt.Errorf("Invalid link id: %s", l) - } - } else { - return fmt.Errorf("Invalid link id: %s", l) - } - updateMainContent() - screen.Windows[0].Scrollposition = 0 - screen.ReflashScreen(true) - return nil -} - -func goHome() error { - if options["homeurl"] != "unset" { - return goToURL(options["homeurl"]) - } - return fmt.Errorf("No home address has been set") -} - -func doLinkCommand(action, target string) error { - num, err := strconv.Atoi(target) - if err != nil { - return fmt.Errorf("Expected number, got %q", target) - } - - switch action { - case "DELETE", "D": - err := settings.Bookmarks.Del(num) - if err != nil { - return err - } - - screen.Windows[1].Content = settings.Bookmarks.List() - err = saveConfig() - if err != nil { - return err - } - - screen.ReflashScreen(false) - return nil - case "BOOKMARKS", "B": - if num > len(settings.Bookmarks.Links)-1 { - return fmt.Errorf("There is no bookmark with ID %d", num) - } - err := goToURL(settings.Bookmarks.Links[num]) - return err - } - - return fmt.Errorf("This method has not been built") -} - -func doCommandAs(action string, values []string) error { - if len(values) < 2 { - return fmt.Errorf("%q", values) - } - - if values[0] == "." { - values[0] = history.Collection[history.Position].Address.Full - } - - switch action { - case "ADD", "A": - err := settings.Bookmarks.Add(values) - if err != nil { - return err - } - - screen.Windows[1].Content = settings.Bookmarks.List() - err = saveConfig() - if err != nil { - return err - } - - screen.ReflashScreen(false) - return nil - case "WRITE", "W": - return saveFile(values[0], strings.Join(values[1:], " ")) - case "SET", "S": - if _, ok := options[values[0]]; ok { - options[values[0]] = strings.Join(values[1:], " ") - return saveConfig() - } - return fmt.Errorf("Unable to set %s, it does not exist", values[0]) - } - return fmt.Errorf("Unknown command structure") -} - -func doCommand(action string, values []string) error { - if length := len(values); length != 1 { - return fmt.Errorf("Expected 1 argument, received %d", length) - } - - switch action { - case "CHECK", "C": - err := checkConfigValue(values[0]) - if err != nil { - return err - } - return nil - } - return fmt.Errorf("Unknown command structure") -} - -func checkConfigValue(setting string) error { - if val, ok := options[setting]; ok { - quickMessage(fmt.Sprintf("%s is set to: %q", setting, val), false) - return nil - - } - return fmt.Errorf("Unable to check %q, it does not exist", setting) -} - -func doLinkCommandAs(action, target string, values []string) error { - num, err := strconv.Atoi(target) - if err != nil { - return fmt.Errorf("Expected number, got %q", target) - } - - links := history.Collection[history.Position].Links - if num >= len(links) { - return fmt.Errorf("Invalid link id: %s", target) - } - - switch action { - case "ADD", "A": - newBookmark := append([]string{links[num-1]}, values...) - err := settings.Bookmarks.Add(newBookmark) - if err != nil { - return err - } - - screen.Windows[1].Content = settings.Bookmarks.List() - - err = saveConfig() - if err != nil { - return err - } - - screen.ReflashScreen(false) - return nil - case "WRITE", "W": - return saveFile(links[num-1], strings.Join(values, " ")) - } - - return fmt.Errorf("This method has not been built") -} - -func updateMainContent() { - screen.Windows[0].Content = history.Collection[history.Position].Content - screen.Bars[0].SetMessage(history.Collection[history.Position].Address.Full) -} - -func clearInput(incError bool) { - cui.MoveCursorTo(screen.Height-1, 0) - cui.Clear("line") - if incError { - cui.MoveCursorTo(screen.Height, 0) - cui.Clear("line") - } -} - -func quickMessage(msg string, clearMsg bool) { - xPos := screen.Width - 2 - len(msg) - if xPos < 2 { - xPos = 2 - } - cui.MoveCursorTo(screen.Height, xPos) - if clearMsg { - cui.Clear("right") - } else { - fmt.Print("\033[48;5;21m\033[38;5;15m", msg, "\033[0m") - } -} +var mc *mailcap.Mailcap func saveConfig() error { - bkmrks := settings.Bookmarks.IniDump() - opts := "\n[SETTINGS]\n" - for k, v := range options { - opts += k - opts += "=" - opts += v - opts += "\n" + var opts strings.Builder + bkmrks := bombadillo.BookMarks.IniDump() + + opts.WriteString(bkmrks) + opts.WriteString("\n[SETTINGS]\n") + for k, v := range bombadillo.Options { + if k == "theme" && v != "normal" && v != "inverse" { + v = "normal" + bombadillo.Options["theme"] = "normal" + } + opts.WriteString(k) + opts.WriteRune('=') + opts.WriteString(v) + opts.WriteRune('\n') } - return ioutil.WriteFile(userinfo.HomeDir+"/.bombadillo.ini", []byte(bkmrks+opts), 0644) + return ioutil.WriteFile(bombadillo.Options["configlocation"] + "/.bombadillo.ini", []byte(opts.String()), 0644) } func loadConfig() error { - file, err := os.Open(userinfo.HomeDir + "/.bombadillo.ini") + file, err := os.Open(bombadillo.Options["configlocation"] + "/.bombadillo.ini") if err != nil { err = saveConfig() if err != nil { @@ -407,164 +68,77 @@ func loadConfig() error { confparser := config.NewParser(file) settings, _ = confparser.Parse() file.Close() - screen.Windows[1].Content = settings.Bookmarks.List() for _, v := range settings.Settings { lowerkey := strings.ToLower(v.Key) - if _, ok := options[lowerkey]; ok { - options[lowerkey] = v.Value + if lowerkey == "configlocation" { + // The config should always be stored in home + // folder. Users cannot really edit this value. + // It is still stored in the ini and as a part + // of the options map. + continue } + + if _, ok := bombadillo.Options[lowerkey]; ok { + if lowerkey == "theme" && v.Value != "normal" && v.Value != "inverse" { + v.Value = "normal" + } + bombadillo.Options[lowerkey] = v.Value + } + } + + for i, v := range settings.Bookmarks.Titles { + bombadillo.BookMarks.Add([]string{v, settings.Bookmarks.Links[i]}) } return nil } -func toggleActiveWindow() { - if screen.Windows[1].Show { - if screen.Windows[0].Active { - screen.Windows[0].Active = false - screen.Windows[1].Active = true - screen.Activewindow = 1 - } else { - screen.Windows[0].Active = true - screen.Windows[1].Active = false - screen.Activewindow = 0 - } - screen.Windows[1].DrawWindow() - } -} - -func displayError(err error) { - cui.MoveCursorTo(screen.Height, 0) - fmt.Print("\033[41m\033[37m", err, "\033[0m") -} - func initClient() error { - history.Position = -1 - - screen = cui.NewScreen() + bombadillo = MakeClient(" ((( Bombadillo ))) ") cui.SetCharMode() - - screen.AddWindow(2, 1, screen.Height-2, screen.Width, false, false, true) - screen.Windows[0].Active = true - screen.AddMsgBar(1, " ((( Bombadillo ))) ", " A fun gopher client!", true) - bookmarksWidth := 40 - if screen.Width < 40 { - bookmarksWidth = screen.Width - } - screen.AddWindow(2, screen.Width-bookmarksWidth, screen.Height-2, screen.Width, false, true, false) - return loadConfig() -} - -func handleResize() { - oldh, oldw := screen.Height, screen.Width - screen.GetSize() - if screen.Height != oldh || screen.Width != oldw { - screen.Windows[0].Box.Row2 = screen.Height - 2 - screen.Windows[0].Box.Col2 = screen.Width - bookmarksWidth := 40 - if screen.Width < 40 { - bookmarksWidth = screen.Width - } - screen.Windows[1].Box.Row2 = screen.Height - 2 - screen.Windows[1].Box.Col1 = screen.Width - bookmarksWidth - screen.Windows[1].Box.Col2 = screen.Width - - screen.DrawAllWindows() - screen.DrawMsgBars() - screen.ClearCommandArea() - } + err := loadConfig() + return err } func main() { - cui.HandleAlternateScreen("smcup") + getVersion := flag.Bool("v", false, "See version number") + flag.Parse() + if *getVersion { + fmt.Printf("Bombadillo v%s\n", version) + os.Exit(0) + } + args := flag.Args() + + // Build the mailcap db + // So that we can open files from gemini + mc = mailcap.NewMailcap() + + cui.Tput("rmam") // turn off line wrapping + cui.Tput("smcup") // use alternate screen defer cui.Exit() err := initClient() if err != nil { - // if we can't initialize the window, - // we can't do anything! + // if we can't initialize we should bail out panic(err) } - mainWindow := screen.Windows[0] + // Start polling for terminal size changes + go bombadillo.GetSize() - if len(os.Args) > 1 { - err = goToURL(os.Args[1]) + if len(args) > 0 { + // If a url was passed, move it down the line + // Goroutine so keypresses can be made during + // page load + bombadillo.Visit(args[0]) } else { - err = goHome() - } - - if err != nil { - displayError(err) - } else { - updateMainContent() + // Otherwise, load the homeurl + // Goroutine so keypresses can be made during + // page load + bombadillo.Visit(bombadillo.Options["homeurl"]) } + // Loop indefinitely on user input for { - c := cui.Getch() - - handleResize() - - switch c { - case 'j', 'J': - screen.Windows[screen.Activewindow].ScrollDown() - screen.ReflashScreen(false) - case 'k', 'K': - screen.Windows[screen.Activewindow].ScrollUp() - screen.ReflashScreen(false) - case 'q', 'Q': - cui.Exit() - case 'g': - screen.Windows[screen.Activewindow].ScrollHome() - screen.ReflashScreen(false) - case 'G': - screen.Windows[screen.Activewindow].ScrollEnd() - screen.ReflashScreen(false) - case 'd': - screen.Windows[screen.Activewindow].PageDown() - screen.ReflashScreen(false) - case 'u': - screen.Windows[screen.Activewindow].PageUp() - screen.ReflashScreen(false) - case 'b': - success := history.GoBack() - if success { - mainWindow.Scrollposition = 0 - updateMainContent() - screen.ReflashScreen(true) - } - case 'B': - toggleBookmarks() - case 'f', 'F': - success := history.GoForward() - if success { - mainWindow.Scrollposition = 0 - updateMainContent() - screen.ReflashScreen(true) - } - case '\t': - toggleActiveWindow() - case ':', ' ': - cui.MoveCursorTo(screen.Height-1, 0) - entry, err := cui.GetLine() - if err != nil { - displayError(err) - } - - // Clear entry line and error line - clearInput(true) - if entry == "" { - continue - } - parser := cmdparse.NewParser(strings.NewReader(entry)) - p, err := parser.Parse() - if err != nil { - displayError(err) - } else { - err := routeInput(p) - if err != nil { - displayError(err) - } - } - } + bombadillo.TakeControlInput() } } diff --git a/page.go b/page.go new file mode 100644 index 0000000..ee8b68c --- /dev/null +++ b/page.go @@ -0,0 +1,92 @@ +package main + +import ( + "strings" +) + +//------------------------------------------------\\ +// + + + T Y P E S + + + \\ +//--------------------------------------------------\\ + +type Page struct { + WrappedContent []string + RawContent string + Links []string + Location Url + ScrollPosition int +} + +//------------------------------------------------\\ +// + + + R E C E I V E R S + + + \\ +//--------------------------------------------------\\ + +func (p *Page) ScrollPositionRange(termHeight int) (int, int) { + termHeight -= 3 + if len(p.WrappedContent) - p.ScrollPosition < termHeight { + p.ScrollPosition = len(p.WrappedContent) - termHeight + } + if p.ScrollPosition < 0 { + p.ScrollPosition = 0 + } + var end int + if len(p.WrappedContent) < termHeight { + end = len(p.WrappedContent) + } else { + end = p.ScrollPosition + termHeight + } + + return p.ScrollPosition, end +} + +// Performs a hard wrap to the requested +// width and updates the WrappedContent +// of the Page struct width a string slice +// of the wrapped data +func (p *Page) WrapContent(width int) { + counter := 0 + var content strings.Builder + content.Grow(len(p.RawContent)) + for _, ch := range []rune(p.RawContent) { + if ch == '\n' { + content.WriteRune(ch) + counter = 0 + } else if ch == '\t' { + if counter + 4 < width { + content.WriteString(" ") + counter += 4 + } else { + content.WriteRune('\n') + counter = 0 + } + } else if ch == '\r' || ch == '\v' || ch == '\b' || ch == '\f' || ch == 27 { + // Get rid of control characters we dont want + continue + } else { + if counter < width { + content.WriteRune(ch) + counter++ + } else { + content.WriteRune('\n') + counter = 0 + if p.Location.Mime == "1" { + spacer := " " + content.WriteString(spacer) + counter += len(spacer) + } + content.WriteRune(ch) + } + } + } + + p.WrappedContent = strings.Split(content.String(), "\n") +} + +//------------------------------------------------\\ +// + + + F U N C T I O N S + + + \\ +//--------------------------------------------------\\ + +func MakePage(url Url, content string, links []string) Page { + p := Page{make([]string, 0), content, links, url, 0} + return p +} + diff --git a/pages.go b/pages.go new file mode 100644 index 0000000..08a403b --- /dev/null +++ b/pages.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" +) + +//------------------------------------------------\\ +// + + + T Y P E S + + + \\ +//--------------------------------------------------\\ + +type Pages struct { + Position int + Length int + History [20]Page +} + + +//------------------------------------------------\\ +// + + + R E C E I V E R S + + + \\ +//--------------------------------------------------\\ + +func (p *Pages) NavigateHistory(qty int) error { + newPosition := p.Position + qty + if newPosition < 0 { + return fmt.Errorf("You are already at the beginning of history") + } else if newPosition > p.Length - 1 { + return fmt.Errorf("Your way is blocked by void, there is nothing forward") + } + + p.Position = newPosition + return nil +} + +func (p *Pages) Add(pg Page) { + if p.Position == p.Length - 1 && p.Length < len(p.History) { + p.History[p.Length] = pg + p.Length++ + p.Position++ + } else if p.Position == p.Length - 1 && p.Length == 20 { + for x := 1; x < len(p.History); x++ { + p.History[x-1] = p.History[x] + } + p.History[len(p.History)-1] = pg + } else { + p.Position += 1 + p.Length = p.Position + 1 + p.History[p.Position] = pg + } +} + +func (p *Pages) Render(termHeight, termWidth int) []string { + if p.Length < 1 { + return make([]string, 0) + } + pos := p.History[p.Position].ScrollPosition + prev := len(p.History[p.Position].WrappedContent) + p.History[p.Position].WrapContent(termWidth) + now := len(p.History[p.Position].WrappedContent) + if prev > now { + diff := prev - now + pos = pos - diff + } else if prev < now { + diff := now - prev + pos = pos + diff + if pos > now - termHeight { + pos = now - termHeight + } + } + + if pos < 0 || now < termHeight - 3 { + pos = 0 + } + + p.History[p.Position].ScrollPosition = pos + + return p.History[p.Position].WrappedContent[pos:] +} + +//------------------------------------------------\\ +// + + + F U N C T I O N S + + + \\ +//--------------------------------------------------\\ + +func MakePages() Pages { + return Pages{-1, 0, [20]Page{}} +} + + diff --git a/telnet/telnet.go b/telnet/telnet.go new file mode 100644 index 0000000..609f13d --- /dev/null +++ b/telnet/telnet.go @@ -0,0 +1,24 @@ +package telnet + +import ( + "fmt" + "os" + "os/exec" +) + +func StartSession(host string, port string) (string, error) { + // Case for telnet links + c := exec.Command("telnet", host, port) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + // Clear the screen and position the cursor at the top left + fmt.Print("\033[2J\033[0;0H") + err := c.Run() + if err != nil { + return "", fmt.Errorf("Telnet error response: %s", err.Error()) + } + + return "Telnet session terminated", nil +} + diff --git a/url.go b/url.go new file mode 100644 index 0000000..faedbbb --- /dev/null +++ b/url.go @@ -0,0 +1,111 @@ +package main + +import ( + "fmt" + "regexp" + "strings" +) + +//------------------------------------------------\\ +// + + + T Y P E S + + + \\ +//--------------------------------------------------\\ + +type Url struct { + Scheme string + Host string + Port string + Resource string + Full string + Mime string + DownloadOnly bool +} + +//------------------------------------------------\\ +// + + + R E C E I V E R S + + + \\ +//--------------------------------------------------\\ + +// There are currently no receivers for the Url struct + + +//------------------------------------------------\\ +// + + + F U N C T I O N S + + + \\ +//--------------------------------------------------\\ + + +// MakeUrl is a Url constructor that takes in a string +// representation of a url and returns a Url struct and +// an error (or nil). +func MakeUrl(u string) (Url, error) { + var out Url + re := regexp.MustCompile(`^((?Pgopher|telnet|http|https|gemini):\/\/)?(?P[\w\-\.\d]+)(?::(?P\d+)?)?(?:/(?P[01345679gIhisp])?)?(?P.*)?$`) + match := re.FindStringSubmatch(u) + + if valid := re.MatchString(u); !valid { + return out, fmt.Errorf("Invalid url, unable to parse") + } + + for i, name := range re.SubexpNames() { + switch name { + case "scheme": + out.Scheme = match[i] + case "host": + out.Host = match[i] + case "port": + out.Port = match[i] + case "type": + out.Mime = match[i] + case "resource": + out.Resource = match[i] + } + } + + if out.Scheme == "" { + out.Scheme = "gopher" + } + + if out.Host == "" { + return out, fmt.Errorf("no host") + } + + if out.Scheme == "gopher" && out.Port == "" { + out.Port = "70" + } else if out.Scheme == "http" && out.Port == "" { + out.Port = "80" + } else if out.Scheme == "https" && out.Port == "" { + out.Port = "443" + } else if out.Scheme == "gemini" && out.Port == "" { + out.Port = "1965" + } + + if out.Scheme == "gopher" && out.Mime == "" { + out.Mime = "1" + } + + if out.Mime == "" && (out.Resource == "" || out.Resource == "/") && out.Scheme == "gopher" { + out.Mime = "1" + } + + if out.Mime == "7" && strings.Contains(out.Resource, "\t") { + out.Mime = "1" + } + + if out.Scheme == "gopher" { + switch out.Mime { + case "1", "0", "h", "7": + out.DownloadOnly = false + default: + out.DownloadOnly = true + } + } else { + out.Resource = fmt.Sprintf("%s%s", out.Mime, out.Resource) + out.Mime = "" + } + + if out.Scheme == "http" || out.Scheme == "https" { + out.Mime = "" + } + + out.Full = out.Scheme + "://" + out.Host + ":" + out.Port + "/" + out.Mime + out.Resource + + return out, nil +}