fix(core): align borders and insets with visible output tiles (#51)
Neighbors and insets come from tiles visible on the output, so a lone tile has no line and no reserved border. The two-tile half rule counts visible tiles, lines are clipped to the output, and fullscreen floats and stashed windows drop their inset. A seeded invariant test checks published scenes across outputs.
- commit
- b3c68fb77fbc584181750076f3f0433ce75a9605
- author
- bnema · · 2026-09-28 18:18 UTC
- committer
- GitHub · · 2026-09-28 18:18 UTC
- parent
- 2df03cd
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
6 files · +256 -41
M docs/config.md +2 -2
| @@ -21,12 +21,12 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 21 | 21 | | `startup` | none | Command run once at session start; repeat the key for more | | |
| 22 | 22 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | |
| 23 | 23 | | `background` | `#111111` | Solid background color | | |
| 24 | - | | `border.width` | `2` | Separator lines between tiles, in logical pixels; `0` hides them | | |
| 24 | + | | `border.width` | `2` | Lines between visible neighboring tiles and around non-fullscreen floats, in logical pixels; `0` hides them | | |
| 25 | 25 | | `floating.dim` | `0.3` | Black veil over tiles and lower layers when a native floating window (a dialog) or the stash is visible; opacity 0 to 1 | | |
| 26 | 26 | | `stash.width` | `80` | Width of the selected stashed window, over the tiles, in percent of the usable width; 10 to 90 (`80%` works too). The rest is split between both sides, where the neighbors show. Its height is 80% of the usable height | | |
| 27 | 27 | | `stash.gap` | `2` | Space between the selected stashed window and its neighbors, in percent of the usable width; 0 to 10. The neighbors show from there to the screen edge: with the defaults, 8% of each. A gap as wide as the side margin hides them | | |
| 28 | 28 | | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 | | |
| 29 | - | | `border.active` / `border.inactive` | `#808080` / `#111111` | Separator colors around the focused / other windows | | |
| 29 | + | | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up | | |
| 30 | 30 | | `layout.gaps` | `0` | Space between windows | | |
| 31 | 31 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | |
| 32 | 32 | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | |
| @@ -21,12 +21,12 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 21 | | `startup` | none | Command run once at session start; repeat the key for more | | 21 | | `startup` | none | Command run once at session start; repeat the key for more | | ||
| 22 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | 22 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | ||
| 23 | | `background` | `#111111` | Solid background color | | 23 | | `background` | `#111111` | Solid background color | | ||
| 24 | - | | `border.width` | `2` | Separator lines between tiles, in logical pixels; `0` hides them | | 24 | + | | `border.width` | `2` | Lines between visible neighboring tiles and around non-fullscreen floats, in logical pixels; `0` hides them | |
| 25 | | `floating.dim` | `0.3` | Black veil over tiles and lower layers when a native floating window (a dialog) or the stash is visible; opacity 0 to 1 | | 25 | | `floating.dim` | `0.3` | Black veil over tiles and lower layers when a native floating window (a dialog) or the stash is visible; opacity 0 to 1 | | ||
| 26 | | `stash.width` | `80` | Width of the selected stashed window, over the tiles, in percent of the usable width; 10 to 90 (`80%` works too). The rest is split between both sides, where the neighbors show. Its height is 80% of the usable height | | 26 | | `stash.width` | `80` | Width of the selected stashed window, over the tiles, in percent of the usable width; 10 to 90 (`80%` works too). The rest is split between both sides, where the neighbors show. Its height is 80% of the usable height | | ||
| 27 | | `stash.gap` | `2` | Space between the selected stashed window and its neighbors, in percent of the usable width; 0 to 10. The neighbors show from there to the screen edge: with the defaults, 8% of each. A gap as wide as the side margin hides them | | 27 | | `stash.gap` | `2` | Space between the selected stashed window and its neighbors, in percent of the usable width; 0 to 10. The neighbors show from there to the screen edge: with the defaults, 8% of each. A gap as wide as the side margin hides them | | ||
| 28 | | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 | | 28 | | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 | | ||
| 29 | - | | `border.active` / `border.inactive` | `#808080` / `#111111` | Separator colors around the focused / other windows | | 29 | + | | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up | |
| 30 | | `layout.gaps` | `0` | Space between windows | | 30 | | `layout.gaps` | `0` | Space between windows | | ||
| 31 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | 31 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | ||
| 32 | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | 32 | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | ||
A internal/core/border_invariants_test.go +202 -0
| @@ -0,0 +1,202 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math/rand" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Published scenes, not just layouts: exercise the real owner loop, binds, | |
| 11 | + | // monitor transfers, unmaps, and both kinds of overflow with a fixed seed. | |
| 12 | + | func TestBorderSceneInvariants(t *testing.T) { | |
| 13 | + | for _, overflow := range []string{"fixed", "scroll"} { | |
| 14 | + | for _, gap := range []int{0, 6} { | |
| 15 | + | t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) { | |
| 16 | + | rng := rand.New(rand.NewSource(20260928)) | |
| 17 | + | r := startMulti(t, func(c *ports.Config) { | |
| 18 | + | c.Border.Width = 2 | |
| 19 | + | c.Layout.MaxColumns = 3 | |
| 20 | + | c.Layout.Overflow = overflow | |
| 21 | + | c.Layout.Gaps = gap | |
| 22 | + | }, left, right) | |
| 23 | + | var ids []ports.WindowID | |
| 24 | + | next := ports.WindowID(1) | |
| 25 | + | // A single keypress may publish a scene even when the action is | |
| 26 | + | // inapplicable; check every resulting scene independently. | |
| 27 | + | keys := []struct { | |
| 28 | + | sym string | |
| 29 | + | mods ports.Mods | |
| 30 | + | }{ | |
| 31 | + | {"Left", ports.ModAlt | ports.ModShift}, {"Right", ports.ModAlt | ports.ModShift}, | |
| 32 | + | {"Left", ports.ModAlt}, {"Right", ports.ModAlt}, | |
| 33 | + | {"Left", ports.ModAlt | ports.ModCtrl}, {"Right", ports.ModAlt | ports.ModCtrl}, | |
| 34 | + | {"Left", ports.ModAlt | ports.ModCtrl | ports.ModShift}, {"Right", ports.ModAlt | ports.ModCtrl | ports.ModShift}, | |
| 35 | + | {"bracketleft", ports.ModAlt}, {"bracketright", ports.ModAlt}, | |
| 36 | + | {"f", ports.ModAlt}, {"f", ports.ModAlt | ports.ModShift}, | |
| 37 | + | {"s", ports.ModAlt}, {"S", ports.ModAlt | ports.ModShift}, {"Up", ports.ModAlt}, {"Down", ports.ModAlt}, | |
| 38 | + | } | |
| 39 | + | for step := range 180 { | |
| 40 | + | var scenes []ports.Scene | |
| 41 | + | switch { | |
| 42 | + | case len(ids) < 2 || rng.Intn(5) == 0 && len(ids) < 12: | |
| 43 | + | scenes = r.mapWindow(t, next) | |
| 44 | + | ids = append(ids, next) | |
| 45 | + | next++ | |
| 46 | + | case rng.Intn(9) == 0: | |
| 47 | + | i := rng.Intn(len(ids)) | |
| 48 | + | r.client <- ports.WindowUnmapped{ID: ids[i]} | |
| 49 | + | scenes = receive(t, r.scenes) | |
| 50 | + | ids = append(ids[:i], ids[i+1:]...) | |
| 51 | + | default: | |
| 52 | + | k := keys[rng.Intn(len(keys))] | |
| 53 | + | scenes = r.key(t, k.sym, k.mods) | |
| 54 | + | } | |
| 55 | + | checkBorderScenes(t, step, gap, scenes) | |
| 56 | + | } | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func checkBorderScenes(t *testing.T, step, gap int, scenes []ports.Scene) { | |
| 63 | + | t.Helper() | |
| 64 | + | focused := 0 | |
| 65 | + | for _, s := range scenes { | |
| 66 | + | checkBorderScene(t, step, gap, s) | |
| 67 | + | for _, w := range s.Windows { | |
| 68 | + | if w.Focused && !w.Hidden && !w.Popup && w.Rect.Overlaps(ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) { | |
| 69 | + | focused++ | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | if focused > 1 { | |
| 74 | + | t.Fatalf("step %d: focused windows on multiple outputs: %+v", step, scenes) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) { | |
| 79 | + | t.Helper() | |
| 80 | + | o := ports.Rect{W: s.OutputWidth, H: s.OutputHeight} | |
| 81 | + | var focus *ports.SceneWindow | |
| 82 | + | tiles := 0 | |
| 83 | + | for i := range s.Windows { | |
| 84 | + | w := &s.Windows[i] | |
| 85 | + | if w.Popup || w.Hidden || !w.Rect.Overlaps(o) { | |
| 86 | + | continue | |
| 87 | + | } | |
| 88 | + | if w.Focused { | |
| 89 | + | if focus != nil { | |
| 90 | + | t.Fatalf("step %d %s: two focused windows: %+v", step, s.Output, s.Windows) | |
| 91 | + | } | |
| 92 | + | focus = w | |
| 93 | + | } | |
| 94 | + | if !w.Floating && !w.Fullscreen { | |
| 95 | + | tiles++ | |
| 96 | + | } | |
| 97 | + | if w.Fullscreen && w.Inset != 0 { | |
| 98 | + | t.Fatalf("step %d %s: fullscreen inset: %+v", step, s.Output, w) | |
| 99 | + | } | |
| 100 | + | if !w.Floating && !w.Fullscreen { | |
| 101 | + | if got, want := w.Inset, wantInset(s, *w, gap); got != want { | |
| 102 | + | t.Fatalf("step %d %s: tile %d inset %v, want %v; scene %+v", step, s.Output, w.ID, got, want, s) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | if tiles <= 1 { | |
| 107 | + | for _, w := range s.Windows { | |
| 108 | + | if !w.Floating && !w.Popup && !w.Hidden && w.Rect.Overlaps(o) && w.Inset != 0 { | |
| 109 | + | t.Fatalf("step %d %s: lone tile reserves a border: %+v; scene %+v", step, s.Output, w, s) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | for _, sep := range s.Separators { | |
| 114 | + | r := sep.Rect | |
| 115 | + | if r.W <= 0 || r.H <= 0 || r.X < 0 || r.Y < 0 || r.X+r.W > o.W || r.Y+r.H > o.H { | |
| 116 | + | t.Fatalf("step %d %s: line outside output: %+v; scene %+v", step, s.Output, sep, s) | |
| 117 | + | } | |
| 118 | + | if sep.Window != 0 { | |
| 119 | + | found := false | |
| 120 | + | for _, w := range s.Windows { | |
| 121 | + | if w.ID == sep.Window && w.Floating && !w.Fullscreen && !w.Hidden && w.Rect.Overlaps(r) { | |
| 122 | + | found = true | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | if !found { | |
| 126 | + | t.Fatalf("step %d %s: line belongs to invisible float: %+v; scene %+v", step, s.Output, sep, s) | |
| 127 | + | } | |
| 128 | + | } else if tiles < 2 { | |
| 129 | + | t.Fatalf("step %d %s: lone tile has shared line: %+v; scene %+v", step, s.Output, sep, s) | |
| 130 | + | } | |
| 131 | + | if sep.Active && (focus == nil || focus.Fullscreen || sep.Window != 0 && sep.Window != focus.ID || sep.Window == 0 && focus.Floating) { | |
| 132 | + | t.Fatalf("step %d %s: lit line without matching focused tile/float: %+v; scene %+v", step, s.Output, sep, s) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | // A focused tile with a visible neighbor lights at least one line | |
| 136 | + | // touching it. | |
| 137 | + | if focus == nil || focus.Floating || focus.Fullscreen || neighborSides(s, *focus, gap) == 0 { | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | near := ports.Rect{X: focus.Rect.X - 2, Y: focus.Rect.Y - 2, W: focus.Rect.W + 4, H: focus.Rect.H + 4} | |
| 141 | + | for _, sep := range s.Separators { | |
| 142 | + | if sep.Active && sep.Window == 0 && sep.Rect.Overlaps(near) { | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | t.Fatalf("step %d %s: focused tile %d has neighbors but no lit line; scene %+v", step, s.Output, focus.ID, s) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // neighborSides are the sides of tile w facing another visible tile across | |
| 150 | + | // gap, with the shared edge strictly inside the output. | |
| 151 | + | func neighborSides(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides { | |
| 152 | + | o := ports.Rect{W: s.OutputWidth, H: s.OutputHeight} | |
| 153 | + | var sides ports.Sides | |
| 154 | + | for _, other := range s.Windows { | |
| 155 | + | if other.ID == w.ID || other.Popup || other.Floating || other.Fullscreen || other.Hidden || !other.Rect.Overlaps(o) { | |
| 156 | + | continue | |
| 157 | + | } | |
| 158 | + | a, b := w.Rect, other.Rect | |
| 159 | + | rows := a.Y < b.Y+b.H && b.Y < a.Y+a.H | |
| 160 | + | cols := a.X < b.X+b.W && b.X < a.X+a.W | |
| 161 | + | switch { | |
| 162 | + | case rows && a.X+a.W+gap == b.X && a.X+a.W > 0 && a.X+a.W < o.W: | |
| 163 | + | sides |= ports.SideRight | |
| 164 | + | case rows && b.X+b.W+gap == a.X && a.X > 0 && a.X < o.W: | |
| 165 | + | sides |= ports.SideLeft | |
| 166 | + | case cols && a.Y+a.H+gap == b.Y && a.Y+a.H > 0 && a.Y+a.H < o.H: | |
| 167 | + | sides |= ports.SideBottom | |
| 168 | + | case cols && b.Y+b.H+gap == a.Y && a.Y > 0 && a.Y < o.H: | |
| 169 | + | sides |= ports.SideTop | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | return sides | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // wantInset is the exact inset of a visible tile: every neighbor side with | |
| 176 | + | // gaps, only the right and bottom (shared-line owner) sides without. | |
| 177 | + | func wantInset(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides { | |
| 178 | + | sides := neighborSides(s, w, gap) | |
| 179 | + | if gap == 0 { | |
| 180 | + | sides &= ports.SideRight | ports.SideBottom | |
| 181 | + | } | |
| 182 | + | return sides | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // Regression for the original two-monitor transfer: when the last tile | |
| 186 | + | // comes back, neither the old output nor the new one has a shared line. | |
| 187 | + | func TestBorderMoveBackAlone(t *testing.T) { | |
| 188 | + | r := startMulti(t, func(c *ports.Config) { | |
| 189 | + | c.Border.Width = 2 | |
| 190 | + | c.Layout.MaxColumns = 3 | |
| 191 | + | c.Layout.Overflow = "fixed" | |
| 192 | + | }, left, right) | |
| 193 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 194 | + | r.mapWindow(t, 1) | |
| 195 | + | for _, sym := range []string{"Left", "Right"} { | |
| 196 | + | scenes := r.key(t, sym, ports.ModAlt|ports.ModShift) | |
| 197 | + | checkBorderScenes(t, 0, 0, scenes) | |
| 198 | + | if got := shown(scenes); len(got["DP-1"]) != map[string]int{"Left": 1, "Right": 0}[sym] || len(got["DP-2"]) != map[string]int{"Left": 0, "Right": 1}[sym] { | |
| 199 | + | t.Fatalf("%s: window did not move: %v", sym, got) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | } | |
| @@ -0,0 +1,202 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math/rand" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Published scenes, not just layouts: exercise the real owner loop, binds, | |||
| 11 | + | // monitor transfers, unmaps, and both kinds of overflow with a fixed seed. | |||
| 12 | + | func TestBorderSceneInvariants(t *testing.T) { | |||
| 13 | + | for _, overflow := range []string{"fixed", "scroll"} { | |||
| 14 | + | for _, gap := range []int{0, 6} { | |||
| 15 | + | t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) { | |||
| 16 | + | rng := rand.New(rand.NewSource(20260928)) | |||
| 17 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 18 | + | c.Border.Width = 2 | |||
| 19 | + | c.Layout.MaxColumns = 3 | |||
| 20 | + | c.Layout.Overflow = overflow | |||
| 21 | + | c.Layout.Gaps = gap | |||
| 22 | + | }, left, right) | |||
| 23 | + | var ids []ports.WindowID | |||
| 24 | + | next := ports.WindowID(1) | |||
| 25 | + | // A single keypress may publish a scene even when the action is | |||
| 26 | + | // inapplicable; check every resulting scene independently. | |||
| 27 | + | keys := []struct { | |||
| 28 | + | sym string | |||
| 29 | + | mods ports.Mods | |||
| 30 | + | }{ | |||
| 31 | + | {"Left", ports.ModAlt | ports.ModShift}, {"Right", ports.ModAlt | ports.ModShift}, | |||
| 32 | + | {"Left", ports.ModAlt}, {"Right", ports.ModAlt}, | |||
| 33 | + | {"Left", ports.ModAlt | ports.ModCtrl}, {"Right", ports.ModAlt | ports.ModCtrl}, | |||
| 34 | + | {"Left", ports.ModAlt | ports.ModCtrl | ports.ModShift}, {"Right", ports.ModAlt | ports.ModCtrl | ports.ModShift}, | |||
| 35 | + | {"bracketleft", ports.ModAlt}, {"bracketright", ports.ModAlt}, | |||
| 36 | + | {"f", ports.ModAlt}, {"f", ports.ModAlt | ports.ModShift}, | |||
| 37 | + | {"s", ports.ModAlt}, {"S", ports.ModAlt | ports.ModShift}, {"Up", ports.ModAlt}, {"Down", ports.ModAlt}, | |||
| 38 | + | } | |||
| 39 | + | for step := range 180 { | |||
| 40 | + | var scenes []ports.Scene | |||
| 41 | + | switch { | |||
| 42 | + | case len(ids) < 2 || rng.Intn(5) == 0 && len(ids) < 12: | |||
| 43 | + | scenes = r.mapWindow(t, next) | |||
| 44 | + | ids = append(ids, next) | |||
| 45 | + | next++ | |||
| 46 | + | case rng.Intn(9) == 0: | |||
| 47 | + | i := rng.Intn(len(ids)) | |||
| 48 | + | r.client <- ports.WindowUnmapped{ID: ids[i]} | |||
| 49 | + | scenes = receive(t, r.scenes) | |||
| 50 | + | ids = append(ids[:i], ids[i+1:]...) | |||
| 51 | + | default: | |||
| 52 | + | k := keys[rng.Intn(len(keys))] | |||
| 53 | + | scenes = r.key(t, k.sym, k.mods) | |||
| 54 | + | } | |||
| 55 | + | checkBorderScenes(t, step, gap, scenes) | |||
| 56 | + | } | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func checkBorderScenes(t *testing.T, step, gap int, scenes []ports.Scene) { | |||
| 63 | + | t.Helper() | |||
| 64 | + | focused := 0 | |||
| 65 | + | for _, s := range scenes { | |||
| 66 | + | checkBorderScene(t, step, gap, s) | |||
| 67 | + | for _, w := range s.Windows { | |||
| 68 | + | if w.Focused && !w.Hidden && !w.Popup && w.Rect.Overlaps(ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) { | |||
| 69 | + | focused++ | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | if focused > 1 { | |||
| 74 | + | t.Fatalf("step %d: focused windows on multiple outputs: %+v", step, scenes) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) { | |||
| 79 | + | t.Helper() | |||
| 80 | + | o := ports.Rect{W: s.OutputWidth, H: s.OutputHeight} | |||
| 81 | + | var focus *ports.SceneWindow | |||
| 82 | + | tiles := 0 | |||
| 83 | + | for i := range s.Windows { | |||
| 84 | + | w := &s.Windows[i] | |||
| 85 | + | if w.Popup || w.Hidden || !w.Rect.Overlaps(o) { | |||
| 86 | + | continue | |||
| 87 | + | } | |||
| 88 | + | if w.Focused { | |||
| 89 | + | if focus != nil { | |||
| 90 | + | t.Fatalf("step %d %s: two focused windows: %+v", step, s.Output, s.Windows) | |||
| 91 | + | } | |||
| 92 | + | focus = w | |||
| 93 | + | } | |||
| 94 | + | if !w.Floating && !w.Fullscreen { | |||
| 95 | + | tiles++ | |||
| 96 | + | } | |||
| 97 | + | if w.Fullscreen && w.Inset != 0 { | |||
| 98 | + | t.Fatalf("step %d %s: fullscreen inset: %+v", step, s.Output, w) | |||
| 99 | + | } | |||
| 100 | + | if !w.Floating && !w.Fullscreen { | |||
| 101 | + | if got, want := w.Inset, wantInset(s, *w, gap); got != want { | |||
| 102 | + | t.Fatalf("step %d %s: tile %d inset %v, want %v; scene %+v", step, s.Output, w.ID, got, want, s) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | if tiles <= 1 { | |||
| 107 | + | for _, w := range s.Windows { | |||
| 108 | + | if !w.Floating && !w.Popup && !w.Hidden && w.Rect.Overlaps(o) && w.Inset != 0 { | |||
| 109 | + | t.Fatalf("step %d %s: lone tile reserves a border: %+v; scene %+v", step, s.Output, w, s) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | for _, sep := range s.Separators { | |||
| 114 | + | r := sep.Rect | |||
| 115 | + | if r.W <= 0 || r.H <= 0 || r.X < 0 || r.Y < 0 || r.X+r.W > o.W || r.Y+r.H > o.H { | |||
| 116 | + | t.Fatalf("step %d %s: line outside output: %+v; scene %+v", step, s.Output, sep, s) | |||
| 117 | + | } | |||
| 118 | + | if sep.Window != 0 { | |||
| 119 | + | found := false | |||
| 120 | + | for _, w := range s.Windows { | |||
| 121 | + | if w.ID == sep.Window && w.Floating && !w.Fullscreen && !w.Hidden && w.Rect.Overlaps(r) { | |||
| 122 | + | found = true | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | if !found { | |||
| 126 | + | t.Fatalf("step %d %s: line belongs to invisible float: %+v; scene %+v", step, s.Output, sep, s) | |||
| 127 | + | } | |||
| 128 | + | } else if tiles < 2 { | |||
| 129 | + | t.Fatalf("step %d %s: lone tile has shared line: %+v; scene %+v", step, s.Output, sep, s) | |||
| 130 | + | } | |||
| 131 | + | if sep.Active && (focus == nil || focus.Fullscreen || sep.Window != 0 && sep.Window != focus.ID || sep.Window == 0 && focus.Floating) { | |||
| 132 | + | t.Fatalf("step %d %s: lit line without matching focused tile/float: %+v; scene %+v", step, s.Output, sep, s) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | // A focused tile with a visible neighbor lights at least one line | |||
| 136 | + | // touching it. | |||
| 137 | + | if focus == nil || focus.Floating || focus.Fullscreen || neighborSides(s, *focus, gap) == 0 { | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | near := ports.Rect{X: focus.Rect.X - 2, Y: focus.Rect.Y - 2, W: focus.Rect.W + 4, H: focus.Rect.H + 4} | |||
| 141 | + | for _, sep := range s.Separators { | |||
| 142 | + | if sep.Active && sep.Window == 0 && sep.Rect.Overlaps(near) { | |||
| 143 | + | return | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | t.Fatalf("step %d %s: focused tile %d has neighbors but no lit line; scene %+v", step, s.Output, focus.ID, s) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // neighborSides are the sides of tile w facing another visible tile across | |||
| 150 | + | // gap, with the shared edge strictly inside the output. | |||
| 151 | + | func neighborSides(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides { | |||
| 152 | + | o := ports.Rect{W: s.OutputWidth, H: s.OutputHeight} | |||
| 153 | + | var sides ports.Sides | |||
| 154 | + | for _, other := range s.Windows { | |||
| 155 | + | if other.ID == w.ID || other.Popup || other.Floating || other.Fullscreen || other.Hidden || !other.Rect.Overlaps(o) { | |||
| 156 | + | continue | |||
| 157 | + | } | |||
| 158 | + | a, b := w.Rect, other.Rect | |||
| 159 | + | rows := a.Y < b.Y+b.H && b.Y < a.Y+a.H | |||
| 160 | + | cols := a.X < b.X+b.W && b.X < a.X+a.W | |||
| 161 | + | switch { | |||
| 162 | + | case rows && a.X+a.W+gap == b.X && a.X+a.W > 0 && a.X+a.W < o.W: | |||
| 163 | + | sides |= ports.SideRight | |||
| 164 | + | case rows && b.X+b.W+gap == a.X && a.X > 0 && a.X < o.W: | |||
| 165 | + | sides |= ports.SideLeft | |||
| 166 | + | case cols && a.Y+a.H+gap == b.Y && a.Y+a.H > 0 && a.Y+a.H < o.H: | |||
| 167 | + | sides |= ports.SideBottom | |||
| 168 | + | case cols && b.Y+b.H+gap == a.Y && a.Y > 0 && a.Y < o.H: | |||
| 169 | + | sides |= ports.SideTop | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | return sides | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // wantInset is the exact inset of a visible tile: every neighbor side with | |||
| 176 | + | // gaps, only the right and bottom (shared-line owner) sides without. | |||
| 177 | + | func wantInset(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides { | |||
| 178 | + | sides := neighborSides(s, w, gap) | |||
| 179 | + | if gap == 0 { | |||
| 180 | + | sides &= ports.SideRight | ports.SideBottom | |||
| 181 | + | } | |||
| 182 | + | return sides | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // Regression for the original two-monitor transfer: when the last tile | |||
| 186 | + | // comes back, neither the old output nor the new one has a shared line. | |||
| 187 | + | func TestBorderMoveBackAlone(t *testing.T) { | |||
| 188 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 189 | + | c.Border.Width = 2 | |||
| 190 | + | c.Layout.MaxColumns = 3 | |||
| 191 | + | c.Layout.Overflow = "fixed" | |||
| 192 | + | }, left, right) | |||
| 193 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 194 | + | r.mapWindow(t, 1) | |||
| 195 | + | for _, sym := range []string{"Left", "Right"} { | |||
| 196 | + | scenes := r.key(t, sym, ports.ModAlt|ports.ModShift) | |||
| 197 | + | checkBorderScenes(t, 0, 0, scenes) | |||
| 198 | + | if got := shown(scenes); len(got["DP-1"]) != map[string]int{"Left": 1, "Right": 0}[sym] || len(got["DP-2"]) != map[string]int{"Left": 0, "Right": 1}[sym] { | |||
| 199 | + | t.Fatalf("%s: window did not move: %v", sym, got) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | } | |||
M internal/core/layout.go +21 -18
| @@ -99,11 +99,9 @@ type Placement struct { | |||
| 99 | 99 | Floating bool | |
| 100 | 100 | // Peek is a stashed window peeking in beside the selected one: dimmed. | |
| 101 | 101 | Peek bool | |
| 102 | - | // Neighbors are the sides touching another tiled window. Inset are the | |
| 103 | - | // sides where the border takes room from the window: all sides of a | |
| 104 | - | // float; for tiles, the right and bottom neighbors only, so two | |
| 105 | - | // windows share one separator line (tmux style). With gaps, each tile | |
| 106 | - | // insets all its neighbor sides. | |
| 102 | + | // Neighbors are the sides touching a visible tile on this output. | |
| 103 | + | // Inset reserves room for drawn lines: all sides of a float; for | |
| 104 | + | // tiles without gaps, only the right and bottom shared sides. | |
| 107 | 105 | Neighbors, Inset ports.Sides | |
| 108 | 106 | } | |
| 109 | 107 | ||
| @@ -1002,7 +1000,7 @@ func (w *Workspace) Layout() []Placement { | |||
| 1002 | 1000 | y += h + gap | |
| 1003 | 1001 | } | |
| 1004 | 1002 | } | |
| 1005 | - | setNeighbors(result, gap) | |
| 1003 | + | setVisibleNeighbors(result, gap, w.Output) | |
| 1006 | 1004 | // Floating windows go last: they are drawn and hit on top, native | |
| 1007 | 1005 | // floats (dialogs) over the stash. | |
| 1008 | 1006 | result = append(result, w.stashLayout(focusedID, cover)...) | |
| @@ -1016,36 +1014,41 @@ func (w *Workspace) Layout() []Placement { | |||
| 1016 | 1014 | continue | |
| 1017 | 1015 | } | |
| 1018 | 1016 | if w.fullscreen == f.ID { | |
| 1019 | - | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | |
| 1017 | + | p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0 | |
| 1020 | 1018 | } | |
| 1021 | 1019 | result = append(result, p) | |
| 1022 | 1020 | } | |
| 1023 | 1021 | return result | |
| 1024 | 1022 | } | |
| 1025 | 1023 | ||
| 1026 | - | // setNeighbors marks the sides where tiles touch across the gap, from | |
| 1027 | - | // geometry alone: client sizes do not change as the view scrolls. | |
| 1028 | - | func setNeighbors(tiles []Placement, gap int) { | |
| 1024 | + | // setVisibleNeighbors reserves client space only for shared lines that | |
| 1025 | + | // are actually on this output. A scrolled-off or hidden tile cannot give a | |
| 1026 | + | // lone visible tile a border (or shrink its client). | |
| 1027 | + | func setVisibleNeighbors(tiles []Placement, gap int, output Rect) { | |
| 1028 | + | visible := func(p Placement) bool { | |
| 1029 | + | return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(output) | |
| 1030 | + | } | |
| 1029 | 1031 | overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 } | |
| 1030 | 1032 | for i := range tiles { | |
| 1031 | 1033 | a := &tiles[i] | |
| 1032 | - | if a.Hidden || a.Fullscreen || a.Rect.W <= 0 || a.Rect.H <= 0 { | |
| 1034 | + | a.Neighbors, a.Inset = 0, 0 | |
| 1035 | + | if !visible(*a) { | |
| 1033 | 1036 | continue | |
| 1034 | 1037 | } | |
| 1035 | 1038 | for j := range tiles { | |
| 1036 | - | b := tiles[j].Rect | |
| 1037 | - | if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 { | |
| 1039 | + | if i == j || !visible(tiles[j]) { | |
| 1038 | 1040 | continue | |
| 1039 | 1041 | } | |
| 1040 | - | r := a.Rect | |
| 1042 | + | b, r := tiles[j].Rect, a.Rect | |
| 1043 | + | // Only count a shared edge strictly within this output. | |
| 1041 | 1044 | switch { | |
| 1042 | - | case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H): | |
| 1045 | + | case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > output.X && r.X+r.W < output.X+output.W: | |
| 1043 | 1046 | a.Neighbors |= ports.SideRight | |
| 1044 | - | case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H): | |
| 1047 | + | case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > output.X && r.X < output.X+output.W: | |
| 1045 | 1048 | a.Neighbors |= ports.SideLeft | |
| 1046 | - | case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W): | |
| 1049 | + | case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > output.Y && r.Y+r.H < output.Y+output.H: | |
| 1047 | 1050 | a.Neighbors |= ports.SideBottom | |
| 1048 | - | case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W): | |
| 1051 | + | case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > output.Y && r.Y < output.Y+output.H: | |
| 1049 | 1052 | a.Neighbors |= ports.SideTop | |
| 1050 | 1053 | } | |
| 1051 | 1054 | } | |
| @@ -99,11 +99,9 @@ type Placement struct { | |||||
| 99 | Floating bool | 99 | Floating bool | ||
| 100 | // Peek is a stashed window peeking in beside the selected one: dimmed. | 100 | // Peek is a stashed window peeking in beside the selected one: dimmed. | ||
| 101 | Peek bool | 101 | Peek bool | ||
| 102 | - | // Neighbors are the sides touching another tiled window. Inset are the | 102 | + | // Neighbors are the sides touching a visible tile on this output. |
| 103 | - | // sides where the border takes room from the window: all sides of a | 103 | + | // Inset reserves room for drawn lines: all sides of a float; for |
| 104 | - | // float; for tiles, the right and bottom neighbors only, so two | 104 | + | // tiles without gaps, only the right and bottom shared sides. |
| 105 | - | // windows share one separator line (tmux style). With gaps, each tile | |||
| 106 | - | // insets all its neighbor sides. | |||
| 107 | Neighbors, Inset ports.Sides | 105 | Neighbors, Inset ports.Sides | ||
| 108 | } | 106 | } | ||
| 109 | 107 | ||||
| @@ -1002,7 +1000,7 @@ func (w *Workspace) Layout() []Placement { | |||||
| 1002 | y += h + gap | 1000 | y += h + gap | ||
| 1003 | } | 1001 | } | ||
| 1004 | } | 1002 | } | ||
| 1005 | - | setNeighbors(result, gap) | 1003 | + | setVisibleNeighbors(result, gap, w.Output) |
| 1006 | // Floating windows go last: they are drawn and hit on top, native | 1004 | // Floating windows go last: they are drawn and hit on top, native | ||
| 1007 | // floats (dialogs) over the stash. | 1005 | // floats (dialogs) over the stash. | ||
| 1008 | result = append(result, w.stashLayout(focusedID, cover)...) | 1006 | result = append(result, w.stashLayout(focusedID, cover)...) | ||
| @@ -1016,36 +1014,41 @@ func (w *Workspace) Layout() []Placement { | |||||
| 1016 | continue | 1014 | continue | ||
| 1017 | } | 1015 | } | ||
| 1018 | if w.fullscreen == f.ID { | 1016 | if w.fullscreen == f.ID { | ||
| 1019 | - | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | 1017 | + | p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0 |
| 1020 | } | 1018 | } | ||
| 1021 | result = append(result, p) | 1019 | result = append(result, p) | ||
| 1022 | } | 1020 | } | ||
| 1023 | return result | 1021 | return result | ||
| 1024 | } | 1022 | } | ||
| 1025 | 1023 | ||||
| 1026 | - | // setNeighbors marks the sides where tiles touch across the gap, from | 1024 | + | // setVisibleNeighbors reserves client space only for shared lines that |
| 1027 | - | // geometry alone: client sizes do not change as the view scrolls. | 1025 | + | // are actually on this output. A scrolled-off or hidden tile cannot give a |
| 1028 | - | func setNeighbors(tiles []Placement, gap int) { | 1026 | + | // lone visible tile a border (or shrink its client). |
| 1027 | + | func setVisibleNeighbors(tiles []Placement, gap int, output Rect) { | |||
| 1028 | + | visible := func(p Placement) bool { | |||
| 1029 | + | return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(output) | |||
| 1030 | + | } | |||
| 1029 | overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 } | 1031 | overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 } | ||
| 1030 | for i := range tiles { | 1032 | for i := range tiles { | ||
| 1031 | a := &tiles[i] | 1033 | a := &tiles[i] | ||
| 1032 | - | if a.Hidden || a.Fullscreen || a.Rect.W <= 0 || a.Rect.H <= 0 { | 1034 | + | a.Neighbors, a.Inset = 0, 0 |
| 1035 | + | if !visible(*a) { | |||
| 1033 | continue | 1036 | continue | ||
| 1034 | } | 1037 | } | ||
| 1035 | for j := range tiles { | 1038 | for j := range tiles { | ||
| 1036 | - | b := tiles[j].Rect | 1039 | + | if i == j || !visible(tiles[j]) { |
| 1037 | - | if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 { | |||
| 1038 | continue | 1040 | continue | ||
| 1039 | } | 1041 | } | ||
| 1040 | - | r := a.Rect | 1042 | + | b, r := tiles[j].Rect, a.Rect |
| 1043 | + | // Only count a shared edge strictly within this output. | |||
| 1041 | switch { | 1044 | switch { | ||
| 1042 | - | case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H): | 1045 | + | case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > output.X && r.X+r.W < output.X+output.W: |
| 1043 | a.Neighbors |= ports.SideRight | 1046 | a.Neighbors |= ports.SideRight | ||
| 1044 | - | case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H): | 1047 | + | case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > output.X && r.X < output.X+output.W: |
| 1045 | a.Neighbors |= ports.SideLeft | 1048 | a.Neighbors |= ports.SideLeft | ||
| 1046 | - | case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W): | 1049 | + | case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > output.Y && r.Y+r.H < output.Y+output.H: |
| 1047 | a.Neighbors |= ports.SideBottom | 1050 | a.Neighbors |= ports.SideBottom | ||
| 1048 | - | case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W): | 1051 | + | case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > output.Y && r.Y < output.Y+output.H: |
| 1049 | a.Neighbors |= ports.SideTop | 1052 | a.Neighbors |= ports.SideTop | ||
| 1050 | } | 1053 | } | ||
| 1051 | } | 1054 | } | ||
M internal/core/separators.go +21 -14
| @@ -2,16 +2,18 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import "github.com/bnema/neferwl/internal/ports" | |
| 4 | 4 | ||
| 5 | - | // Separator lines follow tmux (screen-redraw.c): tiles share one line per | |
| 6 | - | // side where they touch, owned by the left or top tile (Placement.Inset). | |
| 7 | - | // The focused tile lights the lines along its sides, corners included. | |
| 8 | - | // With exactly two tiles one line splits them, and each lights only its | |
| 9 | - | // half of it: the left or top tile the first half. With gaps each tile | |
| 10 | - | // has lines of its own and lights them whole. Floats get a full border | |
| 11 | - | // of their own, drawn with them (Separator.Window). | |
| 12 | - | // | |
| 13 | - | // Only what is on the output counts: lines at the output edge (a column | |
| 14 | - | // scrolled off beyond it) are not drawn, and only tiles on it count. | |
| 5 | + | // Like tmux screen-redraw.c, lines separate visible panes, never outline a | |
| 6 | + | // lone tile or a fullscreen/zoomed tile. Adjacent tiles share one line, | |
| 7 | + | // owned (and inset) by the left/top tile; focus lights only the adjacent | |
| 8 | + | // sides of the focused tile, including corners. With exactly two visible | |
| 9 | + | // tiles and no gaps, each lights its own half of the shared line (left/top | |
| 10 | + | // first). With gaps, each tile owns its neighbor-facing lines and lights | |
| 11 | + | // them whole. Hidden, scrolled-off, other-workspace and other-output tiles | |
| 12 | + | // do not count. Lines at output edges are omitted. Floating windows differ | |
| 13 | + | // from tmux tiles: each gets its own full border, drawn with that window | |
| 14 | + | // (Separator.Window), even when it is the only visible window. Only the | |
| 15 | + | // focused output's focused window can light lines; keyboard grabs by popups | |
| 16 | + | // and layers do not change the underlying window's visual focus. | |
| 15 | 17 | ||
| 16 | 18 | var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom} | |
| 17 | 19 | ||
| @@ -24,9 +26,14 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa | |||
| 24 | 26 | } | |
| 25 | 27 | var out []ports.Separator | |
| 26 | 28 | 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 | + | // A line cannot belong to a scrolled-off window or bleed onto | |
| 30 | + | // another output, even when a neighboring tile remains visible. | |
| 31 | + | x0, y0 := max(r.X, o.X), max(r.Y, o.Y) | |
| 32 | + | x1, y1 := min(r.X+r.W, o.X+o.W), min(r.Y+r.H, o.Y+o.H) | |
| 33 | + | if x0 >= x1 || y0 >= y1 { | |
| 34 | + | return | |
| 29 | 35 | } | |
| 36 | + | r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0} | |
| 30 | 37 | var id WindowID | |
| 31 | 38 | if p.Floating { | |
| 32 | 39 | id = p.ID | |
| @@ -37,10 +44,10 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa | |||
| 37 | 44 | tiles := 0 | |
| 38 | 45 | for i := range ps { | |
| 39 | 46 | p := &ps[i] | |
| 40 | - | if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 { | |
| 47 | + | if p.Fullscreen || !onScreen(*p, o) { | |
| 41 | 48 | continue | |
| 42 | 49 | } | |
| 43 | - | if p.Neighbors != 0 && p.Rect.X < o.X+o.W && p.Rect.X+p.Rect.W > o.X { | |
| 50 | + | if !p.Floating { | |
| 44 | 51 | tiles++ | |
| 45 | 52 | } | |
| 46 | 53 | for _, s := range sides { | |
| @@ -2,16 +2,18 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import "github.com/bnema/neferwl/internal/ports" | 3 | import "github.com/bnema/neferwl/internal/ports" | ||
| 4 | 4 | ||||
| 5 | - | // Separator lines follow tmux (screen-redraw.c): tiles share one line per | 5 | + | // Like tmux screen-redraw.c, lines separate visible panes, never outline a |
| 6 | - | // side where they touch, owned by the left or top tile (Placement.Inset). | 6 | + | // lone tile or a fullscreen/zoomed tile. Adjacent tiles share one line, |
| 7 | - | // The focused tile lights the lines along its sides, corners included. | 7 | + | // owned (and inset) by the left/top tile; focus lights only the adjacent |
| 8 | - | // With exactly two tiles one line splits them, and each lights only its | 8 | + | // sides of the focused tile, including corners. With exactly two visible |
| 9 | - | // half of it: the left or top tile the first half. With gaps each tile | 9 | + | // tiles and no gaps, each lights its own half of the shared line (left/top |
| 10 | - | // has lines of its own and lights them whole. Floats get a full border | 10 | + | // first). With gaps, each tile owns its neighbor-facing lines and lights |
| 11 | - | // of their own, drawn with them (Separator.Window). | 11 | + | // them whole. Hidden, scrolled-off, other-workspace and other-output tiles |
| 12 | - | // | 12 | + | // do not count. Lines at output edges are omitted. Floating windows differ |
| 13 | - | // Only what is on the output counts: lines at the output edge (a column | 13 | + | // from tmux tiles: each gets its own full border, drawn with that window |
| 14 | - | // scrolled off beyond it) are not drawn, and only tiles on it count. | 14 | + | // (Separator.Window), even when it is the only visible window. Only the |
| 15 | + | // focused output's focused window can light lines; keyboard grabs by popups | |||
| 16 | + | // and layers do not change the underlying window's visual focus. | |||
| 15 | 17 | ||||
| 16 | var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom} | 18 | var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom} | ||
| 17 | 19 | ||||
| @@ -24,9 +26,14 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa | |||||
| 24 | } | 26 | } | ||
| 25 | var out []ports.Separator | 27 | var out []ports.Separator | ||
| 26 | add := func(p *Placement, r Rect, active bool) { | 28 | 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 { | 29 | + | // A line cannot belong to a scrolled-off window or bleed onto |
| 28 | - | return // a vertical line at the output edge | 30 | + | // another output, even when a neighboring tile remains visible. |
| 31 | + | x0, y0 := max(r.X, o.X), max(r.Y, o.Y) | |||
| 32 | + | x1, y1 := min(r.X+r.W, o.X+o.W), min(r.Y+r.H, o.Y+o.H) | |||
| 33 | + | if x0 >= x1 || y0 >= y1 { | |||
| 34 | + | return | |||
| 29 | } | 35 | } | ||
| 36 | + | r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0} | |||
| 30 | var id WindowID | 37 | var id WindowID | ||
| 31 | if p.Floating { | 38 | if p.Floating { | ||
| 32 | id = p.ID | 39 | id = p.ID | ||
| @@ -37,10 +44,10 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa | |||||
| 37 | tiles := 0 | 44 | tiles := 0 | ||
| 38 | for i := range ps { | 45 | for i := range ps { | ||
| 39 | p := &ps[i] | 46 | p := &ps[i] | ||
| 40 | - | if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 { | 47 | + | if p.Fullscreen || !onScreen(*p, o) { |
| 41 | continue | 48 | continue | ||
| 42 | } | 49 | } | ||
| 43 | - | if p.Neighbors != 0 && p.Rect.X < o.X+o.W && p.Rect.X+p.Rect.W > o.X { | 50 | + | if !p.Floating { |
| 44 | tiles++ | 51 | tiles++ | ||
| 45 | } | 52 | } | ||
| 46 | for _, s := range sides { | 53 | for _, s := range sides { | ||
M internal/core/separators_test.go +9 -6
| @@ -13,7 +13,7 @@ func tiles(f int, rs ...Rect) []Placement { | |||
| 13 | 13 | for i, r := range rs { | |
| 14 | 14 | ps[i] = Placement{ID: WindowID(i + 1), Rect: r, Focused: i == f} | |
| 15 | 15 | } | |
| 16 | - | setNeighbors(ps, 0) | |
| 16 | + | setVisibleNeighbors(ps, 0, bigOutput) | |
| 17 | 17 | return ps | |
| 18 | 18 | } | |
| 19 | 19 | ||
| @@ -106,8 +106,8 @@ func TestSeparatorsFloat(t *testing.T) { | |||
| 106 | 106 | } | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | - | // Through the real layout: scroll mode keeps client sizes as the view | |
| 110 | - | // moves, and draws no line at the output edge. | |
| 109 | + | // Through the real layout: scrolling removes borders to off-output | |
| 110 | + | // neighbors and gives the client back the reserved space. | |
| 111 | 111 | func TestSeparatorsScroll(t *testing.T) { | |
| 112 | 112 | m := monitor() // 100x80, 2 columns on screen | |
| 113 | 113 | for id := WindowID(1); id <= 3; id++ { | |
| @@ -122,13 +122,16 @@ func TestSeparatorsScroll(t *testing.T) { | |||
| 122 | 122 | return got | |
| 123 | 123 | } | |
| 124 | 124 | before := insets() | |
| 125 | + | if before[1] != 0 || before[3]&ports.SideRight != 0 { | |
| 126 | + | t.Fatalf("scrolled tiles reserve off-output lines: %v", before) | |
| 127 | + | } | |
| 125 | 128 | if w.ViewX == 0 { | |
| 126 | 129 | t.Fatal("expected a scrolled view") | |
| 127 | 130 | } | |
| 128 | 131 | m.Apply(ActionFocusColumnLeft) | |
| 129 | 132 | m.Apply(ActionFocusColumnLeft) | |
| 130 | - | if after := insets(); !reflect.DeepEqual(before, after) { | |
| 131 | - | t.Fatalf("insets change with the view: %v then %v", before, after) | |
| 133 | + | if after := insets(); reflect.DeepEqual(before, after) || after[1]&ports.SideRight == 0 || after[3] != 0 { | |
| 134 | + | t.Fatalf("expected visible borders to change with the view: %v then %v", before, after) | |
| 132 | 135 | } | |
| 133 | 136 | out := Rect{W: 100, H: 80} | |
| 134 | 137 | seps := separators(w.Layout(), 2, 0, out, true) | |
| @@ -147,7 +150,7 @@ func TestSeparatorsScroll(t *testing.T) { | |||
| 147 | 150 | // With gaps each tile has its own line: the focused one lights it whole. | |
| 148 | 151 | func TestSeparatorsGaps(t *testing.T) { | |
| 149 | 152 | 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) | |
| 153 | + | setVisibleNeighbors(ps, 4, bigOutput) | |
| 151 | 154 | seps := separators(ps, 2, 4, bigOutput, true) | |
| 152 | 155 | for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} { | |
| 153 | 156 | if got := colorAt(seps, c.x, c.y); got != c.want { | |
| @@ -13,7 +13,7 @@ func tiles(f int, rs ...Rect) []Placement { | |||||
| 13 | for i, r := range rs { | 13 | for i, r := range rs { | ||
| 14 | 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} | ||
| 15 | } | 15 | } | ||
| 16 | - | setNeighbors(ps, 0) | 16 | + | setVisibleNeighbors(ps, 0, bigOutput) |
| 17 | return ps | 17 | return ps | ||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| @@ -106,8 +106,8 @@ func TestSeparatorsFloat(t *testing.T) { | |||||
| 106 | } | 106 | } | ||
| 107 | } | 107 | } | ||
| 108 | 108 | ||||
| 109 | - | // Through the real layout: scroll mode keeps client sizes as the view | 109 | + | // Through the real layout: scrolling removes borders to off-output |
| 110 | - | // moves, and draws no line at the output edge. | 110 | + | // neighbors and gives the client back the reserved space. |
| 111 | func TestSeparatorsScroll(t *testing.T) { | 111 | func TestSeparatorsScroll(t *testing.T) { | ||
| 112 | m := monitor() // 100x80, 2 columns on screen | 112 | m := monitor() // 100x80, 2 columns on screen | ||
| 113 | for id := WindowID(1); id <= 3; id++ { | 113 | for id := WindowID(1); id <= 3; id++ { | ||
| @@ -122,13 +122,16 @@ func TestSeparatorsScroll(t *testing.T) { | |||||
| 122 | return got | 122 | return got | ||
| 123 | } | 123 | } | ||
| 124 | before := insets() | 124 | before := insets() | ||
| 125 | + | if before[1] != 0 || before[3]&ports.SideRight != 0 { | |||
| 126 | + | t.Fatalf("scrolled tiles reserve off-output lines: %v", before) | |||
| 127 | + | } | |||
| 125 | if w.ViewX == 0 { | 128 | if w.ViewX == 0 { | ||
| 126 | t.Fatal("expected a scrolled view") | 129 | t.Fatal("expected a scrolled view") | ||
| 127 | } | 130 | } | ||
| 128 | m.Apply(ActionFocusColumnLeft) | 131 | m.Apply(ActionFocusColumnLeft) | ||
| 129 | m.Apply(ActionFocusColumnLeft) | 132 | m.Apply(ActionFocusColumnLeft) | ||
| 130 | - | if after := insets(); !reflect.DeepEqual(before, after) { | 133 | + | if after := insets(); reflect.DeepEqual(before, after) || after[1]&ports.SideRight == 0 || after[3] != 0 { |
| 131 | - | t.Fatalf("insets change with the view: %v then %v", before, after) | 134 | + | t.Fatalf("expected visible borders to change with the view: %v then %v", before, after) |
| 132 | } | 135 | } | ||
| 133 | out := Rect{W: 100, H: 80} | 136 | out := Rect{W: 100, H: 80} | ||
| 134 | seps := separators(w.Layout(), 2, 0, out, true) | 137 | seps := separators(w.Layout(), 2, 0, out, true) | ||
| @@ -147,7 +150,7 @@ func TestSeparatorsScroll(t *testing.T) { | |||||
| 147 | // With gaps each tile has its own line: the focused one lights it whole. | 150 | // With gaps each tile has its own line: the focused one lights it whole. | ||
| 148 | func TestSeparatorsGaps(t *testing.T) { | 151 | 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}}} | 152 | 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) | 153 | + | setVisibleNeighbors(ps, 4, bigOutput) |
| 151 | seps := separators(ps, 2, 4, bigOutput, true) | 154 | seps := separators(ps, 2, 4, bigOutput, true) | ||
| 152 | for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} { | 155 | 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 { | 156 | if got := colorAt(seps, c.x, c.y); got != c.want { | ||
M internal/core/stash.go +1 -1
| @@ -161,7 +161,7 @@ func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement { | |||
| 161 | 161 | p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll} | |
| 162 | 162 | switch { | |
| 163 | 163 | case f.ID == cover: | |
| 164 | - | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | |
| 164 | + | p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0 | |
| 165 | 165 | case w.stashHidden || cover != 0: | |
| 166 | 166 | p.Hidden = true | |
| 167 | 167 | case i == w.stashAt: | |
| @@ -161,7 +161,7 @@ func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement { | |||||
| 161 | p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll} | 161 | p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll} | ||
| 162 | switch { | 162 | switch { | ||
| 163 | case f.ID == cover: | 163 | case f.ID == cover: | ||
| 164 | - | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | 164 | + | p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0 |
| 165 | case w.stashHidden || cover != 0: | 165 | case w.stashHidden || cover != 0: | ||
| 166 | p.Hidden = true | 166 | p.Hidden = true | ||
| 167 | case i == w.stashAt: | 167 | case i == w.stashAt: | ||