detached 6369b15

fix(core): rank screen entry against current workspace and frame edges

commit
6369b15c11f7b0f616250a1f8065f2d86577bacc
author
brice · · 2026-10-23 06:38 UTC
parent
15f8339
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +114 -38
M internal/core/action.go +0 -31
@@ -670,34 +670,3 @@ func moveFloat(cur, to *Workspace, id WindowID) {
670670 to.Activate(id)
671671 to.SetFullscreen(id, true)
672672 }
673-
674-// focusEntry selects the visible window nearest the edge entered from d.
675-// Layout order breaks ties; an empty viewport preserves the existing focus.
676-func (m *Monitor) focusEntry(d direction) {
677- view := m.viewport()
678- var id WindowID
679- best := 0
680- for _, p := range m.Layout() {
681- if !view.shows(p) {
682- continue
683- }
684- var distance int
685- area := view.area(p)
686- switch d {
687- case dirLeft:
688- distance = max(0, area.X+area.W-(p.Rect.X+p.Rect.W))
689- case dirRight:
690- distance = max(0, p.Rect.X-area.X)
691- case dirUp:
692- distance = max(0, area.Y+area.H-(p.Rect.Y+p.Rect.H))
693- case dirDown:
694- distance = max(0, p.Rect.Y-area.Y)
695- }
696- if id == 0 || distance < best {
697- id, best = p.ID, distance
698- }
699- }
700- if id != 0 {
701- m.Current().FocusID(id)
702- }
703-}
@@ -670,34 +670,3 @@ func moveFloat(cur, to *Workspace, id WindowID) {
670 to.Activate(id)670 to.Activate(id)
671 to.SetFullscreen(id, true)671 to.SetFullscreen(id, true)
672 }672 }
673-
674-// focusEntry selects the visible window nearest the edge entered from d.
675-// Layout order breaks ties; an empty viewport preserves the existing focus.
676-func (m *Monitor) focusEntry(d direction) {
677- view := m.viewport()
678- var id WindowID
679- best := 0
680- for _, p := range m.Layout() {
681- if !view.shows(p) {
682- continue
683- }
684- var distance int
685- area := view.area(p)
686- switch d {
687- case dirLeft:
688- distance = max(0, area.X+area.W-(p.Rect.X+p.Rect.W))
689- case dirRight:
690- distance = max(0, p.Rect.X-area.X)
691- case dirUp:
692- distance = max(0, area.Y+area.H-(p.Rect.Y+p.Rect.H))
693- case dirDown:
694- distance = max(0, p.Rect.Y-area.Y)
695- }
696- if id == 0 || distance < best {
697- id, best = p.ID, distance
698- }
699- }
700- if id != 0 {
701- m.Current().FocusID(id)
702- }
703-}
M internal/core/flash_test.go +10 -6
@@ -606,12 +606,13 @@ func TestFlashCrossScreenActionsMarkDestinationWindow(t *testing.T) {
606606 name string
607607 action Action
608608 occupied bool
609+ want WindowID
609610 }{
610- {"column focus to empty", ActionFocusColumnRight, false},
611- {"column focus to occupied", ActionFocusColumnRight, true},
612- {"monitor focus to empty", ActionFocusMonitorRight, false},
613- {"column move to empty", ActionMoveColumnRight, false},
614- {"column move to occupied", ActionMoveColumnRight, true},
611+ {"column focus to empty", ActionFocusColumnRight, false, 0},
612+ {"column focus to occupied", ActionFocusColumnRight, true, 4},
613+ {"monitor focus to empty", ActionFocusMonitorRight, false, 0},
614+ {"column move to empty", ActionMoveColumnRight, false, 3},
615+ {"column move to occupied", ActionMoveColumnRight, true, 3},
615616 } {
616617 t.Run(tc.name, func(t *testing.T) {
617618 c, clock := flashCore(t)
@@ -627,7 +628,10 @@ func TestFlashCrossScreenActionsMarkDestinationWindow(t *testing.T) {
627628 t.Fatal("action did not cross screens")
628629 }
629630 id, _ := b.mon.Focused()
630- if id == 0 {
631+ if id != tc.want {
632+ t.Fatalf("focus %d want %d", id, tc.want)
633+ }
634+ if tc.want == 0 {
631635 if c.flash.pending != nil {
632636 t.Fatalf("empty destination request %+v", c.flash.pending)
633637 }
@@ -606,12 +606,13 @@ func TestFlashCrossScreenActionsMarkDestinationWindow(t *testing.T) {
606 name string606 name string
607 action Action607 action Action
608 occupied bool608 occupied bool
609+ want WindowID
609 }{610 }{
610- {"column focus to empty", ActionFocusColumnRight, false},611+ {"column focus to empty", ActionFocusColumnRight, false, 0},
611- {"column focus to occupied", ActionFocusColumnRight, true},612+ {"column focus to occupied", ActionFocusColumnRight, true, 4},
612- {"monitor focus to empty", ActionFocusMonitorRight, false},613+ {"monitor focus to empty", ActionFocusMonitorRight, false, 0},
613- {"column move to empty", ActionMoveColumnRight, false},614+ {"column move to empty", ActionMoveColumnRight, false, 3},
614- {"column move to occupied", ActionMoveColumnRight, true},615+ {"column move to occupied", ActionMoveColumnRight, true, 3},
615 } {616 } {
616 t.Run(tc.name, func(t *testing.T) {617 t.Run(tc.name, func(t *testing.T) {
617 c, clock := flashCore(t)618 c, clock := flashCore(t)
@@ -627,7 +628,10 @@ func TestFlashCrossScreenActionsMarkDestinationWindow(t *testing.T) {
627 t.Fatal("action did not cross screens")628 t.Fatal("action did not cross screens")
628 }629 }
629 id, _ := b.mon.Focused()630 id, _ := b.mon.Focused()
630- if id == 0 {631+ if id != tc.want {
632+ t.Fatalf("focus %d want %d", id, tc.want)
633+ }
634+ if tc.want == 0 {
631 if c.flash.pending != nil {635 if c.flash.pending != nil {
632 t.Fatalf("empty destination request %+v", c.flash.pending)636 t.Fatalf("empty destination request %+v", c.flash.pending)
633 }637 }
M internal/core/monitor.go +36 -0
@@ -448,6 +448,42 @@ func (w *Workspace) receive(col Column, at int) {
448448 w.Activate(id)
449449 }
450450
451+// focusEntry selects the visible window nearest the edge entered from d.
452+// Layout order breaks ties; an empty viewport preserves the existing focus.
453+func (m *Monitor) focusEntry(d direction) {
454+ if m.ov.open || m.switcherShown() {
455+ return
456+ }
457+ w := m.Current()
458+ view := m.viewport()
459+ var id WindowID
460+ best := 0
461+ for _, p := range m.Layout() {
462+ if p.Preview != 0 || !w.has(p.ID) || !view.shows(p) {
463+ continue
464+ }
465+ var distance int
466+ clip := intersect(p.Rect, view.area(p))
467+ frame := view.frame
468+ switch d {
469+ case dirLeft:
470+ distance = frame.X + frame.W - (clip.X + clip.W)
471+ case dirRight:
472+ distance = clip.X - frame.X
473+ case dirUp:
474+ distance = frame.Y + frame.H - (clip.Y + clip.H)
475+ case dirDown:
476+ distance = clip.Y - frame.Y
477+ }
478+ if id == 0 || distance < best {
479+ id, best = p.ID, distance
480+ }
481+ }
482+ if id != 0 {
483+ w.FocusID(id)
484+ }
485+}
486+
451487 // Focused is the focused window of the workspace on screen; in the
452488 // overview, the selected stash card when it is one.
453489 func (m *Monitor) Focused() (WindowID, bool) {
@@ -448,6 +448,42 @@ func (w *Workspace) receive(col Column, at int) {
448 w.Activate(id)448 w.Activate(id)
449 }449 }
450 450
451+// focusEntry selects the visible window nearest the edge entered from d.
452+// Layout order breaks ties; an empty viewport preserves the existing focus.
453+func (m *Monitor) focusEntry(d direction) {
454+ if m.ov.open || m.switcherShown() {
455+ return
456+ }
457+ w := m.Current()
458+ view := m.viewport()
459+ var id WindowID
460+ best := 0
461+ for _, p := range m.Layout() {
462+ if p.Preview != 0 || !w.has(p.ID) || !view.shows(p) {
463+ continue
464+ }
465+ var distance int
466+ clip := intersect(p.Rect, view.area(p))
467+ frame := view.frame
468+ switch d {
469+ case dirLeft:
470+ distance = frame.X + frame.W - (clip.X + clip.W)
471+ case dirRight:
472+ distance = clip.X - frame.X
473+ case dirUp:
474+ distance = frame.Y + frame.H - (clip.Y + clip.H)
475+ case dirDown:
476+ distance = clip.Y - frame.Y
477+ }
478+ if id == 0 || distance < best {
479+ id, best = p.ID, distance
480+ }
481+ }
482+ if id != 0 {
483+ w.FocusID(id)
484+ }
485+}
486+
451 // Focused is the focused window of the workspace on screen; in the487 // Focused is the focused window of the workspace on screen; in the
452 // overview, the selected stash card when it is one.488 // overview, the selected stash card when it is one.
453 func (m *Monitor) Focused() (WindowID, bool) {489 func (m *Monitor) Focused() (WindowID, bool) {
M internal/core/screen_entry_test.go +68 -1
@@ -31,7 +31,7 @@ func TestCrossScreenFocusEntry(t *testing.T) {
3131 w.addFloat(Float{ID: id, W: 40, H: 40, free: true, cx: pos[0], cy: pos[1]}, true)
3232 }
3333 w.FocusID(4)
34- if edge {
34+ if edge && (tc.d == dirLeft || tc.d == dirRight) {
3535 if !c.edgeFocus(horizontalAxis, 1, tc.d) {
3636 t.Fatal("did not cross")
3737 }
@@ -103,3 +103,70 @@ func TestCrossScreenNoVisibleWindowPreservesFocus(t *testing.T) {
103103 t.Fatalf("focus %d flash %+v", id, c.flash.pending)
104104 }
105105 }
106+
107+func TestFocusEntryPreservesOverview(t *testing.T) {
108+ m := overviewMonitor()
109+ m.ToggleOverview()
110+ m.OverviewMove(-1, 0)
111+ selected, at := m.ov.selected, m.ov.selectedAt
112+ focused, _ := m.Current().Focused()
113+ for _, d := range []direction{dirLeft, dirRight, dirUp, dirDown} {
114+ m.focusEntry(d)
115+ got, _ := m.Current().Focused()
116+ if !m.ov.open || m.ov.selected != selected || m.ov.selectedAt != at || got != focused {
117+ t.Fatalf("overview selection or focus changed: selected %d focus %d", m.ov.selected, got)
118+ }
119+ }
120+}
121+
122+func TestFocusEntryDuringWorkspaceSlide(t *testing.T) {
123+ m := overviewMonitor()
124+ m.Focus(1)
125+ m.AddWindow(5)
126+ m.Current().FocusID(5)
127+ m.switchAxis = verticalAxis
128+ m.switchView.off = -.5
129+ view := m.viewport()
130+ outgoing := false
131+ for _, p := range m.Layout() {
132+ if !m.Current().has(p.ID) && view.shows(p) {
133+ outgoing = true
134+ }
135+ }
136+ if !outgoing {
137+ t.Fatal("setup: outgoing workspace must be visible")
138+ }
139+ m.focusEntry(dirDown)
140+ id, _ := m.Current().Focused()
141+ if id != 4 {
142+ t.Fatalf("focus %d want current workspace window 4", id)
143+ }
144+}
145+
146+func TestFocusEntryPanelUsesCommonFrameEdge(t *testing.T) {
147+ c, _ := flashCore(t)
148+ c.setLayers([]ports.LayerSurface{{ID: 90, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 50, ExclusiveZone: 50}})
149+ m := c.cur().mon
150+ w := m.Current()
151+ // The tile reaches the bottom frame edge; the float stops above it.
152+ w.addFloat(Float{ID: 4, W: 40, H: 40, free: true, cx: .5, cy: .8}, true)
153+ view := m.viewport()
154+ if view.usable.H >= view.frame.H {
155+ t.Fatal("setup: missing exclusive panel")
156+ }
157+ var want WindowID
158+ for _, p := range m.Layout() {
159+ if !p.Floating && view.shows(p) {
160+ want = p.ID
161+ break
162+ }
163+ }
164+ if want == 0 {
165+ t.Fatal("setup: no visible tile")
166+ }
167+ m.focusEntry(dirUp)
168+ id, _ := w.Focused()
169+ if id != want {
170+ t.Fatalf("focus %d want tile %d", id, want)
171+ }
172+}
@@ -31,7 +31,7 @@ func TestCrossScreenFocusEntry(t *testing.T) {
31 w.addFloat(Float{ID: id, W: 40, H: 40, free: true, cx: pos[0], cy: pos[1]}, true)31 w.addFloat(Float{ID: id, W: 40, H: 40, free: true, cx: pos[0], cy: pos[1]}, true)
32 }32 }
33 w.FocusID(4)33 w.FocusID(4)
34- if edge {34+ if edge && (tc.d == dirLeft || tc.d == dirRight) {
35 if !c.edgeFocus(horizontalAxis, 1, tc.d) {35 if !c.edgeFocus(horizontalAxis, 1, tc.d) {
36 t.Fatal("did not cross")36 t.Fatal("did not cross")
37 }37 }
@@ -103,3 +103,70 @@ func TestCrossScreenNoVisibleWindowPreservesFocus(t *testing.T) {
103 t.Fatalf("focus %d flash %+v", id, c.flash.pending)103 t.Fatalf("focus %d flash %+v", id, c.flash.pending)
104 }104 }
105 }105 }
106+
107+func TestFocusEntryPreservesOverview(t *testing.T) {
108+ m := overviewMonitor()
109+ m.ToggleOverview()
110+ m.OverviewMove(-1, 0)
111+ selected, at := m.ov.selected, m.ov.selectedAt
112+ focused, _ := m.Current().Focused()
113+ for _, d := range []direction{dirLeft, dirRight, dirUp, dirDown} {
114+ m.focusEntry(d)
115+ got, _ := m.Current().Focused()
116+ if !m.ov.open || m.ov.selected != selected || m.ov.selectedAt != at || got != focused {
117+ t.Fatalf("overview selection or focus changed: selected %d focus %d", m.ov.selected, got)
118+ }
119+ }
120+}
121+
122+func TestFocusEntryDuringWorkspaceSlide(t *testing.T) {
123+ m := overviewMonitor()
124+ m.Focus(1)
125+ m.AddWindow(5)
126+ m.Current().FocusID(5)
127+ m.switchAxis = verticalAxis
128+ m.switchView.off = -.5
129+ view := m.viewport()
130+ outgoing := false
131+ for _, p := range m.Layout() {
132+ if !m.Current().has(p.ID) && view.shows(p) {
133+ outgoing = true
134+ }
135+ }
136+ if !outgoing {
137+ t.Fatal("setup: outgoing workspace must be visible")
138+ }
139+ m.focusEntry(dirDown)
140+ id, _ := m.Current().Focused()
141+ if id != 4 {
142+ t.Fatalf("focus %d want current workspace window 4", id)
143+ }
144+}
145+
146+func TestFocusEntryPanelUsesCommonFrameEdge(t *testing.T) {
147+ c, _ := flashCore(t)
148+ c.setLayers([]ports.LayerSurface{{ID: 90, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 50, ExclusiveZone: 50}})
149+ m := c.cur().mon
150+ w := m.Current()
151+ // The tile reaches the bottom frame edge; the float stops above it.
152+ w.addFloat(Float{ID: 4, W: 40, H: 40, free: true, cx: .5, cy: .8}, true)
153+ view := m.viewport()
154+ if view.usable.H >= view.frame.H {
155+ t.Fatal("setup: missing exclusive panel")
156+ }
157+ var want WindowID
158+ for _, p := range m.Layout() {
159+ if !p.Floating && view.shows(p) {
160+ want = p.ID
161+ break
162+ }
163+ }
164+ if want == 0 {
165+ t.Fatal("setup: no visible tile")
166+ }
167+ m.focusEntry(dirUp)
168+ id, _ := w.Focused()
169+ if id != want {
170+ t.Fatalf("focus %d want tile %d", id, want)
171+ }
172+}

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