detached de25548

refactor(core): compute focus separators in core and send them in the scene

commit
de25548355269e17436d40308cda3aa4a8cc92ab
author
brice · · 2026-09-26 15:09 UTC
parent
0e47d2a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +260 -150
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.Inset|pick.Neighbors != 0 && 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.Inset|pick.Neighbors != 0 && 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 -5
@@ -119,11 +119,7 @@ func TestOverlayPopupAndBorder(t *testing.T) {
119119 t.Fatalf("popup: %q", reason)
120120 }
121121 s, _ = overlayScene()
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
122+ s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}}
127123 if _, _, reason := overlayCandidate(s, c); reason != "border" {
128124 t.Fatalf("border: %q", reason)
129125 }
@@ -119,11 +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 != "" {
124- t.Fatalf("lone window: %q", reason)
125- }
126- s.Windows[len(s.Windows)-1].Neighbors = ports.SideLeft
127 if _, _, reason := overlayCandidate(s, c); reason != "border" {123 if _, _, reason := overlayCandidate(s, c); reason != "border" {
128 t.Fatalf("border: %q", reason)124 t.Fatalf("border: %q", reason)
129 }125 }
M internal/adapters/vulkan/renderer.go +7 -99
@@ -422,7 +422,6 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
422422 }
423423 }
424424 addLayers(false)
425- var focused *ports.SceneWindow
426425 for _, w := range s.Windows {
427426 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
428427 continue
@@ -447,45 +446,15 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
447446 add(body, parseColor(s.Background))
448447 place(w.ID, &content, cx, cy, cw, ch)
449448 }
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))
453- }
454- }
455- if w.Focused && !w.Fullscreen {
456- focused = &w
457- }
458449 }
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- // tmux rule (screen-redraw.c): the focused window lights the lines
462- // along its sides, corners included. With exactly two tiles a single
463- // line splits them: each lights only its half of it (left/top window:
464- // first half).
465- if focused != nil && s.Border.Active != "" && s.Border.Width > 0 {
466- f, tiles := focused, 0
467- for _, w := range s.Windows {
468- if !w.Hidden && !w.Popup && w.Neighbors != 0 {
469- tiles++
470- }
471- }
472- // Inset sides with no neighbor (floats) light fully.
473- for _, strip := range borderStrips(f.Rect, f.Inset&^f.Neighbors, 0, s.Border.Width) {
474- add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active))
450+ // Separators are drawn in order: active lines over inactive ones.
451+ for _, sep := range s.Separators {
452+ col := s.Border.Inactive
453+ if sep.Active {
454+ col = s.Border.Active
475455 }
476- for _, side := range []ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom} {
477- if f.Neighbors&side == 0 {
478- continue
479- }
480- out := s.Border.Width
481- if f.Inset&side != 0 {
482- out = 0
483- }
484- line := closeCorners(borderStrips(f.Rect, side, out, s.Border.Width)[0], *f, side, s.Border.Width)
485- if tiles == 2 {
486- line = halfStrip(line, side)
487- }
488- add(physRect(line.X, line.Y, line.W, line.H), parseColor(s.Border.Active))
456+ if col != "" {
457+ add(physRect(sep.Rect.X, sep.Rect.Y, sep.Rect.W, sep.Rect.H), parseColor(col))
489458 }
490459 }
491460 // Popups draw only what the client drew, shadows clipped. Window popups
@@ -619,64 +588,3 @@ func (r *Renderer) Close() {
619588 r.instance = 0
620589 }
621590 }
622-
623-// borderStrips are the b-wide strips along sides of r, in logical pixels:
624-// inside r when out is 0, else just outside it (a neighbor's border).
625-func borderStrips(r ports.Rect, sides ports.Sides, out, b int) []ports.Rect {
626- if b <= 0 {
627- return nil
628- }
629- b = min(b, max(r.W/2, out), max(r.H/2, out))
630- var strips []ports.Rect
631- if sides&ports.SideLeft != 0 {
632- strips = append(strips, ports.Rect{X: r.X - out, Y: r.Y, W: b, H: r.H})
633- }
634- if sides&ports.SideRight != 0 {
635- strips = append(strips, ports.Rect{X: r.X + r.W - b + out, Y: r.Y, W: b, H: r.H})
636- }
637- if sides&ports.SideTop != 0 {
638- strips = append(strips, ports.Rect{X: r.X, Y: r.Y - out, W: r.W, H: b})
639- }
640- if sides&ports.SideBottom != 0 {
641- strips = append(strips, ports.Rect{X: r.X, Y: r.Y + r.H - b + out, W: r.W, H: b})
642- }
643- return strips
644-}
645-
646-// closeCorners runs a line along side of w b further at each end where
647-// the crossing line lies outside w (a neighbor's), so lit lines meet.
648-func closeCorners(line ports.Rect, w ports.SceneWindow, side ports.Sides, b int) ports.Rect {
649- outside := func(s ports.Sides) bool { return w.Neighbors&s != 0 && w.Inset&s == 0 }
650- if side == ports.SideLeft || side == ports.SideRight {
651- if outside(ports.SideTop) {
652- line.Y, line.H = line.Y-b, line.H+b
653- }
654- if outside(ports.SideBottom) {
655- line.H += b
656- }
657- return line
658- }
659- if outside(ports.SideLeft) {
660- line.X, line.W = line.X-b, line.W+b
661- }
662- if outside(ports.SideRight) {
663- line.W += b
664- }
665- return line
666-}
667-
668-// halfStrip keeps the half of a separator strip owned by the window on
669-// side of it: the first half for the left or top window, else the second.
670-func halfStrip(r ports.Rect, side ports.Sides) ports.Rect {
671- switch side {
672- case ports.SideRight:
673- r.H /= 2
674- case ports.SideLeft:
675- r.Y, r.H = r.Y+r.H/2, r.H-r.H/2
676- case ports.SideBottom:
677- r.W /= 2
678- case ports.SideTop:
679- r.X, r.W = r.X+r.W/2, r.W-r.W/2
680- }
681- return r
682-}
@@ -422,7 +422,6 @@ 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
426 for _, w := range s.Windows {425 for _, w := range s.Windows {
427 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {426 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
428 continue427 continue
@@ -447,45 +446,15 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
447 add(body, parseColor(s.Background))446 add(body, parseColor(s.Background))
448 place(w.ID, &content, cx, cy, cw, ch)447 place(w.ID, &content, cx, cy, cw, ch)
449 }448 }
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))
453- }
454- }
455- if w.Focused && !w.Fullscreen {
456- focused = &w
457- }
458 }449 }
459- // The focused window lights the lines along its own sides only: its450+ // Separators are drawn in order: active lines over inactive ones.
460- // inset border, and its neighbors' border line where they own it.451+ for _, sep := range s.Separators {
461- // tmux rule (screen-redraw.c): the focused window lights the lines452+ col := s.Border.Inactive
462- // along its sides, corners included. With exactly two tiles a single453+ if sep.Active {
463- // line splits them: each lights only its half of it (left/top window:454+ col = s.Border.Active
464- // first half).
465- if focused != nil && s.Border.Active != "" && s.Border.Width > 0 {
466- f, tiles := focused, 0
467- for _, w := range s.Windows {
468- if !w.Hidden && !w.Popup && w.Neighbors != 0 {
469- tiles++
470- }
471- }
472- // Inset sides with no neighbor (floats) light fully.
473- for _, strip := range borderStrips(f.Rect, f.Inset&^f.Neighbors, 0, s.Border.Width) {
474- add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active))
475 }455 }
476- for _, side := range []ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom} {456+ if col != "" {
477- if f.Neighbors&side == 0 {457+ add(physRect(sep.Rect.X, sep.Rect.Y, sep.Rect.W, sep.Rect.H), parseColor(col))
478- continue
479- }
480- out := s.Border.Width
481- if f.Inset&side != 0 {
482- out = 0
483- }
484- line := closeCorners(borderStrips(f.Rect, side, out, s.Border.Width)[0], *f, side, s.Border.Width)
485- if tiles == 2 {
486- line = halfStrip(line, side)
487- }
488- add(physRect(line.X, line.Y, line.W, line.H), parseColor(s.Border.Active))
489 }458 }
490 }459 }
491 // Popups draw only what the client drew, shadows clipped. Window popups460 // Popups draw only what the client drew, shadows clipped. Window popups
@@ -619,64 +588,3 @@ func (r *Renderer) Close() {
619 r.instance = 0588 r.instance = 0
620 }589 }
621 }590 }
622-
623-// borderStrips are the b-wide strips along sides of r, in logical pixels:
624-// inside r when out is 0, else just outside it (a neighbor's border).
625-func borderStrips(r ports.Rect, sides ports.Sides, out, b int) []ports.Rect {
626- if b <= 0 {
627- return nil
628- }
629- b = min(b, max(r.W/2, out), max(r.H/2, out))
630- var strips []ports.Rect
631- if sides&ports.SideLeft != 0 {
632- strips = append(strips, ports.Rect{X: r.X - out, Y: r.Y, W: b, H: r.H})
633- }
634- if sides&ports.SideRight != 0 {
635- strips = append(strips, ports.Rect{X: r.X + r.W - b + out, Y: r.Y, W: b, H: r.H})
636- }
637- if sides&ports.SideTop != 0 {
638- strips = append(strips, ports.Rect{X: r.X, Y: r.Y - out, W: r.W, H: b})
639- }
640- if sides&ports.SideBottom != 0 {
641- strips = append(strips, ports.Rect{X: r.X, Y: r.Y + r.H - b + out, W: r.W, H: b})
642- }
643- return strips
644-}
645-
646-// closeCorners runs a line along side of w b further at each end where
647-// the crossing line lies outside w (a neighbor's), so lit lines meet.
648-func closeCorners(line ports.Rect, w ports.SceneWindow, side ports.Sides, b int) ports.Rect {
649- outside := func(s ports.Sides) bool { return w.Neighbors&s != 0 && w.Inset&s == 0 }
650- if side == ports.SideLeft || side == ports.SideRight {
651- if outside(ports.SideTop) {
652- line.Y, line.H = line.Y-b, line.H+b
653- }
654- if outside(ports.SideBottom) {
655- line.H += b
656- }
657- return line
658- }
659- if outside(ports.SideLeft) {
660- line.X, line.W = line.X-b, line.W+b
661- }
662- if outside(ports.SideRight) {
663- line.W += b
664- }
665- return line
666-}
667-
668-// halfStrip keeps the half of a separator strip owned by the window on
669-// side of it: the first half for the left or top window, else the second.
670-func halfStrip(r ports.Rect, side ports.Sides) ports.Rect {
671- switch side {
672- case ports.SideRight:
673- r.H /= 2
674- case ports.SideLeft:
675- r.Y, r.H = r.Y+r.H/2, r.H-r.H/2
676- case ports.SideBottom:
677- r.W /= 2
678- case ports.SideTop:
679- r.X, r.W = r.X+r.W/2, r.W-r.W/2
680- }
681- return r
682-}
M internal/adapters/vulkan/renderer_test.go +13 -37
@@ -57,47 +57,22 @@ func TestRendererRender(t *testing.T) {
5757 }
5858 }
5959 }
60+ // The client sits inside its inset sides; separators are drawn over
61+ // the windows in order, with the border colors.
6062 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}
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})
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- // tmux style: the left window owns the shared line. With two tiles the
68- // focused one lights its half: top for the left window, bottom for the right.
6965 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, 12}: lit, {15, 12}: lit, {14, 3}: gray, {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, 3}: lit, {14, 12}: gray})
78- pair.Windows[0].Focused = false
79- check(pair, map[image.Point]color.RGBA{{14, 5}: gray, {15, 5}: gray})
80- // Three columns (tmux rule): the focused window lights its full lines.
81- three := s
82- three.Windows = []ports.SceneWindow{
83- {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideRight},
84- {ID: 2, Rect: ports.Rect{X: 16, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideLeft | ports.SideRight, Focused: true},
85- {ID: 3, Rect: ports.Rect{X: 32, Y: 0, W: 16, H: 16}, Neighbors: ports.SideLeft},
86- }
87- check(three, map[image.Point]color.RGBA{{14, 3}: lit, {14, 12}: lit, {30, 3}: lit, {30, 12}: lit})
88- three.Windows[1].Focused, three.Windows[0].Focused = false, true
89- check(three, map[image.Point]color.RGBA{{14, 3}: lit, {14, 12}: lit, {30, 3}: gray, {30, 12}: gray})
90- // Bottom-right tile of a split column: its left line and top line are
91- // lit, and they meet in the corner.
92- grid := s
93- grid.Windows = []ports.SceneWindow{
94- {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 16, H: 32}, Inset: ports.SideRight, Neighbors: ports.SideRight},
95- {ID: 2, Rect: ports.Rect{X: 16, Y: 0, W: 16, H: 16}, Inset: ports.SideBottom, Neighbors: ports.SideLeft | ports.SideBottom},
96- {ID: 3, Rect: ports.Rect{X: 16, Y: 16, W: 16, H: 16}, Neighbors: ports.SideLeft | ports.SideTop, Focused: true},
97- }
98- check(grid, map[image.Point]color.RGBA{{14, 30}: lit, {14, 20}: lit, {14, 14}: lit, {14, 5}: gray, {24, 14}: lit, {17, 14}: lit})
99- grid.Windows[2].Focused, grid.Windows[1].Focused = false, true
100- check(grid, map[image.Point]color.RGBA{{14, 3}: lit, {14, 14}: lit, {14, 15}: lit, {14, 20}: gray, {30, 14}: lit})
66+ s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}}
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+ s.Separators, s.Border.Inactive = nil, "#313244"
10176 s.Windows[0].Fullscreen = true
10277 check(s, map[image.Point]color.RGBA{{9, 9}: wc})
10378 s.Windows[0].Fullscreen = false
@@ -170,6 +145,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
170145 }
171146 scene.Border = ports.Border{Width: 4, Active: "#ffffff"}
172147 scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true, Inset: ports.SideAll}}
148+ scene.Separators = []ports.Separator{{Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 4}, Active: true}}
173149 px := make([]byte, 16*16*4)
174150 for i := 0; i < len(px); i += 4 {
175151 copy(px[i:i+4], []byte{3, 5, 7, 0})
@@ -57,47 +57,22 @@ func TestRendererRender(t *testing.T) {
57 }57 }
58 }58 }
59 }59 }
60+ // The client sits inside its inset sides; separators are drawn over
61+ // the windows in order, with the border colors.
60 win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}, Inset: ports.SideAll}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}
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})
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- // tmux style: the left window owns the shared line. With two tiles the
68- // focused one lights its half: top for the left window, bottom for the right.
69 gray, lit := color.RGBA{0x31, 0x32, 0x44, 255}, color.RGBA{0xb4, 0xbe, 0xfe, 255}65 gray, lit := color.RGBA{0x31, 0x32, 0x44, 255}, color.RGBA{0xb4, 0xbe, 0xfe, 255}
70- pair := s66+ s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}}
71- pair.Windows = []ports.SceneWindow{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})
72- {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideRight},68+ s.Separators = []ports.Separator{
73- {ID: 2, Rect: ports.Rect{X: 16, Y: 0, W: 16, H: 16}, Neighbors: ports.SideLeft, Focused: true},69+ {Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 2}},
74- }70+ {Rect: ports.Rect{X: 8, Y: 8, W: 8, H: 2}, Active: true},
75- check(pair, map[image.Point]color.RGBA{{14, 12}: lit, {15, 12}: lit, {14, 3}: gray, {13, 5}: wc, {0, 0}: wc})71+ }
76- pair.Windows[1].Focused, pair.Windows[0].Focused = false, true72+ check(s, map[image.Point]color.RGBA{{9, 9}: lit, {15, 9}: lit, {16, 9}: gray, {23, 9}: gray, {10, 10}: wc})
77- check(pair, map[image.Point]color.RGBA{{14, 3}: lit, {14, 12}: gray})73+ s.Border.Inactive = ""
78- pair.Windows[0].Focused = false74+ check(s, map[image.Point]color.RGBA{{9, 9}: lit, {16, 9}: bg})
79- check(pair, map[image.Point]color.RGBA{{14, 5}: gray, {15, 5}: gray})75+ s.Separators, s.Border.Inactive = nil, "#313244"
80- // Three columns (tmux rule): the focused window lights its full lines.
81- three := s
82- three.Windows = []ports.SceneWindow{
83- {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideRight},
84- {ID: 2, Rect: ports.Rect{X: 16, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideLeft | ports.SideRight, Focused: true},
85- {ID: 3, Rect: ports.Rect{X: 32, Y: 0, W: 16, H: 16}, Neighbors: ports.SideLeft},
86- }
87- check(three, map[image.Point]color.RGBA{{14, 3}: lit, {14, 12}: lit, {30, 3}: lit, {30, 12}: lit})
88- three.Windows[1].Focused, three.Windows[0].Focused = false, true
89- check(three, map[image.Point]color.RGBA{{14, 3}: lit, {14, 12}: lit, {30, 3}: gray, {30, 12}: gray})
90- // Bottom-right tile of a split column: its left line and top line are
91- // lit, and they meet in the corner.
92- grid := s
93- grid.Windows = []ports.SceneWindow{
94- {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 16, H: 32}, Inset: ports.SideRight, Neighbors: ports.SideRight},
95- {ID: 2, Rect: ports.Rect{X: 16, Y: 0, W: 16, H: 16}, Inset: ports.SideBottom, Neighbors: ports.SideLeft | ports.SideBottom},
96- {ID: 3, Rect: ports.Rect{X: 16, Y: 16, W: 16, H: 16}, Neighbors: ports.SideLeft | ports.SideTop, Focused: true},
97- }
98- check(grid, map[image.Point]color.RGBA{{14, 30}: lit, {14, 20}: lit, {14, 14}: lit, {14, 5}: gray, {24, 14}: lit, {17, 14}: lit})
99- grid.Windows[2].Focused, grid.Windows[1].Focused = false, true
100- check(grid, map[image.Point]color.RGBA{{14, 3}: lit, {14, 14}: lit, {14, 15}: lit, {14, 20}: gray, {30, 14}: lit})
101 s.Windows[0].Fullscreen = true76 s.Windows[0].Fullscreen = true
102 check(s, map[image.Point]color.RGBA{{9, 9}: wc})77 check(s, map[image.Point]color.RGBA{{9, 9}: wc})
103 s.Windows[0].Fullscreen = false78 s.Windows[0].Fullscreen = false
@@ -170,6 +145,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
170 }145 }
171 scene.Border = ports.Border{Width: 4, Active: "#ffffff"}146 scene.Border = ports.Border{Width: 4, Active: "#ffffff"}
172 scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true, Inset: ports.SideAll}}147 scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true, Inset: ports.SideAll}}
148+ scene.Separators = []ports.Separator{{Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 4}, Active: true}}
173 px := make([]byte, 16*16*4)149 px := make([]byte, 16*16*4)
174 for i := 0; i < len(px); i += 4 {150 for i := 0; i < len(px); i += 4 {
175 copy(px[i:i+4], []byte{3, 5, 7, 0})151 copy(px[i:i+4], []byte{3, 5, 7, 0})
M internal/core/core.go +5 -2
@@ -399,11 +399,14 @@ func (c *Core) publish(ctx context.Context) error {
399399 c.seq++
400400 o := sc.mon.Output()
401401 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...)}
402- 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, i == c.focusScreen)
405+ for _, p := range layout {
403406 alive[p.ID] = true
404407 // Only the focused output shows the focused border.
405408 focused := p.Focused && i == c.focusScreen
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+ scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Inset: p.Inset})
407410 floating := p.Floating
408411 if p.Hidden {
409412 if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") {
@@ -399,11 +399,14 @@ func (c *Core) publish(ctx context.Context) error {
399 c.seq++399 c.seq++
400 o := sc.mon.Output()400 o := sc.mon.Output()
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...)}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...)}
402- 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, i == c.focusScreen)
405+ for _, p := range layout {
403 alive[p.ID] = true406 alive[p.ID] = true
404 // Only the focused output shows the focused border.407 // Only the focused output shows the focused border.
405 focused := p.Focused && i == c.focusScreen408 focused := p.Focused && i == c.focusScreen
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+ scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Inset: p.Inset})
407 floating := p.Floating410 floating := p.Floating
408 if p.Hidden {411 if p.Hidden {
409 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 +1 -1
@@ -306,7 +306,7 @@ func TestBorderInset(t *testing.T) {
306306 client <- ports.WindowMapped{ID: 2}
307307 s := scene(t, scenes)
308308 r = s.Windows[1].Rect
309- if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || s.Windows[1].Neighbors != ports.SideLeft {
309+ if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 {
310310 t.Fatal(s)
311311 }
312312 want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1"}
@@ -306,7 +306,7 @@ func TestBorderInset(t *testing.T) {
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[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || s.Windows[1].Neighbors != ports.SideLeft {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: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1"}312 want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1"}
A internal/core/separators.go +119 -0
@@ -0,0 +1,119 @@
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. Floats get a full
10+// border of their own.
11+
12+var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom}
13+
14+// separators are the lines of a layout, inactive ones first so active
15+// ones draw over them. lit says whether the focused tile shows focus
16+// (only on the focused output).
17+func separators(ps []Placement, width int, lit bool) []ports.Separator {
18+ if width <= 0 {
19+ return nil
20+ }
21+ var out []ports.Separator
22+ var focused *Placement
23+ tiles := 0
24+ for i := range ps {
25+ p := &ps[i]
26+ if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 {
27+ continue
28+ }
29+ if p.Neighbors != 0 {
30+ tiles++
31+ }
32+ for _, s := range sides {
33+ if p.Inset&s != 0 {
34+ out = append(out, ports.Separator{Rect: strip(p.Rect, s, 0, width)})
35+ }
36+ }
37+ if p.Focused && lit {
38+ focused = p
39+ }
40+ }
41+ if focused == nil {
42+ return out
43+ }
44+ f := focused
45+ for _, s := range sides {
46+ var line ports.Rect
47+ switch {
48+ case f.Neighbors&s != 0 && f.Inset&s != 0:
49+ line = strip(f.Rect, s, 0, width)
50+ case f.Neighbors&s != 0:
51+ line = strip(f.Rect, s, width, width)
52+ case f.Inset&s != 0:
53+ // A float's own border.
54+ out = append(out, ports.Separator{Rect: strip(f.Rect, s, 0, width), Active: true})
55+ continue
56+ default:
57+ continue
58+ }
59+ line = closeCorners(line, *f, s, width)
60+ if tiles == 2 {
61+ line = half(line, s)
62+ }
63+ out = append(out, ports.Separator{Rect: line, Active: true})
64+ }
65+ return out
66+}
67+
68+// strip is the b-wide strip along side s of r: inside r when out is 0,
69+// else just outside it (the neighbor's line).
70+func strip(r Rect, s ports.Sides, out, b int) Rect {
71+ b = min(b, max(r.W/2, out), max(r.H/2, out))
72+ switch s {
73+ case ports.SideLeft:
74+ return Rect{X: r.X - out, Y: r.Y, W: b, H: r.H}
75+ case ports.SideRight:
76+ return Rect{X: r.X + r.W - b + out, Y: r.Y, W: b, H: r.H}
77+ case ports.SideTop:
78+ return Rect{X: r.X, Y: r.Y - out, W: r.W, H: b}
79+ }
80+ return Rect{X: r.X, Y: r.Y + r.H - b + out, W: r.W, H: b}
81+}
82+
83+// closeCorners runs a line along side s of p b further at each end where
84+// the crossing line lies outside p (a neighbor's), so lit lines meet.
85+func closeCorners(line Rect, p Placement, s ports.Sides, b int) Rect {
86+ outside := func(s ports.Sides) bool { return p.Neighbors&s != 0 && p.Inset&s == 0 }
87+ if s == ports.SideLeft || s == ports.SideRight {
88+ if outside(ports.SideTop) {
89+ line.Y, line.H = line.Y-b, line.H+b
90+ }
91+ if outside(ports.SideBottom) {
92+ line.H += b
93+ }
94+ return line
95+ }
96+ if outside(ports.SideLeft) {
97+ line.X, line.W = line.X-b, line.W+b
98+ }
99+ if outside(ports.SideRight) {
100+ line.W += b
101+ }
102+ return line
103+}
104+
105+// half keeps the half of a line owned by the tile on side s of it: the
106+// first half for the left or top tile, else the second.
107+func half(r Rect, s ports.Sides) Rect {
108+ switch s {
109+ case ports.SideRight:
110+ r.H /= 2
111+ case ports.SideLeft:
112+ r.Y, r.H = r.Y+r.H/2, r.H-r.H/2
113+ case ports.SideBottom:
114+ r.W /= 2
115+ case ports.SideTop:
116+ r.X, r.W = r.X+r.W/2, r.W-r.W/2
117+ }
118+ return r
119+}
@@ -0,0 +1,119 @@
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. Floats get a full
10+// border of their own.
11+
12+var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom}
13+
14+// separators are the lines of a layout, inactive ones first so active
15+// ones draw over them. lit says whether the focused tile shows focus
16+// (only on the focused output).
17+func separators(ps []Placement, width int, lit bool) []ports.Separator {
18+ if width <= 0 {
19+ return nil
20+ }
21+ var out []ports.Separator
22+ var focused *Placement
23+ tiles := 0
24+ for i := range ps {
25+ p := &ps[i]
26+ if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 {
27+ continue
28+ }
29+ if p.Neighbors != 0 {
30+ tiles++
31+ }
32+ for _, s := range sides {
33+ if p.Inset&s != 0 {
34+ out = append(out, ports.Separator{Rect: strip(p.Rect, s, 0, width)})
35+ }
36+ }
37+ if p.Focused && lit {
38+ focused = p
39+ }
40+ }
41+ if focused == nil {
42+ return out
43+ }
44+ f := focused
45+ for _, s := range sides {
46+ var line ports.Rect
47+ switch {
48+ case f.Neighbors&s != 0 && f.Inset&s != 0:
49+ line = strip(f.Rect, s, 0, width)
50+ case f.Neighbors&s != 0:
51+ line = strip(f.Rect, s, width, width)
52+ case f.Inset&s != 0:
53+ // A float's own border.
54+ out = append(out, ports.Separator{Rect: strip(f.Rect, s, 0, width), Active: true})
55+ continue
56+ default:
57+ continue
58+ }
59+ line = closeCorners(line, *f, s, width)
60+ if tiles == 2 {
61+ line = half(line, s)
62+ }
63+ out = append(out, ports.Separator{Rect: line, Active: true})
64+ }
65+ return out
66+}
67+
68+// strip is the b-wide strip along side s of r: inside r when out is 0,
69+// else just outside it (the neighbor's line).
70+func strip(r Rect, s ports.Sides, out, b int) Rect {
71+ b = min(b, max(r.W/2, out), max(r.H/2, out))
72+ switch s {
73+ case ports.SideLeft:
74+ return Rect{X: r.X - out, Y: r.Y, W: b, H: r.H}
75+ case ports.SideRight:
76+ return Rect{X: r.X + r.W - b + out, Y: r.Y, W: b, H: r.H}
77+ case ports.SideTop:
78+ return Rect{X: r.X, Y: r.Y - out, W: r.W, H: b}
79+ }
80+ return Rect{X: r.X, Y: r.Y + r.H - b + out, W: r.W, H: b}
81+}
82+
83+// closeCorners runs a line along side s of p b further at each end where
84+// the crossing line lies outside p (a neighbor's), so lit lines meet.
85+func closeCorners(line Rect, p Placement, s ports.Sides, b int) Rect {
86+ outside := func(s ports.Sides) bool { return p.Neighbors&s != 0 && p.Inset&s == 0 }
87+ if s == ports.SideLeft || s == ports.SideRight {
88+ if outside(ports.SideTop) {
89+ line.Y, line.H = line.Y-b, line.H+b
90+ }
91+ if outside(ports.SideBottom) {
92+ line.H += b
93+ }
94+ return line
95+ }
96+ if outside(ports.SideLeft) {
97+ line.X, line.W = line.X-b, line.W+b
98+ }
99+ if outside(ports.SideRight) {
100+ line.W += b
101+ }
102+ return line
103+}
104+
105+// half keeps the half of a line owned by the tile on side s of it: the
106+// first half for the left or top tile, else the second.
107+func half(r Rect, s ports.Sides) Rect {
108+ switch s {
109+ case ports.SideRight:
110+ r.H /= 2
111+ case ports.SideLeft:
112+ r.Y, r.H = r.Y+r.H/2, r.H-r.H/2
113+ case ports.SideBottom:
114+ r.W /= 2
115+ case ports.SideTop:
116+ r.X, r.W = r.X+r.W/2, r.W-r.W/2
117+ }
118+ return r
119+}
A internal/core/separators_test.go +99 -0
@@ -0,0 +1,99 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+// tiles lays out rects edge to edge (no gap) with focus on index f.
10+func tiles(f int, rs ...Rect) []Placement {
11+ ps := make([]Placement, len(rs))
12+ for i, r := range rs {
13+ ps[i] = Placement{ID: WindowID(i + 1), Rect: r, Focused: i == f}
14+ }
15+ setNeighbors(ps, 0, Rect{W: 1000, H: 1000})
16+ return ps
17+}
18+
19+// colorAt is what the renderer shows at (x, y): the last separator over
20+// it wins. "" is no line.
21+func colorAt(seps []ports.Separator, x, y int) string {
22+ got := ""
23+ for _, s := range seps {
24+ r := s.Rect
25+ if x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H {
26+ got = "gray"
27+ if s.Active {
28+ got = "lit"
29+ }
30+ }
31+ }
32+ return got
33+}
34+
35+func TestSeparators(t *testing.T) {
36+ type px struct {
37+ x, y int
38+ want string
39+ }
40+ cols2 := []Rect{{X: 0, Y: 0, W: 16, H: 16}, {X: 16, Y: 0, W: 16, H: 16}}
41+ 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}}
42+ // A full-height column and a column split in two.
43+ 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}}
44+ for _, tc := range []struct {
45+ name string
46+ ps []Placement
47+ lit bool
48+ check []px
49+ }{
50+ // The left window owns the line (x 14-15); the client of the right
51+ // window is not covered.
52+ {"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, ""}}},
53+ {"two columns, right focused: bottom half", tiles(1, cols2...), true, []px{{14, 3, "gray"}, {14, 12, "lit"}}},
54+ {"unfocused output: all gray", tiles(0, cols2...), false, []px{{14, 3, "gray"}, {14, 12, "gray"}}},
55+ {"three columns, middle: both lines whole", tiles(1, cols3...), true, []px{{14, 3, "lit"}, {14, 12, "lit"}, {30, 3, "lit"}, {30, 12, "lit"}}},
56+ {"three columns, first: its line only", tiles(0, cols3...), true, []px{{14, 3, "lit"}, {14, 12, "lit"}, {30, 3, "gray"}, {30, 12, "gray"}}},
57+ {"no line at the screen edge", tiles(2, cols3...), true, []px{{47, 3, ""}, {0, 3, ""}}},
58+ // The left and top lines meet: the corner square is lit.
59+ {"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"}}},
60+ {"top-right tile", tiles(1, grid...), true, []px{{14, 3, "lit"}, {14, 15, "lit"}, {14, 20, "gray"}, {20, 14, "lit"}}},
61+ {"left column: whole line, split column untouched", tiles(0, grid...), true, []px{{14, 3, "lit"}, {14, 30, "lit"}, {20, 14, "gray"}}},
62+ } {
63+ t.Run(tc.name, func(t *testing.T) {
64+ seps := separators(tc.ps, 2, tc.lit)
65+ for _, c := range tc.check {
66+ if got := colorAt(seps, c.x, c.y); got != c.want {
67+ t.Errorf("(%d,%d) = %q, want %q; %v", c.x, c.y, got, c.want, seps)
68+ }
69+ }
70+ })
71+ }
72+}
73+
74+func TestSeparatorsSkip(t *testing.T) {
75+ if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, true); len(s) != 0 {
76+ t.Fatal("lone window:", s)
77+ }
78+ if s := separators(tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}), 0, true); len(s) != 0 {
79+ t.Fatal("width 0:", s)
80+ }
81+ full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16})
82+ full[0].Fullscreen, full[1].Hidden = true, true
83+ if s := separators(full, 2, true); len(s) != 0 {
84+ t.Fatal("fullscreen:", s)
85+ }
86+}
87+
88+func TestSeparatorsFloat(t *testing.T) {
89+ float := Placement{ID: 1, Rect: Rect{X: 10, Y: 10, W: 20, H: 20}, Floating: true, Focused: true, Inset: ports.SideAll}
90+ seps := separators([]Placement{float}, 2, true)
91+ for _, c := range [][2]int{{10, 10}, {29, 29}, {20, 11}} {
92+ if got := colorAt(seps, c[0], c[1]); got != "lit" {
93+ t.Errorf("(%d,%d) = %q", c[0], c[1], got)
94+ }
95+ }
96+ if got := colorAt(seps, 20, 20); got != "" {
97+ t.Error("inside:", got)
98+ }
99+}
@@ -0,0 +1,99 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+// tiles lays out rects edge to edge (no gap) with focus on index f.
10+func tiles(f int, rs ...Rect) []Placement {
11+ ps := make([]Placement, len(rs))
12+ for i, r := range rs {
13+ ps[i] = Placement{ID: WindowID(i + 1), Rect: r, Focused: i == f}
14+ }
15+ setNeighbors(ps, 0, Rect{W: 1000, H: 1000})
16+ return ps
17+}
18+
19+// colorAt is what the renderer shows at (x, y): the last separator over
20+// it wins. "" is no line.
21+func colorAt(seps []ports.Separator, x, y int) string {
22+ got := ""
23+ for _, s := range seps {
24+ r := s.Rect
25+ if x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H {
26+ got = "gray"
27+ if s.Active {
28+ got = "lit"
29+ }
30+ }
31+ }
32+ return got
33+}
34+
35+func TestSeparators(t *testing.T) {
36+ type px struct {
37+ x, y int
38+ want string
39+ }
40+ cols2 := []Rect{{X: 0, Y: 0, W: 16, H: 16}, {X: 16, Y: 0, W: 16, H: 16}}
41+ 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}}
42+ // A full-height column and a column split in two.
43+ 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}}
44+ for _, tc := range []struct {
45+ name string
46+ ps []Placement
47+ lit bool
48+ check []px
49+ }{
50+ // The left window owns the line (x 14-15); the client of the right
51+ // window is not covered.
52+ {"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, ""}}},
53+ {"two columns, right focused: bottom half", tiles(1, cols2...), true, []px{{14, 3, "gray"}, {14, 12, "lit"}}},
54+ {"unfocused output: all gray", tiles(0, cols2...), false, []px{{14, 3, "gray"}, {14, 12, "gray"}}},
55+ {"three columns, middle: both lines whole", tiles(1, cols3...), true, []px{{14, 3, "lit"}, {14, 12, "lit"}, {30, 3, "lit"}, {30, 12, "lit"}}},
56+ {"three columns, first: its line only", tiles(0, cols3...), true, []px{{14, 3, "lit"}, {14, 12, "lit"}, {30, 3, "gray"}, {30, 12, "gray"}}},
57+ {"no line at the screen edge", tiles(2, cols3...), true, []px{{47, 3, ""}, {0, 3, ""}}},
58+ // The left and top lines meet: the corner square is lit.
59+ {"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"}}},
60+ {"top-right tile", tiles(1, grid...), true, []px{{14, 3, "lit"}, {14, 15, "lit"}, {14, 20, "gray"}, {20, 14, "lit"}}},
61+ {"left column: whole line, split column untouched", tiles(0, grid...), true, []px{{14, 3, "lit"}, {14, 30, "lit"}, {20, 14, "gray"}}},
62+ } {
63+ t.Run(tc.name, func(t *testing.T) {
64+ seps := separators(tc.ps, 2, tc.lit)
65+ for _, c := range tc.check {
66+ if got := colorAt(seps, c.x, c.y); got != c.want {
67+ t.Errorf("(%d,%d) = %q, want %q; %v", c.x, c.y, got, c.want, seps)
68+ }
69+ }
70+ })
71+ }
72+}
73+
74+func TestSeparatorsSkip(t *testing.T) {
75+ if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, true); len(s) != 0 {
76+ t.Fatal("lone window:", s)
77+ }
78+ if s := separators(tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}), 0, true); len(s) != 0 {
79+ t.Fatal("width 0:", s)
80+ }
81+ full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16})
82+ full[0].Fullscreen, full[1].Hidden = true, true
83+ if s := separators(full, 2, true); len(s) != 0 {
84+ t.Fatal("fullscreen:", s)
85+ }
86+}
87+
88+func TestSeparatorsFloat(t *testing.T) {
89+ float := Placement{ID: 1, Rect: Rect{X: 10, Y: 10, W: 20, H: 20}, Floating: true, Focused: true, Inset: ports.SideAll}
90+ seps := separators([]Placement{float}, 2, true)
91+ for _, c := range [][2]int{{10, 10}, {29, 29}, {20, 11}} {
92+ if got := colorAt(seps, c[0], c[1]); got != "lit" {
93+ t.Errorf("(%d,%d) = %q", c[0], c[1], got)
94+ }
95+ }
96+ if got := colorAt(seps, 20, 20); got != "" {
97+ t.Error("inside:", got)
98+ }
99+}
M internal/ports/messages.go +13 -4
@@ -591,6 +591,9 @@ type Scene struct {
591591 Background string
592592 Border Border
593593 Windows []SceneWindow
594+ // Separators are the lines between windows, drawn after them in slice
595+ // order with the Border colors.
596+ Separators []Separator
594597 // Layers are drawn in slice order: background and bottom before windows,
595598 // top and overlay after. A fullscreen window covers bottom and top.
596599 // Window popups are drawn after the windows, layer popups
@@ -621,6 +624,13 @@ type Border struct {
621624 Active, Inactive string
622625 }
623626
627+// Separator is a line between windows, in logical pixels. Active lines
628+// mark the focused window and use Border.Active, others Border.Inactive.
629+type Separator struct {
630+ Rect Rect
631+ Active bool
632+}
633+
624634 // SceneLayer carries core → renderer layer surface placement.
625635 type SceneLayer struct {
626636 ID WindowID
@@ -761,10 +771,9 @@ type SceneWindow struct {
761771 ID WindowID
762772 Rect Rect
763773 Focused, Fullscreen, Hidden 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
774+ // Inset sides carry a separator line inside Rect: the client is drawn
775+ // inside it.
776+ Inset Sides
768777 // Popups are drawn from their content only: no border, no background.
769778 Popup bool
770779 // OverLayers popups hang from a layer surface: drawn over the top and
@@ -591,6 +591,9 @@ 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 after them in slice
595+ // order with the Border colors.
596+ Separators []Separator
594 // Layers are drawn in slice order: background and bottom before windows,597 // Layers are drawn in slice order: background and bottom before windows,
595 // top and overlay after. A fullscreen window covers bottom and top.598 // top and overlay after. A fullscreen window covers bottom and top.
596 // Window popups are drawn after the windows, layer popups599 // Window popups are drawn after the windows, layer popups
@@ -621,6 +624,13 @@ type Border struct {
621 Active, Inactive string624 Active, Inactive string
622 }625 }
623 626
627+// Separator is a line between windows, in logical pixels. Active lines
628+// mark the focused window and use Border.Active, others Border.Inactive.
629+type Separator struct {
630+ Rect Rect
631+ Active bool
632+}
633+
624 // SceneLayer carries core → renderer layer surface placement.634 // SceneLayer carries core → renderer layer surface placement.
625 type SceneLayer struct {635 type SceneLayer struct {
626 ID WindowID636 ID WindowID
@@ -761,10 +771,9 @@ type SceneWindow struct {
761 ID WindowID771 ID WindowID
762 Rect Rect772 Rect Rect
763 Focused, Fullscreen, Hidden bool773 Focused, Fullscreen, Hidden bool
764- // Inset sides carry the border inside Rect. Neighbors are the sides774+ // Inset sides carry a separator line inside Rect: the client is drawn
765- // touching another tile: the focused window also lights its neighbors'775+ // inside it.
766- // border line on the sides it does not inset.776+ Inset Sides
767- Neighbors, Inset Sides
768 // Popups are drawn from their content only: no border, no background.777 // Popups are drawn from their content only: no border, no background.
769 Popup bool778 Popup bool
770 // OverLayers popups hang from a layer surface: drawn over the top and779 // OverLayers popups hang from a layer surface: drawn over the top and

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 25

Tags 10

↑↓ moveenter openesc close