detached 7d665d6

feat(input): place the cursor on rotated outputs

commit
7d665d68416865f1eae33c46c128a7f17f0b0a92
author
brice · · 2026-10-03 18:44 UTC
parent
0868cc3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +50 -4
M internal/adapters/headlessinput/input.go +2 -1
@@ -161,7 +161,8 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap,
161161 y, ey := strconv.ParseFloat(fields[2], 64)
162162 if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {
163163 if o, ok := layout.At(x, y); ok && moveCursor != nil {
164- moveCursor(o.Info.Name, (x-float64(o.X))*o.Scale, (y-float64(o.Y))*o.Scale)
164+ tx, ty := o.ToTarget(x, y)
165+ moveCursor(o.Info.Name, tx, ty)
165166 }
166167 if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {
167168 return err
@@ -161,7 +161,8 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap,
161 y, ey := strconv.ParseFloat(fields[2], 64)161 y, ey := strconv.ParseFloat(fields[2], 64)
162 if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {162 if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {
163 if o, ok := layout.At(x, y); ok && moveCursor != nil {163 if o, ok := layout.At(x, y); ok && moveCursor != nil {
164- moveCursor(o.Info.Name, (x-float64(o.X))*o.Scale, (y-float64(o.Y))*o.Scale)164+ tx, ty := o.ToTarget(x, y)
165+ moveCursor(o.Info.Name, tx, ty)
165 }166 }
166 if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {167 if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {
167 return err168 return err
M internal/adapters/headlessinput/input_test.go +30 -0
@@ -105,3 +105,33 @@ func TestPointerScript(t *testing.T) {
105105 t.Fatal(u)
106106 }
107107 }
108+
109+// The cursor is placed in target pixels: after the output transform.
110+func TestMoveCursorTarget(t *testing.T) {
111+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
112+ if err != nil {
113+ t.Skip(err)
114+ }
115+ layouts := make(chan ports.Layout)
116+ script := make(chan string)
117+ input := make(chan ports.InputEvent, 4)
118+ type pos struct {
119+ out string
120+ x, y float64
121+ }
122+ moved := make(chan pos, 1)
123+ done := make(chan error, 1)
124+ go func() {
125+ done <- Run(context.Background(), km, nil, script, input, layouts, func(o string, x, y float64) { moved <- pos{o, x, y} }, logging.For(context.Background(), "input"))
126+ }()
127+ // Unbuffered: the layout is taken before the move is read.
128+ layouts <- ports.Layout{{Info: ports.OutputInfo{Name: "A", Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}}
129+ script <- "move 10 20"
130+ if got, want := <-moved, (pos{"A", 20, 90}); got != want {
131+ t.Fatalf("got %+v want %+v", got, want)
132+ }
133+ close(script)
134+ if err := <-done; err != nil {
135+ t.Fatal(err)
136+ }
137+}
@@ -105,3 +105,33 @@ func TestPointerScript(t *testing.T) {
105 t.Fatal(u)105 t.Fatal(u)
106 }106 }
107 }107 }
108+
109+// The cursor is placed in target pixels: after the output transform.
110+func TestMoveCursorTarget(t *testing.T) {
111+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
112+ if err != nil {
113+ t.Skip(err)
114+ }
115+ layouts := make(chan ports.Layout)
116+ script := make(chan string)
117+ input := make(chan ports.InputEvent, 4)
118+ type pos struct {
119+ out string
120+ x, y float64
121+ }
122+ moved := make(chan pos, 1)
123+ done := make(chan error, 1)
124+ go func() {
125+ done <- Run(context.Background(), km, nil, script, input, layouts, func(o string, x, y float64) { moved <- pos{o, x, y} }, logging.For(context.Background(), "input"))
126+ }()
127+ // Unbuffered: the layout is taken before the move is read.
128+ layouts <- ports.Layout{{Info: ports.OutputInfo{Name: "A", Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}}
129+ script <- "move 10 20"
130+ if got, want := <-moved, (pos{"A", 20, 90}); got != want {
131+ t.Fatalf("got %+v want %+v", got, want)
132+ }
133+ close(script)
134+ if err := <-done; err != nil {
135+ t.Fatal(err)
136+ }
137+}
M internal/adapters/libinput/libinput.go +4 -2
@@ -775,10 +775,12 @@ func (p *pointer) bounds() ports.Rect {
775775 return r
776776 }
777777
778-// moved reports the physical position on the output under the pointer;
778+// moved reports the target position (physical pixels after the output
779+// transform) on the output under the pointer;
779780 // motion tells the device moved it.
780781 func (p *pointer) moved(move func(output string, x, y float64, motion bool), motion bool) {
781782 if o, ok := p.layout.At(p.x, p.y); ok && move != nil {
782- move(o.Info.Name, (p.x-float64(o.X))*o.Scale, (p.y-float64(o.Y))*o.Scale, motion)
783+ tx, ty := o.ToTarget(p.x, p.y)
784+ move(o.Info.Name, tx, ty, motion)
783785 }
784786 }
@@ -775,10 +775,12 @@ func (p *pointer) bounds() ports.Rect {
775 return r775 return r
776 }776 }
777 777
778-// moved reports the physical position on the output under the pointer;778+// moved reports the target position (physical pixels after the output
779+// transform) on the output under the pointer;
779 // motion tells the device moved it.780 // motion tells the device moved it.
780 func (p *pointer) moved(move func(output string, x, y float64, motion bool), motion bool) {781 func (p *pointer) moved(move func(output string, x, y float64, motion bool), motion bool) {
781 if o, ok := p.layout.At(p.x, p.y); ok && move != nil {782 if o, ok := p.layout.At(p.x, p.y); ok && move != nil {
782- move(o.Info.Name, (p.x-float64(o.X))*o.Scale, (p.y-float64(o.Y))*o.Scale, motion)783+ tx, ty := o.ToTarget(p.x, p.y)
784+ move(o.Info.Name, tx, ty, motion)
783 }785 }
784 }786 }
M internal/adapters/libinput/libinput_test.go +12 -0
@@ -337,3 +337,15 @@ func TestKeyboardProducerQuarantinesBeforeHotkeysAndLogging(t *testing.T) {
337337 t.Fatalf("quarantined release logged: %s", &buf)
338338 }
339339 }
340+
341+// The cursor is placed in target pixels: after the output transform.
342+func TestPointerMovedTransform(t *testing.T) {
343+ p := newPointer(ports.Layout{{Info: ports.OutputInfo{Name: "A", Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}})
344+ p.set(10, 20)
345+ var out string
346+ var px, py float64
347+ p.moved(func(o string, x, y float64, _ bool) { out, px, py = o, x, y }, true)
348+ if out != "A" || px != 20 || py != 90 {
349+ t.Fatal(out, px, py)
350+ }
351+}
@@ -337,3 +337,15 @@ func TestKeyboardProducerQuarantinesBeforeHotkeysAndLogging(t *testing.T) {
337 t.Fatalf("quarantined release logged: %s", &buf)337 t.Fatalf("quarantined release logged: %s", &buf)
338 }338 }
339 }339 }
340+
341+// The cursor is placed in target pixels: after the output transform.
342+func TestPointerMovedTransform(t *testing.T) {
343+ p := newPointer(ports.Layout{{Info: ports.OutputInfo{Name: "A", Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}})
344+ p.set(10, 20)
345+ var out string
346+ var px, py float64
347+ p.moved(func(o string, x, y float64, _ bool) { out, px, py = o, x, y }, true)
348+ if out != "A" || px != 20 || py != 90 {
349+ t.Fatal(out, px, py)
350+ }
351+}
M internal/app/outputs.go +2 -1
@@ -414,7 +414,8 @@ func (c *cursors) set(output string, cur cursor) {
414414 }
415415 }
416416
417-// move places the cursor on output at physical (x, y). motion is false
417+// move places the cursor on output at target pixels (x, y): physical, after
418+// the output transform. motion is false
418419 // when the pointer is only placed again (layout or constraint change): it
419420 // neither shows an idle-hidden cursor nor delays the hide.
420421 func (c *cursors) move(output string, x, y float64, motion bool) {
@@ -414,7 +414,8 @@ func (c *cursors) set(output string, cur cursor) {
414 }414 }
415 }415 }
416 416
417-// move places the cursor on output at physical (x, y). motion is false417+// move places the cursor on output at target pixels (x, y): physical, after
418+// the output transform. motion is false
418 // when the pointer is only placed again (layout or constraint change): it419 // when the pointer is only placed again (layout or constraint change): it
419 // neither shows an idle-hidden cursor nor delays the hide.420 // neither shows an idle-hidden cursor nor delays the hide.
420 func (c *cursors) move(output string, x, y float64, motion bool) {421 func (c *cursors) move(output string, x, y float64, motion bool) {

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