diff --git a/.gitignore b/.gitignore index 96ddd97..1790a37 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ log /newscanoe dist/ +version.txt diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0536d6e..4a27fd8 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,6 +3,7 @@ project_name: newscanoe before: hooks: - go mod tidy + - go generate ./... builds: - env: diff --git a/README.md b/README.md index 8973af0..5327f68 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ## Newscanoe Newscanoe aims to be a minimal reimplementation of the glorious [newsboat](https://newsboat.org/): -- only for Linux terminal emulators (at the moment, at least) supporting [VT100](https://en.wikipedia.org/wiki/VT100) terminal escape sequences +- only for Linux (at the moment, at least) terminal emulators supporting [VT100](https://en.wikipedia.org/wiki/VT100) terminal escape sequences - written in Go but rigorously nonglamorous (i.e. vim-like style) - meant to be lighter and easier to build from source or to distribute (in the future) as a traditional distribution-dependent (e.g. rpm/deb) or independent (e.g. Snap, Flatpak, or AppImage) package. @@ -9,10 +9,12 @@ Newscanoe aims to be a minimal reimplementation of the glorious [newsboat](https The only config file consists of urls of RSS/Atom feeds listed line-by-line (see the [urls sample file](./assets/urls)) and located in the directory `$XDG_CONFIG_HOME/newscanoe` or `$HOME/.config/newscanoe`. -If such file does not already exist, it will be created at the first execution of the app and the user will be able to manually insert a url by typing `a` or by creating/modifying such file with any text editor. +If such file does not already exist, it will be created at the first execution of the app and you will be able to manually insert a url by typing `a`, Otherwise create such file with any text editor. Once loaded, feeds are cached in the directory `$XDG_CACHE_HOME/newscanoe` or `$HOME/.cache/newscanoe`. +Currently, the app uses just the default foreground colour (+ red/green as feedbacks to user actions) of your terminal theme to highlight the different UI components. + ### Keybindings Supported key bindings: @@ -26,10 +28,10 @@ Supported key bindings: - `^`, `v`, move the cursor to the previous/next row - `Page Up`, `Page Down`, move the cursor to the previous/next page - `a`, insert manually a new feed url, then: + - `ENTER`, append it to the config file - `<`, `>`, move the cursor to the previous/next char - `BACKSPACE`, cancel last char - `CANC`, cancel currently highlighted char - - `ENTER`, append the typed in url in the config file ### Installation @@ -45,7 +47,7 @@ Build from source: Or download the latest pre-compiled binary from [GitHub](https://github.com/giulianopz/newscanoe/releases) and then install it in your PATH. -[![asciicast](https://asciinema.org/a/QeAvNtiPK86vTbSWpoC6grymg.svg)](https://asciinema.org/a/QeAvNtiPK86vTbSWpoC6grymg) +[![asciicast](https://asciinema.org/a/GmD6rN1s4vcQVT0xmlYOrlacq.svg)](https://asciinema.org/a/GmD6rN1s4vcQVT0xmlYOrlacq) ### Development diff --git a/Taskfile.yaml b/Taskfile.yaml index 2e6b986..da0a88d 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -4,15 +4,23 @@ tasks: run: cmds: - go run cmd/newscanoe/main.go + build: cmds: - - go build -o newscanoe cmd/newscanoe/main.go + - go build -o newscanoe --ldflags="-X 'github.com/giulianopz/newscanoe/pkg/app.Version=$(git describe --tags $(git rev-list --tags --max-count=1))'" cmd/newscanoe/main.go + install: cmds: - cp ./newscanoe ~/bin/ + + release: + cmds: + - goreleaser release --debug --snapshot --skip-publish --rm-dist + debug: cmds: - go run cmd/newscanoe/main.go -d 2> log + clean: cmds: - rm ~/.cache/newscanoe/feeds.gob diff --git a/pkg/app/info.go b/pkg/app/info.go index d548ee4..1835c98 100644 --- a/pkg/app/info.go +++ b/pkg/app/info.go @@ -1,6 +1,11 @@ package app -const ( - Name = "newscanoe" - Version = "v0.1.0" +import ( + _ "embed" ) + +const Name = "newscanoe" + +//go:generate bash version.sh +//go:embed version.txt +var Version string diff --git a/pkg/app/version.sh b/pkg/app/version.sh new file mode 100755 index 0000000..ed0e482 --- /dev/null +++ b/pkg/app/version.sh @@ -0,0 +1,2 @@ +#!/bin/bash +printf '%s' "$(git describe --tags $(git rev-list --tags --max-count=1))" > version.txt diff --git a/pkg/display/display.go b/pkg/display/display.go index 0680beb..ebd2bb5 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -11,6 +11,7 @@ import ( "unicode/utf8" "github.com/giulianopz/newscanoe/pkg/ansi" + "github.com/giulianopz/newscanoe/pkg/app" "github.com/giulianopz/newscanoe/pkg/cache" "github.com/giulianopz/newscanoe/pkg/util" "github.com/mmcdole/gofeed" @@ -25,8 +26,11 @@ const ( ARTICLE_TEXT ) -// num of lines reserved to bottom bar plus final empty row -const BOTTOM_PADDING = 3 +// num of lines reserved to top and bottom bars plus a final empty row +const ( + TOP_PADDING = 2 + BOTTOM_PADDING = 3 +) // bottom bar messages const ( @@ -48,7 +52,10 @@ type display struct { height int width int - bottomBarColor int + // color of top and bottom bars + barsColor int + // message displayed in the bottom bar + topBarMsg string // message displayed in the bottom bar bottomBarMsg string // message displayed in the right corner of the bottom bar @@ -56,8 +63,6 @@ type display struct { mu sync.Mutex - //TODO use arrays of rune - // dislay raw text raw [][]byte // dislay rendered text @@ -87,7 +92,7 @@ func New() *display { startoff: 0, endoff: 0, cache: cache.NewCache(), - bottomBarColor: ansi.WHITE, + barsColor: ansi.WHITE, ListenToKeyStroke: true, client: &http.Client{ Timeout: 3 * time.Second, @@ -97,6 +102,12 @@ func New() *display { return d } +func (d *display) setTopMessage(msg string) { + if utf8.RuneCountInString(msg) < (d.width - utf8.RuneCountInString(app.Name) - utf8.RuneCountInString(app.Version) - 2) { + d.topBarMsg = msg + } +} + func (d *display) setBottomMessage(msg string) { d.bottomBarMsg = msg } @@ -106,7 +117,7 @@ func (d *display) setTmpBottomMessage(t time.Duration, msg string) { d.setBottomMessage(msg) go func() { time.AfterFunc(t, func() { - d.bottomBarColor = ansi.WHITE + d.barsColor = ansi.WHITE d.setBottomMessage(previous) }) }() @@ -190,7 +201,7 @@ func (d *display) LoadCache() error { func (d *display) exitEditingMode(color int) { d.editingMode = false d.editingBuf = []string{} - d.bottomBarColor = color + d.barsColor = color } func (d *display) enterEditingMode() { @@ -215,7 +226,30 @@ func (d *display) canBeParsed(url string) bool { func (d *display) draw(buf *bytes.Buffer) { - nextEndOff := d.startoff + (d.height - BOTTOM_PADDING) - 1 + buf.WriteString(ansi.SGR(ansi.REVERSE_COLOR)) + buf.WriteString(ansi.SGR(d.barsColor)) + + padding := d.width - utf8.RuneCountInString(app.Name) - utf8.RuneCountInString(d.topBarMsg) - 2 + log.Default().Printf("top-padding: %d", padding) + if padding > 0 { + buf.WriteString(fmt.Sprintf("%s %s %*s\r\n", app.Name, d.topBarMsg, padding, app.Version)) + } else { + buf.WriteString(app.Name) + padding = d.width - utf8.RuneCountInString(app.Name) - utf8.RuneCountInString(app.Version) + for i := padding; i > 0; i-- { + buf.WriteString(" ") + } + buf.WriteString(fmt.Sprintf("%s\r\n", app.Version)) + } + + buf.WriteString(ansi.SGR(ansi.ALL_ATTRIBUTES_OFF)) + buf.WriteString(ansi.SGR(ansi.DEFAULT_FG_COLOR)) + + for k := 0; k < d.width; k++ { + buf.WriteString("-") + } + + nextEndOff := d.startoff + (d.height - BOTTOM_PADDING - TOP_PADDING) - 1 if nextEndOff > (len(d.rendered) - 1) { d.endoff = (len(d.rendered) - 1) } else { @@ -229,7 +263,7 @@ func (d *display) draw(buf *bytes.Buffer) { } } - log.Default().Printf("looping from %d to %d\n", d.startoff, d.endoff) + log.Default().Printf("looping from %d to %d: %d\n", d.startoff, d.endoff, d.endoff-d.startoff) var printed int for i := d.startoff; i <= d.endoff; i++ { @@ -241,28 +275,35 @@ func (d *display) draw(buf *bytes.Buffer) { // TODO check that the terminal supports Unicode output, before outputting a Unicode character // if so, the "LANG" env variable should contain "UTF" - runes := utf8.RuneCountInString(string(d.rendered[i])) - - if runes > d.width { - log.Default().Printf("runes for line %d exceed screen width: %d\n", i, runes) - continue + line := string(d.rendered[i]) + if line == "" { + line = " " + } else { + runes := utf8.RuneCountInString(line) + if runes > d.width { + log.Default().Printf("truncating current line because its length %d exceeda screen width: %d\n", i, runes) + line = util.Truncate(line, d.width) + } } - _, err := buf.Write(d.rendered[i]) + log.Default().Printf("writing to buf line #%d: %q\n", i, line) + + _, err := buf.Write([]byte(line)) if err != nil { - log.Default().Printf("cannot write rune %q: %v", d.rendered[i], err) + log.Default().Printf("cannot write byte array %q: %v", []byte(" "), err) } + buf.WriteString("\r\n") + if i == d.currentRow() && d.currentSection != ARTICLE_TEXT { buf.WriteString(ansi.SGR(ansi.ALL_ATTRIBUTES_OFF)) buf.WriteString(ansi.SGR(ansi.DEFAULT_FG_COLOR)) } - buf.WriteString("\r\n") printed++ } - for ; printed < d.height-BOTTOM_PADDING; printed++ { + for ; printed < d.height-BOTTOM_PADDING-TOP_PADDING; printed++ { buf.WriteString("\r\n") } @@ -271,16 +312,26 @@ func (d *display) draw(buf *bytes.Buffer) { } buf.WriteString("\r\n") + d.bottomRightCorner = fmt.Sprintf("%d/%d", d.cy+d.startoff, len(d.rendered)) if DebugMode { d.bottomRightCorner = fmt.Sprintf("(y:%v,x:%v) (soff:%v, eoff:%v) (h:%v,w:%v)", d.cy, d.cx, d.startoff, d.endoff, d.height, d.width) - } else { - d.bottomRightCorner = fmt.Sprintf("%d/%d", d.cy+d.startoff, len(d.rendered)) } - padding := d.width - utf8.RuneCountInString(d.bottomBarMsg) - 1 + + padding = d.width - utf8.RuneCountInString(d.bottomBarMsg) - 1 + log.Default().Printf("bottom-padding: %d", padding) buf.WriteString(ansi.SGR(ansi.REVERSE_COLOR)) - buf.WriteString(ansi.SGR(d.bottomBarColor)) - buf.WriteString(fmt.Sprintf("%s %*s\r\n", d.bottomBarMsg, padding, d.bottomRightCorner)) + buf.WriteString(ansi.SGR(d.barsColor)) + + if padding > 0 { + buf.WriteString(fmt.Sprintf("%s %*s", d.bottomBarMsg, padding, d.bottomRightCorner)) + } else { + padding = d.width - utf8.RuneCountInString(d.bottomRightCorner) + for i := padding; i > 0; i-- { + buf.WriteString(" ") + } + buf.WriteString(d.bottomRightCorner) + } buf.WriteString(ansi.SGR(ansi.ALL_ATTRIBUTES_OFF)) buf.WriteString(ansi.SGR(ansi.DEFAULT_FG_COLOR)) @@ -306,7 +357,6 @@ func (d *display) RefreshScreen() { case ARTICLE_TEXT: d.renderArticleText() - } d.draw(buf) diff --git a/pkg/display/input.go b/pkg/display/input.go index a2bcc3a..ba8c39b 100644 --- a/pkg/display/input.go +++ b/pkg/display/input.go @@ -261,7 +261,7 @@ func ctrlPlus(k byte) byte { func (d *display) moveCursor(dir byte) { switch dir { case ARROW_DOWN: - if d.cy < (d.height - BOTTOM_PADDING) { + if d.cy < (d.height - BOTTOM_PADDING - TOP_PADDING) { if d.currentRow()+1 <= len(d.rendered)-1 && (d.cx-1) <= (len(d.rendered[d.currentRow()+1])-1) { d.cy++ } @@ -284,7 +284,7 @@ func (d *display) scroll(dir byte) { case PAGE_DOWN: { if d.endoff == len(d.rendered)-1 { - d.cy = d.height - BOTTOM_PADDING + d.cy = d.height - BOTTOM_PADDING - TOP_PADDING return } @@ -296,7 +296,7 @@ func (d *display) scroll(dir byte) { d.endoff = len(d.rendered) - 1 } - d.cy = d.height - BOTTOM_PADDING + d.cy = d.height - BOTTOM_PADDING - TOP_PADDING } case PAGE_UP: { @@ -305,12 +305,12 @@ func (d *display) scroll(dir byte) { return } - firstItemInPreviousPage := d.startoff - (d.height - BOTTOM_PADDING) + firstItemInPreviousPage := d.startoff - (d.height - BOTTOM_PADDING - TOP_PADDING) if firstItemInPreviousPage >= 0 { d.startoff = firstItemInPreviousPage } else { d.startoff = 0 - d.endoff = d.height - BOTTOM_PADDING - 1 + d.endoff = d.height - BOTTOM_PADDING - TOP_PADDING - 1 } d.cy = 1 diff --git a/pkg/display/loading.go b/pkg/display/loading.go index 7f9e88d..13cb16e 100644 --- a/pkg/display/loading.go +++ b/pkg/display/loading.go @@ -56,6 +56,8 @@ func (d *display) LoadURLs() error { d.renderURLs() + d.setTopMessage("") + d.cy = 1 d.cx = 1 d.currentSection = URLS_LIST @@ -167,6 +169,7 @@ func (d *display) loadArticlesList(url string) { lynxHelp = " | l = open with lynx" } + d.setTopMessage(fmt.Sprintf("> %s", cachedFeed.Title)) d.setBottomMessage(fmt.Sprintf("%s %s %s", articlesListSectionMsg, browserHelp, lynxHelp)) go func() { @@ -216,6 +219,7 @@ func (d *display) loadArticleText(url string) { d.currentArticleUrl = url d.currentSection = ARTICLE_TEXT + d.setTopMessage(fmt.Sprintf("> %s > %s", cachedFeed.Title, i.Title)) d.setBottomMessage(articleTextSectionMsg) go func() { @@ -236,7 +240,7 @@ func (d *display) addEnteredFeedUrl() { url := strings.TrimSpace(strings.Join(d.editingBuf, "")) if !d.canBeParsed(url) { - d.bottomBarColor = ansi.RED + d.barsColor = ansi.RED d.setTmpBottomMessage(3*time.Second, "feed url not valid!") return } @@ -244,7 +248,7 @@ func (d *display) addEnteredFeedUrl() { if err := util.AppendUrl(url); err != nil { log.Default().Println(err) - d.bottomBarColor = ansi.RED + d.barsColor = ansi.RED var target *util.UrlAlreadyPresentErr if errors.As(err, &target) { @@ -258,8 +262,8 @@ func (d *display) addEnteredFeedUrl() { d.appendToRaw(url) d.cx = 1 - d.cy = len(d.rendered) % (d.height - BOTTOM_PADDING) - d.startoff = (len(d.rendered) - 1) / (d.height - BOTTOM_PADDING) * (d.height - BOTTOM_PADDING) + d.cy = len(d.raw) % (d.height - BOTTOM_PADDING - TOP_PADDING) + d.startoff = (len(d.raw) - 1) / (d.height - BOTTOM_PADDING - TOP_PADDING) * (d.height - BOTTOM_PADDING - TOP_PADDING) d.loadFeed(url) diff --git a/pkg/display/rendering.go b/pkg/display/rendering.go index f4fdfa3..0158a31 100644 --- a/pkg/display/rendering.go +++ b/pkg/display/rendering.go @@ -65,19 +65,19 @@ func (d *display) renderArticleText() { log.Default().Println("width: ", d.width) - chars := make([]byte, 0) + runes := make([]rune, 0) for row := range d.raw { if len(d.raw[row]) == 0 { - chars = append(chars, '\n') + runes = append(runes, '\n') } - for _, c := range d.raw[row] { - chars = append(chars, c) + for _, c := range string(d.raw[row]) { + runes = append(runes, c) } } d.rendered = make([][]byte, 0) line := make([]byte, 0) - for _, c := range chars { + for _, c := range runes { if c == '\r' || c == '\n' { @@ -97,11 +97,11 @@ func (d *display) renderArticleText() { } if len(line) < d.width-1 { - line = append(line, c) + line = append(line, []byte(string(c))...) } else { d.rendered = append(d.rendered, line) line = make([]byte, 0) - line = append(line, c) + line = append(line, []byte(string(c))...) } } diff --git a/pkg/util/strings.go b/pkg/util/strings.go index 80da943..7202033 100644 --- a/pkg/util/strings.go +++ b/pkg/util/strings.go @@ -30,3 +30,20 @@ see paragraph 2.2: https://www.ietf.org/rfc/rfc3986.txt func IsSpecialChar(input byte) bool { return slices.Contains(specialChars, input) } + +func Truncate(str string, length int) string { + if length <= 0 { + return "" + } + + truncated := "" + count := 0 + for _, rune := range str { + truncated += string(rune) + count++ + if count >= length { + break + } + } + return truncated +}