detached b3c68fb

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 UnverifiedB5690EEEBB952194 The 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`).
2121 | `startup` | none | Command run once at session start; repeat the key for more |
2222 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
2323 | `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 |
2525 | `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 |
2626 | `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 |
2727 | `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 |
2828 | `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 |
3030 | `layout.gaps` | `0` | Space between windows |
3131 | `layout.max-columns` | `2` | Columns that share the screen before scrolling |
3232 | `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 {
9999 Floating bool
100100 // Peek is a stashed window peeking in beside the selected one: dimmed.
101101 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.
107105 Neighbors, Inset ports.Sides
108106 }
109107
@@ -1002,7 +1000,7 @@ func (w *Workspace) Layout() []Placement {
10021000 y += h + gap
10031001 }
10041002 }
1005- setNeighbors(result, gap)
1003+ setVisibleNeighbors(result, gap, w.Output)
10061004 // Floating windows go last: they are drawn and hit on top, native
10071005 // floats (dialogs) over the stash.
10081006 result = append(result, w.stashLayout(focusedID, cover)...)
@@ -1016,36 +1014,41 @@ func (w *Workspace) Layout() []Placement {
10161014 continue
10171015 }
10181016 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
10201018 }
10211019 result = append(result, p)
10221020 }
10231021 return result
10241022 }
10251023
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+ }
10291031 overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }
10301032 for i := range tiles {
10311033 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) {
10331036 continue
10341037 }
10351038 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]) {
10381040 continue
10391041 }
1040- r := a.Rect
1042+ b, r := tiles[j].Rect, a.Rect
1043+ // Only count a shared edge strictly within this output.
10411044 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:
10431046 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:
10451048 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:
10471050 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:
10491052 a.Neighbors |= ports.SideTop
10501053 }
10511054 }
@@ -99,11 +99,9 @@ type Placement struct {
99 Floating bool99 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 bool101 Peek bool
102- // Neighbors are the sides touching another tiled window. Inset are the102+ // Neighbors are the sides touching a visible tile on this output.
103- // sides where the border takes room from the window: all sides of a103+ // Inset reserves room for drawn lines: all sides of a float; for
104- // float; for tiles, the right and bottom neighbors only, so two104+ // 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.Sides105 Neighbors, Inset ports.Sides
108 }106 }
109 107
@@ -1002,7 +1000,7 @@ func (w *Workspace) Layout() []Placement {
1002 y += h + gap1000 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, native1004 // 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 continue1014 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}, true1017+ 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 result1021 return result
1024 }1022 }
1025 1023
1026-// setNeighbors marks the sides where tiles touch across the gap, from1024+// 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 continue1036 continue
1034 }1037 }
1035 for j := range tiles {1038 for j := range tiles {
1036- b := tiles[j].Rect1039+ if i == j || !visible(tiles[j]) {
1037- if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 {
1038 continue1040 continue
1039 }1041 }
1040- r := a.Rect1042+ 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.SideRight1046 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.SideLeft1048 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.SideBottom1050 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.SideTop1052 a.Neighbors |= ports.SideTop
1050 }1053 }
1051 }1054 }
M internal/core/separators.go +21 -14
@@ -2,16 +2,18 @@ package core
22
33 import "github.com/bnema/neferwl/internal/ports"
44
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.
1517
1618 var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom}
1719
@@ -24,9 +26,14 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
2426 }
2527 var out []ports.Separator
2628 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
2935 }
36+ r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
3037 var id WindowID
3138 if p.Floating {
3239 id = p.ID
@@ -37,10 +44,10 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
3744 tiles := 0
3845 for i := range ps {
3946 p := &ps[i]
40- if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 {
47+ if p.Fullscreen || !onScreen(*p, o) {
4148 continue
4249 }
43- if p.Neighbors != 0 && p.Rect.X < o.X+o.W && p.Rect.X+p.Rect.W > o.X {
50+ if !p.Floating {
4451 tiles++
4552 }
4653 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 per5+// 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 its8+// 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 tile9+// 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 border10+// 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 column13+// 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.Separator27 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 edge30+ // 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 WindowID37 var id WindowID
31 if p.Floating {38 if p.Floating {
32 id = p.ID39 id = p.ID
@@ -37,10 +44,10 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
37 tiles := 044 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 continue48 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 {
1313 for i, r := range rs {
1414 ps[i] = Placement{ID: WindowID(i + 1), Rect: r, Focused: i == f}
1515 }
16- setNeighbors(ps, 0)
16+ setVisibleNeighbors(ps, 0, bigOutput)
1717 return ps
1818 }
1919
@@ -106,8 +106,8 @@ func TestSeparatorsFloat(t *testing.T) {
106106 }
107107 }
108108
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.
111111 func TestSeparatorsScroll(t *testing.T) {
112112 m := monitor() // 100x80, 2 columns on screen
113113 for id := WindowID(1); id <= 3; id++ {
@@ -122,13 +122,16 @@ func TestSeparatorsScroll(t *testing.T) {
122122 return got
123123 }
124124 before := insets()
125+ if before[1] != 0 || before[3]&ports.SideRight != 0 {
126+ t.Fatalf("scrolled tiles reserve off-output lines: %v", before)
127+ }
125128 if w.ViewX == 0 {
126129 t.Fatal("expected a scrolled view")
127130 }
128131 m.Apply(ActionFocusColumnLeft)
129132 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)
132135 }
133136 out := Rect{W: 100, H: 80}
134137 seps := separators(w.Layout(), 2, 0, out, true)
@@ -147,7 +150,7 @@ func TestSeparatorsScroll(t *testing.T) {
147150 // With gaps each tile has its own line: the focused one lights it whole.
148151 func TestSeparatorsGaps(t *testing.T) {
149152 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)
151154 seps := separators(ps, 2, 4, bigOutput, true)
152155 for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} {
153156 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 ps17 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 view109+// 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 screen112 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 got122 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 {
161161 p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll}
162162 switch {
163163 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
165165 case w.stashHidden || cover != 0:
166166 p.Hidden = true
167167 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}, true164+ 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 = true166 p.Hidden = true
167 case i == w.stashAt:167 case i == w.stashAt:

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close