detached 771e6fd

fix(core): keep client sizes stable on scroll and draw float borders over tile lines

commit
771e6fddb58054c6670c8bb8536544b75e79f524
author
brice · · 2026-09-26 15:25 UTC
parent
5d259b7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +148 -42
M internal/adapters/vulkan/renderer.go +26 -9
@@ -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 }
@@ -446,17 +467,13 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
446467 add(body, parseColor(s.Background))
447468 place(w.ID, &content, cx, cy, cw, ch)
448469 }
449- }
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
455- }
456- if col != "" {
457- add(physRect(sep.Rect.X, sep.Rect.Y, sep.Rect.W, sep.Rect.H), parseColor(col))
470+ if w.Floating {
471+ addSeparators(w.ID)
458472 }
459473 }
474+ if !tileLines {
475+ addSeparators(0)
476+ }
460477 // Popups draw only what the client drew, shadows clipped. Window popups
461478 // stay with the windows; a layer's go over every layer.
462479 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 }
@@ -446,17 +467,13 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
446 add(body, parseColor(s.Background))467 add(body, parseColor(s.Background))
447 place(w.ID, &content, cx, cy, cw, ch)468 place(w.ID, &content, cx, cy, cw, ch)
448 }469 }
449- }470+ if w.Floating {
450- // Separators are drawn in order: active lines over inactive ones.471+ addSeparators(w.ID)
451- for _, sep := range s.Separators {
452- col := s.Border.Inactive
453- if sep.Active {
454- col = s.Border.Active
455- }
456- if col != "" {
457- add(physRect(sep.Rect.X, sep.Rect.Y, sep.Rect.W, sep.Rect.H), parseColor(col))
458 }472 }
459 }473 }
474+ if !tileLines {
475+ addSeparators(0)
476+ }
460 // Popups draw only what the client drew, shadows clipped. Window popups477 // Popups draw only what the client drew, shadows clipped. Window popups
461 // stay with the windows; a layer's go over every layer.478 // stay with the windows; a layer's go over every layer.
462 addPopups := func(overLayers bool) {479 addPopups := func(overLayers bool) {
M internal/adapters/vulkan/renderer_test.go +9 -0
@@ -72,6 +72,15 @@ func TestRendererRender(t *testing.T) {
7272 check(s, map[image.Point]color.RGBA{{9, 9}: lit, {15, 9}: lit, {16, 9}: gray, {23, 9}: gray, {10, 10}: wc})
7373 s.Border.Inactive = ""
7474 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}
7584 s.Separators, s.Border.Inactive = nil, "#313244"
7685 s.Windows[0].Fullscreen = true
7786 check(s, map[image.Point]color.RGBA{{9, 9}: wc})
@@ -72,6 +72,15 @@ func TestRendererRender(t *testing.T) {
72 check(s, map[image.Point]color.RGBA{{9, 9}: lit, {15, 9}: lit, {16, 9}: gray, {23, 9}: gray, {10, 10}: wc})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 = ""73 s.Border.Inactive = ""
74 check(s, map[image.Point]color.RGBA{{9, 9}: lit, {16, 9}: bg})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}
75 s.Separators, s.Border.Inactive = nil, "#313244"84 s.Separators, s.Border.Inactive = nil, "#313244"
76 s.Windows[0].Fullscreen = true85 s.Windows[0].Fullscreen = true
77 check(s, map[image.Point]color.RGBA{{9, 9}: wc})86 check(s, map[image.Point]color.RGBA{{9, 9}: wc})
M internal/core/core.go +3 -3
@@ -401,12 +401,12 @@ func (c *Core) publish(ctx context.Context) error {
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...)}
402402 layout := sc.mon.Layout()
403403 // Only the focused output lights the focused window's lines.
404- scene.Separators = separators(layout, c.cfg.Border.Width, i == c.focusScreen)
404+ scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)
405405 for _, p := range layout {
406406 alive[p.ID] = true
407- // Only the focused output shows the focused border.
407+ // Only the focused output has an activated window.
408408 focused := p.Focused && i == c.focusScreen
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})
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})
410410 floating := p.Floating
411411 if p.Hidden {
412412 if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") {
@@ -401,12 +401,12 @@ func (c *Core) publish(ctx context.Context) error {
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 layout := sc.mon.Layout()402 layout := sc.mon.Layout()
403 // Only the focused output lights the focused window's lines.403 // Only the focused output lights the focused window's lines.
404- scene.Separators = separators(layout, c.cfg.Border.Width, i == c.focusScreen)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 for _, p := range layout {
406 alive[p.ID] = true406 alive[p.ID] = true
407- // Only the focused output shows the focused border.407+ // Only the focused output has an activated window.
408 focused := p.Focused && i == c.focusScreen408 focused := p.Focused && i == c.focusScreen
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})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})
410 floating := p.Floating410 floating := p.Floating
411 if p.Hidden {411 if p.Hidden {
412 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/layout.go +5 -5
@@ -672,7 +672,7 @@ func (w *Workspace) Layout() []Placement {
672672 y += h + gap
673673 }
674674 }
675- setNeighbors(result, gap, w.Output)
675+ setNeighbors(result, gap)
676676 // Floating windows go last: they are drawn and hit on top.
677677 for _, f := range w.Floats {
678678 p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll}
@@ -686,9 +686,9 @@ func (w *Workspace) Layout() []Placement {
686686 return result
687687 }
688688
689-// setNeighbors marks the sides where tiles touch across the gap. Tiles
690-// scrolled off the output are no neighbors: no line at the screen edge.
691-func setNeighbors(tiles []Placement, gap int, out Rect) {
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) {
692692 overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }
693693 for i := range tiles {
694694 a := &tiles[i]
@@ -697,7 +697,7 @@ func setNeighbors(tiles []Placement, gap int, out Rect) {
697697 }
698698 for j := range tiles {
699699 b := tiles[j].Rect
700- if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 || b.X >= out.X+out.W || b.X+b.W <= out.X {
700+ if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 {
701701 continue
702702 }
703703 r := a.Rect
@@ -672,7 +672,7 @@ func (w *Workspace) Layout() []Placement {
672 y += h + gap672 y += h + gap
673 }673 }
674 }674 }
675- setNeighbors(result, gap, w.Output)675+ setNeighbors(result, gap)
676 // Floating windows go last: they are drawn and hit on top.676 // Floating windows go last: they are drawn and hit on top.
677 for _, f := range w.Floats {677 for _, f := range w.Floats {
678 p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll}678 p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll}
@@ -686,9 +686,9 @@ func (w *Workspace) Layout() []Placement {
686 return result686 return result
687 }687 }
688 688
689-// setNeighbors marks the sides where tiles touch across the gap. Tiles689+// setNeighbors marks the sides where tiles touch across the gap, from
690-// scrolled off the output are no neighbors: no line at the screen edge.690+// geometry alone: client sizes do not change as the view scrolls.
691-func setNeighbors(tiles []Placement, gap int, out Rect) {691+func setNeighbors(tiles []Placement, gap int) {
692 overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }692 overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }
693 for i := range tiles {693 for i := range tiles {
694 a := &tiles[i]694 a := &tiles[i]
@@ -697,7 +697,7 @@ func setNeighbors(tiles []Placement, gap int, out Rect) {
697 }697 }
698 for j := range tiles {698 for j := range tiles {
699 b := tiles[j].Rect699 b := tiles[j].Rect
700- if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 || b.X >= out.X+out.W || b.X+b.W <= out.X {700+ if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 {
701 continue701 continue
702 }702 }
703 r := a.Rect703 r := a.Rect
M internal/core/layout_test.go +2 -1
@@ -1,10 +1,11 @@
11 package core
22
33 import (
4- "github.com/bnema/nefertty/internal/ports"
54 "math"
65 "reflect"
76 "testing"
7+
8+ "github.com/bnema/nefertty/internal/ports"
89 )
910
1011 func workspace() *Workspace {
@@ -1,10 +1,11 @@
1 package core1 package core
2 2
3 import (3 import (
4- "github.com/bnema/nefertty/internal/ports"
5 "math"4 "math"
6 "reflect"5 "reflect"
7 "testing"6 "testing"
7+
8+ "github.com/bnema/nefertty/internal/ports"
8 )9 )
9 10
10 func workspace() *Workspace {11 func workspace() *Workspace {
M internal/core/monitor_test.go +2 -1
@@ -1,10 +1,11 @@
11 package core
22
33 import (
4- "github.com/bnema/nefertty/internal/ports"
54 "reflect"
65 "slices"
76 "testing"
7+
8+ "github.com/bnema/nefertty/internal/ports"
89 )
910
1011 func monitor() *Monitor {
@@ -1,10 +1,11 @@
1 package core1 package core
2 2
3 import (3 import (
4- "github.com/bnema/nefertty/internal/ports"
5 "reflect"4 "reflect"
6 "slices"5 "slices"
7 "testing"6 "testing"
7+
8+ "github.com/bnema/nefertty/internal/ports"
8 )9 )
9 10
10 func monitor() *Monitor {11 func monitor() *Monitor {
M internal/core/separators.go +26 -11
@@ -6,19 +6,33 @@ import "github.com/bnema/nefertty/internal/ports"
66 // side where they touch, owned by the left or top tile (Placement.Inset).
77 // The focused tile lights the lines along its sides, corners included.
88 // 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.
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.
1115
1216 var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom}
1317
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+// 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 {
1822 if width <= 0 {
1923 return nil
2024 }
2125 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+ }
2236 var focused *Placement
2337 tiles := 0
2438 for i := range ps {
@@ -26,12 +40,12 @@ func separators(ps []Placement, width int, lit bool) []ports.Separator {
2640 if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 {
2741 continue
2842 }
29- if p.Neighbors != 0 {
43+ if p.Neighbors != 0 && p.Rect.X < o.X+o.W && p.Rect.X+p.Rect.W > o.X {
3044 tiles++
3145 }
3246 for _, s := range sides {
3347 if p.Inset&s != 0 {
34- out = append(out, ports.Separator{Rect: strip(p.Rect, s, 0, width)})
48+ add(p, strip(p.Rect, s, 0, width), false)
3549 }
3650 }
3751 if p.Focused && lit {
@@ -51,16 +65,17 @@ func separators(ps []Placement, width int, lit bool) []ports.Separator {
5165 line = strip(f.Rect, s, width, width)
5266 case f.Inset&s != 0:
5367 // A float's own border.
54- out = append(out, ports.Separator{Rect: strip(f.Rect, s, 0, width), Active: true})
68+ add(f, strip(f.Rect, s, 0, width), true)
5569 continue
5670 default:
5771 continue
5872 }
5973 line = closeCorners(line, *f, s, width)
60- if tiles == 2 {
74+ // Two tiles share one line only without gaps.
75+ if tiles == 2 && gap == 0 {
6176 line = half(line, s)
6277 }
63- out = append(out, ports.Separator{Rect: line, Active: true})
78+ add(f, line, true)
6479 }
6580 return out
6681 }
@@ -6,19 +6,33 @@ import "github.com/bnema/nefertty/internal/ports"
6 // side where they touch, owned by the left or top tile (Placement.Inset).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.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 its8 // 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 full9+// half of it: the left or top tile the first half. With gaps each tile
10-// border of their own.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.
11 15
12 var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom}16 var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom}
13 17
14-// separators are the lines of a layout, inactive ones first so active18+// separators are the lines of a layout on output o, inactive ones first
15-// ones draw over them. lit says whether the focused tile shows focus19+// so active ones draw over them. lit says whether the focused tile shows
16-// (only on the focused output).20+// focus (only on the focused output).
17-func separators(ps []Placement, width int, lit bool) []ports.Separator {21+func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separator {
18 if width <= 0 {22 if width <= 0 {
19 return nil23 return nil
20 }24 }
21 var out []ports.Separator25 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+ }
22 var focused *Placement36 var focused *Placement
23 tiles := 037 tiles := 0
24 for i := range ps {38 for i := range ps {
@@ -26,12 +40,12 @@ func separators(ps []Placement, width int, lit bool) []ports.Separator {
26 if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 {40 if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 {
27 continue41 continue
28 }42 }
29- if p.Neighbors != 0 {43+ if p.Neighbors != 0 && p.Rect.X < o.X+o.W && p.Rect.X+p.Rect.W > o.X {
30 tiles++44 tiles++
31 }45 }
32 for _, s := range sides {46 for _, s := range sides {
33 if p.Inset&s != 0 {47 if p.Inset&s != 0 {
34- out = append(out, ports.Separator{Rect: strip(p.Rect, s, 0, width)})48+ add(p, strip(p.Rect, s, 0, width), false)
35 }49 }
36 }50 }
37 if p.Focused && lit {51 if p.Focused && lit {
@@ -51,16 +65,17 @@ func separators(ps []Placement, width int, lit bool) []ports.Separator {
51 line = strip(f.Rect, s, width, width)65 line = strip(f.Rect, s, width, width)
52 case f.Inset&s != 0:66 case f.Inset&s != 0:
53 // A float's own border.67 // A float's own border.
54- out = append(out, ports.Separator{Rect: strip(f.Rect, s, 0, width), Active: true})68+ add(f, strip(f.Rect, s, 0, width), true)
55 continue69 continue
56 default:70 default:
57 continue71 continue
58 }72 }
59 line = closeCorners(line, *f, s, width)73 line = closeCorners(line, *f, s, width)
60- if tiles == 2 {74+ // Two tiles share one line only without gaps.
75+ if tiles == 2 && gap == 0 {
61 line = half(line, s)76 line = half(line, s)
62 }77 }
63- out = append(out, ports.Separator{Rect: line, Active: true})78+ add(f, line, true)
64 }79 }
65 return out80 return out
66 }81 }
M internal/core/separators_test.go +68 -10
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "reflect"
45 "testing"
56
67 "github.com/bnema/nefertty/internal/ports"
@@ -12,10 +13,13 @@ func tiles(f int, rs ...Rect) []Placement {
1213 for i, r := range rs {
1314 ps[i] = Placement{ID: WindowID(i + 1), Rect: r, Focused: i == f}
1415 }
15- setNeighbors(ps, 0, Rect{W: 1000, H: 1000})
16+ setNeighbors(ps, 0)
1617 return ps
1718 }
1819
20+// bigOutput is an output wider than every test layout.
21+var bigOutput = Rect{W: 1000, H: 1000}
22+
1923 // colorAt is what the renderer shows at (x, y): the last separator over
2024 // it wins. "" is no line.
2125 func colorAt(seps []ports.Separator, x, y int) string {
@@ -32,11 +36,12 @@ func colorAt(seps []ports.Separator, x, y int) string {
3236 return got
3337 }
3438
39+type px struct {
40+ x, y int
41+ want string
42+}
43+
3544 func TestSeparators(t *testing.T) {
36- type px struct {
37- x, y int
38- want string
39- }
4045 cols2 := []Rect{{X: 0, Y: 0, W: 16, H: 16}, {X: 16, Y: 0, W: 16, H: 16}}
4146 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}}
4247 // A full-height column and a column split in two.
@@ -61,7 +66,7 @@ func TestSeparators(t *testing.T) {
6166 {"left column: whole line, split column untouched", tiles(0, grid...), true, []px{{14, 3, "lit"}, {14, 30, "lit"}, {20, 14, "gray"}}},
6267 } {
6368 t.Run(tc.name, func(t *testing.T) {
64- seps := separators(tc.ps, 2, tc.lit)
69+ seps := separators(tc.ps, 2, 0, bigOutput, tc.lit)
6570 for _, c := range tc.check {
6671 if got := colorAt(seps, c.x, c.y); got != c.want {
6772 t.Errorf("(%d,%d) = %q, want %q; %v", c.x, c.y, got, c.want, seps)
@@ -72,22 +77,25 @@ func TestSeparators(t *testing.T) {
7277 }
7378
7479 func TestSeparatorsSkip(t *testing.T) {
75- if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, true); len(s) != 0 {
80+ if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, 0, bigOutput, true); len(s) != 0 {
7681 t.Fatal("lone window:", s)
7782 }
78- if s := separators(tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}), 0, true); len(s) != 0 {
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 {
7984 t.Fatal("width 0:", s)
8085 }
8186 full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16})
8287 full[0].Fullscreen, full[1].Hidden = true, true
83- if s := separators(full, 2, true); len(s) != 0 {
88+ if s := separators(full, 2, 0, bigOutput, true); len(s) != 0 {
8489 t.Fatal("fullscreen:", s)
8590 }
8691 }
8792
8893 func TestSeparatorsFloat(t *testing.T) {
8994 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)
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+ }
9199 for _, c := range [][2]int{{10, 10}, {29, 29}, {20, 11}} {
92100 if got := colorAt(seps, c[0], c[1]); got != "lit" {
93101 t.Errorf("(%d,%d) = %q", c[0], c[1], got)
@@ -97,3 +105,53 @@ func TestSeparatorsFloat(t *testing.T) {
97105 t.Error("inside:", got)
98106 }
99107 }
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+}
@@ -1,6 +1,7 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "reflect"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/nefertty/internal/ports"7 "github.com/bnema/nefertty/internal/ports"
@@ -12,10 +13,13 @@ func tiles(f int, rs ...Rect) []Placement {
12 for i, r := range rs {13 for i, r := range rs {
13 ps[i] = Placement{ID: WindowID(i + 1), Rect: r, Focused: i == f}14 ps[i] = Placement{ID: WindowID(i + 1), Rect: r, Focused: i == f}
14 }15 }
15- setNeighbors(ps, 0, Rect{W: 1000, H: 1000})16+ setNeighbors(ps, 0)
16 return ps17 return ps
17 }18 }
18 19
20+// bigOutput is an output wider than every test layout.
21+var bigOutput = Rect{W: 1000, H: 1000}
22+
19 // colorAt is what the renderer shows at (x, y): the last separator over23 // colorAt is what the renderer shows at (x, y): the last separator over
20 // it wins. "" is no line.24 // it wins. "" is no line.
21 func colorAt(seps []ports.Separator, x, y int) string {25 func colorAt(seps []ports.Separator, x, y int) string {
@@ -32,11 +36,12 @@ func colorAt(seps []ports.Separator, x, y int) string {
32 return got36 return got
33 }37 }
34 38
39+type px struct {
40+ x, y int
41+ want string
42+}
43+
35 func TestSeparators(t *testing.T) {44 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}}45 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}}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}}
42 // A full-height column and a column split in two.47 // A full-height column and a column split in two.
@@ -61,7 +66,7 @@ func TestSeparators(t *testing.T) {
61 {"left column: whole line, split column untouched", tiles(0, grid...), true, []px{{14, 3, "lit"}, {14, 30, "lit"}, {20, 14, "gray"}}},66 {"left column: whole line, split column untouched", tiles(0, grid...), true, []px{{14, 3, "lit"}, {14, 30, "lit"}, {20, 14, "gray"}}},
62 } {67 } {
63 t.Run(tc.name, func(t *testing.T) {68 t.Run(tc.name, func(t *testing.T) {
64- seps := separators(tc.ps, 2, tc.lit)69+ seps := separators(tc.ps, 2, 0, bigOutput, tc.lit)
65 for _, c := range tc.check {70 for _, c := range tc.check {
66 if got := colorAt(seps, c.x, c.y); got != c.want {71 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)72 t.Errorf("(%d,%d) = %q, want %q; %v", c.x, c.y, got, c.want, seps)
@@ -72,22 +77,25 @@ func TestSeparators(t *testing.T) {
72 }77 }
73 78
74 func TestSeparatorsSkip(t *testing.T) {79 func TestSeparatorsSkip(t *testing.T) {
75- if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, true); len(s) != 0 {80+ if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, 0, bigOutput, true); len(s) != 0 {
76 t.Fatal("lone window:", s)81 t.Fatal("lone window:", s)
77 }82 }
78- if s := separators(tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}), 0, true); len(s) != 0 {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 {
79 t.Fatal("width 0:", s)84 t.Fatal("width 0:", s)
80 }85 }
81 full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16})86 full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16})
82 full[0].Fullscreen, full[1].Hidden = true, true87 full[0].Fullscreen, full[1].Hidden = true, true
83- if s := separators(full, 2, true); len(s) != 0 {88+ if s := separators(full, 2, 0, bigOutput, true); len(s) != 0 {
84 t.Fatal("fullscreen:", s)89 t.Fatal("fullscreen:", s)
85 }90 }
86 }91 }
87 92
88 func TestSeparatorsFloat(t *testing.T) {93 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}94 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)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+ }
91 for _, c := range [][2]int{{10, 10}, {29, 29}, {20, 11}} {99 for _, c := range [][2]int{{10, 10}, {29, 29}, {20, 11}} {
92 if got := colorAt(seps, c[0], c[1]); got != "lit" {100 if got := colorAt(seps, c[0], c[1]); got != "lit" {
93 t.Errorf("(%d,%d) = %q", c[0], c[1], got)101 t.Errorf("(%d,%d) = %q", c[0], c[1], got)
@@ -97,3 +105,53 @@ func TestSeparatorsFloat(t *testing.T) {
97 t.Error("inside:", got)105 t.Error("inside:", got)
98 }106 }
99 }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 +7 -2
@@ -591,8 +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.
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.
596597 Separators []Separator
597598 // Layers are drawn in slice order: background and bottom before windows,
598599 // top and overlay after. A fullscreen window covers bottom and top.
@@ -626,9 +627,11 @@ type Border struct {
626627
627628 // Separator is a line between windows, in logical pixels. Active lines
628629 // 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.
629631 type Separator struct {
630632 Rect Rect
631633 Active bool
634+ Window WindowID
632635 }
633636
634637 // SceneLayer carries core → renderer layer surface placement.
@@ -774,6 +777,8 @@ type SceneWindow struct {
774777 // Inset sides carry a separator line inside Rect: the client is drawn
775778 // inside it.
776779 Inset Sides
780+ // Floating windows are drawn over the tiles and their lines.
781+ Floating bool
777782 // Popups are drawn from their content only: no border, no background.
778783 Popup bool
779784 // OverLayers popups hang from a layer surface: drawn over the top and
@@ -591,8 +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 slice594+ // Separators are the lines between windows, drawn in slice order with
595- // order with the Border colors.595+ // the Border colors: tile lines over the tiles, under the floats; a
596+ // float's border right after the float.
596 Separators []Separator597 Separators []Separator
597 // Layers are drawn in slice order: background and bottom before windows,598 // Layers are drawn in slice order: background and bottom before windows,
598 // top and overlay after. A fullscreen window covers bottom and top.599 // top and overlay after. A fullscreen window covers bottom and top.
@@ -626,9 +627,11 @@ type Border struct {
626 627
627 // Separator is a line between windows, in logical pixels. Active lines628 // Separator is a line between windows, in logical pixels. Active lines
628 // mark the focused window and use Border.Active, others Border.Inactive.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.
629 type Separator struct {631 type Separator struct {
630 Rect Rect632 Rect Rect
631 Active bool633 Active bool
634+ Window WindowID
632 }635 }
633 636
634 // SceneLayer carries core → renderer layer surface placement.637 // SceneLayer carries core → renderer layer surface placement.
@@ -774,6 +777,8 @@ type SceneWindow struct {
774 // Inset sides carry a separator line inside Rect: the client is drawn777 // Inset sides carry a separator line inside Rect: the client is drawn
775 // inside it.778 // inside it.
776 Inset Sides779 Inset Sides
780+ // Floating windows are drawn over the tiles and their lines.
781+ Floating bool
777 // Popups are drawn from their content only: no border, no background.782 // Popups are drawn from their content only: no border, no background.
778 Popup bool783 Popup bool
779 // OverLayers popups hang from a layer surface: drawn over the top and784 // 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 26

Tags 10

↑↓ moveenter openesc close