~ghost08/trunky

5ed8980f3c1f35576303cf43e0557914d38b990a — VladimĂ­r Magyar 2 years ago f8620db master
feat: better status
7 files changed, 331 insertions(+), 15 deletions(-)

M go.mod
M go.sum
M main.go
A notification.go
A status.go
A styles.go
A timeline.go
M go.mod => go.mod +6 -5
@@ 5,9 5,8 @@ go 1.17
require (
	github.com/charmbracelet/bubbles v0.10.3
	github.com/charmbracelet/bubbletea v0.20.0
	github.com/charmbracelet/lipgloss v0.4.0
	github.com/charmbracelet/lipgloss v0.5.0
	github.com/mattn/go-mastodon v0.0.5-0.20211214115546-7745e19ff787
	github.com/muesli/reflow v0.3.0
	github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
	golang.org/x/net v0.0.0-20220225172249-27dd8689420f
	gopkg.in/ini.v1 v1.66.4


@@ 16,15 15,17 @@ require (
require (
	github.com/atotto/clipboard v0.1.4 // indirect
	github.com/containerd/console v1.0.3 // indirect
	github.com/gorilla/websocket v1.4.2 // indirect
	github.com/gorilla/websocket v1.5.0 // indirect
	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
	github.com/mattn/go-isatty v0.0.14 // indirect
	github.com/mattn/go-runewidth v0.0.13 // indirect
	github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
	github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
	github.com/muesli/reflow v0.3.0 // indirect
	github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect
	github.com/rivo/uniseg v0.2.0 // indirect
	github.com/sbani/go-humanizer v0.3.2 // indirect
	github.com/stretchr/testify v1.7.0 // indirect
	github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect
	golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
	golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect
	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
)

M go.sum => go.sum +11 -0
@@ 11,6 11,8 @@ github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNo
github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g=
github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=


@@ 21,6 23,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=


@@ 43,10 47,13 @@ github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA=
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=


@@ 60,6 67,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sbani/go-humanizer v0.3.2 h1:uw2Fncf7rrl+IjlMS1sPwNcpMq041xrnV7SbG1QZ4N0=
github.com/sbani/go-humanizer v0.3.2/go.mod h1:FKsFliG5Wldo/Qm59N3Mi7tsCM+7s0k55wMwOyAndWo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=


@@ 88,6 97,8 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/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-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=

M main.go => main.go +6 -10
@@ 22,6 22,7 @@ func main() {
}

type model struct {
	width, height       int
	err                 error
	client              *mastodon.Client
	registerScreen      tea.Model


@@ 51,8 52,9 @@ func (m *model) Init() tea.Cmd {
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.width, m.height = msg.Width, msg.Height
		for _, col := range m.columns {
			col.SetSize(msg.Width/len(m.columns)-2, msg.Height-4)
			col.SetSize(msg.Width/len(m.columns)-2, msg.Height-5)
		}
		return m, nil
	case tea.KeyMsg:


@@ 130,12 132,6 @@ func (m *model) getNotifications(since string) ([]Item, error) {
	return items, nil
}

var (
	border         = lipgloss.NewStyle().Border(lipgloss.NormalBorder())
	borderSelected = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).
			BorderForeground(lipgloss.Color("122"))
)

func (m *model) View() string {
	switch {
	case m.registerScreen != nil:


@@ 146,13 142,13 @@ func (m *model) View() string {
		cols := make([]string, len(m.columns))
		for i := 0; i < len(cols); i++ {
			if i == m.selectedColumnIndex {
				cols[i] = borderSelected.Render(m.columns[i].View())
				cols[i] = columnBorderSelected.Copy().Height(m.height - 3).Render(m.columns[i].View())
			} else {
				cols[i] = border.Render(m.columns[i].View())
				cols[i] = columnBorder.Copy().Height(m.height - 3).Render(m.columns[i].View())
			}
			cols[i] = lipgloss.JoinVertical(
				lipgloss.Top,
				lipgloss.NewStyle().PaddingLeft(1).Render(m.columnNames[i]),
				columnNameStyle.Render(m.columnNames[i]),
				cols[i],
			)
		}

A notification.go => notification.go +51 -0
@@ 0,0 1,51 @@
package main

import (
	"strings"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/mattn/go-mastodon"
	"golang.org/x/net/html"
)

type notification struct {
	*mastodon.Notification
	width int
}

func (n *notification) ID() string {
	return string(n.Notification.ID)
}

func (n *notification) SetWidth(w int) {
	n.width = w
}

func (n *notification) Init() tea.Cmd {
	return nil
}

func (n *notification) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	return n, nil
}

func (n *notification) View() string {
	s := n.Type
	if n.Status != nil {
		s = n.Status.Content
	}
	nodes, err := html.ParseFragment(strings.NewReader(s), nil)
	if err != nil {
		return err.Error()
	}
	var b strings.Builder
	if nodes != nil {
		for _, node := range nodes {
			parseContent(&b, node, nil)
		}
	} else {
		b.WriteString(n.Status.Content)
	}

	return b.String()
}

A status.go => status.go +87 -0
@@ 0,0 1,87 @@
package main

import (
	"io"
	"strings"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
	"github.com/mattn/go-mastodon"
	htime "github.com/sbani/go-humanizer/time"
	"golang.org/x/net/html"
)

type item struct {
	*mastodon.Status
	width int
}

func (i *item) ID() string {
	return string(i.Status.ID)
}

func (i *item) SetWidth(w int) {
	i.width = w
}

func (i *item) Init() tea.Cmd {
	return nil
}

func (i *item) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	return i, nil
}

func (i *item) View() string {
	nodes, err := html.ParseFragment(strings.NewReader(i.Content), nil)
	if err != nil {
		return err.Error()
	}
	var b strings.Builder
	created := htime.Difference(time.Now(), i.CreatedAt)
	b.WriteString(
		lipgloss.JoinHorizontal(
			lipgloss.Top,
			statusUsernameStyle.Copy().Width(i.width-lipgloss.Width(created)).Render(i.Account.Username),
			statusTimeStyle.Render(created),
		),
	)
	b.WriteRune('\n')
	if nodes != nil {
		for _, node := range nodes {
			parseContent(&b, node, nil)
		}
	} else {
		b.WriteString(i.Content)
	}

	return b.String()
}

func parseContent(w io.Writer, node *html.Node, render func(string) string) {
	for n := node; n != nil; n = n.NextSibling {
		switch n.Data {
		case "p":
			parseContent(w, n.FirstChild, render)
			w.Write([]byte("\n"))
		case "html", "body", "span":
			parseContent(w, n.FirstChild, render)
		case "a":
			parseContent(w, n.FirstChild, statusLinkStyle.Render)
		case "br":
			w.Write([]byte("\n"))
		case "head":
		default:
			if n.Type == html.TextNode {
				if render != nil {
					w.Write([]byte(render(n.Data)))
					continue
				}
				w.Write([]byte(n.Data))
				continue
			}
			panic(n.Data)
		}
	}
}

A styles.go => styles.go +17 -0
@@ 0,0 1,17 @@
package main

import "github.com/charmbracelet/lipgloss"

var (
	subtle    = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
	highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
	special   = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}

	columnNameStyle      = lipgloss.NewStyle().PaddingLeft(1)
	columnBorder         = lipgloss.NewStyle().Border(lipgloss.NormalBorder())
	columnBorderSelected = columnBorder.Copy().BorderForeground(highlight)
	statusStyle          = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true)
	statusUsernameStyle  = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("8")).Align(lipgloss.Left)
	statusTimeStyle      = lipgloss.NewStyle().Italic(true).Foreground(subtle).Align(lipgloss.Right)
	statusLinkStyle      = lipgloss.NewStyle().Foreground(special)
)

A timeline.go => timeline.go +153 -0
@@ 0,0 1,153 @@
package main

import (
	"fmt"
	"strings"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
)

type Item interface {
	tea.Model
	ID() string
	SetWidth(int)
}

type Fetch func(string) ([]Item, error)

type Timeline struct {
	getItems        Fetch
	items           []Item
	itemHeights     []int
	width, height   int
	firstItemIndex  int
	lastItemIndex   int
	firstItemOffset int
	lastItemOffset  int
	lastFetch       bool
}

func NewTimeline(gi Fetch) *Timeline {
	return &Timeline{getItems: gi}
}

func (tl *Timeline) SetSize(w, h int) {
	tl.width = w
	tl.height = h
	tl.itemHeights = make([]int, len(tl.items))
	for _, i := range tl.items {
		i.SetWidth(w)
	}
}

func (tl *Timeline) Init() tea.Cmd {
	return nil
}

func (tl *Timeline) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case "j", "down":
			tl.scroll(1)
		case "k", "up":
			tl.scroll(-1)
		case "ctrl+d":
			tl.scroll(tl.height / 2)
		case "ctrl+u":
			tl.scroll(-tl.height / 2)
		}
	}
	return tl, nil
}

