feat(render): draw tmux-style focus separators between tiles
- commit
- 5facfaf4e2ffa77c14e861b6fab3d5b7335289db
- author
- brice · · 2026-09-26 14:43 UTC
- committer
- brice · · 2026-09-26 15:07 UTC
- parent
- 23657a0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +181 -64
M examples/config +7 -5
| @@ -22,14 +22,16 @@ terminal = foot | |||
| 22 | 22 | ||
| 23 | 23 | background = #111111 | |
| 24 | 24 | ||
| 25 | - | # Drawn inside the window edge; width 0 disables it, empty inactive draws nothing. | |
| 26 | - | border.width = 2 | |
| 27 | - | border.active = #b4befe | |
| 28 | - | border.inactive = | |
| 25 | + | # Separator lines between tiled windows (tmux style), in logical pixels. | |
| 26 | + | # The lines along the focused window use active; width 0 disables them, | |
| 27 | + | # empty inactive draws only the focused lines. Floats get a full border. | |
| 28 | + | border.width = 1 | |
| 29 | + | border.active = #6c6c6c | |
| 30 | + | border.inactive = #2a2a2a | |
| 29 | 31 | ||
| 30 | 32 | layout.gaps = 0 | |
| 31 | 33 | # Columns sharing the screen equally before new ones scroll off to the right. | |
| 32 | - | # A lone column fills the screen without a border. | |
| 34 | + | # A lone column fills the screen without separators. | |
| 33 | 35 | layout.max-columns = 2 | |
| 34 | 36 | # Past max-columns: scroll opens columns to the right; fixed keeps everything | |
| 35 | 37 | # on screen, each new window splitting the newest one (spiral). | |
| @@ -22,14 +22,16 @@ terminal = foot | |||||
| 22 | 22 | ||||
| 23 | background = #111111 | 23 | background = #111111 | ||
| 24 | 24 | ||||
| 25 | - | # Drawn inside the window edge; width 0 disables it, empty inactive draws nothing. | 25 | + | # Separator lines between tiled windows (tmux style), in logical pixels. |
| 26 | - | border.width = 2 | 26 | + | # The lines along the focused window use active; width 0 disables them, |
| 27 | - | border.active = #b4befe | 27 | + | # empty inactive draws only the focused lines. Floats get a full border. |
| 28 | - | border.inactive = | 28 | + | border.width = 1 |
| 29 | + | border.active = #6c6c6c | |||
| 30 | + | border.inactive = #2a2a2a | |||
| 29 | 31 | ||||
| 30 | layout.gaps = 0 | 32 | layout.gaps = 0 | ||
| 31 | # Columns sharing the screen equally before new ones scroll off to the right. | 33 | # Columns sharing the screen equally before new ones scroll off to the right. | ||
| 32 | - | # A lone column fills the screen without a border. | 34 | + | # A lone column fills the screen without separators. |
| 33 | layout.max-columns = 2 | 35 | layout.max-columns = 2 | ||
| 34 | # Past max-columns: scroll opens columns to the right; fixed keeps everything | 36 | # Past max-columns: scroll opens columns to the right; fixed keeps everything | ||
| 35 | # on screen, each new window splitting the newest one (spiral). | 37 | # on screen, each new window splitting the newest one (spiral). | ||
M internal/adapters/config/config.go +3 -2
| @@ -82,8 +82,9 @@ func Defaults() ports.Config { | |||
| 82 | 82 | c.Terminal.Command = []string{"foot"} | |
| 83 | 83 | c.Xwayland = "xwayland-satellite" | |
| 84 | 84 | c.Background.Color = "#111111" | |
| 85 | - | c.Border.Width = 2 | |
| 86 | - | c.Border.Active = "#b4befe" | |
| 85 | + | c.Border.Width = 1 | |
| 86 | + | c.Border.Active = "#6c6c6c" | |
| 87 | + | c.Border.Inactive = "#2a2a2a" | |
| 87 | 88 | c.Layout.MaxColumns = 2 | |
| 88 | 89 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | |
| 89 | 90 | c.Layout.Overflow = "scroll" | |
| @@ -82,8 +82,9 @@ func Defaults() ports.Config { | |||||
| 82 | c.Terminal.Command = []string{"foot"} | 82 | c.Terminal.Command = []string{"foot"} | ||
| 83 | c.Xwayland = "xwayland-satellite" | 83 | c.Xwayland = "xwayland-satellite" | ||
| 84 | c.Background.Color = "#111111" | 84 | c.Background.Color = "#111111" | ||
| 85 | - | c.Border.Width = 2 | 85 | + | c.Border.Width = 1 |
| 86 | - | c.Border.Active = "#b4befe" | 86 | + | c.Border.Active = "#6c6c6c" |
| 87 | + | c.Border.Inactive = "#2a2a2a" | |||
| 87 | c.Layout.MaxColumns = 2 | 88 | c.Layout.MaxColumns = 2 | ||
| 88 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | 89 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | ||
| 89 | c.Layout.Overflow = "scroll" | 90 | c.Layout.Overflow = "scroll" | ||
M internal/adapters/drm/overlay.go +1 -1
| @@ -91,7 +91,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 91 | 91 | return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above" | |
| 92 | 92 | } | |
| 93 | 93 | } | |
| 94 | - | if !pick.Fullscreen && !pick.Borderless && s.Border.Width > 0 { | |
| 94 | + | if !pick.Fullscreen && pick.Inset|pick.Neighbors != 0 && s.Border.Width > 0 { | |
| 95 | 95 | // The overlay shows the buffer alone; its border would go with it. | |
| 96 | 96 | return ports.SceneWindow{}, ports.SurfaceContent{}, "border" | |
| 97 | 97 | } | |
| @@ -91,7 +91,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 91 | return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above" | 91 | return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above" | ||
| 92 | } | 92 | } | ||
| 93 | } | 93 | } | ||
| 94 | - | if !pick.Fullscreen && !pick.Borderless && s.Border.Width > 0 { | 94 | + | if !pick.Fullscreen && pick.Inset|pick.Neighbors != 0 && s.Border.Width > 0 { |
| 95 | // The overlay shows the buffer alone; its border would go with it. | 95 | // The overlay shows the buffer alone; its border would go with it. | ||
| 96 | return ports.SceneWindow{}, ports.SurfaceContent{}, "border" | 96 | return ports.SceneWindow{}, ports.SurfaceContent{}, "border" | ||
| 97 | } | 97 | } | ||
M internal/adapters/drm/overlay_test.go +4 -0
| @@ -120,6 +120,10 @@ func TestOverlayPopupAndBorder(t *testing.T) { | |||
| 120 | 120 | } | |
| 121 | 121 | s, _ = overlayScene() | |
| 122 | 122 | s.Border.Width = 2 | |
| 123 | + | if _, _, reason := overlayCandidate(s, c); reason != "" { | |
| 124 | + | t.Fatalf("lone window: %q", reason) | |
| 125 | + | } | |
| 126 | + | s.Windows[len(s.Windows)-1].Neighbors = ports.SideLeft | |
| 123 | 127 | if _, _, reason := overlayCandidate(s, c); reason != "border" { | |
| 124 | 128 | t.Fatalf("border: %q", reason) | |
| 125 | 129 | } | |
| @@ -120,6 +120,10 @@ func TestOverlayPopupAndBorder(t *testing.T) { | |||||
| 120 | } | 120 | } | ||
| 121 | s, _ = overlayScene() | 121 | s, _ = overlayScene() | ||
| 122 | s.Border.Width = 2 | 122 | s.Border.Width = 2 | ||
| 123 | + | if _, _, reason := overlayCandidate(s, c); reason != "" { | |||
| 124 | + | t.Fatalf("lone window: %q", reason) | |||
| 125 | + | } | |||
| 126 | + | s.Windows[len(s.Windows)-1].Neighbors = ports.SideLeft | |||
| 123 | if _, _, reason := overlayCandidate(s, c); reason != "border" { | 127 | if _, _, reason := overlayCandidate(s, c); reason != "border" { | ||
| 124 | t.Fatalf("border: %q", reason) | 128 | t.Fatalf("border: %q", reason) | ||
| 125 | } | 129 | } | ||
M internal/adapters/vulkan/compose_test.go +4 -4
| @@ -30,7 +30,7 @@ func TestRendererScaledSHMFiltersLinearly(t *testing.T) { | |||
| 30 | 30 | px := []byte{0, 0, 0, 255, 255, 255, 255, 255} | |
| 31 | 31 | c := shmContent(t, 2, 1, 8, px) | |
| 32 | 32 | c.LogicalW, c.LogicalH = 64, 16 | |
| 33 | - | scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}, Borderless: true}}} | |
| 33 | + | scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}} | |
| 34 | 34 | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | |
| 35 | 35 | t.Fatal(err) | |
| 36 | 36 | } | |
| @@ -62,7 +62,7 @@ func TestRendererScaledDMABufFiltersLinearly(t *testing.T) { | |||
| 62 | 62 | return [4]byte{255, 255, 255, 255} | |
| 63 | 63 | }) | |
| 64 | 64 | buf := &ports.DMABuf{ID: 3, Width: 64, Height: 1, Format: linear.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}} | |
| 65 | - | scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 128, H: 4}, Borderless: true}}} | |
| 65 | + | scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 128, H: 4}}}} | |
| 66 | 66 | c := ports.SurfaceContent{ID: 1, Width: 64, Height: 1, LogicalW: 128, LogicalH: 4, DMABuf: buf} | |
| 67 | 67 | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil { | |
| 68 | 68 | t.Fatal(err) | |
| @@ -108,7 +108,7 @@ func TestRendererSHMCopiedOncePerContent(t *testing.T) { | |||
| 108 | 108 | defer r.Close() | |
| 109 | 109 | c := solidContent(t, 16, 16, color.RGBA{1, 2, 3, 255}) | |
| 110 | 110 | c.ID, c.Seq = 1, 1 | |
| 111 | - | scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}, Borderless: true}}} | |
| 111 | + | scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}}} | |
| 112 | 112 | contents := map[ports.WindowID]ports.SurfaceContent{1: c} | |
| 113 | 113 | render := func() { | |
| 114 | 114 | t.Helper() | |
| @@ -160,7 +160,7 @@ func TestRendererSHMDoubleBufferWaitsForReaders(t *testing.T) { | |||
| 160 | 160 | if !r.syncFD { | |
| 161 | 161 | t.Skip("frames are finished on return without sync files") | |
| 162 | 162 | } | |
| 163 | - | scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}, Borderless: true}}} | |
| 163 | + | scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}}} | |
| 164 | 164 | var readers []uint64 | |
| 165 | 165 | for seq := uint64(1); seq <= 3; seq++ { | |
| 166 | 166 | c := solidContent(t, 16, 16, color.RGBA{uint8(seq * 60), 0, 0, 255}) | |
| @@ -30,7 +30,7 @@ func TestRendererScaledSHMFiltersLinearly(t *testing.T) { | |||||
| 30 | px := []byte{0, 0, 0, 255, 255, 255, 255, 255} | 30 | px := []byte{0, 0, 0, 255, 255, 255, 255, 255} | ||
| 31 | c := shmContent(t, 2, 1, 8, px) | 31 | c := shmContent(t, 2, 1, 8, px) | ||
| 32 | c.LogicalW, c.LogicalH = 64, 16 | 32 | c.LogicalW, c.LogicalH = 64, 16 | ||
| 33 | - | scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}, Borderless: true}}} | 33 | + | scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}} |
| 34 | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | 34 | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | ||
| 35 | t.Fatal(err) | 35 | t.Fatal(err) | ||
| 36 | } | 36 | } | ||
| @@ -62,7 +62,7 @@ func TestRendererScaledDMABufFiltersLinearly(t *testing.T) { | |||||
| 62 | return [4]byte{255, 255, 255, 255} | 62 | return [4]byte{255, 255, 255, 255} | ||
| 63 | }) | 63 | }) | ||
| 64 | buf := &ports.DMABuf{ID: 3, Width: 64, Height: 1, Format: linear.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}} | 64 | buf := &ports.DMABuf{ID: 3, Width: 64, Height: 1, Format: linear.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}} | ||
| 65 | - | scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 128, H: 4}, Borderless: true}}} | 65 | + | scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 128, H: 4}}}} |
| 66 | c := ports.SurfaceContent{ID: 1, Width: 64, Height: 1, LogicalW: 128, LogicalH: 4, DMABuf: buf} | 66 | c := ports.SurfaceContent{ID: 1, Width: 64, Height: 1, LogicalW: 128, LogicalH: 4, DMABuf: buf} | ||
| 67 | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil { | 67 | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil { | ||
| 68 | t.Fatal(err) | 68 | t.Fatal(err) | ||
| @@ -108,7 +108,7 @@ func TestRendererSHMCopiedOncePerContent(t *testing.T) { | |||||
| 108 | defer r.Close() | 108 | defer r.Close() | ||
| 109 | c := solidContent(t, 16, 16, color.RGBA{1, 2, 3, 255}) | 109 | c := solidContent(t, 16, 16, color.RGBA{1, 2, 3, 255}) | ||
| 110 | c.ID, c.Seq = 1, 1 | 110 | c.ID, c.Seq = 1, 1 | ||
| 111 | - | scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}, Borderless: true}}} | 111 | + | scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}}} |
| 112 | contents := map[ports.WindowID]ports.SurfaceContent{1: c} | 112 | contents := map[ports.WindowID]ports.SurfaceContent{1: c} | ||
| 113 | render := func() { | 113 | render := func() { | ||
| 114 | t.Helper() | 114 | t.Helper() | ||
| @@ -160,7 +160,7 @@ func TestRendererSHMDoubleBufferWaitsForReaders(t *testing.T) { | |||||
| 160 | if !r.syncFD { | 160 | if !r.syncFD { | ||
| 161 | t.Skip("frames are finished on return without sync files") | 161 | t.Skip("frames are finished on return without sync files") | ||
| 162 | } | 162 | } | ||
| 163 | - | scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}, Borderless: true}}} | 163 | + | scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}}} |
| 164 | var readers []uint64 | 164 | var readers []uint64 | ||
| 165 | for seq := uint64(1); seq <= 3; seq++ { | 165 | for seq := uint64(1); seq <= 3; seq++ { | ||
| 166 | c := solidContent(t, 16, 16, color.RGBA{uint8(seq * 60), 0, 0, 255}) | 166 | c := solidContent(t, 16, 16, color.RGBA{uint8(seq * 60), 0, 0, 255}) | ||
M internal/adapters/vulkan/damage_test.go +2 -2
| @@ -54,7 +54,7 @@ func TestRendererDamageMatchesFullRedraw(t *testing.T) { | |||
| 54 | 54 | base := shmContent(t, w, h, w*4, pixels) | |
| 55 | 55 | base.ID = 1 | |
| 56 | 56 | f := base.SHM.File | |
| 57 | - | scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}, Borderless: true}}} | |
| 57 | + | scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}} | |
| 58 | 58 | hist := []ports.SeqDamage{{Seq: 1, Full: true}} | |
| 59 | 59 | c := damagedContent(t, f, *base, 1, pixels, hist) | |
| 60 | 60 | step := func(i int, c ports.SurfaceContent) { | |
| @@ -113,7 +113,7 @@ func TestRendererDamageWindowLeftOutAndBack(t *testing.T) { | |||
| 113 | 113 | c := solidContent(t, 16, 16, color.RGBA{200, 10, 10, 255}) | |
| 114 | 114 | c.ID, c.Seq = 1, 1 | |
| 115 | 115 | contents := map[ports.WindowID]ports.SurfaceContent{1: c} | |
| 116 | - | with := ports.Scene{Seq: 9, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 16, H: 16}, Borderless: true}}} | |
| 116 | + | with := ports.Scene{Seq: 9, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 16, H: 16}}}} | |
| 117 | 117 | without := with | |
| 118 | 118 | without.Windows = nil | |
| 119 | 119 | check := func(s ports.Scene, what string) { | |
| @@ -54,7 +54,7 @@ func TestRendererDamageMatchesFullRedraw(t *testing.T) { | |||||
| 54 | base := shmContent(t, w, h, w*4, pixels) | 54 | base := shmContent(t, w, h, w*4, pixels) | ||
| 55 | base.ID = 1 | 55 | base.ID = 1 | ||
| 56 | f := base.SHM.File | 56 | f := base.SHM.File | ||
| 57 | - | scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}, Borderless: true}}} | 57 | + | scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}} |
| 58 | hist := []ports.SeqDamage{{Seq: 1, Full: true}} | 58 | hist := []ports.SeqDamage{{Seq: 1, Full: true}} | ||
| 59 | c := damagedContent(t, f, *base, 1, pixels, hist) | 59 | c := damagedContent(t, f, *base, 1, pixels, hist) | ||
| 60 | step := func(i int, c ports.SurfaceContent) { | 60 | step := func(i int, c ports.SurfaceContent) { | ||
| @@ -113,7 +113,7 @@ func TestRendererDamageWindowLeftOutAndBack(t *testing.T) { | |||||
| 113 | c := solidContent(t, 16, 16, color.RGBA{200, 10, 10, 255}) | 113 | c := solidContent(t, 16, 16, color.RGBA{200, 10, 10, 255}) | ||
| 114 | c.ID, c.Seq = 1, 1 | 114 | c.ID, c.Seq = 1, 1 | ||
| 115 | contents := map[ports.WindowID]ports.SurfaceContent{1: c} | 115 | contents := map[ports.WindowID]ports.SurfaceContent{1: c} | ||
| 116 | - | with := ports.Scene{Seq: 9, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 16, H: 16}, Borderless: true}}} | 116 | + | with := ports.Scene{Seq: 9, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 16, H: 16}}}} |
| 117 | without := with | 117 | without := with | ||
| 118 | without.Windows = nil | 118 | without.Windows = nil | ||
| 119 | check := func(s ports.Scene, what string) { | 119 | check := func(s ports.Scene, what string) { | ||
M internal/adapters/vulkan/renderer.go +46 -18
| @@ -422,6 +422,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 422 | 422 | } | |
| 423 | 423 | } | |
| 424 | 424 | addLayers(false) | |
| 425 | + | var focused *ports.SceneWindow | |
| 425 | 426 | for _, w := range s.Windows { | |
| 426 | 427 | if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 { | |
| 427 | 428 | continue | |
| @@ -431,11 +432,12 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 431 | 432 | continue | |
| 432 | 433 | } | |
| 433 | 434 | // Content sits inside the border; core sized the client to match. | |
| 434 | - | b := 0 | |
| 435 | - | if !w.Fullscreen && !w.Borderless { | |
| 436 | - | b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2) | |
| 435 | + | b, inset := 0, ports.Sides(0) | |
| 436 | + | if !w.Fullscreen { | |
| 437 | + | b, inset = s.Border.Width, w.Inset | |
| 437 | 438 | } | |
| 438 | - | cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b | |
| 439 | + | c := w.Rect.Inset(inset, b) | |
| 440 | + | cx, cy, cw, ch := c.X, c.Y, c.W, c.H | |
| 439 | 441 | body := physRect(cx, cy, cw, ch) | |
| 440 | 442 | content := contents[w.ID] | |
| 441 | 443 | dmg.window(w.ID, content, physRect(x, y, w.Rect.W, w.Rect.H)) | |
| @@ -445,22 +447,25 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 445 | 447 | add(body, parseColor(s.Background)) | |
| 446 | 448 | place(w.ID, &content, cx, cy, cw, ch) | |
| 447 | 449 | } | |
| 448 | - | borderColor := s.Border.Inactive | |
| 449 | - | if w.Focused { | |
| 450 | - | borderColor = s.Border.Active | |
| 451 | - | } | |
| 452 | - | if b > 0 && borderColor != "" { | |
| 453 | - | rgb := parseColor(borderColor) | |
| 454 | - | outer, inner := physRect(x, y, w.Rect.W, w.Rect.H), physRect(cx, cy, cw, ch) | |
| 455 | - | for _, strip := range []image.Rectangle{ | |
| 456 | - | image.Rect(outer.Min.X, outer.Min.Y, outer.Max.X, inner.Min.Y), | |
| 457 | - | image.Rect(outer.Min.X, inner.Max.Y, outer.Max.X, outer.Max.Y), | |
| 458 | - | image.Rect(outer.Min.X, inner.Min.Y, inner.Min.X, inner.Max.Y), | |
| 459 | - | image.Rect(inner.Max.X, inner.Min.Y, outer.Max.X, inner.Max.Y), | |
| 460 | - | } { | |
| 461 | - | add(strip, rgb) | |
| 450 | + | if s.Border.Inactive != "" { | |
| 451 | + | for _, strip := range borderStrips(w.Rect, inset, 0, b) { | |
| 452 | + | add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Inactive)) | |
| 462 | 453 | } | |
| 463 | 454 | } | |
| 455 | + | if w.Focused && !w.Fullscreen { | |
| 456 | + | focused = &w | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | // The focused window lights the lines along its own sides only: its | |
| 460 | + | // inset border, and its neighbors' border line where they own it. | |
| 461 | + | if focused != nil && s.Border.Active != "" && s.Border.Width > 0 { | |
| 462 | + | f := focused | |
| 463 | + | for _, strip := range borderStrips(f.Rect, f.Inset, 0, s.Border.Width) { | |
| 464 | + | add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active)) | |
| 465 | + | } | |
| 466 | + | for _, strip := range borderStrips(f.Rect, f.Neighbors&^f.Inset, s.Border.Width, s.Border.Width) { | |
| 467 | + | add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active)) | |
| 468 | + | } | |
| 464 | 469 | } | |
| 465 | 470 | // Popups draw only what the client drew, shadows clipped. Window popups | |
| 466 | 471 | // stay with the windows; a layer's go over every layer. | |
| @@ -593,3 +598,26 @@ func (r *Renderer) Close() { | |||
| 593 | 598 | r.instance = 0 | |
| 594 | 599 | } | |
| 595 | 600 | } | |
| 601 | + | ||
| 602 | + | // borderStrips are the b-wide strips along sides of r, in logical pixels: | |
| 603 | + | // inside r when out is 0, else just outside it (a neighbor's border). | |
| 604 | + | func borderStrips(r ports.Rect, sides ports.Sides, out, b int) []ports.Rect { | |
| 605 | + | if b <= 0 { | |
| 606 | + | return nil | |
| 607 | + | } | |
| 608 | + | b = min(b, max(r.W/2, out), max(r.H/2, out)) | |
| 609 | + | var strips []ports.Rect | |
| 610 | + | if sides&ports.SideLeft != 0 { | |
| 611 | + | strips = append(strips, ports.Rect{X: r.X - out, Y: r.Y, W: b, H: r.H}) | |
| 612 | + | } | |
| 613 | + | if sides&ports.SideRight != 0 { | |
| 614 | + | strips = append(strips, ports.Rect{X: r.X + r.W - b + out, Y: r.Y, W: b, H: r.H}) | |
| 615 | + | } | |
| 616 | + | if sides&ports.SideTop != 0 { | |
| 617 | + | strips = append(strips, ports.Rect{X: r.X, Y: r.Y - out, W: r.W, H: b}) | |
| 618 | + | } | |
| 619 | + | if sides&ports.SideBottom != 0 { | |
| 620 | + | strips = append(strips, ports.Rect{X: r.X, Y: r.Y + r.H - b + out, W: r.W, H: b}) | |
| 621 | + | } | |
| 622 | + | return strips | |
| 623 | + | } | |
| @@ -422,6 +422,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 422 | } | 422 | } | ||
| 423 | } | 423 | } | ||
| 424 | addLayers(false) | 424 | addLayers(false) | ||
| 425 | + | var focused *ports.SceneWindow | |||
| 425 | for _, w := range s.Windows { | 426 | for _, w := range s.Windows { | ||
| 426 | if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 { | 427 | if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 { | ||
| 427 | continue | 428 | continue | ||
| @@ -431,11 +432,12 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 431 | continue | 432 | continue | ||
| 432 | } | 433 | } | ||
| 433 | // Content sits inside the border; core sized the client to match. | 434 | // Content sits inside the border; core sized the client to match. | ||
| 434 | - | b := 0 | 435 | + | b, inset := 0, ports.Sides(0) |
| 435 | - | if !w.Fullscreen && !w.Borderless { | 436 | + | if !w.Fullscreen { |
| 436 | - | b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2) | 437 | + | b, inset = s.Border.Width, w.Inset |
| 437 | } | 438 | } | ||
| 438 | - | cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b | 439 | + | c := w.Rect.Inset(inset, b) |
| 440 | + | cx, cy, cw, ch := c.X, c.Y, c.W, c.H | |||
| 439 | body := physRect(cx, cy, cw, ch) | 441 | body := physRect(cx, cy, cw, ch) | ||
| 440 | content := contents[w.ID] | 442 | content := contents[w.ID] | ||
| 441 | dmg.window(w.ID, content, physRect(x, y, w.Rect.W, w.Rect.H)) | 443 | dmg.window(w.ID, content, physRect(x, y, w.Rect.W, w.Rect.H)) | ||
| @@ -445,22 +447,25 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 445 | add(body, parseColor(s.Background)) | 447 | add(body, parseColor(s.Background)) | ||
| 446 | place(w.ID, &content, cx, cy, cw, ch) | 448 | place(w.ID, &content, cx, cy, cw, ch) | ||
| 447 | } | 449 | } | ||
| 448 | - | borderColor := s.Border.Inactive | 450 | + | if s.Border.Inactive != "" { |
| 449 | - | if w.Focused { | 451 | + | for _, strip := range borderStrips(w.Rect, inset, 0, b) { |
| 450 | - | borderColor = s.Border.Active | 452 | + | add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Inactive)) |
| 451 | - | } | |||
| 452 | - | if b > 0 && borderColor != "" { | |||
| 453 | - | rgb := parseColor(borderColor) | |||
| 454 | - | outer, inner := physRect(x, y, w.Rect.W, w.Rect.H), physRect(cx, cy, cw, ch) | |||
| 455 | - | for _, strip := range []image.Rectangle{ | |||
| 456 | - | image.Rect(outer.Min.X, outer.Min.Y, outer.Max.X, inner.Min.Y), | |||
| 457 | - | image.Rect(outer.Min.X, inner.Max.Y, outer.Max.X, outer.Max.Y), | |||
| 458 | - | image.Rect(outer.Min.X, inner.Min.Y, inner.Min.X, inner.Max.Y), | |||
| 459 | - | image.Rect(inner.Max.X, inner.Min.Y, outer.Max.X, inner.Max.Y), | |||
| 460 | - | } { | |||
| 461 | - | add(strip, rgb) | |||
| 462 | } | 453 | } | ||
| 463 | } | 454 | } | ||
| 455 | + | if w.Focused && !w.Fullscreen { | |||
| 456 | + | focused = &w | |||
| 457 | + | } | |||
| 458 | + | } | |||
| 459 | + | // The focused window lights the lines along its own sides only: its | |||
| 460 | + | // inset border, and its neighbors' border line where they own it. | |||
| 461 | + | if focused != nil && s.Border.Active != "" && s.Border.Width > 0 { | |||
| 462 | + | f := focused | |||
| 463 | + | for _, strip := range borderStrips(f.Rect, f.Inset, 0, s.Border.Width) { | |||
| 464 | + | add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active)) | |||
| 465 | + | } | |||
| 466 | + | for _, strip := range borderStrips(f.Rect, f.Neighbors&^f.Inset, s.Border.Width, s.Border.Width) { | |||
| 467 | + | add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active)) | |||
| 468 | + | } | |||
| 464 | } | 469 | } | ||
| 465 | // Popups draw only what the client drew, shadows clipped. Window popups | 470 | // Popups draw only what the client drew, shadows clipped. Window popups | ||
| 466 | // stay with the windows; a layer's go over every layer. | 471 | // stay with the windows; a layer's go over every layer. | ||
| @@ -593,3 +598,26 @@ func (r *Renderer) Close() { | |||||
| 593 | r.instance = 0 | 598 | r.instance = 0 | ||
| 594 | } | 599 | } | ||
| 595 | } | 600 | } | ||
| 601 | + | ||||
| 602 | + | // borderStrips are the b-wide strips along sides of r, in logical pixels: | |||
| 603 | + | // inside r when out is 0, else just outside it (a neighbor's border). | |||
| 604 | + | func borderStrips(r ports.Rect, sides ports.Sides, out, b int) []ports.Rect { | |||
| 605 | + | if b <= 0 { | |||
| 606 | + | return nil | |||
| 607 | + | } | |||
| 608 | + | b = min(b, max(r.W/2, out), max(r.H/2, out)) | |||
| 609 | + | var strips []ports.Rect | |||
| 610 | + | if sides&ports.SideLeft != 0 { | |||
| 611 | + | strips = append(strips, ports.Rect{X: r.X - out, Y: r.Y, W: b, H: r.H}) | |||
| 612 | + | } | |||
| 613 | + | if sides&ports.SideRight != 0 { | |||
| 614 | + | strips = append(strips, ports.Rect{X: r.X + r.W - b + out, Y: r.Y, W: b, H: r.H}) | |||
| 615 | + | } | |||
| 616 | + | if sides&ports.SideTop != 0 { | |||
| 617 | + | strips = append(strips, ports.Rect{X: r.X, Y: r.Y - out, W: r.W, H: b}) | |||
| 618 | + | } | |||
| 619 | + | if sides&ports.SideBottom != 0 { | |||
| 620 | + | strips = append(strips, ports.Rect{X: r.X, Y: r.Y + r.H - b + out, W: r.W, H: b}) | |||
| 621 | + | } | |||
| 622 | + | return strips | |||
| 623 | + | } | |||
M internal/adapters/vulkan/renderer_test.go +18 -5
| @@ -57,13 +57,26 @@ func TestRendererRender(t *testing.T) { | |||
| 57 | 57 | } | |
| 58 | 58 | } | |
| 59 | 59 | } | |
| 60 | - | win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}} | |
| 60 | + | win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}, Inset: ports.SideAll} | |
| 61 | 61 | c := windowColor(1) | |
| 62 | 62 | wc := color.RGBA{c[0], c[1], c[2], 255} | |
| 63 | 63 | s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}} | |
| 64 | 64 | check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: {0x31, 0x32, 0x44, 255}, {10, 10}: wc, {16, 16}: wc, {21, 21}: wc, {22, 21}: {0x31, 0x32, 0x44, 255}, {21, 22}: {0x31, 0x32, 0x44, 255}, {23, 23}: {0x31, 0x32, 0x44, 255}, {24, 24}: bg}) | |
| 65 | 65 | s.Windows[0].Focused = true | |
| 66 | 66 | check(s, map[image.Point]color.RGBA{{9, 9}: {0xb4, 0xbe, 0xfe, 255}, {10, 10}: wc, {16, 16}: wc}) | |
| 67 | + | // tmux style: the left window owns the shared line; focusing the right | |
| 68 | + | // window lights that line too. | |
| 69 | + | gray, lit := color.RGBA{0x31, 0x32, 0x44, 255}, color.RGBA{0xb4, 0xbe, 0xfe, 255} | |
| 70 | + | pair := s | |
| 71 | + | pair.Windows = []ports.SceneWindow{ | |
| 72 | + | {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideRight}, | |
| 73 | + | {ID: 2, Rect: ports.Rect{X: 16, Y: 0, W: 16, H: 16}, Neighbors: ports.SideLeft, Focused: true}, | |
| 74 | + | } | |
| 75 | + | check(pair, map[image.Point]color.RGBA{{14, 5}: lit, {15, 5}: lit, {13, 5}: wc, {0, 0}: wc}) | |
| 76 | + | pair.Windows[1].Focused, pair.Windows[0].Focused = false, true | |
| 77 | + | check(pair, map[image.Point]color.RGBA{{14, 5}: lit}) | |
| 78 | + | pair.Windows[0].Focused = false | |
| 79 | + | check(pair, map[image.Point]color.RGBA{{14, 5}: gray, {15, 5}: gray}) | |
| 67 | 80 | s.Windows[0].Fullscreen = true | |
| 68 | 81 | check(s, map[image.Point]color.RGBA{{9, 9}: wc}) | |
| 69 | 82 | s.Windows[0].Fullscreen = false | |
| @@ -135,7 +148,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) { | |||
| 135 | 148 | t.Errorf("overlap = %v, want %v", got, want) | |
| 136 | 149 | } | |
| 137 | 150 | scene.Border = ports.Border{Width: 4, Active: "#ffffff"} | |
| 138 | - | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}} | |
| 151 | + | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true, Inset: ports.SideAll}} | |
| 139 | 152 | px := make([]byte, 16*16*4) | |
| 140 | 153 | for i := 0; i < len(px); i += 4 { | |
| 141 | 154 | copy(px[i:i+4], []byte{3, 5, 7, 0}) | |
| @@ -214,7 +227,7 @@ func TestRendererScale(t *testing.T) { | |||
| 214 | 227 | red := [3]uint8{200, 0, 0} | |
| 215 | 228 | // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28); | |
| 216 | 229 | // its 20x20 buffer (fractional client) is copied 1:1. | |
| 217 | - | s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}, Borderless: true}}} | |
| 230 | + | s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}}}} | |
| 218 | 231 | scaled := shmContent(t, 20, 20, 80, solid(20, 20, red)) | |
| 219 | 232 | scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10 | |
| 220 | 233 | content := map[ports.WindowID]ports.SurfaceContent{1: *scaled} | |
| @@ -299,7 +312,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) { | |||
| 299 | 312 | defer r.Close() | |
| 300 | 313 | red, green, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}, color.RGBA{0, 0, 255, 255} | |
| 301 | 314 | bg := color.RGBA{16, 32, 48, 255} | |
| 302 | - | scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 10, Y: 10, W: 20, H: 10}, Borderless: true}}} | |
| 315 | + | scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 10, Y: 10, W: 20, H: 10}}}} | |
| 303 | 316 | // A 30×20 surface whose window is the 20×10 at (5, 5): a 5px shadow. | |
| 304 | 317 | root := solidContent(t, 30, 20, red) | |
| 305 | 318 | root.Geometry = ports.Rect{X: 5, Y: 5, W: 20, H: 10} | |
| @@ -396,7 +409,7 @@ func TestRendererAlphaBlend(t *testing.T) { | |||
| 396 | 409 | 4: *shmContent(t, 16, 8, 16*4, fill(16, 8, halfBlue)), | |
| 397 | 410 | } | |
| 398 | 411 | scene := ports.Scene{Background: "#000000", | |
| 399 | - | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}, Borderless: true}}, | |
| 412 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}}}, | |
| 400 | 413 | Layers: []ports.SceneLayer{ | |
| 401 | 414 | {ID: 2, Layer: ports.LayerBottom, Rect: ports.Rect{W: 64, H: 48}}, | |
| 402 | 415 | {ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 16, H: 8}}, | |
| @@ -57,13 +57,26 @@ func TestRendererRender(t *testing.T) { | |||||
| 57 | } | 57 | } | ||
| 58 | } | 58 | } | ||
| 59 | } | 59 | } | ||
| 60 | - | win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}} | 60 | + | win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}, Inset: ports.SideAll} |
| 61 | c := windowColor(1) | 61 | c := windowColor(1) | ||
| 62 | wc := color.RGBA{c[0], c[1], c[2], 255} | 62 | wc := color.RGBA{c[0], c[1], c[2], 255} | ||
| 63 | s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}} | 63 | s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}} | ||
| 64 | check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: {0x31, 0x32, 0x44, 255}, {10, 10}: wc, {16, 16}: wc, {21, 21}: wc, {22, 21}: {0x31, 0x32, 0x44, 255}, {21, 22}: {0x31, 0x32, 0x44, 255}, {23, 23}: {0x31, 0x32, 0x44, 255}, {24, 24}: bg}) | 64 | check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: {0x31, 0x32, 0x44, 255}, {10, 10}: wc, {16, 16}: wc, {21, 21}: wc, {22, 21}: {0x31, 0x32, 0x44, 255}, {21, 22}: {0x31, 0x32, 0x44, 255}, {23, 23}: {0x31, 0x32, 0x44, 255}, {24, 24}: bg}) | ||
| 65 | s.Windows[0].Focused = true | 65 | s.Windows[0].Focused = true | ||
| 66 | check(s, map[image.Point]color.RGBA{{9, 9}: {0xb4, 0xbe, 0xfe, 255}, {10, 10}: wc, {16, 16}: wc}) | 66 | check(s, map[image.Point]color.RGBA{{9, 9}: {0xb4, 0xbe, 0xfe, 255}, {10, 10}: wc, {16, 16}: wc}) | ||
| 67 | + | // tmux style: the left window owns the shared line; focusing the right | |||
| 68 | + | // window lights that line too. | |||
| 69 | + | gray, lit := color.RGBA{0x31, 0x32, 0x44, 255}, color.RGBA{0xb4, 0xbe, 0xfe, 255} | |||
| 70 | + | pair := s | |||
| 71 | + | pair.Windows = []ports.SceneWindow{ | |||
| 72 | + | {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideRight}, | |||
| 73 | + | {ID: 2, Rect: ports.Rect{X: 16, Y: 0, W: 16, H: 16}, Neighbors: ports.SideLeft, Focused: true}, | |||
| 74 | + | } | |||
| 75 | + | check(pair, map[image.Point]color.RGBA{{14, 5}: lit, {15, 5}: lit, {13, 5}: wc, {0, 0}: wc}) | |||
| 76 | + | pair.Windows[1].Focused, pair.Windows[0].Focused = false, true | |||
| 77 | + | check(pair, map[image.Point]color.RGBA{{14, 5}: lit}) | |||
| 78 | + | pair.Windows[0].Focused = false | |||
| 79 | + | check(pair, map[image.Point]color.RGBA{{14, 5}: gray, {15, 5}: gray}) | |||
| 67 | s.Windows[0].Fullscreen = true | 80 | s.Windows[0].Fullscreen = true | ||
| 68 | check(s, map[image.Point]color.RGBA{{9, 9}: wc}) | 81 | check(s, map[image.Point]color.RGBA{{9, 9}: wc}) | ||
| 69 | s.Windows[0].Fullscreen = false | 82 | s.Windows[0].Fullscreen = false | ||
| @@ -135,7 +148,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) { | |||||
| 135 | t.Errorf("overlap = %v, want %v", got, want) | 148 | t.Errorf("overlap = %v, want %v", got, want) | ||
| 136 | } | 149 | } | ||
| 137 | scene.Border = ports.Border{Width: 4, Active: "#ffffff"} | 150 | scene.Border = ports.Border{Width: 4, Active: "#ffffff"} | ||
| 138 | - | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}} | 151 | + | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true, Inset: ports.SideAll}} |
| 139 | px := make([]byte, 16*16*4) | 152 | px := make([]byte, 16*16*4) | ||
| 140 | for i := 0; i < len(px); i += 4 { | 153 | for i := 0; i < len(px); i += 4 { | ||
| 141 | copy(px[i:i+4], []byte{3, 5, 7, 0}) | 154 | copy(px[i:i+4], []byte{3, 5, 7, 0}) | ||
| @@ -214,7 +227,7 @@ func TestRendererScale(t *testing.T) { | |||||
| 214 | red := [3]uint8{200, 0, 0} | 227 | red := [3]uint8{200, 0, 0} | ||
| 215 | // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28); | 228 | // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28); | ||
| 216 | // its 20x20 buffer (fractional client) is copied 1:1. | 229 | // its 20x20 buffer (fractional client) is copied 1:1. | ||
| 217 | - | s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}, Borderless: true}}} | 230 | + | s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}}}} |
| 218 | scaled := shmContent(t, 20, 20, 80, solid(20, 20, red)) | 231 | scaled := shmContent(t, 20, 20, 80, solid(20, 20, red)) | ||
| 219 | scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10 | 232 | scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10 | ||
| 220 | content := map[ports.WindowID]ports.SurfaceContent{1: *scaled} | 233 | content := map[ports.WindowID]ports.SurfaceContent{1: *scaled} | ||
| @@ -299,7 +312,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) { | |||||
| 299 | defer r.Close() | 312 | defer r.Close() | ||
| 300 | red, green, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}, color.RGBA{0, 0, 255, 255} | 313 | red, green, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}, color.RGBA{0, 0, 255, 255} | ||
| 301 | bg := color.RGBA{16, 32, 48, 255} | 314 | bg := color.RGBA{16, 32, 48, 255} | ||
| 302 | - | scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 10, Y: 10, W: 20, H: 10}, Borderless: true}}} | 315 | + | scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 10, Y: 10, W: 20, H: 10}}}} |
| 303 | // A 30×20 surface whose window is the 20×10 at (5, 5): a 5px shadow. | 316 | // A 30×20 surface whose window is the 20×10 at (5, 5): a 5px shadow. | ||
| 304 | root := solidContent(t, 30, 20, red) | 317 | root := solidContent(t, 30, 20, red) | ||
| 305 | root.Geometry = ports.Rect{X: 5, Y: 5, W: 20, H: 10} | 318 | root.Geometry = ports.Rect{X: 5, Y: 5, W: 20, H: 10} | ||
| @@ -396,7 +409,7 @@ func TestRendererAlphaBlend(t *testing.T) { | |||||
| 396 | 4: *shmContent(t, 16, 8, 16*4, fill(16, 8, halfBlue)), | 409 | 4: *shmContent(t, 16, 8, 16*4, fill(16, 8, halfBlue)), | ||
| 397 | } | 410 | } | ||
| 398 | scene := ports.Scene{Background: "#000000", | 411 | scene := ports.Scene{Background: "#000000", | ||
| 399 | - | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}, Borderless: true}}, | 412 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}}}, |
| 400 | Layers: []ports.SceneLayer{ | 413 | Layers: []ports.SceneLayer{ | ||
| 401 | {ID: 2, Layer: ports.LayerBottom, Rect: ports.Rect{W: 64, H: 48}}, | 414 | {ID: 2, Layer: ports.LayerBottom, Rect: ports.Rect{W: 64, H: 48}}, | ||
| 402 | {ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 16, H: 8}}, | 415 | {ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 16, H: 8}}, | ||
M internal/core/core.go +5 -7
| @@ -292,15 +292,13 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | |||
| 292 | 292 | } | |
| 293 | 293 | } | |
| 294 | 294 | ||
| 295 | - | // clientRect is the placement minus the border drawn around the client. | |
| 295 | + | // clientRect is the placement minus the border on its inset sides. | |
| 296 | 296 | // The renderer applies the same inset (ports.Border). | |
| 297 | 297 | func (c *Core) clientRect(p Placement) Rect { | |
| 298 | - | r := p.Rect | |
| 299 | - | if p.Fullscreen || p.Borderless { | |
| 300 | - | return r | |
| 298 | + | if p.Fullscreen { | |
| 299 | + | return p.Rect | |
| 301 | 300 | } | |
| 302 | - | b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2) | |
| 303 | - | return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b} | |
| 301 | + | return p.Rect.Inset(p.Inset, c.cfg.Border.Width) | |
| 304 | 302 | } | |
| 305 | 303 | ||
| 306 | 304 | // visible reports whether the window, layer surface or popup is on screen | |
| @@ -405,7 +403,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 405 | 403 | alive[p.ID] = true | |
| 406 | 404 | // Only the focused output shows the focused border. | |
| 407 | 405 | focused := p.Focused && i == c.focusScreen | |
| 408 | - | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless}) | |
| 406 | + | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Neighbors: p.Neighbors, Inset: p.Inset}) | |
| 409 | 407 | floating := p.Floating | |
| 410 | 408 | if p.Hidden { | |
| 411 | 409 | if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") { | |
| @@ -292,15 +292,13 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | |||||
| 292 | } | 292 | } | ||
| 293 | } | 293 | } | ||
| 294 | 294 | ||||
| 295 | - | // clientRect is the placement minus the border drawn around the client. | 295 | + | // clientRect is the placement minus the border on its inset sides. |
| 296 | // The renderer applies the same inset (ports.Border). | 296 | // The renderer applies the same inset (ports.Border). | ||
| 297 | func (c *Core) clientRect(p Placement) Rect { | 297 | func (c *Core) clientRect(p Placement) Rect { | ||
| 298 | - | r := p.Rect | 298 | + | if p.Fullscreen { |
| 299 | - | if p.Fullscreen || p.Borderless { | 299 | + | return p.Rect |
| 300 | - | return r | |||
| 301 | } | 300 | } | ||
| 302 | - | b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2) | 301 | + | return p.Rect.Inset(p.Inset, c.cfg.Border.Width) |
| 303 | - | return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b} | |||
| 304 | } | 302 | } | ||
| 305 | 303 | ||||
| 306 | // visible reports whether the window, layer surface or popup is on screen | 304 | // visible reports whether the window, layer surface or popup is on screen | ||
| @@ -405,7 +403,7 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 405 | alive[p.ID] = true | 403 | alive[p.ID] = true | ||
| 406 | // Only the focused output shows the focused border. | 404 | // Only the focused output shows the focused border. | ||
| 407 | focused := p.Focused && i == c.focusScreen | 405 | focused := p.Focused && i == c.focusScreen | ||
| 408 | - | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless}) | 406 | + | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Neighbors: p.Neighbors, Inset: p.Inset}) |
| 409 | floating := p.Floating | 407 | floating := p.Floating | ||
| 410 | if p.Hidden { | 408 | if p.Hidden { | ||
| 411 | if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") { | 409 | if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") { | ||
M internal/core/core_test.go +7 -7
| @@ -69,7 +69,7 @@ func TestOwner(t *testing.T) { | |||
| 69 | 69 | client <- ports.WindowMapped{ID: 1} | |
| 70 | 70 | s = scene(t, scenes) | |
| 71 | 71 | // A single column fills the usable width. | |
| 72 | - | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless { | |
| 72 | + | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 { | |
| 73 | 73 | t.Fatal(s) | |
| 74 | 74 | } | |
| 75 | 75 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1"}) { | |
| @@ -301,15 +301,15 @@ func TestBorderInset(t *testing.T) { | |||
| 301 | 301 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) { | |
| 302 | 302 | t.Fatalf("%v for outer %v", v, r) | |
| 303 | 303 | } | |
| 304 | - | // With a second column, the scene keeps the outer rect and the client is | |
| 305 | - | // configured 2px smaller on each side. | |
| 304 | + | // With a second column, the left window owns the shared separator: its | |
| 305 | + | // client is 2px narrower on the right; the right window keeps its rect. | |
| 306 | 306 | client <- ports.WindowMapped{ID: 2} | |
| 307 | 307 | s := scene(t, scenes) | |
| 308 | 308 | r = s.Windows[1].Rect | |
| 309 | - | if s.Windows[1].ID != 2 || s.Windows[1].Borderless { | |
| 309 | + | if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || s.Windows[1].Neighbors != ports.SideLeft { | |
| 310 | 310 | t.Fatal(s) | |
| 311 | 311 | } | |
| 312 | - | want := ports.ConfigureWindow{ID: 2, Width: r.W - 4, Height: r.H - 4, Activated: true, Output: "OUT-1"} | |
| 312 | + | want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1"} | |
| 313 | 313 | found := false | |
| 314 | 314 | for len(commands) > 0 { | |
| 315 | 315 | if v := <-commands; v == want { | |
| @@ -320,7 +320,7 @@ func TestBorderInset(t *testing.T) { | |||
| 320 | 320 | t.Fatalf("no %v for outer %v", want, r) | |
| 321 | 321 | } | |
| 322 | 322 | input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1} | |
| 323 | - | if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 0, Y: 3}) { | |
| 323 | + | if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 2, Y: 5}) { | |
| 324 | 324 | t.Fatal(v) | |
| 325 | 325 | } | |
| 326 | 326 | } | |
| @@ -421,7 +421,7 @@ func TestWorkspaceSwitch(t *testing.T) { | |||
| 421 | 421 | // A new window opens on workspace 2, alone and borderless. | |
| 422 | 422 | client <- ports.WindowMapped{ID: 2} | |
| 423 | 423 | s = scene(t, scenes) | |
| 424 | - | if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || !s.Windows[1].Borderless { | |
| 424 | + | if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 { | |
| 425 | 425 | t.Fatal(s) | |
| 426 | 426 | } | |
| 427 | 427 | } | |
| @@ -69,7 +69,7 @@ func TestOwner(t *testing.T) { | |||||
| 69 | client <- ports.WindowMapped{ID: 1} | 69 | client <- ports.WindowMapped{ID: 1} | ||
| 70 | s = scene(t, scenes) | 70 | s = scene(t, scenes) | ||
| 71 | // A single column fills the usable width. | 71 | // A single column fills the usable width. | ||
| 72 | - | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless { | 72 | + | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 { |
| 73 | t.Fatal(s) | 73 | t.Fatal(s) | ||
| 74 | } | 74 | } | ||
| 75 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1"}) { | 75 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1"}) { | ||
| @@ -301,15 +301,15 @@ func TestBorderInset(t *testing.T) { | |||||
| 301 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) { | 301 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) { | ||
| 302 | t.Fatalf("%v for outer %v", v, r) | 302 | t.Fatalf("%v for outer %v", v, r) | ||
| 303 | } | 303 | } | ||
| 304 | - | // With a second column, the scene keeps the outer rect and the client is | 304 | + | // With a second column, the left window owns the shared separator: its |
| 305 | - | // configured 2px smaller on each side. | 305 | + | // client is 2px narrower on the right; the right window keeps its rect. |
| 306 | client <- ports.WindowMapped{ID: 2} | 306 | client <- ports.WindowMapped{ID: 2} | ||
| 307 | s := scene(t, scenes) | 307 | s := scene(t, scenes) | ||
| 308 | r = s.Windows[1].Rect | 308 | r = s.Windows[1].Rect | ||
| 309 | - | if s.Windows[1].ID != 2 || s.Windows[1].Borderless { | 309 | + | if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || s.Windows[1].Neighbors != ports.SideLeft { |
| 310 | t.Fatal(s) | 310 | t.Fatal(s) | ||
| 311 | } | 311 | } | ||
| 312 | - | want := ports.ConfigureWindow{ID: 2, Width: r.W - 4, Height: r.H - 4, Activated: true, Output: "OUT-1"} | 312 | + | want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1"} |
| 313 | found := false | 313 | found := false | ||
| 314 | for len(commands) > 0 { | 314 | for len(commands) > 0 { | ||
| 315 | if v := <-commands; v == want { | 315 | if v := <-commands; v == want { | ||
| @@ -320,7 +320,7 @@ func TestBorderInset(t *testing.T) { | |||||
| 320 | t.Fatalf("no %v for outer %v", want, r) | 320 | t.Fatalf("no %v for outer %v", want, r) | ||
| 321 | } | 321 | } | ||
| 322 | input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1} | 322 | input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1} | ||
| 323 | - | if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 0, Y: 3}) { | 323 | + | if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 2, Y: 5}) { |
| 324 | t.Fatal(v) | 324 | t.Fatal(v) | ||
| 325 | } | 325 | } | ||
| 326 | } | 326 | } | ||
| @@ -421,7 +421,7 @@ func TestWorkspaceSwitch(t *testing.T) { | |||||
| 421 | // A new window opens on workspace 2, alone and borderless. | 421 | // A new window opens on workspace 2, alone and borderless. | ||
| 422 | client <- ports.WindowMapped{ID: 2} | 422 | client <- ports.WindowMapped{ID: 2} | ||
| 423 | s = scene(t, scenes) | 423 | s = scene(t, scenes) | ||
| 424 | - | if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || !s.Windows[1].Borderless { | 424 | + | if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 { |
| 425 | t.Fatal(s) | 425 | t.Fatal(s) | ||
| 426 | } | 426 | } | ||
| 427 | } | 427 | } | ||
M internal/core/layout.go +42 -5
| @@ -93,9 +93,12 @@ type Placement struct { | |||
| 93 | 93 | Fullscreen, Focused, Hidden bool | |
| 94 | 94 | // Floating windows sit over the columns at their own size. | |
| 95 | 95 | Floating bool | |
| 96 | - | // Borderless is set when the column fills the usable width: it is the only | |
| 97 | - | // column on screen, so no border marks focus. | |
| 98 | - | Borderless bool | |
| 96 | + | // Neighbors are the sides touching another tiled window. Inset are the | |
| 97 | + | // sides where the border takes room from the window: all sides of a | |
| 98 | + | // float; for tiles, the right and bottom neighbors only, so two | |
| 99 | + | // windows share one separator line (tmux style). With gaps, each tile | |
| 100 | + | // insets all its neighbor sides. | |
| 101 | + | Neighbors, Inset ports.Sides | |
| 99 | 102 | } | |
| 100 | 103 | ||
| 101 | 104 | // Overflow says what happens past MaxColumns columns. | |
| @@ -665,13 +668,14 @@ func (w *Workspace) Layout() []Placement { | |||
| 665 | 668 | r = Rect{} | |
| 666 | 669 | } | |
| 667 | 670 | focused := !floatFocused && i == w.Focus && j == c.Focus | |
| 668 | - | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)}) | |
| 671 | + | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden}) | |
| 669 | 672 | y += h + gap | |
| 670 | 673 | } | |
| 671 | 674 | } | |
| 675 | + | setNeighbors(result, gap, w.Output) | |
| 672 | 676 | // Floating windows go last: they are drawn and hit on top. | |
| 673 | 677 | for _, f := range w.Floats { | |
| 674 | - | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID} | |
| 678 | + | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll} | |
| 675 | 679 | // Floats stay above a fullscreen window: a dialog opened from a | |
| 676 | 680 | // fullscreen app must be seen. | |
| 677 | 681 | if w.fullscreen == f.ID { | |
| @@ -682,6 +686,39 @@ func (w *Workspace) Layout() []Placement { | |||
| 682 | 686 | return result | |
| 683 | 687 | } | |
| 684 | 688 | ||
| 689 | + | // setNeighbors marks the sides where tiles touch across the gap. Tiles | |
| 690 | + | // scrolled off the output are no neighbors: no line at the screen edge. | |
| 691 | + | func setNeighbors(tiles []Placement, gap int, out Rect) { | |
| 692 | + | overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 } | |
| 693 | + | for i := range tiles { | |
| 694 | + | a := &tiles[i] | |
| 695 | + | if a.Hidden || a.Fullscreen || a.Rect.W <= 0 || a.Rect.H <= 0 { | |
| 696 | + | continue | |
| 697 | + | } | |
| 698 | + | for j := range tiles { | |
| 699 | + | b := tiles[j].Rect | |
| 700 | + | if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 || b.X >= out.X+out.W || b.X+b.W <= out.X { | |
| 701 | + | continue | |
| 702 | + | } | |
| 703 | + | r := a.Rect | |
| 704 | + | switch { | |
| 705 | + | case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H): | |
| 706 | + | a.Neighbors |= ports.SideRight | |
| 707 | + | case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H): | |
| 708 | + | a.Neighbors |= ports.SideLeft | |
| 709 | + | case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W): | |
| 710 | + | a.Neighbors |= ports.SideBottom | |
| 711 | + | case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W): | |
| 712 | + | a.Neighbors |= ports.SideTop | |
| 713 | + | } | |
| 714 | + | } | |
| 715 | + | a.Inset = a.Neighbors | |
| 716 | + | if gap == 0 { | |
| 717 | + | a.Inset &= ports.SideRight | ports.SideBottom | |
| 718 | + | } | |
| 719 | + | } | |
| 720 | + | } | |
| 721 | + | ||
| 685 | 722 | // floatRect centres a floating window in the usable area, its border | |
| 686 | 723 | // around its client size, clamped to the area. | |
| 687 | 724 | func (w *Workspace) floatRect(f Float) Rect { | |
| @@ -93,9 +93,12 @@ type Placement struct { | |||||
| 93 | Fullscreen, Focused, Hidden bool | 93 | Fullscreen, Focused, Hidden bool | ||
| 94 | // Floating windows sit over the columns at their own size. | 94 | // Floating windows sit over the columns at their own size. | ||
| 95 | Floating bool | 95 | Floating bool | ||
| 96 | - | // Borderless is set when the column fills the usable width: it is the only | 96 | + | // Neighbors are the sides touching another tiled window. Inset are the |
| 97 | - | // column on screen, so no border marks focus. | 97 | + | // sides where the border takes room from the window: all sides of a |
| 98 | - | Borderless bool | 98 | + | // float; for tiles, the right and bottom neighbors only, so two |
| 99 | + | // windows share one separator line (tmux style). With gaps, each tile | |||
| 100 | + | // insets all its neighbor sides. | |||
| 101 | + | Neighbors, Inset ports.Sides | |||
| 99 | } | 102 | } | ||
| 100 | 103 | ||||
| 101 | // Overflow says what happens past MaxColumns columns. | 104 | // Overflow says what happens past MaxColumns columns. | ||
| @@ -665,13 +668,14 @@ func (w *Workspace) Layout() []Placement { | |||||
| 665 | r = Rect{} | 668 | r = Rect{} | ||
| 666 | } | 669 | } | ||
| 667 | focused := !floatFocused && i == w.Focus && j == c.Focus | 670 | focused := !floatFocused && i == w.Focus && j == c.Focus | ||
| 668 | - | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)}) | 671 | + | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden}) |
| 669 | y += h + gap | 672 | y += h + gap | ||
| 670 | } | 673 | } | ||
| 671 | } | 674 | } | ||
| 675 | + | setNeighbors(result, gap, w.Output) | |||
| 672 | // Floating windows go last: they are drawn and hit on top. | 676 | // Floating windows go last: they are drawn and hit on top. | ||
| 673 | for _, f := range w.Floats { | 677 | for _, f := range w.Floats { | ||
| 674 | - | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID} | 678 | + | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll} |
| 675 | // Floats stay above a fullscreen window: a dialog opened from a | 679 | // Floats stay above a fullscreen window: a dialog opened from a | ||
| 676 | // fullscreen app must be seen. | 680 | // fullscreen app must be seen. | ||
| 677 | if w.fullscreen == f.ID { | 681 | if w.fullscreen == f.ID { | ||
| @@ -682,6 +686,39 @@ func (w *Workspace) Layout() []Placement { | |||||
| 682 | return result | 686 | return result | ||
| 683 | } | 687 | } | ||
| 684 | 688 | ||||
| 689 | + | // setNeighbors marks the sides where tiles touch across the gap. Tiles | |||
| 690 | + | // scrolled off the output are no neighbors: no line at the screen edge. | |||
| 691 | + | func setNeighbors(tiles []Placement, gap int, out Rect) { | |||
| 692 | + | overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 } | |||
| 693 | + | for i := range tiles { | |||
| 694 | + | a := &tiles[i] | |||
| 695 | + | if a.Hidden || a.Fullscreen || a.Rect.W <= 0 || a.Rect.H <= 0 { | |||
| 696 | + | continue | |||
| 697 | + | } | |||
| 698 | + | for j := range tiles { | |||
| 699 | + | b := tiles[j].Rect | |||
| 700 | + | if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 || b.X >= out.X+out.W || b.X+b.W <= out.X { | |||
| 701 | + | continue | |||
| 702 | + | } | |||
| 703 | + | r := a.Rect | |||
| 704 | + | switch { | |||
| 705 | + | case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H): | |||
| 706 | + | a.Neighbors |= ports.SideRight | |||
| 707 | + | case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H): | |||
| 708 | + | a.Neighbors |= ports.SideLeft | |||
| 709 | + | case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W): | |||
| 710 | + | a.Neighbors |= ports.SideBottom | |||
| 711 | + | case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W): | |||
| 712 | + | a.Neighbors |= ports.SideTop | |||
| 713 | + | } | |||
| 714 | + | } | |||
| 715 | + | a.Inset = a.Neighbors | |||
| 716 | + | if gap == 0 { | |||
| 717 | + | a.Inset &= ports.SideRight | ports.SideBottom | |||
| 718 | + | } | |||
| 719 | + | } | |||
| 720 | + | } | |||
| 721 | + | ||||
| 685 | // floatRect centres a floating window in the usable area, its border | 722 | // floatRect centres a floating window in the usable area, its border | ||
| 686 | // around its client size, clamped to the area. | 723 | // around its client size, clamped to the area. | ||
| 687 | func (w *Workspace) floatRect(f Float) Rect { | 724 | func (w *Workspace) floatRect(f Float) Rect { | ||
M internal/core/layout_test.go +3 -2
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "github.com/bnema/nefertty/internal/ports" | |
| 4 | 5 | "math" | |
| 5 | 6 | "reflect" | |
| 6 | 7 | "testing" | |
| @@ -84,7 +85,7 @@ func TestStackAndLayout(t *testing.T) { | |||
| 84 | 85 | } | |
| 85 | 86 | w.FocusWindow(-1) | |
| 86 | 87 | p := w.Layout() | |
| 87 | - | if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || !p[0].Borderless { | |
| 88 | + | if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || p[0].Neighbors != ports.SideBottom || p[1].Inset != ports.SideTop { | |
| 88 | 89 | t.Fatal(p) | |
| 89 | 90 | } | |
| 90 | 91 | w.ToggleFullscreen() | |
| @@ -444,7 +445,7 @@ func TestFixedOverflowEdgeCases(t *testing.T) { | |||
| 444 | 445 | if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) { | |
| 445 | 446 | t.Fatal(got[1].Rect, got[2].Rect) | |
| 446 | 447 | } | |
| 447 | - | if got[1].Borderless || got[2].Borderless { | |
| 448 | + | if got[1].Neighbors == 0 || got[2].Neighbors == 0 { | |
| 448 | 449 | t.Fatal("spiral windows must keep borders to show focus") | |
| 449 | 450 | } | |
| 450 | 451 | }) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 4 | "math" | 5 | "math" | ||
| 5 | "reflect" | 6 | "reflect" | ||
| 6 | "testing" | 7 | "testing" | ||
| @@ -84,7 +85,7 @@ func TestStackAndLayout(t *testing.T) { | |||||
| 84 | } | 85 | } | ||
| 85 | w.FocusWindow(-1) | 86 | w.FocusWindow(-1) | ||
| 86 | p := w.Layout() | 87 | p := w.Layout() | ||
| 87 | - | if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || !p[0].Borderless { | 88 | + | if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || p[0].Neighbors != ports.SideBottom || p[1].Inset != ports.SideTop { |
| 88 | t.Fatal(p) | 89 | t.Fatal(p) | ||
| 89 | } | 90 | } | ||
| 90 | w.ToggleFullscreen() | 91 | w.ToggleFullscreen() | ||
| @@ -444,7 +445,7 @@ func TestFixedOverflowEdgeCases(t *testing.T) { | |||||
| 444 | if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) { | 445 | if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) { | ||
| 445 | t.Fatal(got[1].Rect, got[2].Rect) | 446 | t.Fatal(got[1].Rect, got[2].Rect) | ||
| 446 | } | 447 | } | ||
| 447 | - | if got[1].Borderless || got[2].Borderless { | 448 | + | if got[1].Neighbors == 0 || got[2].Neighbors == 0 { |
| 448 | t.Fatal("spiral windows must keep borders to show focus") | 449 | t.Fatal("spiral windows must keep borders to show focus") | ||
| 449 | } | 450 | } | ||
| 450 | }) | 451 | }) | ||
M internal/core/monitor_test.go +4 -3
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "github.com/bnema/nefertty/internal/ports" | |
| 4 | 5 | "reflect" | |
| 5 | 6 | "slices" | |
| 6 | 7 | "testing" | |
| @@ -180,13 +181,13 @@ func TestEqualSharesAndBorderless(t *testing.T) { | |||
| 180 | 181 | m.SetMaxColumns(3) | |
| 181 | 182 | m.AddWindow(1) | |
| 182 | 183 | p := m.Layout() | |
| 183 | - | if p[0].Rect.W != 100 || !p[0].Borderless { | |
| 184 | + | if p[0].Rect.W != 100 || p[0].Neighbors != 0 { | |
| 184 | 185 | t.Fatal(p) | |
| 185 | 186 | } | |
| 186 | 187 | m.AddWindow(2) | |
| 187 | 188 | m.AddWindow(3) | |
| 188 | 189 | p = m.Layout() | |
| 189 | - | if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Borderless { | |
| 190 | + | if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Neighbors != ports.SideRight || p[0].Inset != ports.SideRight { | |
| 190 | 191 | t.Fatal(p) | |
| 191 | 192 | } | |
| 192 | 193 | // A fourth column scrolls: still 1/3 each. | |
| @@ -307,7 +308,7 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) { | |||
| 307 | 308 | m.SetPresets([]Width{{Num: 1, Den: 2}}) | |
| 308 | 309 | m.AddWindow(1) | |
| 309 | 310 | m.Apply(ActionCycleColumnWidth) | |
| 310 | - | if p := m.Layout()[0]; p.Rect.W != 50 || p.Borderless { | |
| 311 | + | if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 { | |
| 311 | 312 | t.Fatal(p) | |
| 312 | 313 | } | |
| 313 | 314 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 4 | "reflect" | 5 | "reflect" | ||
| 5 | "slices" | 6 | "slices" | ||
| 6 | "testing" | 7 | "testing" | ||
| @@ -180,13 +181,13 @@ func TestEqualSharesAndBorderless(t *testing.T) { | |||||
| 180 | m.SetMaxColumns(3) | 181 | m.SetMaxColumns(3) | ||
| 181 | m.AddWindow(1) | 182 | m.AddWindow(1) | ||
| 182 | p := m.Layout() | 183 | p := m.Layout() | ||
| 183 | - | if p[0].Rect.W != 100 || !p[0].Borderless { | 184 | + | if p[0].Rect.W != 100 || p[0].Neighbors != 0 { |
| 184 | t.Fatal(p) | 185 | t.Fatal(p) | ||
| 185 | } | 186 | } | ||
| 186 | m.AddWindow(2) | 187 | m.AddWindow(2) | ||
| 187 | m.AddWindow(3) | 188 | m.AddWindow(3) | ||
| 188 | p = m.Layout() | 189 | p = m.Layout() | ||
| 189 | - | if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Borderless { | 190 | + | if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Neighbors != ports.SideRight || p[0].Inset != ports.SideRight { |
| 190 | t.Fatal(p) | 191 | t.Fatal(p) | ||
| 191 | } | 192 | } | ||
| 192 | // A fourth column scrolls: still 1/3 each. | 193 | // A fourth column scrolls: still 1/3 each. | ||
| @@ -307,7 +308,7 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) { | |||||
| 307 | m.SetPresets([]Width{{Num: 1, Den: 2}}) | 308 | m.SetPresets([]Width{{Num: 1, Den: 2}}) | ||
| 308 | m.AddWindow(1) | 309 | m.AddWindow(1) | ||
| 309 | m.Apply(ActionCycleColumnWidth) | 310 | m.Apply(ActionCycleColumnWidth) | ||
| 310 | - | if p := m.Layout()[0]; p.Rect.W != 50 || p.Borderless { | 311 | + | if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 { |
| 311 | t.Fatal(p) | 312 | t.Fatal(p) | ||
| 312 | } | 313 | } | ||
| 313 | } | 314 | } | ||
M internal/ports/messages.go +6 -3
| @@ -614,7 +614,8 @@ func (s Scene) Shows(id WindowID) bool { | |||
| 614 | 614 | return false | |
| 615 | 615 | } | |
| 616 | 616 | ||
| 617 | - | // Border carries the window border style; colors are #rrggbb, "" skips drawing. | |
| 617 | + | // Border carries the window border style; colors are #rrggbb, "" skips | |
| 618 | + | // drawing. Width is in logical pixels. | |
| 618 | 619 | type Border struct { | |
| 619 | 620 | Width int | |
| 620 | 621 | Active, Inactive string | |
| @@ -760,8 +761,10 @@ type SceneWindow struct { | |||
| 760 | 761 | ID WindowID | |
| 761 | 762 | Rect Rect | |
| 762 | 763 | Focused, Fullscreen, Hidden bool | |
| 763 | - | // Borderless windows fill the usable width alone; no border is drawn. | |
| 764 | - | Borderless bool | |
| 764 | + | // Inset sides carry the border inside Rect. Neighbors are the sides | |
| 765 | + | // touching another tile: the focused window also lights its neighbors' | |
| 766 | + | // border line on the sides it does not inset. | |
| 767 | + | Neighbors, Inset Sides | |
| 765 | 768 | // Popups are drawn from their content only: no border, no background. | |
| 766 | 769 | Popup bool | |
| 767 | 770 | // OverLayers popups hang from a layer surface: drawn over the top and | |
| @@ -614,7 +614,8 @@ func (s Scene) Shows(id WindowID) bool { | |||||
| 614 | return false | 614 | return false | ||
| 615 | } | 615 | } | ||
| 616 | 616 | ||||
| 617 | - | // Border carries the window border style; colors are #rrggbb, "" skips drawing. | 617 | + | // Border carries the window border style; colors are #rrggbb, "" skips |
| 618 | + | // drawing. Width is in logical pixels. | |||
| 618 | type Border struct { | 619 | type Border struct { | ||
| 619 | Width int | 620 | Width int | ||
| 620 | Active, Inactive string | 621 | Active, Inactive string | ||
| @@ -760,8 +761,10 @@ type SceneWindow struct { | |||||
| 760 | ID WindowID | 761 | ID WindowID | ||
| 761 | Rect Rect | 762 | Rect Rect | ||
| 762 | Focused, Fullscreen, Hidden bool | 763 | Focused, Fullscreen, Hidden bool | ||
| 763 | - | // Borderless windows fill the usable width alone; no border is drawn. | 764 | + | // Inset sides carry the border inside Rect. Neighbors are the sides |
| 764 | - | Borderless bool | 765 | + | // touching another tile: the focused window also lights its neighbors' |
| 766 | + | // border line on the sides it does not inset. | |||
| 767 | + | Neighbors, Inset Sides | |||
| 765 | // Popups are drawn from their content only: no border, no background. | 768 | // Popups are drawn from their content only: no border, no background. | ||
| 766 | Popup bool | 769 | Popup bool | ||
| 767 | // OverLayers popups hang from a layer surface: drawn over the top and | 770 | // OverLayers popups hang from a layer surface: drawn over the top and | ||
M internal/ports/types.go +29 -0
| @@ -5,3 +5,32 @@ type WindowID uint64 | |||
| 5 | 5 | ||
| 6 | 6 | // Rect is a pixel rectangle. | |
| 7 | 7 | type Rect struct{ X, Y, W, H int } | |
| 8 | + | ||
| 9 | + | // Sides is a set of window sides. | |
| 10 | + | type Sides uint8 | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | SideLeft Sides = 1 << iota | |
| 14 | + | SideRight | |
| 15 | + | SideTop | |
| 16 | + | SideBottom | |
| 17 | + | SideAll = SideLeft | SideRight | SideTop | SideBottom | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // Inset shrinks r by b on each side in s. | |
| 21 | + | func (r Rect) Inset(s Sides, b int) Rect { | |
| 22 | + | b = min(max(b, 0), r.W/2, r.H/2) | |
| 23 | + | if s&SideLeft != 0 { | |
| 24 | + | r.X, r.W = r.X+b, r.W-b | |
| 25 | + | } | |
| 26 | + | if s&SideRight != 0 { | |
| 27 | + | r.W -= b | |
| 28 | + | } | |
| 29 | + | if s&SideTop != 0 { | |
| 30 | + | r.Y, r.H = r.Y+b, r.H-b | |
| 31 | + | } | |
| 32 | + | if s&SideBottom != 0 { | |
| 33 | + | r.H -= b | |
| 34 | + | } | |
| 35 | + | return r | |
| 36 | + | } | |
| @@ -5,3 +5,32 @@ type WindowID uint64 | |||||
| 5 | 5 | ||||
| 6 | // Rect is a pixel rectangle. | 6 | // Rect is a pixel rectangle. | ||
| 7 | type Rect struct{ X, Y, W, H int } | 7 | type Rect struct{ X, Y, W, H int } | ||
| 8 | + | ||||
| 9 | + | // Sides is a set of window sides. | |||
| 10 | + | type Sides uint8 | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | SideLeft Sides = 1 << iota | |||
| 14 | + | SideRight | |||
| 15 | + | SideTop | |||
| 16 | + | SideBottom | |||
| 17 | + | SideAll = SideLeft | SideRight | SideTop | SideBottom | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // Inset shrinks r by b on each side in s. | |||
| 21 | + | func (r Rect) Inset(s Sides, b int) Rect { | |||
| 22 | + | b = min(max(b, 0), r.W/2, r.H/2) | |||
| 23 | + | if s&SideLeft != 0 { | |||
| 24 | + | r.X, r.W = r.X+b, r.W-b | |||
| 25 | + | } | |||
| 26 | + | if s&SideRight != 0 { | |||
| 27 | + | r.W -= b | |||
| 28 | + | } | |||
| 29 | + | if s&SideTop != 0 { | |||
| 30 | + | r.Y, r.H = r.Y+b, r.H-b | |||
| 31 | + | } | |||
| 32 | + | if s&SideBottom != 0 { | |||
| 33 | + | r.H -= b | |||
| 34 | + | } | |||
| 35 | + | return r | |||
| 36 | + | } | |||