detached f2fc42d

#10 feat(core): resize tiled columns and rows by dragging their border

state
merged · 318e2d8 · merge commit
branch
feat/pointer-grab-resize → main
opened
bnema ·
updated

What

cmd + right drag on the line between two tiled columns, or between two stacked windows, resizes them. The line follows the pointer and the cursor shows ew-resize or ns-resize while the button is held.

  • fixed / cascade: only the two columns beside the line change; their total width stays the same.
  • scroll: only the left column changes; the columns on its right shift.
  • rows: only the two rows beside the line change.
  • cmd + double right-click on the line resets the lane (columns) or the column (rows).
  • Escape cancels and restores the previous sizes.
  • Column widths reset to the max-columns sizes when a column enters or leaves the lane. Keyboard width changes also clear them.

How

  • Pointer grab model (internal/core/pointergrab.go): pointerGrab interface (valid, button, motion, release, cancel, cursor) owned by core, with shared begin/end/abort. borderResize is the first implementation. It is the base for later column moves and edge-hold transfer to another workspace or monitor.
  • Ratios, not pixels: Column.Span in basis points per lane, tagged with a per-workspace stamp. dropStaleSpans (run from Workspace.scroll) clears any lane whose stamp is no longer complete and exclusive. Column.Shares is now in basis points too.
  • Cursor override: new ports.CursorOverride command. The wayland adapter shows it over the client cursor and restores the client cursor afterwards; it is ignored while the session is locked.
  • Headless: press / release script commands hold a modifier across mouse actions.

Checks

  • make check, make race, make mocks-check, staticcheck ./... pass.
  • Headless e2e with foot, fixed overflow, 3 columns, 1920 px: dragging the first line +120 px gives 624/622/626 → 743/503/626 (third column unchanged). Scroll and row cases were checked the same way.
  • Wrap-up review: two go-reviewer passes; all findings fixed (grab validity through the interface, fullscreen abort, pixel remainder, maximized width stamping, lane identity, foreign stamps, duplicate helper).
CommitSubjectAuthorDateSignatureStatus
f2fc42dfix(core): clear foreign span stamps on columns added by addColumnbriceGPG Verified
f020f70test(core): drop struct-shape check for border resizebriceGPG Verified
00a0a44refactor(core): reuse contains for border hit zonesbriceGPG Verified
d029245refactor(core): remove unused border resize start statebriceGPG Verified
d72b69dfix(core): identify span lanes by stampbriceGPG Verified
9f44b24fix(core): stamp spans from unmaximized widthsbriceGPG Verified
ff69908fix(core): keep untouched columns fixed when stamping spansbriceGPG Verified
4c917cffix(core): abort border resize when the workspace goes fullscreenbriceGPG Verified
080065efix(core): validate pointer grabs through the grab interfacebriceGPG Verified
2916ceadocs(config): describe tiled border resizing and resetbriceGPG Verified
c5fa0d0feat(headless): hold modifiers across scripted pointer actionsbriceGPG Verified
8cf90bbfeat(core): resize tiled borders with compositor pointer grabsbriceGPG Verified
02f2bf5fix(core): preserve untouched tiles across border roundingbriceGPG Verified
5932e6bfeat(core): add border hit-testing and resize geometrybriceGPG Verified
4e22582feat(core): add lane-scoped column width spansbriceGPG Verified
d458689refactor(core): store row shares in basis pointsbriceGPG Verified
dbc10e2feat(wayland): apply compositor cursor overrides over client cursorsbriceGPG Verified
a8dbdb2feat(ports): add CursorOverride client commandbriceGPG Verified

Checks 1

