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
+}