Initial commit
This commit is contained in:
commit
df90c50ce6
8
README.md
Normal file
8
README.md
Normal 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
42
about.go
Normal 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
71
backup
Normal 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
181
bindings.go
Normal 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
37
bookmarks/bookmarks.go
Normal 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
548
dom/document.go
Normal 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
|
||||
}
|
186
fetch.go
Normal file
186
fetch.go
Normal 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
20
go.mod
Normal 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
41
go.sum
Normal 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
47
history.go
Normal 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
22
icon/icon-old.svg
Normal 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
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
BIN
icon/icon64.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
253
input/input.go
Normal file
253
input/input.go
Normal 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
304
main.go
Normal 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()
|
||||
}
|
Loading…
Reference in New Issue
Block a user