Skip to content

Commit 13f19b7

Browse files
authored
feat: spotify plugin and image display (#35)
1 parent 812d201 commit 13f19b7

7 files changed

Lines changed: 614 additions & 15 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ require (
2020
github.com/clipperhouse/displaywidth v0.11.0 // indirect
2121
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
2222
github.com/go-ole/go-ole v1.2.6 // indirect
23+
github.com/godbus/dbus/v5 v5.2.2 // indirect
2324
github.com/google/go-cmp v0.7.0 // indirect
2425
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
2526
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
2626
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2727
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
2828
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
29+
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
30+
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
2931
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
3032
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
3133
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=

internal/layout/layout.go

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package layout
22

33
import (
4+
"strings"
5+
46
"github.com/floatpane/lattice/pkg/module"
57
"github.com/floatpane/lattice/pkg/styles"
68

@@ -21,10 +23,18 @@ var (
2123
MarginBottom(1)
2224
)
2325

26+
// ScreenPlacement is an image placement with absolute screen coordinates.
27+
type ScreenPlacement struct {
28+
Row int // absolute terminal row (1-based)
29+
Col int // absolute terminal col (1-based)
30+
Escape string // kitty graphics escape sequence
31+
}
32+
2433
// Render arranges modules into a columnar grid.
25-
func Render(modules []module.Module, columns, termWidth, termHeight int) string {
34+
// It returns the rendered text and any image placements with absolute positions.
35+
func Render(modules []module.Module, columns, termWidth, termHeight int) (string, []ScreenPlacement) {
2636
if len(modules) == 0 {
27-
return ""
37+
return "", nil
2838
}
2939
if columns < 1 {
3040
columns = 1
@@ -43,8 +53,13 @@ func Render(modules []module.Module, columns, termWidth, termHeight int) string
4353
// Content width inside the box (subtract border=2 + padding=4)
4454
contentWidth := colWidth - 6
4555

56+
// Track per-column cumulative height (in rendered lines) for positioning.
57+
colHeights := make([]int, columns)
58+
4659
// Distribute modules across columns round-robin
4760
cols := make([][]string, columns)
61+
var placements []ScreenPlacement
62+
4863
for i, mod := range modules {
4964
col := i % columns
5065
_, minH := mod.MinSize()
@@ -57,6 +72,32 @@ func Render(modules []module.Module, columns, termWidth, termHeight int) string
5772
inner := lipgloss.JoinVertical(lipgloss.Left, title, content)
5873

5974
box := boxStyle.Width(colWidth - 2).Height(minH + 2).Render(inner)
75+
76+
if placer, ok := mod.(module.ImagePlacer); ok {
77+
for _, ip := range placer.ImagePlacements() {
78+
// To find the content area within the box, we need to account for:
79+
// - docStyle Padding(1, 2): 1 row top, 2 cols left
80+
// - box border top: 1 row
81+
// - box padding top: 1 row
82+
// - title line: 1 row
83+
// - title MarginBottom(1): 1 row
84+
// - box border left: 1 col, box padding left: 2 cols
85+
// Total row offset from box top to content: 4
86+
// Total col offset from box left to content: 3
87+
absRow := 1 + colHeights[col] + 4 + ip.Row + 1 // +1 for docPadTop, +1 for 1-based
88+
absCol := 2 + (col * colWidth) + 3 + ip.Col + 1 // +2 for docPadLeft, +1 for 1-based
89+
placements = append(placements, ScreenPlacement{
90+
Row: absRow,
91+
Col: absCol,
92+
Escape: ip.Escape,
93+
})
94+
}
95+
}
96+
97+
// Count actual rendered lines in the box for accurate height tracking
98+
boxHeight := strings.Count(box, "\n") + 1
99+
colHeights[col] += boxHeight
100+
60101
cols[col] = append(cols[col], box)
61102
}
62103

@@ -66,7 +107,9 @@ func Render(modules []module.Module, columns, termWidth, termHeight int) string
66107
rendered[i] = lipgloss.JoinVertical(lipgloss.Left, col...)
67108
}
68109

69-
return docStyle.Render(
110+
text := docStyle.Render(
70111
lipgloss.JoinHorizontal(lipgloss.Top, rendered...),
71112
)
113+
114+
return text, placements
72115
}

internal/layout/layout_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func (m *mockModule) View(_, _ int) string { return m.content }
2121
func (m *mockModule) MinSize() (int, int) { return m.w, m.h }
2222

2323
func TestRenderEmpty(t *testing.T) {
24-
result := Render(nil, 2, 80, 24)
24+
result, _ := Render(nil, 2, 80, 24)
2525
if result != "" {
2626
t.Errorf("expected empty string, got %q", result)
2727
}
@@ -31,7 +31,7 @@ func TestRenderSingleModule(t *testing.T) {
3131
mods := []module.Module{
3232
&mockModule{name: "TEST", content: "hello", w: 20, h: 3},
3333
}
34-
result := Render(mods, 2, 80, 24)
34+
result, _ := Render(mods, 2, 80, 24)
3535
if result == "" {
3636
t.Fatal("expected non-empty output")
3737
}
@@ -50,7 +50,7 @@ func TestRenderMultipleModules(t *testing.T) {
5050
&mockModule{name: "C", content: "ccc", w: 20, h: 3},
5151
&mockModule{name: "D", content: "ddd", w: 20, h: 3},
5252
}
53-
result := Render(mods, 2, 100, 40)
53+
result, _ := Render(mods, 2, 100, 40)
5454
for _, name := range []string{"A", "B", "C", "D"} {
5555
if !strings.Contains(result, name) {
5656
t.Errorf("expected %q in output", name)
@@ -63,7 +63,7 @@ func TestRenderClampsColumns(t *testing.T) {
6363
&mockModule{name: "ONLY", content: "one", w: 20, h: 3},
6464
}
6565
// 5 columns but only 1 module — should clamp to 1 column
66-
result := Render(mods, 5, 80, 24)
66+
result, _ := Render(mods, 5, 80, 24)
6767
if !strings.Contains(result, "ONLY") {
6868
t.Error("expected module in output even with excess columns")
6969
}
@@ -74,7 +74,7 @@ func TestRenderMinColumnWidth(t *testing.T) {
7474
&mockModule{name: "NARROW", content: "x", w: 20, h: 3},
7575
}
7676
// Very narrow terminal — should still render
77-
result := Render(mods, 1, 10, 24)
77+
result, _ := Render(mods, 1, 10, 24)
7878
if result == "" {
7979
t.Error("expected output even with tiny terminal")
8080
}
@@ -85,7 +85,7 @@ func TestRenderZeroColumns(t *testing.T) {
8585
&mockModule{name: "A", content: "a", w: 20, h: 3},
8686
}
8787
// 0 columns should be treated as 1
88-
result := Render(mods, 0, 80, 24)
88+
result, _ := Render(mods, 0, 80, 24)
8989
if !strings.Contains(result, "A") {
9090
t.Error("expected module with 0 columns (should default to 1)")
9191
}
@@ -95,7 +95,7 @@ func TestRenderMinHeight(t *testing.T) {
9595
mods := []module.Module{
9696
&mockModule{name: "TINY", content: "x", w: 20, h: 1}, // below minimum of 3
9797
}
98-
result := Render(mods, 1, 80, 24)
98+
result, _ := Render(mods, 1, 80, 24)
9999
if !strings.Contains(result, "TINY") {
100100
t.Error("expected module with small min height")
101101
}

0 commit comments

Comments
 (0)