39 files · +1461 -434
1/1
M docs/config.md +3 -1
M docs/desktop.md +0 -3
M docs/dev/headless.md +1 -0
M internal/adapters/headlessinput/input.go +24 -0
M internal/adapters/headlessinput/input_test.go +49 -0
M internal/adapters/statefile/statefile.go +6 -86
M internal/adapters/statefile/statefile_test.go +1 -95
M internal/adapters/wayland/cursor.go +26 -2
M internal/adapters/wayland/cursor_test.go +48 -0
M internal/adapters/wayland/server.go +6 -1
M internal/adapters/wayland/session_lock.go +1 -0
A internal/core/border.go +183 -0
A internal/core/border_resize.go +89 -0
A internal/core/border_test.go +230 -0
M internal/core/cascade.go +28 -7
M internal/core/cascade_test.go +1 -1
M internal/core/client.go +5 -0
M internal/core/clientgesture.go +1 -1
M internal/core/column_cells_test.go +2 -2
M internal/core/core.go +13 -4
M internal/core/datadrag.go +1 -1
M internal/core/hint.go +1 -1
M internal/core/input.go +25 -5
M internal/core/layout.go +30 -20
M internal/core/layout_buffers_test.go +2 -2
M internal/core/layout_policy.go +58 -0
@@ -53,3 +53,61 @@ func (w *Workspace) policy() layoutPolicy {
5353 }
5454 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
5555 }
56+
57+// validSpans requires a complete, exclusive lane stamp: every column has
58+// a positive span and the same nonzero identity, with no members elsewhere
59+// in the workspace. Equal-cell lanes must also sum to 10000 basis points.
60+func (w *Workspace) validSpans(first, last int) bool {
61+ if first > last || w.Overflow == OverflowFixed && !w.laneRow() {
62+ return false
63+ }
64+ stamp := w.Columns[first].spanStamp
65+ if stamp == 0 {
66+ return false
67+ }
68+ total := 0
69+ for i := first; i <= last; i++ {
70+ c := w.Columns[i]
71+ if c.Span <= 0 || c.spanStamp != stamp {
72+ return false
73+ }
74+ total += c.Span
75+ }
76+ count := 0
77+ for _, c := range w.Columns {
78+ if c.spanStamp == stamp {
79+ count++
80+ }
81+ }
82+ return count == last-first+1 && (!w.policy().equalCells || total == 10000)
83+}
84+
85+// dropStaleSpans runs before scroll measures mutated layout geometry. Decide
86+// validity before clearing any stamps, so shared identities cannot become
87+// valid merely because an earlier invalid lane was cleared.
88+func (w *Workspace) dropStaleSpans() {
89+ var stale []int
90+ for first := 0; first < len(w.Columns); {
91+ _, last := w.laneBounds(w.laneOf(first))
92+ if !w.validSpans(first, last) {
93+ for i := first; i <= last; i++ {
94+ stale = append(stale, i)
95+ }
96+ }
97+ first = last + 1
98+ }
99+ for _, i := range stale {
100+ w.Columns[i].Span, w.Columns[i].spanStamp = 0, 0
101+ }
102+}
103+
104+func (w *Workspace) clearSpans(col int) {
105+ if col < 0 || col >= len(w.Columns) {
106+ return
107+ }
108+ first, last := w.laneBounds(w.laneOf(col))
109+ for i := first; i <= last; i++ {
110+ w.Columns[i].Span = 0
111+ w.Columns[i].spanStamp = 0
112+ }
113+}
@@ -53,3 +53,61 @@ func (w *Workspace) policy() layoutPolicy {
53 }53 }
54 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}54 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
55 }55 }
56+
57+// validSpans requires a complete, exclusive lane stamp: every column has
58+// a positive span and the same nonzero identity, with no members elsewhere
59+// in the workspace. Equal-cell lanes must also sum to 10000 basis points.
60+func (w *Workspace) validSpans(first, last int) bool {
61+ if first > last || w.Overflow == OverflowFixed && !w.laneRow() {
62+ return false
63+ }
64+ stamp := w.Columns[first].spanStamp
65+ if stamp == 0 {
66+ return false
67+ }
68+ total := 0
69+ for i := first; i <= last; i++ {
70+ c := w.Columns[i]
71+ if c.Span <= 0 || c.spanStamp != stamp {
72+ return false
73+ }
74+ total += c.Span
75+ }
76+ count := 0
77+ for _, c := range w.Columns {
78+ if c.spanStamp == stamp {
79+ count++
80+ }
81+ }
82+ return count == last-first+1 && (!w.policy().equalCells || total == 10000)
83+}
84+
85+// dropStaleSpans runs before scroll measures mutated layout geometry. Decide
86+// validity before clearing any stamps, so shared identities cannot become
87+// valid merely because an earlier invalid lane was cleared.
88+func (w *Workspace) dropStaleSpans() {
89+ var stale []int
90+ for first := 0; first < len(w.Columns); {
91+ _, last := w.laneBounds(w.laneOf(first))
92+ if !w.validSpans(first, last) {
93+ for i := first; i <= last; i++ {
94+ stale = append(stale, i)
95+ }
96+ }
97+ first = last + 1
98+ }
99+ for _, i := range stale {
100+ w.Columns[i].Span, w.Columns[i].spanStamp = 0, 0
101+ }
102+}
103+
104+func (w *Workspace) clearSpans(col int) {
105+ if col < 0 || col >= len(w.Columns) {
106+ return
107+ }
108+ first, last := w.laneBounds(w.laneOf(col))
109+ for i := first; i <= last; i++ {
110+ w.Columns[i].Span = 0
111+ w.Columns[i].spanStamp = 0
112+ }
113+}
M internal/core/layout_test.go +9 -5
@@ -50,15 +50,19 @@ func TestLaneWidths(t *testing.T) {
5050 t.Errorf("2,auto,auto: got %v want %v", got, want)
5151 }
5252 // No room for them: an auto column weighs one cell.
53- if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail / 5}; !reflect.DeepEqual(got, want) {
53+ if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail - avail*3/5 - avail/5}; !reflect.DeepEqual(got, want) {
5454 t.Errorf("3,auto,auto: got %v want %v", got, want)
5555 }
56- // All auto: the equal share of n columns, pixel for pixel.
56+ // All auto: equal shares, with remaining pixels in the last column.
5757 for n := 1; n <= 4; n++ {
5858 w := mk(n)
5959 for i := range w.Columns {
60- if got := w.cellWidth(i); got != w.equalShare(n) {
61- t.Errorf("%d auto columns: width %d want %d", n, got, w.equalShare(n))
60+ want := w.equalShare(n)
61+ if i == n-1 {
62+ want = 1000 - 8*(n+1) - (n-1)*want
63+ }
64+ if got := w.cellWidth(i); got != want {
65+ t.Errorf("%d auto columns: width %d want %d", n, got, want)
6266 }
6367 }
6468 }
@@ -510,7 +514,7 @@ func TestFixedGrowColumn(t *testing.T) {
510514 check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}})
511515 // No donor is left: back to equal shares.
512516 w.GrowColumn(true)
513- check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 33, H: 80}})
517+ check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 34, H: 80}})
514518 })
515519 t.Run("with gaps", func(t *testing.T) {
516520 w := fixedWorkspace(4, 4, 2)
@@ -50,15 +50,19 @@ func TestLaneWidths(t *testing.T) {
50 t.Errorf("2,auto,auto: got %v want %v", got, want)50 t.Errorf("2,auto,auto: got %v want %v", got, want)
51 }51 }
52 // No room for them: an auto column weighs one cell.52 // No room for them: an auto column weighs one cell.
53- if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail / 5}; !reflect.DeepEqual(got, want) {53+ if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail - avail*3/5 - avail/5}; !reflect.DeepEqual(got, want) {
54 t.Errorf("3,auto,auto: got %v want %v", got, want)54 t.Errorf("3,auto,auto: got %v want %v", got, want)
55 }55 }
56- // All auto: the equal share of n columns, pixel for pixel.56+ // All auto: equal shares, with remaining pixels in the last column.
57 for n := 1; n <= 4; n++ {57 for n := 1; n <= 4; n++ {
58 w := mk(n)58 w := mk(n)
59 for i := range w.Columns {59 for i := range w.Columns {
60- if got := w.cellWidth(i); got != w.equalShare(n) {60+ want := w.equalShare(n)
61- t.Errorf("%d auto columns: width %d want %d", n, got, w.equalShare(n))61+ if i == n-1 {
62+ want = 1000 - 8*(n+1) - (n-1)*want
63+ }
64+ if got := w.cellWidth(i); got != want {
65+ t.Errorf("%d auto columns: width %d want %d", n, got, want)
62 }66 }
63 }67 }
64 }68 }
@@ -510,7 +514,7 @@ func TestFixedGrowColumn(t *testing.T) {
510 check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}})514 check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}})
511 // No donor is left: back to equal shares.515 // No donor is left: back to equal shares.
512 w.GrowColumn(true)516 w.GrowColumn(true)
513- check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 33, H: 80}})517+ check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 34, H: 80}})
514 })518 })
515 t.Run("with gaps", func(t *testing.T) {519 t.Run("with gaps", func(t *testing.T) {
516 w := fixedWorkspace(4, 4, 2)520 w := fixedWorkspace(4, 4, 2)
A internal/core/pointergrab.go +95 -0
@@ -0,0 +1,95 @@
1+package core
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// pointerGrab is a compositor-owned pointer interaction started by cmd+button.
11+// Clients get no pointer events while it runs. Implementations: borderResize
12+// (border_resize.go); planned: column move within a workspace, and edge-hold
13+// transfer of a dragged column to the next workspace or the adjacent monitor.
14+type pointerGrab interface {
15+ // valid reports whether the grab still applies to the current layout; the core aborts it otherwise.
16+ valid() bool
17+ button() uint32
18+ motion(ctx context.Context, at time.Duration) error
19+ release(ctx context.Context) error
20+ cancel() // Escape: restore the state from before the grab
21+ cursor() string // CSS cursor name; "" = the client's cursor
22+}
23+
24+func (c *Core) pointerBusy() bool { return c.drag != nil || c.pgrab != nil }
25+
26+func (c *Core) startPointerGrab(ctx context.Context, v ports.PointerButton) (bool, error) {
27+ if c.synthetic || !v.Pressed || c.pointerBusy() || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 || c.keyboard.inhibited(c.keyboardFocus()) {
28+ return false, nil
29+ }
30+ if v.Button == btnRight {
31+ if i := c.screenAt(c.cursorX, c.cursorY); i >= 0 {
32+ s := c.screens[i]
33+ w := s.mon.Current()
34+ lx, ly := int(c.cursorX)-s.x, int(c.cursorY)-s.y
35+ if !s.mon.previewing() && !s.mon.ov.open && !w.pinned() && s.mon.frameHas(float64(lx), float64(ly)) {
36+ if b, ok := w.borderAt(lx, ly, max(w.gap()/2, gapPad)); ok {
37+ last := c.lastBorderClick
38+ if last.ws == w && last.b.kind == b.kind && last.b.col == b.col && last.b.row == b.row && v.Time >= last.at && v.Time-last.at <= 400*time.Millisecond {
39+ c.lastBorderClick.ws = nil
40+ now := c.now()
41+ shots := c.snapshot(now)
42+ w.resetBorder(b)
43+ c.transition(shots, now)
44+ c.buttons[v.Button], c.swallow[v.Button] = true, true
45+ return true, c.publish(ctx)
46+ }
47+ c.lastBorderClick.at, c.lastBorderClick.ws, c.lastBorderClick.b = v.Time, w, b
48+ g := newBorderResize(c, s, w, b, v.Button)
49+ return true, c.beginPointerGrab(ctx, g)
50+ }
51+ }
52+ }
53+ }
54+ c.lastBorderClick.ws = nil
55+ return c.startDrag(ctx, v)
56+}
57+
58+func (c *Core) beginPointerGrab(ctx context.Context, g pointerGrab) error {
59+ if err := c.dismissGrabs(ctx, 0); err != nil {
60+ return err
61+ }
62+ c.pgrab = g
63+ c.buttons[g.button()] = true
64+ c.grab = 0
65+ if c.pointer != 0 {
66+ c.pointer = 0
67+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
68+ return err
69+ }
70+ }
71+ if err := c.command(ctx, ports.CursorOverride{Shape: g.cursor()}); err != nil {
72+ return err
73+ }
74+ return c.publish(ctx)
75+}
76+
77+func (c *Core) endPointerGrab(ctx context.Context) error {
78+ g := c.pgrab
79+ c.pgrab = nil
80+ if err := c.command(ctx, ports.CursorOverride{}); err != nil {
81+ return err
82+ }
83+ return g.release(ctx)
84+}
85+
86+// abortPointerGrab keeps the current layout and swallows the held release.
87+// The caller restores state first when this is an Escape cancellation.
88+func (c *Core) abortPointerGrab(ctx context.Context) error {
89+ if c.pgrab == nil {
90+ return nil
91+ }
92+ c.swallow[c.pgrab.button()] = true
93+ c.pgrab = nil
94+ return c.command(ctx, ports.CursorOverride{})
95+}
@@ -0,0 +1,95 @@
1+package core
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// pointerGrab is a compositor-owned pointer interaction started by cmd+button.
11+// Clients get no pointer events while it runs. Implementations: borderResize
12+// (border_resize.go); planned: column move within a workspace, and edge-hold
13+// transfer of a dragged column to the next workspace or the adjacent monitor.
14+type pointerGrab interface {
15+ // valid reports whether the grab still applies to the current layout; the core aborts it otherwise.
16+ valid() bool
17+ button() uint32
18+ motion(ctx context.Context, at time.Duration) error
19+ release(ctx context.Context) error
20+ cancel() // Escape: restore the state from before the grab
21+ cursor() string // CSS cursor name; "" = the client's cursor
22+}
23+
24+func (c *Core) pointerBusy() bool { return c.drag != nil || c.pgrab != nil }
25+
26+func (c *Core) startPointerGrab(ctx context.Context, v ports.PointerButton) (bool, error) {
27+ if c.synthetic || !v.Pressed || c.pointerBusy() || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 || c.keyboard.inhibited(c.keyboardFocus()) {
28+ return false, nil
29+ }
30+ if v.Button == btnRight {
31+ if i := c.screenAt(c.cursorX, c.cursorY); i >= 0 {
32+ s := c.screens[i]
33+ w := s.mon.Current()
34+ lx, ly := int(c.cursorX)-s.x, int(c.cursorY)-s.y
35+ if !s.mon.previewing() && !s.mon.ov.open && !w.pinned() && s.mon.frameHas(float64(lx), float64(ly)) {
36+ if b, ok := w.borderAt(lx, ly, max(w.gap()/2, gapPad)); ok {
37+ last := c.lastBorderClick
38+ if last.ws == w && last.b.kind == b.kind && last.b.col == b.col && last.b.row == b.row && v.Time >= last.at && v.Time-last.at <= 400*time.Millisecond {
39+ c.lastBorderClick.ws = nil
40+ now := c.now()
41+ shots := c.snapshot(now)
42+ w.resetBorder(b)
43+ c.transition(shots, now)
44+ c.buttons[v.Button], c.swallow[v.Button] = true, true
45+ return true, c.publish(ctx)
46+ }
47+ c.lastBorderClick.at, c.lastBorderClick.ws, c.lastBorderClick.b = v.Time, w, b
48+ g := newBorderResize(c, s, w, b, v.Button)
49+ return true, c.beginPointerGrab(ctx, g)
50+ }
51+ }
52+ }
53+ }
54+ c.lastBorderClick.ws = nil
55+ return c.startDrag(ctx, v)
56+}
57+
58+func (c *Core) beginPointerGrab(ctx context.Context, g pointerGrab) error {
59+ if err := c.dismissGrabs(ctx, 0); err != nil {
60+ return err
61+ }
62+ c.pgrab = g
63+ c.buttons[g.button()] = true
64+ c.grab = 0
65+ if c.pointer != 0 {
66+ c.pointer = 0
67+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
68+ return err
69+ }
70+ }
71+ if err := c.command(ctx, ports.CursorOverride{Shape: g.cursor()}); err != nil {
72+ return err
73+ }
74+ return c.publish(ctx)
75+}
76+
77+func (c *Core) endPointerGrab(ctx context.Context) error {
78+ g := c.pgrab
79+ c.pgrab = nil
80+ if err := c.command(ctx, ports.CursorOverride{}); err != nil {
81+ return err
82+ }
83+ return g.release(ctx)
84+}
85+
86+// abortPointerGrab keeps the current layout and swallows the held release.
87+// The caller restores state first when this is an Escape cancellation.
88+func (c *Core) abortPointerGrab(ctx context.Context) error {
89+ if c.pgrab == nil {
90+ return nil
91+ }
92+ c.swallow[c.pgrab.button()] = true
93+ c.pgrab = nil
94+ return c.command(ctx, ports.CursorOverride{})
95+}
A internal/core/pointergrab_test.go +291 -0
@@ -0,0 +1,291 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+type pointerRig struct {
14+ *Core
15+ commands chan ports.ClientCommand
16+}
17+
18+func borderCore(t *testing.T, row bool) (*pointerRig, *Workspace) {
19+ t.Helper()
20+ commands := make(chan ports.ClientCommand, 1024)
21+ c := &pointerRig{Core: dragCore(t), commands: commands}
22+ c.ch.Commands = commands
23+ w := c.cur().mon.Current()
24+ w.Overflow = OverflowFixed
25+ w.SetMaxColumns(2)
26+ w.SetGaps(10)
27+ w.AddWindow(1)
28+ w.AddWindow(2)
29+ if row {
30+ w.Columns = []Column{{Windows: []WindowID{1, 2}}}
31+ w.Focus = 0
32+ }
33+ rs := w.columnRects()
34+ c.cursorX, c.cursorY = float64(rs[0].X+rs[0].W+w.gap()/2), 100
35+ if row {
36+ rows := rowRects(rs[0], w.Columns[0], w.gap())
37+ c.cursorX, c.cursorY = 100, float64(rows[0].Y+rows[0].H+w.gap()/2)
38+ }
39+ c.mods = ports.ModSuper
40+ return c, w
41+}
42+
43+func grabButton(t *testing.T, c *pointerRig, b uint32, down bool, at time.Duration) {
44+ t.Helper()
45+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: b, Pressed: down, Time: at}); err != nil {
46+ t.Fatal(err)
47+ }
48+}
49+
50+func grabMove(t *testing.T, c *pointerRig, x, y float64) {
51+ t.Helper()
52+ if err := c.pointerMotion(context.Background(), ports.PointerMotion{X: x, Y: y, Time: time.Second}); err != nil {
53+ t.Fatal(err)
54+ }
55+}
56+
57+func grabCommands(c *pointerRig) []ports.ClientCommand {
58+ var out []ports.ClientCommand
59+ for len(c.commands) > 0 {
60+ out = append(out, <-c.commands)
61+ }
62+ return out
63+}
64+
65+func requireOverride(t *testing.T, cmds []ports.ClientCommand, shape string) {
66+ t.Helper()
67+ if !slices.ContainsFunc(cmds, func(cmd ports.ClientCommand) bool { v, ok := cmd.(ports.CursorOverride); return ok && v.Shape == shape }) {
68+ t.Fatalf("missing cursor %q in %+v", shape, cmds)
69+ }
70+}
71+
72+func TestBorderPointerGrab(t *testing.T) {
73+ for _, row := range []bool{false, true} {
74+ t.Run(map[bool]string{false: "column", true: "row"}[row], func(t *testing.T) {
75+ c, w := borderCore(t, row)
76+ before := w.Layout()
77+ grabButton(t, c, btnRight, true, time.Second)
78+ if c.pgrab == nil || c.pointer != 0 || c.drag != nil {
79+ t.Fatal("no exclusive border grab")
80+ }
81+ shape := "ew-resize"
82+ if row {
83+ shape = "ns-resize"
84+ }
85+ requireOverride(t, grabCommands(c), shape)
86+ x, y := c.cursorX+150, c.cursorY
87+ if row {
88+ x, y = c.cursorX, c.cursorY+100
89+ }
90+ grabMove(t, c, x, y)
91+ after := w.Layout()
92+ if before[0].Rect == after[0].Rect || before[1].Rect == after[1].Rect {
93+ t.Fatal("tiles did not resize", before, after)
94+ }
95+ cmds := grabCommands(c)
96+ for _, id := range []WindowID{1, 2} {
97+ if !slices.ContainsFunc(cmds, func(cmd ports.ClientCommand) bool {
98+ v, ok := cmd.(ports.ConfigureWindow)
99+ r := after[int(id)-1].Rect
100+ return ok && v.ID == id && v.Width == r.W-2*w.border && v.Height == r.H-2*w.border
101+ }) {
102+ t.Fatalf("no live configure for %d: %+v", id, cmds)
103+ }
104+ }
105+ grabButton(t, c, btnRight, false, 2*time.Second)
106+ if c.pgrab != nil || !slices.Equal(w.Layout(), after) {
107+ t.Fatal("release changed sizes")
108+ }
109+ requireOverride(t, grabCommands(c), "")
110+ })
111+ }
112+}
113+
114+func TestBorderGrabEscape(t *testing.T) {
115+ for _, row := range []bool{false, true} {
116+ c, w := borderCore(t, row)
117+ before := w.Layout()
118+ grabButton(t, c, btnRight, true, time.Second)
119+ grabMove(t, c, c.cursorX+100, c.cursorY+100)
120+ grabCommands(c)
121+ if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Escape", Pressed: true}); err != nil {
122+ t.Fatal(err)
123+ }
124+ if c.pgrab != nil || !slices.Equal(w.Layout(), before) {
125+ t.Fatal("cancel did not restore", w.Layout(), before)
126+ }
127+ requireOverride(t, grabCommands(c), "")
128+ if !c.swallow[btnRight] {
129+ t.Fatal("release not swallowed")
130+ }
131+ grabButton(t, c, btnRight, false, 2*time.Second)
132+ if c.buttons[btnRight] || c.swallow[btnRight] {
133+ t.Fatal("release still held")
134+ }
135+ }
136+}
137+
138+func TestBorderGrabOtherButtons(t *testing.T) {
139+ c, _ := borderCore(t, false)
140+ grabButton(t, c, btnRight, true, time.Second)
141+ grabCommands(c)
142+ grabButton(t, c, btnLeft, true, time.Second)
143+ grabButton(t, c, btnLeft, false, time.Second)
144+ if c.pgrab == nil || len(grabCommands(c)) != 0 || c.buttons[btnLeft] {
145+ t.Fatal("other button escaped grab")
146+ }
147+}
148+
149+func TestBorderGrabPreconditions(t *testing.T) {
150+ for _, kind := range []string{"no-cmd", "interior", "overview", "preview", "synthetic", "held", "inhibited"} {
151+ t.Run(kind, func(t *testing.T) {
152+ c, _ := borderCore(t, false)
153+ switch kind {
154+ case "no-cmd":
155+ c.mods = 0
156+ case "interior":
157+ c.cursorX = 100
158+ case "overview":
159+ c.cur().mon.ov.open = true
160+ case "preview":
161+ c.cur().mon.sw = switcherState{ws: c.cur().mon.Current(), shown: true}
162+ case "synthetic":
163+ c.synthetic = true
164+ case "held":
165+ c.buttons[btnLeft] = true
166+ case "inhibited":
167+ c.keyboard.inhibiting = 1
168+ c.cur().mon.Current().FocusID(1)
169+ }
170+ started, err := c.startPointerGrab(context.Background(), ports.PointerButton{Button: btnRight, Pressed: true})
171+ if err != nil || started || c.pgrab != nil {
172+ t.Fatal(started, err)
173+ }
174+ })
175+ }
176+}
177+
178+func TestBorderGrabFloatFallback(t *testing.T) {
179+ c, w := borderCore(t, false)
180+ w.AddFloating(3, 200, 200)
181+ r := w.floatRect(w.Floats[0])
182+ c.pointer = 3
183+ c.cursorX, c.cursorY = float64(r.X+r.W/2), float64(r.Y+r.H/2)
184+ grabButton(t, c, btnRight, true, time.Second)
185+ if c.pgrab != nil || c.drag == nil || !c.drag.resize || !c.drag.float {
186+ t.Fatal("float resize lost")
187+ }
188+}
189+
190+func TestBorderDoubleClick(t *testing.T) {
191+ for _, row := range []bool{false, true} {
192+ c, w := borderCore(t, row)
193+ grabButton(t, c, btnRight, true, time.Second)
194+ grabMove(t, c, c.cursorX+100, c.cursorY+100)
195+ grabButton(t, c, btnRight, false, 1100*time.Millisecond)
196+ grabButton(t, c, btnRight, true, 1200*time.Millisecond)
197+ if c.pgrab != nil || !c.swallow[btnRight] || c.lastBorderClick.ws != nil {
198+ t.Fatal("not reset")
199+ }
200+ for _, col := range w.Columns {
201+ if col.Span != 0 || col.Shares != nil {
202+ t.Fatal("manual size remains", col)
203+ }
204+ }
205+ grabButton(t, c, btnRight, false, 1300*time.Millisecond)
206+ }
207+}
208+
209+func TestBorderGrabInvalidation(t *testing.T) {
210+ for _, change := range []string{"window", "workspace", "output"} {
211+ t.Run(change, func(t *testing.T) {
212+ c, w := borderCore(t, false)
213+ grabButton(t, c, btnRight, true, time.Second)
214+ grabCommands(c)
215+ switch change {
216+ case "window":
217+ if err := c.unmapClient(context.Background(), ports.WindowUnmapped{ID: 2}); err != nil {
218+ t.Fatal(err)
219+ }
220+ case "workspace":
221+ c.cur().mon.Workspaces = append(c.cur().mon.Workspaces, workspace())
222+ c.cur().mon.Active = len(c.cur().mon.Workspaces) - 1
223+ case "output":
224+ c.screens = []*screen{dragCore(t).cur()}
225+ }
226+ if c.pgrab != nil {
227+ if err := c.pgrab.motion(context.Background(), time.Second); err != nil {
228+ t.Fatal(err)
229+ }
230+ }
231+ if c.pgrab != nil || !c.swallow[btnRight] {
232+ t.Fatal("stale grab survives", w.Columns)
233+ }
234+ requireOverride(t, grabCommands(c), "")
235+ })
236+ }
237+}
238+
239+func TestBorderGrabSecurityReset(t *testing.T) {
240+ c, _ := borderCore(t, false)
241+ grabButton(t, c, btnRight, true, time.Second)
242+ grabCommands(c)
243+ gate := portsmocks.NewMockSessionSecurity(t)
244+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
245+ c.opts.Security = gate
246+ if !c.syncSecurity() || c.pgrab != nil {
247+ t.Fatal("grab survived protection")
248+ }
249+ if len(grabCommands(c)) != 0 {
250+ t.Fatal("security reset sent commands")
251+ }
252+}
253+
254+func TestPointerGrabValidityContract(t *testing.T) {
255+ c, _ := borderCore(t, false)
256+ grabButton(t, c, btnRight, true, time.Second)
257+ if !c.pgrab.valid() {
258+ t.Fatal("new grab is invalid")
259+ }
260+ c.screens = nil
261+ if c.pgrab.valid() {
262+ t.Fatal("grab survived output removal")
263+ }
264+}
265+
266+func TestBorderGrabFullscreenPublish(t *testing.T) {
267+ c, w := borderCore(t, false)
268+ w.Overflow = OverflowScroll
269+ grabButton(t, c, btnRight, true, time.Second)
270+ if c.pgrab == nil {
271+ t.Fatal("grab not started")
272+ }
273+ grabCommands(c)
274+ w.fullscreen = 1
275+ if err := c.publish(context.Background()); err != nil {
276+ t.Fatal(err)
277+ }
278+ if c.pgrab != nil || !c.swallow[btnRight] {
279+ t.Fatal("fullscreen did not abort grab and swallow release")
280+ }
281+ requireOverride(t, grabCommands(c), "")
282+ grabButton(t, c, btnRight, false, 2*time.Second)
283+ for _, cmd := range grabCommands(c) {
284+ if _, ok := cmd.(ports.PointerButtonTo); ok {
285+ t.Fatal("release forwarded")
286+ }
287+ }
288+ if c.buttons[btnRight] || c.swallow[btnRight] {
289+ t.Fatal("release still held")
290+ }
291+}
@@ -0,0 +1,291 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+type pointerRig struct {
14+ *Core
15+ commands chan ports.ClientCommand
16+}
17+
18+func borderCore(t *testing.T, row bool) (*pointerRig, *Workspace) {
19+ t.Helper()
20+ commands := make(chan ports.ClientCommand, 1024)
21+ c := &pointerRig{Core: dragCore(t), commands: commands}
22+ c.ch.Commands = commands
23+ w := c.cur().mon.Current()
24+ w.Overflow = OverflowFixed
25+ w.SetMaxColumns(2)
26+ w.SetGaps(10)
27+ w.AddWindow(1)
28+ w.AddWindow(2)
29+ if row {
30+ w.Columns = []Column{{Windows: []WindowID{1, 2}}}
31+ w.Focus = 0
32+ }
33+ rs := w.columnRects()
34+ c.cursorX, c.cursorY = float64(rs[0].X+rs[0].W+w.gap()/2), 100
35+ if row {
36+ rows := rowRects(rs[0], w.Columns[0], w.gap())
37+ c.cursorX, c.cursorY = 100, float64(rows[0].Y+rows[0].H+w.gap()/2)
38+ }
39+ c.mods = ports.ModSuper
40+ return c, w
41+}
42+
43+func grabButton(t *testing.T, c *pointerRig, b uint32, down bool, at time.Duration) {
44+ t.Helper()
45+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: b, Pressed: down, Time: at}); err != nil {
46+ t.Fatal(err)
47+ }
48+}
49+
50+func grabMove(t *testing.T, c *pointerRig, x, y float64) {
51+ t.Helper()
52+ if err := c.pointerMotion(context.Background(), ports.PointerMotion{X: x, Y: y, Time: time.Second}); err != nil {
53+ t.Fatal(err)
54+ }
55+}
56+
57+func grabCommands(c *pointerRig) []ports.ClientCommand {
58+ var out []ports.ClientCommand
59+ for len(c.commands) > 0 {
60+ out = append(out, <-c.commands)
61+ }
62+ return out
63+}
64+
65+func requireOverride(t *testing.T, cmds []ports.ClientCommand, shape string) {
66+ t.Helper()
67+ if !slices.ContainsFunc(cmds, func(cmd ports.ClientCommand) bool { v, ok := cmd.(ports.CursorOverride); return ok && v.Shape == shape }) {
68+ t.Fatalf("missing cursor %q in %+v", shape, cmds)
69+ }
70+}
71+
72+func TestBorderPointerGrab(t *testing.T) {
73+ for _, row := range []bool{false, true} {
74+ t.Run(map[bool]string{false: "column", true: "row"}[row], func(t *testing.T) {
75+ c, w := borderCore(t, row)
76+ before := w.Layout()
77+ grabButton(t, c, btnRight, true, time.Second)
78+ if c.pgrab == nil || c.pointer != 0 || c.drag != nil {
79+ t.Fatal("no exclusive border grab")
80+ }
81+ shape := "ew-resize"
82+ if row {
83+ shape = "ns-resize"
84+ }
85+ requireOverride(t, grabCommands(c), shape)
86+ x, y := c.cursorX+150, c.cursorY
87+ if row {
88+ x, y = c.cursorX, c.cursorY+100
89+ }
90+ grabMove(t, c, x, y)
91+ after := w.Layout()
92+ if before[0].Rect == after[0].Rect || before[1].Rect == after[1].Rect {
93+ t.Fatal("tiles did not resize", before, after)
94+ }
95+ cmds := grabCommands(c)
96+ for _, id := range []WindowID{1, 2} {
97+ if !slices.ContainsFunc(cmds, func(cmd ports.ClientCommand) bool {
98+ v, ok := cmd.(ports.ConfigureWindow)
99+ r := after[int(id)-1].Rect
100+ return ok && v.ID == id && v.Width == r.W-2*w.border && v.Height == r.H-2*w.border
101+ }) {
102+ t.Fatalf("no live configure for %d: %+v", id, cmds)
103+ }
104+ }
105+ grabButton(t, c, btnRight, false, 2*time.Second)
106+ if c.pgrab != nil || !slices.Equal(w.Layout(), after) {
107+ t.Fatal("release changed sizes")
108+ }
109+ requireOverride(t, grabCommands(c), "")
110+ })
111+ }
112+}
113+
114+func TestBorderGrabEscape(t *testing.T) {
115+ for _, row := range []bool{false, true} {
116+ c, w := borderCore(t, row)
117+ before := w.Layout()
118+ grabButton(t, c, btnRight, true, time.Second)
119+ grabMove(t, c, c.cursorX+100, c.cursorY+100)
120+ grabCommands(c)
121+ if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Escape", Pressed: true}); err != nil {
122+ t.Fatal(err)
123+ }
124+ if c.pgrab != nil || !slices.Equal(w.Layout(), before) {
125+ t.Fatal("cancel did not restore", w.Layout(), before)
126+ }
127+ requireOverride(t, grabCommands(c), "")
128+ if !c.swallow[btnRight] {
129+ t.Fatal("release not swallowed")
130+ }
131+ grabButton(t, c, btnRight, false, 2*time.Second)
132+ if c.buttons[btnRight] || c.swallow[btnRight] {
133+ t.Fatal("release still held")
134+ }
135+ }
136+}
137+
138+func TestBorderGrabOtherButtons(t *testing.T) {
139+ c, _ := borderCore(t, false)
140+ grabButton(t, c, btnRight, true, time.Second)
141+ grabCommands(c)
142+ grabButton(t, c, btnLeft, true, time.Second)
143+ grabButton(t, c, btnLeft, false, time.Second)
144+ if c.pgrab == nil || len(grabCommands(c)) != 0 || c.buttons[btnLeft] {
145+ t.Fatal("other button escaped grab")
146+ }
147+}
148+
149+func TestBorderGrabPreconditions(t *testing.T) {
150+ for _, kind := range []string{"no-cmd", "interior", "overview", "preview", "synthetic", "held", "inhibited"} {
151+ t.Run(kind, func(t *testing.T) {
152+ c, _ := borderCore(t, false)
153+ switch kind {
154+ case "no-cmd":
155+ c.mods = 0
156+ case "interior":
157+ c.cursorX = 100
158+ case "overview":
159+ c.cur().mon.ov.open = true
160+ case "preview":
161+ c.cur().mon.sw = switcherState{ws: c.cur().mon.Current(), shown: true}
162+ case "synthetic":
163+ c.synthetic = true
164+ case "held":
165+ c.buttons[btnLeft] = true
166+ case "inhibited":
167+ c.keyboard.inhibiting = 1
168+ c.cur().mon.Current().FocusID(1)
169+ }
170+ started, err := c.startPointerGrab(context.Background(), ports.PointerButton{Button: btnRight, Pressed: true})
171+ if err != nil || started || c.pgrab != nil {
172+ t.Fatal(started, err)
173+ }
174+ })
175+ }
176+}
177+
178+func TestBorderGrabFloatFallback(t *testing.T) {
179+ c, w := borderCore(t, false)
180+ w.AddFloating(3, 200, 200)
181+ r := w.floatRect(w.Floats[0])
182+ c.pointer = 3
183+ c.cursorX, c.cursorY = float64(r.X+r.W/2), float64(r.Y+r.H/2)
184+ grabButton(t, c, btnRight, true, time.Second)
185+ if c.pgrab != nil || c.drag == nil || !c.drag.resize || !c.drag.float {
186+ t.Fatal("float resize lost")
187+ }
188+}
189+
190+func TestBorderDoubleClick(t *testing.T) {
191+ for _, row := range []bool{false, true} {
192+ c, w := borderCore(t, row)
193+ grabButton(t, c, btnRight, true, time.Second)
194+ grabMove(t, c, c.cursorX+100, c.cursorY+100)
195+ grabButton(t, c, btnRight, false, 1100*time.Millisecond)
196+ grabButton(t, c, btnRight, true, 1200*time.Millisecond)
197+ if c.pgrab != nil || !c.swallow[btnRight] || c.lastBorderClick.ws != nil {
198+ t.Fatal("not reset")
199+ }
200+ for _, col := range w.Columns {
201+ if col.Span != 0 || col.Shares != nil {
202+ t.Fatal("manual size remains", col)
203+ }
204+ }
205+ grabButton(t, c, btnRight, false, 1300*time.Millisecond)
206+ }
207+}
208+
209+func TestBorderGrabInvalidation(t *testing.T) {
210+ for _, change := range []string{"window", "workspace", "output"} {
211+ t.Run(change, func(t *testing.T) {
212+ c, w := borderCore(t, false)
213+ grabButton(t, c, btnRight, true, time.Second)
214+ grabCommands(c)
215+ switch change {
216+ case "window":
217+ if err := c.unmapClient(context.Background(), ports.WindowUnmapped{ID: 2}); err != nil {
218+ t.Fatal(err)
219+ }
220+ case "workspace":
221+ c.cur().mon.Workspaces = append(c.cur().mon.Workspaces, workspace())
222+ c.cur().mon.Active = len(c.cur().mon.Workspaces) - 1
223+ case "output":
224+ c.screens = []*screen{dragCore(t).cur()}
225+ }
226+ if c.pgrab != nil {
227+ if err := c.pgrab.motion(context.Background(), time.Second); err != nil {
228+ t.Fatal(err)
229+ }
230+ }
231+ if c.pgrab != nil || !c.swallow[btnRight] {
232+ t.Fatal("stale grab survives", w.Columns)
233+ }
234+ requireOverride(t, grabCommands(c), "")
235+ })
236+ }
237+}
238+
239+func TestBorderGrabSecurityReset(t *testing.T) {
240+ c, _ := borderCore(t, false)
241+ grabButton(t, c, btnRight, true, time.Second)
242+ grabCommands(c)
243+ gate := portsmocks.NewMockSessionSecurity(t)
244+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
245+ c.opts.Security = gate
246+ if !c.syncSecurity() || c.pgrab != nil {
247+ t.Fatal("grab survived protection")
248+ }
249+ if len(grabCommands(c)) != 0 {
250+ t.Fatal("security reset sent commands")
251+ }
252+}
253+
254+func TestPointerGrabValidityContract(t *testing.T) {
255+ c, _ := borderCore(t, false)
256+ grabButton(t, c, btnRight, true, time.Second)
257+ if !c.pgrab.valid() {
258+ t.Fatal("new grab is invalid")
259+ }
260+ c.screens = nil
261+ if c.pgrab.valid() {
262+ t.Fatal("grab survived output removal")
263+ }
264+}
265+
266+func TestBorderGrabFullscreenPublish(t *testing.T) {
267+ c, w := borderCore(t, false)
268+ w.Overflow = OverflowScroll
269+ grabButton(t, c, btnRight, true, time.Second)
270+ if c.pgrab == nil {
271+ t.Fatal("grab not started")
272+ }
273+ grabCommands(c)
274+ w.fullscreen = 1
275+ if err := c.publish(context.Background()); err != nil {
276+ t.Fatal(err)
277+ }
278+ if c.pgrab != nil || !c.swallow[btnRight] {
279+ t.Fatal("fullscreen did not abort grab and swallow release")
280+ }
281+ requireOverride(t, grabCommands(c), "")
282+ grabButton(t, c, btnRight, false, 2*time.Second)
283+ for _, cmd := range grabCommands(c) {
284+ if _, ok := cmd.(ports.PointerButtonTo); ok {
285+ t.Fatal("release forwarded")
286+ }
287+ }
288+ if c.buttons[btnRight] || c.swallow[btnRight] {
289+ t.Fatal("release still held")
290+ }
291+}
M internal/core/publish_alloc_test.go +1 -1
@@ -447,7 +447,7 @@ func TestPublishSubscribersAllocations(t *testing.T) {
447447 t.Error("the sent output layout shares the scratch")
448448 }
449449 // And a later change leaves them as they were.
450- want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows), Workspaces: cloneState(st).Workspaces}
450+ want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows)}
451451 w := *st.Window
452452 want.Window = &w
453453 c.windows.setIdleInhibit(st.Windows[0].ID, true)
@@ -447,7 +447,7 @@ func TestPublishSubscribersAllocations(t *testing.T) {
447 t.Error("the sent output layout shares the scratch")447 t.Error("the sent output layout shares the scratch")
448 }448 }
449 // And a later change leaves them as they were.449 // And a later change leaves them as they were.
450- want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows), Workspaces: cloneState(st).Workspaces}450+ want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows)}
451 w := *st.Window451 w := *st.Window
452 want.Window = &w452 want.Window = &w
453 c.windows.setIdleInhibit(st.Windows[0].ID, true)453 c.windows.setIdleInhibit(st.Windows[0].ID, true)
M internal/core/row_shares_test.go +17 -17
@@ -29,15 +29,15 @@ func TestRowRectsEqualWithoutShares(t *testing.T) {
2929 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
3030 t.Fatal(rowRects(r, c, 5))
3131 }
32- c.Shares = []int{50, 50} // wrong length: ignored
32+ c.Shares = []int{5000, 5000} // wrong length: ignored
3333 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
3434 t.Fatal(rowRects(r, c, 5))
3535 }
36- c.Shares = []int{50, 30, 30} // wrong sum: ignored
36+ c.Shares = []int{5000, 3000, 3000} // wrong sum: ignored
3737 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
3838 t.Fatal(rowRects(r, c, 5))
3939 }
40- c.Shares = []int{50, 30, 20}
40+ c.Shares = []int{5000, 3000, 2000}
4141 rows := rowRects(r, c, 5)
4242 if rows[0].H != 46 || rows[1].H != 27 || rows[2].H != 93-46-27 || rows[2].Y+rows[2].H != 103 {
4343 t.Fatal(rows)
@@ -51,7 +51,7 @@ func TestResizeRow(t *testing.T) {
5151 w.SetOutput(1000, 800)
5252 w.Columns = []Column{{Windows: []WindowID{1, 2}}}
5353 w.Apply("set-window-height +10%")
54- if !slices.Equal(w.Columns[0].Shares, []int{60, 40}) {
54+ if !slices.Equal(w.Columns[0].Shares, []int{6000, 4000}) {
5555 t.Fatal(o, w.Columns[0].Shares)
5656 }
5757 h := heights(w)
@@ -61,13 +61,13 @@ func TestResizeRow(t *testing.T) {
6161 for range 10 {
6262 w.Apply("set-window-height +10%")
6363 }
64- if !slices.Equal(w.Columns[0].Shares, []int{90, 10}) {
64+ if !slices.Equal(w.Columns[0].Shares, []int{9000, 1000}) {
6565 t.Fatal(o, w.Columns[0].Shares)
6666 }
6767 for range 10 {
6868 w.Apply("set-window-height -10%")
6969 }
70- if !slices.Equal(w.Columns[0].Shares, []int{10, 90}) {
70+ if !slices.Equal(w.Columns[0].Shares, []int{1000, 9000}) {
7171 t.Fatal(o, w.Columns[0].Shares)
7272 }
7373 }
@@ -79,24 +79,24 @@ func TestResizeRowThree(t *testing.T) {
7979 w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}}
8080 w.Apply("set-window-height +10%")
8181 s := w.Columns[0].Shares
82- if sum(s) != 100 || s[1] != 43 || s[0] < 10 || s[2] < 10 {
82+ if sum(s) != 10000 || s[1] != 4333 || s[0] < 1000 || s[2] < 1000 {
8383 t.Fatal(s)
8484 }
8585 for _, step := range []string{"+25%", "+25%", "-7%", "-100%", "+100%", "-3%"} {
8686 w.Apply(Action("set-window-height " + step))
8787 s := w.Columns[0].Shares
88- if sum(s) != 100 || slices.Min(s) < 10 {
88+ if sum(s) != 10000 || slices.Min(s) < 1000 {
8989 t.Fatal(step, s)
9090 }
9191 }
92- if s := w.Columns[0].Shares; s[1] != 77 {
92+ if s := w.Columns[0].Shares; s[1] != 7700 {
9393 t.Fatal(s)
9494 }
9595 // Proportional: the others shrink by their room above the minimum.
96- w.Columns[0].Shares = []int{50, 20, 30}
96+ w.Columns[0].Shares = []int{5000, 2000, 3000}
9797 w.Columns[0].Focus = 1
9898 w.Apply("set-window-height +20%")
99- if s := w.Columns[0].Shares; !slices.Equal(s, []int{37, 40, 23}) {
99+ if s := w.Columns[0].Shares; !slices.Equal(s, []int{3667, 4000, 2333}) {
100100 t.Fatal(s)
101101 }
102102 }
@@ -140,25 +140,25 @@ func TestSharesResetAndTravel(t *testing.T) {
140140 t.Fatal(w.Columns)
141141 }
142142 w.Apply("set-window-height +20%")
143- if !slices.Equal(w.Columns[0].Shares, []int{30, 70}) {
143+ if !slices.Equal(w.Columns[0].Shares, []int{3000, 7000}) {
144144 t.Fatal(w.Columns[0].Shares)
145145 }
146146 // Moving the window inside the column swaps the shares with it.
147147 w.Apply(ActionMoveWindowUp)
148- if !slices.Equal(w.Columns[0].Shares, []int{70, 30}) || w.Columns[0].Windows[0] != 2 {
148+ if !slices.Equal(w.Columns[0].Shares, []int{7000, 3000}) || w.Columns[0].Windows[0] != 2 {
149149 t.Fatal(w.Columns[0])
150150 }
151151 // The column takes its shares to another workspace.
152152 m.MoveToWorkspace(1, true)
153153 m.FocusNumber(2)
154- if c := m.Current().Columns[0]; !slices.Equal(c.Shares, []int{70, 30}) {
154+ if c := m.Current().Columns[0]; !slices.Equal(c.Shares, []int{7000, 3000}) {
155155 t.Fatal(c)
156156 }
157157 // MoveColumn keeps them.
158158 m.AddWindow(3)
159159 m.Current().FocusColumn(-1)
160160 m.Current().MoveColumn(1)
161- if c := m.Current().Columns[1]; !slices.Equal(c.Shares, []int{70, 30}) {
161+ if c := m.Current().Columns[1]; !slices.Equal(c.Shares, []int{7000, 3000}) {
162162 t.Fatal(m.Current().Columns)
163163 }
164164 // A window leaving resets the column to equal rows.
@@ -171,7 +171,7 @@ func TestSharesResetAndTravel(t *testing.T) {
171171 }
172172 }
173173 // A window joining invalidates them: equal rows.
174- w.Columns = []Column{{Windows: []WindowID{1, 3}, Shares: []int{80, 20}}}
174+ w.Columns = []Column{{Windows: []WindowID{1, 3}, Shares: []int{8000, 2000}}}
175175 w.Columns[0].Windows = append(w.Columns[0].Windows, 4)
176176 h := heights(w)
177177 if h[1] != h[3] {
@@ -182,7 +182,7 @@ func TestSharesResetAndTravel(t *testing.T) {
182182 func TestOverviewPreviewUsesShares(t *testing.T) {
183183 w := workspace()
184184 w.SetOutput(1000, 800)
185- w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{70, 30}}}
185+ w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{7000, 3000}}}
186186 tiles, _, _ := w.previewTiles()
187187 h := map[WindowID]int{}
188188 for _, p := range tiles {
@@ -29,15 +29,15 @@ func TestRowRectsEqualWithoutShares(t *testing.T) {
29 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {29 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
30 t.Fatal(rowRects(r, c, 5))30 t.Fatal(rowRects(r, c, 5))
31 }31 }
32- c.Shares = []int{50, 50} // wrong length: ignored32+ c.Shares = []int{5000, 5000} // wrong length: ignored
33 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {33 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
34 t.Fatal(rowRects(r, c, 5))34 t.Fatal(rowRects(r, c, 5))
35 }35 }
36- c.Shares = []int{50, 30, 30} // wrong sum: ignored36+ c.Shares = []int{5000, 3000, 3000} // wrong sum: ignored
37 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {37 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
38 t.Fatal(rowRects(r, c, 5))38 t.Fatal(rowRects(r, c, 5))
39 }39 }
40- c.Shares = []int{50, 30, 20}40+ c.Shares = []int{5000, 3000, 2000}
41 rows := rowRects(r, c, 5)41 rows := rowRects(r, c, 5)
42 if rows[0].H != 46 || rows[1].H != 27 || rows[2].H != 93-46-27 || rows[2].Y+rows[2].H != 103 {42 if rows[0].H != 46 || rows[1].H != 27 || rows[2].H != 93-46-27 || rows[2].Y+rows[2].H != 103 {
43 t.Fatal(rows)43 t.Fatal(rows)
@@ -51,7 +51,7 @@ func TestResizeRow(t *testing.T) {
51 w.SetOutput(1000, 800)51 w.SetOutput(1000, 800)
52 w.Columns = []Column{{Windows: []WindowID{1, 2}}}52 w.Columns = []Column{{Windows: []WindowID{1, 2}}}
53 w.Apply("set-window-height +10%")53 w.Apply("set-window-height +10%")
54- if !slices.Equal(w.Columns[0].Shares, []int{60, 40}) {54+ if !slices.Equal(w.Columns[0].Shares, []int{6000, 4000}) {
55 t.Fatal(o, w.Columns[0].Shares)55 t.Fatal(o, w.Columns[0].Shares)
56 }56 }
57 h := heights(w)57 h := heights(w)
@@ -61,13 +61,13 @@ func TestResizeRow(t *testing.T) {
61 for range 10 {61 for range 10 {
62 w.Apply("set-window-height +10%")62 w.Apply("set-window-height +10%")
63 }63 }
64- if !slices.Equal(w.Columns[0].Shares, []int{90, 10}) {64+ if !slices.Equal(w.Columns[0].Shares, []int{9000, 1000}) {
65 t.Fatal(o, w.Columns[0].Shares)65 t.Fatal(o, w.Columns[0].Shares)
66 }66 }
67 for range 10 {67 for range 10 {
68 w.Apply("set-window-height -10%")68 w.Apply("set-window-height -10%")
69 }69 }
70- if !slices.Equal(w.Columns[0].Shares, []int{10, 90}) {70+ if !slices.Equal(w.Columns[0].Shares, []int{1000, 9000}) {
71 t.Fatal(o, w.Columns[0].Shares)71 t.Fatal(o, w.Columns[0].Shares)
72 }72 }
73 }73 }
@@ -79,24 +79,24 @@ func TestResizeRowThree(t *testing.T) {
79 w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}}79 w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}}
80 w.Apply("set-window-height +10%")80 w.Apply("set-window-height +10%")
81 s := w.Columns[0].Shares81 s := w.Columns[0].Shares
82- if sum(s) != 100 || s[1] != 43 || s[0] < 10 || s[2] < 10 {82+ if sum(s) != 10000 || s[1] != 4333 || s[0] < 1000 || s[2] < 1000 {
83 t.Fatal(s)83 t.Fatal(s)
84 }84 }
85 for _, step := range []string{"+25%", "+25%", "-7%", "-100%", "+100%", "-3%"} {85 for _, step := range []string{"+25%", "+25%", "-7%", "-100%", "+100%", "-3%"} {
86 w.Apply(Action("set-window-height " + step))86 w.Apply(Action("set-window-height " + step))
87 s := w.Columns[0].Shares87 s := w.Columns[0].Shares
88- if sum(s) != 100 || slices.Min(s) < 10 {88+ if sum(s) != 10000 || slices.Min(s) < 1000 {
89 t.Fatal(step, s)89 t.Fatal(step, s)
90 }90 }
91 }91 }
92- if s := w.Columns[0].Shares; s[1] != 77 {92+ if s := w.Columns[0].Shares; s[1] != 7700 {
93 t.Fatal(s)93 t.Fatal(s)
94 }94 }
95 // Proportional: the others shrink by their room above the minimum.95 // Proportional: the others shrink by their room above the minimum.
96- w.Columns[0].Shares = []int{50, 20, 30}96+ w.Columns[0].Shares = []int{5000, 2000, 3000}
97 w.Columns[0].Focus = 197 w.Columns[0].Focus = 1
98 w.Apply("set-window-height +20%")98 w.Apply("set-window-height +20%")
99- if s := w.Columns[0].Shares; !slices.Equal(s, []int{37, 40, 23}) {99+ if s := w.Columns[0].Shares; !slices.Equal(s, []int{3667, 4000, 2333}) {
100 t.Fatal(s)100 t.Fatal(s)
101 }101 }
102 }102 }
@@ -140,25 +140,25 @@ func TestSharesResetAndTravel(t *testing.T) {
140 t.Fatal(w.Columns)140 t.Fatal(w.Columns)
141 }141 }
142 w.Apply("set-window-height +20%")142 w.Apply("set-window-height +20%")
143- if !slices.Equal(w.Columns[0].Shares, []int{30, 70}) {143+ if !slices.Equal(w.Columns[0].Shares, []int{3000, 7000}) {
144 t.Fatal(w.Columns[0].Shares)144 t.Fatal(w.Columns[0].Shares)
145 }145 }
146 // Moving the window inside the column swaps the shares with it.146 // Moving the window inside the column swaps the shares with it.
147 w.Apply(ActionMoveWindowUp)147 w.Apply(ActionMoveWindowUp)
148- if !slices.Equal(w.Columns[0].Shares, []int{70, 30}) || w.Columns[0].Windows[0] != 2 {148+ if !slices.Equal(w.Columns[0].Shares, []int{7000, 3000}) || w.Columns[0].Windows[0] != 2 {
149 t.Fatal(w.Columns[0])149 t.Fatal(w.Columns[0])
150 }150 }
151 // The column takes its shares to another workspace.151 // The column takes its shares to another workspace.
152 m.MoveToWorkspace(1, true)152 m.MoveToWorkspace(1, true)
153 m.FocusNumber(2)153 m.FocusNumber(2)
154- if c := m.Current().Columns[0]; !slices.Equal(c.Shares, []int{70, 30}) {154+ if c := m.Current().Columns[0]; !slices.Equal(c.Shares, []int{7000, 3000}) {
155 t.Fatal(c)155 t.Fatal(c)
156 }156 }
157 // MoveColumn keeps them.157 // MoveColumn keeps them.
158 m.AddWindow(3)158 m.AddWindow(3)
159 m.Current().FocusColumn(-1)159 m.Current().FocusColumn(-1)
160 m.Current().MoveColumn(1)160 m.Current().MoveColumn(1)
161- if c := m.Current().Columns[1]; !slices.Equal(c.Shares, []int{70, 30}) {161+ if c := m.Current().Columns[1]; !slices.Equal(c.Shares, []int{7000, 3000}) {
162 t.Fatal(m.Current().Columns)162 t.Fatal(m.Current().Columns)
163 }163 }
164 // A window leaving resets the column to equal rows.164 // A window leaving resets the column to equal rows.
@@ -171,7 +171,7 @@ func TestSharesResetAndTravel(t *testing.T) {
171 }171 }
172 }172 }
173 // A window joining invalidates them: equal rows.173 // A window joining invalidates them: equal rows.
174- w.Columns = []Column{{Windows: []WindowID{1, 3}, Shares: []int{80, 20}}}174+ w.Columns = []Column{{Windows: []WindowID{1, 3}, Shares: []int{8000, 2000}}}
175 w.Columns[0].Windows = append(w.Columns[0].Windows, 4)175 w.Columns[0].Windows = append(w.Columns[0].Windows, 4)
176 h := heights(w)176 h := heights(w)
177 if h[1] != h[3] {177 if h[1] != h[3] {
@@ -182,7 +182,7 @@ func TestSharesResetAndTravel(t *testing.T) {
182 func TestOverviewPreviewUsesShares(t *testing.T) {182 func TestOverviewPreviewUsesShares(t *testing.T) {
183 w := workspace()183 w := workspace()
184 w.SetOutput(1000, 800)184 w.SetOutput(1000, 800)
185- w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{70, 30}}}185+ w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{7000, 3000}}}
186 tiles, _, _ := w.previewTiles()186 tiles, _, _ := w.previewTiles()
187 h := map[WindowID]int{}187 h := map[WindowID]int{}
188 for _, p := range tiles {188 for _, p := range tiles {
M internal/core/screens_test.go +0 -6
@@ -838,12 +838,6 @@ func TestStateSnapshot(t *testing.T) {
838838 {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true, Column: 1, Row: 1},
839839 },
840840 }
841- want.Workspaces = []ports.WorkspaceState{
842- {ID: 1, Number: 1, Output: "DP-1", Visible: true, Overflow: "scroll", Columns: []ports.ColumnState{{Rect: ports.Rect{W: 200, H: 100}, Windows: []ports.TileState{{ID: 1, Rect: ports.Rect{W: 200, H: 100}}}}}},
843- {ID: 3, Number: 2, Output: "DP-1", Overflow: "scroll", Columns: []ports.ColumnState{}},
844- {ID: 2, Number: 1, Output: "DP-2", Visible: true, Overflow: "scroll", Columns: []ports.ColumnState{{Rect: ports.Rect{W: 400, H: 200}, Windows: []ports.TileState{{ID: 2, Rect: ports.Rect{W: 400, H: 200}}}}}},
845- {ID: 4, Number: 2, Output: "DP-2", Overflow: "scroll", Columns: []ports.ColumnState{}},
846- }
847841 want.Window = &want.Windows[1]
848842 if !reflect.DeepEqual(st, want) {
849843 t.Fatalf("got %+v\nwant %+v", st, want)
@@ -838,12 +838,6 @@ func TestStateSnapshot(t *testing.T) {
838 {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true, Column: 1, Row: 1},838 {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true, Column: 1, Row: 1},
839 },839 },
840 }840 }
841- want.Workspaces = []ports.WorkspaceState{
842- {ID: 1, Number: 1, Output: "DP-1", Visible: true, Overflow: "scroll", Columns: []ports.ColumnState{{Rect: ports.Rect{W: 200, H: 100}, Windows: []ports.TileState{{ID: 1, Rect: ports.Rect{W: 200, H: 100}}}}}},
843- {ID: 3, Number: 2, Output: "DP-1", Overflow: "scroll", Columns: []ports.ColumnState{}},
844- {ID: 2, Number: 1, Output: "DP-2", Visible: true, Overflow: "scroll", Columns: []ports.ColumnState{{Rect: ports.Rect{W: 400, H: 200}, Windows: []ports.TileState{{ID: 2, Rect: ports.Rect{W: 400, H: 200}}}}}},
845- {ID: 4, Number: 2, Output: "DP-2", Overflow: "scroll", Columns: []ports.ColumnState{}},
846- }
847 want.Window = &want.Windows[1]841 want.Window = &want.Windows[1]
848 if !reflect.DeepEqual(st, want) {842 if !reflect.DeepEqual(st, want) {
849 t.Fatalf("got %+v\nwant %+v", st, want)843 t.Fatalf("got %+v\nwant %+v", st, want)
M internal/core/security.go +3 -0
@@ -35,6 +35,9 @@ func (c *Core) syncSecurity() bool {
3535 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0
3636 // Wayland cancels its own drag when the epoch changes.
3737 c.dataDrag = nil
38+ // The adapter clears cursor overrides when protection starts; send nothing.
39+ c.pgrab = nil
40+ c.lastBorderClick.ws = nil
3841 c.pointerAt, c.motionTime = [2]float64{}, 0
3942 c.keyboard = keyboard{}
4043 c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}
@@ -35,6 +35,9 @@ func (c *Core) syncSecurity() bool {
35 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 035 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0
36 // Wayland cancels its own drag when the epoch changes.36 // Wayland cancels its own drag when the epoch changes.
37 c.dataDrag = nil37 c.dataDrag = nil
38+ // The adapter clears cursor overrides when protection starts; send nothing.
39+ c.pgrab = nil
40+ c.lastBorderClick.ws = nil
38 c.pointerAt, c.motionTime = [2]float64{}, 041 c.pointerAt, c.motionTime = [2]float64{}, 0
39 c.keyboard = keyboard{}42 c.keyboard = keyboard{}
40 c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}43 c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}
A internal/core/spans_test.go +203 -0
@@ -0,0 +1,203 @@
1+package core
2+
3+import "testing"
4+
5+func spanWorkspace(mode Overflow, n int) *Workspace {
6+ w := workspace()
7+ w.Overflow = mode
8+ w.MaxColumns = 3
9+ w.SetOutput(1000, 800)
10+ w.SetGaps(10)
11+ for i := 0; i < n; i++ {
12+ w.AddWindow(WindowID(i + 1))
13+ }
14+ w.View = 0
15+ w.view = slide{}
16+ return w
17+}
18+
19+func stampSpans(w *Workspace, first, last int) {
20+ w.spanSeq++
21+ for i := first; i <= last; i++ {
22+ w.Columns[i].Span = 10000 / (last - first + 1)
23+ w.Columns[i].spanStamp = w.spanSeq
24+ }
25+ w.Columns[last].Span += 10000 % (last - first + 1)
26+}
27+
28+func TestSpansMutations(t *testing.T) {
29+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
30+ for _, mutation := range []string{"add", "remove", "take", "receive", "swap", "grow", "shrink", "action"} {
31+ t.Run(string(mode)+"/"+mutation, func(t *testing.T) {
32+ w := spanWorkspace(mode, 2)
33+ stampSpans(w, 0, 1)
34+ switch mutation {
35+ case "add":
36+ w.AddWindow(3)
37+ case "remove":
38+ w.RemoveWindow(1)
39+ case "take":
40+ col, ok := w.takeColumn()
41+ if !ok {
42+ t.Fatal("take")
43+ }
44+ other := spanWorkspace(mode, 1)
45+ other.receive(col, -1)
46+ for _, c := range other.Columns {
47+ if c.Span != 0 {
48+ t.Fatal("destination kept stale span")
49+ }
50+ }
51+ case "receive":
52+ w.receive(Column{Windows: []WindowID{3}}, 1)
53+ case "swap":
54+ w.Focus = 0
55+ w.MoveColumn(1)
56+ case "grow":
57+ w.GrowColumn(false)
58+ case "shrink":
59+ w.ShrinkColumn()
60+ case "action":
61+ w.Apply("set-column-width +1")
62+ }
63+ for _, c := range w.Columns {
64+ if mutation == "swap" {
65+ if c.Span == 0 {
66+ t.Fatal("swap discarded valid spans")
67+ }
68+ } else if c.Span != 0 || c.spanStamp != 0 {
69+ t.Fatal("stale span", c)
70+ }
71+ }
72+ })
73+ }
74+ }
75+}
76+
77+func TestSpansCascadeIsolation(t *testing.T) {
78+ w := spanWorkspace(OverflowCascade, 5)
79+ stampSpans(w, 0, 2)
80+ stampSpans(w, 3, 4)
81+ w.RemoveWindow(w.Columns[0].Windows[0])
82+ if w.Columns[0].Span != 0 || w.Columns[1].Span != 0 {
83+ t.Fatal("changed band retained spans")
84+ }
85+ if w.Columns[2].Span != 5000 || w.Columns[3].Span != 5000 {
86+ t.Fatal("unrelated band reset")
87+ }
88+}
89+
90+func TestSpanWidths(t *testing.T) {
91+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
92+ w := spanWorkspace(mode, 2)
93+ stampSpans(w, 0, 1)
94+ w.Columns[0].Span = 3000
95+ w.Columns[1].Span = 7000
96+ rs := w.columnRects()
97+ avail := 970
98+ if mode == OverflowScroll {
99+ avail = 980
100+ }
101+ if rs[0].W != avail*3/10 {
102+ t.Fatal(mode, rs)
103+ }
104+ w.SetOutput(1400, 800)
105+ if w.columnRects()[0].W != (avail+400)*3/10 {
106+ t.Fatal("ratio lost", mode, w.columnRects())
107+ }
108+ w.Columns[0].FullWidth = true
109+ if w.columnWidth(0) != 1380 {
110+ t.Fatal("special width lost")
111+ }
112+ }
113+ w := spanWorkspace(OverflowFixed, 4)
114+ stampSpans(w, 0, 3)
115+ w.scroll()
116+ for _, c := range w.Columns {
117+ if c.Span != 0 {
118+ t.Fatal("spiral spans")
119+ }
120+ }
121+}
122+
123+func TestSpansDropIntoLane(t *testing.T) {
124+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
125+ w := spanWorkspace(mode, 2)
126+ stampSpans(w, 0, 1)
127+ src := spanWorkspace(mode, 1)
128+ src.Columns[0].Windows[0] = 9
129+ m := monitor()
130+ m.Workspaces = []*Workspace{w, src}
131+ sc := &screen{mon: m}
132+ c := &Core{screens: []*screen{sc}}
133+ c.drop(9, dropTarget{kind: dropInsert, screen: sc, ws: w, anchor: w.Columns[1].Windows[0]})
134+ if len(w.Columns) != 3 {
135+ t.Fatal("not dropped", mode, w.Columns)
136+ }
137+ for _, col := range w.Columns {
138+ if col.Span != 0 || col.spanStamp != 0 {
139+ t.Fatal("drop retained stamp", mode, col)
140+ }
141+ }
142+ }
143+}
144+
145+func TestSpanLaneIdentity(t *testing.T) {
146+ for _, action := range []string{"cross-drop", "within-drop", "within-keyboard"} {
147+ t.Run(action, func(t *testing.T) {
148+ w := spanWorkspace(OverflowCascade, 4)
149+ w.SetMaxColumns(2)
150+ for i := range w.Columns {
151+ w.Columns[i].BandStart = i%2 == 0
152+ }
153+ for _, i := range []int{0, 2} {
154+ rs := w.columnRects()
155+ w.setBorder(border{kind: borderColumn, col: i}, rs[i].X+rs[i].W+5)
156+ }
157+ for _, col := range w.Columns {
158+ if col.Span != 5000 {
159+ t.Fatal("not stamped equally", w.Columns)
160+ }
161+ }
162+ cross := action == "cross-drop"
163+ if action == "cross-drop" || action == "within-drop" {
164+ m := monitor()
165+ m.Workspaces = []*Workspace{w}
166+ sc := &screen{mon: m}
167+ c := &Core{screens: []*screen{sc}}
168+ to := 1
169+ if cross {
170+ to = 2
171+ }
172+ c.drop(w.Columns[0].Windows[0], dropTarget{kind: dropSwap, screen: sc, ws: w, anchor: w.Columns[to].Windows[0]})
173+ } else {
174+ w.Focus = 0
175+ w.MoveColumn(1)
176+ }
177+ w.dropStaleSpans()
178+ for _, col := range w.Columns {
179+ if cross && col.Span != 0 {
180+ t.Fatal("cross-band swap retained stamp", w.Columns)
181+ }
182+ if !cross && col.Span != 5000 {
183+ t.Fatal("within-band swap discarded stamp", w.Columns)
184+ }
185+ }
186+ })
187+ }
188+}
189+
190+// A column arriving from another workspace carries that workspace's stamp,
191+// which may collide with a stamp issued here.
192+func TestSpansForeignStampCleared(t *testing.T) {
193+ w := spanWorkspace(OverflowScroll, 2)
194+ stampSpans(w, 0, 1)
195+ stamp := w.spanSeq
196+ w.receive(Column{Windows: []WindowID{9}, Span: 3000, spanStamp: stamp}, -1)
197+ w.dropStaleSpans()
198+ for _, col := range w.Columns {
199+ if col.Span != 0 || col.spanStamp != 0 {
200+ t.Fatal("foreign stamp joined the lane", w.Columns)
201+ }
202+ }
203+}
@@ -0,0 +1,203 @@
1+package core
2+
3+import "testing"
4+
5+func spanWorkspace(mode Overflow, n int) *Workspace {
6+ w := workspace()
7+ w.Overflow = mode
8+ w.MaxColumns = 3
9+ w.SetOutput(1000, 800)
10+ w.SetGaps(10)
11+ for i := 0; i < n; i++ {
12+ w.AddWindow(WindowID(i + 1))
13+ }
14+ w.View = 0
15+ w.view = slide{}
16+ return w
17+}
18+
19+func stampSpans(w *Workspace, first, last int) {
20+ w.spanSeq++
21+ for i := first; i <= last; i++ {
22+ w.Columns[i].Span = 10000 / (last - first + 1)
23+ w.Columns[i].spanStamp = w.spanSeq
24+ }
25+ w.Columns[last].Span += 10000 % (last - first + 1)
26+}
27+
28+func TestSpansMutations(t *testing.T) {
29+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
30+ for _, mutation := range []string{"add", "remove", "take", "receive", "swap", "grow", "shrink", "action"} {
31+ t.Run(string(mode)+"/"+mutation, func(t *testing.T) {
32+ w := spanWorkspace(mode, 2)
33+ stampSpans(w, 0, 1)
34+ switch mutation {
35+ case "add":
36+ w.AddWindow(3)
37+ case "remove":
38+ w.RemoveWindow(1)
39+ case "take":
40+ col, ok := w.takeColumn()
41+ if !ok {
42+ t.Fatal("take")
43+ }
44+ other := spanWorkspace(mode, 1)
45+ other.receive(col, -1)
46+ for _, c := range other.Columns {
47+ if c.Span != 0 {
48+ t.Fatal("destination kept stale span")
49+ }
50+ }
51+ case "receive":
52+ w.receive(Column{Windows: []WindowID{3}}, 1)
53+ case "swap":
54+ w.Focus = 0
55+ w.MoveColumn(1)
56+ case "grow":
57+ w.GrowColumn(false)
58+ case "shrink":
59+ w.ShrinkColumn()
60+ case "action":
61+ w.Apply("set-column-width +1")
62+ }
63+ for _, c := range w.Columns {
64+ if mutation == "swap" {
65+ if c.Span == 0 {
66+ t.Fatal("swap discarded valid spans")
67+ }
68+ } else if c.Span != 0 || c.spanStamp != 0 {
69+ t.Fatal("stale span", c)
70+ }
71+ }
72+ })
73+ }
74+ }
75+}
76+
77+func TestSpansCascadeIsolation(t *testing.T) {
78+ w := spanWorkspace(OverflowCascade, 5)
79+ stampSpans(w, 0, 2)
80+ stampSpans(w, 3, 4)
81+ w.RemoveWindow(w.Columns[0].Windows[0])
82+ if w.Columns[0].Span != 0 || w.Columns[1].Span != 0 {
83+ t.Fatal("changed band retained spans")
84+ }
85+ if w.Columns[2].Span != 5000 || w.Columns[3].Span != 5000 {
86+ t.Fatal("unrelated band reset")
87+ }
88+}
89+
90+func TestSpanWidths(t *testing.T) {
91+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
92+ w := spanWorkspace(mode, 2)
93+ stampSpans(w, 0, 1)
94+ w.Columns[0].Span = 3000
95+ w.Columns[1].Span = 7000
96+ rs := w.columnRects()
97+ avail := 970
98+ if mode == OverflowScroll {
99+ avail = 980
100+ }
101+ if rs[0].W != avail*3/10 {
102+ t.Fatal(mode, rs)
103+ }
104+ w.SetOutput(1400, 800)
105+ if w.columnRects()[0].W != (avail+400)*3/10 {
106+ t.Fatal("ratio lost", mode, w.columnRects())
107+ }
108+ w.Columns[0].FullWidth = true
109+ if w.columnWidth(0) != 1380 {
110+ t.Fatal("special width lost")
111+ }
112+ }
113+ w := spanWorkspace(OverflowFixed, 4)
114+ stampSpans(w, 0, 3)
115+ w.scroll()
116+ for _, c := range w.Columns {
117+ if c.Span != 0 {
118+ t.Fatal("spiral spans")
119+ }
120+ }
121+}
122+
123+func TestSpansDropIntoLane(t *testing.T) {
124+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
125+ w := spanWorkspace(mode, 2)
126+ stampSpans(w, 0, 1)
127+ src := spanWorkspace(mode, 1)
128+ src.Columns[0].Windows[0] = 9
129+ m := monitor()
130+ m.Workspaces = []*Workspace{w, src}
131+ sc := &screen{mon: m}
132+ c := &Core{screens: []*screen{sc}}
133+ c.drop(9, dropTarget{kind: dropInsert, screen: sc, ws: w, anchor: w.Columns[1].Windows[0]})
134+ if len(w.Columns) != 3 {
135+ t.Fatal("not dropped", mode, w.Columns)
136+ }
137+ for _, col := range w.Columns {
138+ if col.Span != 0 || col.spanStamp != 0 {
139+ t.Fatal("drop retained stamp", mode, col)
140+ }
141+ }
142+ }
143+}
144+
145+func TestSpanLaneIdentity(t *testing.T) {
146+ for _, action := range []string{"cross-drop", "within-drop", "within-keyboard"} {
147+ t.Run(action, func(t *testing.T) {
148+ w := spanWorkspace(OverflowCascade, 4)
149+ w.SetMaxColumns(2)
150+ for i := range w.Columns {
151+ w.Columns[i].BandStart = i%2 == 0
152+ }
153+ for _, i := range []int{0, 2} {
154+ rs := w.columnRects()
155+ w.setBorder(border{kind: borderColumn, col: i}, rs[i].X+rs[i].W+5)
156+ }
157+ for _, col := range w.Columns {
158+ if col.Span != 5000 {
159+ t.Fatal("not stamped equally", w.Columns)
160+ }
161+ }
162+ cross := action == "cross-drop"
163+ if action == "cross-drop" || action == "within-drop" {
164+ m := monitor()
165+ m.Workspaces = []*Workspace{w}
166+ sc := &screen{mon: m}
167+ c := &Core{screens: []*screen{sc}}
168+ to := 1
169+ if cross {
170+ to = 2
171+ }
172+ c.drop(w.Columns[0].Windows[0], dropTarget{kind: dropSwap, screen: sc, ws: w, anchor: w.Columns[to].Windows[0]})
173+ } else {
174+ w.Focus = 0
175+ w.MoveColumn(1)
176+ }
177+ w.dropStaleSpans()
178+ for _, col := range w.Columns {
179+ if cross && col.Span != 0 {
180+ t.Fatal("cross-band swap retained stamp", w.Columns)
181+ }
182+ if !cross && col.Span != 5000 {
183+ t.Fatal("within-band swap discarded stamp", w.Columns)
184+ }
185+ }
186+ })
187+ }
188+}
189+
190+// A column arriving from another workspace carries that workspace's stamp,
191+// which may collide with a stamp issued here.
192+func TestSpansForeignStampCleared(t *testing.T) {
193+ w := spanWorkspace(OverflowScroll, 2)
194+ stampSpans(w, 0, 1)
195+ stamp := w.spanSeq
196+ w.receive(Column{Windows: []WindowID{9}, Span: 3000, spanStamp: stamp}, -1)
197+ w.dropStaleSpans()
198+ for _, col := range w.Columns {
199+ if col.Span != 0 || col.spanStamp != 0 {
200+ t.Fatal("foreign stamp joined the lane", w.Columns)
201+ }
202+ }
203+}
M internal/core/state.go +0 -55
@@ -33,8 +33,6 @@ func (c *Core) state() ports.State { return cloneState(c.stateInto(ports.State{}
3333 func (c *Core) stateInto(st ports.State) ports.State {
3434 st.Output, st.Window = c.cur().name(), nil
3535 st.Outputs, st.Windows = st.Outputs[:0], st.Windows[:0]
36- // Workspaces keep their Columns storage across calls (see workspaceInto).
37- nws := 0
3836 if c.scratch.shown == nil {
3937 c.scratch.shown = map[WindowID]bool{}
4038 }
@@ -62,8 +60,6 @@ func (c *Core) stateInto(st ports.State) ports.State {
6260 }
6361 for w := range m.all() {
6462 n := indexOf(m.Workspaces, w) + 1
65- st.Workspaces = c.workspaceInto(st.Workspaces, nws, w, s.name(), n, w == cur)
66- nws++
6763 c.scratch.ids = w.appendWindows(c.scratch.ids[:0])
6864 for _, id := range c.scratch.ids {
6965 rec := c.windows.lookup(id)
@@ -78,7 +74,6 @@ func (c *Core) stateInto(st ports.State) ports.State {
7874 }
7975 }
8076 }
81- st.Workspaces = st.Workspaces[:nws]
8277 slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) })
8378 for i := range st.Windows {
8479 if st.Windows[i].ID == focus && focus != 0 {
@@ -88,50 +83,8 @@ func (c *Core) stateInto(st ports.State) ports.State {
8883 return st
8984 }
9085
91-// workspaceInto fills ws[i] (appended when ws is shorter) with w's settled
92-// geometry, reusing the storage the element held: no slide, fade or zoom,
93-// floats and the stash left out. A fullscreen tiled window covers the output.
94-func (c *Core) workspaceInto(ws []ports.WorkspaceState, i int, w *Workspace, output string, number int, visible bool) []ports.WorkspaceState {
95- if i < cap(ws) {
96- ws = ws[:i+1]
97- } else {
98- ws = append(ws, ports.WorkspaceState{})
99- }
100- v := &ws[i]
101- cols := v.Columns[:0]
102- *v = ports.WorkspaceState{ID: w.ID, Number: number, Name: w.Name, Output: output, Visible: visible, Overflow: string(w.Overflow)}
103- c.scratch.rects = w.columnRectsAt(c.scratch.rects, false, 0)
104- for ci, col := range w.Columns {
105- if ci < cap(cols) {
106- cols = cols[:ci+1]
107- } else {
108- cols = append(cols, ports.ColumnState{})
109- }
110- cv := &cols[ci]
111- tiles := cv.Windows[:0]
112- r := c.scratch.rects[ci]
113- c.scratch.rows = rowRectsInto(c.scratch.rows[:0], r, col, w.gap())
114- for ri, id := range col.Windows {
115- tr := c.scratch.rows[ri]
116- if w.fullscreen == id {
117- tr = w.Output
118- }
119- tiles = append(tiles, ports.TileState{ID: id, Rect: tr})
120- }
121- *cv = ports.ColumnState{Rect: r, Windows: tiles}
122- }
123- v.Columns = cols
124- return ws
125-}
126-
12786 func sameState(a, b ports.State) bool {
12887 return a.Output == b.Output && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Windows, b.Windows) &&
129- slices.EqualFunc(a.Workspaces, b.Workspaces, func(x, y ports.WorkspaceState) bool {
130- return x.ID == y.ID && x.Number == y.Number && x.Name == y.Name && x.Output == y.Output && x.Visible == y.Visible && x.Overflow == y.Overflow &&
131- slices.EqualFunc(x.Columns, y.Columns, func(p, q ports.ColumnState) bool {
132- return p.Rect == q.Rect && slices.Equal(p.Windows, q.Windows)
133- })
134- }) &&
13588 (a.Window == nil) == (b.Window == nil) && (a.Window == nil || *a.Window == *b.Window)
13689 }
13790
@@ -139,14 +92,6 @@ func sameState(a, b ports.State) bool {
13992 // []), and Window is its own copy.
14093 func cloneState(st ports.State) ports.State {
14194 out := ports.State{Output: st.Output, Outputs: append([]ports.OutputState{}, st.Outputs...), Windows: append([]ports.WindowState{}, st.Windows...)}
142- out.Workspaces = make([]ports.WorkspaceState, len(st.Workspaces))
143- for i, w := range st.Workspaces {
144- out.Workspaces[i] = w
145- out.Workspaces[i].Columns = make([]ports.ColumnState, len(w.Columns))
146- for j, col := range w.Columns {
147- out.Workspaces[i].Columns[j] = ports.ColumnState{Rect: col.Rect, Windows: append([]ports.TileState{}, col.Windows...)}
148- }
149- }
15095 if st.Window != nil {
15196 w := *st.Window
15297 out.Window = &w
@@ -33,8 +33,6 @@ func (c *Core) state() ports.State { return cloneState(c.stateInto(ports.State{}
33 func (c *Core) stateInto(st ports.State) ports.State {33 func (c *Core) stateInto(st ports.State) ports.State {
34 st.Output, st.Window = c.cur().name(), nil34 st.Output, st.Window = c.cur().name(), nil
35 st.Outputs, st.Windows = st.Outputs[:0], st.Windows[:0]35 st.Outputs, st.Windows = st.Outputs[:0], st.Windows[:0]
36- // Workspaces keep their Columns storage across calls (see workspaceInto).
37- nws := 0
38 if c.scratch.shown == nil {36 if c.scratch.shown == nil {
39 c.scratch.shown = map[WindowID]bool{}37 c.scratch.shown = map[WindowID]bool{}
40 }38 }
@@ -62,8 +60,6 @@ func (c *Core) stateInto(st ports.State) ports.State {
62 }60 }
63 for w := range m.all() {61 for w := range m.all() {
64 n := indexOf(m.Workspaces, w) + 162 n := indexOf(m.Workspaces, w) + 1
65- st.Workspaces = c.workspaceInto(st.Workspaces, nws, w, s.name(), n, w == cur)
66- nws++
67 c.scratch.ids = w.appendWindows(c.scratch.ids[:0])63 c.scratch.ids = w.appendWindows(c.scratch.ids[:0])
68 for _, id := range c.scratch.ids {64 for _, id := range c.scratch.ids {
69 rec := c.windows.lookup(id)65 rec := c.windows.lookup(id)
@@ -78,7 +74,6 @@ func (c *Core) stateInto(st ports.State) ports.State {
78 }74 }
79 }75 }
80 }76 }
81- st.Workspaces = st.Workspaces[:nws]
82 slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) })77 slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) })
83 for i := range st.Windows {78 for i := range st.Windows {
84 if st.Windows[i].ID == focus && focus != 0 {79 if st.Windows[i].ID == focus && focus != 0 {
@@ -88,50 +83,8 @@ func (c *Core) stateInto(st ports.State) ports.State {
88 return st83 return st
89 }84 }
90 85
91-// workspaceInto fills ws[i] (appended when ws is shorter) with w's settled
92-// geometry, reusing the storage the element held: no slide, fade or zoom,
93-// floats and the stash left out. A fullscreen tiled window covers the output.
94-func (c *Core) workspaceInto(ws []ports.WorkspaceState, i int, w *Workspace, output string, number int, visible bool) []ports.WorkspaceState {
95- if i < cap(ws) {
96- ws = ws[:i+1]
97- } else {
98- ws = append(ws, ports.WorkspaceState{})
99- }
100- v := &ws[i]
101- cols := v.Columns[:0]
102- *v = ports.WorkspaceState{ID: w.ID, Number: number, Name: w.Name, Output: output, Visible: visible, Overflow: string(w.Overflow)}
103- c.scratch.rects = w.columnRectsAt(c.scratch.rects, false, 0)
104- for ci, col := range w.Columns {
105- if ci < cap(cols) {
106- cols = cols[:ci+1]
107- } else {
108- cols = append(cols, ports.ColumnState{})
109- }
110- cv := &cols[ci]
111- tiles := cv.Windows[:0]
112- r := c.scratch.rects[ci]
113- c.scratch.rows = rowRectsInto(c.scratch.rows[:0], r, col, w.gap())
114- for ri, id := range col.Windows {
115- tr := c.scratch.rows[ri]
116- if w.fullscreen == id {
117- tr = w.Output
118- }
119- tiles = append(tiles, ports.TileState{ID: id, Rect: tr})
120- }
121- *cv = ports.ColumnState{Rect: r, Windows: tiles}
122- }
123- v.Columns = cols
124- return ws
125-}
126-
127 func sameState(a, b ports.State) bool {86 func sameState(a, b ports.State) bool {
128 return a.Output == b.Output && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Windows, b.Windows) &&87 return a.Output == b.Output && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Windows, b.Windows) &&
129- slices.EqualFunc(a.Workspaces, b.Workspaces, func(x, y ports.WorkspaceState) bool {
130- return x.ID == y.ID && x.Number == y.Number && x.Name == y.Name && x.Output == y.Output && x.Visible == y.Visible && x.Overflow == y.Overflow &&
131- slices.EqualFunc(x.Columns, y.Columns, func(p, q ports.ColumnState) bool {
132- return p.Rect == q.Rect && slices.Equal(p.Windows, q.Windows)
133- })
134- }) &&
135 (a.Window == nil) == (b.Window == nil) && (a.Window == nil || *a.Window == *b.Window)88 (a.Window == nil) == (b.Window == nil) && (a.Window == nil || *a.Window == *b.Window)
136 }89 }
137 90
@@ -139,14 +92,6 @@ func sameState(a, b ports.State) bool {
139 // []), and Window is its own copy.92 // []), and Window is its own copy.
140 func cloneState(st ports.State) ports.State {93 func cloneState(st ports.State) ports.State {
141 out := ports.State{Output: st.Output, Outputs: append([]ports.OutputState{}, st.Outputs...), Windows: append([]ports.WindowState{}, st.Windows...)}94 out := ports.State{Output: st.Output, Outputs: append([]ports.OutputState{}, st.Outputs...), Windows: append([]ports.WindowState{}, st.Windows...)}
142- out.Workspaces = make([]ports.WorkspaceState, len(st.Workspaces))
143- for i, w := range st.Workspaces {
144- out.Workspaces[i] = w
145- out.Workspaces[i].Columns = make([]ports.ColumnState, len(w.Columns))
146- for j, col := range w.Columns {
147- out.Workspaces[i].Columns[j] = ports.ColumnState{Rect: col.Rect, Windows: append([]ports.TileState{}, col.Windows...)}
148- }
149- }
150 if st.Window != nil {95 if st.Window != nil {
151 w := *st.Window96 w := *st.Window
152 out.Window = &w97 out.Window = &w
D internal/core/state_geometry_test.go +0 -91
@@ -1,91 +0,0 @@
1-package core
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/neferwl/internal/ports"
7-)
8-
9-// Every workspace lists its tiled columns and windows with settled
10-// rectangles; only the workspace on screen is visible.
11-func TestStateWorkspaceGeometry(t *testing.T) {
12- cfg := ports.Config{}
13- cfg.Keyboard.CmdKey = "super"
14- cfg.Layout.Overflow = "fixed"
15- cfg.Layout.MaxColumns = 2
16- cfg.Layout.Gaps = 10
17- c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)}, Options{})
18- if err != nil {
19- t.Fatal(err)
20- }
21- c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800})
22- m := c.cur().mon
23- shown := m.Current()
24- shown.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}}}
25- m.normalize() // the trailing empty workspace, as in a session
26- other := m.newWorkspace()
27- other.Columns = []Column{{Windows: []WindowID{4}}}
28- m.hidden = append(m.hidden, other)
29-
30- got := c.state().Workspaces
31- if len(got) != 3 {
32- t.Fatalf("%d workspaces: %+v", len(got), got)
33- }
34- ws := got[0]
35- if ws.ID != shown.ID || ws.Number != 1 || ws.Output != "OUT-1" || !ws.Visible || ws.Overflow != "fixed" {
36- t.Fatalf("%+v", ws)
37- }
38- want := []ports.ColumnState{
39- {Rect: Rect{X: 10, Y: 10, W: 485, H: 780}, Windows: []ports.TileState{{ID: 1, Rect: Rect{X: 10, Y: 10, W: 485, H: 780}}}},
40- {Rect: Rect{X: 505, Y: 10, W: 485, H: 780}, Windows: []ports.TileState{{ID: 2, Rect: Rect{X: 505, Y: 10, W: 485, H: 385}}, {ID: 3, Rect: Rect{X: 505, Y: 405, W: 485, H: 385}}}},
41- }
42- if len(ws.Columns) != len(want) {
43- t.Fatalf("%+v", ws.Columns)
44- }
45- for i := range want {
46- if ws.Columns[i].Rect != want[i].Rect || len(ws.Columns[i].Windows) != len(want[i].Windows) {
47- t.Fatalf("column %d: %+v, want %+v", i, ws.Columns[i], want[i])
48- }
49- for j := range want[i].Windows {
50- if ws.Columns[i].Windows[j] != want[i].Windows[j] {
51- t.Errorf("column %d tile %d: %+v, want %+v", i, j, ws.Columns[i].Windows[j], want[i].Windows[j])
52- }
53- }
54- }
55- var found bool
56- for _, w := range got[1:] {
57- if w.ID == other.ID {
58- found = true
59- if w.Visible || w.Number != 0 || len(w.Columns) != 1 || w.Columns[0].Windows[0].ID != 4 {
60- t.Errorf("hidden workspace: %+v", w)
61- }
62- } else if w.Visible {
63- t.Errorf("workspace %d visible", w.ID)
64- }
65- }
66- if !found {
67- t.Error("second workspace missing")
68- }
69-}
70-
71-// A change of geometry is a change of state; an unchanged one is not.
72-func TestStateWorkspaceChange(t *testing.T) {
73- cfg := ports.Config{}
74- cfg.Keyboard.CmdKey = "super"
75- cfg.Layout.MaxColumns = 2
76- c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)}, Options{})
77- if err != nil {
78- t.Fatal(err)
79- }
80- c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800})
81- w := c.cur().mon.Current()
82- w.Columns = []Column{{Windows: []WindowID{1}}}
83- a := c.state()
84- if !sameState(a, c.state()) {
85- t.Fatal("same layout reported as changed")
86- }
87- w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2}}}
88- if sameState(a, c.state()) {
89- t.Fatal("new column not seen")
90- }
91-}
@@ -1,91 +0,0 @@
1-package core
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/neferwl/internal/ports"
7-)
8-
9-// Every workspace lists its tiled columns and windows with settled
10-// rectangles; only the workspace on screen is visible.
11-func TestStateWorkspaceGeometry(t *testing.T) {
12- cfg := ports.Config{}
13- cfg.Keyboard.CmdKey = "super"
14- cfg.Layout.Overflow = "fixed"
15- cfg.Layout.MaxColumns = 2
16- cfg.Layout.Gaps = 10
17- c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)}, Options{})
18- if err != nil {
19- t.Fatal(err)
20- }
21- c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800})
22- m := c.cur().mon
23- shown := m.Current()
24- shown.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}}}
25- m.normalize() // the trailing empty workspace, as in a session
26- other := m.newWorkspace()
27- other.Columns = []Column{{Windows: []WindowID{4}}}
28- m.hidden = append(m.hidden, other)
29-
30- got := c.state().Workspaces
31- if len(got) != 3 {
32- t.Fatalf("%d workspaces: %+v", len(got), got)
33- }
34- ws := got[0]
35- if ws.ID != shown.ID || ws.Number != 1 || ws.Output != "OUT-1" || !ws.Visible || ws.Overflow != "fixed" {
36- t.Fatalf("%+v", ws)
37- }
38- want := []ports.ColumnState{
39- {Rect: Rect{X: 10, Y: 10, W: 485, H: 780}, Windows: []ports.TileState{{ID: 1, Rect: Rect{X: 10, Y: 10, W: 485, H: 780}}}},
40- {Rect: Rect{X: 505, Y: 10, W: 485, H: 780}, Windows: []ports.TileState{{ID: 2, Rect: Rect{X: 505, Y: 10, W: 485, H: 385}}, {ID: 3, Rect: Rect{X: 505, Y: 405, W: 485, H: 385}}}},
41- }
42- if len(ws.Columns) != len(want) {
43- t.Fatalf("%+v", ws.Columns)
44- }
45- for i := range want {
46- if ws.Columns[i].Rect != want[i].Rect || len(ws.Columns[i].Windows) != len(want[i].Windows) {
47- t.Fatalf("column %d: %+v, want %+v", i, ws.Columns[i], want[i])
48- }
49- for j := range want[i].Windows {
50- if ws.Columns[i].Windows[j] != want[i].Windows[j] {
51- t.Errorf("column %d tile %d: %+v, want %+v", i, j, ws.Columns[i].Windows[j], want[i].Windows[j])
52- }
53- }
54- }
55- var found bool
56- for _, w := range got[1:] {
57- if w.ID == other.ID {
58- found = true
59- if w.Visible || w.Number != 0 || len(w.Columns) != 1 || w.Columns[0].Windows[0].ID != 4 {
60- t.Errorf("hidden workspace: %+v", w)
61- }
62- } else if w.Visible {
63- t.Errorf("workspace %d visible", w.ID)
64- }
65- }
66- if !found {
67- t.Error("second workspace missing")
68- }
69-}
70-
71-// A change of geometry is a change of state; an unchanged one is not.
72-func TestStateWorkspaceChange(t *testing.T) {
73- cfg := ports.Config{}
74- cfg.Keyboard.CmdKey = "super"
75- cfg.Layout.MaxColumns = 2
76- c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)}, Options{})
77- if err != nil {
78- t.Fatal(err)
79- }
80- c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800})
81- w := c.cur().mon.Current()
82- w.Columns = []Column{{Windows: []WindowID{1}}}
83- a := c.state()
84- if !sameState(a, c.state()) {
85- t.Fatal("same layout reported as changed")
86- }
87- w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2}}}
88- if sameState(a, c.state()) {
89- t.Fatal("new column not seen")
90- }
91-}
M internal/core/switcher.go +1 -1
@@ -239,7 +239,7 @@ func (c *Core) switchStep(dir int) {
239239 // screen: start over.
240240 c.cancelSwitcher()
241241 }
242- if sc.mon.ov.open || c.drag != nil {
242+ if sc.mon.ov.open || c.pointerBusy() {
243243 return
244244 }
245245 m := sc.mon
@@ -239,7 +239,7 @@ func (c *Core) switchStep(dir int) {
239 // screen: start over.239 // screen: start over.
240 c.cancelSwitcher()240 c.cancelSwitcher()
241 }241 }
242- if sc.mon.ov.open || c.drag != nil {242+ if sc.mon.ov.open || c.pointerBusy() {
243 return243 return
244 }244 }
245 m := sc.mon245 m := sc.mon
M internal/ports/client_commands.go +7 -0
@@ -297,3 +297,10 @@ type SetModifiers struct {
297297 }
298298
299299 func (SetModifiers) clientCommand() {}
300+
301+// CursorOverride carries core -> wayland the cursor a compositor pointer
302+// interaction (border resize, future column move) shows instead of the
303+// client's: a CSS cursor name; "" clears it.
304+type CursorOverride struct{ Shape string }
305+
306+func (CursorOverride) clientCommand() {}
@@ -297,3 +297,10 @@ type SetModifiers struct {
297 }297 }
298 298
299 func (SetModifiers) clientCommand() {}299 func (SetModifiers) clientCommand() {}
300+
301+// CursorOverride carries core -> wayland the cursor a compositor pointer
302+// interaction (border resize, future column move) shows instead of the
303+// client's: a CSS cursor name; "" clears it.
304+type CursorOverride struct{ Shape string }
305+
306+func (CursorOverride) clientCommand() {}
M internal/ports/state.go +0 -26
@@ -9,32 +9,6 @@ type State struct {
99 // Window is the focused window, nil when none.
1010 Window *WindowState
1111 Windows []WindowState
12- // Workspaces is every workspace of every output.
13- Workspaces []WorkspaceState
14-}
15-
16-// WorkspaceState is one workspace and its tiled columns, left to right in
17-// layout order. Rects are logical pixels in the output's coordinates.
18-type WorkspaceState struct {
19- ID uint64
20- Number int // numbered workspace, 0 for a hidden/named one
21- Name string // configured name, "" if dynamic
22- Output string
23- Visible bool // on screen on its output
24- Overflow string // "fixed", "scroll", "cascade"
25- Columns []ColumnState
26-}
27-
28-// ColumnState is one column and its tiled windows.
29-type ColumnState struct {
30- Rect Rect // column area, view offset applied (may be off-screen)
31- Windows []TileState // top to bottom
32-}
33-
34-// TileState is one tiled window and its settled rectangle.
35-type TileState struct {
36- ID WindowID
37- Rect Rect
3812 }
3913
4014 // OutputState is one output: its workspace on screen and how many numbered
@@ -9,32 +9,6 @@ type State struct {
9 // Window is the focused window, nil when none.9 // Window is the focused window, nil when none.
10 Window *WindowState10 Window *WindowState
11 Windows []WindowState11 Windows []WindowState
12- // Workspaces is every workspace of every output.
13- Workspaces []WorkspaceState
14-}
15-
16-// WorkspaceState is one workspace and its tiled columns, left to right in
17-// layout order. Rects are logical pixels in the output's coordinates.
18-type WorkspaceState struct {
19- ID uint64
20- Number int // numbered workspace, 0 for a hidden/named one
21- Name string // configured name, "" if dynamic
22- Output string
23- Visible bool // on screen on its output
24- Overflow string // "fixed", "scroll", "cascade"
25- Columns []ColumnState
26-}
27-
28-// ColumnState is one column and its tiled windows.
29-type ColumnState struct {
30- Rect Rect // column area, view offset applied (may be off-screen)
31- Windows []TileState // top to bottom
32-}
33-
34-// TileState is one tiled window and its settled rectangle.
35-type TileState struct {
36- ID WindowID
37- Rect Rect
38 }12 }
39 13
40 // OutputState is one output: its workspace on screen and how many numbered14 // OutputState is one output: its workspace on screen and how many numbered

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close