Better word wrapping #214

Merged
sloum merged 4 commits from raidancampbell/bombadillo:master into release-2.4.0 2022-02-16 04:03:48 +00:00
5 changed files with 93 additions and 25 deletions

View File

@ -73,7 +73,7 @@ func (c *client) Draw() {
screen.WriteString("\033[0m")
screen.WriteString(c.TopBar.Render(c.Width, c.Options["theme"]))
screen.WriteString("\n")
pageContent := c.PageState.Render(c.Height, c.Width-1, (c.Options["theme"] == "color"))
pageContent := c.PageState.Render(c.Height, c.Width-1, getMaxWidth(c.Options), (c.Options["theme"] == "color"))
Outdated
Review

In general I think ignoring error should be avoided where possible. I think we can likely encapsule this in a function that can be called wherever we need it:

func getMaxWidth() int {
    out, err := strconv.Atoi(c.Options["maxwidth"])
    if err != nil {
        out = 100
    }
    if out < 10 {
        out = 10
    }
    return out
}

It makes sense that we could really just store the value in a module var:

var MaxWidth = getMaxWidth()

Then in the code for setting config values have a case for changing maxwidth and reset the var. Then we can just reference the var everywhere instead of recasting to int in a few places (that said, I have not gotten to do a deep dive into your changes, just a cursory look.... so I may be overthinking this).

In general I think ignoring error should be avoided where possible. I think we can likely encapsule this in a function that can be called wherever we need it: ``` go func getMaxWidth() int { out, err := strconv.Atoi(c.Options["maxwidth"]) if err != nil { out = 100 } if out < 10 { out = 10 } return out } ```` It makes sense that we could really just store the value in a module var: ``` go var MaxWidth = getMaxWidth() ``` Then in the code for setting config values have a case for changing maxwidth and reset the var. Then we can just reference the var everywhere instead of recasting to int in a few places (that said, I have not gotten to do a deep dive into your changes, just a cursory look.... so I may be overthinking this).

070f7eb6ba261866395706f709d12dff3ead2e74 contains a first pass on this. Since c.Options is both a field on the struct and altered by reading in the configuration after instantiating the struct, it's a bit less clean.

Other potential options:

  • add a piece of state to hold a sync.Once (existed in 1.12) in getMaxWidth to avoid the checks and casts
  • have some hook on the client that gets called after the configuration file is read in
  • see if the configuration can be read in first, then passed to the client constructor, then maxWidth can be a field on the client alongside Width
`070f7eb6ba261866395706f709d12dff3ead2e74` contains a first pass on this. Since `c.Options` is both a field on the struct and altered by reading in the configuration after instantiating the struct, it's a bit less clean. Other potential options: - add a piece of state to hold a `sync.Once` (existed in 1.12) in `getMaxWidth` to avoid the checks and casts - have some hook on the client that gets called after the configuration file is read in - see if the configuration can be read in first, then passed to the client constructor, then `maxWidth` can be a field on the client alongside `Width`
var re *regexp.Regexp
if c.Options["theme"] == "inverse" {
screen.WriteString("\033[7m")
@ -258,7 +258,7 @@ func (c *client) TakeControlInput() {
}
err = c.NextSearchItem(0)
if err != nil {
c.PageState.History[c.PageState.Position].WrapContent(c.Width-1,(c.Options["theme"] == "color"))
c.PageState.History[c.PageState.Position].WrapContent(c.Width-1,getMaxWidth(c.Options),(c.Options["theme"] == "color"))
c.Draw()
}
case ':', ' ':
@ -988,7 +988,7 @@ func (c *client) handleGopher(u Url) {
} else {
pg.FileType = "text"
}
pg.WrapContent(c.Width-1, (c.Options["theme"] == "color"))
pg.WrapContent(c.Width-1, getMaxWidth(c.Options), (c.Options["theme"] == "color"))
c.PageState.Add(pg)
c.SetPercentRead()
c.ClearMessage()
@ -1015,7 +1015,7 @@ func (c *client) handleGemini(u Url) {
u.Mime = capsule.MimeMin
pg := MakePage(u, capsule.Content, capsule.Links)
pg.FileType = capsule.MimeMaj
pg.WrapContent(c.Width-1, (c.Options["theme"] == "color"))
pg.WrapContent(c.Width-1, getMaxWidth(c.Options), (c.Options["theme"] == "color"))
c.PageState.Add(pg)
c.SetPercentRead()
c.ClearMessage()
@ -1081,7 +1081,7 @@ func (c *client) handleLocal(u Url) {
if ext == ".jpg" || ext == ".jpeg" || ext == ".gif" || ext == ".png" {
pg.FileType = "image"
}
pg.WrapContent(c.Width-1, (c.Options["theme"] == "color"))
pg.WrapContent(c.Width-1, getMaxWidth(c.Options), (c.Options["theme"] == "color"))
c.PageState.Add(pg)
c.SetPercentRead()
c.ClearMessage()
@ -1097,7 +1097,7 @@ func (c *client) handleFinger(u Url) {
return
}
pg := MakePage(u, content, []string{})
pg.WrapContent(c.Width-1, (c.Options["theme"] == "color"))
pg.WrapContent(c.Width-1, getMaxWidth(c.Options), (c.Options["theme"] == "color"))
c.PageState.Add(pg)
c.SetPercentRead()
c.ClearMessage()
@ -1117,7 +1117,7 @@ func (c *client) handleWeb(u Url) {
return
}
pg := MakePage(u, page.Content, page.Links)
pg.WrapContent(c.Width-1, (c.Options["theme"] == "color"))
pg.WrapContent(c.Width-1, getMaxWidth(c.Options), (c.Options["theme"] == "color"))
c.PageState.Add(pg)
c.SetPercentRead()
c.ClearMessage()
@ -1258,3 +1258,16 @@ func updateTimeouts(timeoutString string) error {
return nil
}
// getMaxWidth looks through the given options map and will safely return a max width to render
// if the option is missing or malformed, it will default to 100. A sane minimum of 10 is enforced.
func getMaxWidth(options map[string]string) int {
out, err := strconv.Atoi(options["maxwidth"])
if err != nil {
out = 100
}
if out < 10 {
out = 10
}
return out
}

View File

@ -56,6 +56,7 @@ var defaultOptions = map[string]string{
"theme": "normal", // "normal", "inverted", "color"
"timeout": "15", // connection timeout for gopher/gemini in seconds
"webmode": "none", // "none", "gui", "lynx", "w3m", "elinks"
"maxwidth": "100",
}
// homePath will return the path to your home directory as a string

30
page.go
View File

@ -3,8 +3,8 @@ package main
import (
"fmt"
"strings"
"tildegit.org/sloum/bombadillo/tdiv"
"unicode"
)
//------------------------------------------------\\
@ -65,12 +65,12 @@ func (p *Page) RenderImage(width int) {
// width and updates the WrappedContent
// of the Page struct width a string slice
// of the wrapped data
func (p *Page) WrapContent(width int, color bool) {
func (p *Page) WrapContent(width, maxWidth int, color bool) {
if p.FileType == "image" {
p.RenderImage(width)
return
}
width = min(width, 100)
width = min(width, maxWidth)
counter := 0
Outdated
Review

I'm not sure I follow why this is here. Since we are trying to wrap at maxwidth and remove hardcoded values in favor of the passed value that comes from the config, why are we hardcoding 100 here?

I'm not sure I follow why this is here. Since we are trying to wrap at maxwidth and remove hardcoded values in favor of the passed value that comes from the config, why are we hardcoding 100 here?

The intent here was to catch malformed / missing configuration entries: your suggestion of wrapping the logic into a getMaxWidth function will solve this problem and remove the need here.

Much better, thanks!

The intent here was to catch malformed / missing configuration entries: your suggestion of wrapping the logic into a `getMaxWidth` function will solve this problem and remove the need here. Much better, thanks!
spacer := ""
var content strings.Builder
@ -83,7 +83,10 @@ func (p *Page) WrapContent(width int, color bool) {
} else if strings.HasSuffix(p.Location.Mime, "gemini") { //gemini document
spacer = " "
}
for _, ch := range []rune(p.RawContent) {
runeArr := []rune(p.RawContent)
for i := 0; i < len(runeArr); i++ {
ch := runeArr[i]
if escape {
if color {
esc.WriteRune(ch)
@ -109,7 +112,7 @@ func (p *Page) WrapContent(width int, color bool) {
counter = 0
}
} else if ch == '\r' || ch == '\v' || ch == '\b' || ch == '\f' || ch == '\a' {
// Get rid of control characters we dont want
// Get rid of control characters we don't want
continue
} else if ch == 27 {
if p.Location.Scheme == "local" {
@ -125,9 +128,24 @@ func (p *Page) WrapContent(width int, color bool) {
}
continue
} else {
if counter <= width {
// peek forward to see if we can render the word without going over
j := i
for ; j < len(runeArr) && !unicode.IsSpace(runeArr[j]); j++ {
if counter+(j-i) > width+1 {
break
}
}
// if we can render the rest of the word, write the next letter. else, skip to the next line.
// TODO(raidancampbell): optimize this to write out the whole word, this will involve referencing the
// above special cases
if counter+(j-i) <= width+1 && !(j == i && counter == width+1) {
content.WriteRune(ch)
counter++
} else if ch == ' ' || ch == '\t' {
// we want to wrap and write this char, but it's a space. eat it to prevent the next line from
// having a leading whitespace because of our wrapping
counter++
} else {
content.WriteRune('\n')
counter = 0

View File

@ -20,8 +20,9 @@ func Test_WrapContent_Wrapped_Line_Length(t *testing.T) {
Color bool
}
type args struct {
width int
color bool
width int
maxWidth int
color bool
}
// create a Url for use by the MakePage function
@ -41,22 +42,56 @@ func Test_WrapContent_Wrapped_Line_Length(t *testing.T) {
"",
},
args{
10,
10,
false,
},
},
{
"Long line wrapped to 10 columns",
"0123456789 123456789 123456789 123456789 123456789\n",
"multiple words should wrap at the right point",
"01 345 789 123456789 123456789 123456789 123456789\n",
[]string{
"0123456789",
" 123456789",
" 123456789",
" 123456789",
" 123456789",
"01 345 789",
"123456789 ",
"123456789 ",
"123456789 ",
"123456789",
"",
},
args{
10,
10,
false,
},
},
{
"Long line wrapped to 10 columns, leading spaces omitted when wrapping",
"0123456789 123456789 123456789 123456789 123456789\n",
[]string{
"0123456789",
"123456789 ",
"123456789 ",
"123456789 ",
"123456789",
"",
},
args{
10,
10,
false,
},
},
{
"Intentional leading spaces aren't trimmed",
"01 345\n 789 123456789\n",
[]string{
"01 345",
" 789 ",
"123456789",
"",
},
args{
10,
10,
false,
},
@ -77,6 +112,7 @@ func Test_WrapContent_Wrapped_Line_Length(t *testing.T) {
"",
},
args{
10,
10,
false,
},
@ -85,7 +121,7 @@ func Test_WrapContent_Wrapped_Line_Length(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := MakePage(url, tt.input, []string{""})
p.WrapContent(tt.args.width-1, tt.args.color)
p.WrapContent(tt.args.width-1, tt.args.maxWidth, tt.args.color)
if !reflect.DeepEqual(p.WrappedContent, tt.expects) {
t.Errorf("Test failed - %s\nexpects %s\nactual %s", tt.name, tt.expects, p.WrappedContent)
}

View File

@ -60,7 +60,7 @@ func (p *Pages) Add(pg Page) {
// Render wraps the content for the current page and returns
// the page content as a string slice
func (p *Pages) Render(termHeight, termWidth int, color bool) []string {
func (p *Pages) Render(termHeight, termWidth, maxWidth int, color bool) []string {
if p.Length < 1 {
return make([]string, 0)
}
@ -68,7 +68,7 @@ func (p *Pages) Render(termHeight, termWidth int, color bool) []string {
prev := len(p.History[p.Position].WrappedContent)
if termWidth != p.History[p.Position].WrapWidth || p.History[p.Position].Color != color {
p.History[p.Position].WrapContent(termWidth, color)
p.History[p.Position].WrapContent(termWidth, maxWidth, color)
}
now := len(p.History[p.Position].WrappedContent)