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
@@ -332,7 +332,9 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that
332332
333333 - `cmd` + left drag moves a window. Drop it on the top or bottom of another window to stack it there, or in the middle to swap.
334334 - `cmd` + right drag resizes a floating window.
335-- `Escape` cancels the drag.
335+- `cmd` + right drag on the line between two columns or two stacked windows resizes them.
336+- `cmd` + double right-click on that line resets the lane's column widths or the column's row heights. Column widths reset when a column enters or leaves the lane.
337+- `Escape` cancels the drag and restores the previous sizes.
336338
337339 ### Overview
338340
@@ -332,7 +332,9 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that
332 332
333 - `cmd` + left drag moves a window. Drop it on the top or bottom of another window to stack it there, or in the middle to swap.333 - `cmd` + left drag moves a window. Drop it on the top or bottom of another window to stack it there, or in the middle to swap.
334 - `cmd` + right drag resizes a floating window.334 - `cmd` + right drag resizes a floating window.
335-- `Escape` cancels the drag.335+- `cmd` + right drag on the line between two columns or two stacked windows resizes them.
336+- `cmd` + double right-click on that line resets the lane's column widths or the column's row heights. Column widths reset when a column enters or leaves the lane.
337+- `Escape` cancels the drag and restores the previous sizes.
336 338
337 ### Overview339 ### Overview
338 340
M docs/desktop.md +0 -3
@@ -133,11 +133,8 @@ Workspace IDs are `name:<name>` for named workspaces, stable across launches. Nu
133133
134134 NeferWL writes its state as JSON to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json`, also given to started programs as `$NEFERWL_STATE`. It lists outputs, workspaces, the focused window, and every window with its app ID, PID, workspace, column and row.
135135
136-The `workspaces` list holds every workspace with its `visible` flag, its columns and the windows in them, each with `x`, `y`, `width` and `height` in logical pixels of its output. The file is written at most once per second.
137-
138136 ```sh
139137 neferwl state # the whole state
140138 neferwl state output-of "$PID" # the output showing that process's window
141139 neferwl state | jq '[.windows[] | select(.hidden)] | length' # windows in hidden stashes
142-neferwl state | jq '.workspaces[] | select(.visible) | .columns[] | .width' # column widths on screen
143140 ```
@@ -133,11 +133,8 @@ Workspace IDs are `name:<name>` for named workspaces, stable across launches. Nu
133 133
134 NeferWL writes its state as JSON to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json`, also given to started programs as `$NEFERWL_STATE`. It lists outputs, workspaces, the focused window, and every window with its app ID, PID, workspace, column and row.134 NeferWL writes its state as JSON to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json`, also given to started programs as `$NEFERWL_STATE`. It lists outputs, workspaces, the focused window, and every window with its app ID, PID, workspace, column and row.
135 135
136-The `workspaces` list holds every workspace with its `visible` flag, its columns and the windows in them, each with `x`, `y`, `width` and `height` in logical pixels of its output. The file is written at most once per second.
137-
138 ```sh136 ```sh
139 neferwl state # the whole state137 neferwl state # the whole state
140 neferwl state output-of "$PID" # the output showing that process's window138 neferwl state output-of "$PID" # the output showing that process's window
141 neferwl state | jq '[.windows[] | select(.hidden)] | length' # windows in hidden stashes139 neferwl state | jq '[.windows[] | select(.hidden)] | length' # windows in hidden stashes
142-neferwl state | jq '.workspaces[] | select(.visible) | .columns[] | .width' # column widths on screen
143 ```140 ```
M docs/dev/headless.md +1 -0
@@ -41,6 +41,7 @@ The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`
4141 | `type <text>` | Type text |
4242 | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super |
4343 | `hold Super+h 1s` | Press a key like `key`, hold it, then release it |
44+| `press Super`, `release Super` | Hold or release a modifier across mouse actions (`Super`, `Shift`, `Ctrl`, `Alt`) |
4445 | `sleep 1s` | Wait |
4546 | `move <x> <y>` | Move the pointer, in output coordinates |
4647 | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons |
@@ -41,6 +41,7 @@ The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`
41 | `type <text>` | Type text |41 | `type <text>` | Type text |
42 | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super |42 | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super |
43 | `hold Super+h 1s` | Press a key like `key`, hold it, then release it |43 | `hold Super+h 1s` | Press a key like `key`, hold it, then release it |
44+| `press Super`, `release Super` | Hold or release a modifier across mouse actions (`Super`, `Shift`, `Ctrl`, `Alt`) |
44 | `sleep 1s` | Wait |45 | `sleep 1s` | Wait |
45 | `move <x> <y>` | Move the pointer, in output coordinates |46 | `move <x> <y>` | Move the pointer, in output coordinates |
46 | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons |47 | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons |
M internal/adapters/headlessinput/input.go +24 -0
@@ -166,6 +166,30 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap,
166166 return 0
167167 }
168168 now := monotonic()
169+ // Separate modifier edges allow mouse actions while a modifier is held.
170+ if len(fields) > 0 && (fields[0] == "press" || fields[0] == "release") {
171+ var code uint32
172+ if len(fields) == 2 {
173+ switch fields[1] {
174+ case "Super":
175+ code = 125
176+ case "Shift":
177+ code = 42
178+ case "Ctrl":
179+ code = 29
180+ case "Alt":
181+ code = 56
182+ }
183+ }
184+ if code == 0 {
185+ warnInput("line", line, "invalid modifier")
186+ continue
187+ }
188+ if err := emit(code, fields[0] == "press"); err != nil {
189+ return err
190+ }
191+ continue
192+ }
169193 if len(fields) > 0 && fields[0] == "move" {
170194 if len(fields) == 3 {
171195 x, ex := strconv.ParseFloat(fields[1], 64)
@@ -166,6 +166,30 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap,
166 return 0166 return 0
167 }167 }
168 now := monotonic()168 now := monotonic()
169+ // Separate modifier edges allow mouse actions while a modifier is held.
170+ if len(fields) > 0 && (fields[0] == "press" || fields[0] == "release") {
171+ var code uint32
172+ if len(fields) == 2 {
173+ switch fields[1] {
174+ case "Super":
175+ code = 125
176+ case "Shift":
177+ code = 42
178+ case "Ctrl":
179+ code = 29
180+ case "Alt":
181+ code = 56
182+ }
183+ }
184+ if code == 0 {
185+ warnInput("line", line, "invalid modifier")
186+ continue
187+ }
188+ if err := emit(code, fields[0] == "press"); err != nil {
189+ return err
190+ }
191+ continue
192+ }
169 if len(fields) > 0 && fields[0] == "move" {193 if len(fields) > 0 && fields[0] == "move" {
170 if len(fields) == 3 {194 if len(fields) == 3 {
171 x, ex := strconv.ParseFloat(fields[1], 64)195 x, ex := strconv.ParseFloat(fields[1], 64)
M internal/adapters/headlessinput/input_test.go +49 -0
@@ -23,6 +23,14 @@ func TestScript(t *testing.T) {
2323 {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}},
2424 {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}},
2525 {"hold h", nil, nil},
26+ {"press Super", []string{"Super_L"}, []uint32{125}},
27+ {"release Super", []string{"Super_L"}, []uint32{125}},
28+ {"press Shift", []string{"Shift_L"}, []uint32{42}},
29+ {"press Ctrl", []string{"Control_L"}, []uint32{29}},
30+ {"press Alt", []string{"Alt_L"}, []uint32{56}},
31+ {"press", nil, nil},
32+ {"release Super extra", nil, nil},
33+ {"press unknown", nil, nil},
2634 } {
2735 t.Run(tt.line, func(t *testing.T) {
2836 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
@@ -61,6 +69,47 @@ func TestScript(t *testing.T) {
6169 }
6270 }
6371
72+func TestModifierAcrossMouseActions(t *testing.T) {
73+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ script := make(chan string, 8)
78+ for _, line := range []string{"press Super", "move 500 100", "down right", "move 700 100", "up right", "release Super"} {
79+ script <- line
80+ }
81+ close(script)
82+ input := make(chan ports.InputEvent, 16)
83+ if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil {
84+ t.Fatal(err)
85+ }
86+ if len(input) != 6 {
87+ t.Fatal(len(input))
88+ }
89+ press := (<-input).(ports.KeyEvent)
90+ if !press.Pressed || press.Mods&ports.ModSuper == 0 {
91+ t.Fatal(press)
92+ }
93+ if _, ok := (<-input).(ports.PointerMotion); !ok {
94+ t.Fatal("missing motion")
95+ }
96+ down := (<-input).(ports.PointerButton)
97+ if down.Button != 0x111 || !down.Pressed {
98+ t.Fatal(down)
99+ }
100+ if _, ok := (<-input).(ports.PointerMotion); !ok {
101+ t.Fatal("missing drag motion")
102+ }
103+ up := (<-input).(ports.PointerButton)
104+ if up.Button != 0x111 || up.Pressed {
105+ t.Fatal(up)
106+ }
107+ release := (<-input).(ports.KeyEvent)
108+ if release.Pressed || release.Mods&ports.ModSuper != 0 {
109+ t.Fatal(release)
110+ }
111+}
112+
64113 // hold keeps the key and its modifiers down for the given duration, then
65114 // releases the key before the modifier.
66115 func TestHoldKeepsKeyDown(t *testing.T) {
@@ -23,6 +23,14 @@ func TestScript(t *testing.T) {
23 {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}},23 {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}},
24 {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}},24 {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}},
25 {"hold h", nil, nil},25 {"hold h", nil, nil},
26+ {"press Super", []string{"Super_L"}, []uint32{125}},
27+ {"release Super", []string{"Super_L"}, []uint32{125}},
28+ {"press Shift", []string{"Shift_L"}, []uint32{42}},
29+ {"press Ctrl", []string{"Control_L"}, []uint32{29}},
30+ {"press Alt", []string{"Alt_L"}, []uint32{56}},
31+ {"press", nil, nil},
32+ {"release Super extra", nil, nil},
33+ {"press unknown", nil, nil},
26 } {34 } {
27 t.Run(tt.line, func(t *testing.T) {35 t.Run(tt.line, func(t *testing.T) {
28 km, err := xkb.New(xkb.RMLVO{Layout: "us"})36 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
@@ -61,6 +69,47 @@ func TestScript(t *testing.T) {
61 }69 }
62 }70 }
63 71
72+func TestModifierAcrossMouseActions(t *testing.T) {
73+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ script := make(chan string, 8)
78+ for _, line := range []string{"press Super", "move 500 100", "down right", "move 700 100", "up right", "release Super"} {
79+ script <- line
80+ }
81+ close(script)
82+ input := make(chan ports.InputEvent, 16)
83+ if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil {
84+ t.Fatal(err)
85+ }
86+ if len(input) != 6 {
87+ t.Fatal(len(input))
88+ }
89+ press := (<-input).(ports.KeyEvent)
90+ if !press.Pressed || press.Mods&ports.ModSuper == 0 {
91+ t.Fatal(press)
92+ }
93+ if _, ok := (<-input).(ports.PointerMotion); !ok {
94+ t.Fatal("missing motion")
95+ }
96+ down := (<-input).(ports.PointerButton)
97+ if down.Button != 0x111 || !down.Pressed {
98+ t.Fatal(down)
99+ }
100+ if _, ok := (<-input).(ports.PointerMotion); !ok {
101+ t.Fatal("missing drag motion")
102+ }
103+ up := (<-input).(ports.PointerButton)
104+ if up.Button != 0x111 || up.Pressed {
105+ t.Fatal(up)
106+ }
107+ release := (<-input).(ports.KeyEvent)
108+ if release.Pressed || release.Mods&ports.ModSuper != 0 {
109+ t.Fatal(release)
110+ }
111+}
112+
64 // hold keeps the key and its modifiers down for the given duration, then113 // hold keeps the key and its modifiers down for the given duration, then
65 // releases the key before the modifier.114 // releases the key before the modifier.
66 func TestHoldKeepsKeyDown(t *testing.T) {115 func TestHoldKeepsKeyDown(t *testing.T) {
M internal/adapters/statefile/statefile.go +6 -86
@@ -12,7 +12,6 @@ import (
1212 "path/filepath"
1313 "strconv"
1414 "strings"
15- "time"
1615
1716 "github.com/bnema/neferwl/internal/adapters/workspaceid"
1817 "github.com/bnema/neferwl/internal/ports"
@@ -38,40 +37,6 @@ type State struct {
3837 Outputs []Output `json:"outputs"`
3938 Window *Window `json:"window"`
4039 Windows []Window `json:"windows"`
41- // Workspaces lists every workspace of every output.
42- Workspaces []Workspace `json:"workspaces"`
43-}
44-
45-// Workspace is one workspace and its tiled columns, left to right. ID is
46-// the workspace_id string of Output. Number is 0 for a hidden workspace.
47-// Visible means on screen on its output.
48-type Workspace struct {
49- ID string `json:"id"`
50- Number int `json:"number"`
51- Name string `json:"name"`
52- Output string `json:"output"`
53- Visible bool `json:"visible"`
54- Overflow string `json:"overflow"`
55- Columns []Column `json:"columns"`
56-}
57-
58-// Column is the area of one column in logical pixels of its output, view
59-// offset applied (it may be off-screen), and its windows top to bottom.
60-type Column struct {
61- X int `json:"x"`
62- Y int `json:"y"`
63- Width int `json:"width"`
64- Height int `json:"height"`
65- Windows []Tile `json:"windows"`
66-}
67-
68-// Tile is one tiled window and its rectangle.
69-type Tile struct {
70- ID uint64 `json:"id"`
71- X int `json:"x"`
72- Y int `json:"y"`
73- Width int `json:"width"`
74- Height int `json:"height"`
7540 }
7641
7742 // Output is one output: its active numbered workspace (0 while a hidden one
@@ -113,24 +78,13 @@ type Window struct {
11378 }
11479
11580 func fromPorts(st ports.State, ids *workspaceid.IDs) State {
116- out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows)), Workspaces: make([]Workspace, 0, len(st.Workspaces))}
81+ out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows))}
11782 for _, o := range st.Outputs {
11883 out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace, WorkspaceID: ids.ID(o.WorkspaceID, o.Workspace)})
11984 }
12085 for _, w := range st.Windows {
12186 out.Windows = append(out.Windows, window(w, ids))
12287 }
123- for _, w := range st.Workspaces {
124- ws := Workspace{ID: ids.ID(w.ID, w.Name), Number: w.Number, Name: w.Name, Output: w.Output, Visible: w.Visible, Overflow: w.Overflow, Columns: make([]Column, 0, len(w.Columns))}
125- for _, c := range w.Columns {
126- col := Column{X: c.Rect.X, Y: c.Rect.Y, Width: c.Rect.W, Height: c.Rect.H, Windows: make([]Tile, 0, len(c.Windows))}
127- for _, t := range c.Windows {
128- col.Windows = append(col.Windows, Tile{ID: uint64(t.ID), X: t.Rect.X, Y: t.Rect.Y, Width: t.Rect.W, Height: t.Rect.H})
129- }
130- ws.Columns = append(ws.Columns, col)
131- }
132- out.Workspaces = append(out.Workspaces, ws)
133- }
13488 if st.Window != nil {
13589 w := window(*st.Window, ids)
13690 out.Window = &w
@@ -142,13 +96,8 @@ func window(w ports.WindowState, ids *workspaceid.IDs) Window {
14296 return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}
14397 }
14498
145-// writeInterval is the least time between two writes of the file.
146-const writeInterval = time.Second
147-
148-// Run writes the states it receives to path until ctx ends, then removes
149-// the file: a stale state must not outlive the session. It writes at most
150-// once per writeInterval: a state after a quiet interval is written at once,
151-// others are coalesced and the latest is written when the interval ends.
99+// Run writes every state it receives to path until ctx ends, then removes
100+// the file: a stale state must not outlive the session.
152101 func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error {
153102 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
154103 return err
@@ -156,44 +105,15 @@ func Run(ctx context.Context, path string, states <-chan ports.State, ids *works
156105 // A file left by a crashed session on this socket is not ours.
157106 _ = os.Remove(path)
158107 defer os.Remove(path)
159- flush := func(st ports.State) {
160- if err := write(path, fromPorts(st, ids)); err != nil {
161- // Scripts lose their state; the session goes on.
162- log.Warn().Err(err).Str("path", path).Msg("write state")
163- }
164- }
165- timer := time.NewTimer(0)
166- defer timer.Stop()
167- <-timer.C
168- var (
169- last time.Time
170- pending ports.State
171- waiting bool
172- )
173108 for {
174- var due <-chan time.Time
175- if waiting {
176- due = timer.C
177- }
178109 select {
179110 case <-ctx.Done():
180111 return nil
181112 case st := <-states:
182- if waiting {
183- pending = st
184- continue
185- }
186- if wait := writeInterval - time.Since(last); !last.IsZero() && wait > 0 {
187- pending, waiting = st, true
188- timer.Reset(wait)
189- continue
113+ if err := write(path, fromPorts(st, ids)); err != nil {
114+ // Scripts lose their state; the session goes on.
115+ log.Warn().Err(err).Str("path", path).Msg("write state")
190116 }
191- flush(st)
192- last = time.Now()
193- case <-due:
194- waiting = false
195- flush(pending)
196- last = time.Now()
197117 }
198118 }
199119 }
@@ -12,7 +12,6 @@ import (
12 "path/filepath"12 "path/filepath"
13 "strconv"13 "strconv"
14 "strings"14 "strings"
15- "time"
16 15
17 "github.com/bnema/neferwl/internal/adapters/workspaceid"16 "github.com/bnema/neferwl/internal/adapters/workspaceid"
18 "github.com/bnema/neferwl/internal/ports"17 "github.com/bnema/neferwl/internal/ports"
@@ -38,40 +37,6 @@ type State struct {
38 Outputs []Output `json:"outputs"`37 Outputs []Output `json:"outputs"`
39 Window *Window `json:"window"`38 Window *Window `json:"window"`
40 Windows []Window `json:"windows"`39 Windows []Window `json:"windows"`
41- // Workspaces lists every workspace of every output.
42- Workspaces []Workspace `json:"workspaces"`
43-}
44-
45-// Workspace is one workspace and its tiled columns, left to right. ID is
46-// the workspace_id string of Output. Number is 0 for a hidden workspace.
47-// Visible means on screen on its output.
48-type Workspace struct {
49- ID string `json:"id"`
50- Number int `json:"number"`
51- Name string `json:"name"`
52- Output string `json:"output"`
53- Visible bool `json:"visible"`
54- Overflow string `json:"overflow"`
55- Columns []Column `json:"columns"`
56-}
57-
58-// Column is the area of one column in logical pixels of its output, view
59-// offset applied (it may be off-screen), and its windows top to bottom.
60-type Column struct {
61- X int `json:"x"`
62- Y int `json:"y"`
63- Width int `json:"width"`
64- Height int `json:"height"`
65- Windows []Tile `json:"windows"`
66-}
67-
68-// Tile is one tiled window and its rectangle.
69-type Tile struct {
70- ID uint64 `json:"id"`
71- X int `json:"x"`
72- Y int `json:"y"`
73- Width int `json:"width"`
74- Height int `json:"height"`
75 }40 }
76 41
77 // Output is one output: its active numbered workspace (0 while a hidden one42 // Output is one output: its active numbered workspace (0 while a hidden one
@@ -113,24 +78,13 @@ type Window struct {
113 }78 }
114 79
115 func fromPorts(st ports.State, ids *workspaceid.IDs) State {80 func fromPorts(st ports.State, ids *workspaceid.IDs) State {
116- out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows)), Workspaces: make([]Workspace, 0, len(st.Workspaces))}81+ out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows))}
117 for _, o := range st.Outputs {82 for _, o := range st.Outputs {
118 out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace, WorkspaceID: ids.ID(o.WorkspaceID, o.Workspace)})83 out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace, WorkspaceID: ids.ID(o.WorkspaceID, o.Workspace)})
119 }84 }
120 for _, w := range st.Windows {85 for _, w := range st.Windows {
121 out.Windows = append(out.Windows, window(w, ids))86 out.Windows = append(out.Windows, window(w, ids))
122 }87 }
123- for _, w := range st.Workspaces {
124- ws := Workspace{ID: ids.ID(w.ID, w.Name), Number: w.Number, Name: w.Name, Output: w.Output, Visible: w.Visible, Overflow: w.Overflow, Columns: make([]Column, 0, len(w.Columns))}
125- for _, c := range w.Columns {
126- col := Column{X: c.Rect.X, Y: c.Rect.Y, Width: c.Rect.W, Height: c.Rect.H, Windows: make([]Tile, 0, len(c.Windows))}
127- for _, t := range c.Windows {
128- col.Windows = append(col.Windows, Tile{ID: uint64(t.ID), X: t.Rect.X, Y: t.Rect.Y, Width: t.Rect.W, Height: t.Rect.H})
129- }
130- ws.Columns = append(ws.Columns, col)
131- }
132- out.Workspaces = append(out.Workspaces, ws)
133- }
134 if st.Window != nil {88 if st.Window != nil {
135 w := window(*st.Window, ids)89 w := window(*st.Window, ids)
136 out.Window = &w90 out.Window = &w
@@ -142,13 +96,8 @@ func window(w ports.WindowState, ids *workspaceid.IDs) Window {
142 return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}96 return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}
143 }97 }
144 98
145-// writeInterval is the least time between two writes of the file.99+// Run writes every state it receives to path until ctx ends, then removes
146-const writeInterval = time.Second100+// the file: a stale state must not outlive the session.
147-
148-// Run writes the states it receives to path until ctx ends, then removes
149-// the file: a stale state must not outlive the session. It writes at most
150-// once per writeInterval: a state after a quiet interval is written at once,
151-// others are coalesced and the latest is written when the interval ends.
152 func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error {101 func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error {
153 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {102 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
154 return err103 return err
@@ -156,44 +105,15 @@ func Run(ctx context.Context, path string, states <-chan ports.State, ids *works
156 // A file left by a crashed session on this socket is not ours.105 // A file left by a crashed session on this socket is not ours.
157 _ = os.Remove(path)106 _ = os.Remove(path)
158 defer os.Remove(path)107 defer os.Remove(path)
159- flush := func(st ports.State) {
160- if err := write(path, fromPorts(st, ids)); err != nil {
161- // Scripts lose their state; the session goes on.
162- log.Warn().Err(err).Str("path", path).Msg("write state")
163- }
164- }
165- timer := time.NewTimer(0)
166- defer timer.Stop()
167- <-timer.C
168- var (
169- last time.Time
170- pending ports.State
171- waiting bool
172- )
173 for {108 for {
174- var due <-chan time.Time
175- if waiting {
176- due = timer.C
177- }
178 select {109 select {
179 case <-ctx.Done():110 case <-ctx.Done():
180 return nil111 return nil
181 case st := <-states:112 case st := <-states:
182- if waiting {113+ if err := write(path, fromPorts(st, ids)); err != nil {
183- pending = st114+ // Scripts lose their state; the session goes on.
184- continue115+ log.Warn().Err(err).Str("path", path).Msg("write state")
185- }
186- if wait := writeInterval - time.Since(last); !last.IsZero() && wait > 0 {
187- pending, waiting = st, true
188- timer.Reset(wait)
189- continue
190 }116 }
191- flush(st)
192- last = time.Now()
193- case <-due:
194- waiting = false
195- flush(pending)
196- last = time.Now()
197 }117 }
198 }118 }
199 }119 }
M internal/adapters/statefile/statefile_test.go +1 -95
@@ -9,7 +9,6 @@ import (
99 "reflect"
1010 "strings"
1111 "testing"
12- "testing/synctest"
1312 "time"
1413
1514 "github.com/bnema/neferwl/internal/adapters/workspaceid"
@@ -42,7 +41,7 @@ func TestRunWritesAndRemoves(t *testing.T) {
4241 go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
4342 focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 9, Visible: true}
4443 states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 9}}, Window: &focused, Windows: []ports.WindowState{focused}}
45- want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: "0a1b2c3d-9"}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}}, Workspaces: []Workspace{}}
44+ want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: "0a1b2c3d-9"}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}}}
4645 deadline := time.Now().Add(2 * time.Second)
4746 for {
4847 if got, err := Read(path); err == nil && got.Output != "stale" {
@@ -159,96 +158,3 @@ func TestWorkspaceIDStrings(t *testing.T) {
159158 t.Fatalf("%s %v", data, err)
160159 }
161160 }
162-
163-// Workspaces reach the file with their script-facing names.
164-func TestWorkspaceGeometryFields(t *testing.T) {
165- ids := workspaceid.WithPrefix("0a1b2c3d")
166- st := fromPorts(ports.State{Workspaces: []ports.WorkspaceState{
167- {ID: 7, Number: 2, Name: "web", Output: "DP-2", Visible: true, Overflow: "fixed", Columns: []ports.ColumnState{
168- {Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 400}, Windows: []ports.TileState{{ID: 5, Rect: ports.Rect{X: 11, Y: 21, W: 298, H: 198}}}},
169- }},
170- {ID: 8, Output: "DP-2"},
171- }}, ids)
172- want := []Workspace{
173- {ID: "name:web", Number: 2, Name: "web", Output: "DP-2", Visible: true, Overflow: "fixed", Columns: []Column{
174- {X: 10, Y: 20, Width: 300, Height: 400, Windows: []Tile{{ID: 5, X: 11, Y: 21, Width: 298, Height: 198}}},
175- }},
176- {ID: "0a1b2c3d-8", Output: "DP-2", Columns: []Column{}},
177- }
178- if !reflect.DeepEqual(st.Workspaces, want) {
179- t.Fatalf("%+v", st.Workspaces)
180- }
181- data, err := json.Marshal(st)
182- if err != nil {
183- t.Fatal(err)
184- }
185- for _, key := range []string{`"workspaces":[`, `"id":"name:web"`, `"number":2`, `"visible":true`, `"overflow":"fixed"`, `"columns":[{"x":10,"y":20,"width":300,"height":400,"windows":[{"id":5,"x":11,"y":21,"width":298,"height":198}]}]`} {
186- if !strings.Contains(string(data), key) {
187- t.Fatalf("%s missing %s", data, key)
188- }
189- }
190-}
191-
192-// The file is written at most once per second: the first state and one after
193-// a quiet second at once, a burst within the second as one trailing write of
194-// the latest state.
195-func TestRunRateLimit(t *testing.T) {
196- synctest.Test(t, func(t *testing.T) {
197- path := filepath.Join(t.TempDir(), "s", "wayland-9.json")
198- states := make(chan ports.State)
199- ctx, cancel := context.WithCancel(context.Background())
200- done := make(chan error, 1)
201- go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
202- output := func() string {
203- t.Helper()
204- synctest.Wait()
205- st, err := Read(path)
206- if err != nil {
207- return ""
208- }
209- return st.Output
210- }
211- send := func(name string) { states <- ports.State{Output: name} }
212-
213- send("a")
214- if got := output(); got != "a" {
215- t.Fatalf("first state not written at once: %q", got)
216- }
217- // A burst within the second: nothing yet, then the latest only.
218- time.Sleep(200 * time.Millisecond)
219- send("b")
220- send("c")
221- send("d")
222- if got := output(); got != "a" {
223- t.Fatalf("written within the second: %q", got)
224- }
225- time.Sleep(799 * time.Millisecond)
226- if got := output(); got != "a" {
227- t.Fatalf("written before the second ended: %q", got)
228- }
229- time.Sleep(time.Millisecond)
230- if got := output(); got != "d" {
231- t.Fatalf("trailing write: %q, want d", got)
232- }
233- // Nothing more is pending: the file stays as it is.
234- if err := os.Remove(path); err != nil {
235- t.Fatal(err)
236- }
237- time.Sleep(5 * time.Second)
238- if got := output(); got != "" {
239- t.Fatalf("a write with no new state: %q", got)
240- }
241- // After a quiet period the next state is immediate.
242- send("e")
243- if got := output(); got != "e" {
244- t.Fatalf("state after a quiet period: %q", got)
245- }
246- cancel()
247- if err := <-done; err != nil {
248- t.Fatal(err)
249- }
250- if _, err := os.Stat(path); !os.IsNotExist(err) {
251- t.Fatal("state file left behind", err)
252- }
253- })
254-}
@@ -9,7 +9,6 @@ import (
9 "reflect"9 "reflect"
10 "strings"10 "strings"
11 "testing"11 "testing"
12- "testing/synctest"
13 "time"12 "time"
14 13
15 "github.com/bnema/neferwl/internal/adapters/workspaceid"14 "github.com/bnema/neferwl/internal/adapters/workspaceid"
@@ -42,7 +41,7 @@ func TestRunWritesAndRemoves(t *testing.T) {
42 go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()41 go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
43 focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 9, Visible: true}42 focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 9, Visible: true}
44 states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 9}}, Window: &focused, Windows: []ports.WindowState{focused}}43 states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 9}}, Window: &focused, Windows: []ports.WindowState{focused}}
45- want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: "0a1b2c3d-9"}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}}, Workspaces: []Workspace{}}44+ want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: "0a1b2c3d-9"}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}}}
46 deadline := time.Now().Add(2 * time.Second)45 deadline := time.Now().Add(2 * time.Second)
47 for {46 for {
48 if got, err := Read(path); err == nil && got.Output != "stale" {47 if got, err := Read(path); err == nil && got.Output != "stale" {
@@ -159,96 +158,3 @@ func TestWorkspaceIDStrings(t *testing.T) {
159 t.Fatalf("%s %v", data, err)158 t.Fatalf("%s %v", data, err)
160 }159 }
161 }160 }
162-
163-// Workspaces reach the file with their script-facing names.
164-func TestWorkspaceGeometryFields(t *testing.T) {
165- ids := workspaceid.WithPrefix("0a1b2c3d")
166- st := fromPorts(ports.State{Workspaces: []ports.WorkspaceState{
167- {ID: 7, Number: 2, Name: "web", Output: "DP-2", Visible: true, Overflow: "fixed", Columns: []ports.ColumnState{
168- {Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 400}, Windows: []ports.TileState{{ID: 5, Rect: ports.Rect{X: 11, Y: 21, W: 298, H: 198}}}},
169- }},
170- {ID: 8, Output: "DP-2"},
171- }}, ids)
172- want := []Workspace{
173- {ID: "name:web", Number: 2, Name: "web", Output: "DP-2", Visible: true, Overflow: "fixed", Columns: []Column{
174- {X: 10, Y: 20, Width: 300, Height: 400, Windows: []Tile{{ID: 5, X: 11, Y: 21, Width: 298, Height: 198}}},
175- }},
176- {ID: "0a1b2c3d-8", Output: "DP-2", Columns: []Column{}},
177- }
178- if !reflect.DeepEqual(st.Workspaces, want) {
179- t.Fatalf("%+v", st.Workspaces)
180- }
181- data, err := json.Marshal(st)
182- if err != nil {
183- t.Fatal(err)
184- }
185- for _, key := range []string{`"workspaces":[`, `"id":"name:web"`, `"number":2`, `"visible":true`, `"overflow":"fixed"`, `"columns":[{"x":10,"y":20,"width":300,"height":400,"windows":[{"id":5,"x":11,"y":21,"width":298,"height":198}]}]`} {
186- if !strings.Contains(string(data), key) {
187- t.Fatalf("%s missing %s", data, key)
188- }
189- }
190-}
191-
192-// The file is written at most once per second: the first state and one after
193-// a quiet second at once, a burst within the second as one trailing write of
194-// the latest state.
195-func TestRunRateLimit(t *testing.T) {
196- synctest.Test(t, func(t *testing.T) {
197- path := filepath.Join(t.TempDir(), "s", "wayland-9.json")
198- states := make(chan ports.State)
199- ctx, cancel := context.WithCancel(context.Background())
200- done := make(chan error, 1)
201- go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
202- output := func() string {
203- t.Helper()
204- synctest.Wait()
205- st, err := Read(path)
206- if err != nil {
207- return ""
208- }
209- return st.Output
210- }
211- send := func(name string) { states <- ports.State{Output: name} }
212-
213- send("a")
214- if got := output(); got != "a" {
215- t.Fatalf("first state not written at once: %q", got)
216- }
217- // A burst within the second: nothing yet, then the latest only.
218- time.Sleep(200 * time.Millisecond)
219- send("b")
220- send("c")
221- send("d")
222- if got := output(); got != "a" {
223- t.Fatalf("written within the second: %q", got)
224- }
225- time.Sleep(799 * time.Millisecond)
226- if got := output(); got != "a" {
227- t.Fatalf("written before the second ended: %q", got)
228- }
229- time.Sleep(time.Millisecond)
230- if got := output(); got != "d" {
231- t.Fatalf("trailing write: %q, want d", got)
232- }
233- // Nothing more is pending: the file stays as it is.
234- if err := os.Remove(path); err != nil {
235- t.Fatal(err)
236- }
237- time.Sleep(5 * time.Second)
238- if got := output(); got != "" {
239- t.Fatalf("a write with no new state: %q", got)
240- }
241- // After a quiet period the next state is immediate.
242- send("e")
243- if got := output(); got != "e" {
244- t.Fatalf("state after a quiet period: %q", got)
245- }
246- cancel()
247- if err := <-done; err != nil {
248- t.Fatal(err)
249- }
250- if _, err := os.Stat(path); !os.IsNotExist(err) {
251- t.Fatal("state file left behind", err)
252- }
253- })
254-}
M internal/adapters/wayland/cursor.go +26 -2
@@ -138,9 +138,33 @@ func (s *surface) cursorImage() ports.CursorChange {
138138 return ports.CursorChange{Image: &img, Scale: scale}
139139 }
140140
141-// setCursor queues a cursor change; only the latest is delivered.
141+// setCursor remembers the client cursor, forwarding it unless overridden.
142142 func (s *Server) setCursor(c ports.CursorChange) {
143- if s.protected() || s.channels.Cursors == nil {
143+ if s.protected() {
144+ return
145+ }
146+ s.cursorClient = c
147+ if s.cursorOverride == "" {
148+ s.queueCursor(c)
149+ }
150+}
151+
152+func (s *Server) setCursorOverride(shape string) {
153+ if s.protected() || shape == s.cursorOverride {
154+ return
155+ }
156+ s.cursorOverride = shape
157+ s.log.Debug().Str("shape", shape).Msg("cursor override")
158+ if shape == "" {
159+ s.queueCursor(s.cursorClient)
160+ } else {
161+ s.queueCursor(ports.CursorChange{Shape: shape})
162+ }
163+}
164+
165+// queueCursor queues a cursor change; only the latest is delivered.
166+func (s *Server) queueCursor(c ports.CursorChange) {
167+ if s.channels.Cursors == nil {
144168 return
145169 }
146170 s.cursorMu.Lock()
@@ -138,9 +138,33 @@ func (s *surface) cursorImage() ports.CursorChange {
138 return ports.CursorChange{Image: &img, Scale: scale}138 return ports.CursorChange{Image: &img, Scale: scale}
139 }139 }
140 140
141-// setCursor queues a cursor change; only the latest is delivered.141+// setCursor remembers the client cursor, forwarding it unless overridden.
142 func (s *Server) setCursor(c ports.CursorChange) {142 func (s *Server) setCursor(c ports.CursorChange) {
143- if s.protected() || s.channels.Cursors == nil {143+ if s.protected() {
144+ return
145+ }
146+ s.cursorClient = c
147+ if s.cursorOverride == "" {
148+ s.queueCursor(c)
149+ }
150+}
151+
152+func (s *Server) setCursorOverride(shape string) {
153+ if s.protected() || shape == s.cursorOverride {
154+ return
155+ }
156+ s.cursorOverride = shape
157+ s.log.Debug().Str("shape", shape).Msg("cursor override")
158+ if shape == "" {
159+ s.queueCursor(s.cursorClient)
160+ } else {
161+ s.queueCursor(ports.CursorChange{Shape: shape})
162+ }
163+}
164+
165+// queueCursor queues a cursor change; only the latest is delivered.
166+func (s *Server) queueCursor(c ports.CursorChange) {
167+ if s.channels.Cursors == nil {
144 return168 return
145 }169 }
146 s.cursorMu.Lock()170 s.cursorMu.Lock()
M internal/adapters/wayland/cursor_test.go +48 -0
@@ -83,6 +83,23 @@ func TestCursorShapeFollowsPointerFocus(t *testing.T) {
8383 if got := nextCursor(t, cursors); got.Shape != "pointer" {
8484 t.Fatalf("shape: %+v", got)
8585 }
86+ commands <- ports.CursorOverride{Shape: "ew-resize"}
87+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "ew-resize"}) {
88+ t.Fatalf("override: %+v", got)
89+ }
90+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(3), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText))
91+ if err := c.Roundtrip(); err != nil {
92+ t.Fatal(err)
93+ }
94+ select {
95+ case got := <-cursors:
96+ t.Fatalf("client cursor forwarded during override: %+v", got)
97+ case <-time.After(50 * time.Millisecond):
98+ }
99+ commands <- ports.CursorOverride{}
100+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "text"}) {
101+ t.Fatalf("restored client cursor: %+v", got)
102+ }
86103 commands <- ports.PointerFocus{ID: 0}
87104 if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {
88105 t.Fatalf("leave: %+v, want the arrow", got)
@@ -205,3 +222,34 @@ func TestSetCursorRoleError(t *testing.T) {
205222 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0))
206223 expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole))
207224 }
225+
226+func TestCursorOverrideProtection(t *testing.T) {
227+ s, _, commands, cursors, _ := cursorServer(t)
228+ state := installSecurity(t, s)
229+ commands <- ports.CursorOverride{Shape: "ns-resize"}
230+ if got := nextCursor(t, cursors); got.Shape != "ns-resize" {
231+ t.Fatalf("override: %+v", got)
232+ }
233+ protect(t, s, state, 1, true)
234+ if !s.display.Do(func() {
235+ s.resetProtectionInput()
236+ s.apply(ports.SecurityCommand{State: *state.Load(), Command: ports.CursorOverride{Shape: "ew-resize"}})
237+ if s.cursorOverride != "" {
238+ t.Errorf("protected override: %q", s.cursorOverride)
239+ }
240+ }) {
241+ t.Fatal("display stopped")
242+ }
243+ select {
244+ case got := <-cursors:
245+ t.Fatalf("protected cursor forwarded: %+v", got)
246+ case <-time.After(50 * time.Millisecond):
247+ }
248+ protect(t, s, state, 2, false)
249+ if !s.display.Do(func() { s.setCursor(ports.CursorChange{Hidden: true}) }) {
250+ t.Fatal("display stopped")
251+ }
252+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Hidden: true}) {
253+ t.Fatalf("override survived protection: %+v", got)
254+ }
255+}
@@ -83,6 +83,23 @@ func TestCursorShapeFollowsPointerFocus(t *testing.T) {
83 if got := nextCursor(t, cursors); got.Shape != "pointer" {83 if got := nextCursor(t, cursors); got.Shape != "pointer" {
84 t.Fatalf("shape: %+v", got)84 t.Fatalf("shape: %+v", got)
85 }85 }
86+ commands <- ports.CursorOverride{Shape: "ew-resize"}
87+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "ew-resize"}) {
88+ t.Fatalf("override: %+v", got)
89+ }
90+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(3), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText))
91+ if err := c.Roundtrip(); err != nil {
92+ t.Fatal(err)
93+ }
94+ select {
95+ case got := <-cursors:
96+ t.Fatalf("client cursor forwarded during override: %+v", got)
97+ case <-time.After(50 * time.Millisecond):
98+ }
99+ commands <- ports.CursorOverride{}
100+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "text"}) {
101+ t.Fatalf("restored client cursor: %+v", got)
102+ }
86 commands <- ports.PointerFocus{ID: 0}103 commands <- ports.PointerFocus{ID: 0}
87 if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {104 if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {
88 t.Fatalf("leave: %+v, want the arrow", got)105 t.Fatalf("leave: %+v, want the arrow", got)
@@ -205,3 +222,34 @@ func TestSetCursorRoleError(t *testing.T) {
205 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0))222 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0))
206 expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole))223 expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole))
207 }224 }
225+
226+func TestCursorOverrideProtection(t *testing.T) {
227+ s, _, commands, cursors, _ := cursorServer(t)
228+ state := installSecurity(t, s)
229+ commands <- ports.CursorOverride{Shape: "ns-resize"}
230+ if got := nextCursor(t, cursors); got.Shape != "ns-resize" {
231+ t.Fatalf("override: %+v", got)
232+ }
233+ protect(t, s, state, 1, true)
234+ if !s.display.Do(func() {
235+ s.resetProtectionInput()
236+ s.apply(ports.SecurityCommand{State: *state.Load(), Command: ports.CursorOverride{Shape: "ew-resize"}})
237+ if s.cursorOverride != "" {
238+ t.Errorf("protected override: %q", s.cursorOverride)
239+ }
240+ }) {
241+ t.Fatal("display stopped")
242+ }
243+ select {
244+ case got := <-cursors:
245+ t.Fatalf("protected cursor forwarded: %+v", got)
246+ case <-time.After(50 * time.Millisecond):
247+ }
248+ protect(t, s, state, 2, false)
249+ if !s.display.Do(func() { s.setCursor(ports.CursorChange{Hidden: true}) }) {
250+ t.Fatal("display stopped")
251+ }
252+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Hidden: true}) {
253+ t.Fatalf("override survived protection: %+v", got)
254+ }
255+}
M internal/adapters/wayland/server.go +6 -1
@@ -288,6 +288,9 @@ type Server struct {
288288 fractions map[*surface]*fractionalscale.WpFractionalScaleV1
289289 // cursorSurface is the wl_pointer.set_cursor surface in use.
290290 cursorSurface *surface
291+ // cursorClient and cursorOverride belong to the display goroutine.
292+ cursorClient ports.CursorChange
293+ cursorOverride string
291294 // cursorMu guards only the latest cursor change for forwardCursors.
292295 cursorMu sync.Mutex
293296 cursorLatest ports.CursorChange
@@ -751,12 +754,14 @@ func (s *Server) apply(cmd ports.ClientCommand) {
751754 // Only input/focus commands require an owner envelope. Config relay
752755 // also produces SetKeymap and other epoch-independent commands.
753756 switch cmd.(type) {
754- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers,
757+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers, ports.CursorOverride,
755758 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
756759 return
757760 }
758761 }
759762 switch c := cmd.(type) {
763+ case ports.CursorOverride:
764+ s.setCursorOverride(c.Shape)
760765 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:
761766 s.applyInput(c)
762767 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
@@ -288,6 +288,9 @@ type Server struct {
288 fractions map[*surface]*fractionalscale.WpFractionalScaleV1288 fractions map[*surface]*fractionalscale.WpFractionalScaleV1
289 // cursorSurface is the wl_pointer.set_cursor surface in use.289 // cursorSurface is the wl_pointer.set_cursor surface in use.
290 cursorSurface *surface290 cursorSurface *surface
291+ // cursorClient and cursorOverride belong to the display goroutine.
292+ cursorClient ports.CursorChange
293+ cursorOverride string
291 // cursorMu guards only the latest cursor change for forwardCursors.294 // cursorMu guards only the latest cursor change for forwardCursors.
292 cursorMu sync.Mutex295 cursorMu sync.Mutex
293 cursorLatest ports.CursorChange296 cursorLatest ports.CursorChange
@@ -751,12 +754,14 @@ func (s *Server) apply(cmd ports.ClientCommand) {
751 // Only input/focus commands require an owner envelope. Config relay754 // Only input/focus commands require an owner envelope. Config relay
752 // also produces SetKeymap and other epoch-independent commands.755 // also produces SetKeymap and other epoch-independent commands.
753 switch cmd.(type) {756 switch cmd.(type) {
754- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers,757+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers, ports.CursorOverride,
755 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:758 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
756 return759 return
757 }760 }
758 }761 }
759 switch c := cmd.(type) {762 switch c := cmd.(type) {
763+ case ports.CursorOverride:
764+ s.setCursorOverride(c.Shape)
760 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:765 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:
761 s.applyInput(c)766 s.applyInput(c)
762 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:767 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
M internal/adapters/wayland/session_lock.go +1 -0
@@ -138,6 +138,7 @@ func (l *sessionLock) orphan() {
138138 }
139139
140140 func (s *Server) resetProtectionInput() {
141+ s.cursorOverride = ""
141142 s.dndCancelAll()
142143 s.changeFocus(0)
143144 s.changePointerFocus(0, 0, 0)
@@ -138,6 +138,7 @@ func (l *sessionLock) orphan() {
138 }138 }
139 139
140 func (s *Server) resetProtectionInput() {140 func (s *Server) resetProtectionInput() {
141+ s.cursorOverride = ""
141 s.dndCancelAll()142 s.dndCancelAll()
142 s.changeFocus(0)143 s.changeFocus(0)
143 s.changePointerFocus(0, 0, 0)144 s.changePointerFocus(0, 0, 0)
A internal/core/border.go +183 -0
@@ -0,0 +1,183 @@
1+package core
2+
3+type borderKind int
4+
5+const (
6+ borderColumn borderKind = iota + 1
7+ borderRow
8+)
9+
10+// border is an output-local hit zone between consecutive columns or rows.
11+type border struct {
12+ kind borderKind
13+ col, row int
14+ rect Rect
15+}
16+
17+// borderMinWidth is the minimum logical pixel width for a border resize.
18+// The effective minimum also includes 5% of the lane and four border widths.
19+const borderMinWidth = 48
20+
21+func borderZone(a, b, pad int) (int, int) {
22+ mid := (a + b) / 2
23+ lo, hi := min(a, mid-pad), max(b, mid+pad)
24+ return lo, max(hi-lo, 1)
25+}
26+
27+func (w *Workspace) borderAt(x, y, pad int) (border, bool) {
28+ if w.fullscreen != 0 || !contains(w.Usable, x, y) {
29+ return border{}, false
30+ }
31+ // Overview and preview are Monitor presentation, not Workspace state. Their
32+ // caller must not ask for workspace borders while presenting those scenes.
33+ for _, p := range w.Layout() {
34+ if p.Floating && !p.Hidden && !p.Below && contains(p.Rect, x, y) {
35+ return border{}, false
36+ }
37+ }
38+ rs := w.columnRects()
39+ fixedFull := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
40+ for i, r := range rs {
41+ if fixedFull && i != w.Focus {
42+ continue
43+ }
44+ if r.X+r.W <= w.Usable.X || r.X >= w.Usable.X+w.Usable.W || r.Y+r.H <= w.Usable.Y || r.Y >= w.Usable.Y+w.Usable.H {
45+ continue
46+ }
47+ rows := rowRects(r, w.Columns[i], w.gap())
48+ for j := 0; j+1 < len(rows); j++ {
49+ lo, size := borderZone(rows[j].Y+rows[j].H, rows[j+1].Y, pad)
50+ b := border{kind: borderRow, col: i, row: j, rect: Rect{X: r.X, Y: lo, W: r.W, H: size}}
51+ if contains(b.rect, x, y) {
52+ return b, true
53+ }
54+ }
55+ if fixedFull || w.Overflow == OverflowFixed && !w.laneRow() || i+1 >= len(rs) || w.laneOf(i) != w.laneOf(i+1) || w.Columns[i].FullWidth || w.Columns[i+1].FullWidth {
56+ continue
57+ }
58+ next := rs[i+1]
59+ if next.X >= w.Usable.X+w.Usable.W || next.X+next.W <= w.Usable.X {
60+ continue
61+ }
62+ top, bottom := max(r.Y, next.Y), min(r.Y+r.H, next.Y+next.H)
63+ lo, size := borderZone(r.X+r.W, next.X, pad)
64+ b := border{kind: borderColumn, col: i, rect: Rect{X: lo, Y: top, W: size, H: max(bottom-top, 0)}}
65+ if contains(b.rect, x, y) {
66+ return b, true
67+ }
68+ }
69+ return border{}, false
70+}
71+
72+// pixelShare rounds upward so a stamped width reproduces its integer pixels.
73+func pixelShare(pixels, total int) int {
74+ if total <= 0 {
75+ return 0
76+ }
77+ return (pixels*10000 + total - 1) / total
78+}
79+
80+func (w *Workspace) setBorder(b border, pos int) {
81+ if b.col < 0 || b.col >= len(w.Columns) || w.fullscreen != 0 {
82+ return
83+ }
84+ rs := w.columnRects()
85+ r := rs[b.col]
86+ if b.kind == borderRow {
87+ c := &w.Columns[b.col]
88+ if b.row < 0 || b.row+1 >= len(c.Windows) || len(c.Windows)*minShare > 10000 {
89+ return
90+ }
91+ rows := rowRects(r, *c, w.gap())
92+ avail := max(r.H-(len(rows)-1)*w.gap(), 0)
93+ if avail == 0 {
94+ return
95+ }
96+ if !validShares(*c) {
97+ c.Shares = make([]int, len(rows))
98+ used := 0
99+ for i := range rows {
100+ // Stamp the middle of the pixel bucket, leaving room for
101+ // pair-preserving rounding on subsequent resizes.
102+ c.Shares[i] = (rows[i].H*10000 + 5000) / avail
103+ used += c.Shares[i]
104+ }
105+ c.Shares[len(rows)-1] += 10000 - used
106+ }
107+ pair := c.Shares[b.row] + c.Shares[b.row+1]
108+ if pair < 2*minShare {
109+ return
110+ }
111+ target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail)
112+ target = min(max(target, minShare), pair-minShare)
113+ if b.row+1 < len(rows)-1 {
114+ target = pairPixelShare(target, pair, avail, rows[b.row].H+rows[b.row+1].H, minShare)
115+ }
116+ c.Shares[b.row], c.Shares[b.row+1] = target, pair-target
117+ return
118+ }
119+ if b.kind != borderColumn || b.col+1 >= len(w.Columns) || w.laneOf(b.col) != w.laneOf(b.col+1) || w.Overflow == OverflowFixed && !w.laneRow() || w.Columns[b.col].FullWidth || w.Columns[b.col+1].FullWidth {
120+ return
121+ }
122+ first, last := w.laneBounds(w.laneOf(b.col))
123+ avail := max(w.Usable.W-2*w.gap(), 0)
124+ if w.policy().equalCells {
125+ avail = max(w.Usable.W-w.gap()*(last-first+2), 0)
126+ }
127+ if avail == 0 {
128+ return
129+ }
130+ if !w.validSpans(first, last) {
131+ w.spanSeq++
132+ used := 0
133+ for i := first; i <= last; i++ {
134+ w.Columns[i].Span = max((w.columnWidthFor(i, true)*10000+5000)/avail, 1)
135+ w.Columns[i].spanStamp = w.spanSeq
136+ used += w.Columns[i].Span
137+ }
138+ if w.policy().equalCells {
139+ w.Columns[last].Span += 10000 - used
140+ }
141+ }
142+ minimum := pixelShare(max(avail/20, 4*w.border, borderMinWidth), avail)
143+ target := pixelShare(pos-w.gap()/2-r.X, avail)
144+ if !w.policy().equalCells {
145+ w.Columns[b.col].Span = min(max(target, min(minimum, 10000)), 10000)
146+ return
147+ }
148+ pair := w.Columns[b.col].Span + w.Columns[b.col+1].Span
149+ if pair < 2*minimum {
150+ return
151+ }
152+ target = min(max(target, minimum), pair-minimum)
153+ if b.col+1 < last {
154+ target = pairPixelShare(target, pair, avail, rs[b.col].W+rs[b.col+1].W, minimum)
155+ }
156+ w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target
157+}
158+
159+func (w *Workspace) resetBorder(b border) {
160+ if b.col < 0 || b.col >= len(w.Columns) {
161+ return
162+ }
163+ switch b.kind {
164+ case borderColumn:
165+ w.clearSpans(b.col)
166+ case borderRow:
167+ w.Columns[b.col].Shares = nil
168+ }
169+}
170+
171+// pairPixelShare keeps the pair's combined integer width unchanged as well as
172+// its basis-point sum. Independent floors can otherwise shift later tiles by
173+// one pixel even when the sum of the two shares is constant.
174+func pairPixelShare(target, pair, avail, pixels, minimum int) int {
175+ left := avail * target / 10000
176+ right := pixels - left
177+ lo := max(pixelShare(left, avail), pair-pixelShare(right+1, avail)+1, minimum)
178+ hi := min(pixelShare(left+1, avail)-1, pair-pixelShare(right, avail), pair-minimum)
179+ if lo <= hi {
180+ return min(max(target, lo), hi)
181+ }
182+ return target
183+}
@@ -0,0 +1,183 @@
1+package core
2+
3+type borderKind int
4+
5+const (
6+ borderColumn borderKind = iota + 1
7+ borderRow
8+)
9+
10+// border is an output-local hit zone between consecutive columns or rows.
11+type border struct {
12+ kind borderKind
13+ col, row int
14+ rect Rect
15+}
16+
17+// borderMinWidth is the minimum logical pixel width for a border resize.
18+// The effective minimum also includes 5% of the lane and four border widths.
19+const borderMinWidth = 48
20+
21+func borderZone(a, b, pad int) (int, int) {
22+ mid := (a + b) / 2
23+ lo, hi := min(a, mid-pad), max(b, mid+pad)
24+ return lo, max(hi-lo, 1)
25+}
26+
27+func (w *Workspace) borderAt(x, y, pad int) (border, bool) {
28+ if w.fullscreen != 0 || !contains(w.Usable, x, y) {
29+ return border{}, false
30+ }
31+ // Overview and preview are Monitor presentation, not Workspace state. Their
32+ // caller must not ask for workspace borders while presenting those scenes.
33+ for _, p := range w.Layout() {
34+ if p.Floating && !p.Hidden && !p.Below && contains(p.Rect, x, y) {
35+ return border{}, false
36+ }
37+ }
38+ rs := w.columnRects()
39+ fixedFull := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
40+ for i, r := range rs {
41+ if fixedFull && i != w.Focus {
42+ continue
43+ }
44+ if r.X+r.W <= w.Usable.X || r.X >= w.Usable.X+w.Usable.W || r.Y+r.H <= w.Usable.Y || r.Y >= w.Usable.Y+w.Usable.H {
45+ continue
46+ }
47+ rows := rowRects(r, w.Columns[i], w.gap())
48+ for j := 0; j+1 < len(rows); j++ {
49+ lo, size := borderZone(rows[j].Y+rows[j].H, rows[j+1].Y, pad)
50+ b := border{kind: borderRow, col: i, row: j, rect: Rect{X: r.X, Y: lo, W: r.W, H: size}}
51+ if contains(b.rect, x, y) {
52+ return b, true
53+ }
54+ }
55+ if fixedFull || w.Overflow == OverflowFixed && !w.laneRow() || i+1 >= len(rs) || w.laneOf(i) != w.laneOf(i+1) || w.Columns[i].FullWidth || w.Columns[i+1].FullWidth {
56+ continue
57+ }
58+ next := rs[i+1]
59+ if next.X >= w.Usable.X+w.Usable.W || next.X+next.W <= w.Usable.X {
60+ continue
61+ }
62+ top, bottom := max(r.Y, next.Y), min(r.Y+r.H, next.Y+next.H)
63+ lo, size := borderZone(r.X+r.W, next.X, pad)
64+ b := border{kind: borderColumn, col: i, rect: Rect{X: lo, Y: top, W: size, H: max(bottom-top, 0)}}
65+ if contains(b.rect, x, y) {
66+ return b, true
67+ }
68+ }
69+ return border{}, false
70+}
71+
72+// pixelShare rounds upward so a stamped width reproduces its integer pixels.
73+func pixelShare(pixels, total int) int {
74+ if total <= 0 {
75+ return 0
76+ }
77+ return (pixels*10000 + total - 1) / total
78+}
79+
80+func (w *Workspace) setBorder(b border, pos int) {
81+ if b.col < 0 || b.col >= len(w.Columns) || w.fullscreen != 0 {
82+ return
83+ }
84+ rs := w.columnRects()
85+ r := rs[b.col]
86+ if b.kind == borderRow {
87+ c := &w.Columns[b.col]
88+ if b.row < 0 || b.row+1 >= len(c.Windows) || len(c.Windows)*minShare > 10000 {
89+ return
90+ }
91+ rows := rowRects(r, *c, w.gap())
92+ avail := max(r.H-(len(rows)-1)*w.gap(), 0)
93+ if avail == 0 {
94+ return
95+ }
96+ if !validShares(*c) {
97+ c.Shares = make([]int, len(rows))
98+ used := 0
99+ for i := range rows {
100+ // Stamp the middle of the pixel bucket, leaving room for
101+ // pair-preserving rounding on subsequent resizes.
102+ c.Shares[i] = (rows[i].H*10000 + 5000) / avail
103+ used += c.Shares[i]
104+ }
105+ c.Shares[len(rows)-1] += 10000 - used
106+ }
107+ pair := c.Shares[b.row] + c.Shares[b.row+1]
108+ if pair < 2*minShare {
109+ return
110+ }
111+ target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail)
112+ target = min(max(target, minShare), pair-minShare)
113+ if b.row+1 < len(rows)-1 {
114+ target = pairPixelShare(target, pair, avail, rows[b.row].H+rows[b.row+1].H, minShare)
115+ }
116+ c.Shares[b.row], c.Shares[b.row+1] = target, pair-target
117+ return
118+ }
119+ if b.kind != borderColumn || b.col+1 >= len(w.Columns) || w.laneOf(b.col) != w.laneOf(b.col+1) || w.Overflow == OverflowFixed && !w.laneRow() || w.Columns[b.col].FullWidth || w.Columns[b.col+1].FullWidth {
120+ return
121+ }
122+ first, last := w.laneBounds(w.laneOf(b.col))
123+ avail := max(w.Usable.W-2*w.gap(), 0)
124+ if w.policy().equalCells {
125+ avail = max(w.Usable.W-w.gap()*(last-first+2), 0)
126+ }
127+ if avail == 0 {
128+ return
129+ }
130+ if !w.validSpans(first, last) {
131+ w.spanSeq++
132+ used := 0
133+ for i := first; i <= last; i++ {
134+ w.Columns[i].Span = max((w.columnWidthFor(i, true)*10000+5000)/avail, 1)
135+ w.Columns[i].spanStamp = w.spanSeq
136+ used += w.Columns[i].Span
137+ }
138+ if w.policy().equalCells {
139+ w.Columns[last].Span += 10000 - used
140+ }
141+ }
142+ minimum := pixelShare(max(avail/20, 4*w.border, borderMinWidth), avail)
143+ target := pixelShare(pos-w.gap()/2-r.X, avail)
144+ if !w.policy().equalCells {
145+ w.Columns[b.col].Span = min(max(target, min(minimum, 10000)), 10000)
146+ return
147+ }
148+ pair := w.Columns[b.col].Span + w.Columns[b.col+1].Span
149+ if pair < 2*minimum {
150+ return
151+ }
152+ target = min(max(target, minimum), pair-minimum)
153+ if b.col+1 < last {
154+ target = pairPixelShare(target, pair, avail, rs[b.col].W+rs[b.col+1].W, minimum)
155+ }
156+ w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target
157+}
158+
159+func (w *Workspace) resetBorder(b border) {
160+ if b.col < 0 || b.col >= len(w.Columns) {
161+ return
162+ }
163+ switch b.kind {
164+ case borderColumn:
165+ w.clearSpans(b.col)
166+ case borderRow:
167+ w.Columns[b.col].Shares = nil
168+ }
169+}
170+
171+// pairPixelShare keeps the pair's combined integer width unchanged as well as
172+// its basis-point sum. Independent floors can otherwise shift later tiles by
173+// one pixel even when the sum of the two shares is constant.
174+func pairPixelShare(target, pair, avail, pixels, minimum int) int {
175+ left := avail * target / 10000
176+ right := pixels - left
177+ lo := max(pixelShare(left, avail), pair-pixelShare(right+1, avail)+1, minimum)
178+ hi := min(pixelShare(left+1, avail)-1, pair-pixelShare(right, avail), pair-minimum)
179+ if lo <= hi {
180+ return min(max(target, lo), hi)
181+ }
182+ return target
183+}
A internal/core/border_resize.go +89 -0
@@ -0,0 +1,89 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "time"
7+)
8+
9+// borderResize retains the original border identity, never the hit zone that
10+// moves as its neighbouring tiles resize. saved owns its slices.
11+type borderResize struct {
12+ core *Core
13+ screen *screen
14+ ws *Workspace
15+ border border
16+ btn uint32
17+ saved []Column
18+}
19+
20+func newBorderResize(c *Core, s *screen, w *Workspace, b border, button uint32) *borderResize {
21+ g := &borderResize{core: c, screen: s, ws: w, border: b, btn: button, saved: slices.Clone(w.Columns)}
22+ for i := range g.saved {
23+ g.saved[i].Windows = slices.Clone(g.saved[i].Windows)
24+ g.saved[i].Shares = slices.Clone(g.saved[i].Shares)
25+ }
26+ c.focusScreen = c.screenIndex(s.name())
27+ return g
28+}
29+
30+func (g *borderResize) button() uint32 { return g.btn }
31+func (g *borderResize) cursor() string {
32+ if g.border.kind == borderColumn {
33+ return "ew-resize"
34+ }
35+ return "ns-resize"
36+}
37+
38+func (g *borderResize) valid() bool {
39+ c, s, w := g.core, g.screen, g.ws
40+ if !slices.Contains(c.screens, s) || s.mon.Current() != w || s.mon.previewing() || s.mon.ov.open || w.pinned() || w.fullscreen != 0 || len(w.Columns) != len(g.saved) {
41+ return false
42+ }
43+ for i, col := range w.Columns {
44+ if !slices.Equal(col.Windows, g.saved[i].Windows) {
45+ return false
46+ }
47+ }
48+ b := g.border
49+ if b.col < 0 || b.col >= len(w.Columns) {
50+ return false
51+ }
52+ if b.kind == borderRow {
53+ return b.row >= 0 && b.row+1 < len(w.Columns[b.col].Windows)
54+ }
55+ return b.col+1 < len(w.Columns) && w.laneOf(b.col) == w.laneOf(b.col+1)
56+}
57+
58+func (g *borderResize) motion(ctx context.Context, _ time.Duration) error {
59+ c := g.core
60+ if !g.valid() {
61+ if err := c.abortPointerGrab(ctx); err != nil {
62+ return err
63+ }
64+ return c.publish(ctx)
65+ }
66+ pos := int(c.cursorX) - g.screen.x
67+ if g.border.kind == borderRow {
68+ pos = int(c.cursorY) - g.screen.y
69+ }
70+ g.ws.setBorder(g.border, pos)
71+ return c.publish(ctx)
72+}
73+
74+func (g *borderResize) release(ctx context.Context) error {
75+ if err := g.core.publish(ctx); err != nil {
76+ return err
77+ }
78+ return g.core.rehit(ctx)
79+}
80+
81+func (g *borderResize) cancel() {
82+ if !g.valid() {
83+ return
84+ }
85+ for i, saved := range g.saved {
86+ col := &g.ws.Columns[i]
87+ col.Span, col.spanStamp, col.Shares = saved.Span, saved.spanStamp, slices.Clone(saved.Shares)
88+ }
89+}
@@ -0,0 +1,89 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "time"
7+)
8+
9+// borderResize retains the original border identity, never the hit zone that
10+// moves as its neighbouring tiles resize. saved owns its slices.
11+type borderResize struct {
12+ core *Core
13+ screen *screen
14+ ws *Workspace
15+ border border
16+ btn uint32
17+ saved []Column
18+}
19+
20+func newBorderResize(c *Core, s *screen, w *Workspace, b border, button uint32) *borderResize {
21+ g := &borderResize{core: c, screen: s, ws: w, border: b, btn: button, saved: slices.Clone(w.Columns)}
22+ for i := range g.saved {
23+ g.saved[i].Windows = slices.Clone(g.saved[i].Windows)
24+ g.saved[i].Shares = slices.Clone(g.saved[i].Shares)
25+ }
26+ c.focusScreen = c.screenIndex(s.name())
27+ return g
28+}
29+
30+func (g *borderResize) button() uint32 { return g.btn }
31+func (g *borderResize) cursor() string {
32+ if g.border.kind == borderColumn {
33+ return "ew-resize"
34+ }
35+ return "ns-resize"
36+}
37+
38+func (g *borderResize) valid() bool {
39+ c, s, w := g.core, g.screen, g.ws
40+ if !slices.Contains(c.screens, s) || s.mon.Current() != w || s.mon.previewing() || s.mon.ov.open || w.pinned() || w.fullscreen != 0 || len(w.Columns) != len(g.saved) {
41+ return false
42+ }
43+ for i, col := range w.Columns {
44+ if !slices.Equal(col.Windows, g.saved[i].Windows) {
45+ return false
46+ }
47+ }
48+ b := g.border
49+ if b.col < 0 || b.col >= len(w.Columns) {
50+ return false
51+ }
52+ if b.kind == borderRow {
53+ return b.row >= 0 && b.row+1 < len(w.Columns[b.col].Windows)
54+ }
55+ return b.col+1 < len(w.Columns) && w.laneOf(b.col) == w.laneOf(b.col+1)
56+}
57+
58+func (g *borderResize) motion(ctx context.Context, _ time.Duration) error {
59+ c := g.core
60+ if !g.valid() {
61+ if err := c.abortPointerGrab(ctx); err != nil {
62+ return err
63+ }
64+ return c.publish(ctx)
65+ }
66+ pos := int(c.cursorX) - g.screen.x
67+ if g.border.kind == borderRow {
68+ pos = int(c.cursorY) - g.screen.y
69+ }
70+ g.ws.setBorder(g.border, pos)
71+ return c.publish(ctx)
72+}
73+
74+func (g *borderResize) release(ctx context.Context) error {
75+ if err := g.core.publish(ctx); err != nil {
76+ return err
77+ }
78+ return g.core.rehit(ctx)
79+}
80+
81+func (g *borderResize) cancel() {
82+ if !g.valid() {
83+ return
84+ }
85+ for i, saved := range g.saved {
86+ col := &g.ws.Columns[i]
87+ col.Span, col.spanStamp, col.Shares = saved.Span, saved.spanStamp, slices.Clone(saved.Shares)
88+ }
89+}
A internal/core/border_test.go +230 -0
@@ -0,0 +1,230 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func TestBorderAt(t *testing.T) {
9+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
10+ for _, gap := range []int{0, 10} {
11+ w := spanWorkspace(mode, 2)
12+ w.SetGaps(gap)
13+ w.View = 0
14+ w.view = slide{}
15+ rs := w.columnRects()
16+ x := rs[0].X + rs[0].W + gap/2
17+ y := rs[0].Y + 100
18+ b, ok := w.borderAt(x, y, 6)
19+ if !ok || b.kind != borderColumn || b.col != 0 {
20+ t.Fatal(mode, gap, b, ok)
21+ }
22+ if _, ok = w.borderAt(x-5, y, 6); !ok {
23+ t.Fatal("padding")
24+ }
25+ if _, ok = w.borderAt(rs[0].X+20, y, 6); ok {
26+ t.Fatal("tile interior")
27+ }
28+ w.Columns[0].Windows = append(w.Columns[0].Windows, 9)
29+ rows := rowRects(rs[0], w.Columns[0], gap)
30+ b, ok = w.borderAt(rs[0].X+20, rows[0].Y+rows[0].H+gap/2, 6)
31+ if !ok || b.kind != borderRow {
32+ t.Fatal("row", b, ok)
33+ }
34+ w.AddFloating(10, 400, 400)
35+ f := w.floatRect(w.Floats[0])
36+ if _, ok = w.borderAt(f.X+f.W/2, f.Y+f.H/2, 6); ok {
37+ t.Fatal("float")
38+ }
39+ w.FocusID(1)
40+ w.SetFullscreen(1, true)
41+ if _, ok = w.borderAt(x, y, 6); ok {
42+ t.Fatal("fullscreen")
43+ }
44+ }
45+ }
46+ w := spanWorkspace(OverflowFixed, 4)
47+ rs := w.columnRects()
48+ if _, ok := w.borderAt(rs[0].X+rs[0].W+5, 100, 8); ok {
49+ t.Fatal("spiral")
50+ }
51+ w = spanWorkspace(OverflowFixed, 2)
52+ w.Columns[w.Focus].FullWidth = true
53+ if _, ok := w.borderAt(500, 100, 8); ok {
54+ t.Fatal("maximized")
55+ }
56+ w = spanWorkspace(OverflowCascade, 4)
57+ rs = w.columnRects()
58+ if _, ok := w.borderAt(rs[2].X+rs[2].W+5, 100, 8); ok {
59+ t.Fatal("different bands")
60+ }
61+ // Presentation offsets must be applied just like Layout.
62+ w = spanWorkspace(OverflowScroll, 4)
63+ w.View = 100
64+ rs = w.columnRects()
65+ b, ok := w.borderAt(rs[0].X+rs[0].W+5, 100, 6)
66+ if !ok || b.col != 0 {
67+ t.Fatal("view offset", b, ok)
68+ }
69+}
70+
71+func TestSetColumnBorder(t *testing.T) {
72+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade} {
73+ for _, n := range []int{2, 3} {
74+ w := spanWorkspace(mode, n)
75+ before := w.columnRects()
76+ b := border{kind: borderColumn, col: 0}
77+ pos := before[0].X + before[0].W + 5 + 50
78+ w.setBorder(b, pos)
79+ after := w.columnRects()
80+ if after[0].X+after[0].W+5 != pos {
81+ t.Fatal("position", mode, n, pos, after)
82+ }
83+ if n == 3 && after[2] != before[2] {
84+ t.Fatal("third moved", before, after)
85+ }
86+ if after[n-1].X+after[n-1].W != 990 {
87+ t.Fatal("not pixel filled", after)
88+ }
89+ total := 0
90+ for _, c := range w.Columns {
91+ total += c.Span
92+ }
93+ if total != 10000 {
94+ t.Fatal(total)
95+ }
96+ pair := w.Columns[0].Span + w.Columns[1].Span
97+ w.setBorder(b, -1000)
98+ after = w.columnRects()
99+ if after[0].W < 48 || w.Columns[0].Span+w.Columns[1].Span != pair {
100+ t.Fatal("minimum", after)
101+ }
102+ w.setBorder(b, 10000)
103+ after = w.columnRects()
104+ if after[1].W < 48 {
105+ t.Fatal("right minimum", after)
106+ }
107+ w.resetBorder(b)
108+ for _, c := range w.Columns {
109+ if c.Span != 0 || c.spanStamp != 0 {
110+ t.Fatal("reset")
111+ }
112+ }
113+ }
114+ }
115+}
116+
117+func TestSetBorderCascadeBand(t *testing.T) {
118+ w := spanWorkspace(OverflowCascade, 5)
119+ before := w.columnRects()
120+ w.setBorder(border{kind: borderColumn, col: 0}, 400)
121+ after := w.columnRects()
122+ if !slices.Equal(before[3:], after[3:]) || w.Columns[3].Span != 0 {
123+ t.Fatal("other band changed", before, after)
124+ }
125+}
126+
127+func TestSetBorderScroll(t *testing.T) {
128+ w := spanWorkspace(OverflowScroll, 3)
129+ before := w.columnRects()
130+ b := border{kind: borderColumn, col: 0}
131+ pos := before[0].X + before[0].W + 5 + 60
132+ w.setBorder(b, pos)
133+ after := w.columnRects()
134+ if after[0].X+after[0].W+5 != pos {
135+ t.Fatal("position", after)
136+ }
137+ for i := 1; i < len(after); i++ {
138+ if after[i].W != before[i].W || after[i].X != before[i].X+60 {
139+ t.Fatal("right columns", before, after)
140+ }
141+ }
142+ saved := w.Columns[1].Span
143+ w.setBorder(b, 10000)
144+ if w.columnRects()[0].W != 980 || w.Columns[1].Span != saved {
145+ t.Fatal("maximum")
146+ }
147+ w.setBorder(b, -10000)
148+ if w.columnRects()[0].W < 49 {
149+ t.Fatal("minimum")
150+ }
151+}
152+
153+func TestSetRowBorder(t *testing.T) {
154+ w := spanWorkspace(OverflowFixed, 2)
155+ w.Columns[0].Windows = []WindowID{1, 3, 4}
156+ r := w.columnRects()[0]
157+ before := rowRects(r, w.Columns[0], w.gap())
158+ b := border{kind: borderRow, col: 0, row: 0}
159+ pos := before[0].Y + before[0].H + 5 + 30
160+ w.setBorder(b, pos)
161+ after := rowRects(r, w.Columns[0], w.gap())
162+ if after[0].Y+after[0].H+5 != pos || after[2] != before[2] || sum(w.Columns[0].Shares) != 10000 {
163+ t.Fatal(before, after, w.Columns[0].Shares)
164+ }
165+ pair := w.Columns[0].Shares[0] + w.Columns[0].Shares[1]
166+ w.setBorder(b, -10000)
167+ if w.Columns[0].Shares[0] != minShare {
168+ t.Fatal("minimum")
169+ }
170+ w.setBorder(b, 10000)
171+ if w.Columns[0].Shares[1] != minShare || w.Columns[0].Shares[0]+w.Columns[0].Shares[1] != pair {
172+ t.Fatal("pair")
173+ }
174+ w.resetBorder(b)
175+ if w.Columns[0].Shares != nil {
176+ t.Fatal("reset")
177+ }
178+}
179+
180+func TestBorderPixelRemainders(t *testing.T) {
181+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade} {
182+ for n := 2; n <= 4; n++ {
183+ for width := 997; width <= 1003; width++ {
184+ for pair := 0; pair < n-1; pair++ {
185+ w := spanWorkspace(mode, n)
186+ w.SetMaxColumns(n)
187+ for i := range w.Columns {
188+ w.Columns[i].BandStart = false
189+ }
190+ w.SetOutput(width, 800)
191+ before := w.columnRects()
192+ b := border{kind: borderColumn, col: pair}
193+ pos := before[pair].X + before[pair].W + 5
194+ w.setBorder(b, pos)
195+ if after := w.columnRects(); !slices.Equal(before, after) {
196+ t.Fatalf("stamp changed geometry: mode=%v n=%d width=%d pair=%d before=%v after=%v", mode, n, width, pair, before, after)
197+ }
198+ for delta := -20; delta <= 20; delta++ {
199+ w.setBorder(b, pos+delta)
200+ after := w.columnRects()
201+ for i := range before {
202+ if i != pair && i != pair+1 && after[i] != before[i] {
203+ t.Fatalf("untouched rect changed: mode=%v n=%d width=%d pair=%d delta=%d before=%v after=%v", mode, n, width, pair, delta, before, after)
204+ }
205+ }
206+ if after[pair].X+after[pair].W+5 != pos+delta {
207+ t.Fatal("border missed pointer", before, after)
208+ }
209+ }
210+ }
211+ }
212+ }
213+ }
214+}
215+
216+func TestBorderStampIgnoresMaximizedWidth(t *testing.T) {
217+ for _, mode := range []Overflow{OverflowScroll, OverflowFixed, OverflowCascade} {
218+ t.Run(string(mode), func(t *testing.T) {
219+ w := spanWorkspace(mode, 3)
220+ normal := w.columnWidthFor(2, true)
221+ w.Columns[2].FullWidth = true
222+ rs := w.columnRects()
223+ w.setBorder(border{kind: borderColumn, col: 0}, rs[0].X+rs[0].W+15)
224+ w.Columns[2].FullWidth = false
225+ if got := w.columnWidth(2); got != normal {
226+ t.Fatalf("unmaximized width = %d, want %d", got, normal)
227+ }
228+ })
229+ }
230+}
@@ -0,0 +1,230 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func TestBorderAt(t *testing.T) {
9+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
10+ for _, gap := range []int{0, 10} {
11+ w := spanWorkspace(mode, 2)
12+ w.SetGaps(gap)
13+ w.View = 0
14+ w.view = slide{}
15+ rs := w.columnRects()
16+ x := rs[0].X + rs[0].W + gap/2
17+ y := rs[0].Y + 100
18+ b, ok := w.borderAt(x, y, 6)
19+ if !ok || b.kind != borderColumn || b.col != 0 {
20+ t.Fatal(mode, gap, b, ok)
21+ }
22+ if _, ok = w.borderAt(x-5, y, 6); !ok {
23+ t.Fatal("padding")
24+ }
25+ if _, ok = w.borderAt(rs[0].X+20, y, 6); ok {
26+ t.Fatal("tile interior")
27+ }
28+ w.Columns[0].Windows = append(w.Columns[0].Windows, 9)
29+ rows := rowRects(rs[0], w.Columns[0], gap)
30+ b, ok = w.borderAt(rs[0].X+20, rows[0].Y+rows[0].H+gap/2, 6)
31+ if !ok || b.kind != borderRow {
32+ t.Fatal("row", b, ok)
33+ }
34+ w.AddFloating(10, 400, 400)
35+ f := w.floatRect(w.Floats[0])
36+ if _, ok = w.borderAt(f.X+f.W/2, f.Y+f.H/2, 6); ok {
37+ t.Fatal("float")
38+ }
39+ w.FocusID(1)
40+ w.SetFullscreen(1, true)
41+ if _, ok = w.borderAt(x, y, 6); ok {
42+ t.Fatal("fullscreen")
43+ }
44+ }
45+ }
46+ w := spanWorkspace(OverflowFixed, 4)
47+ rs := w.columnRects()
48+ if _, ok := w.borderAt(rs[0].X+rs[0].W+5, 100, 8); ok {
49+ t.Fatal("spiral")
50+ }
51+ w = spanWorkspace(OverflowFixed, 2)
52+ w.Columns[w.Focus].FullWidth = true
53+ if _, ok := w.borderAt(500, 100, 8); ok {
54+ t.Fatal("maximized")
55+ }
56+ w = spanWorkspace(OverflowCascade, 4)
57+ rs = w.columnRects()
58+ if _, ok := w.borderAt(rs[2].X+rs[2].W+5, 100, 8); ok {
59+ t.Fatal("different bands")
60+ }
61+ // Presentation offsets must be applied just like Layout.
62+ w = spanWorkspace(OverflowScroll, 4)
63+ w.View = 100
64+ rs = w.columnRects()
65+ b, ok := w.borderAt(rs[0].X+rs[0].W+5, 100, 6)
66+ if !ok || b.col != 0 {
67+ t.Fatal("view offset", b, ok)
68+ }
69+}
70+
71+func TestSetColumnBorder(t *testing.T) {
72+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade} {
73+ for _, n := range []int{2, 3} {
74+ w := spanWorkspace(mode, n)
75+ before := w.columnRects()
76+ b := border{kind: borderColumn, col: 0}
77+ pos := before[0].X + before[0].W + 5 + 50
78+ w.setBorder(b, pos)
79+ after := w.columnRects()
80+ if after[0].X+after[0].W+5 != pos {
81+ t.Fatal("position", mode, n, pos, after)
82+ }
83+ if n == 3 && after[2] != before[2] {
84+ t.Fatal("third moved", before, after)
85+ }
86+ if after[n-1].X+after[n-1].W != 990 {
87+ t.Fatal("not pixel filled", after)
88+ }
89+ total := 0
90+ for _, c := range w.Columns {
91+ total += c.Span
92+ }
93+ if total != 10000 {
94+ t.Fatal(total)
95+ }
96+ pair := w.Columns[0].Span + w.Columns[1].Span
97+ w.setBorder(b, -1000)
98+ after = w.columnRects()
99+ if after[0].W < 48 || w.Columns[0].Span+w.Columns[1].Span != pair {
100+ t.Fatal("minimum", after)
101+ }
102+ w.setBorder(b, 10000)
103+ after = w.columnRects()
104+ if after[1].W < 48 {
105+ t.Fatal("right minimum", after)
106+ }
107+ w.resetBorder(b)
108+ for _, c := range w.Columns {
109+ if c.Span != 0 || c.spanStamp != 0 {
110+ t.Fatal("reset")
111+ }
112+ }
113+ }
114+ }
115+}
116+
117+func TestSetBorderCascadeBand(t *testing.T) {
118+ w := spanWorkspace(OverflowCascade, 5)
119+ before := w.columnRects()
120+ w.setBorder(border{kind: borderColumn, col: 0}, 400)
121+ after := w.columnRects()
122+ if !slices.Equal(before[3:], after[3:]) || w.Columns[3].Span != 0 {
123+ t.Fatal("other band changed", before, after)
124+ }
125+}
126+
127+func TestSetBorderScroll(t *testing.T) {
128+ w := spanWorkspace(OverflowScroll, 3)
129+ before := w.columnRects()
130+ b := border{kind: borderColumn, col: 0}
131+ pos := before[0].X + before[0].W + 5 + 60
132+ w.setBorder(b, pos)
133+ after := w.columnRects()
134+ if after[0].X+after[0].W+5 != pos {
135+ t.Fatal("position", after)
136+ }
137+ for i := 1; i < len(after); i++ {
138+ if after[i].W != before[i].W || after[i].X != before[i].X+60 {
139+ t.Fatal("right columns", before, after)
140+ }
141+ }
142+ saved := w.Columns[1].Span
143+ w.setBorder(b, 10000)
144+ if w.columnRects()[0].W != 980 || w.Columns[1].Span != saved {
145+ t.Fatal("maximum")
146+ }
147+ w.setBorder(b, -10000)
148+ if w.columnRects()[0].W < 49 {
149+ t.Fatal("minimum")
150+ }
151+}
152+
153+func TestSetRowBorder(t *testing.T) {
154+ w := spanWorkspace(OverflowFixed, 2)
155+ w.Columns[0].Windows = []WindowID{1, 3, 4}
156+ r := w.columnRects()[0]
157+ before := rowRects(r, w.Columns[0], w.gap())
158+ b := border{kind: borderRow, col: 0, row: 0}
159+ pos := before[0].Y + before[0].H + 5 + 30
160+ w.setBorder(b, pos)
161+ after := rowRects(r, w.Columns[0], w.gap())
162+ if after[0].Y+after[0].H+5 != pos || after[2] != before[2] || sum(w.Columns[0].Shares) != 10000 {
163+ t.Fatal(before, after, w.Columns[0].Shares)
164+ }
165+ pair := w.Columns[0].Shares[0] + w.Columns[0].Shares[1]
166+ w.setBorder(b, -10000)
167+ if w.Columns[0].Shares[0] != minShare {
168+ t.Fatal("minimum")
169+ }
170+ w.setBorder(b, 10000)
171+ if w.Columns[0].Shares[1] != minShare || w.Columns[0].Shares[0]+w.Columns[0].Shares[1] != pair {
172+ t.Fatal("pair")
173+ }
174+ w.resetBorder(b)
175+ if w.Columns[0].Shares != nil {
176+ t.Fatal("reset")
177+ }
178+}
179+
180+func TestBorderPixelRemainders(t *testing.T) {
181+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade} {
182+ for n := 2; n <= 4; n++ {
183+ for width := 997; width <= 1003; width++ {
184+ for pair := 0; pair < n-1; pair++ {
185+ w := spanWorkspace(mode, n)
186+ w.SetMaxColumns(n)
187+ for i := range w.Columns {
188+ w.Columns[i].BandStart = false
189+ }
190+ w.SetOutput(width, 800)
191+ before := w.columnRects()
192+ b := border{kind: borderColumn, col: pair}
193+ pos := before[pair].X + before[pair].W + 5
194+ w.setBorder(b, pos)
195+ if after := w.columnRects(); !slices.Equal(before, after) {
196+ t.Fatalf("stamp changed geometry: mode=%v n=%d width=%d pair=%d before=%v after=%v", mode, n, width, pair, before, after)
197+ }
198+ for delta := -20; delta <= 20; delta++ {
199+ w.setBorder(b, pos+delta)
200+ after := w.columnRects()
201+ for i := range before {
202+ if i != pair && i != pair+1 && after[i] != before[i] {
203+ t.Fatalf("untouched rect changed: mode=%v n=%d width=%d pair=%d delta=%d before=%v after=%v", mode, n, width, pair, delta, before, after)
204+ }
205+ }
206+ if after[pair].X+after[pair].W+5 != pos+delta {
207+ t.Fatal("border missed pointer", before, after)
208+ }
209+ }
210+ }
211+ }
212+ }
213+ }
214+}
215+
216+func TestBorderStampIgnoresMaximizedWidth(t *testing.T) {
217+ for _, mode := range []Overflow{OverflowScroll, OverflowFixed, OverflowCascade} {
218+ t.Run(string(mode), func(t *testing.T) {
219+ w := spanWorkspace(mode, 3)
220+ normal := w.columnWidthFor(2, true)
221+ w.Columns[2].FullWidth = true
222+ rs := w.columnRects()
223+ w.setBorder(border{kind: borderColumn, col: 0}, rs[0].X+rs[0].W+15)
224+ w.Columns[2].FullWidth = false
225+ if got := w.columnWidth(2); got != normal {
226+ t.Fatalf("unmaximized width = %d, want %d", got, normal)
227+ }
228+ })
229+ }
230+}
M internal/core/cascade.go +28 -7
@@ -141,15 +141,36 @@ func (w *Workspace) laneWeight(i, S, u int) int {
141141
142142 // laneWidth is the width of column i of the lane first..last with the
143143 // stats of laneStats: the usable width after gaps, split by weight. The
144-// remainder under one pixel per weight stays empty, as in equalShare.
144+// last column receives the pixel remainder, just as for stamped spans.
145145 func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {
146+ if w.validSpans(first, last) {
147+ avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
148+ if i == last {
149+ used := 0
150+ for j := first; j < last; j++ {
151+ used += avail * w.Columns[j].Span / 10000
152+ }
153+ return avail - used
154+ }
155+ return avail * w.Columns[i].Span / 10000
156+ }
146157 if sum <= 0 {
147158 return 0
148159 }
149160 avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
150161 // floor(avail*weight/sum) without overflowing on a huge area.
151- weight := w.laneWeight(i, S, u)
152- return avail/sum*weight + avail%sum*weight/sum
162+ width := func(j int) int {
163+ weight := w.laneWeight(j, S, u)
164+ return avail/sum*weight + avail%sum*weight/sum
165+ }
166+ if i == last {
167+ used := 0
168+ for j := first; j < last; j++ {
169+ used += width(j)
170+ }
171+ return avail - used
172+ }
173+ return width(i)
153174 }
154175
155176 // equalShare is the width of each of k columns sharing the usable width
@@ -175,12 +196,10 @@ func (w *Workspace) focusBand(dir int) bool {
175196 w.scroll()
176197 return true
177198 }
178-
179-// cascadeRectsAt is cascadeRectsInto with the view slide's shift given.
180-func (w *Workspace) cascadeRectsAt(dst []Rect, ignoreFullWidth bool, shift int) []Rect {
199+func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
181200 g := w.gap()
182201 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
183- view := w.View + shift
202+ view := w.View + w.shiftPixels()
184203 w.walkBands(func(lane, first, last int) bool {
185204 S, u, sum := w.laneStats(first, last)
186205 x := g
@@ -492,6 +511,8 @@ func (w *Workspace) spawnIndex() (at int, newBand bool) {
492511
493512 // deleteColumn removes column i; a band it started keeps its start.
494513 func (w *Workspace) deleteColumn(i int) {
514+ // A removed member cannot be detected by counting surviving stamp members.
515+ w.clearSpans(i)
495516 if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {
496517 w.Columns[i+1].BandStart = true
497518 }
@@ -141,15 +141,36 @@ func (w *Workspace) laneWeight(i, S, u int) int {
141 141
142 // laneWidth is the width of column i of the lane first..last with the142 // laneWidth is the width of column i of the lane first..last with the
143 // stats of laneStats: the usable width after gaps, split by weight. The143 // stats of laneStats: the usable width after gaps, split by weight. The
144-// remainder under one pixel per weight stays empty, as in equalShare.144+// last column receives the pixel remainder, just as for stamped spans.
145 func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {145 func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {
146+ if w.validSpans(first, last) {
147+ avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
148+ if i == last {
149+ used := 0
150+ for j := first; j < last; j++ {
151+ used += avail * w.Columns[j].Span / 10000
152+ }
153+ return avail - used
154+ }
155+ return avail * w.Columns[i].Span / 10000
156+ }
146 if sum <= 0 {157 if sum <= 0 {
147 return 0158 return 0
148 }159 }
149 avail := max(w.Usable.W-w.gap()*(last-first+2), 0)160 avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
150 // floor(avail*weight/sum) without overflowing on a huge area.161 // floor(avail*weight/sum) without overflowing on a huge area.
151- weight := w.laneWeight(i, S, u)162+ width := func(j int) int {
152- return avail/sum*weight + avail%sum*weight/sum163+ weight := w.laneWeight(j, S, u)
164+ return avail/sum*weight + avail%sum*weight/sum
165+ }
166+ if i == last {
167+ used := 0
168+ for j := first; j < last; j++ {
169+ used += width(j)
170+ }
171+ return avail - used
172+ }
173+ return width(i)
153 }174 }
154 175
155 // equalShare is the width of each of k columns sharing the usable width176 // equalShare is the width of each of k columns sharing the usable width
@@ -175,12 +196,10 @@ func (w *Workspace) focusBand(dir int) bool {
175 w.scroll()196 w.scroll()
176 return true197 return true
177 }198 }
178-199+func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
179-// cascadeRectsAt is cascadeRectsInto with the view slide's shift given.
180-func (w *Workspace) cascadeRectsAt(dst []Rect, ignoreFullWidth bool, shift int) []Rect {
181 g := w.gap()200 g := w.gap()
182 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]201 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
183- view := w.View + shift202+ view := w.View + w.shiftPixels()
184 w.walkBands(func(lane, first, last int) bool {203 w.walkBands(func(lane, first, last int) bool {
185 S, u, sum := w.laneStats(first, last)204 S, u, sum := w.laneStats(first, last)
186 x := g205 x := g
@@ -492,6 +511,8 @@ func (w *Workspace) spawnIndex() (at int, newBand bool) {
492 511
493 // deleteColumn removes column i; a band it started keeps its start.512 // deleteColumn removes column i; a band it started keeps its start.
494 func (w *Workspace) deleteColumn(i int) {513 func (w *Workspace) deleteColumn(i int) {
514+ // A removed member cannot be detected by counting surviving stamp members.
515+ w.clearSpans(i)
495 if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {516 if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {
496 w.Columns[i+1].BandStart = true517 w.Columns[i+1].BandStart = true
497 }518 }
M internal/core/cascade_test.go +1 -1
@@ -49,7 +49,7 @@ func TestCascadePartialLastBandWithGaps(t *testing.T) {
4949 third, half := (1000-4*8)/3, (1000-3*8)/2
5050 rects := w.columnRectsFor(false)
5151 for i, want := range []struct{ x, w int }{
52- {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), third},
52+ {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), 1000 - 4*8 - 2*third},
5353 {8, half}, {8 + half + 8, half},
5454 } {
5555 if r := rects[i]; r.X != want.x || r.W != want.w {
@@ -49,7 +49,7 @@ func TestCascadePartialLastBandWithGaps(t *testing.T) {
49 third, half := (1000-4*8)/3, (1000-3*8)/249 third, half := (1000-4*8)/3, (1000-3*8)/2
50 rects := w.columnRectsFor(false)50 rects := w.columnRectsFor(false)
51 for i, want := range []struct{ x, w int }{51 for i, want := range []struct{ x, w int }{
52- {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), third},52+ {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), 1000 - 4*8 - 2*third},
53 {8, half}, {8 + half + 8, half},53 {8, half}, {8 + half + 8, half},
54 } {54 } {
55 if r := rects[i]; r.X != want.x || r.W != want.w {55 if r := rects[i]; r.X != want.x || r.W != want.w {
M internal/core/client.go +5 -0
@@ -136,6 +136,11 @@ func (c *Core) unmapClient(ctx context.Context, v ports.WindowUnmapped) error {
136136 return err
137137 }
138138 c.unmapWindow(v)
139+ if c.pgrab != nil && !c.pgrab.valid() {
140+ if err := c.abortPointerGrab(ctx); err != nil {
141+ return err
142+ }
143+ }
139144 c.releaseSlots()
140145 if c.pointer == v.ID {
141146 c.pointer = 0
@@ -136,6 +136,11 @@ func (c *Core) unmapClient(ctx context.Context, v ports.WindowUnmapped) error {
136 return err136 return err
137 }137 }
138 c.unmapWindow(v)138 c.unmapWindow(v)
139+ if c.pgrab != nil && !c.pgrab.valid() {
140+ if err := c.abortPointerGrab(ctx); err != nil {
141+ return err
142+ }
143+ }
139 c.releaseSlots()144 c.releaseSlots()
140 if c.pointer == v.ID {145 if c.pointer == v.ID {
141 c.pointer = 0146 c.pointer = 0
M internal/core/clientgesture.go +1 -1
@@ -38,7 +38,7 @@ func (c *Core) gestureBegin(ctx context.Context, kind ports.GestureKind, fingers
3838 return err
3939 }
4040 c.gesture = clientGesture{on: true, kind: kind, at: at}
41- if c.pointer == 0 || c.drag != nil || c.dataDrag != nil {
41+ if c.pointer == 0 || c.pointerBusy() || c.dataDrag != nil {
4242 return nil
4343 }
4444 c.gesture.id = c.pointer
@@ -38,7 +38,7 @@ func (c *Core) gestureBegin(ctx context.Context, kind ports.GestureKind, fingers
38 return err38 return err
39 }39 }
40 c.gesture = clientGesture{on: true, kind: kind, at: at}40 c.gesture = clientGesture{on: true, kind: kind, at: at}
41- if c.pointer == 0 || c.drag != nil || c.dataDrag != nil {41+ if c.pointer == 0 || c.pointerBusy() || c.dataDrag != nil {
42 return nil42 return nil
43 }43 }
44 c.gesture.id = c.pointer44 c.gesture.id = c.pointer
M internal/core/column_cells_test.go +2 -2
@@ -56,11 +56,11 @@ func TestGrowColumnCycle(t *testing.T) {
5656 }},
5757 {"cascade three columns", OverflowCascade, 4, 3, 2, []step{
5858 {[]int{1, 2, 1}, []int{200, 400, 200}},
59- {[]int{0, 0, 0}, []int{266, 266, 266}},
59+ {[]int{0, 0, 0}, []int{266, 266, 268}},
6060 }},
6161 {"fixed three columns", OverflowFixed, 4, 3, 2, []step{
6262 {[]int{1, 2, 1}, []int{200, 400, 200}},
63- {[]int{0, 0, 0}, []int{266, 266, 266}},
63+ {[]int{0, 0, 0}, []int{266, 266, 268}},
6464 }},
6565 {"remainder goes to the focused column and is the step", OverflowCascade, 5, 3, 1, []step{
6666 // 5 cells over 3 columns: 1 each and the 2 left to the focused one.
@@ -56,11 +56,11 @@ func TestGrowColumnCycle(t *testing.T) {
56 }},56 }},
57 {"cascade three columns", OverflowCascade, 4, 3, 2, []step{57 {"cascade three columns", OverflowCascade, 4, 3, 2, []step{
58 {[]int{1, 2, 1}, []int{200, 400, 200}},58 {[]int{1, 2, 1}, []int{200, 400, 200}},
59- {[]int{0, 0, 0}, []int{266, 266, 266}},59+ {[]int{0, 0, 0}, []int{266, 266, 268}},
60 }},60 }},
61 {"fixed three columns", OverflowFixed, 4, 3, 2, []step{61 {"fixed three columns", OverflowFixed, 4, 3, 2, []step{
62 {[]int{1, 2, 1}, []int{200, 400, 200}},62 {[]int{1, 2, 1}, []int{200, 400, 200}},
63- {[]int{0, 0, 0}, []int{266, 266, 266}},63+ {[]int{0, 0, 0}, []int{266, 266, 268}},
64 }},64 }},
65 {"remainder goes to the focused column and is the step", OverflowCascade, 5, 3, 1, []step{65 {"remainder goes to the focused column and is the step", OverflowCascade, 5, 3, 1, []step{
66 // 5 cells over 3 columns: 1 each and the 2 left to the focused one.66 // 5 cells over 3 columns: 1 each and the 2 left to the focused one.
M internal/core/core.go +13 -4
@@ -110,7 +110,13 @@ type Core struct {
110110 // buttons whose release no client must see. mods are the modifiers
111111 // held, cmdMod the one keyboard.cmd names. lastButton is the latest
112112 // press a client saw, the one xdg_toplevel.move's serial names.
113- drag *dragState
113+ drag *dragState
114+ pgrab pointerGrab
115+ lastBorderClick struct {
116+ at time.Duration
117+ ws *Workspace
118+ b border
119+ }
114120 // dataDrag is the client data drag in progress (datadrag.go).
115121 dataDrag *dataDragState
116122 swallow map[uint32]bool
@@ -196,8 +202,6 @@ type publishScratch struct {
196202 workspaces ports.Workspaces
197203 shown map[WindowID]bool
198204 ids []WindowID
199- rects []Rect
200- rows []Rect
201205 layout ports.Layout
202206 }
203207
@@ -633,6 +637,11 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
633637 sc.mon.Current().noteFocus()
634638 }
635639 }
640+ if c.pgrab != nil && !c.pgrab.valid() {
641+ if err := c.abortPointerGrab(ctx); err != nil {
642+ return err
643+ }
644+ }
636645 c.refreshShown()
637646 c.captureExpire()
638647 capture, err := c.captureEvaluate(ctx)
@@ -754,7 +763,7 @@ func (c *Core) rehit(ctx context.Context) error {
754763 // The drag target follows the layout too.
755764 return c.dataDragRetarget(ctx, c.motionTime)
756765 }
757- if c.grab != 0 || c.drag != nil {
766+ if c.grab != 0 || c.pointerBusy() {
758767 return nil
759768 }
760769 id, x, y := c.hit(c.cursorX, c.cursorY)
@@ -110,7 +110,13 @@ type Core struct {
110 // buttons whose release no client must see. mods are the modifiers110 // buttons whose release no client must see. mods are the modifiers
111 // held, cmdMod the one keyboard.cmd names. lastButton is the latest111 // held, cmdMod the one keyboard.cmd names. lastButton is the latest
112 // press a client saw, the one xdg_toplevel.move's serial names.112 // press a client saw, the one xdg_toplevel.move's serial names.
113- drag *dragState113+ drag *dragState
114+ pgrab pointerGrab
115+ lastBorderClick struct {
116+ at time.Duration
117+ ws *Workspace
118+ b border
119+ }
114 // dataDrag is the client data drag in progress (datadrag.go).120 // dataDrag is the client data drag in progress (datadrag.go).
115 dataDrag *dataDragState121 dataDrag *dataDragState
116 swallow map[uint32]bool122 swallow map[uint32]bool
@@ -196,8 +202,6 @@ type publishScratch struct {
196 workspaces ports.Workspaces202 workspaces ports.Workspaces
197 shown map[WindowID]bool203 shown map[WindowID]bool
198 ids []WindowID204 ids []WindowID
199- rects []Rect
200- rows []Rect
201 layout ports.Layout205 layout ports.Layout
202 }206 }
203 207
@@ -633,6 +637,11 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
633 sc.mon.Current().noteFocus()637 sc.mon.Current().noteFocus()
634 }638 }
635 }639 }
640+ if c.pgrab != nil && !c.pgrab.valid() {
641+ if err := c.abortPointerGrab(ctx); err != nil {
642+ return err
643+ }
644+ }
636 c.refreshShown()645 c.refreshShown()
637 c.captureExpire()646 c.captureExpire()
638 capture, err := c.captureEvaluate(ctx)647 capture, err := c.captureEvaluate(ctx)
@@ -754,7 +763,7 @@ func (c *Core) rehit(ctx context.Context) error {
754 // The drag target follows the layout too.763 // The drag target follows the layout too.
755 return c.dataDragRetarget(ctx, c.motionTime)764 return c.dataDragRetarget(ctx, c.motionTime)
756 }765 }
757- if c.grab != 0 || c.drag != nil {766+ if c.grab != 0 || c.pointerBusy() {
758 return nil767 return nil
759 }768 }
760 id, x, y := c.hit(c.cursorX, c.cursorY)769 id, x, y := c.hit(c.cursorX, c.cursorY)
M internal/core/datadrag.go +1 -1
@@ -50,7 +50,7 @@ type dataDragIcon struct {
5050 // during a window drag or a locked session is refused.
5151 func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error {
5252 button := v.Button
53- if c.dataDrag != nil || c.drag != nil || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {
53+ if c.dataDrag != nil || c.pointerBusy() || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {
5454 return c.command(ctx, ports.DataDragCancel{})
5555 }
5656 c.dataDrag = &dataDragState{button: button, icon: dataDragIcon{id: v.Icon}}
@@ -50,7 +50,7 @@ type dataDragIcon struct {
50 // during a window drag or a locked session is refused.50 // during a window drag or a locked session is refused.
51 func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error {51 func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error {
52 button := v.Button52 button := v.Button
53- if c.dataDrag != nil || c.drag != nil || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {53+ if c.dataDrag != nil || c.pointerBusy() || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {
54 return c.command(ctx, ports.DataDragCancel{})54 return c.command(ctx, ports.DataDragCancel{})
55 }55 }
56 c.dataDrag = &dataDragState{button: button, icon: dataDragIcon{id: v.Icon}}56 c.dataDrag = &dataDragState{button: button, icon: dataDragIcon{id: v.Icon}}
M internal/core/hint.go +1 -1
@@ -127,7 +127,7 @@ func (c *Core) hintWindows(sc *screen) []ports.HintWindow {
127127 // drag, or the column switcher.
128128 func (c *Core) hintPointerBusy() bool {
129129 return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||
130- c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching()
130+ c.grab != 0 || len(c.buttons) > 0 || c.pointerBusy() || c.dataDrag != nil || c.switching()
131131 }
132132
133133 // hintResult handles a scanner result and reports whether to publish.
@@ -127,7 +127,7 @@ func (c *Core) hintWindows(sc *screen) []ports.HintWindow {
127 // drag, or the column switcher.127 // drag, or the column switcher.
128 func (c *Core) hintPointerBusy() bool {128 func (c *Core) hintPointerBusy() bool {
129 return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||129 return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||
130- c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching()130+ c.grab != 0 || len(c.buttons) > 0 || c.pointerBusy() || c.dataDrag != nil || c.switching()
131 }131 }
132 132
133 // hintResult handles a scanner result and reports whether to publish.133 // hintResult handles a scanner result and reports whether to publish.
M internal/core/input.go +25 -5
@@ -98,6 +98,9 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error {
9898 }
9999 }
100100 }
101+ if c.pgrab != nil {
102+ return c.pgrab.motion(ctx, v.Time)
103+ }
101104 if c.drag != nil {
102105 // The dragged window and the others get no pointer events.
103106 return c.dragMotion(ctx, v.Time)
@@ -150,6 +153,16 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
150153 if c.dataDrag != nil {
151154 return c.dataDragButton(ctx, v)
152155 }
156+ if g := c.pgrab; g != nil {
157+ if !v.Pressed && v.Button == g.button() {
158+ delete(c.buttons, v.Button)
159+ return c.endPointerGrab(ctx)
160+ }
161+ if v.Pressed {
162+ c.buttons[v.Button], c.swallow[v.Button] = true, true
163+ }
164+ return nil
165+ }
153166 if d := c.drag; d != nil {
154167 if !v.Pressed && v.Button == d.button {
155168 delete(c.buttons, v.Button)
@@ -167,7 +180,7 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
167180 return err
168181 }
169182 }
170- if started, err := c.startDrag(ctx, v); err != nil || started {
183+ if started, err := c.startPointerGrab(ctx, v); err != nil || started {
171184 return err
172185 }
173186 if v.Pressed && c.pointer == 0 && c.grab == 0 && c.held() == 0 {
@@ -248,7 +261,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
248261 }
249262 // Scroll goes to the window under the pointer, which has the
250263 // pointer focus even mid-drag. None during a window drag.
251- if c.pointer != 0 && c.drag == nil && c.dataDrag == nil {
264+ if c.pointer != 0 && !c.pointerBusy() && c.dataDrag == nil {
252265 if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
253266 return err
254267 }
@@ -278,9 +291,16 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
278291 c.pressed[heldKey(key)] = true
279292 return c.abortSwitcher(ctx)
280293 }
281- if c.drag != nil && key.Pressed && key.Keysym == "Escape" {
282- // Escape cancels the drag; its release goes nowhere either.
283- c.cancelDrag()
294+ if c.pointerBusy() && key.Pressed && key.Keysym == "Escape" {
295+ // Escape cancels the grab; its release goes nowhere either.
296+ if c.pgrab != nil {
297+ c.pgrab.cancel()
298+ if err := c.abortPointerGrab(ctx); err != nil {
299+ return err
300+ }
301+ } else {
302+ c.cancelDrag()
303+ }
284304 c.pressed[heldKey(key)] = true
285305 if err := c.publish(ctx); err != nil {
286306 return err
@@ -98,6 +98,9 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error {
98 }98 }
99 }99 }
100 }100 }
101+ if c.pgrab != nil {
102+ return c.pgrab.motion(ctx, v.Time)
103+ }
101 if c.drag != nil {104 if c.drag != nil {
102 // The dragged window and the others get no pointer events.105 // The dragged window and the others get no pointer events.
103 return c.dragMotion(ctx, v.Time)106 return c.dragMotion(ctx, v.Time)
@@ -150,6 +153,16 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
150 if c.dataDrag != nil {153 if c.dataDrag != nil {
151 return c.dataDragButton(ctx, v)154 return c.dataDragButton(ctx, v)
152 }155 }
156+ if g := c.pgrab; g != nil {
157+ if !v.Pressed && v.Button == g.button() {
158+ delete(c.buttons, v.Button)
159+ return c.endPointerGrab(ctx)
160+ }
161+ if v.Pressed {
162+ c.buttons[v.Button], c.swallow[v.Button] = true, true
163+ }
164+ return nil
165+ }
153 if d := c.drag; d != nil {166 if d := c.drag; d != nil {
154 if !v.Pressed && v.Button == d.button {167 if !v.Pressed && v.Button == d.button {
155 delete(c.buttons, v.Button)168 delete(c.buttons, v.Button)
@@ -167,7 +180,7 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
167 return err180 return err
168 }181 }
169 }182 }
170- if started, err := c.startDrag(ctx, v); err != nil || started {183+ if started, err := c.startPointerGrab(ctx, v); err != nil || started {
171 return err184 return err
172 }185 }
173 if v.Pressed && c.pointer == 0 && c.grab == 0 && c.held() == 0 {186 if v.Pressed && c.pointer == 0 && c.grab == 0 && c.held() == 0 {
@@ -248,7 +261,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
248 }261 }
249 // Scroll goes to the window under the pointer, which has the262 // Scroll goes to the window under the pointer, which has the
250 // pointer focus even mid-drag. None during a window drag.263 // pointer focus even mid-drag. None during a window drag.
251- if c.pointer != 0 && c.drag == nil && c.dataDrag == nil {264+ if c.pointer != 0 && !c.pointerBusy() && c.dataDrag == nil {
252 if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {265 if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
253 return err266 return err
254 }267 }
@@ -278,9 +291,16 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
278 c.pressed[heldKey(key)] = true291 c.pressed[heldKey(key)] = true
279 return c.abortSwitcher(ctx)292 return c.abortSwitcher(ctx)
280 }293 }
281- if c.drag != nil && key.Pressed && key.Keysym == "Escape" {294+ if c.pointerBusy() && key.Pressed && key.Keysym == "Escape" {
282- // Escape cancels the drag; its release goes nowhere either.295+ // Escape cancels the grab; its release goes nowhere either.
283- c.cancelDrag()296+ if c.pgrab != nil {
297+ c.pgrab.cancel()
298+ if err := c.abortPointerGrab(ctx); err != nil {
299+ return err
300+ }
301+ } else {
302+ c.cancelDrag()
303+ }
284 c.pressed[heldKey(key)] = true304 c.pressed[heldKey(key)] = true
285 if err := c.publish(ctx); err != nil {305 if err := c.publish(ctx); err != nil {
286 return err306 return err
M internal/core/layout.go +30 -20
@@ -19,6 +19,9 @@ const maxCells = 16
1919 // max-columns-th of the usable width. Zero (auto) shares the width equally
2020 // with the other auto columns, up to MaxColumns visible at once.
2121 type Column struct {
22+ // Span is a manually sized width in basis points; zero uses Cells.
23+ Span int
24+ spanStamp uint64
2225 Windows []WindowID
2326 Cells int
2427 FullWidth bool
@@ -26,9 +29,9 @@ type Column struct {
2629 // Slot is the declared column number (workspace.<name>.column.N) of a
2730 // slot window; 0 for normal columns.
2831 Slot int
29- // Shares are the row heights set by set-window-height, in percent, in
32+ // Shares are the row heights set by set-window-height, in basis points, in
3033 // Windows order. They apply only while there is one per window and
31- // they sum to 100; otherwise the rows share the height equally.
34+ // they sum to 10000; otherwise the rows share the height equally.
3235 Shares []int
3336 // BandStart marks the first column of a cascade band. It belongs to the
3437 // position, not the column: swaps leave it in place. See cascade.go.
@@ -105,6 +108,8 @@ type Workspace struct {
105108 overviewAfter *Workspace
106109 Overflow Overflow
107110 Columns []Column
111+ // spanSeq issues workspace-local identities for complete lane stamps.
112+ spanSeq uint64
108113 // maximized remembers recently maximized columns by window ID, newest
109114 // first; stack() filters removed windows at read time.
110115 maximized []WindowID
@@ -534,6 +539,7 @@ func (w *Workspace) addWindow(id WindowID, cells int) {
534539 at = min(w.Focus+1, len(w.Columns))
535540 }
536541 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})
542+ w.dropStaleSpans()
537543 return
538544 }
539545 w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})
@@ -542,6 +548,8 @@ func (w *Workspace) addWindow(id WindowID, cells int) {
542548 // addColumn places a new column like a new window and focuses it.
543549 func (w *Workspace) addColumn(col Column) {
544550 at := 0
551+ // Stamp identities belong to the issuing workspace and complete lane.
552+ col.Span, col.spanStamp = 0, 0
545553 col.BandStart = false
546554 if w.policy().wraps {
547555 // After the focused band: it joins it when it has room, else it
@@ -916,33 +924,33 @@ func (w *Workspace) MoveWindow(dir int) {
916924 w.scroll()
917925 }
918926
919-// minShare is the smallest row height set-window-height leaves, percent.
920-const minShare = 10
927+// minShare is the smallest row height set-window-height leaves, in basis points.
928+const minShare = 1000
921929
922930 // ResizeRow changes the focused window's height share by pct percent; the
923931 // other rows of its column give or take it in proportion to their shares.
924-// Every row keeps at least minShare percent.
932+// Every row keeps at least minShare basis points.
925933 func (w *Workspace) ResizeRow(pct int) {
926934 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
927935 return
928936 }
929937 c := &w.Columns[w.Focus]
930938 n := len(c.Windows)
931- if n < 2 || n*minShare > 100 {
939+ if n < 2 || n*minShare > 10000 {
932940 return
933941 }
934942 shares := c.Shares
935943 if !validShares(*c) {
936944 shares = make([]int, n)
937945 for i := range shares {
938- shares[i] = 100 / n
946+ shares[i] = 10000 / n
939947 }
940- shares[n-1] += 100 % n
948+ shares[n-1] += 10000 % n
941949 } else {
942950 shares = slices.Clone(shares)
943951 }
944952 f := c.Focus
945- target := min(max(shares[f]+pct, minShare), 100-minShare*(n-1))
953+ target := min(max(shares[f]+pct*100, minShare), 10000-minShare*(n-1))
946954 delta := target - shares[f]
947955 if delta == 0 {
948956 return
@@ -1033,6 +1041,8 @@ func (w *Workspace) insertColumn(at int, col Column) {
10331041 // insertAt inserts col at index at; in a cascade it joins the band of the
10341042 // column it goes before.
10351043 func (w *Workspace) insertAt(at int, col Column) {
1044+ // Stamp identities belong to the issuing workspace and complete lane.
1045+ col.Span, col.spanStamp = 0, 0
10361046 col.BandStart = false
10371047 if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {
10381048 col.BandStart, w.Columns[at].BandStart = true, false
@@ -1051,6 +1061,7 @@ func (w *Workspace) GrowColumn(cycle bool) {
10511061 return
10521062 }
10531063 defer w.scroll()
1064+ w.clearSpans(w.Focus)
10541065 if w.policy().wraps && w.Columns[w.Focus].FullWidth {
10551066 w.unmaximizeBand()
10561067 }
@@ -1120,6 +1131,7 @@ func (w *Workspace) ShrinkColumn() {
11201131 return
11211132 }
11221133 defer w.scroll()
1134+ w.clearSpans(w.Focus)
11231135 if w.policy().wraps && w.Columns[w.Focus].FullWidth {
11241136 w.unmaximizeBand()
11251137 }
@@ -1667,6 +1679,9 @@ func (w *Workspace) scrollWidth(i int) int {
16671679 // Fixed overflow past MaxColumns: the spiral's slots.
16681680 return w.equalShare(k)
16691681 }
1682+ if w.validSpans(0, len(w.Columns)-1) {
1683+ return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000
1684+ }
16701685 c := w.Columns[i].Cells
16711686 if c <= 0 {
16721687 return w.equalShare(min(len(w.Columns), k))
@@ -1682,6 +1697,7 @@ func (w *Workspace) columnX(i int) int {
16821697 return x
16831698 }
16841699 func (w *Workspace) scroll() {
1700+ w.dropStaleSpans()
16851701 before := w.View
16861702 defer func() { w.retarget(before) }()
16871703 if w.policy().wraps {
@@ -1726,19 +1742,13 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
17261742 // when too small): the result aliases dst, so a caller passing a reused
17271743 // buffer must be done with the previous result.
17281744 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1729- return w.columnRectsAt(dst, ignoreFullWidth, w.shiftPixels())
1730-}
1731-
1732-// columnRectsAt is columnRectsInto with the view slide's shift given: 0 is
1733-// the settled geometry, without the animation's presentation offset.
1734-func (w *Workspace) columnRectsAt(dst []Rect, ignoreFullWidth bool, shift int) []Rect {
17351745 if w.policy().wraps {
1736- return w.cascadeRectsAt(dst, ignoreFullWidth, shift)
1746+ return w.cascadeRectsInto(dst, ignoreFullWidth)
17371747 }
17381748 g := w.gap()
17391749 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
17401750 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
1741- view := w.View + shift
1751+ view := w.View + w.shiftPixels()
17421752 x := w.Usable.X + g
17431753 for i := range w.Columns {
17441754 width := w.columnWidthFor(i, ignoreFullWidth)
@@ -1810,7 +1820,7 @@ func rowRectsInto(dst []Rect, r Rect, c Column, gap int) []Rect {
18101820 avail := max(r.H-(n-1)*gap, 0)
18111821 yy, bottom, used := r.Y, r.Y+r.H, 0
18121822 for i, share := range c.Shares {
1813- hh := avail * share / 100
1823+ hh := avail * share / 10000
18141824 if i == n-1 {
18151825 hh = avail - used
18161826 }
@@ -1824,7 +1834,7 @@ func rowRectsInto(dst []Rect, r Rect, c Column, gap int) []Rect {
18241834 }
18251835
18261836 // validShares reports whether c.Shares has one share per window, each at
1827-// least 1, summing to 100.
1837+// least 1, summing to 10000.
18281838 func validShares(c Column) bool {
18291839 if len(c.Shares) != len(c.Windows) || len(c.Shares) < 2 {
18301840 return false
@@ -1836,7 +1846,7 @@ func validShares(c Column) bool {
18361846 }
18371847 sum += v
18381848 }
1839- return sum == 100
1849+ return sum == 10000
18401850 }
18411851
18421852 // split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
@@ -19,6 +19,9 @@ const maxCells = 16
19 // max-columns-th of the usable width. Zero (auto) shares the width equally19 // max-columns-th of the usable width. Zero (auto) shares the width equally
20 // with the other auto columns, up to MaxColumns visible at once.20 // with the other auto columns, up to MaxColumns visible at once.
21 type Column struct {21 type Column struct {
22+ // Span is a manually sized width in basis points; zero uses Cells.
23+ Span int
24+ spanStamp uint64
22 Windows []WindowID25 Windows []WindowID
23 Cells int26 Cells int
24 FullWidth bool27 FullWidth bool
@@ -26,9 +29,9 @@ type Column struct {
26 // Slot is the declared column number (workspace.<name>.column.N) of a29 // Slot is the declared column number (workspace.<name>.column.N) of a
27 // slot window; 0 for normal columns.30 // slot window; 0 for normal columns.
28 Slot int31 Slot int
29- // Shares are the row heights set by set-window-height, in percent, in32+ // Shares are the row heights set by set-window-height, in basis points, in
30 // Windows order. They apply only while there is one per window and33 // Windows order. They apply only while there is one per window and
31- // they sum to 100; otherwise the rows share the height equally.34+ // they sum to 10000; otherwise the rows share the height equally.
32 Shares []int35 Shares []int
33 // BandStart marks the first column of a cascade band. It belongs to the36 // BandStart marks the first column of a cascade band. It belongs to the
34 // position, not the column: swaps leave it in place. See cascade.go.37 // position, not the column: swaps leave it in place. See cascade.go.
@@ -105,6 +108,8 @@ type Workspace struct {
105 overviewAfter *Workspace108 overviewAfter *Workspace
106 Overflow Overflow109 Overflow Overflow
107 Columns []Column110 Columns []Column
111+ // spanSeq issues workspace-local identities for complete lane stamps.
112+ spanSeq uint64
108 // maximized remembers recently maximized columns by window ID, newest113 // maximized remembers recently maximized columns by window ID, newest
109 // first; stack() filters removed windows at read time.114 // first; stack() filters removed windows at read time.
110 maximized []WindowID115 maximized []WindowID
@@ -534,6 +539,7 @@ func (w *Workspace) addWindow(id WindowID, cells int) {
534 at = min(w.Focus+1, len(w.Columns))539 at = min(w.Focus+1, len(w.Columns))
535 }540 }
536 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})541 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})
542+ w.dropStaleSpans()
537 return543 return
538 }544 }
539 w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})545 w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})
@@ -542,6 +548,8 @@ func (w *Workspace) addWindow(id WindowID, cells int) {
542 // addColumn places a new column like a new window and focuses it.548 // addColumn places a new column like a new window and focuses it.
543 func (w *Workspace) addColumn(col Column) {549 func (w *Workspace) addColumn(col Column) {
544 at := 0550 at := 0
551+ // Stamp identities belong to the issuing workspace and complete lane.
552+ col.Span, col.spanStamp = 0, 0
545 col.BandStart = false553 col.BandStart = false
546 if w.policy().wraps {554 if w.policy().wraps {
547 // After the focused band: it joins it when it has room, else it555 // After the focused band: it joins it when it has room, else it
@@ -916,33 +924,33 @@ func (w *Workspace) MoveWindow(dir int) {
916 w.scroll()924 w.scroll()
917 }925 }
918 926
919-// minShare is the smallest row height set-window-height leaves, percent.927+// minShare is the smallest row height set-window-height leaves, in basis points.
920-const minShare = 10928+const minShare = 1000
921 929
922 // ResizeRow changes the focused window's height share by pct percent; the930 // ResizeRow changes the focused window's height share by pct percent; the
923 // other rows of its column give or take it in proportion to their shares.931 // other rows of its column give or take it in proportion to their shares.
924-// Every row keeps at least minShare percent.932+// Every row keeps at least minShare basis points.
925 func (w *Workspace) ResizeRow(pct int) {933 func (w *Workspace) ResizeRow(pct int) {
926 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {934 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
927 return935 return
928 }936 }
929 c := &w.Columns[w.Focus]937 c := &w.Columns[w.Focus]
930 n := len(c.Windows)938 n := len(c.Windows)
931- if n < 2 || n*minShare > 100 {939+ if n < 2 || n*minShare > 10000 {
932 return940 return
933 }941 }
934 shares := c.Shares942 shares := c.Shares
935 if !validShares(*c) {943 if !validShares(*c) {
936 shares = make([]int, n)944 shares = make([]int, n)
937 for i := range shares {945 for i := range shares {
938- shares[i] = 100 / n946+ shares[i] = 10000 / n
939 }947 }
940- shares[n-1] += 100 % n948+ shares[n-1] += 10000 % n
941 } else {949 } else {
942 shares = slices.Clone(shares)950 shares = slices.Clone(shares)
943 }951 }
944 f := c.Focus952 f := c.Focus
945- target := min(max(shares[f]+pct, minShare), 100-minShare*(n-1))953+ target := min(max(shares[f]+pct*100, minShare), 10000-minShare*(n-1))
946 delta := target - shares[f]954 delta := target - shares[f]
947 if delta == 0 {955 if delta == 0 {
948 return956 return
@@ -1033,6 +1041,8 @@ func (w *Workspace) insertColumn(at int, col Column) {
1033 // insertAt inserts col at index at; in a cascade it joins the band of the1041 // insertAt inserts col at index at; in a cascade it joins the band of the
1034 // column it goes before.1042 // column it goes before.
1035 func (w *Workspace) insertAt(at int, col Column) {1043 func (w *Workspace) insertAt(at int, col Column) {
1044+ // Stamp identities belong to the issuing workspace and complete lane.
1045+ col.Span, col.spanStamp = 0, 0
1036 col.BandStart = false1046 col.BandStart = false
1037 if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {1047 if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {
1038 col.BandStart, w.Columns[at].BandStart = true, false1048 col.BandStart, w.Columns[at].BandStart = true, false
@@ -1051,6 +1061,7 @@ func (w *Workspace) GrowColumn(cycle bool) {
1051 return1061 return
1052 }1062 }
1053 defer w.scroll()1063 defer w.scroll()
1064+ w.clearSpans(w.Focus)
1054 if w.policy().wraps && w.Columns[w.Focus].FullWidth {1065 if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1055 w.unmaximizeBand()1066 w.unmaximizeBand()
1056 }1067 }
@@ -1120,6 +1131,7 @@ func (w *Workspace) ShrinkColumn() {
1120 return1131 return
1121 }1132 }
1122 defer w.scroll()1133 defer w.scroll()
1134+ w.clearSpans(w.Focus)
1123 if w.policy().wraps && w.Columns[w.Focus].FullWidth {1135 if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1124 w.unmaximizeBand()1136 w.unmaximizeBand()
1125 }1137 }
@@ -1667,6 +1679,9 @@ func (w *Workspace) scrollWidth(i int) int {
1667 // Fixed overflow past MaxColumns: the spiral's slots.1679 // Fixed overflow past MaxColumns: the spiral's slots.
1668 return w.equalShare(k)1680 return w.equalShare(k)
1669 }1681 }
1682+ if w.validSpans(0, len(w.Columns)-1) {
1683+ return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000
1684+ }
1670 c := w.Columns[i].Cells1685 c := w.Columns[i].Cells
1671 if c <= 0 {1686 if c <= 0 {
1672 return w.equalShare(min(len(w.Columns), k))1687 return w.equalShare(min(len(w.Columns), k))
@@ -1682,6 +1697,7 @@ func (w *Workspace) columnX(i int) int {
1682 return x1697 return x
1683 }1698 }
1684 func (w *Workspace) scroll() {1699 func (w *Workspace) scroll() {
1700+ w.dropStaleSpans()
1685 before := w.View1701 before := w.View
1686 defer func() { w.retarget(before) }()1702 defer func() { w.retarget(before) }()
1687 if w.policy().wraps {1703 if w.policy().wraps {
@@ -1726,19 +1742,13 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1726 // when too small): the result aliases dst, so a caller passing a reused1742 // when too small): the result aliases dst, so a caller passing a reused
1727 // buffer must be done with the previous result.1743 // buffer must be done with the previous result.
1728 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {1744 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1729- return w.columnRectsAt(dst, ignoreFullWidth, w.shiftPixels())
1730-}
1731-
1732-// columnRectsAt is columnRectsInto with the view slide's shift given: 0 is
1733-// the settled geometry, without the animation's presentation offset.
1734-func (w *Workspace) columnRectsAt(dst []Rect, ignoreFullWidth bool, shift int) []Rect {
1735 if w.policy().wraps {1745 if w.policy().wraps {
1736- return w.cascadeRectsAt(dst, ignoreFullWidth, shift)1746+ return w.cascadeRectsInto(dst, ignoreFullWidth)
1737 }1747 }
1738 g := w.gap()1748 g := w.gap()
1739 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)1749 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
1740 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]1750 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
1741- view := w.View + shift1751+ view := w.View + w.shiftPixels()
1742 x := w.Usable.X + g1752 x := w.Usable.X + g
1743 for i := range w.Columns {1753 for i := range w.Columns {
1744 width := w.columnWidthFor(i, ignoreFullWidth)1754 width := w.columnWidthFor(i, ignoreFullWidth)
@@ -1810,7 +1820,7 @@ func rowRectsInto(dst []Rect, r Rect, c Column, gap int) []Rect {
1810 avail := max(r.H-(n-1)*gap, 0)1820 avail := max(r.H-(n-1)*gap, 0)
1811 yy, bottom, used := r.Y, r.Y+r.H, 01821 yy, bottom, used := r.Y, r.Y+r.H, 0
1812 for i, share := range c.Shares {1822 for i, share := range c.Shares {
1813- hh := avail * share / 1001823+ hh := avail * share / 10000
1814 if i == n-1 {1824 if i == n-1 {
1815 hh = avail - used1825 hh = avail - used
1816 }1826 }
@@ -1824,7 +1834,7 @@ func rowRectsInto(dst []Rect, r Rect, c Column, gap int) []Rect {
1824 }1834 }
1825 1835
1826 // validShares reports whether c.Shares has one share per window, each at1836 // validShares reports whether c.Shares has one share per window, each at
1827-// least 1, summing to 100.1837+// least 1, summing to 10000.
1828 func validShares(c Column) bool {1838 func validShares(c Column) bool {
1829 if len(c.Shares) != len(c.Windows) || len(c.Shares) < 2 {1839 if len(c.Shares) != len(c.Windows) || len(c.Shares) < 2 {
1830 return false1840 return false
@@ -1836,7 +1846,7 @@ func validShares(c Column) bool {
1836 }1846 }
1837 sum += v1847 sum += v
1838 }1848 }
1839- return sum == 1001849+ return sum == 10000
1840 }1850 }
1841 1851
1842 // split cuts r in two halves with a gap: top/bottom when vertical, else left/right.1852 // split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
M internal/core/layout_buffers_test.go +2 -2
@@ -39,7 +39,7 @@ func layoutVariants(t *testing.T) map[string]*Monitor {
3939 m = base(OverflowScroll, 4)
4040 w := m.Current()
4141 w.Columns[0].Windows = []WindowID{1, 5, 6}
42- w.Columns[0].Shares = []int{50, 30, 20}
42+ w.Columns[0].Shares = []int{5000, 3000, 2000}
4343 m.AddWindow(5)
4444 v["shares"] = m
4545
@@ -204,7 +204,7 @@ func TestRectHelpersKeepAppendSemantics(t *testing.T) {
204204 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], stackRects(r, 3, 5)) {
205205 t.Fatalf("stackRectsInto: %+v", got)
206206 }
207- c := Column{Windows: []WindowID{1, 2, 3}, Shares: []int{50, 30, 20}}
207+ c := Column{Windows: []WindowID{1, 2, 3}, Shares: []int{5000, 3000, 2000}}
208208 got = rowRectsInto(slices.Clone(pre), r, c, 5)
209209 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], rowRects(r, c, 5)) {
210210 t.Fatalf("rowRectsInto: %+v", got)
@@ -39,7 +39,7 @@ func layoutVariants(t *testing.T) map[string]*Monitor {
39 m = base(OverflowScroll, 4)39 m = base(OverflowScroll, 4)
40 w := m.Current()40 w := m.Current()
41 w.Columns[0].Windows = []WindowID{1, 5, 6}41 w.Columns[0].Windows = []WindowID{1, 5, 6}
42- w.Columns[0].Shares = []int{50, 30, 20}42+ w.Columns[0].Shares = []int{5000, 3000, 2000}
43 m.AddWindow(5)43 m.AddWindow(5)
44 v["shares"] = m44 v["shares"] = m
45 45
@@ -204,7 +204,7 @@ func TestRectHelpersKeepAppendSemantics(t *testing.T) {
204 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], stackRects(r, 3, 5)) {204 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], stackRects(r, 3, 5)) {
205 t.Fatalf("stackRectsInto: %+v", got)205 t.Fatalf("stackRectsInto: %+v", got)
206 }206 }
207- c := Column{Windows: []WindowID{1, 2, 3}, Shares: []int{50, 30, 20}}207+ c := Column{Windows: []WindowID{1, 2, 3}, Shares: []int{5000, 3000, 2000}}
208 got = rowRectsInto(slices.Clone(pre), r, c, 5)208 got = rowRectsInto(slices.Clone(pre), r, c, 5)
209 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], rowRects(r, c, 5)) {209 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], rowRects(r, c, 5)) {
210 t.Fatalf("rowRectsInto: %+v", got)210 t.Fatalf("rowRectsInto: %+v", got)
M internal/core/layout_policy.go +58 -0
M internal/core/layout_test.go +9 -5
A internal/core/pointergrab.go +95 -0
A internal/core/pointergrab_test.go +291 -0
M internal/core/publish_alloc_test.go +1 -1
M internal/core/row_shares_test.go +17 -17
M internal/core/screens_test.go +0 -6
M internal/core/security.go +3 -0
A internal/core/spans_test.go +203 -0
M internal/core/state.go +0 -55
D internal/core/state_geometry_test.go +0 -91
M internal/core/switcher.go +1 -1
M internal/ports/client_commands.go +7 -0
M internal/ports/state.go +0 -26

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