Initial commit

This commit is contained in:
Sasha Koshka 2022-11-26 11:44:07 -05:00
commit df90c50ce6
16 changed files with 1775 additions and 0 deletions

8
README.md Normal file
View File

@ -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.

42
about.go Normal file
View File

@ -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`

71
backup Normal file
View File

@ -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()
}

181
bindings.go Normal file
View File

@ -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()
},
}

37
bookmarks/bookmarks.go Normal file
View File

@ -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)
}

548
dom/document.go Normal file
View File

@ -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
}

1
event.go Normal file
View File

@ -0,0 +1 @@
package main

186
fetch.go Normal file
View File

@ -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")
}

20
go.mod Normal file
View File

@ -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
)

41
go.sum Normal file
View File

@ -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=

47
history.go Normal file
View File

@ -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 --
}

22
icon/icon-old.svg Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Created with Vectornator (http://vectornator.io/) -->
<svg stroke-miterlimit="10" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" version="1.1" viewBox="0 0 1024 1024" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<path d="M59.5312 120.5C50.9819 120.906 43.377 122.501 36.8438 125.469C-47.302 163.696 4.07946 509.575 281.75 610.031C220.339 641.042 170.531 677.332 170.531 713.656C170.531 856.123 453.813 903.625 453.812 903.625C483.608 903.625 501.588 877.222 512 838.5C522.412 877.222 540.392 903.625 570.188 903.625C570.187 903.625 853.469 856.123 853.469 713.656C853.469 677.332 803.661 641.042 742.25 610.031C1019.92 509.575 1071.3 163.696 987.156 125.469C892.537 82.4833 572.414 322.647 512 369C455.757 325.848 174.799 115.029 59.5312 120.5Z" id="Fill"/>
<linearGradient gradientTransform="matrix(1 0 0 1 0 0)" gradientUnits="userSpaceOnUse" id="LinearGradient" x1="0.0237276" x2="1023.98" y1="511.984" y2="511.984">
<stop offset="0.497934" stop-color="#37a4e3"/>
<stop offset="0.502066" stop-color="#04dcf0"/>
</linearGradient>
</defs>
<g id="Layer-1">
<g opacity="1">
<use fill="url(#LinearGradient)" fill-rule="nonzero" stroke="none" xlink:href="#Fill"/>
<mask height="783.282" id="StrokeMask" maskUnits="userSpaceOnUse" width="1023.95" x="0.0237276" y="120.343">
<rect fill="#000000" height="783.282" stroke="none" width="1023.95" x="0.0237276" y="120.343"/>
<use fill="#ffffff" fill-rule="evenodd" stroke="none" xlink:href="#Fill"/>
</mask>
<use fill="none" mask="url(#StrokeMask)" stroke="#000000" stroke-linecap="butt" stroke-linejoin="miter" stroke-width="78.1135" xlink:href="#Fill"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

14
icon/icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

BIN
icon/icon64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

253
input/input.go Normal file
View File

@ -0,0 +1,253 @@
package input
import "unicode"
import "git.tebibyte.media/sashakoshka/stone"
var selectedInput *Input
func SelectNone () {
selectedInput = nil
}
type Input struct {
cursor int
text []rune
X, Y, Width int
Obscure bool
OnDone func ()
OnCancel func ()
}
func (input *Input) HandleButton (button stone.Button, control, shift, alt bool) {
switch button {
case stone.KeyLeft:
if control {
input.cursor -= input.distanceToPreviousWordBound()
} else if alt {
input.cursorToBeginning()
} else {
input.cursor --
}
input.constrainCursor()
case stone.KeyRight:
if control {
input.cursor += input.distanceToNextWordBound()
} else if alt {
input.cursorToEnd()
} else {
input.cursor ++
}
input.constrainCursor()
case stone.KeyHome:
input.cursorToBeginning()
input.constrainCursor()
case stone.KeyEnd:
input.cursorToEnd()
input.constrainCursor()
case stone.KeyBackspace:
if control {
input.backspace(input.distanceToPreviousWordBound())
} else if alt {
input.backspace(input.cursor)
} else {
input.backspace(1)
}
input.constrainCursor()
case stone.KeyDelete:
if control {
input.delete(input.distanceToNextWordBound())
} else if alt {
input.delete(len(input.text))
} else {
input.delete(1)
}
input.constrainCursor()
case stone.KeyEnter:
SelectNone()
if input.OnDone != nil {
input.OnDone()
}
case stone.KeyEscape:
SelectNone()
if input.OnCancel != nil {
input.OnCancel()
}
default:
if button.Printable() {
input.text = append(input.text, 0)
copy (
input.text[input.cursor + 1:],
input.text[input.cursor:])
input.text[input.cursor] = rune(button)
input.cursor ++
}
}
return
}
func (input *Input) Draw (buffer stone.Buffer) {
buffer.SetRune(input.X, input.Y, '[')
buffer.SetRune(input.X + 1, input.Y, 0)
buffer.SetRune(input.X + input.Width - 2, input.Y, 0)
buffer.SetRune(input.X + input.Width - 1, input.Y, ']')
actualWidth := input.Width - 4
scroll := 0
x := input.X + 2
for index := 0; index < actualWidth; index ++ {
character := input.charAtSafe(index + scroll)
if input.Selected() && index == input.cursor {
buffer.SetRune(x, input.Y, '_')
} else if input.Obscure && character > 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
}

304
main.go Normal file
View File

@ -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()
}