detached 890c437

feat(core): track recently used columns

commit
890c4374b9b3f2459596bf031e21c378e426c352
author
brice · · 2026-10-08 17:19 UTC
parent
65119ef
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +220 -2
M internal/core/core.go +4 -0
@@ -596,6 +596,10 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
596596 if !sc.mon.ov.open {
597597 sc.mon.Current().settleLeft()
598598 }
599+ // A switcher's selection is provisional too.
600+ if !sc.mon.previewing() {
601+ sc.mon.Current().noteFocus()
602+ }
599603 }
600604 c.refreshShown()
601605 c.captureExpire()
@@ -596,6 +596,10 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
596 if !sc.mon.ov.open {596 if !sc.mon.ov.open {
597 sc.mon.Current().settleLeft()597 sc.mon.Current().settleLeft()
598 }598 }
599+ // A switcher's selection is provisional too.
600+ if !sc.mon.previewing() {
601+ sc.mon.Current().noteFocus()
602+ }
599 }603 }
600 c.refreshShown()604 c.refreshShown()
601 c.captureExpire()605 c.captureExpire()
M internal/core/layout.go +6 -2
@@ -170,8 +170,11 @@ type Workspace struct {
170170 // maximized remembers recently maximized columns by window ID, newest
171171 // first; stack() filters removed windows at read time.
172172 maximized []WindowID
173- Focus int
174- View int
173+ // recent holds one window per column the user focused, newest first
174+ // (switcher.go); columns that went are dropped when read.
175+ recent []WindowID
176+ Focus int
177+ View int
175178 // View is the settled offset along the layout's content axis.
176179 // view is its presentation offset, in logical pixels.
177180 view slide
@@ -641,6 +644,7 @@ func (w *Workspace) unslot(n int) {
641644
642645 func (w *Workspace) RemoveWindow(id WindowID) {
643646 w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id })
647+ w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return v == id })
644648 if w.fullscreen == id {
645649 w.fullscreen = 0
646650 }
@@ -170,8 +170,11 @@ type Workspace struct {
170 // maximized remembers recently maximized columns by window ID, newest170 // maximized remembers recently maximized columns by window ID, newest
171 // first; stack() filters removed windows at read time.171 // first; stack() filters removed windows at read time.
172 maximized []WindowID172 maximized []WindowID
173- Focus int173+ // recent holds one window per column the user focused, newest first
174- View int174+ // (switcher.go); columns that went are dropped when read.
175+ recent []WindowID
176+ Focus int
177+ View int
175 // View is the settled offset along the layout's content axis.178 // View is the settled offset along the layout's content axis.
176 // view is its presentation offset, in logical pixels.179 // view is its presentation offset, in logical pixels.
177 view slide180 view slide
@@ -641,6 +644,7 @@ func (w *Workspace) unslot(n int) {
641 644
642 func (w *Workspace) RemoveWindow(id WindowID) {645 func (w *Workspace) RemoveWindow(id WindowID) {
643 w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id })646 w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id })
647+ w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return v == id })
644 if w.fullscreen == id {648 if w.fullscreen == id {
645 w.fullscreen = 0649 w.fullscreen = 0
646 }650 }
M internal/core/monitor.go +2 -0
@@ -45,6 +45,8 @@ type Monitor struct {
4545 switchList []*Workspace
4646 // ov holds the overview selection and its Escape snapshot.
4747 ov overviewState
48+ // sw is the column switcher (switcher.go).
49+ sw switcherState
4850 // overviewOpens counts the overview's openings: a swipe sliding when
4951 // one happens is dropped (gesture.go).
5052 overviewOpens int
@@ -45,6 +45,8 @@ type Monitor struct {
45 switchList []*Workspace45 switchList []*Workspace
46 // ov holds the overview selection and its Escape snapshot.46 // ov holds the overview selection and its Escape snapshot.
47 ov overviewState47 ov overviewState
48+ // sw is the column switcher (switcher.go).
49+ sw switcherState
48 // overviewOpens counts the overview's openings: a swipe sliding when50 // overviewOpens counts the overview's openings: a swipe sliding when
49 // one happens is dropped (gesture.go).51 // one happens is dropped (gesture.go).
50 overviewOpens int52 overviewOpens int
A internal/core/switcher.go +79 -0
@@ -0,0 +1,79 @@
1+package core
2+
3+import (
4+ "slices"
5+)
6+
7+// The column switcher is an Alt+Tab for the tiled columns of the workspace on
8+// screen. Each workspace keeps the columns it focused, most recent first
9+// (recent); the switcher steps through them without moving the focus, and
10+// commits the selected one when the command key is released.
11+
12+// switcherRecentMax caps a workspace's recent list.
13+const switcherRecentMax = 64
14+
15+// switcherState is the switcher of a monitor. A window stands for each
16+// column in order, the focused column first. Nothing moves until the commit;
17+// shown is set once the cards are drawn.
18+type switcherState struct {
19+ open, shown bool
20+ ws *Workspace
21+ order []WindowID
22+ at int
23+ // sizes are the client sizes the cards are scaled from.
24+ sizes map[WindowID]Rect
25+}
26+
27+// previewing reports whether the monitor draws previews instead of its
28+// workspace: the overview, or the switcher's cards.
29+func (m *Monitor) previewing() bool { return m.ov.open || m.sw.shown }
30+
31+// noteFocus records the focused column as the most recently used. It does
32+// nothing while a float or the stash has the focus, and allocates nothing
33+// when the focused column is already the latest.
34+func (w *Workspace) noteFocus() {
35+ if w.floatFocus || w.stashFocused() || w.Focus < 0 || w.Focus >= len(w.Columns) {
36+ return
37+ }
38+ col := w.Columns[w.Focus]
39+ id := col.Windows[col.Focus]
40+ if len(w.recent) > 0 && w.columnOf(w.recent[0]) == w.Focus {
41+ w.recent[0] = id
42+ return
43+ }
44+ w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return v == id || w.columnOf(v) == w.Focus })
45+ w.recent = slices.Insert(w.recent, 0, id)
46+ if len(w.recent) > switcherRecentMax {
47+ w.recent = w.recent[:switcherRecentMax]
48+ }
49+}
50+
51+// switchOrder lists one window per column, the focused window of each: the
52+// focused column first, then the others by recent use, then those never
53+// used by position. It is nil with fewer than two columns.
54+func (w *Workspace) switchOrder() []WindowID {
55+ n := len(w.Columns)
56+ if n < 2 || w.Focus < 0 || w.Focus >= n {
57+ return nil
58+ }
59+ seen := make([]bool, n)
60+ order := make([]WindowID, 0, n)
61+ add := func(i int) {
62+ if seen[i] {
63+ return
64+ }
65+ seen[i] = true
66+ c := w.Columns[i]
67+ order = append(order, c.Windows[c.Focus])
68+ }
69+ add(w.Focus)
70+ for _, id := range w.recent {
71+ if i := w.columnOf(id); i >= 0 {
72+ add(i)
73+ }
74+ }
75+ for i := range n {
76+ add(i)
77+ }
78+ return order
79+}
@@ -0,0 +1,79 @@
1+package core
2+
3+import (
4+ "slices"
5+)
6+
7+// The column switcher is an Alt+Tab for the tiled columns of the workspace on
8+// screen. Each workspace keeps the columns it focused, most recent first
9+// (recent); the switcher steps through them without moving the focus, and
10+// commits the selected one when the command key is released.
11+
12+// switcherRecentMax caps a workspace's recent list.
13+const switcherRecentMax = 64
14+
15+// switcherState is the switcher of a monitor. A window stands for each
16+// column in order, the focused column first. Nothing moves until the commit;
17+// shown is set once the cards are drawn.
18+type switcherState struct {
19+ open, shown bool
20+ ws *Workspace
21+ order []WindowID
22+ at int
23+ // sizes are the client sizes the cards are scaled from.
24+ sizes map[WindowID]Rect
25+}
26+
27+// previewing reports whether the monitor draws previews instead of its
28+// workspace: the overview, or the switcher's cards.
29+func (m *Monitor) previewing() bool { return m.ov.open || m.sw.shown }
30+
31+// noteFocus records the focused column as the most recently used. It does
32+// nothing while a float or the stash has the focus, and allocates nothing
33+// when the focused column is already the latest.
34+func (w *Workspace) noteFocus() {
35+ if w.floatFocus || w.stashFocused() || w.Focus < 0 || w.Focus >= len(w.Columns) {
36+ return
37+ }
38+ col := w.Columns[w.Focus]
39+ id := col.Windows[col.Focus]
40+ if len(w.recent) > 0 && w.columnOf(w.recent[0]) == w.Focus {
41+ w.recent[0] = id
42+ return
43+ }
44+ w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return v == id || w.columnOf(v) == w.Focus })
45+ w.recent = slices.Insert(w.recent, 0, id)
46+ if len(w.recent) > switcherRecentMax {
47+ w.recent = w.recent[:switcherRecentMax]
48+ }
49+}
50+
51+// switchOrder lists one window per column, the focused window of each: the
52+// focused column first, then the others by recent use, then those never
53+// used by position. It is nil with fewer than two columns.
54+func (w *Workspace) switchOrder() []WindowID {
55+ n := len(w.Columns)
56+ if n < 2 || w.Focus < 0 || w.Focus >= n {
57+ return nil
58+ }
59+ seen := make([]bool, n)
60+ order := make([]WindowID, 0, n)
61+ add := func(i int) {
62+ if seen[i] {
63+ return
64+ }
65+ seen[i] = true
66+ c := w.Columns[i]
67+ order = append(order, c.Windows[c.Focus])
68+ }
69+ add(w.Focus)
70+ for _, id := range w.recent {
71+ if i := w.columnOf(id); i >= 0 {
72+ add(i)
73+ }
74+ }
75+ for i := range n {
76+ add(i)
77+ }
78+ return order
79+}
A internal/core/switcher_test.go +129 -0
@@ -0,0 +1,129 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+// threeColumns is a workspace of three one-window columns, windows 1..3.
9+func threeColumns() *Workspace {
10+ w := workspace()
11+ w.SetMaxColumns(3)
12+ for id := WindowID(1); id <= 3; id++ {
13+ w.AddWindow(id)
14+ }
15+ return w
16+}
17+
18+func focusNote(w *Workspace, id WindowID) {
19+ w.FocusID(id)
20+ w.noteFocus()
21+}
22+
23+func TestMRUOrderFollowsFocus(t *testing.T) {
24+ w := threeColumns()
25+ // Columns 0, 2, 1 in turn: the last one used goes first.
26+ focusNote(w, 1)
27+ focusNote(w, 3)
28+ focusNote(w, 2)
29+ if got, want := w.switchOrder(), []WindowID{2, 3, 1}; !slices.Equal(got, want) {
30+ t.Fatalf("order %v, want %v", got, want)
31+ }
32+ focusNote(w, 1)
33+ if got, want := w.switchOrder(), []WindowID{1, 2, 3}; !slices.Equal(got, want) {
34+ t.Fatalf("order %v, want %v", got, want)
35+ }
36+}
37+
38+func TestMRUColumnsNeverFocusedFollowByPosition(t *testing.T) {
39+ w := threeColumns()
40+ focusNote(w, 3)
41+ if got, want := w.switchOrder(), []WindowID{3, 1, 2}; !slices.Equal(got, want) {
42+ t.Fatalf("order %v, want %v", got, want)
43+ }
44+}
45+
46+func TestMRUPrunesClosedWindows(t *testing.T) {
47+ w := threeColumns()
48+ focusNote(w, 1)
49+ focusNote(w, 3)
50+ focusNote(w, 2)
51+ w.RemoveWindow(3)
52+ if got, want := w.switchOrder(), []WindowID{2, 1}; !slices.Equal(got, want) {
53+ t.Fatalf("order %v, want %v", got, want)
54+ }
55+ if slices.Contains(w.recent, 3) {
56+ t.Fatalf("recent %v keeps a closed window", w.recent)
57+ }
58+}
59+
60+func TestMRUKeepsRankWhenColumnMoves(t *testing.T) {
61+ w := threeColumns()
62+ focusNote(w, 1)
63+ focusNote(w, 3)
64+ focusNote(w, 2)
65+ // Window 2 moves to the right: its column is the same, so is its rank.
66+ w.MoveColumn(1)
67+ w.noteFocus()
68+ if got, want := w.switchOrder(), []WindowID{2, 3, 1}; !slices.Equal(got, want) {
69+ t.Fatalf("order %v, want %v", got, want)
70+ }
71+ if len(w.recent) != 3 {
72+ t.Fatalf("recent %v", w.recent)
73+ }
74+}
75+
76+func TestMRUOneWindowPerColumn(t *testing.T) {
77+ w := threeColumns()
78+ w.AddWindow(4)
79+ w.FocusColumn(-1)
80+ w.ConsumeOrExpel(-1)
81+ // Whatever the columns became, each appears once.
82+ for range 3 {
83+ w.noteFocus()
84+ w.FocusColumn(-1)
85+ }
86+ order := w.switchOrder()
87+ if len(order) != len(w.Columns) {
88+ t.Fatalf("order %v for %d columns", order, len(w.Columns))
89+ }
90+ seen := map[int]bool{}
91+ for _, id := range order {
92+ if seen[w.columnOf(id)] {
93+ t.Fatalf("order %v lists a column twice", order)
94+ }
95+ seen[w.columnOf(id)] = true
96+ }
97+}
98+
99+func TestMRUSwitchOrderNeedsTwoColumns(t *testing.T) {
100+ w := workspace()
101+ if w.switchOrder() != nil {
102+ t.Fatal("order of an empty workspace")
103+ }
104+ w.AddWindow(1)
105+ w.noteFocus()
106+ if w.switchOrder() != nil {
107+ t.Fatal("order of one column")
108+ }
109+}
110+
111+func TestMRUCapped(t *testing.T) {
112+ w := workspace()
113+ w.SetMaxColumns(3)
114+ for id := WindowID(1); id <= switcherRecentMax+10; id++ {
115+ w.AddWindow(id)
116+ w.noteFocus()
117+ }
118+ if len(w.recent) != switcherRecentMax {
119+ t.Fatalf("recent holds %d", len(w.recent))
120+ }
121+}
122+
123+func TestMRUNoteFocusDoesNotAllocateWhenSettled(t *testing.T) {
124+ w := threeColumns()
125+ focusNote(w, 2)
126+ if n := testing.AllocsPerRun(100, w.noteFocus); n != 0 {
127+ t.Fatalf("noteFocus allocates %v", n)
128+ }
129+}
@@ -0,0 +1,129 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+// threeColumns is a workspace of three one-window columns, windows 1..3.
9+func threeColumns() *Workspace {
10+ w := workspace()
11+ w.SetMaxColumns(3)
12+ for id := WindowID(1); id <= 3; id++ {
13+ w.AddWindow(id)
14+ }
15+ return w
16+}
17+
18+func focusNote(w *Workspace, id WindowID) {
19+ w.FocusID(id)
20+ w.noteFocus()
21+}
22+
23+func TestMRUOrderFollowsFocus(t *testing.T) {
24+ w := threeColumns()
25+ // Columns 0, 2, 1 in turn: the last one used goes first.
26+ focusNote(w, 1)
27+ focusNote(w, 3)
28+ focusNote(w, 2)
29+ if got, want := w.switchOrder(), []WindowID{2, 3, 1}; !slices.Equal(got, want) {
30+ t.Fatalf("order %v, want %v", got, want)
31+ }
32+ focusNote(w, 1)
33+ if got, want := w.switchOrder(), []WindowID{1, 2, 3}; !slices.Equal(got, want) {
34+ t.Fatalf("order %v, want %v", got, want)
35+ }
36+}
37+
38+func TestMRUColumnsNeverFocusedFollowByPosition(t *testing.T) {
39+ w := threeColumns()
40+ focusNote(w, 3)
41+ if got, want := w.switchOrder(), []WindowID{3, 1, 2}; !slices.Equal(got, want) {
42+ t.Fatalf("order %v, want %v", got, want)
43+ }
44+}
45+
46+func TestMRUPrunesClosedWindows(t *testing.T) {
47+ w := threeColumns()
48+ focusNote(w, 1)
49+ focusNote(w, 3)
50+ focusNote(w, 2)
51+ w.RemoveWindow(3)
52+ if got, want := w.switchOrder(), []WindowID{2, 1}; !slices.Equal(got, want) {
53+ t.Fatalf("order %v, want %v", got, want)
54+ }
55+ if slices.Contains(w.recent, 3) {
56+ t.Fatalf("recent %v keeps a closed window", w.recent)
57+ }
58+}
59+
60+func TestMRUKeepsRankWhenColumnMoves(t *testing.T) {
61+ w := threeColumns()
62+ focusNote(w, 1)
63+ focusNote(w, 3)
64+ focusNote(w, 2)
65+ // Window 2 moves to the right: its column is the same, so is its rank.
66+ w.MoveColumn(1)
67+ w.noteFocus()
68+ if got, want := w.switchOrder(), []WindowID{2, 3, 1}; !slices.Equal(got, want) {
69+ t.Fatalf("order %v, want %v", got, want)
70+ }
71+ if len(w.recent) != 3 {
72+ t.Fatalf("recent %v", w.recent)
73+ }
74+}
75+
76+func TestMRUOneWindowPerColumn(t *testing.T) {
77+ w := threeColumns()
78+ w.AddWindow(4)
79+ w.FocusColumn(-1)
80+ w.ConsumeOrExpel(-1)
81+ // Whatever the columns became, each appears once.
82+ for range 3 {
83+ w.noteFocus()
84+ w.FocusColumn(-1)
85+ }
86+ order := w.switchOrder()
87+ if len(order) != len(w.Columns) {
88+ t.Fatalf("order %v for %d columns", order, len(w.Columns))
89+ }
90+ seen := map[int]bool{}
91+ for _, id := range order {
92+ if seen[w.columnOf(id)] {
93+ t.Fatalf("order %v lists a column twice", order)
94+ }
95+ seen[w.columnOf(id)] = true
96+ }
97+}
98+
99+func TestMRUSwitchOrderNeedsTwoColumns(t *testing.T) {
100+ w := workspace()
101+ if w.switchOrder() != nil {
102+ t.Fatal("order of an empty workspace")
103+ }
104+ w.AddWindow(1)
105+ w.noteFocus()
106+ if w.switchOrder() != nil {
107+ t.Fatal("order of one column")
108+ }
109+}
110+
111+func TestMRUCapped(t *testing.T) {
112+ w := workspace()
113+ w.SetMaxColumns(3)
114+ for id := WindowID(1); id <= switcherRecentMax+10; id++ {
115+ w.AddWindow(id)
116+ w.noteFocus()
117+ }
118+ if len(w.recent) != switcherRecentMax {
119+ t.Fatalf("recent holds %d", len(w.recent))
120+ }
121+}
122+
123+func TestMRUNoteFocusDoesNotAllocateWhenSettled(t *testing.T) {
124+ w := threeColumns()
125+ focusNote(w, 2)
126+ if n := testing.AllocsPerRun(100, w.noteFocus); n != 0 {
127+ t.Fatalf("noteFocus allocates %v", n)
128+ }
129+}

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