From df90c50ce681496804246984d19725a2a3eadf2a Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Sat, 26 Nov 2022 11:44:07 -0500 Subject: [PATCH] Initial commit --- README.md | 8 + about.go | 42 ++++ backup | 71 ++++++ bindings.go | 181 ++++++++++++++ bookmarks/bookmarks.go | 37 +++ dom/document.go | 548 +++++++++++++++++++++++++++++++++++++++++ event.go | 1 + fetch.go | 186 ++++++++++++++ go.mod | 20 ++ go.sum | 41 +++ history.go | 47 ++++ icon/icon-old.svg | 22 ++ icon/icon.svg | 14 ++ icon/icon64.png | Bin 0 -> 2963 bytes input/input.go | 253 +++++++++++++++++++ main.go | 304 +++++++++++++++++++++++ 16 files changed, 1775 insertions(+) create mode 100644 README.md create mode 100644 about.go create mode 100644 backup create mode 100644 bindings.go create mode 100644 bookmarks/bookmarks.go create mode 100644 dom/document.go create mode 100644 event.go create mode 100644 fetch.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 history.go create mode 100644 icon/icon-old.svg create mode 100644 icon/icon.svg create mode 100644 icon/icon64.png create mode 100644 input/input.go create mode 100644 main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3a9064 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +![Skipper](icon/icon64.png) + +# skipper + +Skipper is a Gemini protocol browser built with the stone application framework. + +It aims to be fast, fully-featured, and convenient, while having a minimalist +user interface that gets out of your way and lets you just browse. diff --git a/about.go b/about.go new file mode 100644 index 0000000..6154bd1 --- /dev/null +++ b/about.go @@ -0,0 +1,42 @@ +package main + +const aboutGemtext = +`# About Skipper + + +` + "```" + ` + --. ,-- + ' '. . . ,' ' + \ '. \ / .' / + \ '.\/.' / _ o __ __ __ __ + \ '' / / /, / /_/ /_/ /_ /_/ + '--... ...--' _/ /\ / / / /_ / | + .' '. + \____/\____/ +` + "```" + ` + + +Skipper is a Gemini protocol browser built with the stone application framework. + +It aims to be fast, fully-featured, and convenient, while having a minimalist user interface that gets out of your way and lets you just browse.` + +const homeGemtext = +`# Home + +Welcome to Skipper. + +You can press... + +* Space to perform a search +* Comma to edit the current URL +* Period to follow numbered links +* 0-9 to quickly follow numbered links 0-9 +* H to go home +* B to view bookmarks +* Shift + B to bookmark a page +* Left/right arrow keys to go back/forward +* Up/down arrow keys and page up/down to scroll + +Happy browsing! + +=> gemini://gemini.circumlunar.space:1965/ Project Gemini Home` diff --git a/backup b/backup new file mode 100644 index 0000000..1d7a789 --- /dev/null +++ b/backup @@ -0,0 +1,71 @@ +package main + +import "io" +import "os" +import "fmt" +import "image" +import _ "image/png" +import "git.sr.ht/~yotam/go-gemini" +import "git.tebibyte.media/sashakoshka/stone" +import _ "git.tebibyte.media/sashakoshka/stone/backends/x" + +var application = &stone.Application { } +var client = gemini.Client { InsecureSkipVerify: true } +var document string + +func loadImage (name string) (output image.Image) { + file, err := os.Open(name) + defer file.Close() + if err != nil { panic(err) } + output, _, err = image.Decode(file) + if err != nil { panic(err) } + return +} + +func main () { + application.SetTitle("hellorld") + application.SetSize(12, 7) + + application.SetIcon ([]image.Image { + loadImage("icon/icon16.png"), + loadImage("icon/icon24.png"), + loadImage("icon/icon32.png"), + loadImage("icon/icon48.png"), + }) + + channel, err := application.Run() + if err != nil { panic(err) } + + response, err := client.Fetch("gemini://gemini.circumlunar.space:1965") + if err != nil { + panic(err.Error()) + } + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + panic(err.Error()) + } + response.Body.Close() + println("read") + document = string(responseBytes) + println(document) + + redraw() + + for { + event := <- channel + switch event.(type) { + case stone.EventQuit: + os.Exit(0) + + case stone.EventResize: + redraw() + } + } +} + +func redraw () { + // width, height := application.Size() + application.ResetDot() + fmt.Fprint(application, document) + application.Draw() +} diff --git a/bindings.go b/bindings.go new file mode 100644 index 0000000..bf5724a --- /dev/null +++ b/bindings.go @@ -0,0 +1,181 @@ +package main + +import "git.tebibyte.media/sashakoshka/stone" +import "git.tebibyte.media/sashakoshka/skipper/bookmarks" + +type bindingKey struct { + control bool + shift bool + alt bool + button stone.Button +} + +var bindings = map[bindingKey] func () { + bindingKey { + button: stone.Button('`'), + }: func () { fetchStringUrl("about:skipper") }, + + bindingKey { + button: stone.Button('r'), + }: func () { go fetchNoTrace(page.currentUrl) }, + + bindingKey { + button: stone.Button('u'), + }: func () { go fetchSource(page.currentUrl) }, + + bindingKey { + button: stone.Button(' '), + }: func () { onUrlBarSelect("") }, + + bindingKey { + button: stone.Button(','), + }: func () { onUrlBarSelect(page.currentUrl.String()) }, + + bindingKey { + button: stone.Button('.'), + }: func () { onUrlBarSelect(".") }, + + bindingKey { + button: stone.Button('/'), + }: func () { onUrlBarSelect("?") }, + + bindingKey { + button: stone.Button('b'), + }: func () { fetchStringUrl("about:bookmarks") }, + + bindingKey { + shift: true, + button: stone.Button('B'), + }: func () { + bookmarks.Add (bookmarks.Bookmark { + Title: page.document.Title(), + Location: page.currentUrl, + }) + }, + + bindingKey { + button: stone.Button('h'), + }: func () { fetch(bookmarks.HomePage.Location) }, + + bindingKey { + button: stone.Button('0'), + }: func () { followLink(0) }, + + bindingKey { + button: stone.Button('1'), + }: func () { followLink(1) }, + + bindingKey { + button: stone.Button('2'), + }: func () { followLink(2) }, + + bindingKey { + button: stone.Button('3'), + }: func () { followLink(3) }, + + bindingKey { + button: stone.Button('4'), + }: func () { followLink(4) }, + + bindingKey { + button: stone.Button('5'), + }: func () { followLink(5) }, + + bindingKey { + button: stone.Button('6'), + }: func () { followLink(6) }, + + bindingKey { + button: stone.Button('7'), + }: func () { followLink(7) }, + + bindingKey { + button: stone.Button('8'), + }: func () { followLink(8) }, + + bindingKey { + button: stone.Button('9'), + }: func () { followLink(9) }, + + bindingKey { + button: stone.KeyLeft, + }: func () { fetchBackward() }, + + bindingKey { + button: stone.KeyRight, + }: func () { fetchForward() }, + + bindingKey { + button: stone.MouseButtonBack, + }: func () { fetchBackward() }, + + bindingKey { + button: stone.MouseButtonForward, + }: func () { fetchForward() }, + + bindingKey { + button: stone.KeyHome, + }: func () { + page.scroll = 0 + constrainScroll() + redraw() + application.Draw() + }, + + bindingKey { + button: stone.KeyEnd, + }: func () { + page.scroll = page.height + constrainScroll() + redraw() + application.Draw() + }, + + bindingKey { + button: stone.KeyPageUp, + }: func () { + page.scroll -= page.viewHeight + constrainScroll() + redraw() + application.Draw() + }, + + bindingKey { + button: stone.KeyPageDown, + }: func () { + page.scroll += page.viewHeight + constrainScroll() + redraw() + application.Draw() + }, + + bindingKey { + button: stone.KeyUp, + }: func () { + page.scroll -= 1 + constrainScroll() + redraw() + application.Draw() + }, + + bindingKey { + button: stone.KeyDown, + }: func () { + page.scroll += 1 + constrainScroll() + redraw() + application.Draw() + }, + + bindingKey { + button: stone.MouseButtonBack, + }: func () { + fetchBackward() + }, + + bindingKey { + button: stone.MouseButtonForward, + }: func () { + fetchForward() + }, +} diff --git a/bookmarks/bookmarks.go b/bookmarks/bookmarks.go new file mode 100644 index 0000000..4a6f347 --- /dev/null +++ b/bookmarks/bookmarks.go @@ -0,0 +1,37 @@ +package bookmarks + +import "net/url" + +type Bookmark struct { + Title string + Location *url.URL +} + +var HomePage = New("Home", "about:home") +var SearchEngine = New("Search", "gemini://geminispace.info:1965/search/") + +var bookmarks = []Bookmark { + New("Project Gemini", "gemini://gemini.circumlunar.space:1965/"), + New("Search", "gemini://geminispace.info:1965/search/"), +} + +func Gemtext () (page string) { + page += "# Bookmarks\n" + for _, bookmark := range bookmarks { + page += + "=> " + bookmark.Location.String() + + " " + bookmark.Title + "\n" + } + + return +} + +func New (title, location string) (bookmark Bookmark) { + bookmark.Title = title + bookmark.Location, _ = url.Parse(location) + return +} + +func Add (bookmark Bookmark) { + bookmarks = append(bookmarks, bookmark) +} diff --git a/dom/document.go b/dom/document.go new file mode 100644 index 0000000..dbd212d --- /dev/null +++ b/dom/document.go @@ -0,0 +1,548 @@ +package dom + +import "io" +import "fmt" +import "net" +import "bufio" +import "net/url" +import "strings" +import "unicode" +import "git.tebibyte.media/sashakoshka/stone" + +type Document struct { + title string + location *url.URL + elements []Element + + metrics struct { + width int + maxWidth int + margin int + } +} + +type Element interface { + Render (target stone.Buffer, offset int) () + Height () (height int) +} + +type ElementPreformatted struct { + parent *Document + text string + altText string + height int +} + +func (element *ElementPreformatted) Render ( + target stone.Buffer, + offset int, +) { + height := 1 + x := element.parent.metrics.margin + y := offset + for _, character := range element.text { + if character == '\n' { + height ++ + x = element.parent.metrics.margin + y ++ + } else if unicode.IsPrint(character) { + target.SetRune(x, y, character) + target.SetColor(x, y, stone.ColorYellow) + x ++ + } + } + + element.height = height + + return +} + +func (element *ElementPreformatted) Height () (height int) { + height = element.height + return +} + +type ElementText struct { + parent *Document + text string + height int +} + +func (element *ElementText) Render ( + target stone.Buffer, + offset int, +) { + height := 1 + x := element.parent.metrics.margin + y := offset + for _, character := range element.text { + if x >= element.parent.metrics.maxWidth { + height ++ + x = element.parent.metrics.margin + y ++ + } + + if unicode.IsPrint(character) { + target.SetRune(x, y, character) + x ++ + } + } + + element.height = height + + return +} + +func (element *ElementText) Height () (height int) { + height = element.height + return +} + +type HeadingLevel int + +const ( + HeadingLevel1 HeadingLevel = iota + HeadingLevel2 + HeadingLevel3 +) + +type ElementHeading struct { + parent *Document + text string + level HeadingLevel + height int +} + +func (element *ElementHeading) Render ( + target stone.Buffer, + offset int, +) { + height := 1 + x := element.parent.metrics.margin + y := offset + + text := "" + switch element.level { + case HeadingLevel2: + text = "## " + case HeadingLevel3: + text = "### " + } + + text += element.text + + for _, character := range text { + if x >= element.parent.metrics.maxWidth { + height ++ + x = element.parent.metrics.margin + y ++ + } + + if unicode.IsPrint(character) { + target.SetRune(x, y, character) + target.SetColor(x, y, stone.ColorBlue) + x ++ + } + } + + if element.level == HeadingLevel1 { + height ++ + y ++ + for + x = element.parent.metrics.margin; + x < element.parent.metrics.maxWidth; + x ++ { + + target.SetRune(x, y, '-') + target.SetColor(x, y, stone.ColorBlue) + } + } + + element.height = height + + return +} + +func (element *ElementHeading) Height () (height int) { + height = element.height + return +} + +type ElementHyperlink struct { + parent *Document + text string + number int + url *url.URL + height int +} + +func (element *ElementHyperlink) Render ( + target stone.Buffer, + offset int, +) { + height := 1 + x := element.parent.metrics.margin + y := offset + + text := "=> " + text += fmt.Sprint("[", element.number, "] ") + prefixLen := len(text) + + if element.text == "" { + text += element.url.String() + } else { + text += element.text + } + + for + x := element.parent.metrics.margin; + x < prefixLen + element.parent.metrics.margin; + x ++ { + + target.SetColor(x, offset, stone.ColorPurple) + + } + + for _, character := range text { + if x >= element.parent.metrics.maxWidth { + height ++ + x = element.parent.metrics.margin + y ++ + } + + if unicode.IsPrint(character) { + target.SetRune(x, y, character) + x ++ + } + } + + element.height = height + + return +} + +func (element *ElementHyperlink) Height () (height int) { + height = element.height + return +} + +func (element *ElementHyperlink) Location () (location *url.URL) { + location = element.url + return +} + +type ElementListItem struct { + parent *Document + text string + height int +} + +func (element *ElementListItem) Render ( + target stone.Buffer, + offset int, +) { + height := 1 + x := element.parent.metrics.margin + y := offset + + text := "* " + element.text + + target.SetColor(element.parent.metrics.margin, offset, stone.ColorGreen) + + for _, character := range text { + if x >= element.parent.metrics. maxWidth { + height ++ + x = element.parent.metrics.margin + 2 + y ++ + } + + if unicode.IsPrint(character) { + target.SetRune(x, y, character) + x ++ + } + } + + element.height = height + + return +} + +func (element *ElementListItem) Height () (height int) { + height = element.height + return +} + +type ElementQuote struct { + parent *Document + text string + height int +} + +func (element *ElementQuote) Render ( + target stone.Buffer, + offset int, +) { + height := 1 + x := element.parent.metrics.margin + y := offset + + text := " ''" + element.text + ",," + + target.SetColor(element.parent.metrics.margin, offset, stone.ColorGreen) + + for _, character := range text { + if x >= element.parent.metrics.maxWidth || character == '\n' { + height ++ + x = element.parent.metrics.margin + 3 + y ++ + } + + if unicode.IsPrint(character) { + target.SetRune(x, y, character) + target.SetColor(x, y, stone.ColorGreen) + x ++ + } + } + + element.height = height + + return +} + +func (element *ElementQuote) Height () (height int) { + height = element.height + return +} + +func (document *Document) Title () (title string) { + title = document.title + return +} + +func (document *Document) Render (target stone.Buffer, scroll int) (height int) { + width, _ := target.Size() + document.metrics.width = width + if document.metrics.width > 90 { document.metrics.width = 90 } + + document.metrics.margin = (width - document.metrics.width) / 2 + document.metrics.maxWidth = document.metrics.width + document.metrics.margin + + y := scroll + for _, element := range document.elements { + element.Render(target, y) + y += element.Height() + } + + height = y - scroll + return +} + +func (document *Document) ElementAtY (y int) (element Element) { + currentY := 0 + for _, currentElement := range document.elements { + height := currentElement.Height() + if y >= currentY && y < currentY + height { + element = currentElement + return + } + currentY += height + } + + return +} + +func (document *Document) UrlAtId (id int) (location *url.URL) { + for _, element := range document.elements { + hyperlink, ok := element.(*ElementHyperlink) + if !ok { continue } + + if hyperlink.number == id { + location = hyperlink.url + } + } + + return +} + +func ParseDocument (reader io.Reader, location *url.URL) (document *Document) { + document = &Document { location: location } + + scanner := bufio.NewScanner(reader) + gotTitle := false + amountOfHyperlinks := 0 + + for scanner.Scan() { + line := scanner.Text() + + // preformatted + if strings.HasPrefix(line, "```") { + altText := strings.TrimSpace(line[3:]) + + text := "" + for scanner.Scan() { + currentLine := scanner.Text() + + if strings.HasPrefix(currentLine, "```") { + break + } else { + if len(text) > 0 { + text += "\n" + } + text += currentLine + } + } + + document.elements = append ( + document.elements, + &ElementPreformatted { + parent: document, + text: text, + altText: altText, + }) + continue + + // heading + } else if len(line) >= 1 && line[0] == '#' { + level := HeadingLevel1 + if len(line) >= 2 && line[1] == '#' { + level = HeadingLevel2 + if len(line) >= 3 && line[2] == '#' { + level = HeadingLevel3 + } + } + + line = strings.TrimSpace(strings.TrimLeft(line, "# ")) + + if level == HeadingLevel1 && !gotTitle { + gotTitle = true + document.title = line + } + + document.elements = append ( + document.elements, + &ElementHeading { + parent: document, + text: line, + level: level, + }) + + // hyperlink + } else if strings.HasPrefix(line, "=>") { + line = line [2:] + fields := strings.Fields(line) + + if len(fields) >= 1 { + urlString := fields[0] + text := "" + fields = fields[1:] + if len(fields) >= 1 { + text = strings.Join(fields, " ") + } + + location, _ := + url.Parse (strings.TrimSpace(urlString)) + + document.elements = append ( + document.elements, + &ElementHyperlink { + parent: document, + url: location, + text: text, + number: amountOfHyperlinks, + }) + amountOfHyperlinks ++ + } + + // list item + } else if strings.HasPrefix(line, "*") { + document.elements = append ( + document.elements, + &ElementListItem { + parent: document, + text: strings.TrimSpace(line[1:]), + }) + + // block quote + } else if strings.HasPrefix(line, ">") { + line = strings.TrimSpace(line[1:]) + + if len (document.elements) > 1 { + quote, alreadyInQuote := + document.elements [ + len(document.elements) - 1]. + (*ElementQuote) + + if alreadyInQuote { + quote.text += "\n" + line + } else { + document.elements = append ( + document.elements, + &ElementQuote { + parent: document, + text: line, + }) + } + } else { + document.elements = append ( + document.elements, + &ElementQuote { + parent: document, + text: line, + }) + } + + // normal text + } else { + document.elements = append ( + document.elements, + &ElementText { + text: line, + parent: document, + }) + } + } + + for { + if len(document.elements) < 1 { break } + element, ok := + document.elements[len(document.elements) - 1]. + (*ElementText) + if !ok { break } + if element.text != "" { break } + document.elements = + document.elements[:len(document.elements) - 1] + } + + if !gotTitle { + host, _, err := net.SplitHostPort(document.location.Host) + if err != nil { + document.title = document.location.String() + } else { + document.title = host + } + } + + return +} + +func ParseSource (reader io.Reader, location *url.URL) (document *Document) { + document = &Document { location: location } + + scanner := bufio.NewScanner(reader) + text := "" + number := 0 + + for scanner.Scan() { + line := scanner.Text() + text += fmt.Sprintf("%4d %s\n", number, line) + number ++ + } + + document.elements = append ( + document.elements, + &ElementPreformatted { + parent: document, + text: text, + }) + return +} diff --git a/event.go b/event.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/event.go @@ -0,0 +1 @@ +package main diff --git a/fetch.go b/fetch.go new file mode 100644 index 0000000..27ad11e --- /dev/null +++ b/fetch.go @@ -0,0 +1,186 @@ +package main + +import "git.tebibyte.media/sashakoshka/skipper/dom" +import "git.tebibyte.media/sashakoshka/skipper/input" +import "git.tebibyte.media/sashakoshka/skipper/bookmarks" + +import "fmt" +import "net/url" +import "strings" +import "git.sr.ht/~yotam/go-gemini" + +func fetchBackward () { + if page.loading { return } + location := page.history.Backward() + if location != nil { + go fetchNoTrace(location) + } +} + +func fetchForward () { + if page.loading { return } + location := page.history.Forward() + if location != nil { + go fetchNoTrace(location) + } +} + +func fetch (location *url.URL) { + fetchBack(location, false, true) +} + +func fetchStringUrl (stringLocation string) { + location, _ := url.Parse(stringLocation) + fetch(location) +} + +func fetchNoTrace (location *url.URL) { + fetchBack(location, false, false) +} + +func fetchSource (location *url.URL) { + fetchBack(location, true, false) +} + +func fetchBack (location *url.URL, viewSource bool, addToHistory bool) { + if page.loading { return } + + page.loading = true + page.readingInput = false + page.readingUrlInput = false + input.SelectNone() + + page.currentUrl = location + if addToHistory { + page.history.Add(location) + } + + if location.Scheme == "about" { + switch location.Opaque { + case "skipper": + fetchString(aboutGemtext, page.currentUrl) + case "home": + fetchString(homeGemtext, page.currentUrl) + case "bookmarks": + fetchString(bookmarks.Gemtext(), page.currentUrl) + default: + fetchString ( + "# Error\n" + + "That page does not exist.", + page.currentUrl) + } + page.loading = false + redraw() + application.Draw() + return + } + + redraw() + application.Draw() + + response, err := client.Fetch(location.String()) + body := response.Body + + defer func () { + if body != nil { + body.Close() + } + } () + + if err != nil { + fetchString ( + "# Error\n" + + "There was an error connecting to " + + location.String() + "\n\n" + + "> " + err.Error() + "\n", + page.currentUrl) + response.Status = 0 + } + + simpleStatus := gemini.SimplifyStatus(response.Status) + + if simpleStatus == gemini.StatusRedirect { + page.redirectCounter ++ + } else { + page.redirectCounter = 0 + } + + switch simpleStatus { + case 0: + case gemini.StatusInput: + fetchString("# " + response.Meta, page.currentUrl) + page.input.Reset() + page.input.Obscure = response.Status == 11 + page.input.Select() + page.readingInput = true + + case gemini.StatusSuccess: + if body != nil { + meta := strings.Split(response.Meta, ";") + var mime string + if len(meta) >= 1 { + mime = meta[0] + } + + if viewSource { + setDocument(dom.ParseSource(body, page.currentUrl)) + } else if mime == "text/gemini" { + setDocument(dom.ParseDocument(body, page.currentUrl)) + } else { + setDocument(dom.ParseSource(body, page.currentUrl)) + } + } + + case gemini.StatusRedirect: + if page.redirectCounter > 8 { + fetchString (fmt.Sprintf ( + "# Too many redirects\n" + + "Enough! The site has redirected us too many " + + "times."), + page.currentUrl) + break + } + + location, err := url.Parse(response.Meta) + if err != nil { + fetchString (fmt.Sprintf ( + "# Error\n" + + "The site responded with an invalid redirect:\n\n" + + "%s", + err.Error()), page.currentUrl) + } else { + page.history.Remove() + defer func () { + go fetchBack(location, viewSource, true) + } () + } + + case gemini.StatusTemporaryFailure, gemini.StatusPermanentFailure: + fetchString (fmt.Sprintf ( + "# Error %d\n" + + "The site responded with an error:\n\n" + + "> %s", + response.Status, response.Meta), page.currentUrl) + + default: + fetchString (fmt.Sprintf ( + "# Unhandled response status %d\n" + + "Skipper doesn't know how to handle this type of " + + "response yet.", + response.Status), page.currentUrl) + } + + page.loading = false + redraw() + application.Draw() +} + +func fetchString (data string, location *url.URL) { + setDocument(dom.ParseDocument(strings.NewReader(data), location)) +} + +func setDocument (document *dom.Document) { + page.document = document + page.scroll = 0 + application.SetTitle(document.Title() + " - Skipper") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1257f07 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module git.tebibyte.media/sashakoshka/skipper + +go 1.19 + +require ( + git.sr.ht/~yotam/go-gemini v0.0.0-20191116204306-8ebb75240eef + git.tebibyte.media/sashakoshka/stone v0.2.1 +) + +replace git.tebibyte.media/sashakoshka/stone => /home/sashakoshka/repos/tebibyte/stone + +require ( + github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 // indirect + github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 // indirect + github.com/flopp/go-findfont v0.1.0 // indirect + github.com/jezek/xgb v1.1.0 // indirect + github.com/jezek/xgbutil v0.0.0-20210302171758-530099784e66 // indirect + golang.org/x/image v0.1.0 // indirect + golang.org/x/text v0.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..236a96b --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +git.sr.ht/~yotam/go-gemini v0.0.0-20191116204306-8ebb75240eef h1:rHPrfUoN0cIwxf5PaSDQgDd9r1kBab0OiEu2akI12dU= +git.sr.ht/~yotam/go-gemini v0.0.0-20191116204306-8ebb75240eef/go.mod h1:KxQlipD0Ti7MfV3itYJfuvgcvd+SOlRTtbOK+A0DCCE= +github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 h1:1qlsVAQJXZHsaM8b6OLVo6muQUQd4CwkH/D3fnnbHXA= +github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ= +github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 h1:lTG4HQym5oPKjL7nGs+csTgiDna685ZXjxijkne828g= +github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0= +github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU= +github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk= +github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/jezek/xgbutil v0.0.0-20210302171758-530099784e66 h1:+wPhoJD8EH0/bXipIq8Lc2z477jfox9zkXPCJdhvHj8= +github.com/jezek/xgbutil v0.0.0-20210302171758-530099784e66/go.mod h1:KACeV+k6b+aoLTVrrurywEbu3UpqoQcQywj4qX8aQKM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk= +golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/history.go b/history.go new file mode 100644 index 0000000..9850062 --- /dev/null +++ b/history.go @@ -0,0 +1,47 @@ +package main + +import "net/url" + +type History struct { + history []*url.URL + position int +} + +func (history *History) Add (location *url.URL) { + if len(history.history) == 0 { + history.history = append(history.history, location) + history.position = 0 + return + } + + history.position ++ + + if len(history.history) > history.position + 1 { + history.history = history.history[:history.position + 1] + history.history[history.position] = location + } else { + history.history = append(history.history, location) + } +} + +func (history *History) Backward () (location *url.URL) { + if history.position < 1 || len(history.history) < 1 { return } + history.position -- + location = history.history[history.position] + return +} + +func (history *History) Forward () (location *url.URL) { + if history.position + 1 >= len(history.history) { return } + history.position ++ + location = history.history[history.position] + return +} + +func (history *History) Remove () { + if len(history.history) <= 1 { return } + history.history = append ( + history.history[:history.position], + history.history[history.position + 1:]...) + history.position -- +} diff --git a/icon/icon-old.svg b/icon/icon-old.svg new file mode 100644 index 0000000..c9c2e80 --- /dev/null +++ b/icon/icon-old.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/icon/icon.svg b/icon/icon.svg new file mode 100644 index 0000000..765aa34 --- /dev/null +++ b/icon/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/icon/icon64.png b/icon/icon64.png new file mode 100644 index 0000000000000000000000000000000000000000..57d14645e21a82e9afb7a292e2144c74950a9084 GIT binary patch literal 2963 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 0 { + buffer.SetRune(x, input.Y, '*') + } else { + buffer.SetRune(x, input.Y, character) + } + x ++ + } +} + +func (input *Input) charAtSafe (index int) (character rune) { + if index < 0 { return } + if index >= len(input.text) { return } + character = input.text[index] + return +} + +func (input *Input) constrainCursor () (constrained bool) { + if input.cursor < 0 { + input.cursorToBeginning() + constrained = true + } else if input.cursor > len(input.text) { + input.cursorToEnd() + constrained = true + } + + return +} + +func (input *Input) distanceToPreviousWordBound () (distance int) { + index := input.cursor + index -- + distance ++ + + for { + if index < 0 { break } + + character := input.text[index] + isWordChar := + unicode.IsLetter(character) || + unicode.IsDigit(character) + if !isWordChar { + distance -- + break + } + + distance ++ + index -- + } + + if distance < 1 { + distance ++ + } + + return +} + +func (input *Input) distanceToNextWordBound () (distance int) { + index := input.cursor + + for { + if index >= len(input.text) { break } + + character := input.text[index] + isWordChar := + unicode.IsLetter(character) || + unicode.IsDigit(character) + if !isWordChar { + break + } + + distance ++ + index ++ + } + + if distance < 1 { + distance ++ + } + + return +} + +func (input *Input) backspace (amount int) { + if input.cursor - amount < 0 { + amount = input.cursor + } + + copy ( + input.text[input.cursor - amount:], + input.text[input.cursor:]) + input.text = input.text[:len(input.text) - amount] + input.cursor -= amount +} + +func (input *Input) delete (amount int) { + if input.cursor + amount > len(input.text) { + amount = len(input.text) - input.cursor + } + + // TODO + copy ( + input.text[input.cursor:], + input.text[input.cursor + amount:]) + input.text = input.text[:len(input.text) - amount] +} + +func (input *Input) cursorToBeginning () { + input.cursor = 0 +} + +func (input *Input) cursorToEnd () { + input.cursor = len(input.text) +} + +func (input *Input) Reset () { + input.SetText("") +} + +func (input *Input) Text () (text string) { + text = string(input.text) + return +} + +func (input *Input) SetText (text string) { + input.text = []rune(text) + input.cursorToEnd() + return +} + +func (input *Input) Selected () (selected bool) { + selected = selectedInput == input + return +} + +func (input *Input) Select () () { + selectedInput = input + return +} + +func Selected () (selected *Input) { + selected = selectedInput + return +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2eb4040 --- /dev/null +++ b/main.go @@ -0,0 +1,304 @@ +package main + +// import "net/http" +// import _ "net/http/pprof" + +import "git.tebibyte.media/sashakoshka/skipper/dom" +import "git.tebibyte.media/sashakoshka/skipper/input" +import "git.tebibyte.media/sashakoshka/skipper/bookmarks" + +import "os" +import "fmt" +import "sync" +import "bytes" +import "image" +import "net/url" +import "strings" +import "strconv" +import _ "embed" +import _ "image/png" +import "git.sr.ht/~yotam/go-gemini" +import "git.tebibyte.media/sashakoshka/stone" +import _ "git.tebibyte.media/sashakoshka/stone/backends/x" + +//go:embed icon/icon64.png +var iconBytes []byte + +var application = &stone.Application { } +var client = gemini.Client { InsecureSkipVerify: true } + +var page struct { + history History + + document *dom.Document + currentUrl *url.URL + loading bool + scroll int + height int + + redirectCounter int + + viewHeight int + + input input.Input + readingInput bool + + urlInput input.Input + readingUrlInput bool + + redrawLock sync.Mutex +} + +var inputState struct { + x int + y int +} + +func loadImage (name string) (output image.Image) { + file, err := os.Open(name) + defer file.Close() + if err != nil { panic(err) } + output, _, err = image.Decode(file) + if err != nil { panic(err) } + return +} + +func main () { + // go func() { + // println(http.ListenAndServe("localhost:7070", nil)) + // } () + + application.SetTitle("Skipper") + application.SetSize(80, 24) + + icon, _, err := image.Decode(bytes.NewReader(iconBytes)) + if err != nil { panic(err) } + application.SetIcon([]image.Image { icon }) + + application.OnStart(onStart) + application.OnResize(onResize) + application.OnPress(onPress) + application.OnMouseMove(onMouseMove) + application.OnScroll(onScroll) + + page.input.OnDone = onInputPageDone + page.input.OnCancel = onInputPageCancel + page.urlInput.OnDone = onUrlBarDone + page.urlInput.OnCancel = onUrlBarCancel + + err = application.Run() + if err != nil { panic(err) } +} + +func onStart () { + go fetch(bookmarks.HomePage.Location) +} + +func onResize () { + redraw() + constrainScroll() +} + +func onPress (button stone.Button, modifiers stone.Modifiers) { + switch button { + case stone.MouseButtonLeft: + if inputState.y >= page.viewHeight { + onUrlBarSelect(page.currentUrl.String()) + } else { + actualY := inputState.y + page.scroll + element := page.document.ElementAtY(actualY) + + hyperlink, isHyperlink := element.(*dom.ElementHyperlink) + if !isHyperlink { break } + + location := normalizeDocumentUrl(hyperlink.Location()) + go fetch(location,) + } + + default: + } + + if input.Selected() != nil { + input.Selected().HandleButton ( + button, + modifiers.Control, + modifiers.Shift, + modifiers.Alt) + redrawInputs() + application.Draw() + return + } + + action, bound := bindings[bindingKey { + control: modifiers.Control, + shift: modifiers.Shift, + alt: modifiers.Alt, + button: button, + }] + if bound && action != nil { + action() + return + } +} + +func onMouseMove (x, y int) { + inputState.x = x + inputState.y = y +} + +func onScroll (x, y int) { + page.scroll += y * 4 + constrainScroll() + redraw() + application.Draw() +} + +func onInputPageDone () { + location := *page.currentUrl + location.RawQuery = page.input.Text() + page.readingInput = false + go fetch(&location) +} + +func onInputPageCancel () { + page.readingInput = false + fetchBackward() +} + +func onUrlBarSelect (startingText string) { + page.urlInput.SetText(startingText) + page.urlInput.Select() + page.readingUrlInput = true + redrawInputs() + application.Draw() +} + +func onUrlBarDone () { + page.readingUrlInput = false + inputText := page.urlInput.Text() + + if strings.HasPrefix(inputText, "/") { + inputText = inputText[1:] + // TODO: find in page + + } else if strings.HasPrefix(inputText, ".") { + inputText = inputText[1:] + linkId, err := strconv.Atoi(inputText) + if err != nil { return } + followLink(linkId) + + } else if strings.HasPrefix(inputText, "gemini://") { + location, err := url.Parse(inputText) + if err != nil { + location := bookmarks.SearchEngine.Location.JoinPath("") + location.RawQuery = inputText + go fetch(location) + return + } + go fetch(location) + + } else { + location := bookmarks.SearchEngine.Location.JoinPath("") + location.RawQuery = inputText + go fetch(location) + } +} + +func onUrlBarCancel () { + page.readingUrlInput = false + redrawStatus() + application.Draw() +} + +func constrainScroll () (constrained bool) { + if page.scroll < 0 { + page.scroll = 0 + constrained = true + } + + if page.scroll > page.height - 4 { + page.scroll = page.height - 4 + constrained = true + } + + return +} + +func followLink (id int) { + location := page.document.UrlAtId(id) + if location == nil { return } + location = normalizeDocumentUrl(location) + go fetch(location) +} + +func normalizeDocumentUrl (strange *url.URL) (normal *url.URL) { + normal = strange.JoinPath("") + if !normal.IsAbs() { + normal = page.currentUrl.JoinPath ( + normal.String()) + normal.RawQuery = "" + } + + if !strings.Contains(normal.Host, ":") { + normal.Host += ":1965" + } + return +} + +func redrawInputs () { + if page.readingUrlInput { + page.urlInput.Draw(application) + } else if page.readingInput { + page.input.Draw(application) + } +} + +func redrawStatus () { + width, height := application.Size() + for x := 0; x < width; x ++ { + application.SetRune(x, height - 1, 0) + } + + application.SetDot(0, height - 1) + fmt.Fprint(application, page.currentUrl) + + if page.loading { + application.SetDot(width - 7, height - 1) + fmt.Fprint(application, "loading") + } +} + +func redraw () { + page.redrawLock.Lock() + defer page.redrawLock.Unlock() + + width, height := application.Size() + documentWidth := width + if documentWidth > 90 { documentWidth = 90 } + documentMargin := (width - documentWidth) / 2 + application.Clear() + + if page.document != nil { + page.height = page.document.Render(application, 0 - page.scroll) + } + + for y := height - 2; y < height; y ++ { + for x := 0; x < width; x ++ { + application.SetRune(x, y, 0) + application.SetColor(x, y, stone.ColorDim) + }} + + redrawStatus() + + page.viewHeight = height - 2 + + page.input.X = documentMargin + page.input.Y = page.height + 1 + page.input.Width = documentWidth + + page.urlInput.X = 0 + page.urlInput.Y = height - 1 + page.urlInput.Width = width + + redrawInputs() +}