feat(render): configurable window border with a thin lavender default
- commit
- c3deddaab4473837b0c086735b92efa43e5ea8e6
- author
- brice · · 2026-09-24 09:08 UTC
- parent
- 89b5b78
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +47 -7
M examples/config.toml +6 -0
| @@ -12,6 +12,12 @@ command = ["foot"] | |||
| 12 | 12 | [background] | |
| 13 | 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 | 21 | [layout] | |
| 16 | 22 | gaps = 8 | |
| 17 | 23 | 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 = 8 | 22 | 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 { | |||
| 38 | 38 | c.Keyboard.CmdKey = "super" | |
| 39 | 39 | c.Terminal.Command = []string{"foot"} | |
| 40 | 40 | c.Background.Color = "#1e1e2e" | |
| 41 | + | c.Border.Width = 2 | |
| 42 | + | c.Border.Active = "#b4befe" | |
| 43 | + | c.Border.Inactive = "" | |
| 41 | 44 | c.Layout.Gaps = 8 | |
| 42 | 45 | c.Layout.DefaultColumnWidth = "1/2" | |
| 43 | 46 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | |
| @@ -207,6 +210,15 @@ func Validate(c ports.Config) error { | |||
| 207 | 210 | if !color.MatchString(c.Background.Color) { | |
| 208 | 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 | 222 | for i, o := range c.Outputs { | |
| 211 | 223 | path := fmt.Sprintf("output[%d]", i) | |
| 212 | 224 | 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 = 8 | 44 | 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 | |||
| 377 | 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 | 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 | - | 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{}) | |
| 383 | 388 | } | |
| 384 | 389 | } | |
| 385 | 390 | } | |
| @@ -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) { | |||
| 57 | 57 | win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}} | |
| 58 | 58 | c := windowColor(1) | |
| 59 | 59 | 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}) | |
| 62 | 62 | 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 | 67 | s.Windows[0] = ports.SceneWindow{ID: 2, Rect: ports.Rect{X: -10, Y: 0, W: 20, H: 10}} | |
| 65 | 68 | c = windowColor(2) | |
| 66 | 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 | 131 | if got, want := r.Pixels().RGBAAt(10, 10), (color.RGBA{c[0], c[1], c[2], 255}); got != want { | |
| 129 | 132 | t.Errorf("overlap = %v, want %v", got, want) | |
| 130 | 133 | } | |
| 134 | + | scene.Border = ports.Border{Width: 4, Active: "#ffffff"} | |
| 131 | 135 | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}} | |
| 132 | 136 | contents := map[ports.WindowID]ports.SurfaceContent{1: {Width: 16, Height: 16, Stride: 64, Opaque: true, Pixels: make([]byte, 16*16*4)}} | |
| 133 | 137 | 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 = true | 62 | 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 { | |||
| 147 | 147 | } | |
| 148 | 148 | func (c *Core) publish(ctx context.Context) error { | |
| 149 | 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 | 151 | alive := map[WindowID]bool{} | |
| 152 | 152 | focus, _ := c.ws.Focused() | |
| 153 | 153 | 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 { | |||
| 21 | 21 | } `toml:"keyboard"` | |
| 22 | 22 | Terminal struct{ Command []string } `toml:"terminal"` | |
| 23 | 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 | 31 | Gaps int | |
| 26 | 32 | DefaultColumnWidth string `toml:"default_column_width"` | |
| 27 | 33 | 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 int | 31 | Gaps int | ||
| 26 | DefaultColumnWidth string `toml:"default_column_width"` | 32 | DefaultColumnWidth string `toml:"default_column_width"` | ||
| 27 | Presets []string | 33 | Presets []string | ||
M internal/ports/messages.go +7 -0
| @@ -187,12 +187,19 @@ type Scene struct { | |||
| 187 | 187 | Seq uint64 | |
| 188 | 188 | OutputWidth, OutputHeight int | |
| 189 | 189 | Background string | |
| 190 | + | Border Border | |
| 190 | 191 | Windows []SceneWindow | |
| 191 | 192 | // Layers are drawn in slice order: background and bottom before windows, | |
| 192 | 193 | // top and overlay after. A fullscreen window covers bottom and top. | |
| 193 | 194 | 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 | 203 | // SceneLayer carries core → renderer layer surface placement. | |
| 197 | 204 | type SceneLayer struct { | |
| 198 | 205 | ID WindowID | |
| @@ -187,12 +187,19 @@ type Scene struct { | |||||
| 187 | Seq uint64 | 187 | Seq uint64 | ||
| 188 | OutputWidth, OutputHeight int | 188 | OutputWidth, OutputHeight int | ||
| 189 | Background string | 189 | Background string | ||
| 190 | + | Border Border | |||
| 190 | Windows []SceneWindow | 191 | 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 []SceneLayer | 194 | 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 WindowID | 205 | ID WindowID | ||