detached cf75d01

feat(core): add scrollable column layout model

commit
cf75d0101623526f9e034218228e52ec0194f52f
author
brice · · 2026-09-23 10:52 UTC
parent
42465c1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +705 -0
A internal/core/action.go +52 -0
@@ -0,0 +1,52 @@
1+package core
2+
3+type Action string
4+
5+const (
6+ ActionSpawnTerminal Action = "spawn-terminal"
7+ ActionFocusColumnLeft Action = "focus-column-left"
8+ ActionFocusColumnRight Action = "focus-column-right"
9+ ActionFocusWindowUp Action = "focus-window-up"
10+ ActionFocusWindowDown Action = "focus-window-down"
11+ ActionMoveColumnLeft Action = "move-column-left"
12+ ActionMoveColumnRight Action = "move-column-right"
13+ ActionCycleColumnWidth Action = "cycle-column-width"
14+ ActionToggleFullscreen Action = "toggle-fullscreen"
15+ ActionCloseWindow Action = "close-window"
16+ ActionQuit Action = "quit"
17+)
18+
19+type Effect struct {
20+ Spawn bool
21+ Close WindowID
22+ Quit bool
23+}
24+
25+func (w *Workspace) Apply(a Action) Effect {
26+ switch a {
27+ case ActionSpawnTerminal:
28+ return Effect{Spawn: true}
29+ case ActionFocusColumnLeft:
30+ w.FocusColumn(-1)
31+ case ActionFocusColumnRight:
32+ w.FocusColumn(1)
33+ case ActionFocusWindowUp:
34+ w.FocusWindow(-1)
35+ case ActionFocusWindowDown:
36+ w.FocusWindow(1)
37+ case ActionMoveColumnLeft:
38+ w.MoveColumn(-1)
39+ case ActionMoveColumnRight:
40+ w.MoveColumn(1)
41+ case ActionCycleColumnWidth:
42+ w.CycleWidth()
43+ case ActionToggleFullscreen:
44+ w.ToggleFullscreen()
45+ case ActionCloseWindow:
46+ id, _ := w.Focused()
47+ return Effect{Close: id}
48+ case ActionQuit:
49+ return Effect{Quit: true}
50+ }
51+ return Effect{}
52+}
@@ -0,0 +1,52 @@
1+package core
2+
3+type Action string
4+
5+const (
6+ ActionSpawnTerminal Action = "spawn-terminal"
7+ ActionFocusColumnLeft Action = "focus-column-left"
8+ ActionFocusColumnRight Action = "focus-column-right"
9+ ActionFocusWindowUp Action = "focus-window-up"
10+ ActionFocusWindowDown Action = "focus-window-down"
11+ ActionMoveColumnLeft Action = "move-column-left"
12+ ActionMoveColumnRight Action = "move-column-right"
13+ ActionCycleColumnWidth Action = "cycle-column-width"
14+ ActionToggleFullscreen Action = "toggle-fullscreen"
15+ ActionCloseWindow Action = "close-window"
16+ ActionQuit Action = "quit"
17+)
18+
19+type Effect struct {
20+ Spawn bool
21+ Close WindowID
22+ Quit bool
23+}
24+
25+func (w *Workspace) Apply(a Action) Effect {
26+ switch a {
27+ case ActionSpawnTerminal:
28+ return Effect{Spawn: true}
29+ case ActionFocusColumnLeft:
30+ w.FocusColumn(-1)
31+ case ActionFocusColumnRight:
32+ w.FocusColumn(1)
33+ case ActionFocusWindowUp:
34+ w.FocusWindow(-1)
35+ case ActionFocusWindowDown:
36+ w.FocusWindow(1)
37+ case ActionMoveColumnLeft:
38+ w.MoveColumn(-1)
39+ case ActionMoveColumnRight:
40+ w.MoveColumn(1)
41+ case ActionCycleColumnWidth:
42+ w.CycleWidth()
43+ case ActionToggleFullscreen:
44+ w.ToggleFullscreen()
45+ case ActionCloseWindow:
46+ id, _ := w.Focused()
47+ return Effect{Close: id}
48+ case ActionQuit:
49+ return Effect{Quit: true}
50+ }
51+ return Effect{}
52+}
A internal/core/layout.go +320 -0
@@ -0,0 +1,320 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "math/bits"
6+ "strconv"
7+ "strings"
8+)
9+
10+type WindowID uint64
11+
12+type Rect struct{ X, Y, W, H int }
13+
14+type Width struct{ Num, Den, Pixels int }
15+
16+func ParseWidth(s string) (Width, error) {
17+ if s == "1" {
18+ return Width{Num: 1, Den: 1}, nil
19+ }
20+ if strings.HasSuffix(s, "px") {
21+ n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
22+ if e == nil && n > 0 {
23+ return Width{Pixels: n}, nil
24+ }
25+ } else {
26+ p := strings.Split(s, "/")
27+ if len(p) == 2 {
28+ a, e1 := strconv.Atoi(p[0])
29+ b, e2 := strconv.Atoi(p[1])
30+ if e1 == nil && e2 == nil && a > 0 && a <= b {
31+ return Width{Num: a, Den: b}, nil
32+ }
33+ }
34+ }
35+ return Width{}, fmt.Errorf("invalid width %q", s)
36+}
37+
38+// Resolve returns column pixels: round(frac * (usableW - gaps)) - gaps.
39+// Widths are clamped to the available area (zero if no space remains).
40+func (v Width) Resolve(usableW, gaps int) int {
41+ if usableW <= 0 {
42+ return 0
43+ }
44+ gaps = min(max(gaps, 0), usableW/2)
45+ limit := max(usableW-2*gaps, 0)
46+ if limit == 0 {
47+ return 0
48+ }
49+ if v.Pixels > 0 {
50+ return min(v.Pixels, limit)
51+ }
52+ if v.Den <= 0 || v.Num <= 0 {
53+ return 1
54+ }
55+ // Cap invalid fractions above one before multiplying; the result would
56+ // be clamped to the available width anyway.
57+ num, den := uint64(min(v.Num, v.Den)), uint64(v.Den)
58+ avail := uint64(usableW - gaps)
59+ q, r := avail/den, avail%den
60+ hi, lo := bits.Mul64(r, num)
61+ lo, carry := bits.Add64(lo, den/2, 0)
62+ hi += carry
63+ // hi < den: r and num are both smaller than den.
64+ fraction, _ := bits.Div64(hi, lo, den)
65+ rounded := q*num + fraction // no larger than avail (plus rounding)
66+ if rounded <= uint64(gaps) {
67+ return 1
68+ }
69+ return int(min(rounded-uint64(gaps), uint64(limit)))
70+}
71+
72+type Column struct {
73+ Windows []WindowID
74+ Width Width
75+ Focus int
76+}
77+type Placement struct {
78+ ID WindowID
79+ Rect Rect
80+ Fullscreen, Focused, Hidden bool
81+}
82+type Workspace struct {
83+ Columns []Column
84+ Focus int
85+ ViewX int
86+ Output Rect
87+ Usable Rect
88+ Gaps int
89+ DefaultWidth Width
90+ presets []Width
91+ fullscreen WindowID
92+}
93+
94+func (w *Workspace) Focused() (WindowID, bool) {
95+ if w.Focus < 0 || w.Focus >= len(w.Columns) {
96+ return 0, false
97+ }
98+ c := w.Columns[w.Focus]
99+ if c.Focus < 0 || c.Focus >= len(c.Windows) {
100+ return 0, false
101+ }
102+ return c.Windows[c.Focus], true
103+}
104+func (w *Workspace) AddWindow(id WindowID) {
105+ if id == 0 {
106+ return
107+ }
108+ for _, c := range w.Columns {
109+ for _, v := range c.Windows {
110+ if v == id {
111+ return
112+ }
113+ }
114+ }
115+ at := 0
116+ if len(w.Columns) > 0 {
117+ at = w.Focus + 1
118+ }
119+ w.Columns = append(w.Columns, Column{})
120+ copy(w.Columns[at+1:], w.Columns[at:])
121+ w.Columns[at] = Column{Windows: []WindowID{id}, Width: w.DefaultWidth}
122+ w.Focus = at
123+ w.scroll()
124+}
125+func (w *Workspace) RemoveWindow(id WindowID) {
126+ for i := range w.Columns {
127+ for j, v := range w.Columns[i].Windows {
128+ if v != id {
129+ continue
130+ }
131+ c := &w.Columns[i]
132+ c.Windows = append(c.Windows[:j], c.Windows[j+1:]...)
133+ if w.fullscreen == id {
134+ w.fullscreen = 0
135+ }
136+ if len(c.Windows) == 0 {
137+ w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)
138+ if len(w.Columns) == 0 {
139+ w.Focus = 0
140+ w.ViewX = 0
141+ return
142+ }
143+ if i < w.Focus || (i == w.Focus && i > 0) {
144+ w.Focus--
145+ }
146+ } else {
147+ if j < c.Focus {
148+ c.Focus--
149+ } else if j == c.Focus && c.Focus > 0 {
150+ c.Focus--
151+ }
152+ }
153+ w.scroll()
154+ return
155+ }
156+ }
157+}
158+func (w *Workspace) FocusColumn(dir int) {
159+ if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
160+ w.Focus += dir
161+ w.scroll()
162+ }
163+}
164+func (w *Workspace) FocusWindow(dir int) {
165+ if len(w.Columns) == 0 || (dir != -1 && dir != 1) {
166+ return
167+ }
168+ c := &w.Columns[w.Focus]
169+ if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) {
170+ c.Focus += dir
171+ w.scroll()
172+ }
173+}
174+func (w *Workspace) MoveColumn(dir int) {
175+ if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
176+ i := w.Focus
177+ w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i]
178+ w.Focus += dir
179+ w.scroll()
180+ }
181+}
182+func (w *Workspace) CycleWidth() {
183+ if len(w.Columns) == 0 || len(w.presets) == 0 {
184+ return
185+ }
186+ c := &w.Columns[w.Focus]
187+ next := 0
188+ for i, v := range w.presets {
189+ if v == c.Width {
190+ next = (i + 1) % len(w.presets)
191+ break
192+ }
193+ }
194+ c.Width = w.presets[next]
195+ w.scroll()
196+}
197+func (w *Workspace) ToggleFullscreen() {
198+ id, ok := w.Focused()
199+ if !ok {
200+ return
201+ }
202+ if w.fullscreen == id {
203+ w.fullscreen = 0
204+ } else {
205+ w.fullscreen = id
206+ }
207+ w.scroll()
208+}
209+func (w *Workspace) SetOutput(width, height int) {
210+ if width < 0 {
211+ width = 0
212+ }
213+ if height < 0 {
214+ height = 0
215+ }
216+ old := w.Output
217+ w.Output = Rect{W: width, H: height}
218+ if w.Usable == old {
219+ w.Usable = w.Output
220+ }
221+ w.SetUsable(w.Usable)
222+}
223+
224+// SetUsable clamps negative dimensions to zero and clips the usable rectangle
225+// to the output. Effective gaps shrink to fit the available area.
226+func (w *Workspace) SetUsable(r Rect) {
227+ r.X = min(max(r.X, 0), w.Output.W)
228+ r.Y = min(max(r.Y, 0), w.Output.H)
229+ r.W = min(max(r.W, 0), w.Output.W-r.X)
230+ r.H = min(max(r.H, 0), w.Output.H-r.Y)
231+ w.Usable = r
232+ w.scroll()
233+}
234+func (w *Workspace) SetGaps(g int) {
235+ if g < 0 {
236+ g = 0
237+ }
238+ w.Gaps = g
239+ w.scroll()
240+}
241+func (w *Workspace) SetDefaultWidth(v Width) { w.DefaultWidth = v }
242+func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }
243+func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
244+func (w *Workspace) fullscreenColumn(i int) bool {
245+ for _, id := range w.Columns[i].Windows {
246+ if id == w.fullscreen && id != 0 {
247+ return true
248+ }
249+ }
250+ return false
251+}
252+func (w *Workspace) columnWidth(i int) int {
253+ if w.fullscreenColumn(i) {
254+ return w.Output.W
255+ }
256+ return w.Columns[i].Width.Resolve(w.Usable.W, w.gap())
257+}
258+func (w *Workspace) columnX(i int) int {
259+ x := w.Usable.X + w.gap()
260+ for j := 0; j < i; j++ {
261+ x += w.columnWidth(j) + w.gap()
262+ }
263+ return x
264+}
265+func (w *Workspace) scroll() {
266+ if len(w.Columns) == 0 {
267+ return
268+ }
269+ left := w.columnX(w.Focus) - w.ViewX
270+ width := w.columnWidth(w.Focus)
271+ if w.fullscreenColumn(w.Focus) {
272+ w.ViewX += left
273+ return
274+ }
275+ minX := w.Usable.X + w.gap()
276+ maxX := w.Usable.X + w.Usable.W - w.gap()
277+ if width > maxX-minX || left < minX {
278+ w.ViewX += left - minX
279+ } else if left+width > maxX {
280+ w.ViewX += left + width - maxX
281+ }
282+}
283+func (w *Workspace) Layout() []Placement {
284+ var result []Placement
285+ gap := w.gap()
286+ for i, c := range w.Columns {
287+ x := w.columnX(i) - w.ViewX
288+ width := w.columnWidth(i)
289+ n := len(c.Windows)
290+ if n == 0 {
291+ continue
292+ }
293+ fullColumn := w.fullscreenColumn(i)
294+ available := max(w.Usable.H-(n+1)*gap, 0)
295+ height := available / n
296+ y := w.Usable.Y + gap
297+ for j, id := range c.Windows {
298+ h := height
299+ if j == n-1 {
300+ h = available - height*(n-1)
301+ }
302+ if y > w.Usable.Y+w.Usable.H {
303+ y = w.Usable.Y + w.Usable.H
304+ }
305+ h = min(h, w.Usable.Y+w.Usable.H-y)
306+ r := Rect{X: x, Y: y, W: width, H: h}
307+ full := w.fullscreen == id && id != 0
308+ hidden := fullColumn && !full
309+ if full {
310+ r = Rect{X: x, Y: 0, W: w.Output.W, H: w.Output.H}
311+ }
312+ if hidden {
313+ r = Rect{}
314+ }
315+ result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden})
316+ y += h + gap
317+ }
318+ }
319+ return result
320+}
@@ -0,0 +1,320 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "math/bits"
6+ "strconv"
7+ "strings"
8+)
9+
10+type WindowID uint64
11+
12+type Rect struct{ X, Y, W, H int }
13+
14+type Width struct{ Num, Den, Pixels int }
15+
16+func ParseWidth(s string) (Width, error) {
17+ if s == "1" {
18+ return Width{Num: 1, Den: 1}, nil
19+ }
20+ if strings.HasSuffix(s, "px") {
21+ n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
22+ if e == nil && n > 0 {
23+ return Width{Pixels: n}, nil
24+ }
25+ } else {
26+ p := strings.Split(s, "/")
27+ if len(p) == 2 {
28+ a, e1 := strconv.Atoi(p[0])
29+ b, e2 := strconv.Atoi(p[1])
30+ if e1 == nil && e2 == nil && a > 0 && a <= b {
31+ return Width{Num: a, Den: b}, nil
32+ }
33+ }
34+ }
35+ return Width{}, fmt.Errorf("invalid width %q", s)
36+}
37+
38+// Resolve returns column pixels: round(frac * (usableW - gaps)) - gaps.
39+// Widths are clamped to the available area (zero if no space remains).
40+func (v Width) Resolve(usableW, gaps int) int {
41+ if usableW <= 0 {
42+ return 0
43+ }
44+ gaps = min(max(gaps, 0), usableW/2)
45+ limit := max(usableW-2*gaps, 0)
46+ if limit == 0 {
47+ return 0
48+ }
49+ if v.Pixels > 0 {
50+ return min(v.Pixels, limit)
51+ }
52+ if v.Den <= 0 || v.Num <= 0 {
53+ return 1
54+ }
55+ // Cap invalid fractions above one before multiplying; the result would
56+ // be clamped to the available width anyway.
57+ num, den := uint64(min(v.Num, v.Den)), uint64(v.Den)
58+ avail := uint64(usableW - gaps)
59+ q, r := avail/den, avail%den
60+ hi, lo := bits.Mul64(r, num)
61+ lo, carry := bits.Add64(lo, den/2, 0)
62+ hi += carry
63+ // hi < den: r and num are both smaller than den.
64+ fraction, _ := bits.Div64(hi, lo, den)
65+ rounded := q*num + fraction // no larger than avail (plus rounding)
66+ if rounded <= uint64(gaps) {
67+ return 1
68+ }
69+ return int(min(rounded-uint64(gaps), uint64(limit)))
70+}
71+
72+type Column struct {
73+ Windows []WindowID
74+ Width Width
75+ Focus int
76+}
77+type Placement struct {
78+ ID WindowID
79+ Rect Rect
80+ Fullscreen, Focused, Hidden bool
81+}
82+type Workspace struct {
83+ Columns []Column
84+ Focus int
85+ ViewX int
86+ Output Rect
87+ Usable Rect
88+ Gaps int
89+ DefaultWidth Width
90+ presets []Width
91+ fullscreen WindowID
92+}
93+
94+func (w *Workspace) Focused() (WindowID, bool) {
95+ if w.Focus < 0 || w.Focus >= len(w.Columns) {
96+ return 0, false
97+ }
98+ c := w.Columns[w.Focus]
99+ if c.Focus < 0 || c.Focus >= len(c.Windows) {
100+ return 0, false
101+ }
102+ return c.Windows[c.Focus], true
103+}
104+func (w *Workspace) AddWindow(id WindowID) {
105+ if id == 0 {
106+ return
107+ }
108+ for _, c := range w.Columns {
109+ for _, v := range c.Windows {
110+ if v == id {
111+ return
112+ }
113+ }
114+ }
115+ at := 0
116+ if len(w.Columns) > 0 {
117+ at = w.Focus + 1
118+ }
119+ w.Columns = append(w.Columns, Column{})
120+ copy(w.Columns[at+1:], w.Columns[at:])
121+ w.Columns[at] = Column{Windows: []WindowID{id}, Width: w.DefaultWidth}
122+ w.Focus = at
123+ w.scroll()
124+}
125+func (w *Workspace) RemoveWindow(id WindowID) {
126+ for i := range w.Columns {
127+ for j, v := range w.Columns[i].Windows {
128+ if v != id {
129+ continue
130+ }
131+ c := &w.Columns[i]
132+ c.Windows = append(c.Windows[:j], c.Windows[j+1:]...)
133+ if w.fullscreen == id {
134+ w.fullscreen = 0
135+ }
136+ if len(c.Windows) == 0 {
137+ w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)
138+ if len(w.Columns) == 0 {
139+ w.Focus = 0
140+ w.ViewX = 0
141+ return
142+ }
143+ if i < w.Focus || (i == w.Focus && i > 0) {
144+ w.Focus--
145+ }
146+ } else {
147+ if j < c.Focus {
148+ c.Focus--
149+ } else if j == c.Focus && c.Focus > 0 {
150+ c.Focus--
151+ }
152+ }
153+ w.scroll()
154+ return
155+ }
156+ }
157+}
158+func (w *Workspace) FocusColumn(dir int) {
159+ if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
160+ w.Focus += dir
161+ w.scroll()
162+ }
163+}
164+func (w *Workspace) FocusWindow(dir int) {
165+ if len(w.Columns) == 0 || (dir != -1 && dir != 1) {
166+ return
167+ }
168+ c := &w.Columns[w.Focus]
169+ if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) {
170+ c.Focus += dir
171+ w.scroll()
172+ }
173+}
174+func (w *Workspace) MoveColumn(dir int) {
175+ if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
176+ i := w.Focus
177+ w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i]
178+ w.Focus += dir
179+ w.scroll()
180+ }
181+}
182+func (w *Workspace) CycleWidth() {
183+ if len(w.Columns) == 0 || len(w.presets) == 0 {
184+ return
185+ }
186+ c := &w.Columns[w.Focus]
187+ next := 0
188+ for i, v := range w.presets {
189+ if v == c.Width {
190+ next = (i + 1) % len(w.presets)
191+ break
192+ }
193+ }
194+ c.Width = w.presets[next]
195+ w.scroll()
196+}
197+func (w *Workspace) ToggleFullscreen() {
198+ id, ok := w.Focused()
199+ if !ok {
200+ return
201+ }
202+ if w.fullscreen == id {
203+ w.fullscreen = 0
204+ } else {
205+ w.fullscreen = id
206+ }
207+ w.scroll()
208+}
209+func (w *Workspace) SetOutput(width, height int) {
210+ if width < 0 {
211+ width = 0
212+ }
213+ if height < 0 {
214+ height = 0
215+ }
216+ old := w.Output
217+ w.Output = Rect{W: width, H: height}
218+ if w.Usable == old {
219+ w.Usable = w.Output
220+ }
221+ w.SetUsable(w.Usable)
222+}
223+
224+// SetUsable clamps negative dimensions to zero and clips the usable rectangle
225+// to the output. Effective gaps shrink to fit the available area.
226+func (w *Workspace) SetUsable(r Rect) {
227+ r.X = min(max(r.X, 0), w.Output.W)
228+ r.Y = min(max(r.Y, 0), w.Output.H)
229+ r.W = min(max(r.W, 0), w.Output.W-r.X)
230+ r.H = min(max(r.H, 0), w.Output.H-r.Y)
231+ w.Usable = r
232+ w.scroll()
233+}
234+func (w *Workspace) SetGaps(g int) {
235+ if g < 0 {
236+ g = 0
237+ }
238+ w.Gaps = g
239+ w.scroll()
240+}
241+func (w *Workspace) SetDefaultWidth(v Width) { w.DefaultWidth = v }
242+func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }
243+func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
244+func (w *Workspace) fullscreenColumn(i int) bool {
245+ for _, id := range w.Columns[i].Windows {
246+ if id == w.fullscreen && id != 0 {
247+ return true
248+ }
249+ }
250+ return false
251+}
252+func (w *Workspace) columnWidth(i int) int {
253+ if w.fullscreenColumn(i) {
254+ return w.Output.W
255+ }
256+ return w.Columns[i].Width.Resolve(w.Usable.W, w.gap())
257+}
258+func (w *Workspace) columnX(i int) int {
259+ x := w.Usable.X + w.gap()
260+ for j := 0; j < i; j++ {
261+ x += w.columnWidth(j) + w.gap()
262+ }
263+ return x
264+}
265+func (w *Workspace) scroll() {
266+ if len(w.Columns) == 0 {
267+ return
268+ }
269+ left := w.columnX(w.Focus) - w.ViewX
270+ width := w.columnWidth(w.Focus)
271+ if w.fullscreenColumn(w.Focus) {
272+ w.ViewX += left
273+ return
274+ }
275+ minX := w.Usable.X + w.gap()
276+ maxX := w.Usable.X + w.Usable.W - w.gap()
277+ if width > maxX-minX || left < minX {
278+ w.ViewX += left - minX
279+ } else if left+width > maxX {
280+ w.ViewX += left + width - maxX
281+ }
282+}
283+func (w *Workspace) Layout() []Placement {
284+ var result []Placement
285+ gap := w.gap()
286+ for i, c := range w.Columns {
287+ x := w.columnX(i) - w.ViewX
288+ width := w.columnWidth(i)
289+ n := len(c.Windows)
290+ if n == 0 {
291+ continue
292+ }
293+ fullColumn := w.fullscreenColumn(i)
294+ available := max(w.Usable.H-(n+1)*gap, 0)
295+ height := available / n
296+ y := w.Usable.Y + gap
297+ for j, id := range c.Windows {
298+ h := height
299+ if j == n-1 {
300+ h = available - height*(n-1)
301+ }
302+ if y > w.Usable.Y+w.Usable.H {
303+ y = w.Usable.Y + w.Usable.H
304+ }
305+ h = min(h, w.Usable.Y+w.Usable.H-y)
306+ r := Rect{X: x, Y: y, W: width, H: h}
307+ full := w.fullscreen == id && id != 0
308+ hidden := fullColumn && !full
309+ if full {
310+ r = Rect{X: x, Y: 0, W: w.Output.W, H: w.Output.H}
311+ }
312+ if hidden {
313+ r = Rect{}
314+ }
315+ result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden})
316+ y += h + gap
317+ }
318+ }
319+ return result
320+}
A internal/core/layout_test.go +333 -0
@@ -0,0 +1,333 @@
1+package core
2+
3+import (
4+ "math"
5+ "testing"
6+)
7+
8+func workspace() *Workspace {
9+ w := &Workspace{}
10+ w.SetOutput(100, 81)
11+ w.SetGaps(5)
12+ w.SetDefaultWidth(Width{Num: 1, Den: 2})
13+ return w
14+}
15+func TestWidths(t *testing.T) {
16+ for _, tc := range []struct {
17+ s string
18+ ok bool
19+ pixels int
20+ }{{"1", true, 0}, {"1/2", true, 0}, {"25px", true, 25}, {"2/1", false, 0}, {"0px", false, 0}, {"1/0", false, 0}, {"abc", false, 0}} {
21+ v, e := ParseWidth(tc.s)
22+ if (e == nil) != tc.ok || (e == nil && v.Pixels != tc.pixels) {
23+ t.Errorf("%q: %v %v", tc.s, v, e)
24+ }
25+ }
26+ if n := (Width{Num: 1, Den: 2}).Resolve(100, 5); n != 43 {
27+ t.Fatal(n)
28+ }
29+ if n := (Width{Pixels: 200}).Resolve(100, 5); n != 90 {
30+ t.Fatal(n)
31+ }
32+}
33+func TestOperations(t *testing.T) {
34+ w := workspace()
35+ w.FocusColumn(1)
36+ w.FocusWindow(-1)
37+ w.MoveColumn(1)
38+ w.CycleWidth()
39+ w.ToggleFullscreen()
40+ w.RemoveWindow(1)
41+ if _, ok := w.Focused(); ok {
42+ t.Fatal("empty focus")
43+ }
44+ w.AddWindow(1)
45+ w.AddWindow(2)
46+ w.AddWindow(3)
47+ if w.Focus != 2 || w.ViewX != 49 {
48+ t.Fatalf("scroll: %+v", w)
49+ }
50+ w.FocusColumn(-1)
51+ if w.ViewX != 48 {
52+ t.Fatal(w.ViewX)
53+ }
54+ w.FocusColumn(-1)
55+ if w.ViewX != 0 {
56+ t.Fatal(w.ViewX)
57+ }
58+ w.MoveColumn(1)
59+ if w.Focus != 1 || w.Columns[1].Windows[0] != 1 {
60+ t.Fatal(w.Columns)
61+ }
62+ w.RemoveWindow(1)
63+ if id, _ := w.Focused(); id != 2 {
64+ t.Fatal(id)
65+ }
66+ w.RemoveWindow(3)
67+ w.RemoveWindow(2)
68+ if len(w.Columns) != 0 {
69+ t.Fatal(w.Columns)
70+ }
71+}
72+func TestStackAndLayout(t *testing.T) {
73+ w := workspace()
74+ w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Width: Width{Num: 1, Den: 2}, Focus: 1}}
75+ w.RemoveWindow(2)
76+ if id, _ := w.Focused(); id != 1 {
77+ t.Fatal(id)
78+ }
79+ w.FocusWindow(1)
80+ if id, _ := w.Focused(); id != 3 {
81+ t.Fatal(id)
82+ }
83+ w.FocusWindow(-1)
84+ p := w.Layout()
85+ if p[0].Rect != (Rect{5, 5, 43, 33}) || p[1].Rect != (Rect{5, 43, 43, 33}) {
86+ t.Fatal(p)
87+ }
88+ w.ToggleFullscreen()
89+ if p = w.Layout(); !p[0].Fullscreen || p[0].Rect != (Rect{X: 0, Y: 0, W: 100, H: 81}) || !p[1].Hidden || p[1].Rect != (Rect{}) {
90+ t.Fatal(p)
91+ }
92+ w.ToggleFullscreen()
93+ if w.Layout()[0].Fullscreen {
94+ t.Fatal("toggle")
95+ }
96+ w.SetUsable(Rect{10, 10, 60, 50})
97+ w.SetGaps(2)
98+ if w.Layout()[0].Rect.X != 12 {
99+ t.Fatal(w.Layout())
100+ }
101+ w.SetOutput(120, 90)
102+ if w.Usable.W != 60 {
103+ t.Fatal(w.Usable)
104+ }
105+}
106+func TestPresetsAndActions(t *testing.T) {
107+ w := workspace()
108+ w.AddWindow(1)
109+ p := []Width{{Pixels: 30}, {Pixels: 40}}
110+ w.SetPresets(p)
111+ p[0].Pixels = 99
112+ w.Apply(ActionCycleColumnWidth)
113+ if w.Columns[0].Width.Pixels != 30 {
114+ t.Fatal(w.Columns)
115+ }
116+ w.CycleWidth()
117+ if w.Columns[0].Width.Pixels != 40 {
118+ t.Fatal(w.Columns)
119+ }
120+ w.CycleWidth()
121+ if w.Columns[0].Width.Pixels != 30 {
122+ t.Fatal(w.Columns)
123+ }
124+ for _, tc := range []struct {
125+ a Action
126+ want Effect
127+ }{{ActionSpawnTerminal, Effect{Spawn: true}}, {ActionCloseWindow, Effect{Close: 1}}, {ActionQuit, Effect{Quit: true}}, {ActionFocusColumnLeft, Effect{}}, {ActionFocusColumnRight, Effect{}}, {ActionFocusWindowUp, Effect{}}, {ActionFocusWindowDown, Effect{}}, {ActionMoveColumnLeft, Effect{}}, {ActionMoveColumnRight, Effect{}}, {ActionToggleFullscreen, Effect{}}, {ActionCycleColumnWidth, Effect{}}} {
128+ if got := w.Apply(tc.a); got != tc.want {
129+ t.Errorf("%s: %+v", tc.a, got)
130+ }
131+ }
132+ w.SetPresets(nil)
133+ old := w.Columns[0].Width
134+ w.CycleWidth()
135+ if w.Columns[0].Width != old {
136+ t.Fatal("empty presets")
137+ }
138+}
139+func FuzzWorkspaceOps(f *testing.F) {
140+ f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
141+ f.Fuzz(func(t *testing.T, ops []byte) {
142+ w := workspace()
143+ w.SetPresets([]Width{{Pixels: 25}, {Pixels: 50}})
144+ next := WindowID(1)
145+ for _, op := range ops {
146+ switch op % 18 {
147+ case 0:
148+ w.AddWindow(next)
149+ next++
150+ case 1:
151+ if id, ok := w.Focused(); ok {
152+ w.RemoveWindow(id)
153+ }
154+ case 2:
155+ w.FocusColumn(-1)
156+ case 3:
157+ w.FocusColumn(1)
158+ case 4:
159+ w.FocusWindow(-1)
160+ case 5:
161+ w.FocusWindow(1)
162+ case 6:
163+ w.MoveColumn(-1)
164+ case 7:
165+ w.MoveColumn(1)
166+ case 8:
167+ w.CycleWidth()
168+ case 9:
169+ w.ToggleFullscreen()
170+ case 10:
171+ w.SetGaps(int(op) % 10)
172+ case 11:
173+ w.SetOutput(int(op)-50, int(op)-80)
174+ case 12:
175+ w.SetUsable(Rect{X: int(op) % 20, Y: int(op) % 20, W: int(op) - 50, H: int(op) - 80})
176+ case 13:
177+ w.SetGaps(int(op) * 2)
178+ case 14:
179+ if len(w.Columns) > 1 {
180+ stackFocused(w)
181+ }
182+ case 15:
183+ w.SetDefaultWidth(Width{Num: math.MaxInt, Den: math.MaxInt})
184+ if len(w.Columns) > 0 {
185+ w.Columns[w.Focus].Width = w.DefaultWidth
186+ w.scroll()
187+ }
188+ case 16:
189+ w.SetOutput(0, 0)
190+ case 17:
191+ w.SetUsable(Rect{W: math.MaxInt, H: math.MaxInt})
192+ }
193+ seen := map[WindowID]bool{}
194+ for _, c := range w.Columns {
195+ if len(c.Windows) == 0 || c.Focus < 0 || c.Focus >= len(c.Windows) {
196+ t.Fatal("invalid column")
197+ }
198+ for _, id := range c.Windows {
199+ if seen[id] {
200+ t.Fatal("duplicate")
201+ }
202+ seen[id] = true
203+ }
204+ }
205+ if len(w.Columns) > 0 {
206+ if w.Focus < 0 || w.Focus >= len(w.Columns) {
207+ t.Fatal("focus")
208+ }
209+ left := w.columnX(w.Focus) - w.ViewX
210+ width := w.columnWidth(w.Focus)
211+ minX, maxX := w.Usable.X+w.gap(), w.Usable.X+w.Usable.W-w.gap()
212+ if w.fullscreenColumn(w.Focus) {
213+ minX, maxX = 0, w.Output.W
214+ }
215+ if width <= maxX-minX && (left < minX || left+width > maxX) {
216+ t.Fatal("scroll")
217+ }
218+ }
219+ placements := w.Layout()
220+ if len(placements) != len(seen) {
221+ t.Fatal("count")
222+ }
223+ fullCount := 0
224+ for _, p := range placements {
225+ if p.Fullscreen {
226+ fullCount++
227+ }
228+ r := p.Rect
229+ if r.W < 0 || r.H < 0 || r.Y < 0 || r.Y > w.Output.H || r.H > w.Output.H-r.Y {
230+ t.Fatalf("bounds: %+v output %+v", p, w.Output)
231+ }
232+ if p.Hidden && r != (Rect{}) {
233+ t.Fatal("hidden rect")
234+ }
235+ if !p.Hidden && !p.Fullscreen && (r.Y < w.Usable.Y || r.Y > w.Usable.Y+w.Usable.H || r.H > w.Usable.Y+w.Usable.H-r.Y) {
236+ t.Fatalf("usable vertical bounds: %+v usable %+v", p, w.Usable)
237+ }
238+ if !seen[p.ID] {
239+ t.Fatal("missing")
240+ }
241+ delete(seen, p.ID)
242+ }
243+ if fullCount > 1 {
244+ t.Fatal("multiple fullscreen")
245+ }
246+ }
247+ })
248+}
249+
250+// stackFocused moves the next column's window into the focused column.
251+func stackFocused(w *Workspace) {
252+ i := w.Focus
253+ j := (i + 1) % len(w.Columns)
254+ id := w.Columns[j].Windows[0]
255+ w.Columns[i].Windows = append(w.Columns[i].Windows, id)
256+ w.Columns[j].Windows = w.Columns[j].Windows[1:]
257+ if len(w.Columns[j].Windows) == 0 {
258+ w.Columns = append(w.Columns[:j], w.Columns[j+1:]...)
259+ if j < i {
260+ w.Focus--
261+ }
262+ } else if w.Columns[j].Focus >= len(w.Columns[j].Windows) {
263+ w.Columns[j].Focus = len(w.Columns[j].Windows) - 1
264+ }
265+ w.scroll()
266+}
267+
268+func TestLargeWidthAndBounds(t *testing.T) {
269+ for _, tc := range []struct {
270+ width Width
271+ usable, gaps, want int
272+ }{
273+ {Width{Num: 1, Den: 1}, math.MaxInt, 0, math.MaxInt},
274+ {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 0, math.MaxInt},
275+ {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 5, math.MaxInt - 10},
276+ {Width{Num: 1, Den: 2}, math.MaxInt, 0, (math.MaxInt + 1) / 2},
277+ {Width{Num: math.MaxInt, Den: math.MaxInt}, 100, 5, 90},
278+ } {
279+ if got := tc.width.Resolve(tc.usable, tc.gaps); got != tc.want {
280+ t.Errorf("Resolve(%+v, %d, %d) = %d; want %d", tc.width, tc.usable, tc.gaps, got, tc.want)
281+ }
282+ }
283+ w := workspace()
284+ w.AddWindow(1)
285+ w.SetUsable(Rect{X: -1, Y: -2, W: -10, H: math.MaxInt})
286+ w.SetGaps(math.MaxInt)
287+ for _, p := range w.Layout() {
288+ if p.Rect.W != 0 || p.Rect.H < 0 {
289+ t.Fatal(p)
290+ }
291+ }
292+ w.SetOutput(-1, -1)
293+ if w.Output.W != 0 || w.Output.H != 0 {
294+ t.Fatal(w.Output)
295+ }
296+}
297+func TestUnchangedUsablePreservesViewX(t *testing.T) {
298+ w := workspace()
299+ w.AddWindow(1)
300+ w.AddWindow(2)
301+ w.AddWindow(3)
302+ before := w.ViewX
303+ if before == 0 {
304+ t.Fatal("expected a scrolled workspace")
305+ }
306+ w.SetUsable(w.Usable)
307+ if w.ViewX != before {
308+ t.Fatalf("ViewX changed from %d to %d", before, w.ViewX)
309+ }
310+}
311+
312+func TestFullscreenPersistsAndScrolls(t *testing.T) {
313+ w := workspace()
314+ w.AddWindow(1)
315+ w.AddWindow(2)
316+ w.FocusColumn(-1)
317+ w.ToggleFullscreen()
318+ if p := w.Layout()[0]; !p.Fullscreen || p.Rect.X != 0 || p.Rect.W != 100 {
319+ t.Fatal(p)
320+ }
321+ w.FocusColumn(1)
322+ if p := w.Layout()[0]; !p.Fullscreen || p.Rect.X >= 0 {
323+ t.Fatal(p)
324+ }
325+ w.ToggleFullscreen()
326+ if w.Layout()[0].Fullscreen || !w.Layout()[1].Fullscreen {
327+ t.Fatal(w.Layout())
328+ }
329+ w.RemoveWindow(2)
330+ if w.Layout()[0].Fullscreen {
331+ t.Fatal(w.Layout())
332+ }
333+}
@@ -0,0 +1,333 @@
1+package core
2+
3+import (
4+ "math"
5+ "testing"
6+)
7+
8+func workspace() *Workspace {
9+ w := &Workspace{}
10+ w.SetOutput(100, 81)
11+ w.SetGaps(5)
12+ w.SetDefaultWidth(Width{Num: 1, Den: 2})
13+ return w
14+}
15+func TestWidths(t *testing.T) {
16+ for _, tc := range []struct {
17+ s string
18+ ok bool
19+ pixels int
20+ }{{"1", true, 0}, {"1/2", true, 0}, {"25px", true, 25}, {"2/1", false, 0}, {"0px", false, 0}, {"1/0", false, 0}, {"abc", false, 0}} {
21+ v, e := ParseWidth(tc.s)
22+ if (e == nil) != tc.ok || (e == nil && v.Pixels != tc.pixels) {
23+ t.Errorf("%q: %v %v", tc.s, v, e)
24+ }
25+ }
26+ if n := (Width{Num: 1, Den: 2}).Resolve(100, 5); n != 43 {
27+ t.Fatal(n)
28+ }
29+ if n := (Width{Pixels: 200}).Resolve(100, 5); n != 90 {
30+ t.Fatal(n)
31+ }
32+}
33+func TestOperations(t *testing.T) {
34+ w := workspace()
35+ w.FocusColumn(1)
36+ w.FocusWindow(-1)
37+ w.MoveColumn(1)
38+ w.CycleWidth()
39+ w.ToggleFullscreen()
40+ w.RemoveWindow(1)
41+ if _, ok := w.Focused(); ok {
42+ t.Fatal("empty focus")
43+ }
44+ w.AddWindow(1)
45+ w.AddWindow(2)
46+ w.AddWindow(3)
47+ if w.Focus != 2 || w.ViewX != 49 {
48+ t.Fatalf("scroll: %+v", w)
49+ }
50+ w.FocusColumn(-1)
51+ if w.ViewX != 48 {
52+ t.Fatal(w.ViewX)
53+ }
54+ w.FocusColumn(-1)
55+ if w.ViewX != 0 {
56+ t.Fatal(w.ViewX)
57+ }
58+ w.MoveColumn(1)
59+ if w.Focus != 1 || w.Columns[1].Windows[0] != 1 {
60+ t.Fatal(w.Columns)
61+ }
62+ w.RemoveWindow(1)
63+ if id, _ := w.Focused(); id != 2 {
64+ t.Fatal(id)
65+ }
66+ w.RemoveWindow(3)
67+ w.RemoveWindow(2)
68+ if len(w.Columns) != 0 {
69+ t.Fatal(w.Columns)
70+ }
71+}
72+func TestStackAndLayout(t *testing.T) {
73+ w := workspace()
74+ w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Width: Width{Num: 1, Den: 2}, Focus: 1}}
75+ w.RemoveWindow(2)
76+ if id, _ := w.Focused(); id != 1 {
77+ t.Fatal(id)
78+ }
79+ w.FocusWindow(1)
80+ if id, _ := w.Focused(); id != 3 {
81+ t.Fatal(id)
82+ }
83+ w.FocusWindow(-1)
84+ p := w.Layout()
85+ if p[0].Rect != (Rect{5, 5, 43, 33}) || p[1].Rect != (Rect{5, 43, 43, 33}) {
86+ t.Fatal(p)
87+ }
88+ w.ToggleFullscreen()
89+ if p = w.Layout(); !p[0].Fullscreen || p[0].Rect != (Rect{X: 0, Y: 0, W: 100, H: 81}) || !p[1].Hidden || p[1].Rect != (Rect{}) {
90+ t.Fatal(p)
91+ }
92+ w.ToggleFullscreen()
93+ if w.Layout()[0].Fullscreen {
94+ t.Fatal("toggle")
95+ }
96+ w.SetUsable(Rect{10, 10, 60, 50})
97+ w.SetGaps(2)
98+ if w.Layout()[0].Rect.X != 12 {
99+ t.Fatal(w.Layout())
100+ }
101+ w.SetOutput(120, 90)
102+ if w.Usable.W != 60 {
103+ t.Fatal(w.Usable)
104+ }
105+}
106+func TestPresetsAndActions(t *testing.T) {
107+ w := workspace()
108+ w.AddWindow(1)
109+ p := []Width{{Pixels: 30}, {Pixels: 40}}
110+ w.SetPresets(p)
111+ p[0].Pixels = 99
112+ w.Apply(ActionCycleColumnWidth)
113+ if w.Columns[0].Width.Pixels != 30 {
114+ t.Fatal(w.Columns)
115+ }
116+ w.CycleWidth()
117+ if w.Columns[0].Width.Pixels != 40 {
118+ t.Fatal(w.Columns)
119+ }
120+ w.CycleWidth()
121+ if w.Columns[0].Width.Pixels != 30 {
122+ t.Fatal(w.Columns)
123+ }
124+ for _, tc := range []struct {
125+ a Action
126+ want Effect
127+ }{{ActionSpawnTerminal, Effect{Spawn: true}}, {ActionCloseWindow, Effect{Close: 1}}, {ActionQuit, Effect{Quit: true}}, {ActionFocusColumnLeft, Effect{}}, {ActionFocusColumnRight, Effect{}}, {ActionFocusWindowUp, Effect{}}, {ActionFocusWindowDown, Effect{}}, {ActionMoveColumnLeft, Effect{}}, {ActionMoveColumnRight, Effect{}}, {ActionToggleFullscreen, Effect{}}, {ActionCycleColumnWidth, Effect{}}} {
128+ if got := w.Apply(tc.a); got != tc.want {
129+ t.Errorf("%s: %+v", tc.a, got)
130+ }
131+ }
132+ w.SetPresets(nil)
133+ old := w.Columns[0].Width
134+ w.CycleWidth()
135+ if w.Columns[0].Width != old {
136+ t.Fatal("empty presets")
137+ }
138+}
139+func FuzzWorkspaceOps(f *testing.F) {
140+ f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
141+ f.Fuzz(func(t *testing.T, ops []byte) {
142+ w := workspace()
143+ w.SetPresets([]Width{{Pixels: 25}, {Pixels: 50}})
144+ next := WindowID(1)
145+ for _, op := range ops {
146+ switch op % 18 {
147+ case 0:
148+ w.AddWindow(next)
149+ next++
150+ case 1:
151+ if id, ok := w.Focused(); ok {
152+ w.RemoveWindow(id)
153+ }
154+ case 2:
155+ w.FocusColumn(-1)
156+ case 3:
157+ w.FocusColumn(1)
158+ case 4:
159+ w.FocusWindow(-1)
160+ case 5:
161+ w.FocusWindow(1)
162+ case 6:
163+ w.MoveColumn(-1)
164+ case 7:
165+ w.MoveColumn(1)
166+ case 8:
167+ w.CycleWidth()
168+ case 9:
169+ w.ToggleFullscreen()
170+ case 10:
171+ w.SetGaps(int(op) % 10)
172+ case 11:
173+ w.SetOutput(int(op)-50, int(op)-80)
174+ case 12:
175+ w.SetUsable(Rect{X: int(op) % 20, Y: int(op) % 20, W: int(op) - 50, H: int(op) - 80})
176+ case 13:
177+ w.SetGaps(int(op) * 2)
178+ case 14:
179+ if len(w.Columns) > 1 {
180+ stackFocused(w)
181+ }
182+ case 15:
183+ w.SetDefaultWidth(Width{Num: math.MaxInt, Den: math.MaxInt})
184+ if len(w.Columns) > 0 {
185+ w.Columns[w.Focus].Width = w.DefaultWidth
186+ w.scroll()
187+ }
188+ case 16:
189+ w.SetOutput(0, 0)
190+ case 17:
191+ w.SetUsable(Rect{W: math.MaxInt, H: math.MaxInt})
192+ }
193+ seen := map[WindowID]bool{}
194+ for _, c := range w.Columns {
195+ if len(c.Windows) == 0 || c.Focus < 0 || c.Focus >= len(c.Windows) {
196+ t.Fatal("invalid column")
197+ }
198+ for _, id := range c.Windows {
199+ if seen[id] {
200+ t.Fatal("duplicate")
201+ }
202+ seen[id] = true
203+ }
204+ }
205+ if len(w.Columns) > 0 {
206+ if w.Focus < 0 || w.Focus >= len(w.Columns) {
207+ t.Fatal("focus")
208+ }
209+ left := w.columnX(w.Focus) - w.ViewX
210+ width := w.columnWidth(w.Focus)
211+ minX, maxX := w.Usable.X+w.gap(), w.Usable.X+w.Usable.W-w.gap()
212+ if w.fullscreenColumn(w.Focus) {
213+ minX, maxX = 0, w.Output.W
214+ }
215+ if width <= maxX-minX && (left < minX || left+width > maxX) {
216+ t.Fatal("scroll")
217+ }
218+ }
219+ placements := w.Layout()
220+ if len(placements) != len(seen) {
221+ t.Fatal("count")
222+ }
223+ fullCount := 0
224+ for _, p := range placements {
225+ if p.Fullscreen {
226+ fullCount++
227+ }
228+ r := p.Rect
229+ if r.W < 0 || r.H < 0 || r.Y < 0 || r.Y > w.Output.H || r.H > w.Output.H-r.Y {
230+ t.Fatalf("bounds: %+v output %+v", p, w.Output)
231+ }
232+ if p.Hidden && r != (Rect{}) {
233+ t.Fatal("hidden rect")
234+ }
235+ if !p.Hidden && !p.Fullscreen && (r.Y < w.Usable.Y || r.Y > w.Usable.Y+w.Usable.H || r.H > w.Usable.Y+w.Usable.H-r.Y) {
236+ t.Fatalf("usable vertical bounds: %+v usable %+v", p, w.Usable)
237+ }
238+ if !seen[p.ID] {
239+ t.Fatal("missing")
240+ }
241+ delete(seen, p.ID)
242+ }
243+ if fullCount > 1 {
244+ t.Fatal("multiple fullscreen")
245+ }
246+ }
247+ })
248+}
249+
250+// stackFocused moves the next column's window into the focused column.
251+func stackFocused(w *Workspace) {
252+ i := w.Focus
253+ j := (i + 1) % len(w.Columns)
254+ id := w.Columns[j].Windows[0]
255+ w.Columns[i].Windows = append(w.Columns[i].Windows, id)
256+ w.Columns[j].Windows = w.Columns[j].Windows[1:]
257+ if len(w.Columns[j].Windows) == 0 {
258+ w.Columns = append(w.Columns[:j], w.Columns[j+1:]...)
259+ if j < i {
260+ w.Focus--
261+ }
262+ } else if w.Columns[j].Focus >= len(w.Columns[j].Windows) {
263+ w.Columns[j].Focus = len(w.Columns[j].Windows) - 1
264+ }
265+ w.scroll()
266+}
267+
268+func TestLargeWidthAndBounds(t *testing.T) {
269+ for _, tc := range []struct {
270+ width Width
271+ usable, gaps, want int
272+ }{
273+ {Width{Num: 1, Den: 1}, math.MaxInt, 0, math.MaxInt},
274+ {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 0, math.MaxInt},
275+ {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 5, math.MaxInt - 10},
276+ {Width{Num: 1, Den: 2}, math.MaxInt, 0, (math.MaxInt + 1) / 2},
277+ {Width{Num: math.MaxInt, Den: math.MaxInt}, 100, 5, 90},
278+ } {
279+ if got := tc.width.Resolve(tc.usable, tc.gaps); got != tc.want {
280+ t.Errorf("Resolve(%+v, %d, %d) = %d; want %d", tc.width, tc.usable, tc.gaps, got, tc.want)
281+ }
282+ }
283+ w := workspace()
284+ w.AddWindow(1)
285+ w.SetUsable(Rect{X: -1, Y: -2, W: -10, H: math.MaxInt})
286+ w.SetGaps(math.MaxInt)
287+ for _, p := range w.Layout() {
288+ if p.Rect.W != 0 || p.Rect.H < 0 {
289+ t.Fatal(p)
290+ }
291+ }
292+ w.SetOutput(-1, -1)
293+ if w.Output.W != 0 || w.Output.H != 0 {
294+ t.Fatal(w.Output)
295+ }
296+}
297+func TestUnchangedUsablePreservesViewX(t *testing.T) {
298+ w := workspace()
299+ w.AddWindow(1)
300+ w.AddWindow(2)
301+ w.AddWindow(3)
302+ before := w.ViewX
303+ if before == 0 {
304+ t.Fatal("expected a scrolled workspace")
305+ }
306+ w.SetUsable(w.Usable)
307+ if w.ViewX != before {
308+ t.Fatalf("ViewX changed from %d to %d", before, w.ViewX)
309+ }
310+}
311+
312+func TestFullscreenPersistsAndScrolls(t *testing.T) {
313+ w := workspace()
314+ w.AddWindow(1)
315+ w.AddWindow(2)
316+ w.FocusColumn(-1)
317+ w.ToggleFullscreen()
318+ if p := w.Layout()[0]; !p.Fullscreen || p.Rect.X != 0 || p.Rect.W != 100 {
319+ t.Fatal(p)
320+ }
321+ w.FocusColumn(1)
322+ if p := w.Layout()[0]; !p.Fullscreen || p.Rect.X >= 0 {
323+ t.Fatal(p)
324+ }
325+ w.ToggleFullscreen()
326+ if w.Layout()[0].Fullscreen || !w.Layout()[1].Fullscreen {
327+ t.Fatal(w.Layout())
328+ }
329+ w.RemoveWindow(2)
330+ if w.Layout()[0].Fullscreen {
331+ t.Fatal(w.Layout())
332+ }
333+}

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 26

Tags 10

↑↓ moveenter openesc close