From 596749f91980cda2088d95fd0ec7d8bd70b11e85 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 6 Sep 2020 00:01:52 +0100 Subject: [PATCH 1/6] Started simple browser. --- browse/main.go | 478 +++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 21 +++ 3 files changed, 501 insertions(+) create mode 100644 browse/main.go create mode 100644 go.sum diff --git a/browse/main.go b/browse/main.go new file mode 100644 index 0000000..36539d3 --- /dev/null +++ b/browse/main.go @@ -0,0 +1,478 @@ +package main + +import ( + "fmt" + "io/ioutil" + "net/url" + "os" + "strings" + "unicode" + + "github.com/a-h/gemini" + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" +) + +func main() { + // Create a screen. + tcell.SetEncodingFallback(tcell.EncodingFallbackASCII) + s, err := tcell.NewScreen() + if err != nil { + fmt.Println("Error creating screen:", err) + os.Exit(1) + } + err = s.Init() + if err != nil { + fmt.Println("Error initializing screen:", err) + os.Exit(1) + } + defer s.Fini() + + // Set default colours. + s.SetStyle(tcell.StyleDefault. + Foreground(tcell.ColorWhite). + Background(tcell.ColorBlack)) + s.Clear() + s.Show() + + // Parse the input. + urlString := strings.Join(os.Args[1:], "") + if urlString == "" { + //TODO: Load up a home page. + urlString = "gemini://localhost" + } + for { + // Grab the URL input. + urlString, ok := NewInput(s, 0, 0, tcell.StyleDefault, "Location:", urlString).Focus() + if !ok { + break + } + + // Check the URL. + u, err := url.Parse(urlString) + if err != nil { + NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Failed to parse address: %q: %v", urlString, err), "OK").Focus() + continue + } + + // Connect. + client := gemini.NewClient() + var resp *gemini.Response + var certificates []string + var redirectCount int + out: + for { + //TODO: Add cert store etc. to the client. + resp, certificates, _, ok, err = client.RequestURL(u) + if err != nil { + switch NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Request error: %v", err), "Retry", "Cancel").Focus() { + case "Retry": + continue + case "Cancel": + break out + } + + } + if !ok { + //TOFU check required. + switch NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Accept client certificate?\n\n %v", certificates[0]), "Accept", "Reject").Focus() { + case "Accept": + client.AddAlllowedCertificateForHost(u.Host, certificates[0]) + continue + case "Reject": + break out + } + } + break + } + if !ok || resp == nil { + continue + } + if resp.Header.Code == gemini.CodeClientCertificateRequired { + switch NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("The server is requested a certificate."), "Create temporary", "Cancel").Focus() { + case "Create temporary": + //TODO: Add a certificate to the store. + break + case "Cancel": + break + } + } + //if resp.Header.Code == gemini.CodeInput { + //text, ok := NewInput(s, 0, 0, tcell.StyleDefault, resp.Header.Meta, "").Focus() + ////TODO: Post the input back. + //continue + //} + if strings.HasPrefix(string(resp.Header.Code), "3") { + //TODO: Handle redirect. + redirectCount++ + } + if strings.HasPrefix(string(resp.Header.Code), "2") { + NewBrowser(s, u, resp).Focus() + continue + } + NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Unknown code: %v %s", resp.Header.Code, resp.Header.Meta), "OK").Focus() + } +} + +// flow breaks up text to its maximum width. +func flow(s string, maxWidth int) []string { + var ss []string + flowProcessor(s, maxWidth, func(line string) { + ss = append(ss, line) + }) + return ss +} + +func flowProcessor(s string, maxWidth int, out func(string)) { + var buf strings.Builder + var col int + var lastSpace int + for _, r := range s { + if r == '\r' { + continue + } + if r == '\n' { + out(buf.String()) + buf.Reset() + col = 0 + lastSpace = 0 + continue + } + buf.WriteRune(r) + if unicode.IsSpace(r) { + lastSpace = col + } + if col == maxWidth { + // If the word is greater than the width, then break the word down. + end := lastSpace + if end == 0 { + end = col + } + out(strings.TrimSpace(buf.String()[:end])) + prefix := strings.TrimSpace(buf.String()[end:]) + buf.Reset() + lastSpace = 0 + buf.WriteString(prefix) + col = len(prefix) + continue + } + col++ + } + out(buf.String()) +} + +func NewText(s tcell.Screen, x, y int, st tcell.Style, text string) Text { + return Text{ + Screen: s, + X: x, + Y: y, + Style: st, + Text: text, + } +} + +type Text struct { + Screen tcell.Screen + X int + Y int + Style tcell.Style + Text string +} + +func (t Text) Draw() (x, y int) { + maxX, maxY := t.Screen.Size() + flowed := flow(t.Text, maxX) + for lineIndex := 0; lineIndex < len(flowed); lineIndex++ { + y := t.Y + lineIndex + if y > maxY { + break + } + x = t.X + for _, c := range flowed[lineIndex] { + var comb []rune + w := runewidth.RuneWidth(c) + if w == 0 { + comb = []rune{c} + c = ' ' + w = 1 + } + t.Screen.SetContent(x, y, c, comb, t.Style) + x += w + } + } + return x, y +} + +func NewOptions(s tcell.Screen, x, y int, st tcell.Style, msg string, opts ...string) *Options { + return &Options{ + Screen: s, + X: x, + Y: y, + Style: st, + Message: msg, + Options: opts, + } +} + +type Options struct { + Screen tcell.Screen + X int + Y int + Style tcell.Style + Message string + Options []string + ActiveIndex int +} + +func (o *Options) Draw() { + o.Screen.Clear() + t := NewText(o.Screen, 0, 0, tcell.StyleDefault, o.Message) + _, y := t.Draw() + for i, oo := range o.Options { + style := tcell.StyleDefault + if i == o.ActiveIndex { + style = tcell.StyleDefault.Background(tcell.ColorLightGray) + } + t := NewText(o.Screen, 1, i+y+2, style, fmt.Sprintf("[ %s ]", oo)) + t.Draw() + } +} + +func (o *Options) Up() { + if o.ActiveIndex == 0 { + return + } + o.ActiveIndex-- +} + +func (o *Options) Down() { + if o.ActiveIndex == len(o.Options)-1 { + return + } + o.ActiveIndex++ +} + +func (o *Options) Focus() string { + o.Draw() + o.Screen.Show() + for { + switch ev := o.Screen.PollEvent().(type) { + case *tcell.EventResize: + o.Screen.Sync() + case *tcell.EventKey: + switch ev.Key() { + case tcell.KeyUp: + o.Up() + case tcell.KeyDown: + o.Down() + case tcell.KeyEnter: + return o.Options[o.ActiveIndex] + } + } + o.Draw() + o.Screen.Show() + } +} + +func NewBrowser(s tcell.Screen, u *url.URL, resp *gemini.Response) *Browser { + return &Browser{ + Screen: s, + URL: u, + Response: resp, + } +} + +type Browser struct { + Screen tcell.Screen + URL *url.URL + Response *gemini.Response +} + +func (b Browser) Draw() { + b.Screen.Clear() + //TODO: Handle error reading. + body, _ := ioutil.ReadAll(b.Response.Body) + //TODO: Render the lines properly. + NewText(b.Screen, 0, 0, tcell.StyleDefault, string(body)).Draw() +} + +func (b Browser) Focus() { + b.Draw() + b.Screen.Show() + for { + switch ev := b.Screen.PollEvent().(type) { + case *tcell.EventResize: + b.Screen.Sync() + b.Draw() + b.Screen.Show() + case *tcell.EventKey: + if ev.Key() == tcell.KeyEscape { + return + } + } + } +} + +func NewInput(s tcell.Screen, x, y int, st tcell.Style, msg, text string) *Input { + return &Input{ + Screen: s, + X: x, + Y: y, + Style: st, + Message: msg, + Text: text, + CursorIndex: len(text), + } +} + +type Input struct { + Screen tcell.Screen + X int + Y int + Style tcell.Style + Message string + Text string + CursorIndex int + ActiveIndex int +} + +func (o *Input) Draw() { + o.Screen.Clear() + t := NewText(o.Screen, o.X, o.Y, o.Style, o.Message) + _, y := t.Draw() + + defaultStyle := tcell.StyleDefault + activeStyle := tcell.StyleDefault.Background(tcell.ColorLightGray) + + textStyle := defaultStyle + if o.ActiveIndex == 0 { + textStyle = defaultStyle.Underline(true) + // Show that the input is active. + NewText(o.Screen, o.X, o.Y+y+2, defaultStyle, ">").Draw() + } + NewText(o.Screen, o.X+2, o.Y+y+2, textStyle, o.Text).Draw() + if o.ActiveIndex == 0 { + o.Screen.ShowCursor(o.X+2+o.CursorIndex, o.Y+y+2) + } else { + o.Screen.HideCursor() + } + + okStyle := defaultStyle + if o.ActiveIndex == 1 { + okStyle = activeStyle + } + NewText(o.Screen, 1, o.Y+y+4, okStyle, "[ OK ]").Draw() + cancelStyle := defaultStyle + if o.ActiveIndex == 2 { + cancelStyle = activeStyle + } + NewText(o.Screen, 1, o.Y+y+5, cancelStyle, "[ Cancel ]").Draw() +} + +func (o *Input) Up() { + if o.ActiveIndex == 0 { + return + } + o.ActiveIndex-- +} + +func (o *Input) Down() { + if o.ActiveIndex == 2 { + return + } + o.ActiveIndex++ +} + +type InputResult string + +func (o *Input) Focus() (text string, ok bool) { + o.Draw() + o.Screen.Show() + for { + if o.ActiveIndex == 0 { + // Handle textbox keys. + switch ev := o.Screen.PollEvent().(type) { + case *tcell.EventResize: + o.Screen.Sync() + case *tcell.EventKey: + switch ev.Key() { + case tcell.KeyBackspace: + if tl := len(o.Text); tl > 0 { + o.CursorIndex-- + o.Text = o.Text[0 : tl-1] + } + case tcell.KeyLeft: + if o.CursorIndex > 0 { + o.CursorIndex-- + } + case tcell.KeyRight: + if o.CursorIndex < len(o.Text) { + o.CursorIndex++ + } + case tcell.KeyDelete: + o.Text = cut(o.Text, o.CursorIndex) + case tcell.KeyHome: + o.CursorIndex = 0 + case tcell.KeyEnd: + o.CursorIndex = len(o.Text) + case tcell.KeyRune: + o.Text = insert(o.Text, o.CursorIndex, ev.Rune()) + o.CursorIndex++ + case tcell.KeyDown: + o.Down() + case tcell.KeyEnter: + o.Down() + } + } + o.Draw() + o.Screen.Show() + continue + } + switch ev := o.Screen.PollEvent().(type) { + case *tcell.EventResize: + o.Screen.Sync() + case *tcell.EventKey: + switch ev.Key() { + case tcell.KeyUp: + o.Up() + case tcell.KeyDown: + o.Down() + case tcell.KeyEnter: + switch o.ActiveIndex { + case 0: + o.ActiveIndex = 1 + break + case 1: + return o.Text, true + case 2: + return o.Text, false + } + } + } + o.Draw() + o.Screen.Show() + } +} + +func cut(s string, at int) string { + prefix, suffix := split(s, at) + if len(suffix) > 0 { + suffix = suffix[1:] + } + return prefix + suffix +} + +func split(s string, at int) (prefix, suffix string) { + if at > len(s) { + prefix = s + return + } + prefix = string([]rune(s)[:at]) + suffix = string([]rune(s)[at:]) + return +} + +func insert(s string, at int, r rune) string { + prefix, suffix := split(s, at) + return prefix + string(r) + suffix +} diff --git a/go.mod b/go.mod index 73272a1..87f8a0c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/a-h/gemini go 1.14 + +require github.com/rivo/tview v0.0.0-20200818120338-53d50e499bf9 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3844182 --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.3.0 h1:r35w0JBADPZCVQijYebl6YMWWtHRqVEGt7kL2eBADRM= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= +github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/rivo/tview v0.0.0-20200818120338-53d50e499bf9 h1:csnip7QsoiE2Ee0RkELN1YggwejK2EFfcjU6tXOT0Q8= +github.com/rivo/tview v0.0.0-20200818120338-53d50e499bf9/go.mod h1:xV4Aw4WIX8cmhg71U7MUHBdpIQ7zSEXdRruGHLaEAOc= +github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200817155316-9781c653f443/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From da5d5e5b3c423db5eac22bdc6740b435b81fde36 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 6 Sep 2020 17:12:19 +0100 Subject: [PATCH 2/6] client: fix missing meta from server repsponses. --- browse/main.go | 316 ++++++++++++++++++++++++++++++++++++++++++++----- client.go | 2 +- 2 files changed, 285 insertions(+), 33 deletions(-) diff --git a/browse/main.go b/browse/main.go index 36539d3..4e514ee 100644 --- a/browse/main.go +++ b/browse/main.go @@ -1,8 +1,9 @@ package main import ( + "bufio" "fmt" - "io/ioutil" + "io" "net/url" "os" "strings" @@ -41,12 +42,17 @@ func main() { //TODO: Load up a home page. urlString = "gemini://localhost" } + var askForURL, ok bool + askForURL = true for { // Grab the URL input. - urlString, ok := NewInput(s, 0, 0, tcell.StyleDefault, "Location:", urlString).Focus() - if !ok { - break + if askForURL { + urlString, ok = NewInput(s, 0, 0, tcell.StyleDefault, "Location:", urlString).Focus() + if !ok { + break + } } + askForURL = !askForURL // Check the URL. u, err := url.Parse(urlString) @@ -75,8 +81,9 @@ func main() { } if !ok { //TOFU check required. - switch NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Accept client certificate?\n\n %v", certificates[0]), "Accept", "Reject").Focus() { + switch NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Accept client certificate?\n %v", certificates[0]), "Accept", "Reject").Focus() { case "Accept": + //TODO: Save this in a persistent store. client.AddAlllowedCertificateForHost(u.Host, certificates[0]) continue case "Reject": @@ -97,17 +104,37 @@ func main() { break } } - //if resp.Header.Code == gemini.CodeInput { - //text, ok := NewInput(s, 0, 0, tcell.StyleDefault, resp.Header.Meta, "").Focus() - ////TODO: Post the input back. - //continue - //} + if resp.Header.Code == gemini.CodeInput { + text, ok := NewInput(s, 0, 0, tcell.StyleDefault, resp.Header.Meta, "").Focus() + if !ok { + continue + } + // Post the input back. + askForURL = false + u.RawQuery = url.QueryEscape(text) + urlString = u.String() + continue + } if strings.HasPrefix(string(resp.Header.Code), "3") { //TODO: Handle redirect. redirectCount++ } if strings.HasPrefix(string(resp.Header.Code), "2") { - NewBrowser(s, u, resp).Focus() + b, err := NewBrowser(s, u, resp) + if err != nil { + NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Error displaying server response:\n\n%v", err), "OK").Focus() + continue + } + next, err := b.Focus() + if err != nil { + //TODO: The link was garbage, show the error. + continue + } + if next != nil { + askForURL = false + urlString = next.String() + continue + } continue } NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Unknown code: %v %s", resp.Header.Code, resp.Header.Meta), "OK").Focus() @@ -181,14 +208,20 @@ type Text struct { func (t Text) Draw() (x, y int) { maxX, maxY := t.Screen.Size() - flowed := flow(t.Text, maxX) - for lineIndex := 0; lineIndex < len(flowed); lineIndex++ { - y := t.Y + lineIndex + maxWidth := maxX - t.X + if maxWidth < 0 { + // It's off screen, so there's nothing to display. + return + } + lines := flow(t.Text, maxWidth) + var requiredMaxWidth int + for lineIndex := 0; lineIndex < len(lines); lineIndex++ { + y = t.Y + lineIndex if y > maxY { break } x = t.X - for _, c := range flowed[lineIndex] { + for _, c := range lines[lineIndex] { var comb []rune w := runewidth.RuneWidth(c) if w == 0 { @@ -198,9 +231,12 @@ func (t Text) Draw() (x, y int) { } t.Screen.SetContent(x, y, c, comb, t.Style) x += w + if x > requiredMaxWidth { + requiredMaxWidth = x + } } } - return x, y + return requiredMaxWidth, y } func NewOptions(s tcell.Screen, x, y int, st tcell.Style, msg string, opts ...string) *Options { @@ -274,42 +310,255 @@ func (o *Options) Focus() string { } } -func NewBrowser(s tcell.Screen, u *url.URL, resp *gemini.Response) *Browser { - return &Browser{ - Screen: s, - URL: u, +func NewLineConverter(resp *gemini.Response) *LineConverter { + return &LineConverter{ Response: resp, } } +type LineConverter struct { + Response *gemini.Response + preFormatted bool +} + +func (lc *LineConverter) process(s string) (l Line, isVisualLine bool) { + if strings.HasPrefix(s, "```") { + lc.preFormatted = !lc.preFormatted + return l, false + } + if lc.preFormatted { + return PreformattedTextLine{Text: s}, true + } + if strings.HasPrefix(s, "=>") { + return LinkLine{Text: s}, true + } + if strings.HasPrefix(s, "#") { + return HeadingLine{Text: s}, true + } + if strings.HasPrefix(s, "* ") { + return UnorderedListItemLine{Text: s}, true + } + if strings.HasPrefix(s, ">") { + return QuoteLine{Text: s}, true + } + return TextLine{Text: s}, true +} + +func (lc *LineConverter) Lines() (lines []Line, err error) { + reader := bufio.NewReader(lc.Response.Body) + var s string + for { + s, err = reader.ReadString('\n') + line, isVisual := lc.process(strings.TrimRight(s, "\n")) + if isVisual { + lines = append(lines, line) + } + if err != nil { + break + } + } + if err == io.EOF { + err = nil + } + return +} + +type Line interface { + Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y int) +} + +type TextLine struct { + Text string +} + +func (l TextLine) Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y int) { + return NewText(to, atX, atY, tcell.StyleDefault, l.Text).Draw() +} + +type PreformattedTextLine struct { + Text string +} + +func (l PreformattedTextLine) Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y int) { + return NewText(to, atX, atY, tcell.StyleDefault, l.Text).Draw() +} + +type LinkLine struct { + Text string +} + +func (l LinkLine) URL(relativeTo *url.URL) (u *url.URL, err error) { + urlString := strings.TrimPrefix(l.Text, "=>") + urlString = strings.TrimSpace(urlString) + urlString = strings.SplitN(urlString, " ", 2)[0] + u, err = url.Parse(urlString) + if err != nil { + return + } + if u.IsAbs() || relativeTo == nil { + return + } + return relativeTo.ResolveReference(u), nil +} + +func (l LinkLine) Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y int) { + ls := tcell.StyleDefault.Foreground(tcell.ColorBlue) + if highlighted { + ls = ls.Underline(true) + } + return NewText(to, atX+2, atY, ls, l.Text).Draw() +} + +type HeadingLine struct { + Text string +} + +func (l HeadingLine) Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y int) { + return NewText(to, atX, atY, tcell.StyleDefault.Foreground(tcell.ColorGreen), l.Text).Draw() +} + +type UnorderedListItemLine struct { + Text string +} + +func (l UnorderedListItemLine) Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y int) { + return NewText(to, atX+2, atY, tcell.StyleDefault, l.Text).Draw() +} + +type QuoteLine struct { + Text string +} + +func (l QuoteLine) Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y int) { + return NewText(to, atX+2, atY, tcell.StyleDefault.Foreground(tcell.ColorLightGrey), l.Text).Draw() +} + +func NewBrowser(s tcell.Screen, u *url.URL, resp *gemini.Response) (b *Browser, err error) { + b = &Browser{ + Screen: s, + URL: u, + ResponseHeader: resp.Header, + ActiveLineIndex: -1, + } + b.Lines, err = NewLineConverter(resp).Lines() + return +} + type Browser struct { - Screen tcell.Screen - URL *url.URL - Response *gemini.Response + Screen tcell.Screen + URL *url.URL + ResponseHeader *gemini.Header + Lines []Line + ActiveLineIndex int +} + +func (b *Browser) Links() (indices []int) { + for i := 0; i < len(b.Lines); i++ { + if _, ok := b.Lines[i].(LinkLine); ok { + indices = append(indices, i) + } + } + return indices +} + +func (b *Browser) CurrentLink() (u *url.URL, err error) { + for i := 0; i < len(b.Lines); i++ { + if i == b.ActiveLineIndex { + if ll, ok := b.Lines[b.ActiveLineIndex].(LinkLine); ok { + return ll.URL(b.URL) + } + } + } + return nil, nil +} + +func (b *Browser) PreviousLink() { + ll := b.Links() + if len(ll) == 0 { + return + } + if b.ActiveLineIndex < 0 { + b.ActiveLineIndex = ll[len(ll)-1] + return + } + var curIndex, li int + for curIndex, li = range ll { + if li == b.ActiveLineIndex { + break + } + } + if curIndex == 0 { + b.ActiveLineIndex = ll[len(ll)-1] + return + } + b.ActiveLineIndex = ll[curIndex-1] +} + +func (b *Browser) NextLink() { + ll := b.Links() + if len(ll) == 0 { + return + } + if b.ActiveLineIndex < 0 { + b.ActiveLineIndex = ll[0] + return + } + var curIndex, li int + for curIndex, li = range ll { + if li == b.ActiveLineIndex { + break + } + } + if curIndex == len(ll)-1 { + b.ActiveLineIndex = ll[0] + return + } + b.ActiveLineIndex = ll[curIndex+1] } func (b Browser) Draw() { b.Screen.Clear() - //TODO: Handle error reading. - body, _ := ioutil.ReadAll(b.Response.Body) - //TODO: Render the lines properly. - NewText(b.Screen, 0, 0, tcell.StyleDefault, string(body)).Draw() + //TODO: Handle scrolling. + var y int + for lineIndex, line := range b.Lines { + highlighted := lineIndex == b.ActiveLineIndex + _, yy := line.Draw(b.Screen, 0, y, highlighted) + y = yy + 1 + } } -func (b Browser) Focus() { +func (b Browser) Focus() (next *url.URL, err error) { b.Draw() b.Screen.Show() for { switch ev := b.Screen.PollEvent().(type) { case *tcell.EventResize: b.Screen.Sync() - b.Draw() - b.Screen.Show() case *tcell.EventKey: - if ev.Key() == tcell.KeyEscape { + switch ev.Key() { + case tcell.KeyEscape: return + case tcell.KeyBacktab: + b.PreviousLink() + case tcell.KeyTAB: + b.NextLink() + case tcell.KeyCtrlO: + b.PreviousLink() + case tcell.KeyEnter: + return b.CurrentLink() + case tcell.KeyRune: + switch ev.Rune() { + case 'j': + //TODO: Scroll down. + case 'k': + //TODO: Scroll up. + case 'n': + b.NextLink() + } } } + b.Draw() + b.Screen.Show() } } @@ -347,7 +596,6 @@ func (o *Input) Draw() { textStyle := defaultStyle if o.ActiveIndex == 0 { textStyle = defaultStyle.Underline(true) - // Show that the input is active. NewText(o.Screen, o.X, o.Y+y+2, defaultStyle, ">").Draw() } NewText(o.Screen, o.X+2, o.Y+y+2, textStyle, o.Text).Draw() @@ -418,6 +666,10 @@ func (o *Input) Focus() (text string, ok bool) { case tcell.KeyRune: o.Text = insert(o.Text, o.CursorIndex, ev.Rune()) o.CursorIndex++ + case tcell.KeyBacktab: + o.Up() + case tcell.KeyTab: + o.Down() case tcell.KeyDown: o.Down() case tcell.KeyEnter: diff --git a/client.go b/client.go index 5d2161b..2bc865d 100644 --- a/client.go +++ b/client.go @@ -69,7 +69,7 @@ func readHeader(r io.Reader) (h Header, err error) { err = ErrInvalidCode return } - if len(h.Meta) > 1 { + if len(parts) > 1 { h.Meta = parts[1] if !isValidMeta(h.Meta) { err = ErrInvalidMeta From bc84b2896cb5df4d31686071976af9053dca40cc Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 6 Sep 2020 17:17:17 +0100 Subject: [PATCH 3/6] Added ability to press escape to quit input boxes. --- browse/main.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/browse/main.go b/browse/main.go index 4e514ee..8d6b8c4 100644 --- a/browse/main.go +++ b/browse/main.go @@ -595,7 +595,6 @@ func (o *Input) Draw() { textStyle := defaultStyle if o.ActiveIndex == 0 { - textStyle = defaultStyle.Underline(true) NewText(o.Screen, o.X, o.Y+y+2, defaultStyle, ">").Draw() } NewText(o.Screen, o.X+2, o.Y+y+2, textStyle, o.Text).Draw() @@ -685,6 +684,10 @@ func (o *Input) Focus() (text string, ok bool) { o.Screen.Sync() case *tcell.EventKey: switch ev.Key() { + case tcell.KeyBacktab: + o.Up() + case tcell.KeyTab: + o.Down() case tcell.KeyUp: o.Up() case tcell.KeyDown: @@ -699,6 +702,8 @@ func (o *Input) Focus() (text string, ok bool) { case 2: return o.Text, false } + case tcell.KeyEscape: + return o.Text, false } } o.Draw() From ffca608d27c7ff8a6570727eee29c05996dc7931 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 6 Sep 2020 17:23:32 +0100 Subject: [PATCH 4/6] Precalulate all links to remove wasted processing. --- browse/main.go | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/browse/main.go b/browse/main.go index 8d6b8c4..6745469 100644 --- a/browse/main.go +++ b/browse/main.go @@ -441,6 +441,7 @@ func NewBrowser(s tcell.Screen, u *url.URL, resp *gemini.Response) (b *Browser, ActiveLineIndex: -1, } b.Lines, err = NewLineConverter(resp).Lines() + b.calculateLinkIndices() return } @@ -449,16 +450,16 @@ type Browser struct { URL *url.URL ResponseHeader *gemini.Header Lines []Line + LinkLineIndices []int ActiveLineIndex int } -func (b *Browser) Links() (indices []int) { +func (b *Browser) calculateLinkIndices() { for i := 0; i < len(b.Lines); i++ { if _, ok := b.Lines[i].(LinkLine); ok { - indices = append(indices, i) + b.LinkLineIndices = append(b.LinkLineIndices, i) } } - return indices } func (b *Browser) CurrentLink() (u *url.URL, err error) { @@ -473,47 +474,45 @@ func (b *Browser) CurrentLink() (u *url.URL, err error) { } func (b *Browser) PreviousLink() { - ll := b.Links() - if len(ll) == 0 { + if len(b.LinkLineIndices) == 0 { return } if b.ActiveLineIndex < 0 { - b.ActiveLineIndex = ll[len(ll)-1] + b.ActiveLineIndex = b.LinkLineIndices[len(b.LinkLineIndices)-1] return } var curIndex, li int - for curIndex, li = range ll { + for curIndex, li = range b.LinkLineIndices { if li == b.ActiveLineIndex { break } } if curIndex == 0 { - b.ActiveLineIndex = ll[len(ll)-1] + b.ActiveLineIndex = b.LinkLineIndices[len(b.LinkLineIndices)-1] return } - b.ActiveLineIndex = ll[curIndex-1] + b.ActiveLineIndex = b.LinkLineIndices[curIndex-1] } func (b *Browser) NextLink() { - ll := b.Links() - if len(ll) == 0 { + if len(b.LinkLineIndices) == 0 { return } if b.ActiveLineIndex < 0 { - b.ActiveLineIndex = ll[0] + b.ActiveLineIndex = b.LinkLineIndices[0] return } var curIndex, li int - for curIndex, li = range ll { + for curIndex, li = range b.LinkLineIndices { if li == b.ActiveLineIndex { break } } - if curIndex == len(ll)-1 { - b.ActiveLineIndex = ll[0] + if curIndex == len(b.LinkLineIndices)-1 { + b.ActiveLineIndex = b.LinkLineIndices[0] return } - b.ActiveLineIndex = ll[curIndex+1] + b.ActiveLineIndex = b.LinkLineIndices[curIndex+1] } func (b Browser) Draw() { From 091fadb330cdbbc5152ffb377b2cd24bb1c29a33 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 6 Sep 2020 18:02:03 +0100 Subject: [PATCH 5/6] Added ability to escape from dialogs, also ensured asking for URLs. --- browse/main.go | 100 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 74 insertions(+), 26 deletions(-) diff --git a/browse/main.go b/browse/main.go index 6745469..01c330b 100644 --- a/browse/main.go +++ b/browse/main.go @@ -36,12 +36,17 @@ func main() { s.Clear() s.Show() - // Parse the input. + // Parse the command-line URL, if provided. urlString := strings.Join(os.Args[1:], "") if urlString == "" { - //TODO: Load up a home page. + //TODO: Load up a home page based on configuration. urlString = "gemini://localhost" } + + // Create required top-level variables. + client := gemini.NewClient() + var redirectCount int + var askForURL, ok bool askForURL = true for { @@ -52,20 +57,18 @@ func main() { break } } - askForURL = !askForURL // Check the URL. u, err := url.Parse(urlString) if err != nil { NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Failed to parse address: %q: %v", urlString, err), "OK").Focus() + askForURL = true continue } // Connect. - client := gemini.NewClient() var resp *gemini.Response var certificates []string - var redirectCount int out: for { //TODO: Add cert store etc. to the client. @@ -73,11 +76,11 @@ func main() { if err != nil { switch NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Request error: %v", err), "Retry", "Cancel").Focus() { case "Retry": + askForURL = false continue case "Cancel": break out } - } if !ok { //TOFU check required. @@ -85,6 +88,7 @@ func main() { case "Accept": //TODO: Save this in a persistent store. client.AddAlllowedCertificateForHost(u.Host, certificates[0]) + askForURL = false continue case "Reject": break out @@ -93,51 +97,76 @@ func main() { break } if !ok || resp == nil { + askForURL = true continue } - if resp.Header.Code == gemini.CodeClientCertificateRequired { - switch NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("The server is requested a certificate."), "Create temporary", "Cancel").Focus() { + if strings.HasPrefix(string(resp.Header.Code), "3") { + redirectCount++ + if redirectCount >= 5 { + NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Server issued 5 or more redirects, cancelling request."), "OK").Focus() + askForURL = true + continue + } + redirectTo, err := url.Parse(resp.Header.Meta) + if err != nil { + //TODO: Add the ability to go back, once history has been added. + NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Server returned invalid redirect: code %s, meta: %q", resp.Header.Code, resp.Header.Meta), "OK").Focus() + askForURL = true + continue + } + //TODO: Check with the user if the redirect is to another domain or protocol. + urlString = u.ResolveReference(redirectTo).String() + askForURL = false + continue + } + redirectCount = 0 + if strings.HasPrefix(string(resp.Header.Code), "6") { + switch NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("The server has requested a certificate: code %s, meta: %q", resp.Header.Code, resp.Header.Meta), "Create temporary", "Cancel").Focus() { case "Create temporary": //TODO: Add a certificate to the store. - break + askForURL = false + continue case "Cancel": - break + askForURL = true + continue } } - if resp.Header.Code == gemini.CodeInput { + if strings.HasPrefix(string(resp.Header.Code), "1") { text, ok := NewInput(s, 0, 0, tcell.StyleDefault, resp.Header.Meta, "").Focus() if !ok { continue } // Post the input back. - askForURL = false u.RawQuery = url.QueryEscape(text) urlString = u.String() + askForURL = false continue } - if strings.HasPrefix(string(resp.Header.Code), "3") { - //TODO: Handle redirect. - redirectCount++ - } if strings.HasPrefix(string(resp.Header.Code), "2") { b, err := NewBrowser(s, u, resp) if err != nil { NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Error displaying server response:\n\n%v", err), "OK").Focus() + askForURL = true continue } next, err := b.Focus() if err != nil { //TODO: The link was garbage, show the error. + NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Invalid link: %q\n", err), "OK").Focus() + askForURL = true continue } if next != nil { - askForURL = false + //TODO: Ask the user whether they want to follow it, if it's a non-Gemini link. urlString = next.String() + askForURL = false continue } + askForURL = true continue } NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Unknown code: %v %s", resp.Header.Code, resp.Header.Meta), "OK").Focus() + askForURL = true } } @@ -240,13 +269,21 @@ func (t Text) Draw() (x, y int) { } func NewOptions(s tcell.Screen, x, y int, st tcell.Style, msg string, opts ...string) *Options { + cancelIndex := -1 + for i, o := range opts { + if o == "Cancel" { + cancelIndex = i + break + } + } return &Options{ - Screen: s, - X: x, - Y: y, - Style: st, - Message: msg, - Options: opts, + Screen: s, + X: x, + Y: y, + Style: st, + Message: msg, + Options: opts, + CancelIndex: cancelIndex, } } @@ -258,6 +295,7 @@ type Options struct { Message string Options []string ActiveIndex int + CancelIndex int } func (o *Options) Draw() { @@ -276,14 +314,14 @@ func (o *Options) Draw() { func (o *Options) Up() { if o.ActiveIndex == 0 { - return + o.ActiveIndex = len(o.Options) - 1 } o.ActiveIndex-- } func (o *Options) Down() { if o.ActiveIndex == len(o.Options)-1 { - return + o.ActiveIndex = 0 } o.ActiveIndex++ } @@ -297,10 +335,18 @@ func (o *Options) Focus() string { o.Screen.Sync() case *tcell.EventKey: switch ev.Key() { + case tcell.KeyBacktab: + o.Up() + case tcell.KeyTab: + o.Down() case tcell.KeyUp: o.Up() case tcell.KeyDown: o.Down() + case tcell.KeyEscape: + if o.CancelIndex > -1 { + return o.Options[o.CancelIndex] + } case tcell.KeyEnter: return o.Options[o.ActiveIndex] } @@ -395,7 +441,7 @@ func (l LinkLine) URL(relativeTo *url.URL) (u *url.URL, err error) { if err != nil { return } - if u.IsAbs() || relativeTo == nil { + if relativeTo == nil { return } return relativeTo.ResolveReference(u), nil @@ -617,6 +663,7 @@ func (o *Input) Draw() { func (o *Input) Up() { if o.ActiveIndex == 0 { + o.ActiveIndex = 2 return } o.ActiveIndex-- @@ -624,6 +671,7 @@ func (o *Input) Up() { func (o *Input) Down() { if o.ActiveIndex == 2 { + o.ActiveIndex = 0 return } o.ActiveIndex++ From 4cefa05c982b542c570307cb0dcb275fdc9b536a Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Mon, 7 Sep 2020 18:15:20 +0100 Subject: [PATCH 6/6] Minor tidy up. --- browse/main.go | 106 +++++++++++++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 43 deletions(-) diff --git a/browse/main.go b/browse/main.go index 01c330b..6f231c9 100644 --- a/browse/main.go +++ b/browse/main.go @@ -52,7 +52,7 @@ func main() { for { // Grab the URL input. if askForURL { - urlString, ok = NewInput(s, 0, 0, tcell.StyleDefault, "Location:", urlString).Focus() + urlString, ok = NewInput(s, "Location:", urlString).Focus() if !ok { break } @@ -61,7 +61,7 @@ func main() { // Check the URL. u, err := url.Parse(urlString) if err != nil { - NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Failed to parse address: %q: %v", urlString, err), "OK").Focus() + NewOptions(s, fmt.Sprintf("Failed to parse address: %q: %v", urlString, err), "OK").Focus() askForURL = true continue } @@ -74,7 +74,7 @@ func main() { //TODO: Add cert store etc. to the client. resp, certificates, _, ok, err = client.RequestURL(u) if err != nil { - switch NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Request error: %v", err), "Retry", "Cancel").Focus() { + switch NewOptions(s, fmt.Sprintf("Request error: %v", err), "Retry", "Cancel").Focus() { case "Retry": askForURL = false continue @@ -84,12 +84,16 @@ func main() { } if !ok { //TOFU check required. - switch NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Accept client certificate?\n %v", certificates[0]), "Accept", "Reject").Focus() { - case "Accept": + switch NewOptions(s, fmt.Sprintf("Accept client certificate?\n %v", certificates[0]), "Accept (Permanent)", "Accept (Temporary)", "Reject").Focus() { + case "Accept (Permanent)": //TODO: Save this in a persistent store. client.AddAlllowedCertificateForHost(u.Host, certificates[0]) askForURL = false continue + case "Accept (Temporary)": + client.AddAlllowedCertificateForHost(u.Host, certificates[0]) + askForURL = false + continue case "Reject": break out } @@ -103,14 +107,14 @@ func main() { if strings.HasPrefix(string(resp.Header.Code), "3") { redirectCount++ if redirectCount >= 5 { - NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Server issued 5 or more redirects, cancelling request."), "OK").Focus() + NewOptions(s, fmt.Sprintf("Server issued 5 or more redirects, cancelling request."), "OK").Focus() askForURL = true continue } redirectTo, err := url.Parse(resp.Header.Meta) if err != nil { //TODO: Add the ability to go back, once history has been added. - NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Server returned invalid redirect: code %s, meta: %q", resp.Header.Code, resp.Header.Meta), "OK").Focus() + NewOptions(s, fmt.Sprintf("Server returned invalid redirect: code %s, meta: %q", resp.Header.Code, resp.Header.Meta), "OK").Focus() askForURL = true continue } @@ -121,9 +125,14 @@ func main() { } redirectCount = 0 if strings.HasPrefix(string(resp.Header.Code), "6") { - switch NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("The server has requested a certificate: code %s, meta: %q", resp.Header.Code, resp.Header.Meta), "Create temporary", "Cancel").Focus() { - case "Create temporary": - //TODO: Add a certificate to the store. + msg := fmt.Sprintf("The server has requested a certificate: code %s, meta: %q", resp.Header.Code, resp.Header.Meta) + switch NewOptions(s, msg, "Create (Permanent)", "Create (Temporary)", "Cancel").Focus() { + case "Create (Permanent)": + //TODO: Add a certificate to the permanent store. + askForURL = false + continue + case "Create (Temporary)": + //TODO: Add a certificate to the client. askForURL = false continue case "Cancel": @@ -132,7 +141,7 @@ func main() { } } if strings.HasPrefix(string(resp.Header.Code), "1") { - text, ok := NewInput(s, 0, 0, tcell.StyleDefault, resp.Header.Meta, "").Focus() + text, ok := NewInput(s, resp.Header.Meta, "").Focus() if !ok { continue } @@ -145,19 +154,19 @@ func main() { if strings.HasPrefix(string(resp.Header.Code), "2") { b, err := NewBrowser(s, u, resp) if err != nil { - NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Error displaying server response:\n\n%v", err), "OK").Focus() + NewOptions(s, fmt.Sprintf("Error displaying server response:\n\n%v", err), "OK").Focus() askForURL = true continue } next, err := b.Focus() if err != nil { //TODO: The link was garbage, show the error. - NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Invalid link: %q\n", err), "OK").Focus() + NewOptions(s, fmt.Sprintf("Invalid link: %v\n", err), "OK").Focus() askForURL = true continue } if next != nil { - //TODO: Ask the user whether they want to follow it, if it's a non-Gemini link. + //TODO: Ask the user whether they want to follow it, if it's a non-Gemini link, or goes to a different domain. urlString = next.String() askForURL = false continue @@ -165,7 +174,7 @@ func main() { askForURL = true continue } - NewOptions(s, 0, 0, tcell.StyleDefault, fmt.Sprintf("Unknown code: %v %s", resp.Header.Code, resp.Header.Meta), "OK").Focus() + NewOptions(s, fmt.Sprintf("Unknown code: %v %s", resp.Header.Code, resp.Header.Meta), "OK").Focus() askForURL = true } } @@ -217,12 +226,12 @@ func flowProcessor(s string, maxWidth int, out func(string)) { out(buf.String()) } -func NewText(s tcell.Screen, x, y int, st tcell.Style, text string) Text { - return Text{ +func NewText(s tcell.Screen, text string) *Text { + return &Text{ Screen: s, - X: x, - Y: y, - Style: st, + X: 0, + Y: 0, + Style: tcell.StyleDefault, Text: text, } } @@ -235,6 +244,17 @@ type Text struct { Text string } +func (t *Text) WithOffset(x, y int) *Text { + t.X = x + t.Y = y + return t +} + +func (t *Text) WithStyle(st tcell.Style) *Text { + t.Style = st + return t +} + func (t Text) Draw() (x, y int) { maxX, maxY := t.Screen.Size() maxWidth := maxX - t.X @@ -268,7 +288,7 @@ func (t Text) Draw() (x, y int) { return requiredMaxWidth, y } -func NewOptions(s tcell.Screen, x, y int, st tcell.Style, msg string, opts ...string) *Options { +func NewOptions(s tcell.Screen, msg string, opts ...string) *Options { cancelIndex := -1 for i, o := range opts { if o == "Cancel" { @@ -278,9 +298,9 @@ func NewOptions(s tcell.Screen, x, y int, st tcell.Style, msg string, opts ...st } return &Options{ Screen: s, - X: x, - Y: y, - Style: st, + X: 0, + Y: 0, + Style: tcell.StyleDefault, Message: msg, Options: opts, CancelIndex: cancelIndex, @@ -300,21 +320,21 @@ type Options struct { func (o *Options) Draw() { o.Screen.Clear() - t := NewText(o.Screen, 0, 0, tcell.StyleDefault, o.Message) + t := NewText(o.Screen, o.Message) _, y := t.Draw() for i, oo := range o.Options { style := tcell.StyleDefault if i == o.ActiveIndex { style = tcell.StyleDefault.Background(tcell.ColorLightGray) } - t := NewText(o.Screen, 1, i+y+2, style, fmt.Sprintf("[ %s ]", oo)) - t.Draw() + NewText(o.Screen, fmt.Sprintf("[ %s ]", oo)).WithOffset(1, i+y+2).WithStyle(style).Draw() } } func (o *Options) Up() { if o.ActiveIndex == 0 { o.ActiveIndex = len(o.Options) - 1 + return } o.ActiveIndex-- } @@ -322,6 +342,7 @@ func (o *Options) Up() { func (o *Options) Down() { if o.ActiveIndex == len(o.Options)-1 { o.ActiveIndex = 0 + return } o.ActiveIndex++ } @@ -418,7 +439,7 @@ type TextLine struct { } func (l TextLine) Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y int) { - return NewText(to, atX, atY, tcell.StyleDefault, l.Text).Draw() + return NewText(to, l.Text).WithOffset(atX, atY).Draw() } type PreformattedTextLine struct { @@ -426,7 +447,7 @@ type PreformattedTextLine struct { } func (l PreformattedTextLine) Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y int) { - return NewText(to, atX, atY, tcell.StyleDefault, l.Text).Draw() + return NewText(to, l.Text).WithOffset(atX, atY).Draw() } type LinkLine struct { @@ -452,7 +473,7 @@ func (l LinkLine) Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y in if highlighted { ls = ls.Underline(true) } - return NewText(to, atX+2, atY, ls, l.Text).Draw() + return NewText(to, l.Text).WithOffset(atX+2, atY).WithStyle(ls).Draw() } type HeadingLine struct { @@ -460,7 +481,7 @@ type HeadingLine struct { } func (l HeadingLine) Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y int) { - return NewText(to, atX, atY, tcell.StyleDefault.Foreground(tcell.ColorGreen), l.Text).Draw() + return NewText(to, l.Text).WithOffset(atX, atY).WithStyle(tcell.StyleDefault.Foreground(tcell.ColorGreen)).Draw() } type UnorderedListItemLine struct { @@ -468,7 +489,7 @@ type UnorderedListItemLine struct { } func (l UnorderedListItemLine) Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y int) { - return NewText(to, atX+2, atY, tcell.StyleDefault, l.Text).Draw() + return NewText(to, l.Text).WithOffset(atX+2, atY).Draw() } type QuoteLine struct { @@ -476,7 +497,7 @@ type QuoteLine struct { } func (l QuoteLine) Draw(to tcell.Screen, atX, atY int, highlighted bool) (x, y int) { - return NewText(to, atX+2, atY, tcell.StyleDefault.Foreground(tcell.ColorLightGrey), l.Text).Draw() + return NewText(to, l.Text).WithOffset(atX+2, atY).WithStyle(tcell.StyleDefault.Foreground(tcell.ColorLightGrey)).Draw() } func NewBrowser(s tcell.Screen, u *url.URL, resp *gemini.Response) (b *Browser, err error) { @@ -607,12 +628,12 @@ func (b Browser) Focus() (next *url.URL, err error) { } } -func NewInput(s tcell.Screen, x, y int, st tcell.Style, msg, text string) *Input { +func NewInput(s tcell.Screen, msg, text string) *Input { return &Input{ Screen: s, - X: x, - Y: y, - Style: st, + X: 0, + Y: 0, + Style: tcell.StyleDefault, Message: msg, Text: text, CursorIndex: len(text), @@ -632,17 +653,16 @@ type Input struct { func (o *Input) Draw() { o.Screen.Clear() - t := NewText(o.Screen, o.X, o.Y, o.Style, o.Message) - _, y := t.Draw() + _, y := NewText(o.Screen, o.Message).WithOffset(o.X, o.Y).WithStyle(o.Style).Draw() defaultStyle := tcell.StyleDefault activeStyle := tcell.StyleDefault.Background(tcell.ColorLightGray) textStyle := defaultStyle if o.ActiveIndex == 0 { - NewText(o.Screen, o.X, o.Y+y+2, defaultStyle, ">").Draw() + NewText(o.Screen, ">").WithOffset(o.X, o.Y+y+2).WithStyle(defaultStyle).Draw() } - NewText(o.Screen, o.X+2, o.Y+y+2, textStyle, o.Text).Draw() + NewText(o.Screen, o.Text).WithOffset(o.X+2, o.Y+y+2).WithStyle(textStyle).Draw() if o.ActiveIndex == 0 { o.Screen.ShowCursor(o.X+2+o.CursorIndex, o.Y+y+2) } else { @@ -653,12 +673,12 @@ func (o *Input) Draw() { if o.ActiveIndex == 1 { okStyle = activeStyle } - NewText(o.Screen, 1, o.Y+y+4, okStyle, "[ OK ]").Draw() + NewText(o.Screen, "[ OK ]").WithOffset(1, o.Y+y+4).WithStyle(okStyle).Draw() cancelStyle := defaultStyle if o.ActiveIndex == 2 { cancelStyle = activeStyle } - NewText(o.Screen, 1, o.Y+y+5, cancelStyle, "[ Cancel ]").Draw() + NewText(o.Screen, "[ Cancel ]").WithOffset(1, o.Y+y+5).WithStyle(cancelStyle).Draw() } func (o *Input) Up() {