detached 356dacb

fix(outputs): keyboard screen focus sticks, terminal respawn backoff, stale terminal tokens expire

commit
356dacbd114f1b01e159d08bf752ed5ed511cefe
author
brice · · 2026-09-25 05:09 UTC
parent
099f719
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +63 -36
M internal/core/action.go +1 -1
@@ -126,7 +126,7 @@ func (c *Core) applyAction(a Action) Effect {
126126 if i := c.neighbor(dir); edge && i >= 0 {
127127 // The last column stays: a workspace on screen is never
128128 // empty, and moving it would only spawn a new terminal.
129- if len(w.Columns) < 2 {
129+ if len(w.Columns) < 2 && c.keepsTerminal() {
130130 return Effect{}
131131 }
132132 col, ok := w.takeColumn()
@@ -126,7 +126,7 @@ func (c *Core) applyAction(a Action) Effect {
126 if i := c.neighbor(dir); edge && i >= 0 {126 if i := c.neighbor(dir); edge && i >= 0 {
127 // The last column stays: a workspace on screen is never127 // The last column stays: a workspace on screen is never
128 // empty, and moving it would only spawn a new terminal.128 // empty, and moving it would only spawn a new terminal.
129- if len(w.Columns) < 2 {129+ if len(w.Columns) < 2 && c.keepsTerminal() {
130 return Effect{}130 return Effect{}
131 }131 }
132 col, ok := w.takeColumn()132 col, ok := w.takeColumn()
M internal/core/core.go +10 -4
@@ -49,6 +49,7 @@ type Core struct {
4949 grab WindowID
5050 buttons map[uint32]bool
5151 cursorX, cursorY float64 // global, logical
52+ pointerOutput string // output under the pointer at the last motion
5253 seq uint64
5354 // layerChanged is set once layer state arrives from wayland.
5455 layerChanged bool
@@ -549,10 +550,15 @@ func (c *Core) Run(ctx context.Context) error {
549550 c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)
550551 // The focused screen follows the pointer, so new windows
551552 // and launchers open where the user is.
552- if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.cur().name() {
553- c.focusScreen = c.screenIndex(o.Info.Name)
554- if err := c.publish(ctx); err != nil {
555- return nil
553+ // Only a pointer entering another output switches: keyboard
554+ // moves to another screen stick until then. Not mid-drag.
555+ if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.pointerOutput && c.grab == 0 {
556+ c.pointerOutput = o.Info.Name
557+ if o.Info.Name != c.cur().name() {
558+ c.focusScreen = c.screenIndex(o.Info.Name)
559+ if err := c.publish(ctx); err != nil {
560+ return nil
561+ }
556562 }
557563 }
558564 id, x, y := c.hit(c.cursorX, c.cursorY)
@@ -49,6 +49,7 @@ type Core struct {
49 grab WindowID49 grab WindowID
50 buttons map[uint32]bool50 buttons map[uint32]bool
51 cursorX, cursorY float64 // global, logical51 cursorX, cursorY float64 // global, logical
52+ pointerOutput string // output under the pointer at the last motion
52 seq uint6453 seq uint64
53 // layerChanged is set once layer state arrives from wayland.54 // layerChanged is set once layer state arrives from wayland.
54 layerChanged bool55 layerChanged bool
@@ -549,10 +550,15 @@ func (c *Core) Run(ctx context.Context) error {
549 c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)550 c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)
550 // The focused screen follows the pointer, so new windows551 // The focused screen follows the pointer, so new windows
551 // and launchers open where the user is.552 // and launchers open where the user is.
552- if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.cur().name() {553+ // Only a pointer entering another output switches: keyboard
553- c.focusScreen = c.screenIndex(o.Info.Name)554+ // moves to another screen stick until then. Not mid-drag.
554- if err := c.publish(ctx); err != nil {555+ if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.pointerOutput && c.grab == 0 {
555- return nil556+ c.pointerOutput = o.Info.Name
557+ if o.Info.Name != c.cur().name() {
558+ c.focusScreen = c.screenIndex(o.Info.Name)
559+ if err := c.publish(ctx); err != nil {
560+ return nil
561+ }
556 }562 }
557 }563 }
558 id, x, y := c.hit(c.cursorX, c.cursorY)564 id, x, y := c.hit(c.cursorX, c.cursorY)
M internal/core/layout.go +3 -0
@@ -7,6 +7,7 @@ import (
77 "slices"
88 "strconv"
99 "strings"
10+ "time"
1011 )
1112
1213 type WindowID = ports.WindowID
@@ -125,6 +126,8 @@ type Workspace struct {
125126 // its position there, where it returns.
126127 home string
127128 homePos int
129+ // termAt is when core last spawned a terminal for this workspace.
130+ termAt time.Time
128131 }
129132
130133 func (w *Workspace) empty() bool { return len(w.Columns) == 0 }
@@ -7,6 +7,7 @@ import (
7 "slices"7 "slices"
8 "strconv"8 "strconv"
9 "strings"9 "strings"
10+ "time"
10 )11 )
11 12
12 type WindowID = ports.WindowID13 type WindowID = ports.WindowID
@@ -125,6 +126,8 @@ type Workspace struct {
125 // its position there, where it returns.126 // its position there, where it returns.
126 home string127 home string
127 homePos int128 homePos int
129+ // termAt is when core last spawned a terminal for this workspace.
130+ termAt time.Time
128 }131 }
129 132
130 func (w *Workspace) empty() bool { return len(w.Columns) == 0 }133 func (w *Workspace) empty() bool { return len(w.Columns) == 0 }
M internal/core/screens.go +2 -1
@@ -99,7 +99,7 @@ func (c *Core) byName(name string) (*screen, *Workspace) {
9999 return nil, nil
100100 }
101101
102-// configScale is output.<name>.scale, else 1.
102+// isPrimary reports output.<name>.primary.
103103 func (c *Core) isPrimary(name string) bool {
104104 for _, o := range c.cfg.Outputs {
105105 if o.Name == name && o.Primary {
@@ -121,6 +121,7 @@ func (c *Core) anyWindow() bool {
121121 return false
122122 }
123123
124+// configScale is output.<name>.scale, else 1.
124125 func (c *Core) configScale(name string) float64 {
125126 v := 1.0
126127 for _, o := range c.cfg.Outputs {
@@ -99,7 +99,7 @@ func (c *Core) byName(name string) (*screen, *Workspace) {
99 return nil, nil99 return nil, nil
100 }100 }
101 101
102-// configScale is output.<name>.scale, else 1.102+// isPrimary reports output.<name>.primary.
103 func (c *Core) isPrimary(name string) bool {103 func (c *Core) isPrimary(name string) bool {
104 for _, o := range c.cfg.Outputs {104 for _, o := range c.cfg.Outputs {
105 if o.Name == name && o.Primary {105 if o.Name == name && o.Primary {
@@ -121,6 +121,7 @@ func (c *Core) anyWindow() bool {
121 return false121 return false
122 }122 }
123 123
124+// configScale is output.<name>.scale, else 1.
124 func (c *Core) configScale(name string) float64 {125 func (c *Core) configScale(name string) float64 {
125 v := 1.0126 v := 1.0
126 for _, o := range c.cfg.Outputs {127 for _, o := range c.cfg.Outputs {
M internal/core/screens_test.go +25 -14
@@ -361,14 +361,8 @@ func TestMoveColumnCrossesOutputs(t *testing.T) {
361361 if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
362362 t.Fatal(out.Focused)
363363 }
364- // It is DP-2's only column now: it stays. With a second one, the
365- // focused column goes back and lands on the right side of DP-1.
366- set = r.key(t, "Left", ports.ModAlt|ports.ModShift)
367- if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 1 {
368- t.Fatal(got)
369- }
370- r.mapWindow(t, 3)
371- r.key(t, "Left", ports.ModAlt) // focus window 2, the left column
364+ // Back left (no terminal kept here, so a last column may move): it
365+ // lands on the right side of DP-1.
372366 set = r.key(t, "Left", ports.ModAlt|ports.ModShift)
373367 if got := shown(set)["DP-1"]; len(got) != 2 {
374368 t.Fatal(got)
@@ -415,23 +409,40 @@ func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
415409 if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 1 || got["DP-1"][0] == got["DP-2"][0] {
416410 t.Fatal(got)
417411 }
418- // Closing the last window spawns a new terminal; nothing while pending.
412+ // A terminal that exits right after mapping is not respawned at once
413+ // (no loop for a broken terminal command).
419414 r.client <- ports.WindowUnmapped{ID: 2}
420415 receive(t, r.scenes)
421- if v := receive(t, r.spawn); v.Argv[0] != "foot" {
422- t.Fatal(v)
423- }
424416 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
425417 if len(r.spawn) != 0 {
426- t.Fatal("spawned twice")
418+ t.Fatal("respawned at once")
427419 }
428420 }
429421
430422 func TestLastColumnStaysOnItsOutput(t *testing.T) {
431- r := startMulti(t, nil, left, right)
423+ r := startRig(t, true, nil, left, right)
432424 r.mapWindow(t, 1)
433425 set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
434426 if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {
435427 t.Fatal(got)
436428 }
437429 }
430+
431+func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
432+ r := startMulti(t, nil, left, right)
433+ r.mapWindow(t, 1)
434+ r.input <- ports.PointerMotion{X: 10, Y: 10}
435+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
436+ // Jitter on DP-1 keeps DP-2 focused: the next window opens there.
437+ r.input <- ports.PointerMotion{X: 11, Y: 10}
438+ if got := shown(r.mapWindow(t, 2)); len(got["DP-2"]) != 1 {
439+ t.Fatal(got)
440+ }
441+ // Entering DP-1 again focuses it.
442+ r.input <- ports.PointerMotion{X: 250, Y: 10}
443+ r.input <- ports.PointerMotion{X: 10, Y: 10}
444+ receive(t, r.scenes)
445+ if got := shown(r.mapWindow(t, 3)); len(got["DP-1"]) != 2 {
446+ t.Fatal(got)
447+ }
448+}
@@ -361,14 +361,8 @@ func TestMoveColumnCrossesOutputs(t *testing.T) {
361 if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {361 if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
362 t.Fatal(out.Focused)362 t.Fatal(out.Focused)
363 }363 }
364- // It is DP-2's only column now: it stays. With a second one, the364+ // Back left (no terminal kept here, so a last column may move): it
365- // focused column goes back and lands on the right side of DP-1.365+ // lands on the right side of DP-1.
366- set = r.key(t, "Left", ports.ModAlt|ports.ModShift)
367- if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 1 {
368- t.Fatal(got)
369- }
370- r.mapWindow(t, 3)
371- r.key(t, "Left", ports.ModAlt) // focus window 2, the left column
372 set = r.key(t, "Left", ports.ModAlt|ports.ModShift)366 set = r.key(t, "Left", ports.ModAlt|ports.ModShift)
373 if got := shown(set)["DP-1"]; len(got) != 2 {367 if got := shown(set)["DP-1"]; len(got) != 2 {
374 t.Fatal(got)368 t.Fatal(got)
@@ -415,23 +409,40 @@ func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
415 if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 1 || got["DP-1"][0] == got["DP-2"][0] {409 if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 1 || got["DP-1"][0] == got["DP-2"][0] {
416 t.Fatal(got)410 t.Fatal(got)
417 }411 }
418- // Closing the last window spawns a new terminal; nothing while pending.412+ // A terminal that exits right after mapping is not respawned at once
413+ // (no loop for a broken terminal command).
419 r.client <- ports.WindowUnmapped{ID: 2}414 r.client <- ports.WindowUnmapped{ID: 2}
420 receive(t, r.scenes)415 receive(t, r.scenes)
421- if v := receive(t, r.spawn); v.Argv[0] != "foot" {
422- t.Fatal(v)
423- }
424 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)416 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
425 if len(r.spawn) != 0 {417 if len(r.spawn) != 0 {
426- t.Fatal("spawned twice")418+ t.Fatal("respawned at once")
427 }419 }
428 }420 }
429 421
430 func TestLastColumnStaysOnItsOutput(t *testing.T) {422 func TestLastColumnStaysOnItsOutput(t *testing.T) {
431- r := startMulti(t, nil, left, right)423+ r := startRig(t, true, nil, left, right)
432 r.mapWindow(t, 1)424 r.mapWindow(t, 1)
433 set := r.key(t, "Right", ports.ModAlt|ports.ModShift)425 set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
434 if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {426 if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {
435 t.Fatal(got)427 t.Fatal(got)
436 }428 }
437 }429 }
430+
431+func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
432+ r := startMulti(t, nil, left, right)
433+ r.mapWindow(t, 1)
434+ r.input <- ports.PointerMotion{X: 10, Y: 10}
435+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
436+ // Jitter on DP-1 keeps DP-2 focused: the next window opens there.
437+ r.input <- ports.PointerMotion{X: 11, Y: 10}
438+ if got := shown(r.mapWindow(t, 2)); len(got["DP-2"]) != 1 {
439+ t.Fatal(got)
440+ }
441+ // Entering DP-1 again focuses it.
442+ r.input <- ports.PointerMotion{X: 250, Y: 10}
443+ r.input <- ports.PointerMotion{X: 10, Y: 10}
444+ receive(t, r.scenes)
445+ if got := shown(r.mapWindow(t, 3)); len(got["DP-1"]) != 2 {
446+ t.Fatal(got)
447+ }
448+}
M internal/core/terminal.go +22 -16
@@ -12,8 +12,8 @@ import (
1212 // one is, core spawns the configured terminal with a SlotEnv token, and the
1313 // window carrying it fills that workspace, wherever the focus is by then.
1414
15-// termRetry is how long a terminal may take to map before another is spawned
16-// (the first one failed, or the terminal command is broken).
15+// termRetry is the least time between two terminals for one workspace: a
16+// terminal that fails, or maps and exits at once, is not respawned in a loop.
1717 const termRetry = 5 * time.Second
1818
1919 type termSpawn struct {
@@ -21,39 +21,45 @@ type termSpawn struct {
2121 at time.Time
2222 }
2323
24+// keepsTerminal reports whether empty workspaces get a terminal.
25+func (c *Core) keepsTerminal() bool { return c.ch.Terminal && len(c.cfg.Terminal.Command) > 0 }
26+
2427 // fillEmpty returns the terminals to spawn for empty workspaces on screen.
2528 // Workspaces with slots are left to them.
2629 func (c *Core) fillEmpty() []ports.SpawnRequest {
27- if !c.ch.Terminal || len(c.cfg.Terminal.Command) == 0 {
30+ now := time.Now()
31+ // Expired spawns go first, whatever is on screen: pending tokens keep
32+ // wayland reading /proc on every map.
33+ for token, t := range c.terms {
34+ if now.Sub(t.at) > termRetry {
35+ delete(c.terms, token)
36+ }
37+ }
38+ if !c.keepsTerminal() {
2839 return nil
2940 }
30- now := time.Now()
3141 var reqs []ports.SpawnRequest
3242 for _, s := range c.screens {
3343 w := s.mon.Current()
34- if s.name() == "" || !w.empty() || c.hasSlots(w) || c.termPending(w, now) {
44+ if s.name() == "" || !w.empty() || c.hasSlots(w) || c.termPending(w) || now.Sub(w.termAt) < termRetry {
3545 continue
3646 }
3747 token := rand.Text()
48+ w.termAt = now
3849 c.terms[token] = &termSpawn{w: w, at: now}
3950 reqs = append(reqs, ports.SpawnRequest{Argv: slices.Clone(c.cfg.Terminal.Command), Env: []string{ports.SlotEnv + "=" + token}})
4051 }
4152 return reqs
4253 }
4354
44-// termPending reports whether a recent terminal spawn waits for w; it drops
45-// expired ones.
46-func (c *Core) termPending(w *Workspace, now time.Time) bool {
47- found := false
48- for token, t := range c.terms {
49- switch {
50- case now.Sub(t.at) > termRetry:
51- delete(c.terms, token)
52- case t.w == w:
53- found = true
55+// termPending reports whether a terminal spawn waits for w.
56+func (c *Core) termPending(w *Workspace) bool {
57+ for _, t := range c.terms {
58+ if t.w == w {
59+ return true
5460 }
5561 }
56- return found
62+ return false
5763 }
5864
5965 func (c *Core) hasSlots(w *Workspace) bool {
@@ -12,8 +12,8 @@ import (
12 // one is, core spawns the configured terminal with a SlotEnv token, and the12 // one is, core spawns the configured terminal with a SlotEnv token, and the
13 // window carrying it fills that workspace, wherever the focus is by then.13 // window carrying it fills that workspace, wherever the focus is by then.
14 14
15-// termRetry is how long a terminal may take to map before another is spawned15+// termRetry is the least time between two terminals for one workspace: a
16-// (the first one failed, or the terminal command is broken).16+// terminal that fails, or maps and exits at once, is not respawned in a loop.
17 const termRetry = 5 * time.Second17 const termRetry = 5 * time.Second
18 18
19 type termSpawn struct {19 type termSpawn struct {
@@ -21,39 +21,45 @@ type termSpawn struct {
21 at time.Time21 at time.Time
22 }22 }
23 23
24+// keepsTerminal reports whether empty workspaces get a terminal.
25+func (c *Core) keepsTerminal() bool { return c.ch.Terminal && len(c.cfg.Terminal.Command) > 0 }
26+
24 // fillEmpty returns the terminals to spawn for empty workspaces on screen.27 // fillEmpty returns the terminals to spawn for empty workspaces on screen.
25 // Workspaces with slots are left to them.28 // Workspaces with slots are left to them.
26 func (c *Core) fillEmpty() []ports.SpawnRequest {29 func (c *Core) fillEmpty() []ports.SpawnRequest {
27- if !c.ch.Terminal || len(c.cfg.Terminal.Command) == 0 {30+ now := time.Now()
31+ // Expired spawns go first, whatever is on screen: pending tokens keep
32+ // wayland reading /proc on every map.
33+ for token, t := range c.terms {
34+ if now.Sub(t.at) > termRetry {
35+ delete(c.terms, token)
36+ }
37+ }
38+ if !c.keepsTerminal() {
28 return nil39 return nil
29 }40 }
30- now := time.Now()
31 var reqs []ports.SpawnRequest41 var reqs []ports.SpawnRequest
32 for _, s := range c.screens {42 for _, s := range c.screens {
33 w := s.mon.Current()43 w := s.mon.Current()
34- if s.name() == "" || !w.empty() || c.hasSlots(w) || c.termPending(w, now) {44+ if s.name() == "" || !w.empty() || c.hasSlots(w) || c.termPending(w) || now.Sub(w.termAt) < termRetry {
35 continue45 continue
36 }46 }
37 token := rand.Text()47 token := rand.Text()
48+ w.termAt = now
38 c.terms[token] = &termSpawn{w: w, at: now}49 c.terms[token] = &termSpawn{w: w, at: now}
39 reqs = append(reqs, ports.SpawnRequest{Argv: slices.Clone(c.cfg.Terminal.Command), Env: []string{ports.SlotEnv + "=" + token}})50 reqs = append(reqs, ports.SpawnRequest{Argv: slices.Clone(c.cfg.Terminal.Command), Env: []string{ports.SlotEnv + "=" + token}})
40 }51 }
41 return reqs52 return reqs
42 }53 }
43 54
44-// termPending reports whether a recent terminal spawn waits for w; it drops55+// termPending reports whether a terminal spawn waits for w.
45-// expired ones.56+func (c *Core) termPending(w *Workspace) bool {
46-func (c *Core) termPending(w *Workspace, now time.Time) bool {57+ for _, t := range c.terms {
47- found := false58+ if t.w == w {
48- for token, t := range c.terms {59+ return true
49- switch {
50- case now.Sub(t.at) > termRetry:
51- delete(c.terms, token)
52- case t.w == w:
53- found = true
54 }60 }
55 }61 }
56- return found62+ return false
57 }63 }
58 64
59 func (c *Core) hasSlots(w *Workspace) bool {65 func (c *Core) hasSlots(w *Workspace) 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 23

Tags 10

↑↓ moveenter openesc close