feat(core): add scrollable column layout model
- commit
- cf75d0101623526f9e034218228e52ec0194f52f
- author
- brice · · 2026-09-23 10:52 UTC
- parent
- 42465c1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||