detached c3dedda

feat(render): configurable window border with a thin lavender default

commit
c3deddaab4473837b0c086735b92efa43e5ea8e6
author
brice · · 2026-09-24 09:08 UTC
parent
89b5b78
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +47 -7
M examples/config.toml +6 -0
@@ -12,6 +12,12 @@ command = ["foot"]
1212 [background]
1313 color = "#1e1e2e"
1414
15+[border]
16+# Drawn inside the window edge; width = 0 disables it. inactive = "" draws nothing.
17+width = 2
18+active = "#b4befe"
19+inactive = ""
20+
1521 [layout]
1622 gaps = 8
1723 default_column_width = "1/2"
@@ -12,6 +12,12 @@ command = ["foot"]
12 [background]12 [background]
13 color = "#1e1e2e"13 color = "#1e1e2e"
14 14
15+[border]
16+# Drawn inside the window edge; width = 0 disables it. inactive = "" draws nothing.
17+width = 2
18+active = "#b4befe"
19+inactive = ""
20+
15 [layout]21 [layout]
16 gaps = 822 gaps = 8
17 default_column_width = "1/2"23 default_column_width = "1/2"
M internal/adapters/config/config.go +12 -0
@@ -38,6 +38,9 @@ func Defaults() ports.Config {
3838 c.Keyboard.CmdKey = "super"
3939 c.Terminal.Command = []string{"foot"}
4040 c.Background.Color = "#1e1e2e"
41+ c.Border.Width = 2
42+ c.Border.Active = "#b4befe"
43+ c.Border.Inactive = ""
4144 c.Layout.Gaps = 8
4245 c.Layout.DefaultColumnWidth = "1/2"
4346 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
@@ -207,6 +210,15 @@ func Validate(c ports.Config) error {
207210 if !color.MatchString(c.Background.Color) {
208211 add("background.color", "must be #rrggbb")
209212 }
213+ if c.Border.Width < 0 || c.Border.Width > 32 {
214+ add("border.width", "must be between 0 and 32")
215+ }
216+ if !color.MatchString(c.Border.Active) {
217+ add("border.active", "must be #rrggbb")
218+ }
219+ if c.Border.Inactive != "" && !color.MatchString(c.Border.Inactive) {
220+ add("border.inactive", `must be #rrggbb or "" (none)`)
221+ }
210222 for i, o := range c.Outputs {
211223 path := fmt.Sprintf("output[%d]", i)
212224 if o.Name == "" {
@@ -38,6 +38,9 @@ func Defaults() ports.Config {
38 c.Keyboard.CmdKey = "super"38 c.Keyboard.CmdKey = "super"
39 c.Terminal.Command = []string{"foot"}39 c.Terminal.Command = []string{"foot"}
40 c.Background.Color = "#1e1e2e"40 c.Background.Color = "#1e1e2e"
41+ c.Border.Width = 2
42+ c.Border.Active = "#b4befe"
43+ c.Border.Inactive = ""
41 c.Layout.Gaps = 844 c.Layout.Gaps = 8
42 c.Layout.DefaultColumnWidth = "1/2"45 c.Layout.DefaultColumnWidth = "1/2"
43 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}46 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
@@ -207,6 +210,15 @@ func Validate(c ports.Config) error {
207 if !color.MatchString(c.Background.Color) {210 if !color.MatchString(c.Background.Color) {
208 add("background.color", "must be #rrggbb")211 add("background.color", "must be #rrggbb")
209 }212 }
213+ if c.Border.Width < 0 || c.Border.Width > 32 {
214+ add("border.width", "must be between 0 and 32")
215+ }
216+ if !color.MatchString(c.Border.Active) {
217+ add("border.active", "must be #rrggbb")
218+ }
219+ if c.Border.Inactive != "" && !color.MatchString(c.Border.Inactive) {
220+ add("border.inactive", `must be #rrggbb or "" (none)`)
221+ }
210 for i, o := range c.Outputs {222 for i, o := range c.Outputs {
211 path := fmt.Sprintf("output[%d]", i)223 path := fmt.Sprintf("output[%d]", i)
212 if o.Name == "" {224 if o.Name == "" {
M internal/adapters/vulkan/renderer.go +7 -2
@@ -377,9 +377,14 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
377377 add(image.Rect(x, y, x+width, y+height), [3]uint8{}, &content, image.Pt(x, y))
378378 }
379379 }
380+ borderColor := s.Border.Inactive
380381 if w.Focused {
381- for _, strip := range []image.Rectangle{image.Rect(x, y, x+w.Rect.W, y+4), image.Rect(x, y+w.Rect.H-4, x+w.Rect.W, y+w.Rect.H), image.Rect(x, y+4, x+4, y+w.Rect.H-4), image.Rect(x+w.Rect.W-4, y+4, x+w.Rect.W, y+w.Rect.H-4)} {
382- add(strip, [3]uint8{255, 255, 255}, nil, image.Point{})
382+ borderColor = s.Border.Active
383+ }
384+ if b := s.Border.Width; b > 0 && borderColor != "" && !w.Fullscreen && w.Rect.W > 2*b && w.Rect.H > 2*b {
385+ rgb := parseColor(borderColor)
386+ for _, strip := range []image.Rectangle{image.Rect(x, y, x+w.Rect.W, y+b), image.Rect(x, y+w.Rect.H-b, x+w.Rect.W, y+w.Rect.H), image.Rect(x, y+b, x+b, y+w.Rect.H-b), image.Rect(x+w.Rect.W-b, y+b, x+w.Rect.W, y+w.Rect.H-b)} {
387+ add(strip, rgb, nil, image.Point{})
383388 }
384389 }
385390 }
@@ -377,9 +377,14 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
377 add(image.Rect(x, y, x+width, y+height), [3]uint8{}, &content, image.Pt(x, y))377 add(image.Rect(x, y, x+width, y+height), [3]uint8{}, &content, image.Pt(x, y))
378 }378 }
379 }379 }
380+ borderColor := s.Border.Inactive
380 if w.Focused {381 if w.Focused {
381- for _, strip := range []image.Rectangle{image.Rect(x, y, x+w.Rect.W, y+4), image.Rect(x, y+w.Rect.H-4, x+w.Rect.W, y+w.Rect.H), image.Rect(x, y+4, x+4, y+w.Rect.H-4), image.Rect(x+w.Rect.W-4, y+4, x+w.Rect.W, y+w.Rect.H-4)} {382+ borderColor = s.Border.Active
382- add(strip, [3]uint8{255, 255, 255}, nil, image.Point{})383+ }
384+ if b := s.Border.Width; b > 0 && borderColor != "" && !w.Fullscreen && w.Rect.W > 2*b && w.Rect.H > 2*b {
385+ rgb := parseColor(borderColor)
386+ for _, strip := range []image.Rectangle{image.Rect(x, y, x+w.Rect.W, y+b), image.Rect(x, y+w.Rect.H-b, x+w.Rect.W, y+w.Rect.H), image.Rect(x, y+b, x+b, y+w.Rect.H-b), image.Rect(x+w.Rect.W-b, y+b, x+w.Rect.W, y+w.Rect.H-b)} {
387+ add(strip, rgb, nil, image.Point{})
383 }388 }
384 }389 }
385 }390 }
M internal/adapters/vulkan/renderer_test.go +7 -3
@@ -57,10 +57,13 @@ func TestRendererRender(t *testing.T) {
5757 win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}}
5858 c := windowColor(1)
5959 wc := color.RGBA{c[0], c[1], c[2], 255}
60- s := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{win}}
61- check(s, map[image.Point]color.RGBA{{0, 0}: bg, {16, 16}: wc})
60+ s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}}
61+ check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: {0x31, 0x32, 0x44, 255}, {10, 10}: wc, {16, 16}: wc})
6262 s.Windows[0].Focused = true
63- check(s, map[image.Point]color.RGBA{{9, 9}: {255, 255, 255, 255}, {16, 16}: wc})
63+ check(s, map[image.Point]color.RGBA{{9, 9}: {0xb4, 0xbe, 0xfe, 255}, {10, 10}: wc, {16, 16}: wc})
64+ s.Windows[0].Fullscreen = true
65+ check(s, map[image.Point]color.RGBA{{9, 9}: wc})
66+ s.Windows[0].Fullscreen = false
6467 s.Windows[0] = ports.SceneWindow{ID: 2, Rect: ports.Rect{X: -10, Y: 0, W: 20, H: 10}}
6568 c = windowColor(2)
6669 check(s, map[image.Point]color.RGBA{{5, 5}: {c[0], c[1], c[2], 255}, {15, 5}: bg})
@@ -128,6 +131,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
128131 if got, want := r.Pixels().RGBAAt(10, 10), (color.RGBA{c[0], c[1], c[2], 255}); got != want {
129132 t.Errorf("overlap = %v, want %v", got, want)
130133 }
134+ scene.Border = ports.Border{Width: 4, Active: "#ffffff"}
131135 scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}}
132136 contents := map[ports.WindowID]ports.SurfaceContent{1: {Width: 16, Height: 16, Stride: 64, Opaque: true, Pixels: make([]byte, 16*16*4)}}
133137 for i := 0; i < len(contents[1].Pixels); i += 4 {
@@ -57,10 +57,13 @@ func TestRendererRender(t *testing.T) {
57 win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}}57 win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}}
58 c := windowColor(1)58 c := windowColor(1)
59 wc := color.RGBA{c[0], c[1], c[2], 255}59 wc := color.RGBA{c[0], c[1], c[2], 255}
60- s := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{win}}60+ s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}}
61- check(s, map[image.Point]color.RGBA{{0, 0}: bg, {16, 16}: wc})61+ check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: {0x31, 0x32, 0x44, 255}, {10, 10}: wc, {16, 16}: wc})
62 s.Windows[0].Focused = true62 s.Windows[0].Focused = true
63- check(s, map[image.Point]color.RGBA{{9, 9}: {255, 255, 255, 255}, {16, 16}: wc})63+ check(s, map[image.Point]color.RGBA{{9, 9}: {0xb4, 0xbe, 0xfe, 255}, {10, 10}: wc, {16, 16}: wc})
64+ s.Windows[0].Fullscreen = true
65+ check(s, map[image.Point]color.RGBA{{9, 9}: wc})
66+ s.Windows[0].Fullscreen = false
64 s.Windows[0] = ports.SceneWindow{ID: 2, Rect: ports.Rect{X: -10, Y: 0, W: 20, H: 10}}67 s.Windows[0] = ports.SceneWindow{ID: 2, Rect: ports.Rect{X: -10, Y: 0, W: 20, H: 10}}
65 c = windowColor(2)68 c = windowColor(2)
66 check(s, map[image.Point]color.RGBA{{5, 5}: {c[0], c[1], c[2], 255}, {15, 5}: bg})69 check(s, map[image.Point]color.RGBA{{5, 5}: {c[0], c[1], c[2], 255}, {15, 5}: bg})
@@ -128,6 +131,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
128 if got, want := r.Pixels().RGBAAt(10, 10), (color.RGBA{c[0], c[1], c[2], 255}); got != want {131 if got, want := r.Pixels().RGBAAt(10, 10), (color.RGBA{c[0], c[1], c[2], 255}); got != want {
129 t.Errorf("overlap = %v, want %v", got, want)132 t.Errorf("overlap = %v, want %v", got, want)
130 }133 }
134+ scene.Border = ports.Border{Width: 4, Active: "#ffffff"}
131 scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}}135 scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}}
132 contents := map[ports.WindowID]ports.SurfaceContent{1: {Width: 16, Height: 16, Stride: 64, Opaque: true, Pixels: make([]byte, 16*16*4)}}136 contents := map[ports.WindowID]ports.SurfaceContent{1: {Width: 16, Height: 16, Stride: 64, Opaque: true, Pixels: make([]byte, 16*16*4)}}
133 for i := 0; i < len(contents[1].Pixels); i += 4 {137 for i := 0; i < len(contents[1].Pixels); i += 4 {
M internal/core/core.go +1 -1
@@ -147,7 +147,7 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
147147 }
148148 func (c *Core) publish(ctx context.Context) error {
149149 c.seq++
150- scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
150+ scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
151151 alive := map[WindowID]bool{}
152152 focus, _ := c.ws.Focused()
153153 for _, p := range c.ws.Layout() {
@@ -147,7 +147,7 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
147 }147 }
148 func (c *Core) publish(ctx context.Context) error {148 func (c *Core) publish(ctx context.Context) error {
149 c.seq++149 c.seq++
150- scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}150+ scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
151 alive := map[WindowID]bool{}151 alive := map[WindowID]bool{}
152 focus, _ := c.ws.Focused()152 focus, _ := c.ws.Focused()
153 for _, p := range c.ws.Layout() {153 for _, p := range c.ws.Layout() {
M internal/ports/config.go +7 -1
@@ -21,7 +21,13 @@ type Config struct {
2121 } `toml:"keyboard"`
2222 Terminal struct{ Command []string } `toml:"terminal"`
2323 Background struct{ Color string } `toml:"background"`
24- Layout struct {
24+ // Border is drawn inside the window edge; Width 0 disables it.
25+ Border struct {
26+ Width int
27+ Active string
28+ Inactive string
29+ } `toml:"border"`
30+ Layout struct {
2531 Gaps int
2632 DefaultColumnWidth string `toml:"default_column_width"`
2733 Presets []string
@@ -21,7 +21,13 @@ type Config struct {
21 } `toml:"keyboard"`21 } `toml:"keyboard"`
22 Terminal struct{ Command []string } `toml:"terminal"`22 Terminal struct{ Command []string } `toml:"terminal"`
23 Background struct{ Color string } `toml:"background"`23 Background struct{ Color string } `toml:"background"`
24- Layout struct {24+ // Border is drawn inside the window edge; Width 0 disables it.
25+ Border struct {
26+ Width int
27+ Active string
28+ Inactive string
29+ } `toml:"border"`
30+ Layout struct {
25 Gaps int31 Gaps int
26 DefaultColumnWidth string `toml:"default_column_width"`32 DefaultColumnWidth string `toml:"default_column_width"`
27 Presets []string33 Presets []string
M internal/ports/messages.go +7 -0
@@ -187,12 +187,19 @@ type Scene struct {
187187 Seq uint64
188188 OutputWidth, OutputHeight int
189189 Background string
190+ Border Border
190191 Windows []SceneWindow
191192 // Layers are drawn in slice order: background and bottom before windows,
192193 // top and overlay after. A fullscreen window covers bottom and top.
193194 Layers []SceneLayer
194195 }
195196
197+// Border carries the window border style; colors are #rrggbb, "" skips drawing.
198+type Border struct {
199+ Width int
200+ Active, Inactive string
201+}
202+
196203 // SceneLayer carries core → renderer layer surface placement.
197204 type SceneLayer struct {
198205 ID WindowID
@@ -187,12 +187,19 @@ type Scene struct {
187 Seq uint64187 Seq uint64
188 OutputWidth, OutputHeight int188 OutputWidth, OutputHeight int
189 Background string189 Background string
190+ Border Border
190 Windows []SceneWindow191 Windows []SceneWindow
191 // Layers are drawn in slice order: background and bottom before windows,192 // Layers are drawn in slice order: background and bottom before windows,
192 // top and overlay after. A fullscreen window covers bottom and top.193 // top and overlay after. A fullscreen window covers bottom and top.
193 Layers []SceneLayer194 Layers []SceneLayer
194 }195 }
195 196
197+// Border carries the window border style; colors are #rrggbb, "" skips drawing.
198+type Border struct {
199+ Width int
200+ Active, Inactive string
201+}
202+
196 // SceneLayer carries core → renderer layer surface placement.203 // SceneLayer carries core → renderer layer surface placement.
197 type SceneLayer struct {204 type SceneLayer struct {
198 ID WindowID205 ID WindowID

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close