func (tl *Timeline) scroll(lines int) {
	if lines > 0 {
		for lines > 0 {
			if lines < tl.itemHeights[tl.firstItemIndex]-tl.firstItemOffset-1 {
				tl.firstItemOffset += lines
				break
			}
			if tl.firstItemIndex+1 >= len(tl.items) {
				break
			}
			lines -= tl.itemHeights[tl.firstItemIndex] - tl.firstItemOffset
			tl.firstItemIndex++
			tl.firstItemOffset = 0
		}
	} else {
		for lines < 0 {
			if lines+tl.itemHeights[tl.firstItemIndex]-tl.firstItemOffset-1 > 0 {
				if tl.firstItemOffset+lines > 0 {
					tl.firstItemOffset += lines
					break
				}
			}
			if tl.firstItemIndex-1 <= 0 {
				break
			}
			lines += tl.itemHeights[tl.firstItemIndex] - tl.firstItemOffset
			tl.firstItemIndex--
			tl.firstItemOffset = 0
		}
	}
}

func (tl *Timeline) View() string {
	var b strings.Builder
	height := tl.viewItems(&b, tl.items, tl.height)
	for height < tl.height && !tl.lastFetch {
		newItems, err := tl.fetch()
		if err != nil {
			return fmt.Sprintf("ERROR: fetching statuses: %v", err)
		}
		if len(newItems) > 0 {
			height += tl.viewItems(&b, newItems, tl.height-height)
		}
	}
	wrap := lipgloss.NewStyle().Width(tl.width).Height(tl.height)
	return wrap.Render(b.String())
}

