fix(render): split every shared separator stretch and close lit corners
- commit
- 181a3c81b2b3e416997d6a2f3f1924fe08a8cef7
- author
- brice · · 2026-09-26 15:00 UTC
- committer
- brice · · 2026-09-26 15:07 UTC
- parent
- eba6a62
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +100 -27
M internal/adapters/vulkan/renderer.go +77 -27
| @@ -458,29 +458,31 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 458 | 458 | } | |
| 459 | 459 | // The focused window lights the lines along its own sides only: its | |
| 460 | 460 | // inset border, and its neighbors' border line where they own it. | |
| 461 | - | // With exactly two tiles one line splits them: like tmux, the focused | |
| 462 | - | // window lights only its half of it (left/top window: first half). | |
| 461 | + | // tmux style: each stretch of line shared by two tiles is split in | |
| 462 | + | // half, the left or top window owning the first half. The focused | |
| 463 | + | // window lights its half of every stretch along its sides. | |
| 463 | 464 | if focused != nil && s.Border.Active != "" && s.Border.Width > 0 { | |
| 464 | - | f, tiles := focused, 0 | |
| 465 | - | for _, w := range s.Windows { | |
| 466 | - | if !w.Hidden && !w.Popup && w.Neighbors != 0 { | |
| 467 | - | tiles++ | |
| 468 | - | } | |
| 465 | + | f := focused | |
| 466 | + | // Inset sides with no neighbor (floats) light fully. | |
| 467 | + | for _, strip := range borderStrips(f.Rect, f.Inset&^f.Neighbors, 0, s.Border.Width) { | |
| 468 | + | add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active)) | |
| 469 | 469 | } | |
| 470 | 470 | for _, side := range []ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom} { | |
| 471 | - | out := 0 | |
| 472 | - | switch { | |
| 473 | - | case f.Inset&side != 0: | |
| 474 | - | case f.Neighbors&side != 0: | |
| 475 | - | out = s.Border.Width | |
| 476 | - | default: | |
| 471 | + | if f.Neighbors&side == 0 { | |
| 477 | 472 | continue | |
| 478 | 473 | } | |
| 479 | - | for _, strip := range borderStrips(f.Rect, side, out, s.Border.Width) { | |
| 480 | - | if tiles == 2 { | |
| 481 | - | strip = halfStrip(strip, side) | |
| 474 | + | out := s.Border.Width | |
| 475 | + | if f.Inset&side != 0 { | |
| 476 | + | out = 0 | |
| 477 | + | } | |
| 478 | + | line := borderStrips(f.Rect, side, out, s.Border.Width)[0] | |
| 479 | + | for _, n := range s.Windows { | |
| 480 | + | if n.ID == f.ID || n.Hidden || n.Popup || n.Neighbors&opposite(side) == 0 { | |
| 481 | + | continue | |
| 482 | + | } | |
| 483 | + | if part, ok := focusedHalf(line, *f, n.Rect, side, s.Border.Width); ok { | |
| 484 | + | add(physRect(part.X, part.Y, part.W, part.H), parseColor(s.Border.Active)) | |
| 482 | 485 | } | |
| 483 | - | add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active)) | |
| 484 | 486 | } | |
| 485 | 487 | } | |
| 486 | 488 | } | |
| @@ -639,18 +641,66 @@ func borderStrips(r ports.Rect, sides ports.Sides, out, b int) []ports.Rect { | |||
| 639 | 641 | return strips | |
| 640 | 642 | } | |
| 641 | 643 | ||
| 642 | - | // halfStrip keeps the half of a separator strip owned by the window on | |
| 643 | - | // side of it: the first half for the left or top window, else the second. | |
| 644 | - | func halfStrip(r ports.Rect, side ports.Sides) ports.Rect { | |
| 644 | + | // opposite is the side facing side. | |
| 645 | + | func opposite(side ports.Sides) ports.Sides { | |
| 645 | 646 | switch side { | |
| 646 | - | case ports.SideRight: | |
| 647 | - | r.H /= 2 | |
| 648 | 647 | case ports.SideLeft: | |
| 649 | - | r.Y, r.H = r.Y+r.H/2, r.H-r.H/2 | |
| 650 | - | case ports.SideBottom: | |
| 651 | - | r.W /= 2 | |
| 648 | + | return ports.SideRight | |
| 649 | + | case ports.SideRight: | |
| 650 | + | return ports.SideLeft | |
| 652 | 651 | case ports.SideTop: | |
| 653 | - | r.X, r.W = r.X+r.W/2, r.W-r.W/2 | |
| 652 | + | return ports.SideBottom | |
| 653 | + | } | |
| 654 | + | return ports.SideTop | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | // focusedHalf is the part of line, along side of window w, that w owns | |
| 658 | + | // in the stretch it shares with neighbor n: the first half when w is the | |
| 659 | + | // left or top window, else the second. ok is false when n is not across | |
| 660 | + | // side or they share no stretch. A part reaching a corner of w whose | |
| 661 | + | // crossing line lies outside w (a neighbor's) runs b further to close | |
| 662 | + | // the corner. | |
| 663 | + | func focusedHalf(line ports.Rect, w ports.SceneWindow, n ports.Rect, side ports.Sides, b int) (ports.Rect, bool) { | |
| 664 | + | f := w.Rect | |
| 665 | + | outside := func(s ports.Sides) bool { return w.Neighbors&s != 0 && w.Inset&s == 0 } | |
| 666 | + | var lo, hi int | |
| 667 | + | switch side { | |
| 668 | + | case ports.SideRight, ports.SideLeft: | |
| 669 | + | if side == ports.SideRight && n.X < f.X+f.W || side == ports.SideLeft && n.X+n.W > f.X { | |
| 670 | + | return ports.Rect{}, false | |
| 671 | + | } | |
| 672 | + | lo, hi = max(f.Y, n.Y), min(f.Y+f.H, n.Y+n.H) | |
| 673 | + | default: | |
| 674 | + | if side == ports.SideBottom && n.Y < f.Y+f.H || side == ports.SideTop && n.Y+n.H > f.Y { | |
| 675 | + | return ports.Rect{}, false | |
| 676 | + | } | |
| 677 | + | lo, hi = max(f.X, n.X), min(f.X+f.W, n.X+n.W) | |
| 678 | + | } | |
| 679 | + | if hi <= lo { | |
| 680 | + | return ports.Rect{}, false | |
| 681 | + | } | |
| 682 | + | mid := lo + (hi-lo)/2 | |
| 683 | + | if side == ports.SideLeft || side == ports.SideTop { | |
| 684 | + | lo = mid | |
| 685 | + | } else { | |
| 686 | + | hi = mid | |
| 687 | + | } | |
| 688 | + | if side == ports.SideRight || side == ports.SideLeft { | |
| 689 | + | if lo == f.Y && outside(ports.SideTop) { | |
| 690 | + | lo -= b | |
| 691 | + | } | |
| 692 | + | if hi == f.Y+f.H && outside(ports.SideBottom) { | |
| 693 | + | hi += b | |
| 694 | + | } | |
| 695 | + | line.Y, line.H = lo, hi-lo | |
| 696 | + | } else { | |
| 697 | + | if lo == f.X && outside(ports.SideLeft) { | |
| 698 | + | lo -= b | |
| 699 | + | } | |
| 700 | + | if hi == f.X+f.W && outside(ports.SideRight) { | |
| 701 | + | hi += b | |
| 702 | + | } | |
| 703 | + | line.X, line.W = lo, hi-lo | |
| 654 | 704 | } | |
| 655 | - | return r | |
| 705 | + | return line, true | |
| 656 | 706 | } | |
| @@ -458,29 +458,31 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 458 | } | 458 | } | ||
| 459 | // The focused window lights the lines along its own sides only: its | 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. | 460 | // inset border, and its neighbors' border line where they own it. | ||
| 461 | - | // With exactly two tiles one line splits them: like tmux, the focused | 461 | + | // tmux style: each stretch of line shared by two tiles is split in |
| 462 | - | // window lights only its half of it (left/top window: first half). | 462 | + | // half, the left or top window owning the first half. The focused |
| 463 | + | // window lights its half of every stretch along its sides. | |||
| 463 | if focused != nil && s.Border.Active != "" && s.Border.Width > 0 { | 464 | if focused != nil && s.Border.Active != "" && s.Border.Width > 0 { | ||
| 464 | - | f, tiles := focused, 0 | 465 | + | f := focused |
| 465 | - | for _, w := range s.Windows { | 466 | + | // Inset sides with no neighbor (floats) light fully. |
| 466 | - | if !w.Hidden && !w.Popup && w.Neighbors != 0 { | 467 | + | for _, strip := range borderStrips(f.Rect, f.Inset&^f.Neighbors, 0, s.Border.Width) { |
| 467 | - | tiles++ | 468 | + | add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active)) |
| 468 | - | } | |||
| 469 | } | 469 | } | ||
| 470 | for _, side := range []ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom} { | 470 | for _, side := range []ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom} { | ||
| 471 | - | out := 0 | 471 | + | if f.Neighbors&side == 0 { |
| 472 | - | switch { | |||
| 473 | - | case f.Inset&side != 0: | |||
| 474 | - | case f.Neighbors&side != 0: | |||
| 475 | - | out = s.Border.Width | |||
| 476 | - | default: | |||
| 477 | continue | 472 | continue | ||
| 478 | } | 473 | } | ||
| 479 | - | for _, strip := range borderStrips(f.Rect, side, out, s.Border.Width) { | 474 | + | out := s.Border.Width |
| 480 | - | if tiles == 2 { | 475 | + | if f.Inset&side != 0 { |
| 481 | - | strip = halfStrip(strip, side) | 476 | + | out = 0 |
| 477 | + | } | |||
| 478 | + | line := borderStrips(f.Rect, side, out, s.Border.Width)[0] | |||
| 479 | + | for _, n := range s.Windows { | |||
| 480 | + | if n.ID == f.ID || n.Hidden || n.Popup || n.Neighbors&opposite(side) == 0 { | |||
| 481 | + | continue | |||
| 482 | + | } | |||
| 483 | + | if part, ok := focusedHalf(line, *f, n.Rect, side, s.Border.Width); ok { | |||
| 484 | + | add(physRect(part.X, part.Y, part.W, part.H), parseColor(s.Border.Active)) | |||
| 482 | } | 485 | } | ||
| 483 | - | add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active)) | |||
| 484 | } | 486 | } | ||
| 485 | } | 487 | } | ||
| 486 | } | 488 | } | ||
| @@ -639,18 +641,66 @@ func borderStrips(r ports.Rect, sides ports.Sides, out, b int) []ports.Rect { | |||||
| 639 | return strips | 641 | return strips | ||
| 640 | } | 642 | } | ||
| 641 | 643 | ||||
| 642 | - | // halfStrip keeps the half of a separator strip owned by the window on | 644 | + | // opposite is the side facing side. |
| 643 | - | // side of it: the first half for the left or top window, else the second. | 645 | + | func opposite(side ports.Sides) ports.Sides { |
| 644 | - | func halfStrip(r ports.Rect, side ports.Sides) ports.Rect { | |||
| 645 | switch side { | 646 | switch side { | ||
| 646 | - | case ports.SideRight: | |||
| 647 | - | r.H /= 2 | |||
| 648 | case ports.SideLeft: | 647 | case ports.SideLeft: | ||
| 649 | - | r.Y, r.H = r.Y+r.H/2, r.H-r.H/2 | 648 | + | return ports.SideRight |
| 650 | - | case ports.SideBottom: | 649 | + | case ports.SideRight: |
| 651 | - | r.W /= 2 | 650 | + | return ports.SideLeft |
| 652 | case ports.SideTop: | 651 | case ports.SideTop: | ||
| 653 | - | r.X, r.W = r.X+r.W/2, r.W-r.W/2 | 652 | + | return ports.SideBottom |
| 653 | + | } | |||
| 654 | + | return ports.SideTop | |||
| 655 | + | } | |||
| 656 | + | ||||
| 657 | + | // focusedHalf is the part of line, along side of window w, that w owns | |||
| 658 | + | // in the stretch it shares with neighbor n: the first half when w is the | |||
| 659 | + | // left or top window, else the second. ok is false when n is not across | |||
| 660 | + | // side or they share no stretch. A part reaching a corner of w whose | |||
| 661 | + | // crossing line lies outside w (a neighbor's) runs b further to close | |||
| 662 | + | // the corner. | |||
| 663 | + | func focusedHalf(line ports.Rect, w ports.SceneWindow, n ports.Rect, side ports.Sides, b int) (ports.Rect, bool) { | |||
| 664 | + | f := w.Rect | |||
| 665 | + | outside := func(s ports.Sides) bool { return w.Neighbors&s != 0 && w.Inset&s == 0 } | |||
| 666 | + | var lo, hi int | |||
| 667 | + | switch side { | |||
| 668 | + | case ports.SideRight, ports.SideLeft: | |||
| 669 | + | if side == ports.SideRight && n.X < f.X+f.W || side == ports.SideLeft && n.X+n.W > f.X { | |||
| 670 | + | return ports.Rect{}, false | |||
| 671 | + | } | |||
| 672 | + | lo, hi = max(f.Y, n.Y), min(f.Y+f.H, n.Y+n.H) | |||
| 673 | + | default: | |||
| 674 | + | if side == ports.SideBottom && n.Y < f.Y+f.H || side == ports.SideTop && n.Y+n.H > f.Y { | |||
| 675 | + | return ports.Rect{}, false | |||
| 676 | + | } | |||
| 677 | + | lo, hi = max(f.X, n.X), min(f.X+f.W, n.X+n.W) | |||
| 678 | + | } | |||
| 679 | + | if hi <= lo { | |||
| 680 | + | return ports.Rect{}, false | |||
| 681 | + | } | |||
| 682 | + | mid := lo + (hi-lo)/2 | |||
| 683 | + | if side == ports.SideLeft || side == ports.SideTop { | |||
| 684 | + | lo = mid | |||
| 685 | + | } else { | |||
| 686 | + | hi = mid | |||
| 687 | + | } | |||
| 688 | + | if side == ports.SideRight || side == ports.SideLeft { | |||
| 689 | + | if lo == f.Y && outside(ports.SideTop) { | |||
| 690 | + | lo -= b | |||
| 691 | + | } | |||
| 692 | + | if hi == f.Y+f.H && outside(ports.SideBottom) { | |||
| 693 | + | hi += b | |||
| 694 | + | } | |||
| 695 | + | line.Y, line.H = lo, hi-lo | |||
| 696 | + | } else { | |||
| 697 | + | if lo == f.X && outside(ports.SideLeft) { | |||
| 698 | + | lo -= b | |||
| 699 | + | } | |||
| 700 | + | if hi == f.X+f.W && outside(ports.SideRight) { | |||
| 701 | + | hi += b | |||
| 702 | + | } | |||
| 703 | + | line.X, line.W = lo, hi-lo | |||
| 654 | } | 704 | } | ||
| 655 | - | return r | 705 | + | return line, true |
| 656 | } | 706 | } | ||
M internal/adapters/vulkan/renderer_test.go +23 -0
| @@ -77,6 +77,29 @@ func TestRendererRender(t *testing.T) { | |||
| 77 | 77 | check(pair, map[image.Point]color.RGBA{{14, 3}: lit, {14, 12}: gray}) | |
| 78 | 78 | pair.Windows[0].Focused = false | |
| 79 | 79 | check(pair, map[image.Point]color.RGBA{{14, 5}: gray, {15, 5}: gray}) | |
| 80 | + | // Three columns: each line is split on its own. Focus in the middle | |
| 81 | + | // lights the bottom half of the left line and the top half of the right one. | |
| 82 | + | three := s | |
| 83 | + | three.Windows = []ports.SceneWindow{ | |
| 84 | + | {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideRight}, | |
| 85 | + | {ID: 2, Rect: ports.Rect{X: 16, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideLeft | ports.SideRight, Focused: true}, | |
| 86 | + | {ID: 3, Rect: ports.Rect{X: 32, Y: 0, W: 16, H: 16}, Neighbors: ports.SideLeft}, | |
| 87 | + | } | |
| 88 | + | check(three, map[image.Point]color.RGBA{{14, 3}: gray, {14, 12}: lit, {30, 3}: lit, {30, 12}: gray}) | |
| 89 | + | // Bottom-right tile of a split column: its top line (owned by the tile | |
| 90 | + | // above) meets the left line (owned by the left column); lit halves | |
| 91 | + | // ending at that corner close it. | |
| 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}: gray, {24, 14}: lit, {17, 14}: gray}) | |
| 99 | + | grid.Windows[2].Focused, grid.Windows[1].Focused = false, true | |
| 100 | + | // Top-right tile: left line bottom half and bottom line left half meet | |
| 101 | + | // at the corner inside the left column's line. | |
| 102 | + | check(grid, map[image.Point]color.RGBA{{14, 14}: lit, {14, 15}: lit, {16, 14}: lit, {14, 3}: gray, {30, 14}: gray}) | |
| 80 | 103 | s.Windows[0].Fullscreen = true | |
| 81 | 104 | check(s, map[image.Point]color.RGBA{{9, 9}: wc}) | |
| 82 | 105 | s.Windows[0].Fullscreen = false | |
| @@ -77,6 +77,29 @@ func TestRendererRender(t *testing.T) { | |||||
| 77 | check(pair, map[image.Point]color.RGBA{{14, 3}: lit, {14, 12}: gray}) | 77 | check(pair, map[image.Point]color.RGBA{{14, 3}: lit, {14, 12}: gray}) | ||
| 78 | pair.Windows[0].Focused = false | 78 | pair.Windows[0].Focused = false | ||
| 79 | check(pair, map[image.Point]color.RGBA{{14, 5}: gray, {15, 5}: gray}) | 79 | check(pair, map[image.Point]color.RGBA{{14, 5}: gray, {15, 5}: gray}) | ||
| 80 | + | // Three columns: each line is split on its own. Focus in the middle | |||
| 81 | + | // lights the bottom half of the left line and the top half of the right one. | |||
| 82 | + | three := s | |||
| 83 | + | three.Windows = []ports.SceneWindow{ | |||
| 84 | + | {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideRight}, | |||
| 85 | + | {ID: 2, Rect: ports.Rect{X: 16, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideLeft | ports.SideRight, Focused: true}, | |||
| 86 | + | {ID: 3, Rect: ports.Rect{X: 32, Y: 0, W: 16, H: 16}, Neighbors: ports.SideLeft}, | |||
| 87 | + | } | |||
| 88 | + | check(three, map[image.Point]color.RGBA{{14, 3}: gray, {14, 12}: lit, {30, 3}: lit, {30, 12}: gray}) | |||
| 89 | + | // Bottom-right tile of a split column: its top line (owned by the tile | |||
| 90 | + | // above) meets the left line (owned by the left column); lit halves | |||
| 91 | + | // ending at that corner close it. | |||
| 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}: gray, {24, 14}: lit, {17, 14}: gray}) | |||
| 99 | + | grid.Windows[2].Focused, grid.Windows[1].Focused = false, true | |||
| 100 | + | // Top-right tile: left line bottom half and bottom line left half meet | |||
| 101 | + | // at the corner inside the left column's line. | |||
| 102 | + | check(grid, map[image.Point]color.RGBA{{14, 14}: lit, {14, 15}: lit, {16, 14}: lit, {14, 3}: gray, {30, 14}: gray}) | |||
| 80 | s.Windows[0].Fullscreen = true | 103 | s.Windows[0].Fullscreen = true | ||
| 81 | check(s, map[image.Point]color.RGBA{{9, 9}: wc}) | 104 | check(s, map[image.Point]color.RGBA{{9, 9}: wc}) | ||
| 82 | s.Windows[0].Fullscreen = false | 105 | s.Windows[0].Fullscreen = false | ||