detached ca6770e

fix(core): clamp the pointer after outputs change

commit
ca6770ed6349afa33667643dadba6e16c32331dc
author
brice · · 2026-10-03 19:15 UTC
parent
2ee8a71
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +43 -0
M internal/core/core.go +3 -0
@@ -1184,6 +1184,7 @@ func (c *Core) Run(ctx context.Context) error {
11841184 if c.layerChanged {
11851185 c.setLayers(c.allLayers())
11861186 }
1187+ c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
11871188 case ports.OutputRemoved:
11881189 // A drop target may be on the output going away.
11891190 c.abortDrag()
@@ -1214,6 +1215,8 @@ func (c *Core) Run(ctx context.Context) error {
12141215 }
12151216 continue
12161217 }
1218+ // A rotation can shrink the layout under a still pointer.
1219+ c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
12171220 if c.workspaceVisible(ctx, false) != nil {
12181221 return nil
12191222 }
@@ -1184,6 +1184,7 @@ func (c *Core) Run(ctx context.Context) error {
1184 if c.layerChanged {1184 if c.layerChanged {
1185 c.setLayers(c.allLayers())1185 c.setLayers(c.allLayers())
1186 }1186 }
1187+ c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
1187 case ports.OutputRemoved:1188 case ports.OutputRemoved:
1188 // A drop target may be on the output going away.1189 // A drop target may be on the output going away.
1189 c.abortDrag()1190 c.abortDrag()
@@ -1214,6 +1215,8 @@ func (c *Core) Run(ctx context.Context) error {
1214 }1215 }
1215 continue1216 continue
1216 }1217 }
1218+ // A rotation can shrink the layout under a still pointer.
1219+ c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
1217 if c.workspaceVisible(ctx, false) != nil {1220 if c.workspaceVisible(ctx, false) != nil {
1218 return nil1221 return nil
1219 }1222 }
M internal/core/screens_test.go +40 -0
@@ -1426,6 +1426,9 @@ func TestRotatedOutputLayout(t *testing.T) {
14261426 }
14271427 r.mapWindow(t, 1)
14281428 set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1429+ if len(set) != 2 || set[0].Output != "DP-1" || set[1].Output != "DP-2" {
1430+ t.Fatalf("scenes: %+v", set)
1431+ }
14291432 for _, s := range set {
14301433 switch s.Output {
14311434 case "DP-1":
@@ -1503,3 +1506,40 @@ func TestTransformReload(t *testing.T) {
15031506 t.Fatalf("DP-1: %+v", o)
15041507 }
15051508 }
1509+
1510+// A reload that shrinks the layout under a still pointer brings it back onto
1511+// an output.
1512+func TestTransformReloadClampsPointer(t *testing.T) {
1513+ r := startMulti(t, func(c *ports.Config) {
1514+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}}
1515+ }, left, right)
1516+ r.mapWindow(t, 1)
1517+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1518+ r.mapWindow(t, 2)
1519+ // DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2.
1520+ for len(r.commands) > 0 {
1521+ <-r.commands
1522+ }
1523+ r.input <- ports.PointerMotion{X: 550, Y: 50}
1524+ if v := command(t, r.commands); v != (ports.PointerFocus{ID: 2, X: 350, Y: 50}) {
1525+ t.Fatal(v)
1526+ }
1527+ // Rotating DP-1 moves DP-2 to x 100..500: 550 is off every output.
1528+ r.cfg.Outputs[0].Transform = 1
1529+ r.reload <- ports.ConfigChanged{Config: r.cfg}
1530+ receive(t, r.scenes)
1531+ for len(r.commands) > 0 {
1532+ <-r.commands
1533+ }
1534+ // A rehit (here from a swipe end) points at what lies under the pointer:
1535+ // clamped onto DP-1's right edge, window 1, not nothing.
1536+ r.input <- ports.SwipeEnd{}
1537+ for {
1538+ if v, ok := command(t, r.commands).(ports.PointerFocus); ok {
1539+ if v.ID != 1 {
1540+ t.Fatalf("pointer after reload: %+v", v)
1541+ }
1542+ break
1543+ }
1544+ }
1545+}
@@ -1426,6 +1426,9 @@ func TestRotatedOutputLayout(t *testing.T) {
1426 }1426 }
1427 r.mapWindow(t, 1)1427 r.mapWindow(t, 1)
1428 set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl)1428 set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1429+ if len(set) != 2 || set[0].Output != "DP-1" || set[1].Output != "DP-2" {
1430+ t.Fatalf("scenes: %+v", set)
1431+ }
1429 for _, s := range set {1432 for _, s := range set {
1430 switch s.Output {1433 switch s.Output {
1431 case "DP-1":1434 case "DP-1":
@@ -1503,3 +1506,40 @@ func TestTransformReload(t *testing.T) {
1503 t.Fatalf("DP-1: %+v", o)1506 t.Fatalf("DP-1: %+v", o)
1504 }1507 }
1505 }1508 }
1509+
1510+// A reload that shrinks the layout under a still pointer brings it back onto
1511+// an output.
1512+func TestTransformReloadClampsPointer(t *testing.T) {
1513+ r := startMulti(t, func(c *ports.Config) {
1514+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}}
1515+ }, left, right)
1516+ r.mapWindow(t, 1)
1517+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1518+ r.mapWindow(t, 2)
1519+ // DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2.
1520+ for len(r.commands) > 0 {
1521+ <-r.commands
1522+ }
1523+ r.input <- ports.PointerMotion{X: 550, Y: 50}
1524+ if v := command(t, r.commands); v != (ports.PointerFocus{ID: 2, X: 350, Y: 50}) {
1525+ t.Fatal(v)
1526+ }
1527+ // Rotating DP-1 moves DP-2 to x 100..500: 550 is off every output.
1528+ r.cfg.Outputs[0].Transform = 1
1529+ r.reload <- ports.ConfigChanged{Config: r.cfg}
1530+ receive(t, r.scenes)
1531+ for len(r.commands) > 0 {
1532+ <-r.commands
1533+ }
1534+ // A rehit (here from a swipe end) points at what lies under the pointer:
1535+ // clamped onto DP-1's right edge, window 1, not nothing.
1536+ r.input <- ports.SwipeEnd{}
1537+ for {
1538+ if v, ok := command(t, r.commands).(ports.PointerFocus); ok {
1539+ if v.ID != 1 {
1540+ t.Fatalf("pointer after reload: %+v", v)
1541+ }
1542+ break
1543+ }
1544+ }
1545+}

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close