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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 422 | 422 | } | |
| 423 | 423 | } | |
| 424 | 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 | 442 | for _, w := range s.Windows { | |
| 443 | + | if w.Floating && !tileLines { | |
| 444 | + | addSeparators(0) | |
| 445 | + | tileLines = true | |
| 446 | + | } | |
| 426 | 447 | if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 { | |
| 427 | 448 | continue | |
| 428 | 449 | } | |
| @@ -446,17 +467,13 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 446 | 467 | add(body, parseColor(s.Background)) | |
| 447 | 468 | place(w.ID, &content, cx, cy, cw, ch) | |
| 448 | 469 | } | |
| 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) | |
| 458 | 472 | } | |
| 459 | 473 | } | |
| 474 | + | if !tileLines { | |
| 475 | + | addSeparators(0) | |
| 476 | + | } | |
| 460 | 477 | // Popups draw only what the client drew, shadows clipped. Window popups | |
| 461 | 478 | // stay with the windows; a layer's go over every layer. | |
| 462 | 479 | 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 | continue | 448 | 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 popups | 477 | // 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) { | |||
| 72 | 72 | check(s, map[image.Point]color.RGBA{{9, 9}: lit, {15, 9}: lit, {16, 9}: gray, {23, 9}: gray, {10, 10}: wc}) | |
| 73 | 73 | s.Border.Inactive = "" | |
| 74 | 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 | 84 | s.Separators, s.Border.Inactive = nil, "#313244" | |
| 76 | 85 | s.Windows[0].Fullscreen = true | |
| 77 | 86 | 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 = true | 85 | 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 { | |||
| 401 | 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 | 402 | layout := sc.mon.Layout() | |
| 403 | 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 | 405 | for _, p := range layout { | |
| 406 | 406 | alive[p.ID] = true | |
| 407 | - | // Only the focused output shows the focused border. | |
| 407 | + | // Only the focused output has an activated window. | |
| 408 | 408 | 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 | 410 | floating := p.Floating | |
| 411 | 411 | if p.Hidden { | |
| 412 | 412 | 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] = true | 406 | 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.focusScreen | 408 | 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.Floating | 410 | 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 { | |||
| 672 | 672 | y += h + gap | |
| 673 | 673 | } | |
| 674 | 674 | } | |
| 675 | - | setNeighbors(result, gap, w.Output) | |
| 675 | + | setNeighbors(result, gap) | |
| 676 | 676 | // Floating windows go last: they are drawn and hit on top. | |
| 677 | 677 | for _, f := range w.Floats { | |
| 678 | 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 | 686 | return result | |
| 687 | 687 | } | |
| 688 | 688 | ||
| 689 | - | // setNeighbors marks the sides where tiles touch across the gap. Tiles | |
| 690 | - | // scrolled off the output are no neighbors: no line at the screen edge. | |
| 691 | - | func setNeighbors(tiles []Placement, gap int, out Rect) { | |
| 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 | 692 | overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 } | |
| 693 | 693 | for i := range tiles { | |
| 694 | 694 | a := &tiles[i] | |
| @@ -697,7 +697,7 @@ func setNeighbors(tiles []Placement, gap int, out Rect) { | |||
| 697 | 697 | } | |
| 698 | 698 | for j := range tiles { | |
| 699 | 699 | b := tiles[j].Rect | |
| 700 | - | if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 || b.X >= out.X+out.W || b.X+b.W <= out.X { | |
| 700 | + | if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 { | |
| 701 | 701 | continue | |
| 702 | 702 | } | |
| 703 | 703 | r := a.Rect | |
| @@ -672,7 +672,7 @@ func (w *Workspace) Layout() []Placement { | |||||
| 672 | y += h + gap | 672 | 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 result | 686 | return result | ||
| 687 | } | 687 | } | ||
| 688 | 688 | ||||
| 689 | - | // setNeighbors marks the sides where tiles touch across the gap. Tiles | 689 | + | // 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].Rect | 699 | b := tiles[j].Rect | ||
| 700 | - | if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 || b.X >= out.X+out.W || b.X+b.W <= out.X { | 700 | + | if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 { |
| 701 | continue | 701 | continue | ||
| 702 | } | 702 | } | ||
| 703 | r := a.Rect | 703 | r := a.Rect | ||
M internal/core/layout_test.go +2 -1
| @@ -1,10 +1,11 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "github.com/bnema/nefertty/internal/ports" | |
| 5 | 4 | "math" | |
| 6 | 5 | "reflect" | |
| 7 | 6 | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | func workspace() *Workspace { | |
| @@ -1,10 +1,11 @@ | |||||
| 1 | package core | 1 | 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 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "github.com/bnema/nefertty/internal/ports" | |
| 5 | 4 | "reflect" | |
| 6 | 5 | "slices" | |
| 7 | 6 | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | func monitor() *Monitor { | |
| @@ -1,10 +1,11 @@ | |||||
| 1 | package core | 1 | 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" | |||
| 6 | 6 | // side where they touch, owned by the left or top tile (Placement.Inset). | |
| 7 | 7 | // The focused tile lights the lines along its sides, corners included. | |
| 8 | 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. | |
| 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. | |
| 11 | 15 | ||
| 12 | 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 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 { | |
| 18 | 22 | if width <= 0 { | |
| 19 | 23 | return nil | |
| 20 | 24 | } | |
| 21 | 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 | + | } | |
| 22 | 36 | var focused *Placement | |
| 23 | 37 | tiles := 0 | |
| 24 | 38 | for i := range ps { | |
| @@ -26,12 +40,12 @@ func separators(ps []Placement, width int, lit bool) []ports.Separator { | |||
| 26 | 40 | if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 { | |
| 27 | 41 | 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 | 44 | tiles++ | |
| 31 | 45 | } | |
| 32 | 46 | for _, s := range sides { | |
| 33 | 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 | 51 | if p.Focused && lit { | |
| @@ -51,16 +65,17 @@ func separators(ps []Placement, width int, lit bool) []ports.Separator { | |||
| 51 | 65 | line = strip(f.Rect, s, width, width) | |
| 52 | 66 | case f.Inset&s != 0: | |
| 53 | 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 | 69 | continue | |
| 56 | 70 | default: | |
| 57 | 71 | continue | |
| 58 | 72 | } | |
| 59 | 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 | 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 | 80 | return out | |
| 66 | 81 | } | |
| @@ -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 its | 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 | 9 | + | // 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 active | 18 | + | // 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 focus | 19 | + | // 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 nil | 23 | return nil | ||
| 20 | } | 24 | } | ||
| 21 | var out []ports.Separator | 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 | + | } | |||
| 22 | var focused *Placement | 36 | var focused *Placement | ||
| 23 | tiles := 0 | 37 | 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 | continue | 41 | 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 | continue | 69 | continue | ||
| 56 | default: | 70 | default: | ||
| 57 | continue | 71 | 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 out | 80 | return out | ||
| 66 | } | 81 | } | ||
M internal/core/separators_test.go +68 -10
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "reflect" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/nefertty/internal/ports" | |
| @@ -12,10 +13,13 @@ func tiles(f int, rs ...Rect) []Placement { | |||
| 12 | 13 | for i, r := range rs { | |
| 13 | 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 | 17 | 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 | 23 | // colorAt is what the renderer shows at (x, y): the last separator over | |
| 20 | 24 | // it wins. "" is no line. | |
| 21 | 25 | func colorAt(seps []ports.Separator, x, y int) string { | |
| @@ -32,11 +36,12 @@ func colorAt(seps []ports.Separator, x, y int) string { | |||
| 32 | 36 | return got | |
| 33 | 37 | } | |
| 34 | 38 | ||
| 39 | + | type px struct { | |
| 40 | + | x, y int | |
| 41 | + | want string | |
| 42 | + | } | |
| 43 | + | ||
| 35 | 44 | func TestSeparators(t *testing.T) { | |
| 36 | - | type px struct { | |
| 37 | - | x, y int | |
| 38 | - | want string | |
| 39 | - | } | |
| 40 | 45 | cols2 := []Rect{{X: 0, Y: 0, W: 16, H: 16}, {X: 16, Y: 0, W: 16, H: 16}} | |
| 41 | 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 | 47 | // A full-height column and a column split in two. | |
| @@ -61,7 +66,7 @@ func TestSeparators(t *testing.T) { | |||
| 61 | 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 | 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 | 70 | for _, c := range tc.check { | |
| 66 | 71 | if got := colorAt(seps, c.x, c.y); got != c.want { | |
| 67 | 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 | 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 | 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 | 84 | t.Fatal("width 0:", s) | |
| 80 | 85 | } | |
| 81 | 86 | full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}) | |
| 82 | 87 | 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 | 89 | t.Fatal("fullscreen:", s) | |
| 85 | 90 | } | |
| 86 | 91 | } | |
| 87 | 92 | ||
| 88 | 93 | func TestSeparatorsFloat(t *testing.T) { | |
| 89 | 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 | 99 | for _, c := range [][2]int{{10, 10}, {29, 29}, {20, 11}} { | |
| 92 | 100 | if got := colorAt(seps, c[0], c[1]); got != "lit" { | |
| 93 | 101 | t.Errorf("(%d,%d) = %q", c[0], c[1], got) | |
| @@ -97,3 +105,53 @@ func TestSeparatorsFloat(t *testing.T) { | |||
| 97 | 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 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package core | 1 | 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 ps | 17 | 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 over | 23 | // 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 got | 36 | 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, true | 87 | 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 { | |||
| 591 | 591 | Background string | |
| 592 | 592 | Border Border | |
| 593 | 593 | 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. | |
| 596 | 597 | Separators []Separator | |
| 597 | 598 | // Layers are drawn in slice order: background and bottom before windows, | |
| 598 | 599 | // top and overlay after. A fullscreen window covers bottom and top. | |
| @@ -626,9 +627,11 @@ type Border struct { | |||
| 626 | 627 | ||
| 627 | 628 | // Separator is a line between windows, in logical pixels. Active lines | |
| 628 | 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 | 631 | type Separator struct { | |
| 630 | 632 | Rect Rect | |
| 631 | 633 | Active bool | |
| 634 | + | Window WindowID | |
| 632 | 635 | } | |
| 633 | 636 | ||
| 634 | 637 | // SceneLayer carries core → renderer layer surface placement. | |
| @@ -774,6 +777,8 @@ type SceneWindow struct { | |||
| 774 | 777 | // Inset sides carry a separator line inside Rect: the client is drawn | |
| 775 | 778 | // inside it. | |
| 776 | 779 | Inset Sides | |
| 780 | + | // Floating windows are drawn over the tiles and their lines. | |
| 781 | + | Floating bool | |
| 777 | 782 | // Popups are drawn from their content only: no border, no background. | |
| 778 | 783 | Popup bool | |
| 779 | 784 | // OverLayers popups hang from a layer surface: drawn over the top and | |
| @@ -591,8 +591,9 @@ type Scene struct { | |||||
| 591 | Background string | 591 | Background string | ||
| 592 | Border Border | 592 | Border Border | ||
| 593 | Windows []SceneWindow | 593 | Windows []SceneWindow | ||
| 594 | - | // Separators are the lines between windows, drawn after them in slice | 594 | + | // 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 []Separator | 597 | 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 lines | 628 | // 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 Rect | 632 | Rect Rect | ||
| 631 | Active bool | 633 | 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 drawn | 777 | // Inset sides carry a separator line inside Rect: the client is drawn | ||
| 775 | // inside it. | 778 | // inside it. | ||
| 776 | Inset Sides | 779 | 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 bool | 783 | Popup bool | ||
| 779 | // OverLayers popups hang from a layer surface: drawn over the top and | 784 | // OverLayers popups hang from a layer surface: drawn over the top and | ||