detached f176c04

merge: focus-separators (tmux-style focus separators)

commit
f176c04af207b5ead08203cd9b664cdf617a3159
author
brice · · 2026-09-26 15:25 UTC
merge of
23657a0 + 771e6fd · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +483 -70
M examples/config +6 -4
@@ -22,14 +22,16 @@ terminal = foot
2222
2323 background = #111111
2424
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+# 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.
2628 border.width = 2
27-border.active = #b4befe
28-border.inactive =
29+border.active = #808080
30+border.inactive = #111111
2931
3032 layout.gaps = 0
3133 # 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.
3335 layout.max-columns = 2
3436 # Past max-columns: scroll opens columns to the right; fixed keeps everything
3537 # on screen, each new window splitting the newest one (spiral).
@@ -22,14 +22,16 @@ terminal = foot
22 22
23 background = #11111123 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+# 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.
26 border.width = 228 border.width = 2
27-border.active = #b4befe29+border.active = #808080
28-border.inactive =30+border.inactive = #111111
29 31
30 layout.gaps = 032 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 = 235 layout.max-columns = 2
34 # Past max-columns: scroll opens columns to the right; fixed keeps everything36 # 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 +2 -1
@@ -83,7 +83,8 @@ func Defaults() ports.Config {
8383 c.Xwayland = "xwayland-satellite"
8484 c.Background.Color = "#111111"
8585 c.Border.Width = 2
86- c.Border.Active = "#b4befe"
86+ c.Border.Active = "#808080"
87+ c.Border.Inactive = "#111111"
8788 c.Layout.MaxColumns = 2
8889 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
8990 c.Layout.Overflow = "scroll"
@@ -83,7 +83,8 @@ func Defaults() ports.Config {
83 c.Xwayland = "xwayland-satellite"83 c.Xwayland = "xwayland-satellite"
84 c.Background.Color = "#111111"84 c.Background.Color = "#111111"
85 c.Border.Width = 285 c.Border.Width = 2
86- c.Border.Active = "#b4befe"86+ c.Border.Active = "#808080"
87+ c.Border.Inactive = "#111111"
87 c.Layout.MaxColumns = 288 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 +2 -2
@@ -91,8 +91,8 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
9191 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"
9292 }
9393 }
94- if !pick.Fullscreen && !pick.Borderless && s.Border.Width > 0 {
95- // The overlay shows the buffer alone; its border would go with it.
94+ if !pick.Fullscreen && len(s.Separators) > 0 {
95+ // The overlay shows the buffer alone; the lines would go with it.
9696 return ports.SceneWindow{}, ports.SurfaceContent{}, "border"
9797 }
9898 c := surfaces[pick.ID]
@@ -91,8 +91,8 @@ 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 && len(s.Separators) > 0 {
95- // The overlay shows the buffer alone; its border would go with it.95+ // The overlay shows the buffer alone; the lines would go with it.
96 return ports.SceneWindow{}, ports.SurfaceContent{}, "border"96 return ports.SceneWindow{}, ports.SurfaceContent{}, "border"
97 }97 }
98 c := surfaces[pick.ID]98 c := surfaces[pick.ID]
M internal/adapters/drm/overlay_test.go +1 -1
@@ -119,7 +119,7 @@ func TestOverlayPopupAndBorder(t *testing.T) {
119119 t.Fatalf("popup: %q", reason)
120120 }
121121 s, _ = overlayScene()
122- s.Border.Width = 2
122+ s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}}
123123 if _, _, reason := overlayCandidate(s, c); reason != "border" {
124124 t.Fatalf("border: %q", reason)
125125 }
@@ -119,7 +119,7 @@ func TestOverlayPopupAndBorder(t *testing.T) {
119 t.Fatalf("popup: %q", reason)119 t.Fatalf("popup: %q", reason)
120 }120 }
121 s, _ = overlayScene()121 s, _ = overlayScene()
122- s.Border.Width = 2122+ s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}}
123 if _, _, reason := overlayCandidate(s, c); reason != "border" {123 if _, _, reason := overlayCandidate(s, c); reason != "border" {
124 t.Fatalf("border: %q", reason)124 t.Fatalf("border: %q", reason)
125 }125 }
M internal/adapters/vulkan/compose_test.go +4 -4
@@ -30,7 +30,7 @@ func TestRendererScaledSHMFiltersLinearly(t *testing.T) {
3030 px := []byte{0, 0, 0, 255, 255, 255, 255, 255}
3131 c := shmContent(t, 2, 1, 8, px)
3232 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}}}}
3434 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
3535 t.Fatal(err)
3636 }
@@ -62,7 +62,7 @@ func TestRendererScaledDMABufFiltersLinearly(t *testing.T) {
6262 return [4]byte{255, 255, 255, 255}
6363 })
6464 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}}}}
6666 c := ports.SurfaceContent{ID: 1, Width: 64, Height: 1, LogicalW: 128, LogicalH: 4, DMABuf: buf}
6767 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
6868 t.Fatal(err)
@@ -108,7 +108,7 @@ func TestRendererSHMCopiedOncePerContent(t *testing.T) {
108108 defer r.Close()
109109 c := solidContent(t, 16, 16, color.RGBA{1, 2, 3, 255})
110110 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}}}}
112112 contents := map[ports.WindowID]ports.SurfaceContent{1: c}
113113 render := func() {
114114 t.Helper()
@@ -160,7 +160,7 @@ func TestRendererSHMDoubleBufferWaitsForReaders(t *testing.T) {
160160 if !r.syncFD {
161161 t.Skip("frames are finished on return without sync files")
162162 }
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}}}}
164164 var readers []uint64
165165 for seq := uint64(1); seq <= 3; seq++ {
166166 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, 1632 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, 1110 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 []uint64164 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) {
5454 base := shmContent(t, w, h, w*4, pixels)
5555 base.ID = 1
5656 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}}}}
5858 hist := []ports.SeqDamage{{Seq: 1, Full: true}}
5959 c := damagedContent(t, f, *base, 1, pixels, hist)
6060 step := func(i int, c ports.SurfaceContent) {
@@ -113,7 +113,7 @@ func TestRendererDamageWindowLeftOutAndBack(t *testing.T) {
113113 c := solidContent(t, 16, 16, color.RGBA{200, 10, 10, 255})
114114 c.ID, c.Seq = 1, 1
115115 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}}}}
117117 without := with
118118 without.Windows = nil
119119 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 = 155 base.ID = 1
56 f := base.SHM.File56 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, 1114 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 := with117 without := with
118 without.Windows = nil118 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 +31 -19
@@ -422,7 +422,28 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
422422 }
423423 }
424424 addLayers(false)
425+ // Separators are drawn in order, active lines over inactive ones:
426+ // tile lines (Window 0) over the tiles, a float's border with it.
427+ addSeparators := func(id ports.WindowID) {
428+ for _, sep := range s.Separators {
429+ if sep.Window != id {
430+ continue
431+ }
432+ col := s.Border.Inactive
433+ if sep.Active {
434+ col = s.Border.Active
435+ }
436+ if col != "" {
437+ add(physRect(sep.Rect.X, sep.Rect.Y, sep.Rect.W, sep.Rect.H), parseColor(col))
438+ }
439+ }
440+ }
441+ tileLines := false
425442 for _, w := range s.Windows {
443+ if w.Floating && !tileLines {
444+ addSeparators(0)
445+ tileLines = true
446+ }
426447 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
427448 continue
428449 }
@@ -431,11 +452,12 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
431452 continue
432453 }
433454 // 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)
455+ b, inset := 0, ports.Sides(0)
456+ if !w.Fullscreen {
457+ b, inset = s.Border.Width, w.Inset
437458 }
438- cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b
459+ c := w.Rect.Inset(inset, b)
460+ cx, cy, cw, ch := c.X, c.Y, c.W, c.H
439461 body := physRect(cx, cy, cw, ch)
440462 content := contents[w.ID]
441463 dmg.window(w.ID, content, physRect(x, y, w.Rect.W, w.Rect.H))
@@ -445,23 +467,13 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
445467 add(body, parseColor(s.Background))
446468 place(w.ID, &content, cx, cy, cw, ch)
447469 }
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)
462- }
470+ if w.Floating {
471+ addSeparators(w.ID)
463472 }
464473 }
474+ if !tileLines {
475+ addSeparators(0)
476+ }
465477 // Popups draw only what the client drew, shadows clipped. Window popups
466478 // stay with the windows; a layer's go over every layer.
467479 addPopups := func(overLayers bool) {
@@ -422,7 +422,28 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
422 }422 }
423 }423 }
424 addLayers(false)424 addLayers(false)
425+ // Separators are drawn in order, active lines over inactive ones:
426+ // tile lines (Window 0) over the tiles, a float's border with it.
427+ addSeparators := func(id ports.WindowID) {
428+ for _, sep := range s.Separators {
429+ if sep.Window != id {
430+ continue
431+ }
432+ col := s.Border.Inactive
433+ if sep.Active {
434+ col = s.Border.Active
435+ }
436+ if col != "" {
437+ add(physRect(sep.Rect.X, sep.Rect.Y, sep.Rect.W, sep.Rect.H), parseColor(col))
438+ }
439+ }
440+ }
441+ tileLines := false
425 for _, w := range s.Windows {442 for _, w := range s.Windows {
443+ if w.Floating && !tileLines {
444+ addSeparators(0)
445+ tileLines = true
446+ }
426 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {447 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
427 continue448 continue
428 }449 }
@@ -431,11 +452,12 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
431 continue452 continue
432 }453 }
433 // Content sits inside the border; core sized the client to match.454 // Content sits inside the border; core sized the client to match.
434- b := 0455+ b, inset := 0, ports.Sides(0)
435- if !w.Fullscreen && !w.Borderless {456+ if !w.Fullscreen {
436- b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2)457+ b, inset = s.Border.Width, w.Inset
437 }458 }
438- cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b459+ c := w.Rect.Inset(inset, b)
460+ cx, cy, cw, ch := c.X, c.Y, c.W, c.H
439 body := physRect(cx, cy, cw, ch)461 body := physRect(cx, cy, cw, ch)
440 content := contents[w.ID]462 content := contents[w.ID]
441 dmg.window(w.ID, content, physRect(x, y, w.Rect.W, w.Rect.H))463 dmg.window(w.ID, content, physRect(x, y, w.Rect.W, w.Rect.H))
@@ -445,23 +467,13 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
445 add(body, parseColor(s.Background))467 add(body, parseColor(s.Background))
446 place(w.ID, &content, cx, cy, cw, ch)468 place(w.ID, &content, cx, cy, cw, ch)
447 }469 }
448- borderColor := s.Border.Inactive470+ if w.Floating {
449- if w.Focused {471+ addSeparators(w.ID)
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)
462- }
463 }472 }
464 }473 }
474+ if !tileLines {
475+ addSeparators(0)
476+ }
465 // Popups draw only what the client drew, shadows clipped. Window popups477 // Popups draw only what the client drew, shadows clipped. Window popups
466 // stay with the windows; a layer's go over every layer.478 // stay with the windows; a layer's go over every layer.
467 addPopups := func(overLayers bool) {479 addPopups := func(overLayers bool) {
M internal/adapters/vulkan/renderer_test.go +27 -8
@@ -57,13 +57,31 @@ func TestRendererRender(t *testing.T) {
5757 }
5858 }
5959 }
60- win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}}
60+ // The client sits inside its inset sides; separators are drawn over
61+ // the windows in order, with the border colors.
62+ win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}, Inset: ports.SideAll}
6163 c := windowColor(1)
6264 wc := color.RGBA{c[0], c[1], c[2], 255}
65+ gray, lit := color.RGBA{0x31, 0x32, 0x44, 255}, color.RGBA{0xb4, 0xbe, 0xfe, 255}
6366 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})
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})
67+ check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: bg, {10, 10}: wc, {16, 16}: wc, {21, 21}: wc, {22, 22}: bg, {24, 24}: bg})
68+ s.Separators = []ports.Separator{
69+ {Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 2}},
70+ {Rect: ports.Rect{X: 8, Y: 8, W: 8, H: 2}, Active: true},
71+ }
72+ check(s, map[image.Point]color.RGBA{{9, 9}: lit, {15, 9}: lit, {16, 9}: gray, {23, 9}: gray, {10, 10}: wc})
73+ s.Border.Inactive = ""
74+ check(s, map[image.Point]color.RGBA{{9, 9}: lit, {16, 9}: bg})
75+ // A float covers the tile lines; its own border is drawn with it.
76+ s.Windows = []ports.SceneWindow{win, {ID: 3, Rect: ports.Rect{X: 12, Y: 4, W: 8, H: 8}, Floating: true, Inset: ports.SideAll}}
77+ s.Separators = []ports.Separator{
78+ {Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 2}, Active: true},
79+ {Rect: ports.Rect{X: 12, Y: 4, W: 8, H: 1}, Active: true, Window: 3},
80+ }
81+ fc := windowColor(3)
82+ check(s, map[image.Point]color.RGBA{{9, 9}: lit, {14, 9}: {fc[0], fc[1], fc[2], 255}, {14, 4}: lit})
83+ s.Windows = []ports.SceneWindow{win}
84+ s.Separators, s.Border.Inactive = nil, "#313244"
6785 s.Windows[0].Fullscreen = true
6886 check(s, map[image.Point]color.RGBA{{9, 9}: wc})
6987 s.Windows[0].Fullscreen = false
@@ -135,7 +153,8 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
135153 t.Errorf("overlap = %v, want %v", got, want)
136154 }
137155 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}}
156+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true, Inset: ports.SideAll}}
157+ scene.Separators = []ports.Separator{{Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 4}, Active: true}}
139158 px := make([]byte, 16*16*4)
140159 for i := 0; i < len(px); i += 4 {
141160 copy(px[i:i+4], []byte{3, 5, 7, 0})
@@ -214,7 +233,7 @@ func TestRendererScale(t *testing.T) {
214233 red := [3]uint8{200, 0, 0}
215234 // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28);
216235 // 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}}}
236+ s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}}}}
218237 scaled := shmContent(t, 20, 20, 80, solid(20, 20, red))
219238 scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10
220239 content := map[ports.WindowID]ports.SurfaceContent{1: *scaled}
@@ -299,7 +318,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) {
299318 defer r.Close()
300319 red, green, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}, color.RGBA{0, 0, 255, 255}
301320 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}}}
321+ scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 10, Y: 10, W: 20, H: 10}}}}
303322 // A 30×20 surface whose window is the 20×10 at (5, 5): a 5px shadow.
304323 root := solidContent(t, 30, 20, red)
305324 root.Geometry = ports.Rect{X: 5, Y: 5, W: 20, H: 10}
@@ -396,7 +415,7 @@ func TestRendererAlphaBlend(t *testing.T) {
396415 4: *shmContent(t, 16, 8, 16*4, fill(16, 8, halfBlue)),
397416 }
398417 scene := ports.Scene{Background: "#000000",
399- Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}, Borderless: true}},
418+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}}},
400419 Layers: []ports.SceneLayer{
401420 {ID: 2, Layer: ports.LayerBottom, Rect: ports.Rect{W: 64, H: 48}},
402421 {ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 16, H: 8}},
@@ -57,13 +57,31 @@ 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+ // The client sits inside its inset sides; separators are drawn over
61+ // the windows in order, with the border colors.
62+ win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}, Inset: ports.SideAll}
61 c := windowColor(1)63 c := windowColor(1)
62 wc := color.RGBA{c[0], c[1], c[2], 255}64 wc := color.RGBA{c[0], c[1], c[2], 255}
65+ gray, lit := color.RGBA{0x31, 0x32, 0x44, 255}, color.RGBA{0xb4, 0xbe, 0xfe, 255}
63 s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}}66 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})67+ check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: bg, {10, 10}: wc, {16, 16}: wc, {21, 21}: wc, {22, 22}: bg, {24, 24}: bg})
65- s.Windows[0].Focused = true68+ s.Separators = []ports.Separator{
66- check(s, map[image.Point]color.RGBA{{9, 9}: {0xb4, 0xbe, 0xfe, 255}, {10, 10}: wc, {16, 16}: wc})69+ {Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 2}},
70+ {Rect: ports.Rect{X: 8, Y: 8, W: 8, H: 2}, Active: true},
71+ }
72+ check(s, map[image.Point]color.RGBA{{9, 9}: lit, {15, 9}: lit, {16, 9}: gray, {23, 9}: gray, {10, 10}: wc})
73+ s.Border.Inactive = ""
74+ check(s, map[image.Point]color.RGBA{{9, 9}: lit, {16, 9}: bg})
75+ // A float covers the tile lines; its own border is drawn with it.
76+ s.Windows = []ports.SceneWindow{win, {ID: 3, Rect: ports.Rect{X: 12, Y: 4, W: 8, H: 8}, Floating: true, Inset: ports.SideAll}}
77+ s.Separators = []ports.Separator{
78+ {Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 2}, Active: true},
79+ {Rect: ports.Rect{X: 12, Y: 4, W: 8, H: 1}, Active: true, Window: 3},
80+ }
81+ fc := windowColor(3)
82+ check(s, map[image.Point]color.RGBA{{9, 9}: lit, {14, 9}: {fc[0], fc[1], fc[2], 255}, {14, 4}: lit})
83+ s.Windows = []ports.SceneWindow{win}
84+ s.Separators, s.Border.Inactive = nil, "#313244"
67 s.Windows[0].Fullscreen = true85 s.Windows[0].Fullscreen = true
68 check(s, map[image.Point]color.RGBA{{9, 9}: wc})86 check(s, map[image.Point]color.RGBA{{9, 9}: wc})
69 s.Windows[0].Fullscreen = false87 s.Windows[0].Fullscreen = false
@@ -135,7 +153,8 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
135 t.Errorf("overlap = %v, want %v", got, want)153 t.Errorf("overlap = %v, want %v", got, want)
136 }154 }
137 scene.Border = ports.Border{Width: 4, Active: "#ffffff"}155 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}}156+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true, Inset: ports.SideAll}}
157+ scene.Separators = []ports.Separator{{Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 4}, Active: true}}
139 px := make([]byte, 16*16*4)158 px := make([]byte, 16*16*4)
140 for i := 0; i < len(px); i += 4 {159 for i := 0; i < len(px); i += 4 {
141 copy(px[i:i+4], []byte{3, 5, 7, 0})160 copy(px[i:i+4], []byte{3, 5, 7, 0})
@@ -214,7 +233,7 @@ func TestRendererScale(t *testing.T) {
214 red := [3]uint8{200, 0, 0}233 red := [3]uint8{200, 0, 0}
215 // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28);234 // 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.235 // 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}}}236+ 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))237 scaled := shmContent(t, 20, 20, 80, solid(20, 20, red))
219 scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10238 scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10
220 content := map[ports.WindowID]ports.SurfaceContent{1: *scaled}239 content := map[ports.WindowID]ports.SurfaceContent{1: *scaled}
@@ -299,7 +318,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) {
299 defer r.Close()318 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}319 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}320 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}}}321+ 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.322 // A 30×20 surface whose window is the 20×10 at (5, 5): a 5px shadow.
304 root := solidContent(t, 30, 20, red)323 root := solidContent(t, 30, 20, red)
305 root.Geometry = ports.Rect{X: 5, Y: 5, W: 20, H: 10}324 root.Geometry = ports.Rect{X: 5, Y: 5, W: 20, H: 10}
@@ -396,7 +415,7 @@ func TestRendererAlphaBlend(t *testing.T) {
396 4: *shmContent(t, 16, 8, 16*4, fill(16, 8, halfBlue)),415 4: *shmContent(t, 16, 8, 16*4, fill(16, 8, halfBlue)),
397 }416 }
398 scene := ports.Scene{Background: "#000000",417 scene := ports.Scene{Background: "#000000",
399- Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}, Borderless: true}},418+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}}},
400 Layers: []ports.SceneLayer{419 Layers: []ports.SceneLayer{
401 {ID: 2, Layer: ports.LayerBottom, Rect: ports.Rect{W: 64, H: 48}},420 {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}},421 {ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 16, H: 8}},
M internal/core/core.go +10 -9
@@ -292,15 +292,13 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
292292 }
293293 }
294294
295-// clientRect is the placement minus the border drawn around the client.
295+// clientRect is the placement minus the border on its inset sides.
296296 // The renderer applies the same inset (ports.Border).
297297 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
301300 }
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)
304302 }
305303
306304 // visible reports whether the window, layer surface or popup is on screen
@@ -401,11 +399,14 @@ func (c *Core) publish(ctx context.Context) error {
401399 c.seq++
402400 o := sc.mon.Output()
403401 scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, 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), sc.placed...)}
404- for _, p := range sc.mon.Layout() {
402+ layout := sc.mon.Layout()
403+ // Only the focused output lights the focused window's lines.
404+ scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)
405+ for _, p := range layout {
405406 alive[p.ID] = true
406- // Only the focused output shows the focused border.
407+ // Only the focused output has an activated window.
407408 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})
409+ scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset})
409410 floating := p.Floating
410411 if p.Hidden {
411412 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.Rect298+ 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 screen304 // visible reports whether the window, layer surface or popup is on screen
@@ -401,11 +399,14 @@ func (c *Core) publish(ctx context.Context) error {
401 c.seq++399 c.seq++
402 o := sc.mon.Output()400 o := sc.mon.Output()
403 scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, 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), sc.placed...)}401 scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, 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), sc.placed...)}
404- for _, p := range sc.mon.Layout() {402+ layout := sc.mon.Layout()
403+ // Only the focused output lights the focused window's lines.
404+ scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)
405+ for _, p := range layout {
405 alive[p.ID] = true406 alive[p.ID] = true
406- // Only the focused output shows the focused border.407+ // Only the focused output has an activated window.
407 focused := p.Focused && i == c.focusScreen408 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})409+ scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset})
409 floating := p.Floating410 floating := p.Floating
410 if p.Hidden {411 if p.Hidden {
411 if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") {412 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) {
6969 client <- ports.WindowMapped{ID: 1}
7070 s = scene(t, scenes)
7171 // 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 {
7373 t.Fatal(s)
7474 }
7575 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) {
301301 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) {
302302 t.Fatalf("%v for outer %v", v, r)
303303 }
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.
306306 client <- ports.WindowMapped{ID: 2}
307307 s := scene(t, scenes)
308308 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 || len(s.Separators) == 0 {
310310 t.Fatal(s)
311311 }
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"}
313313 found := false
314314 for len(commands) > 0 {
315315 if v := <-commands; v == want {
@@ -320,7 +320,7 @@ func TestBorderInset(t *testing.T) {
320320 t.Fatalf("no %v for outer %v", want, r)
321321 }
322322 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}) {
324324 t.Fatal(v)
325325 }
326326 }
@@ -421,7 +421,7 @@ func TestWorkspaceSwitch(t *testing.T) {
421421 // A new window opens on workspace 2, alone and borderless.
422422 client <- ports.WindowMapped{ID: 2}
423423 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 {
425425 t.Fatal(s)
426426 }
427427 }
@@ -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 is304+ // 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].Rect308 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 || len(s.Separators) == 0 {
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 := false313 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 {
9393 Fullscreen, Focused, Hidden bool
9494 // Floating windows sit over the columns at their own size.
9595 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
99102 }
100103
101104 // Overflow says what happens past MaxColumns columns.
@@ -665,13 +668,14 @@ func (w *Workspace) Layout() []Placement {
665668 r = Rect{}
666669 }
667670 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})
669672 y += h + gap
670673 }
671674 }
675+ setNeighbors(result, gap)
672676 // Floating windows go last: they are drawn and hit on top.
673677 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}
675679 // Floats stay above a fullscreen window: a dialog opened from a
676680 // fullscreen app must be seen.
677681 if w.fullscreen == f.ID {
@@ -682,6 +686,39 @@ func (w *Workspace) Layout() []Placement {
682686 return result
683687 }
684688
689+// setNeighbors marks the sides where tiles touch across the gap, from
690+// geometry alone: client sizes do not change as the view scrolls.
691+func setNeighbors(tiles []Placement, gap int) {
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 {
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+
685722 // floatRect centres a floating window in the usable area, its border
686723 // around its client size, clamped to the area.
687724 func (w *Workspace) floatRect(f Float) Rect {
@@ -93,9 +93,12 @@ type Placement struct {
93 Fullscreen, Focused, Hidden bool93 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 bool95 Floating bool
96- // Borderless is set when the column fills the usable width: it is the only96+ // 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 bool98+ // 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.Focus670 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 + gap672 y += h + gap
670 }673 }
671 }674 }
675+ setNeighbors(result, gap)
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 a679 // 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 result686 return result
683 }687 }
684 688
689+// setNeighbors marks the sides where tiles touch across the gap, from
690+// geometry alone: client sizes do not change as the view scrolls.
691+func setNeighbors(tiles []Placement, gap int) {
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 {
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 border722 // 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 +4 -2
@@ -4,6 +4,8 @@ import (
44 "math"
55 "reflect"
66 "testing"
7+
8+ "github.com/bnema/nefertty/internal/ports"
79 )
810
911 func workspace() *Workspace {
@@ -84,7 +86,7 @@ func TestStackAndLayout(t *testing.T) {
8486 }
8587 w.FocusWindow(-1)
8688 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 {
89+ 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 {
8890 t.Fatal(p)
8991 }
9092 w.ToggleFullscreen()
@@ -444,7 +446,7 @@ func TestFixedOverflowEdgeCases(t *testing.T) {
444446 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}) {
445447 t.Fatal(got[1].Rect, got[2].Rect)
446448 }
447- if got[1].Borderless || got[2].Borderless {
449+ if got[1].Neighbors == 0 || got[2].Neighbors == 0 {
448450 t.Fatal("spiral windows must keep borders to show focus")
449451 }
450452 })
@@ -4,6 +4,8 @@ import (
4 "math"4 "math"
5 "reflect"5 "reflect"
6 "testing"6 "testing"
7+
8+ "github.com/bnema/nefertty/internal/ports"
7 )9 )
8 10
9 func workspace() *Workspace {11 func workspace() *Workspace {
@@ -84,7 +86,7 @@ func TestStackAndLayout(t *testing.T) {
84 }86 }
85 w.FocusWindow(-1)87 w.FocusWindow(-1)
86 p := w.Layout()88 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 {89+ 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)90 t.Fatal(p)
89 }91 }
90 w.ToggleFullscreen()92 w.ToggleFullscreen()
@@ -444,7 +446,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}) {446 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)447 t.Fatal(got[1].Rect, got[2].Rect)
446 }448 }
447- if got[1].Borderless || got[2].Borderless {449+ if got[1].Neighbors == 0 || got[2].Neighbors == 0 {
448 t.Fatal("spiral windows must keep borders to show focus")450 t.Fatal("spiral windows must keep borders to show focus")
449 }451 }
450 })452 })
M internal/core/monitor_test.go +5 -3
@@ -4,6 +4,8 @@ import (
44 "reflect"
55 "slices"
66 "testing"
7+
8+ "github.com/bnema/nefertty/internal/ports"
79 )
810
911 func monitor() *Monitor {
@@ -180,13 +182,13 @@ func TestEqualSharesAndBorderless(t *testing.T) {
180182 m.SetMaxColumns(3)
181183 m.AddWindow(1)
182184 p := m.Layout()
183- if p[0].Rect.W != 100 || !p[0].Borderless {
185+ if p[0].Rect.W != 100 || p[0].Neighbors != 0 {
184186 t.Fatal(p)
185187 }
186188 m.AddWindow(2)
187189 m.AddWindow(3)
188190 p = m.Layout()
189- if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Borderless {
191+ 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 {
190192 t.Fatal(p)
191193 }
192194 // A fourth column scrolls: still 1/3 each.
@@ -307,7 +309,7 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) {
307309 m.SetPresets([]Width{{Num: 1, Den: 2}})
308310 m.AddWindow(1)
309311 m.Apply(ActionCycleColumnWidth)
310- if p := m.Layout()[0]; p.Rect.W != 50 || p.Borderless {
312+ if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 {
311313 t.Fatal(p)
312314 }
313315 }
@@ -4,6 +4,8 @@ import (
4 "reflect"4 "reflect"
5 "slices"5 "slices"
6 "testing"6 "testing"
7+
8+ "github.com/bnema/nefertty/internal/ports"
7 )9 )
8 10
9 func monitor() *Monitor {11 func monitor() *Monitor {
@@ -180,13 +182,13 @@ func TestEqualSharesAndBorderless(t *testing.T) {
180 m.SetMaxColumns(3)182 m.SetMaxColumns(3)
181 m.AddWindow(1)183 m.AddWindow(1)
182 p := m.Layout()184 p := m.Layout()
183- if p[0].Rect.W != 100 || !p[0].Borderless {185+ if p[0].Rect.W != 100 || p[0].Neighbors != 0 {
184 t.Fatal(p)186 t.Fatal(p)
185 }187 }
186 m.AddWindow(2)188 m.AddWindow(2)
187 m.AddWindow(3)189 m.AddWindow(3)
188 p = m.Layout()190 p = m.Layout()
189- if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Borderless {191+ 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)192 t.Fatal(p)
191 }193 }
192 // A fourth column scrolls: still 1/3 each.194 // A fourth column scrolls: still 1/3 each.
@@ -307,7 +309,7 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) {
307 m.SetPresets([]Width{{Num: 1, Den: 2}})309 m.SetPresets([]Width{{Num: 1, Den: 2}})
308 m.AddWindow(1)310 m.AddWindow(1)
309 m.Apply(ActionCycleColumnWidth)311 m.Apply(ActionCycleColumnWidth)
310- if p := m.Layout()[0]; p.Rect.W != 50 || p.Borderless {312+ if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 {
311 t.Fatal(p)313 t.Fatal(p)
312 }314 }
313 }315 }
A internal/core/separators.go +134 -0
@@ -0,0 +1,134 @@
1+package core
2+
3+import "github.com/bnema/nefertty/internal/ports"
4+
5+// Separator lines follow tmux (screen-redraw.c): tiles share one line per
6+// side where they touch, owned by the left or top tile (Placement.Inset).
7+// The focused tile lights the lines along its sides, corners included.
8+// With exactly two tiles one line splits them, and each lights only its
9+// half of it: the left or top tile the first half. With gaps each tile
10+// has lines of its own and lights them whole. Floats get a full border
11+// of their own, drawn with them (Separator.Window).
12+//
13+// Only what is on the output counts: lines at the output edge (a column
14+// scrolled off beyond it) are not drawn, and only tiles on it count.
15+
16+var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom}
17+
18+// separators are the lines of a layout on output o, inactive ones first
19+// so active ones draw over them. lit says whether the focused tile shows
20+// focus (only on the focused output).
21+func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separator {
22+ if width <= 0 {
23+ return nil
24+ }
25+ var out []ports.Separator
26+ add := func(p *Placement, r Rect, active bool) {
27+ if !p.Floating && (r.X < o.X+width || r.X+r.W > o.X+o.W-width) && r.H > r.W {
28+ return // a vertical line at the output edge
29+ }
30+ var id WindowID
31+ if p.Floating {
32+ id = p.ID
33+ }
34+ out = append(out, ports.Separator{Rect: r, Active: active, Window: id})
35+ }
36+ var focused *Placement
37+ tiles := 0
38+ for i := range ps {
39+ p := &ps[i]
40+ if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 {
41+ continue
42+ }
43+ if p.Neighbors != 0 && p.Rect.X < o.X+o.W && p.Rect.X+p.Rect.W > o.X {
44+ tiles++
45+ }
46+ for _, s := range sides {
47+ if p.Inset&s != 0 {
48+ add(p, strip(p.Rect, s, 0, width), false)
49+ }
50+ }
51+ if p.Focused && lit {
52+ focused = p
53+ }
54+ }
55+ if focused == nil {
56+ return out
57+ }
58+ f := focused
59+ for _, s := range sides {
60+ var line ports.Rect
61+ switch {
62+ case f.Neighbors&s != 0 && f.Inset&s != 0:
63+ line = strip(f.Rect, s, 0, width)
64+ case f.Neighbors&s != 0:
65+ line = strip(f.Rect, s, width, width)
66+ case f.Inset&s != 0:
67+ // A float's own border.
68+ add(f, strip(f.Rect, s, 0, width), true)
69+ continue
70+ default:
71+ continue
72+ }
73+ line = closeCorners(line, *f, s, width)
74+ // Two tiles share one line only without gaps.
75+ if tiles == 2 && gap == 0 {
76+ line = half(line, s)
77+ }
78+ add(f, line, true)
79+ }
80+ return out
81+}
82+
83+// strip is the b-wide strip along side s of r: inside r when out is 0,
84+// else just outside it (the neighbor's line).
85+func strip(r Rect, s ports.Sides, out, b int) Rect {
86+ b = min(b, max(r.W/2, out), max(r.H/2, out))
87+ switch s {
88+ case ports.SideLeft:
89+ return Rect{X: r.X - out, Y: r.Y, W: b, H: r.H}
90+ case ports.SideRight:
91+ return Rect{X: r.X + r.W - b + out, Y: r.Y, W: b, H: r.H}
92+ case ports.SideTop:
93+ return Rect{X: r.X, Y: r.Y - out, W: r.W, H: b}
94+ }
95+ return Rect{X: r.X, Y: r.Y + r.H - b + out, W: r.W, H: b}
96+}
97+
98+// closeCorners runs a line along side s of p b further at each end where
99+// the crossing line lies outside p (a neighbor's), so lit lines meet.
100+func closeCorners(line Rect, p Placement, s ports.Sides, b int) Rect {
101+ outside := func(s ports.Sides) bool { return p.Neighbors&s != 0 && p.Inset&s == 0 }
102+ if s == ports.SideLeft || s == ports.SideRight {
103+ if outside(ports.SideTop) {
104+ line.Y, line.H = line.Y-b, line.H+b
105+ }
106+ if outside(ports.SideBottom) {
107+ line.H += b
108+ }
109+ return line
110+ }
111+ if outside(ports.SideLeft) {
112+ line.X, line.W = line.X-b, line.W+b
113+ }
114+ if outside(ports.SideRight) {
115+ line.W += b
116+ }
117+ return line
118+}
119+
120+// half keeps the half of a line owned by the tile on side s of it: the
121+// first half for the left or top tile, else the second.
122+func half(r Rect, s ports.Sides) Rect {
123+ switch s {
124+ case ports.SideRight:
125+ r.H /= 2
126+ case ports.SideLeft:
127+ r.Y, r.H = r.Y+r.H/2, r.H-r.H/2
128+ case ports.SideBottom:
129+ r.W /= 2
130+ case ports.SideTop:
131+ r.X, r.W = r.X+r.W/2, r.W-r.W/2
132+ }
133+ return r
134+}
@@ -0,0 +1,134 @@
1+package core
2+
3+import "github.com/bnema/nefertty/internal/ports"
4+
5+// Separator lines follow tmux (screen-redraw.c): tiles share one line per
6+// side where they touch, owned by the left or top tile (Placement.Inset).
7+// The focused tile lights the lines along its sides, corners included.
8+// With exactly two tiles one line splits them, and each lights only its
9+// half of it: the left or top tile the first half. With gaps each tile
10+// has lines of its own and lights them whole. Floats get a full border
11+// of their own, drawn with them (Separator.Window).
12+//
13+// Only what is on the output counts: lines at the output edge (a column
14+// scrolled off beyond it) are not drawn, and only tiles on it count.
15+
16+var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom}
17+
18+// separators are the lines of a layout on output o, inactive ones first
19+// so active ones draw over them. lit says whether the focused tile shows
20+// focus (only on the focused output).
21+func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separator {
22+ if width <= 0 {
23+ return nil
24+ }
25+ var out []ports.Separator
26+ add := func(p *Placement, r Rect, active bool) {
27+ if !p.Floating && (r.X < o.X+width || r.X+r.W > o.X+o.W-width) && r.H > r.W {
28+ return // a vertical line at the output edge
29+ }
30+ var id WindowID
31+ if p.Floating {
32+ id = p.ID
33+ }
34+ out = append(out, ports.Separator{Rect: r, Active: active, Window: id})
35+ }
36+ var focused *Placement
37+ tiles := 0
38+ for i := range ps {
39+ p := &ps[i]
40+ if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 {
41+ continue
42+ }
43+ if p.Neighbors != 0 && p.Rect.X < o.X+o.W && p.Rect.X+p.Rect.W > o.X {
44+ tiles++
45+ }
46+ for _, s := range sides {
47+ if p.Inset&s != 0 {
48+ add(p, strip(p.Rect, s, 0, width), false)
49+ }
50+ }
51+ if p.Focused && lit {
52+ focused = p
53+ }
54+ }
55+ if focused == nil {
56+ return out
57+ }
58+ f := focused
59+ for _, s := range sides {
60+ var line ports.Rect
61+ switch {
62+ case f.Neighbors&s != 0 && f.Inset&s != 0:
63+ line = strip(f.Rect, s, 0, width)
64+ case f.Neighbors&s != 0:
65+ line = strip(f.Rect, s, width, width)
66+ case f.Inset&s != 0:
67+ // A float's own border.
68+ add(f, strip(f.Rect, s, 0, width), true)
69+ continue
70+ default:
71+ continue
72+ }
73+ line = closeCorners(line, *f, s, width)
74+ // Two tiles share one line only without gaps.
75+ if tiles == 2 && gap == 0 {
76+ line = half(line, s)
77+ }
78+ add(f, line, true)
79+ }
80+ return out
81+}
82+
83+// strip is the b-wide strip along side s of r: inside r when out is 0,
84+// else just outside it (the neighbor's line).
85+func strip(r Rect, s ports.Sides, out, b int) Rect {
86+ b = min(b, max(r.W/2, out), max(r.H/2, out))
87+ switch s {
88+ case ports.SideLeft:
89+ return Rect{X: r.X - out, Y: r.Y, W: b, H: r.H}
90+ case ports.SideRight:
91+ return Rect{X: r.X + r.W - b + out, Y: r.Y, W: b, H: r.H}
92+ case ports.SideTop:
93+ return Rect{X: r.X, Y: r.Y - out, W: r.W, H: b}
94+ }
95+ return Rect{X: r.X, Y: r.Y + r.H - b + out, W: r.W, H: b}
96+}
97+
98+// closeCorners runs a line along side s of p b further at each end where
99+// the crossing line lies outside p (a neighbor's), so lit lines meet.
100+func closeCorners(line Rect, p Placement, s ports.Sides, b int) Rect {
101+ outside := func(s ports.Sides) bool { return p.Neighbors&s != 0 && p.Inset&s == 0 }
102+ if s == ports.SideLeft || s == ports.SideRight {
103+ if outside(ports.SideTop) {
104+ line.Y, line.H = line.Y-b, line.H+b
105+ }
106+ if outside(ports.SideBottom) {
107+ line.H += b
108+ }
109+ return line
110+ }
111+ if outside(ports.SideLeft) {
112+ line.X, line.W = line.X-b, line.W+b
113+ }
114+ if outside(ports.SideRight) {
115+ line.W += b
116+ }
117+ return line
118+}
119+
120+// half keeps the half of a line owned by the tile on side s of it: the
121+// first half for the left or top tile, else the second.
122+func half(r Rect, s ports.Sides) Rect {
123+ switch s {
124+ case ports.SideRight:
125+ r.H /= 2
126+ case ports.SideLeft:
127+ r.Y, r.H = r.Y+r.H/2, r.H-r.H/2
128+ case ports.SideBottom:
129+ r.W /= 2
130+ case ports.SideTop:
131+ r.X, r.W = r.X+r.W/2, r.W-r.W/2
132+ }
133+ return r
134+}
A internal/core/separators_test.go +157 -0
@@ -0,0 +1,157 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+)
9+
10+// tiles lays out rects edge to edge (no gap) with focus on index f.
11+func tiles(f int, rs ...Rect) []Placement {
12+ ps := make([]Placement, len(rs))
13+ for i, r := range rs {
14+ ps[i] = Placement{ID: WindowID(i + 1), Rect: r, Focused: i == f}
15+ }
16+ setNeighbors(ps, 0)
17+ return ps
18+}
19+
20+// bigOutput is an output wider than every test layout.
21+var bigOutput = Rect{W: 1000, H: 1000}
22+
23+// colorAt is what the renderer shows at (x, y): the last separator over
24+// it wins. "" is no line.
25+func colorAt(seps []ports.Separator, x, y int) string {
26+ got := ""
27+ for _, s := range seps {
28+ r := s.Rect
29+ if x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H {
30+ got = "gray"
31+ if s.Active {
32+ got = "lit"
33+ }
34+ }
35+ }
36+ return got
37+}
38+
39+type px struct {
40+ x, y int
41+ want string
42+}
43+
44+func TestSeparators(t *testing.T) {
45+ cols2 := []Rect{{X: 0, Y: 0, W: 16, H: 16}, {X: 16, Y: 0, W: 16, H: 16}}
46+ cols3 := []Rect{{X: 0, Y: 0, W: 16, H: 16}, {X: 16, Y: 0, W: 16, H: 16}, {X: 32, Y: 0, W: 16, H: 16}}
47+ // A full-height column and a column split in two.
48+ grid := []Rect{{X: 0, Y: 0, W: 16, H: 32}, {X: 16, Y: 0, W: 16, H: 16}, {X: 16, Y: 16, W: 16, H: 16}}
49+ for _, tc := range []struct {
50+ name string
51+ ps []Placement
52+ lit bool
53+ check []px
54+ }{
55+ // The left window owns the line (x 14-15); the client of the right
56+ // window is not covered.
57+ {"two columns, left focused: top half", tiles(0, cols2...), true, []px{{14, 3, "lit"}, {15, 7, "lit"}, {14, 8, "gray"}, {14, 15, "gray"}, {16, 3, ""}, {13, 3, ""}}},
58+ {"two columns, right focused: bottom half", tiles(1, cols2...), true, []px{{14, 3, "gray"}, {14, 12, "lit"}}},
59+ {"unfocused output: all gray", tiles(0, cols2...), false, []px{{14, 3, "gray"}, {14, 12, "gray"}}},
60+ {"three columns, middle: both lines whole", tiles(1, cols3...), true, []px{{14, 3, "lit"}, {14, 12, "lit"}, {30, 3, "lit"}, {30, 12, "lit"}}},
61+ {"three columns, first: its line only", tiles(0, cols3...), true, []px{{14, 3, "lit"}, {14, 12, "lit"}, {30, 3, "gray"}, {30, 12, "gray"}}},
62+ {"no line at the screen edge", tiles(2, cols3...), true, []px{{47, 3, ""}, {0, 3, ""}}},
63+ // The left and top lines meet: the corner square is lit.
64+ {"bottom-right tile: corner closed", tiles(2, grid...), true, []px{{14, 30, "lit"}, {14, 20, "lit"}, {14, 14, "lit"}, {14, 15, "lit"}, {14, 5, "gray"}, {20, 14, "lit"}, {31, 15, "lit"}}},
65+ {"top-right tile", tiles(1, grid...), true, []px{{14, 3, "lit"}, {14, 15, "lit"}, {14, 20, "gray"}, {20, 14, "lit"}}},
66+ {"left column: whole line, split column untouched", tiles(0, grid...), true, []px{{14, 3, "lit"}, {14, 30, "lit"}, {20, 14, "gray"}}},
67+ } {
68+ t.Run(tc.name, func(t *testing.T) {
69+ seps := separators(tc.ps, 2, 0, bigOutput, tc.lit)
70+ for _, c := range tc.check {
71+ if got := colorAt(seps, c.x, c.y); got != c.want {
72+ t.Errorf("(%d,%d) = %q, want %q; %v", c.x, c.y, got, c.want, seps)
73+ }
74+ }
75+ })
76+ }
77+}
78+
79+func TestSeparatorsSkip(t *testing.T) {
80+ if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, 0, bigOutput, true); len(s) != 0 {
81+ t.Fatal("lone window:", s)
82+ }
83+ if s := separators(tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}), 0, 0, bigOutput, true); len(s) != 0 {
84+ t.Fatal("width 0:", s)
85+ }
86+ full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16})
87+ full[0].Fullscreen, full[1].Hidden = true, true
88+ if s := separators(full, 2, 0, bigOutput, true); len(s) != 0 {
89+ t.Fatal("fullscreen:", s)
90+ }
91+}
92+
93+func TestSeparatorsFloat(t *testing.T) {
94+ float := Placement{ID: 1, Rect: Rect{X: 10, Y: 10, W: 20, H: 20}, Floating: true, Focused: true, Inset: ports.SideAll}
95+ seps := separators([]Placement{float}, 2, 0, bigOutput, true)
96+ if seps[0].Window != 1 {
97+ t.Fatal("float border not tied to its window:", seps)
98+ }
99+ for _, c := range [][2]int{{10, 10}, {29, 29}, {20, 11}} {
100+ if got := colorAt(seps, c[0], c[1]); got != "lit" {
101+ t.Errorf("(%d,%d) = %q", c[0], c[1], got)
102+ }
103+ }
104+ if got := colorAt(seps, 20, 20); got != "" {
105+ t.Error("inside:", got)
106+ }
107+}
108+
109+// Through the real layout: scroll mode keeps client sizes as the view
110+// moves, and draws no line at the output edge.
111+func TestSeparatorsScroll(t *testing.T) {
112+ m := monitor() // 100x80, 2 columns on screen
113+ for id := WindowID(1); id <= 3; id++ {
114+ m.AddWindow(id)
115+ }
116+ w := m.Current()
117+ insets := func() map[WindowID]ports.Sides {
118+ got := map[WindowID]ports.Sides{}
119+ for _, p := range w.Layout() {
120+ got[p.ID] = p.Inset
121+ }
122+ return got
123+ }
124+ before := insets()
125+ if w.ViewX == 0 {
126+ t.Fatal("expected a scrolled view")
127+ }
128+ m.Apply(ActionFocusColumnLeft)
129+ m.Apply(ActionFocusColumnLeft)
130+ if after := insets(); !reflect.DeepEqual(before, after) {
131+ t.Fatalf("insets change with the view: %v then %v", before, after)
132+ }
133+ out := Rect{W: 100, H: 80}
134+ seps := separators(w.Layout(), 2, 0, out, true)
135+ if got := colorAt(seps, 99, 40); got != "" {
136+ t.Fatalf("line at the output edge: %v", seps)
137+ }
138+ // Two columns on screen out of three: the half rule applies.
139+ if got := colorAt(seps, 48, 60); got != "gray" {
140+ t.Fatalf("(48,60) = %q; %v", got, seps)
141+ }
142+ if got := colorAt(seps, 48, 10); got != "lit" {
143+ t.Fatalf("(48,10) = %q; %v", got, seps)
144+ }
145+}
146+
147+// With gaps each tile has its own line: the focused one lights it whole.
148+func TestSeparatorsGaps(t *testing.T) {
149+ ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}
150+ setNeighbors(ps, 4)
151+ seps := separators(ps, 2, 4, bigOutput, true)
152+ for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} {
153+ if got := colorAt(seps, c.x, c.y); got != c.want {
154+ t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want)
155+ }
156+ }
157+}
@@ -0,0 +1,157 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+)
9+
10+// tiles lays out rects edge to edge (no gap) with focus on index f.
11+func tiles(f int, rs ...Rect) []Placement {
12+ ps := make([]Placement, len(rs))
13+ for i, r := range rs {
14+ ps[i] = Placement{ID: WindowID(i + 1), Rect: r, Focused: i == f}
15+ }
16+ setNeighbors(ps, 0)
17+ return ps
18+}
19+
20+// bigOutput is an output wider than every test layout.
21+var bigOutput = Rect{W: 1000, H: 1000}
22+
23+// colorAt is what the renderer shows at (x, y): the last separator over
24+// it wins. "" is no line.
25+func colorAt(seps []ports.Separator, x, y int) string {
26+ got := ""
27+ for _, s := range seps {
28+ r := s.Rect
29+ if x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H {
30+ got = "gray"
31+ if s.Active {
32+ got = "lit"
33+ }
34+ }
35+ }
36+ return got
37+}
38+
39+type px struct {
40+ x, y int
41+ want string
42+}
43+
44+func TestSeparators(t *testing.T) {
45+ cols2 := []Rect{{X: 0, Y: 0, W: 16, H: 16}, {X: 16, Y: 0, W: 16, H: 16}}
46+ cols3 := []Rect{{X: 0, Y: 0, W: 16, H: 16}, {X: 16, Y: 0, W: 16, H: 16}, {X: 32, Y: 0, W: 16, H: 16}}
47+ // A full-height column and a column split in two.
48+ grid := []Rect{{X: 0, Y: 0, W: 16, H: 32}, {X: 16, Y: 0, W: 16, H: 16}, {X: 16, Y: 16, W: 16, H: 16}}
49+ for _, tc := range []struct {
50+ name string
51+ ps []Placement
52+ lit bool
53+ check []px
54+ }{
55+ // The left window owns the line (x 14-15); the client of the right
56+ // window is not covered.
57+ {"two columns, left focused: top half", tiles(0, cols2...), true, []px{{14, 3, "lit"}, {15, 7, "lit"}, {14, 8, "gray"}, {14, 15, "gray"}, {16, 3, ""}, {13, 3, ""}}},
58+ {"two columns, right focused: bottom half", tiles(1, cols2...), true, []px{{14, 3, "gray"}, {14, 12, "lit"}}},
59+ {"unfocused output: all gray", tiles(0, cols2...), false, []px{{14, 3, "gray"}, {14, 12, "gray"}}},
60+ {"three columns, middle: both lines whole", tiles(1, cols3...), true, []px{{14, 3, "lit"}, {14, 12, "lit"}, {30, 3, "lit"}, {30, 12, "lit"}}},
61+ {"three columns, first: its line only", tiles(0, cols3...), true, []px{{14, 3, "lit"}, {14, 12, "lit"}, {30, 3, "gray"}, {30, 12, "gray"}}},
62+ {"no line at the screen edge", tiles(2, cols3...), true, []px{{47, 3, ""}, {0, 3, ""}}},
63+ // The left and top lines meet: the corner square is lit.
64+ {"bottom-right tile: corner closed", tiles(2, grid...), true, []px{{14, 30, "lit"}, {14, 20, "lit"}, {14, 14, "lit"}, {14, 15, "lit"}, {14, 5, "gray"}, {20, 14, "lit"}, {31, 15, "lit"}}},
65+ {"top-right tile", tiles(1, grid...), true, []px{{14, 3, "lit"}, {14, 15, "lit"}, {14, 20, "gray"}, {20, 14, "lit"}}},
66+ {"left column: whole line, split column untouched", tiles(0, grid...), true, []px{{14, 3, "lit"}, {14, 30, "lit"}, {20, 14, "gray"}}},
67+ } {
68+ t.Run(tc.name, func(t *testing.T) {
69+ seps := separators(tc.ps, 2, 0, bigOutput, tc.lit)
70+ for _, c := range tc.check {
71+ if got := colorAt(seps, c.x, c.y); got != c.want {
72+ t.Errorf("(%d,%d) = %q, want %q; %v", c.x, c.y, got, c.want, seps)
73+ }
74+ }
75+ })
76+ }
77+}
78+
79+func TestSeparatorsSkip(t *testing.T) {
80+ if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, 0, bigOutput, true); len(s) != 0 {
81+ t.Fatal("lone window:", s)
82+ }
83+ if s := separators(tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}), 0, 0, bigOutput, true); len(s) != 0 {
84+ t.Fatal("width 0:", s)
85+ }
86+ full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16})
87+ full[0].Fullscreen, full[1].Hidden = true, true
88+ if s := separators(full, 2, 0, bigOutput, true); len(s) != 0 {
89+ t.Fatal("fullscreen:", s)
90+ }
91+}
92+
93+func TestSeparatorsFloat(t *testing.T) {
94+ float := Placement{ID: 1, Rect: Rect{X: 10, Y: 10, W: 20, H: 20}, Floating: true, Focused: true, Inset: ports.SideAll}
95+ seps := separators([]Placement{float}, 2, 0, bigOutput, true)
96+ if seps[0].Window != 1 {
97+ t.Fatal("float border not tied to its window:", seps)
98+ }
99+ for _, c := range [][2]int{{10, 10}, {29, 29}, {20, 11}} {
100+ if got := colorAt(seps, c[0], c[1]); got != "lit" {
101+ t.Errorf("(%d,%d) = %q", c[0], c[1], got)
102+ }
103+ }
104+ if got := colorAt(seps, 20, 20); got != "" {
105+ t.Error("inside:", got)
106+ }
107+}
108+
109+// Through the real layout: scroll mode keeps client sizes as the view
110+// moves, and draws no line at the output edge.
111+func TestSeparatorsScroll(t *testing.T) {
112+ m := monitor() // 100x80, 2 columns on screen
113+ for id := WindowID(1); id <= 3; id++ {
114+ m.AddWindow(id)
115+ }
116+ w := m.Current()
117+ insets := func() map[WindowID]ports.Sides {
118+ got := map[WindowID]ports.Sides{}
119+ for _, p := range w.Layout() {
120+ got[p.ID] = p.Inset
121+ }
122+ return got
123+ }
124+ before := insets()
125+ if w.ViewX == 0 {
126+ t.Fatal("expected a scrolled view")
127+ }
128+ m.Apply(ActionFocusColumnLeft)
129+ m.Apply(ActionFocusColumnLeft)
130+ if after := insets(); !reflect.DeepEqual(before, after) {
131+ t.Fatalf("insets change with the view: %v then %v", before, after)
132+ }
133+ out := Rect{W: 100, H: 80}
134+ seps := separators(w.Layout(), 2, 0, out, true)
135+ if got := colorAt(seps, 99, 40); got != "" {
136+ t.Fatalf("line at the output edge: %v", seps)
137+ }
138+ // Two columns on screen out of three: the half rule applies.
139+ if got := colorAt(seps, 48, 60); got != "gray" {
140+ t.Fatalf("(48,60) = %q; %v", got, seps)
141+ }
142+ if got := colorAt(seps, 48, 10); got != "lit" {
143+ t.Fatalf("(48,10) = %q; %v", got, seps)
144+ }
145+}
146+
147+// With gaps each tile has its own line: the focused one lights it whole.
148+func TestSeparatorsGaps(t *testing.T) {
149+ ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}
150+ setNeighbors(ps, 4)
151+ seps := separators(ps, 2, 4, bigOutput, true)
152+ for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} {
153+ if got := colorAt(seps, c.x, c.y); got != c.want {
154+ t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want)
155+ }
156+ }
157+}
M internal/ports/messages.go +20 -3
@@ -591,6 +591,10 @@ type Scene struct {
591591 Background string
592592 Border Border
593593 Windows []SceneWindow
594+ // Separators are the lines between windows, drawn in slice order with
595+ // the Border colors: tile lines over the tiles, under the floats; a
596+ // float's border right after the float.
597+ Separators []Separator
594598 // Layers are drawn in slice order: background and bottom before windows,
595599 // top and overlay after. A fullscreen window covers bottom and top.
596600 // Window popups are drawn after the windows, layer popups
@@ -614,12 +618,22 @@ func (s Scene) Shows(id WindowID) bool {
614618 return false
615619 }
616620
617-// Border carries the window border style; colors are #rrggbb, "" skips drawing.
621+// Border carries the window border style; colors are #rrggbb, "" skips
622+// drawing. Width is in logical pixels.
618623 type Border struct {
619624 Width int
620625 Active, Inactive string
621626 }
622627
628+// Separator is a line between windows, in logical pixels. Active lines
629+// mark the focused window and use Border.Active, others Border.Inactive.
630+// Window is the float whose border it is; 0 for lines between tiles.
631+type Separator struct {
632+ Rect Rect
633+ Active bool
634+ Window WindowID
635+}
636+
623637 // SceneLayer carries core → renderer layer surface placement.
624638 type SceneLayer struct {
625639 ID WindowID
@@ -760,8 +774,11 @@ type SceneWindow struct {
760774 ID WindowID
761775 Rect Rect
762776 Focused, Fullscreen, Hidden bool
763- // Borderless windows fill the usable width alone; no border is drawn.
764- Borderless bool
777+ // Inset sides carry a separator line inside Rect: the client is drawn
778+ // inside it.
779+ Inset Sides
780+ // Floating windows are drawn over the tiles and their lines.
781+ Floating bool
765782 // Popups are drawn from their content only: no border, no background.
766783 Popup bool
767784 // OverLayers popups hang from a layer surface: drawn over the top and
@@ -591,6 +591,10 @@ type Scene struct {
591 Background string591 Background string
592 Border Border592 Border Border
593 Windows []SceneWindow593 Windows []SceneWindow
594+ // Separators are the lines between windows, drawn in slice order with
595+ // the Border colors: tile lines over the tiles, under the floats; a
596+ // float's border right after the float.
597+ Separators []Separator
594 // Layers are drawn in slice order: background and bottom before windows,598 // Layers are drawn in slice order: background and bottom before windows,
595 // top and overlay after. A fullscreen window covers bottom and top.599 // top and overlay after. A fullscreen window covers bottom and top.
596 // Window popups are drawn after the windows, layer popups600 // Window popups are drawn after the windows, layer popups
@@ -614,12 +618,22 @@ func (s Scene) Shows(id WindowID) bool {
614 return false618 return false
615 }619 }
616 620
617-// Border carries the window border style; colors are #rrggbb, "" skips drawing.621+// Border carries the window border style; colors are #rrggbb, "" skips
622+// drawing. Width is in logical pixels.
618 type Border struct {623 type Border struct {
619 Width int624 Width int
620 Active, Inactive string625 Active, Inactive string
621 }626 }
622 627
628+// Separator is a line between windows, in logical pixels. Active lines
629+// mark the focused window and use Border.Active, others Border.Inactive.
630+// Window is the float whose border it is; 0 for lines between tiles.
631+type Separator struct {
632+ Rect Rect
633+ Active bool
634+ Window WindowID
635+}
636+
623 // SceneLayer carries core → renderer layer surface placement.637 // SceneLayer carries core → renderer layer surface placement.
624 type SceneLayer struct {638 type SceneLayer struct {
625 ID WindowID639 ID WindowID
@@ -760,8 +774,11 @@ type SceneWindow struct {
760 ID WindowID774 ID WindowID
761 Rect Rect775 Rect Rect
762 Focused, Fullscreen, Hidden bool776 Focused, Fullscreen, Hidden bool
763- // Borderless windows fill the usable width alone; no border is drawn.777+ // Inset sides carry a separator line inside Rect: the client is drawn
764- Borderless bool778+ // inside it.
779+ Inset Sides
780+ // Floating windows are drawn over the tiles and their lines.
781+ Floating bool
765 // Popups are drawn from their content only: no border, no background.782 // Popups are drawn from their content only: no border, no background.
766 Popup bool783 Popup bool
767 // OverLayers popups hang from a layer surface: drawn over the top and784 // 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
55
66 // Rect is a pixel rectangle.
77 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+}

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 26

Tags 10

↑↓ moveenter openesc close