func (tl *Timeline) viewItems(b *strings.Builder, items []Item, heightLeft int) int {
	var height int
	for i := tl.firstItemIndex; i < len(tl.items) && height < heightLeft; i++ {
		item := tl.items[i]
		wrap := lipgloss.NewStyle().Width(tl.width).MaxHeight(heightLeft - height)
		itemStr := wrap.Render(item.View())
		tl.itemHeights[i] = lipgloss.Height(itemStr) + 1
		height += tl.itemHeights[i]
		if i == tl.firstItemIndex {
			itemStr = strings.Join(strings.Split(itemStr, "\n")[tl.firstItemOffset:], "\n")
			height -= tl.firstItemOffset
		}
		b.WriteString(statusStyle.Render(itemStr))
		b.WriteRune('\n')
	}
	return height
}

func (tl *Timeline) fetch() ([]Item, error) {
	if tl.lastFetch {
		return nil, nil
	}
	var since string
	if len(tl.items) > 0 {
		since = tl.items[len(tl.items)-1].ID()
	}
	newItems, err := tl.getItems(since)
	if err != nil {
		return nil, err
	}
	tl.lastFetch = len(newItems) == 0
	if tl.lastFetch {
		return nil, nil
	}
	for _, i := range newItems {
		i.SetWidth(tl.width)
	}
	tl.items = append(tl.items, newItems...)
	tl.itemHeights = append(tl.itemHeights, make([]int, len(newItems))...)
	return newItems, nil
}