feat(layout): add axis-aware cascade bands and navigation
- commit
- d0eb8af122de713259b7c721f7cbe9eafae085bf
- author
- brice · · 2026-10-06 07:44 UTC
- parent
- 2decdfb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
24 files · +397 -90
M docs/config.md +11 -1
| @@ -30,7 +30,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 30 | 30 | | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up | | |
| 31 | 31 | | `layout.gaps` | `0` | Space between windows | | |
| 32 | 32 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | |
| 33 | - | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | |
| 33 | + | | `layout.overflow` | `scroll` | Past the max: `scroll` right, `fixed` spiral split, or `cascade` into vertically scrolling bands | | |
| 34 | 34 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | |
| 35 | 35 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) | | |
| 36 | 36 | | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) | | |
| @@ -187,6 +187,16 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 187 | 187 | - `none` removes a default bind. | |
| 188 | 188 | - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands). | |
| 189 | 189 | ||
| 190 | + | ### Cascade | |
| 191 | + | ||
| 192 | + | Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill equal-width cells, up to `max-columns` per band, then continue in a fresh band below. Incomplete bands keep empty cells. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations. | |
| 193 | + | ||
| 194 | + | Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends. | |
| 195 | + | ||
| 196 | + | Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-up/down` commands still mean previous/next workspace, even when the transition is horizontal. Four-finger and stash gestures keep their existing behaviour. | |
| 197 | + | ||
| 198 | + | The overview presents bands side by side in one workspace card. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. | |
| 199 | + | ||
| 190 | 200 | ### Actions | |
| 191 | 201 | ||
| 192 | 202 | | Action | Effect | | |
| @@ -30,7 +30,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 30 | | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up | | 30 | | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up | | ||
| 31 | | `layout.gaps` | `0` | Space between windows | | 31 | | `layout.gaps` | `0` | Space between windows | | ||
| 32 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | 32 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | ||
| 33 | - | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | 33 | + | | `layout.overflow` | `scroll` | Past the max: `scroll` right, `fixed` spiral split, or `cascade` into vertically scrolling bands | |
| 34 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | 34 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | ||
| 35 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) | | 35 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) | | ||
| 36 | | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) | | 36 | | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) | | ||
| @@ -187,6 +187,16 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 187 | - `none` removes a default bind. | 187 | - `none` removes a default bind. | ||
| 188 | - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands). | 188 | - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands). | ||
| 189 | 189 | ||||
| 190 | + | ### Cascade | |||
| 191 | + | ||||
| 192 | + | Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill equal-width cells, up to `max-columns` per band, then continue in a fresh band below. Incomplete bands keep empty cells. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations. | |||
| 193 | + | ||||
| 194 | + | Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends. | |||
| 195 | + | ||||
| 196 | + | Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-up/down` commands still mean previous/next workspace, even when the transition is horizontal. Four-finger and stash gestures keep their existing behaviour. | |||
| 197 | + | ||||
| 198 | + | The overview presents bands side by side in one workspace card. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. | |||
| 199 | + | ||||
| 190 | ### Actions | 200 | ### Actions | ||
| 191 | 201 | ||||
| 192 | | Action | Effect | | 202 | | Action | Effect | | ||
M internal/adapters/config/config.go +4 -4
| @@ -914,8 +914,8 @@ func setLayoutRule(r *ports.LayoutRules, field, v string) error { | |||
| 914 | 914 | case "max-columns": | |
| 915 | 915 | return positive(&r.MaxColumns, v, 16) | |
| 916 | 916 | case "overflow": | |
| 917 | - | if v != "scroll" && v != "fixed" { | |
| 918 | - | return fmt.Errorf("must be scroll or fixed") | |
| 917 | + | if v != "scroll" && v != "fixed" && v != "cascade" { | |
| 918 | + | return fmt.Errorf("must be scroll, fixed or cascade") | |
| 919 | 919 | } | |
| 920 | 920 | r.Overflow = v | |
| 921 | 921 | return nil | |
| @@ -952,9 +952,9 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning { | |||
| 952 | 952 | rules = o.Over(rules) | |
| 953 | 953 | } | |
| 954 | 954 | } | |
| 955 | - | if w.Over(rules).Overflow == "fixed" && len(w.Slots) > 0 { | |
| 955 | + | if overflow := w.Over(rules).Overflow; (overflow == "fixed" || overflow == "cascade") && len(w.Slots) > 0 { | |
| 956 | 956 | line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)] | |
| 957 | - | warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)}) | |
| 957 | + | warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = %s (columns share the width)", w.Name, overflow)}) | |
| 958 | 958 | } | |
| 959 | 959 | if !bound[w.Name] { | |
| 960 | 960 | line := 0 | |
| @@ -914,8 +914,8 @@ func setLayoutRule(r *ports.LayoutRules, field, v string) error { | |||||
| 914 | case "max-columns": | 914 | case "max-columns": | ||
| 915 | return positive(&r.MaxColumns, v, 16) | 915 | return positive(&r.MaxColumns, v, 16) | ||
| 916 | case "overflow": | 916 | case "overflow": | ||
| 917 | - | if v != "scroll" && v != "fixed" { | 917 | + | if v != "scroll" && v != "fixed" && v != "cascade" { |
| 918 | - | return fmt.Errorf("must be scroll or fixed") | 918 | + | return fmt.Errorf("must be scroll, fixed or cascade") |
| 919 | } | 919 | } | ||
| 920 | r.Overflow = v | 920 | r.Overflow = v | ||
| 921 | return nil | 921 | return nil | ||
| @@ -952,9 +952,9 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning { | |||||
| 952 | rules = o.Over(rules) | 952 | rules = o.Over(rules) | ||
| 953 | } | 953 | } | ||
| 954 | } | 954 | } | ||
| 955 | - | if w.Over(rules).Overflow == "fixed" && len(w.Slots) > 0 { | 955 | + | if overflow := w.Over(rules).Overflow; (overflow == "fixed" || overflow == "cascade") && len(w.Slots) > 0 { |
| 956 | line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)] | 956 | line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)] | ||
| 957 | - | warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)}) | 957 | + | warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = %s (columns share the width)", w.Name, overflow)}) |
| 958 | } | 958 | } | ||
| 959 | if !bound[w.Name] { | 959 | if !bound[w.Name] { | ||
| 960 | line := 0 | 960 | line := 0 | ||
A internal/core/cascade.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "slices" | |
| 4 | + | ||
| 5 | + | func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) } | |
| 6 | + | func (w *Workspace) bandNeighbor(dir int) int { | |
| 7 | + | k := max(w.MaxColumns, 1) | |
| 8 | + | b := w.band(w.Focus) + dir | |
| 9 | + | if b < 0 || b*k >= len(w.Columns) { | |
| 10 | + | return -1 | |
| 11 | + | } | |
| 12 | + | return min(b*k+w.Focus%k, len(w.Columns)-1) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |
| 16 | + | k, g := max(w.MaxColumns, 1), w.gap() | |
| 17 | + | width := max((w.Usable.W-g*(k+1))/k, 0) | |
| 18 | + | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 19 | + | view := w.View + w.shiftPixels() | |
| 20 | + | for i := range w.Columns { | |
| 21 | + | r := Rect{X: w.Usable.X + g + (i%k)*(width+g), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)} | |
| 22 | + | if w.Columns[i].FullWidth && !ignoreFullWidth { | |
| 23 | + | r.X = w.Usable.X + g | |
| 24 | + | r.W = max(w.Usable.W-2*g, 0) | |
| 25 | + | } | |
| 26 | + | rects[i] = r | |
| 27 | + | } | |
| 28 | + | return rects | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (w *Workspace) cascadePoints() []float64 { | |
| 32 | + | if len(w.Columns) == 0 || w.Usable.H <= 0 { | |
| 33 | + | return []float64{0} | |
| 34 | + | } | |
| 35 | + | points := make([]float64, w.band(len(w.Columns)-1)+1) | |
| 36 | + | for i := range points { | |
| 37 | + | points[i] = float64(i * w.Usable.H) | |
| 38 | + | } | |
| 39 | + | return points | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (w *Workspace) cascadeFocus(view int) int { | |
| 43 | + | if len(w.Columns) == 0 { | |
| 44 | + | return 0 | |
| 45 | + | } | |
| 46 | + | k := max(w.MaxColumns, 1) | |
| 47 | + | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | |
| 48 | + | return min(b*k+w.Focus%k, len(w.Columns)-1) | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "slices" | |||
| 4 | + | ||||
| 5 | + | func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) } | |||
| 6 | + | func (w *Workspace) bandNeighbor(dir int) int { | |||
| 7 | + | k := max(w.MaxColumns, 1) | |||
| 8 | + | b := w.band(w.Focus) + dir | |||
| 9 | + | if b < 0 || b*k >= len(w.Columns) { | |||
| 10 | + | return -1 | |||
| 11 | + | } | |||
| 12 | + | return min(b*k+w.Focus%k, len(w.Columns)-1) | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||
| 16 | + | k, g := max(w.MaxColumns, 1), w.gap() | |||
| 17 | + | width := max((w.Usable.W-g*(k+1))/k, 0) | |||
| 18 | + | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |||
| 19 | + | view := w.View + w.shiftPixels() | |||
| 20 | + | for i := range w.Columns { | |||
| 21 | + | r := Rect{X: w.Usable.X + g + (i%k)*(width+g), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)} | |||
| 22 | + | if w.Columns[i].FullWidth && !ignoreFullWidth { | |||
| 23 | + | r.X = w.Usable.X + g | |||
| 24 | + | r.W = max(w.Usable.W-2*g, 0) | |||
| 25 | + | } | |||
| 26 | + | rects[i] = r | |||
| 27 | + | } | |||
| 28 | + | return rects | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (w *Workspace) cascadePoints() []float64 { | |||
| 32 | + | if len(w.Columns) == 0 || w.Usable.H <= 0 { | |||
| 33 | + | return []float64{0} | |||
| 34 | + | } | |||
| 35 | + | points := make([]float64, w.band(len(w.Columns)-1)+1) | |||
| 36 | + | for i := range points { | |||
| 37 | + | points[i] = float64(i * w.Usable.H) | |||
| 38 | + | } | |||
| 39 | + | return points | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (w *Workspace) cascadeFocus(view int) int { | |||
| 43 | + | if len(w.Columns) == 0 { | |||
| 44 | + | return 0 | |||
| 45 | + | } | |||
| 46 | + | k := max(w.MaxColumns, 1) | |||
| 47 | + | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | |||
| 48 | + | return min(b*k+w.Focus%k, len(w.Columns)-1) | |||
| 49 | + | } | |||
A internal/core/cascade_swipe_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestCascadeSwipeAxes(t *testing.T) { | |
| 9 | + | r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3 }) | |
| 10 | + | for i := 1; i <= 7; i++ { | |
| 11 | + | r.mapWindow(t, ports.WindowID(i)) | |
| 12 | + | } | |
| 13 | + | r.begin() | |
| 14 | + | s := r.move(t, 0, -400) | |
| 15 | + | band, ok := rectOf(s, 7) | |
| 16 | + | if !ok || band.X != 0 || band.Y <= 0 { | |
| 17 | + | t.Fatalf("band swipe must move vertically: %v, shown=%v", band, ok) | |
| 18 | + | } | |
| 19 | + | r.end(t, false) | |
| 20 | + | r.settleAll(t, r.outs...) | |
| 21 | + | r.begin() | |
| 22 | + | s = r.move(t, 400, 0) | |
| 23 | + | workspace, ok := rectOf(s, 7) | |
| 24 | + | if !ok || workspace.X == 0 || workspace.Y != 0 { | |
| 25 | + | t.Fatalf("workspace swipe must move horizontally: %v, shown=%v", workspace, ok) | |
| 26 | + | } | |
| 27 | + | r.end(t, true) | |
| 28 | + | r.settleAll(t, r.outs...) | |
| 29 | + | } | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestCascadeSwipeAxes(t *testing.T) { | |||
| 9 | + | r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3 }) | |||
| 10 | + | for i := 1; i <= 7; i++ { | |||
| 11 | + | r.mapWindow(t, ports.WindowID(i)) | |||
| 12 | + | } | |||
| 13 | + | r.begin() | |||
| 14 | + | s := r.move(t, 0, -400) | |||
| 15 | + | band, ok := rectOf(s, 7) | |||
| 16 | + | if !ok || band.X != 0 || band.Y <= 0 { | |||
| 17 | + | t.Fatalf("band swipe must move vertically: %v, shown=%v", band, ok) | |||
| 18 | + | } | |||
| 19 | + | r.end(t, false) | |||
| 20 | + | r.settleAll(t, r.outs...) | |||
| 21 | + | r.begin() | |||
| 22 | + | s = r.move(t, 400, 0) | |||
| 23 | + | workspace, ok := rectOf(s, 7) | |||
| 24 | + | if !ok || workspace.X == 0 || workspace.Y != 0 { | |||
| 25 | + | t.Fatalf("workspace swipe must move horizontally: %v, shown=%v", workspace, ok) | |||
| 26 | + | } | |||
| 27 | + | r.end(t, true) | |||
| 28 | + | r.settleAll(t, r.outs...) | |||
| 29 | + | } | |||
A internal/core/cascade_test.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func cascadeWorkspace(n int) *Workspace { | |
| 9 | + | w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3} | |
| 10 | + | w.SetOutput(900, 600) | |
| 11 | + | for i := 1; i <= n; i++ { | |
| 12 | + | w.AddWindow(WindowID(i)) | |
| 13 | + | } | |
| 14 | + | return w | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func TestCascadeBandsAndReveal(t *testing.T) { | |
| 18 | + | for _, n := range []int{0, 1, 3, 4, 7} { | |
| 19 | + | w := cascadeWorkspace(n) | |
| 20 | + | ps := w.Layout() | |
| 21 | + | if len(ps) != n { | |
| 22 | + | t.Fatalf("n=%d placements=%d", n, len(ps)) | |
| 23 | + | } | |
| 24 | + | for i, p := range ps { | |
| 25 | + | want := Rect{X: i % 3 * 300, Y: (i/3)*600 - w.View, W: 300, H: 600} | |
| 26 | + | if p.Rect != want { | |
| 27 | + | t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | if n > 0 && w.View != ((n-1)/3)*600 { | |
| 31 | + | t.Fatalf("n=%d view=%d", n, w.View) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestCascadeCompactionAndNavigation(t *testing.T) { | |
| 37 | + | w := cascadeWorkspace(7) | |
| 38 | + | w.FocusID(2) | |
| 39 | + | if !w.FocusWindow(1) { | |
| 40 | + | t.Fatal("no lower band") | |
| 41 | + | } | |
| 42 | + | if id, _ := w.Focused(); id != 5 { | |
| 43 | + | t.Fatalf("focus=%d", id) | |
| 44 | + | } | |
| 45 | + | w.RemoveWindow(1) | |
| 46 | + | if w.View != 600 { | |
| 47 | + | t.Fatalf("compacted view=%d", w.View) | |
| 48 | + | } | |
| 49 | + | w.SetMaxColumns(2) | |
| 50 | + | if w.View != 600 { | |
| 51 | + | t.Fatalf("changed max view=%d", w.View) | |
| 52 | + | } | |
| 53 | + | w.FocusID(2) | |
| 54 | + | w.AddWindow(8) | |
| 55 | + | if w.Columns[len(w.Columns)-1].Windows[0] != 8 { | |
| 56 | + | t.Fatal("new column did not append") | |
| 57 | + | } | |
| 58 | + | w.SetUsable(Rect{X: 0, Y: 20, W: 900, H: 580}) | |
| 59 | + | if w.View != w.band(w.Focus)*580 { | |
| 60 | + | t.Fatal("resize did not reveal band") | |
| 61 | + | } | |
| 62 | + | if !slices.Equal(w.snapPoints(), []float64{0, 580, 1160, 1740}) { | |
| 63 | + | t.Fatalf("points=%v", w.snapPoints()) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestCascadeCovers(t *testing.T) { | |
| 68 | + | w := cascadeWorkspace(4) | |
| 69 | + | w.FocusID(2) | |
| 70 | + | w.ToggleFullWidth() | |
| 71 | + | ps := w.Layout() | |
| 72 | + | for _, p := range ps { | |
| 73 | + | if p.ID == 2 && p.Rect.W != 900 { | |
| 74 | + | t.Fatalf("full width=%v", p.Rect) | |
| 75 | + | } | |
| 76 | + | if (p.ID == 1 || p.ID == 3) && !p.Hidden { | |
| 77 | + | t.Fatalf("sibling %d visible", p.ID) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | w.ToggleFullWidth() | |
| 81 | + | w.ToggleFullscreen() | |
| 82 | + | for _, p := range w.Layout() { | |
| 83 | + | if p.ID == 2 && p.Rect != w.Output { | |
| 84 | + | t.Fatalf("fullscreen=%v", p.Rect) | |
| 85 | + | } | |
| 86 | + | if p.ID != 2 && !p.Hidden { | |
| 87 | + | t.Fatalf("fullscreen sibling %d visible", p.ID) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | w.ToggleFullscreen() | |
| 91 | + | w.FocusID(4) | |
| 92 | + | if w.View != 600 { | |
| 93 | + | t.Fatal("fullscreen restore lost band reveal") | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestCascadeViewportSnapAndPolicy(t *testing.T) { | |
| 98 | + | w := cascadeWorkspace(7) | |
| 99 | + | w.FocusID(3) | |
| 100 | + | if w.snapFocus(600, true) != 5 { | |
| 101 | + | t.Fatalf("nearest cell=%d", w.snapFocus(600, true)) | |
| 102 | + | } | |
| 103 | + | if w.snapFocus(1200, true) != 6 { | |
| 104 | + | t.Fatal("short band neighbour") | |
| 105 | + | } | |
| 106 | + | if w.policy().content != verticalAxis || w.policy().workspace != horizontalAxis { | |
| 107 | + | t.Fatal("axes") | |
| 108 | + | } | |
| 109 | + | w.SetUsable(Rect{}) | |
| 110 | + | if got := w.snapPoints(); len(got) != 1 || got[0] != 0 { | |
| 111 | + | t.Fatalf("zero area=%v", got) | |
| 112 | + | } | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func cascadeWorkspace(n int) *Workspace { | |||
| 9 | + | w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3} | |||
| 10 | + | w.SetOutput(900, 600) | |||
| 11 | + | for i := 1; i <= n; i++ { | |||
| 12 | + | w.AddWindow(WindowID(i)) | |||
| 13 | + | } | |||
| 14 | + | return w | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestCascadeBandsAndReveal(t *testing.T) { | |||
| 18 | + | for _, n := range []int{0, 1, 3, 4, 7} { | |||
| 19 | + | w := cascadeWorkspace(n) | |||
| 20 | + | ps := w.Layout() | |||
| 21 | + | if len(ps) != n { | |||
| 22 | + | t.Fatalf("n=%d placements=%d", n, len(ps)) | |||
| 23 | + | } | |||
| 24 | + | for i, p := range ps { | |||
| 25 | + | want := Rect{X: i % 3 * 300, Y: (i/3)*600 - w.View, W: 300, H: 600} | |||
| 26 | + | if p.Rect != want { | |||
| 27 | + | t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | if n > 0 && w.View != ((n-1)/3)*600 { | |||
| 31 | + | t.Fatalf("n=%d view=%d", n, w.View) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestCascadeCompactionAndNavigation(t *testing.T) { | |||
| 37 | + | w := cascadeWorkspace(7) | |||
| 38 | + | w.FocusID(2) | |||
| 39 | + | if !w.FocusWindow(1) { | |||
| 40 | + | t.Fatal("no lower band") | |||
| 41 | + | } | |||
| 42 | + | if id, _ := w.Focused(); id != 5 { | |||
| 43 | + | t.Fatalf("focus=%d", id) | |||
| 44 | + | } | |||
| 45 | + | w.RemoveWindow(1) | |||
| 46 | + | if w.View != 600 { | |||
| 47 | + | t.Fatalf("compacted view=%d", w.View) | |||
| 48 | + | } | |||
| 49 | + | w.SetMaxColumns(2) | |||
| 50 | + | if w.View != 600 { | |||
| 51 | + | t.Fatalf("changed max view=%d", w.View) | |||
| 52 | + | } | |||
| 53 | + | w.FocusID(2) | |||
| 54 | + | w.AddWindow(8) | |||
| 55 | + | if w.Columns[len(w.Columns)-1].Windows[0] != 8 { | |||
| 56 | + | t.Fatal("new column did not append") | |||
| 57 | + | } | |||
| 58 | + | w.SetUsable(Rect{X: 0, Y: 20, W: 900, H: 580}) | |||
| 59 | + | if w.View != w.band(w.Focus)*580 { | |||
| 60 | + | t.Fatal("resize did not reveal band") | |||
| 61 | + | } | |||
| 62 | + | if !slices.Equal(w.snapPoints(), []float64{0, 580, 1160, 1740}) { | |||
| 63 | + | t.Fatalf("points=%v", w.snapPoints()) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestCascadeCovers(t *testing.T) { | |||
| 68 | + | w := cascadeWorkspace(4) | |||
| 69 | + | w.FocusID(2) | |||
| 70 | + | w.ToggleFullWidth() | |||
| 71 | + | ps := w.Layout() | |||
| 72 | + | for _, p := range ps { | |||
| 73 | + | if p.ID == 2 && p.Rect.W != 900 { | |||
| 74 | + | t.Fatalf("full width=%v", p.Rect) | |||
| 75 | + | } | |||
| 76 | + | if (p.ID == 1 || p.ID == 3) && !p.Hidden { | |||
| 77 | + | t.Fatalf("sibling %d visible", p.ID) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | w.ToggleFullWidth() | |||
| 81 | + | w.ToggleFullscreen() | |||
| 82 | + | for _, p := range w.Layout() { | |||
| 83 | + | if p.ID == 2 && p.Rect != w.Output { | |||
| 84 | + | t.Fatalf("fullscreen=%v", p.Rect) | |||
| 85 | + | } | |||
| 86 | + | if p.ID != 2 && !p.Hidden { | |||
| 87 | + | t.Fatalf("fullscreen sibling %d visible", p.ID) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | w.ToggleFullscreen() | |||
| 91 | + | w.FocusID(4) | |||
| 92 | + | if w.View != 600 { | |||
| 93 | + | t.Fatal("fullscreen restore lost band reveal") | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestCascadeViewportSnapAndPolicy(t *testing.T) { | |||
| 98 | + | w := cascadeWorkspace(7) | |||
| 99 | + | w.FocusID(3) | |||
| 100 | + | if w.snapFocus(600, true) != 5 { | |||
| 101 | + | t.Fatalf("nearest cell=%d", w.snapFocus(600, true)) | |||
| 102 | + | } | |||
| 103 | + | if w.snapFocus(1200, true) != 6 { | |||
| 104 | + | t.Fatal("short band neighbour") | |||
| 105 | + | } | |||
| 106 | + | if w.policy().content != verticalAxis || w.policy().workspace != horizontalAxis { | |||
| 107 | + | t.Fatal("axes") | |||
| 108 | + | } | |||
| 109 | + | w.SetUsable(Rect{}) | |||
| 110 | + | if got := w.snapPoints(); len(got) != 1 || got[0] != 0 { | |||
| 111 | + | t.Fatalf("zero area=%v", got) | |||
| 112 | + | } | |||
| 113 | + | } | |||
M internal/core/constraint_frame_test.go +1 -1
| @@ -27,7 +27,7 @@ func partlyShown(t *testing.T) *Core { | |||
| 27 | 27 | for id := WindowID(1); id <= 3; id++ { | |
| 28 | 28 | s.mon.AddWindow(id) | |
| 29 | 29 | } | |
| 30 | - | s.mon.Current().ViewX += 25 | |
| 30 | + | s.mon.Current().View += 25 | |
| 31 | 31 | return c | |
| 32 | 32 | } | |
| 33 | 33 | ||
| @@ -27,7 +27,7 @@ func partlyShown(t *testing.T) *Core { | |||||
| 27 | for id := WindowID(1); id <= 3; id++ { | 27 | for id := WindowID(1); id <= 3; id++ { | ||
| 28 | s.mon.AddWindow(id) | 28 | s.mon.AddWindow(id) | ||
| 29 | } | 29 | } | ||
| 30 | - | s.mon.Current().ViewX += 25 | 30 | + | s.mon.Current().View += 25 |
| 31 | return c | 31 | return c | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
M internal/core/core.go +1 -1
| @@ -251,7 +251,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 251 | 251 | } | |
| 252 | 252 | overflow := func(v string) (Overflow, error) { | |
| 253 | 253 | switch o := Overflow(v); o { | |
| 254 | - | case "", OverflowScroll, OverflowFixed: | |
| 254 | + | case "", OverflowScroll, OverflowFixed, OverflowCascade: | |
| 255 | 255 | return o, nil | |
| 256 | 256 | } | |
| 257 | 257 | return "", fmt.Errorf("invalid overflow %q", v) | |
| @@ -251,7 +251,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 251 | } | 251 | } | ||
| 252 | overflow := func(v string) (Overflow, error) { | 252 | overflow := func(v string) (Overflow, error) { | ||
| 253 | switch o := Overflow(v); o { | 253 | switch o := Overflow(v); o { | ||
| 254 | - | case "", OverflowScroll, OverflowFixed: | 254 | + | case "", OverflowScroll, OverflowFixed, OverflowCascade: |
| 255 | return o, nil | 255 | return o, nil | ||
| 256 | } | 256 | } | ||
| 257 | return "", fmt.Errorf("invalid overflow %q", v) | 257 | return "", fmt.Errorf("invalid overflow %q", v) | ||
M internal/core/drag.go +5 -5
| @@ -340,7 +340,7 @@ func (w *Workspace) dropIn(id WindowID, x, y, t int) dropTarget { | |||
| 340 | 340 | r := Rect{X: w.Usable.X + g, Y: w.Usable.Y + g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)} | |
| 341 | 341 | return dropTarget{kind: dropInsert, hints: outline(r, t)} | |
| 342 | 342 | } | |
| 343 | - | if w.Overflow != OverflowFixed { | |
| 343 | + | if !w.policy().equalCells { | |
| 344 | 344 | if tgt, ok := w.gapDrop(cols, id, src, lone, x, y, t); ok { | |
| 345 | 345 | return tgt | |
| 346 | 346 | } | |
| @@ -531,7 +531,7 @@ func (c *Core) edgeScroll(t time.Duration) bool { | |||
| 531 | 531 | } | |
| 532 | 532 | sc := c.screens[i] | |
| 533 | 533 | w := sc.mon.Current() | |
| 534 | - | if w.Overflow == OverflowFixed || len(w.Columns) == 0 || w.pinned() { | |
| 534 | + | if w.policy().equalCells || len(w.Columns) == 0 || w.pinned() { | |
| 535 | 535 | return false | |
| 536 | 536 | } | |
| 537 | 537 | lx := int(c.cursorX) - sc.x | |
| @@ -557,7 +557,7 @@ func (w *Workspace) scrollBy(dir int) bool { | |||
| 557 | 557 | g := w.gap() | |
| 558 | 558 | n := len(w.Columns) | |
| 559 | 559 | maxView := max(w.columnX(n-1)+w.columnWidth(n-1)+g-(w.Usable.X+w.Usable.W), 0) | |
| 560 | - | view := w.ViewX | |
| 560 | + | view := w.View | |
| 561 | 561 | if dir < 0 { | |
| 562 | 562 | // The first column not fully on screen at the left. | |
| 563 | 563 | for i := n - 1; i >= 0; i-- { | |
| @@ -575,11 +575,11 @@ func (w *Workspace) scrollBy(dir int) bool { | |||
| 575 | 575 | } | |
| 576 | 576 | } | |
| 577 | 577 | view = min(max(view, 0), maxView) | |
| 578 | - | if view == w.ViewX { | |
| 578 | + | if view == w.View { | |
| 579 | 579 | return false | |
| 580 | 580 | } | |
| 581 | 581 | w.view.stop() | |
| 582 | - | w.ViewX = view | |
| 582 | + | w.View = view | |
| 583 | 583 | return true | |
| 584 | 584 | } | |
| 585 | 585 | ||
| @@ -340,7 +340,7 @@ func (w *Workspace) dropIn(id WindowID, x, y, t int) dropTarget { | |||||
| 340 | r := Rect{X: w.Usable.X + g, Y: w.Usable.Y + g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)} | 340 | r := Rect{X: w.Usable.X + g, Y: w.Usable.Y + g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)} | ||
| 341 | return dropTarget{kind: dropInsert, hints: outline(r, t)} | 341 | return dropTarget{kind: dropInsert, hints: outline(r, t)} | ||
| 342 | } | 342 | } | ||
| 343 | - | if w.Overflow != OverflowFixed { | 343 | + | if !w.policy().equalCells { |
| 344 | if tgt, ok := w.gapDrop(cols, id, src, lone, x, y, t); ok { | 344 | if tgt, ok := w.gapDrop(cols, id, src, lone, x, y, t); ok { | ||
| 345 | return tgt | 345 | return tgt | ||
| 346 | } | 346 | } | ||
| @@ -531,7 +531,7 @@ func (c *Core) edgeScroll(t time.Duration) bool { | |||||
| 531 | } | 531 | } | ||
| 532 | sc := c.screens[i] | 532 | sc := c.screens[i] | ||
| 533 | w := sc.mon.Current() | 533 | w := sc.mon.Current() | ||
| 534 | - | if w.Overflow == OverflowFixed || len(w.Columns) == 0 || w.pinned() { | 534 | + | if w.policy().equalCells || len(w.Columns) == 0 || w.pinned() { |
| 535 | return false | 535 | return false | ||
| 536 | } | 536 | } | ||
| 537 | lx := int(c.cursorX) - sc.x | 537 | lx := int(c.cursorX) - sc.x | ||
| @@ -557,7 +557,7 @@ func (w *Workspace) scrollBy(dir int) bool { | |||||
| 557 | g := w.gap() | 557 | g := w.gap() | ||
| 558 | n := len(w.Columns) | 558 | n := len(w.Columns) | ||
| 559 | maxView := max(w.columnX(n-1)+w.columnWidth(n-1)+g-(w.Usable.X+w.Usable.W), 0) | 559 | maxView := max(w.columnX(n-1)+w.columnWidth(n-1)+g-(w.Usable.X+w.Usable.W), 0) | ||
| 560 | - | view := w.ViewX | 560 | + | view := w.View |
| 561 | if dir < 0 { | 561 | if dir < 0 { | ||
| 562 | // The first column not fully on screen at the left. | 562 | // The first column not fully on screen at the left. | ||
| 563 | for i := n - 1; i >= 0; i-- { | 563 | for i := n - 1; i >= 0; i-- { | ||
| @@ -575,11 +575,11 @@ func (w *Workspace) scrollBy(dir int) bool { | |||||
| 575 | } | 575 | } | ||
| 576 | } | 576 | } | ||
| 577 | view = min(max(view, 0), maxView) | 577 | view = min(max(view, 0), maxView) | ||
| 578 | - | if view == w.ViewX { | 578 | + | if view == w.View { |
| 579 | return false | 579 | return false | ||
| 580 | } | 580 | } | ||
| 581 | w.view.stop() | 581 | w.view.stop() | ||
| 582 | - | w.ViewX = view | 582 | + | w.View = view |
| 583 | return true | 583 | return true | ||
| 584 | } | 584 | } | ||
| 585 | 585 | ||||
M internal/core/drag_internal_test.go +5 -5
| @@ -36,8 +36,8 @@ func TestScrollBy(t *testing.T) { | |||
| 36 | 36 | w.AddWindow(id) | |
| 37 | 37 | } | |
| 38 | 38 | w.FocusID(1) | |
| 39 | - | if w.ViewX != 0 { | |
| 40 | - | t.Fatal(w.ViewX) | |
| 39 | + | if w.View != 0 { | |
| 40 | + | t.Fatal(w.View) | |
| 41 | 41 | } | |
| 42 | 42 | if w.scrollBy(-1) { | |
| 43 | 43 | t.Fatal("scrolled past the start") | |
| @@ -52,7 +52,7 @@ func TestScrollBy(t *testing.T) { | |||
| 52 | 52 | if steps != 2 { | |
| 53 | 53 | t.Fatal(steps) | |
| 54 | 54 | } | |
| 55 | - | last := w.columnX(3) + w.columnWidth(3) - w.ViewX | |
| 55 | + | last := w.columnX(3) + w.columnWidth(3) - w.View | |
| 56 | 56 | if last != w.Usable.X+w.Usable.W-w.gap() { | |
| 57 | 57 | t.Fatal(last) | |
| 58 | 58 | } | |
| @@ -62,8 +62,8 @@ func TestScrollBy(t *testing.T) { | |||
| 62 | 62 | } | |
| 63 | 63 | for w.scrollBy(-1) { | |
| 64 | 64 | } | |
| 65 | - | if w.ViewX != 0 { | |
| 66 | - | t.Fatal(w.ViewX) | |
| 65 | + | if w.View != 0 { | |
| 66 | + | t.Fatal(w.View) | |
| 67 | 67 | } | |
| 68 | 68 | } | |
| 69 | 69 | ||
| @@ -36,8 +36,8 @@ func TestScrollBy(t *testing.T) { | |||||
| 36 | w.AddWindow(id) | 36 | w.AddWindow(id) | ||
| 37 | } | 37 | } | ||
| 38 | w.FocusID(1) | 38 | w.FocusID(1) | ||
| 39 | - | if w.ViewX != 0 { | 39 | + | if w.View != 0 { |
| 40 | - | t.Fatal(w.ViewX) | 40 | + | t.Fatal(w.View) |
| 41 | } | 41 | } | ||
| 42 | if w.scrollBy(-1) { | 42 | if w.scrollBy(-1) { | ||
| 43 | t.Fatal("scrolled past the start") | 43 | t.Fatal("scrolled past the start") | ||
| @@ -52,7 +52,7 @@ func TestScrollBy(t *testing.T) { | |||||
| 52 | if steps != 2 { | 52 | if steps != 2 { | ||
| 53 | t.Fatal(steps) | 53 | t.Fatal(steps) | ||
| 54 | } | 54 | } | ||
| 55 | - | last := w.columnX(3) + w.columnWidth(3) - w.ViewX | 55 | + | last := w.columnX(3) + w.columnWidth(3) - w.View |
| 56 | if last != w.Usable.X+w.Usable.W-w.gap() { | 56 | if last != w.Usable.X+w.Usable.W-w.gap() { | ||
| 57 | t.Fatal(last) | 57 | t.Fatal(last) | ||
| 58 | } | 58 | } | ||
| @@ -62,8 +62,8 @@ func TestScrollBy(t *testing.T) { | |||||
| 62 | } | 62 | } | ||
| 63 | for w.scrollBy(-1) { | 63 | for w.scrollBy(-1) { | ||
| 64 | } | 64 | } | ||
| 65 | - | if w.ViewX != 0 { | 65 | + | if w.View != 0 { |
| 66 | - | t.Fatal(w.ViewX) | 66 | + | t.Fatal(w.View) |
| 67 | } | 67 | } | ||
| 68 | } | 68 | } | ||
| 69 | 69 | ||||
M internal/core/gesture.go +26 -7
| @@ -191,7 +191,7 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||
| 191 | 191 | g.mode = swipeDropped | |
| 192 | 192 | return true | |
| 193 | 193 | } | |
| 194 | - | g.ws.view.off = g.snap.pos() - float64(g.ws.ViewX) | |
| 194 | + | g.ws.view.off = g.snap.pos() - float64(g.ws.View) | |
| 195 | 195 | return true | |
| 196 | 196 | case swipeWorkspaces: | |
| 197 | 197 | if g.listChanged(m) { | |
| @@ -241,16 +241,17 @@ func (c *Core) decide(g *swipeGesture) { | |||
| 241 | 241 | } | |
| 242 | 242 | w.stashView.motion = motion{} | |
| 243 | 243 | g.snap = newSnapSwipe(float64(w.stashAt)+w.stashView.off, float64(w.stashAt), 1/stashSwipeMovement, indexPoints(len(w.Stash)), workspaceBand) | |
| 244 | - | case g.horizontal && w.slidable(): | |
| 244 | + | case g.horizontal == (w.policy().content == horizontalAxis) && w.slidable(): | |
| 245 | 245 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | |
| 246 | 246 | w.view.motion = motion{} | |
| 247 | - | g.snap = newSnapSwipe(float64(w.ViewX)+w.view.off, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W))) | |
| 248 | - | case !g.horizontal && m.shown == nil: | |
| 247 | + | g.snap = newSnapSwipe(float64(w.View)+w.view.off, float64(w.View), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.policy().content.span(w.Usable)))) | |
| 248 | + | case g.horizontal == (w.policy().workspace == horizontalAxis) && m.shown == nil: | |
| 249 | 249 | // A landing slide measured in an older list lands at once first. | |
| 250 | 250 | if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) { | |
| 251 | 251 | m.stopSwitch() | |
| 252 | 252 | } | |
| 253 | 253 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | |
| 254 | + | m.switchAxis = w.policy().workspace | |
| 254 | 255 | g.list = slices.Clone(m.Workspaces) | |
| 255 | 256 | m.switchView.motion, m.switchList = motion{}, nil | |
| 256 | 257 | g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) | |
| @@ -282,18 +283,18 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 282 | 283 | w.view.stop() | |
| 283 | 284 | return false | |
| 284 | 285 | } | |
| 285 | - | shown := float64(w.ViewX) + w.view.off | |
| 286 | + | shown := float64(w.View) + w.view.off | |
| 286 | 287 | target, velocity := g.snap.end(e.Cancelled, e.Time) | |
| 287 | 288 | if !e.Cancelled { | |
| 288 | 289 | view := int(math.Round(target)) | |
| 289 | 290 | w.Focus = w.snapFocus(view, target >= shown) | |
| 290 | - | w.ViewX = view | |
| 291 | + | w.View = view | |
| 291 | 292 | // A column wider than the view aligns as focus moves align it. | |
| 292 | 293 | w.scroll() | |
| 293 | 294 | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 294 | 295 | c.keyboard.takeBack() | |
| 295 | 296 | } | |
| 296 | - | w.view.off = shown - float64(w.ViewX) | |
| 297 | + | w.view.off = shown - float64(w.View) | |
| 297 | 298 | if c.animOn() { | |
| 298 | 299 | w.view.motion = c.spring(viewSpring(w.view.off, velocity), now) | |
| 299 | 300 | } else { | |
| @@ -380,6 +381,18 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 380 | 381 | if step == 0 { | |
| 381 | 382 | return false | |
| 382 | 383 | } | |
| 384 | + | if g.screen.mon.Current().Overflow == OverflowCascade && !g.horizontal { | |
| 385 | + | w := g.screen.mon.Current() | |
| 386 | + | if c.cur() != g.screen || w.onFloat() || w.fullscreen != 0 { | |
| 387 | + | return false | |
| 388 | + | } | |
| 389 | + | if next := w.bandNeighbor(step); next >= 0 { | |
| 390 | + | w.Focus = next | |
| 391 | + | w.scroll() | |
| 392 | + | return true | |
| 393 | + | } | |
| 394 | + | return false | |
| 395 | + | } | |
| 383 | 396 | a := ActionFocusWorkspaceDown | |
| 384 | 397 | switch { | |
| 385 | 398 | case g.horizontal && step < 0: | |
| @@ -389,6 +402,12 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 389 | 402 | case step < 0: | |
| 390 | 403 | a = ActionFocusWorkspaceUp | |
| 391 | 404 | } | |
| 405 | + | if g.screen.mon.Current().Overflow == OverflowCascade && g.horizontal { | |
| 406 | + | a = ActionFocusWorkspaceDown | |
| 407 | + | if step < 0 { | |
| 408 | + | a = ActionFocusWorkspaceUp | |
| 409 | + | } | |
| 410 | + | } | |
| 392 | 411 | // Focus actions act on the focused screen: the swipe's, unless the | |
| 393 | 412 | // pointer took the focus to another output meanwhile. | |
| 394 | 413 | if c.cur() != g.screen { | |
| @@ -191,7 +191,7 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||||
| 191 | g.mode = swipeDropped | 191 | g.mode = swipeDropped | ||
| 192 | return true | 192 | return true | ||
| 193 | } | 193 | } | ||
| 194 | - | g.ws.view.off = g.snap.pos() - float64(g.ws.ViewX) | 194 | + | g.ws.view.off = g.snap.pos() - float64(g.ws.View) |
| 195 | return true | 195 | return true | ||
| 196 | case swipeWorkspaces: | 196 | case swipeWorkspaces: | ||
| 197 | if g.listChanged(m) { | 197 | if g.listChanged(m) { | ||
| @@ -241,16 +241,17 @@ func (c *Core) decide(g *swipeGesture) { | |||||
| 241 | } | 241 | } | ||
| 242 | w.stashView.motion = motion{} | 242 | w.stashView.motion = motion{} | ||
| 243 | g.snap = newSnapSwipe(float64(w.stashAt)+w.stashView.off, float64(w.stashAt), 1/stashSwipeMovement, indexPoints(len(w.Stash)), workspaceBand) | 243 | g.snap = newSnapSwipe(float64(w.stashAt)+w.stashView.off, float64(w.stashAt), 1/stashSwipeMovement, indexPoints(len(w.Stash)), workspaceBand) | ||
| 244 | - | case g.horizontal && w.slidable(): | 244 | + | case g.horizontal == (w.policy().content == horizontalAxis) && w.slidable(): |
| 245 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | 245 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | ||
| 246 | w.view.motion = motion{} | 246 | w.view.motion = motion{} | ||
| 247 | - | g.snap = newSnapSwipe(float64(w.ViewX)+w.view.off, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W))) | 247 | + | g.snap = newSnapSwipe(float64(w.View)+w.view.off, float64(w.View), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.policy().content.span(w.Usable)))) |
| 248 | - | case !g.horizontal && m.shown == nil: | 248 | + | case g.horizontal == (w.policy().workspace == horizontalAxis) && m.shown == nil: |
| 249 | // A landing slide measured in an older list lands at once first. | 249 | // A landing slide measured in an older list lands at once first. | ||
| 250 | if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) { | 250 | if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) { | ||
| 251 | m.stopSwitch() | 251 | m.stopSwitch() | ||
| 252 | } | 252 | } | ||
| 253 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | 253 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | ||
| 254 | + | m.switchAxis = w.policy().workspace | |||
| 254 | g.list = slices.Clone(m.Workspaces) | 255 | g.list = slices.Clone(m.Workspaces) | ||
| 255 | m.switchView.motion, m.switchList = motion{}, nil | 256 | m.switchView.motion, m.switchList = motion{}, nil | ||
| 256 | g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) | 257 | g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) | ||
| @@ -282,18 +283,18 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 282 | w.view.stop() | 283 | w.view.stop() | ||
| 283 | return false | 284 | return false | ||
| 284 | } | 285 | } | ||
| 285 | - | shown := float64(w.ViewX) + w.view.off | 286 | + | shown := float64(w.View) + w.view.off |
| 286 | target, velocity := g.snap.end(e.Cancelled, e.Time) | 287 | target, velocity := g.snap.end(e.Cancelled, e.Time) | ||
| 287 | if !e.Cancelled { | 288 | if !e.Cancelled { | ||
| 288 | view := int(math.Round(target)) | 289 | view := int(math.Round(target)) | ||
| 289 | w.Focus = w.snapFocus(view, target >= shown) | 290 | w.Focus = w.snapFocus(view, target >= shown) | ||
| 290 | - | w.ViewX = view | 291 | + | w.View = view |
| 291 | // A column wider than the view aligns as focus moves align it. | 292 | // A column wider than the view aligns as focus moves align it. | ||
| 292 | w.scroll() | 293 | w.scroll() | ||
| 293 | c.focusScreen = c.screenIndex(g.screen.name()) | 294 | c.focusScreen = c.screenIndex(g.screen.name()) | ||
| 294 | c.keyboard.takeBack() | 295 | c.keyboard.takeBack() | ||
| 295 | } | 296 | } | ||
| 296 | - | w.view.off = shown - float64(w.ViewX) | 297 | + | w.view.off = shown - float64(w.View) |
| 297 | if c.animOn() { | 298 | if c.animOn() { | ||
| 298 | w.view.motion = c.spring(viewSpring(w.view.off, velocity), now) | 299 | w.view.motion = c.spring(viewSpring(w.view.off, velocity), now) | ||
| 299 | } else { | 300 | } else { | ||
| @@ -380,6 +381,18 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 380 | if step == 0 { | 381 | if step == 0 { | ||
| 381 | return false | 382 | return false | ||
| 382 | } | 383 | } | ||
| 384 | + | if g.screen.mon.Current().Overflow == OverflowCascade && !g.horizontal { | |||
| 385 | + | w := g.screen.mon.Current() | |||
| 386 | + | if c.cur() != g.screen || w.onFloat() || w.fullscreen != 0 { | |||
| 387 | + | return false | |||
| 388 | + | } | |||
| 389 | + | if next := w.bandNeighbor(step); next >= 0 { | |||
| 390 | + | w.Focus = next | |||
| 391 | + | w.scroll() | |||
| 392 | + | return true | |||
| 393 | + | } | |||
| 394 | + | return false | |||
| 395 | + | } | |||
| 383 | a := ActionFocusWorkspaceDown | 396 | a := ActionFocusWorkspaceDown | ||
| 384 | switch { | 397 | switch { | ||
| 385 | case g.horizontal && step < 0: | 398 | case g.horizontal && step < 0: | ||
| @@ -389,6 +402,12 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 389 | case step < 0: | 402 | case step < 0: | ||
| 390 | a = ActionFocusWorkspaceUp | 403 | a = ActionFocusWorkspaceUp | ||
| 391 | } | 404 | } | ||
| 405 | + | if g.screen.mon.Current().Overflow == OverflowCascade && g.horizontal { | |||
| 406 | + | a = ActionFocusWorkspaceDown | |||
| 407 | + | if step < 0 { | |||
| 408 | + | a = ActionFocusWorkspaceUp | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 392 | // Focus actions act on the focused screen: the swipe's, unless the | 411 | // Focus actions act on the focused screen: the swipe's, unless the | ||
| 393 | // pointer took the focus to another output meanwhile. | 412 | // pointer took the focus to another output meanwhile. | ||
| 394 | if c.cur() != g.screen { | 413 | if c.cur() != g.screen { | ||
M internal/core/layout.go +56 -28
| @@ -149,6 +149,8 @@ type Overflow string | |||
| 149 | 149 | const ( | |
| 150 | 150 | // OverflowScroll opens further columns to the right; the view scrolls. | |
| 151 | 151 | OverflowScroll Overflow = "scroll" | |
| 152 | + | // OverflowCascade wraps columns into vertically scrolling viewport bands. | |
| 153 | + | OverflowCascade Overflow = "cascade" | |
| 152 | 154 | // OverflowFixed keeps every column on screen: past the max, each new | |
| 153 | 155 | // column splits the newest one, alternating top/bottom and left/right | |
| 154 | 156 | // (dwindle spiral). | |
| @@ -169,8 +171,9 @@ type Workspace struct { | |||
| 169 | 171 | // first; stack() filters removed windows at read time. | |
| 170 | 172 | maximized []WindowID | |
| 171 | 173 | Focus int | |
| 172 | - | ViewX int | |
| 173 | - | // view slides the columns on screen past ViewX, in logical pixels. | |
| 174 | + | View int | |
| 175 | + | // View is the settled offset along the layout's content axis. | |
| 176 | + | // view is its presentation offset, in logical pixels. | |
| 174 | 177 | view slide | |
| 175 | 178 | // Output is the effective viewport in monitor coordinates: the whole | |
| 176 | 179 | // monitor unless the workspace has a size override, then a centered | |
| @@ -495,7 +498,7 @@ func (w *Workspace) AddWindow(id WindowID) { | |||
| 495 | 498 | // A new window never moves the view off a covering fullscreen | |
| 496 | 499 | // window (ADR 011): it waits, hidden, after the focused column. | |
| 497 | 500 | at := len(w.Columns) | |
| 498 | - | if w.Overflow != OverflowFixed { | |
| 501 | + | if !w.policy().appendLast { | |
| 499 | 502 | // A covering float or stashed window may have no column under it. | |
| 500 | 503 | at = min(w.Focus+1, len(w.Columns)) | |
| 501 | 504 | } | |
| @@ -508,8 +511,8 @@ func (w *Workspace) AddWindow(id WindowID) { | |||
| 508 | 511 | // addColumn places a new column like a new window and focuses it. | |
| 509 | 512 | func (w *Workspace) addColumn(col Column) { | |
| 510 | 513 | at := 0 | |
| 511 | - | if w.Overflow == OverflowFixed { | |
| 512 | - | // The spiral depends only on window order: new windows go last. | |
| 514 | + | if w.policy().appendLast { | |
| 515 | + | // Ordered overflow depends only on window order: new windows go last. | |
| 513 | 516 | at = len(w.Columns) | |
| 514 | 517 | } else if len(w.Columns) > 0 { | |
| 515 | 518 | at = w.Focus + 1 | |
| @@ -618,7 +621,7 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||
| 618 | 621 | w.Columns = append(w.Columns[:i], w.Columns[i+1:]...) | |
| 619 | 622 | if len(w.Columns) == 0 { | |
| 620 | 623 | w.Focus = 0 | |
| 621 | - | w.ViewX = 0 | |
| 624 | + | w.View = 0 | |
| 622 | 625 | return | |
| 623 | 626 | } | |
| 624 | 627 | if i < w.Focus || (i == w.Focus && i > 0) { | |
| @@ -665,7 +668,7 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||
| 665 | 668 | w.dropStashOver() | |
| 666 | 669 | w.floatFocus, w.stashFocus = false, false | |
| 667 | 670 | w.raiseColumns() | |
| 668 | - | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth { | |
| 671 | + | if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth { | |
| 669 | 672 | w.unmaximize() | |
| 670 | 673 | } | |
| 671 | 674 | w.Focus = i | |
| @@ -710,7 +713,7 @@ func (w *Workspace) focusColumn(dir int) { | |||
| 710 | 713 | } | |
| 711 | 714 | if i := w.columnToward(dir); i >= 0 { | |
| 712 | 715 | w.raiseColumns() | |
| 713 | - | if w.Overflow == OverflowFixed { | |
| 716 | + | if w.policy().equalCells { | |
| 714 | 717 | w.unmaximize() | |
| 715 | 718 | } | |
| 716 | 719 | w.Focus = i | |
| @@ -774,6 +777,18 @@ func (w *Workspace) focusWindow(dir int) bool { | |||
| 774 | 777 | w.scroll() | |
| 775 | 778 | return true | |
| 776 | 779 | } | |
| 780 | + | if w.Overflow == OverflowCascade { | |
| 781 | + | if i := w.bandNeighbor(dir); i >= 0 { | |
| 782 | + | w.Focus = i | |
| 783 | + | w.Columns[i].Focus = 0 | |
| 784 | + | if dir < 0 { | |
| 785 | + | w.Columns[i].Focus = len(w.Columns[i].Windows) - 1 | |
| 786 | + | } | |
| 787 | + | w.raiseColumns() | |
| 788 | + | w.scroll() | |
| 789 | + | return true | |
| 790 | + | } | |
| 791 | + | } | |
| 777 | 792 | if w.onScreenFocus() { | |
| 778 | 793 | if i := w.screenNeighbor(0, dir); i >= 0 { | |
| 779 | 794 | w.Focus = i | |
| @@ -865,7 +880,7 @@ func (w *Workspace) MoveWindow(dir int) { | |||
| 865 | 880 | // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed | |
| 866 | 881 | // overflow shares the width equally: nothing changes there. | |
| 867 | 882 | func (w *Workspace) ResizeColumn(pct int) { | |
| 868 | - | if w.onFloat() || len(w.Columns) == 0 || w.Overflow == OverflowFixed || w.fullscreenHides() { | |
| 883 | + | if w.onFloat() || len(w.Columns) == 0 || w.policy().equalCells || w.fullscreenHides() { | |
| 869 | 884 | return | |
| 870 | 885 | } | |
| 871 | 886 | w.unmaximize() | |
| @@ -973,7 +988,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||
| 973 | 988 | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | |
| 974 | 989 | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | |
| 975 | 990 | if len(w.Columns) == 0 { | |
| 976 | - | w.ViewX = 0 | |
| 991 | + | w.View = 0 | |
| 977 | 992 | } else { | |
| 978 | 993 | w.scroll() | |
| 979 | 994 | } | |
| @@ -994,6 +1009,9 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||
| 994 | 1009 | // CycleWidth steps the focused column through the presets. Fixed overflow | |
| 995 | 1010 | // ignores presets: it toggles the expanded column instead. | |
| 996 | 1011 | func (w *Workspace) CycleWidth() { | |
| 1012 | + | if w.Overflow == OverflowCascade { | |
| 1013 | + | return | |
| 1014 | + | } | |
| 997 | 1015 | if w.Overflow == OverflowFixed { | |
| 998 | 1016 | w.toggleExpanded() | |
| 999 | 1017 | return | |
| @@ -1047,11 +1065,11 @@ func (w *Workspace) maximize(i int) { | |||
| 1047 | 1065 | // columns in fixed mode (including after Escape changed focus). | |
| 1048 | 1066 | func (w *Workspace) unmaximize() { | |
| 1049 | 1067 | for i := range w.Columns { | |
| 1050 | - | if w.Overflow == OverflowFixed || i == w.Focus { | |
| 1068 | + | if w.policy().equalCells || i == w.Focus { | |
| 1051 | 1069 | w.Columns[i].FullWidth = false | |
| 1052 | 1070 | } | |
| 1053 | 1071 | } | |
| 1054 | - | if w.Overflow == OverflowFixed { | |
| 1072 | + | if w.policy().equalCells { | |
| 1055 | 1073 | w.maximized = nil | |
| 1056 | 1074 | } | |
| 1057 | 1075 | } | |
| @@ -1262,7 +1280,7 @@ func (w *Workspace) mayCover(id WindowID) bool { | |||
| 1262 | 1280 | } | |
| 1263 | 1281 | w.left = 0 | |
| 1264 | 1282 | } | |
| 1265 | - | if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 { | |
| 1283 | + | if !w.policy().equalCells || w.stashHidden && w.stashIndex(id) >= 0 { | |
| 1266 | 1284 | return true | |
| 1267 | 1285 | } | |
| 1268 | 1286 | focused, ok := w.Focused() | |
| @@ -1275,7 +1293,7 @@ func (w *Workspace) mayCover(id WindowID) bool { | |||
| 1275 | 1293 | // tiled neighbor scroll it off and show the target instead. | |
| 1276 | 1294 | func (w *Workspace) pinned() bool { | |
| 1277 | 1295 | c := w.cover() | |
| 1278 | - | return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c)) | |
| 1296 | + | return c != 0 && (w.policy().equalCells || w.isFloat(c)) | |
| 1279 | 1297 | } | |
| 1280 | 1298 | ||
| 1281 | 1299 | // leaveCover runs a focus move from the covering fullscreen window as if it | |
| @@ -1333,12 +1351,12 @@ func (w *Workspace) cover() WindowID { | |||
| 1333 | 1351 | if w.fullscreen == 0 { | |
| 1334 | 1352 | return 0 | |
| 1335 | 1353 | } | |
| 1336 | - | if w.isFloat(w.fullscreen) || w.Overflow == OverflowFixed { | |
| 1354 | + | if w.isFloat(w.fullscreen) || w.policy().equalCells { | |
| 1337 | 1355 | return w.fullscreen | |
| 1338 | 1356 | } | |
| 1339 | 1357 | // Scroll overflow: only while its column is aligned on the output. | |
| 1340 | 1358 | for i, c := range w.Columns { | |
| 1341 | - | if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.ViewX == 0 { | |
| 1359 | + | if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.View == 0 { | |
| 1342 | 1360 | return w.fullscreen | |
| 1343 | 1361 | } | |
| 1344 | 1362 | } | |
| @@ -1349,7 +1367,7 @@ func (w *Workspace) cover() WindowID { | |||
| 1349 | 1367 | // column's layout: the others (fixed) or its own column (scroll). Column | |
| 1350 | 1368 | // edits would change what the user cannot see. | |
| 1351 | 1369 | func (w *Workspace) fullscreenHides() bool { | |
| 1352 | - | return w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) | |
| 1370 | + | return w.fullscreen != 0 && (w.policy().equalCells || w.fullscreenColumn(w.Focus)) | |
| 1353 | 1371 | } | |
| 1354 | 1372 | ||
| 1355 | 1373 | func (w *Workspace) fullscreenColumn(i int) bool { | |
| @@ -1373,7 +1391,10 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int { | |||
| 1373 | 1391 | } | |
| 1374 | 1392 | g := w.gap() | |
| 1375 | 1393 | // Fixed overflow never scrolls, so presets would push columns off screen. | |
| 1376 | - | if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed { | |
| 1394 | + | if w.Overflow == OverflowCascade { | |
| 1395 | + | return max((w.Usable.W-g*(max(w.MaxColumns, 1)+1))/max(w.MaxColumns, 1), 0) | |
| 1396 | + | } | |
| 1397 | + | if w.Columns[i].Width == (Width{}) || w.policy().equalCells { | |
| 1377 | 1398 | if len(w.Columns) == 1 { | |
| 1378 | 1399 | return max(w.Usable.W-2*g, 0) | |
| 1379 | 1400 | } | |
| @@ -1391,27 +1412,31 @@ func (w *Workspace) columnX(i int) int { | |||
| 1391 | 1412 | return x | |
| 1392 | 1413 | } | |
| 1393 | 1414 | func (w *Workspace) scroll() { | |
| 1394 | - | before := w.ViewX | |
| 1415 | + | before := w.View | |
| 1395 | 1416 | defer func() { w.retarget(before) }() | |
| 1417 | + | if w.Overflow == OverflowCascade { | |
| 1418 | + | w.View = w.band(w.Focus) * w.Usable.H | |
| 1419 | + | return | |
| 1420 | + | } | |
| 1396 | 1421 | if w.Overflow == OverflowFixed { | |
| 1397 | - | w.ViewX = 0 | |
| 1422 | + | w.View = 0 | |
| 1398 | 1423 | return | |
| 1399 | 1424 | } | |
| 1400 | 1425 | if len(w.Columns) == 0 { | |
| 1401 | 1426 | return | |
| 1402 | 1427 | } | |
| 1403 | - | left := w.columnX(w.Focus) - w.ViewX | |
| 1428 | + | left := w.columnX(w.Focus) - w.View | |
| 1404 | 1429 | width := w.columnWidth(w.Focus) | |
| 1405 | 1430 | if w.fullscreenColumn(w.Focus) { | |
| 1406 | - | w.ViewX += left - w.Output.X | |
| 1431 | + | w.View += left - w.Output.X | |
| 1407 | 1432 | return | |
| 1408 | 1433 | } | |
| 1409 | 1434 | minX := w.Usable.X + w.gap() | |
| 1410 | 1435 | maxX := w.Usable.X + w.Usable.W - w.gap() | |
| 1411 | 1436 | if width > maxX-minX || left < minX { | |
| 1412 | - | w.ViewX += left - minX | |
| 1437 | + | w.View += left - minX | |
| 1413 | 1438 | } else if left+width > maxX { | |
| 1414 | - | w.ViewX += left + width - maxX | |
| 1439 | + | w.View += left + width - maxX | |
| 1415 | 1440 | } | |
| 1416 | 1441 | } | |
| 1417 | 1442 | ||
| @@ -1430,10 +1455,13 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect { | |||
| 1430 | 1455 | // when too small): the result aliases dst, so a caller passing a reused | |
| 1431 | 1456 | // buffer must be done with the previous result. | |
| 1432 | 1457 | func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |
| 1458 | + | if w.Overflow == OverflowCascade { | |
| 1459 | + | return w.cascadeRectsInto(dst, ignoreFullWidth) | |
| 1460 | + | } | |
| 1433 | 1461 | g := w.gap() | |
| 1434 | 1462 | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | |
| 1435 | 1463 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 1436 | - | view := w.ViewX + w.shiftPixels() | |
| 1464 | + | view := w.View + w.shiftPixels() | |
| 1437 | 1465 | x := w.Usable.X + g | |
| 1438 | 1466 | for i := range w.Columns { | |
| 1439 | 1467 | width := w.columnWidthFor(i, ignoreFullWidth) | |
| @@ -1618,12 +1646,12 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement { | |||
| 1618 | 1646 | full := w.fullscreen == id && id != 0 | |
| 1619 | 1647 | // Fixed overflow cannot scroll to other columns while one fills | |
| 1620 | 1648 | // the view. Keep them in place, but out of the scene. | |
| 1621 | - | maximized := w.Overflow == OverflowFixed && w.Columns[w.Focus].FullWidth && i != w.Focus | |
| 1622 | - | hidden := (cover != 0 && id != cover) || ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized | |
| 1649 | + | maximized := (w.Overflow == OverflowFixed || w.Overflow == OverflowCascade) && w.Columns[w.Focus].FullWidth && i != w.Focus && (w.Overflow == OverflowFixed || w.band(i) == w.band(w.Focus)) | |
| 1650 | + | hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized | |
| 1623 | 1651 | if full { | |
| 1624 | 1652 | // Scroll mode aligns the view on the column; fixed never scrolls. | |
| 1625 | 1653 | r = w.Output | |
| 1626 | - | if w.Overflow != OverflowFixed { | |
| 1654 | + | if w.Overflow == OverflowScroll || w.Overflow == "" { | |
| 1627 | 1655 | r.X = col.X | |
| 1628 | 1656 | } | |
| 1629 | 1657 | } | |
| @@ -149,6 +149,8 @@ type Overflow string | |||||
| 149 | const ( | 149 | const ( | ||
| 150 | // OverflowScroll opens further columns to the right; the view scrolls. | 150 | // OverflowScroll opens further columns to the right; the view scrolls. | ||
| 151 | OverflowScroll Overflow = "scroll" | 151 | OverflowScroll Overflow = "scroll" | ||
| 152 | + | // OverflowCascade wraps columns into vertically scrolling viewport bands. | |||
| 153 | + | OverflowCascade Overflow = "cascade" | |||
| 152 | // OverflowFixed keeps every column on screen: past the max, each new | 154 | // OverflowFixed keeps every column on screen: past the max, each new | ||
| 153 | // column splits the newest one, alternating top/bottom and left/right | 155 | // column splits the newest one, alternating top/bottom and left/right | ||
| 154 | // (dwindle spiral). | 156 | // (dwindle spiral). | ||
| @@ -169,8 +171,9 @@ type Workspace struct { | |||||
| 169 | // first; stack() filters removed windows at read time. | 171 | // first; stack() filters removed windows at read time. | ||
| 170 | maximized []WindowID | 172 | maximized []WindowID | ||
| 171 | Focus int | 173 | Focus int | ||
| 172 | - | ViewX int | 174 | + | View int |
| 173 | - | // view slides the columns on screen past ViewX, in logical pixels. | 175 | + | // View is the settled offset along the layout's content axis. |
| 176 | + | // view is its presentation offset, in logical pixels. | |||
| 174 | view slide | 177 | view slide | ||
| 175 | // Output is the effective viewport in monitor coordinates: the whole | 178 | // Output is the effective viewport in monitor coordinates: the whole | ||
| 176 | // monitor unless the workspace has a size override, then a centered | 179 | // monitor unless the workspace has a size override, then a centered | ||
| @@ -495,7 +498,7 @@ func (w *Workspace) AddWindow(id WindowID) { | |||||
| 495 | // A new window never moves the view off a covering fullscreen | 498 | // A new window never moves the view off a covering fullscreen | ||
| 496 | // window (ADR 011): it waits, hidden, after the focused column. | 499 | // window (ADR 011): it waits, hidden, after the focused column. | ||
| 497 | at := len(w.Columns) | 500 | at := len(w.Columns) | ||
| 498 | - | if w.Overflow != OverflowFixed { | 501 | + | if !w.policy().appendLast { |
| 499 | // A covering float or stashed window may have no column under it. | 502 | // A covering float or stashed window may have no column under it. | ||
| 500 | at = min(w.Focus+1, len(w.Columns)) | 503 | at = min(w.Focus+1, len(w.Columns)) | ||
| 501 | } | 504 | } | ||
| @@ -508,8 +511,8 @@ func (w *Workspace) AddWindow(id WindowID) { | |||||
| 508 | // addColumn places a new column like a new window and focuses it. | 511 | // addColumn places a new column like a new window and focuses it. | ||
| 509 | func (w *Workspace) addColumn(col Column) { | 512 | func (w *Workspace) addColumn(col Column) { | ||
| 510 | at := 0 | 513 | at := 0 | ||
| 511 | - | if w.Overflow == OverflowFixed { | 514 | + | if w.policy().appendLast { |
| 512 | - | // The spiral depends only on window order: new windows go last. | 515 | + | // Ordered overflow depends only on window order: new windows go last. |
| 513 | at = len(w.Columns) | 516 | at = len(w.Columns) | ||
| 514 | } else if len(w.Columns) > 0 { | 517 | } else if len(w.Columns) > 0 { | ||
| 515 | at = w.Focus + 1 | 518 | at = w.Focus + 1 | ||
| @@ -618,7 +621,7 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||||
| 618 | w.Columns = append(w.Columns[:i], w.Columns[i+1:]...) | 621 | w.Columns = append(w.Columns[:i], w.Columns[i+1:]...) | ||
| 619 | if len(w.Columns) == 0 { | 622 | if len(w.Columns) == 0 { | ||
| 620 | w.Focus = 0 | 623 | w.Focus = 0 | ||
| 621 | - | w.ViewX = 0 | 624 | + | w.View = 0 |
| 622 | return | 625 | return | ||
| 623 | } | 626 | } | ||
| 624 | if i < w.Focus || (i == w.Focus && i > 0) { | 627 | if i < w.Focus || (i == w.Focus && i > 0) { | ||
| @@ -665,7 +668,7 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||||
| 665 | w.dropStashOver() | 668 | w.dropStashOver() | ||
| 666 | w.floatFocus, w.stashFocus = false, false | 669 | w.floatFocus, w.stashFocus = false, false | ||
| 667 | w.raiseColumns() | 670 | w.raiseColumns() | ||
| 668 | - | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth { | 671 | + | if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth { |
| 669 | w.unmaximize() | 672 | w.unmaximize() | ||
| 670 | } | 673 | } | ||
| 671 | w.Focus = i | 674 | w.Focus = i | ||
| @@ -710,7 +713,7 @@ func (w *Workspace) focusColumn(dir int) { | |||||
| 710 | } | 713 | } | ||
| 711 | if i := w.columnToward(dir); i >= 0 { | 714 | if i := w.columnToward(dir); i >= 0 { | ||
| 712 | w.raiseColumns() | 715 | w.raiseColumns() | ||
| 713 | - | if w.Overflow == OverflowFixed { | 716 | + | if w.policy().equalCells { |
| 714 | w.unmaximize() | 717 | w.unmaximize() | ||
| 715 | } | 718 | } | ||
| 716 | w.Focus = i | 719 | w.Focus = i | ||
| @@ -774,6 +777,18 @@ func (w *Workspace) focusWindow(dir int) bool { | |||||
| 774 | w.scroll() | 777 | w.scroll() | ||
| 775 | return true | 778 | return true | ||
| 776 | } | 779 | } | ||
| 780 | + | if w.Overflow == OverflowCascade { | |||
| 781 | + | if i := w.bandNeighbor(dir); i >= 0 { | |||
| 782 | + | w.Focus = i | |||
| 783 | + | w.Columns[i].Focus = 0 | |||
| 784 | + | if dir < 0 { | |||
| 785 | + | w.Columns[i].Focus = len(w.Columns[i].Windows) - 1 | |||
| 786 | + | } | |||
| 787 | + | w.raiseColumns() | |||
| 788 | + | w.scroll() | |||
| 789 | + | return true | |||
| 790 | + | } | |||
| 791 | + | } | |||
| 777 | if w.onScreenFocus() { | 792 | if w.onScreenFocus() { | ||
| 778 | if i := w.screenNeighbor(0, dir); i >= 0 { | 793 | if i := w.screenNeighbor(0, dir); i >= 0 { | ||
| 779 | w.Focus = i | 794 | w.Focus = i | ||
| @@ -865,7 +880,7 @@ func (w *Workspace) MoveWindow(dir int) { | |||||
| 865 | // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed | 880 | // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed | ||
| 866 | // overflow shares the width equally: nothing changes there. | 881 | // overflow shares the width equally: nothing changes there. | ||
| 867 | func (w *Workspace) ResizeColumn(pct int) { | 882 | func (w *Workspace) ResizeColumn(pct int) { | ||
| 868 | - | if w.onFloat() || len(w.Columns) == 0 || w.Overflow == OverflowFixed || w.fullscreenHides() { | 883 | + | if w.onFloat() || len(w.Columns) == 0 || w.policy().equalCells || w.fullscreenHides() { |
| 869 | return | 884 | return | ||
| 870 | } | 885 | } | ||
| 871 | w.unmaximize() | 886 | w.unmaximize() | ||
| @@ -973,7 +988,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||||
| 973 | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | 988 | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | ||
| 974 | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | 989 | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | ||
| 975 | if len(w.Columns) == 0 { | 990 | if len(w.Columns) == 0 { | ||
| 976 | - | w.ViewX = 0 | 991 | + | w.View = 0 |
| 977 | } else { | 992 | } else { | ||
| 978 | w.scroll() | 993 | w.scroll() | ||
| 979 | } | 994 | } | ||
| @@ -994,6 +1009,9 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||||
| 994 | // CycleWidth steps the focused column through the presets. Fixed overflow | 1009 | // CycleWidth steps the focused column through the presets. Fixed overflow | ||
| 995 | // ignores presets: it toggles the expanded column instead. | 1010 | // ignores presets: it toggles the expanded column instead. | ||
| 996 | func (w *Workspace) CycleWidth() { | 1011 | func (w *Workspace) CycleWidth() { | ||
| 1012 | + | if w.Overflow == OverflowCascade { | |||
| 1013 | + | return | |||
| 1014 | + | } | |||
| 997 | if w.Overflow == OverflowFixed { | 1015 | if w.Overflow == OverflowFixed { | ||
| 998 | w.toggleExpanded() | 1016 | w.toggleExpanded() | ||
| 999 | return | 1017 | return | ||
| @@ -1047,11 +1065,11 @@ func (w *Workspace) maximize(i int) { | |||||
| 1047 | // columns in fixed mode (including after Escape changed focus). | 1065 | // columns in fixed mode (including after Escape changed focus). | ||
| 1048 | func (w *Workspace) unmaximize() { | 1066 | func (w *Workspace) unmaximize() { | ||
| 1049 | for i := range w.Columns { | 1067 | for i := range w.Columns { | ||
| 1050 | - | if w.Overflow == OverflowFixed || i == w.Focus { | 1068 | + | if w.policy().equalCells || i == w.Focus { |
| 1051 | w.Columns[i].FullWidth = false | 1069 | w.Columns[i].FullWidth = false | ||
| 1052 | } | 1070 | } | ||
| 1053 | } | 1071 | } | ||
| 1054 | - | if w.Overflow == OverflowFixed { | 1072 | + | if w.policy().equalCells { |
| 1055 | w.maximized = nil | 1073 | w.maximized = nil | ||
| 1056 | } | 1074 | } | ||
| 1057 | } | 1075 | } | ||
| @@ -1262,7 +1280,7 @@ func (w *Workspace) mayCover(id WindowID) bool { | |||||
| 1262 | } | 1280 | } | ||
| 1263 | w.left = 0 | 1281 | w.left = 0 | ||
| 1264 | } | 1282 | } | ||
| 1265 | - | if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 { | 1283 | + | if !w.policy().equalCells || w.stashHidden && w.stashIndex(id) >= 0 { |
| 1266 | return true | 1284 | return true | ||
| 1267 | } | 1285 | } | ||
| 1268 | focused, ok := w.Focused() | 1286 | focused, ok := w.Focused() | ||
| @@ -1275,7 +1293,7 @@ func (w *Workspace) mayCover(id WindowID) bool { | |||||
| 1275 | // tiled neighbor scroll it off and show the target instead. | 1293 | // tiled neighbor scroll it off and show the target instead. | ||
| 1276 | func (w *Workspace) pinned() bool { | 1294 | func (w *Workspace) pinned() bool { | ||
| 1277 | c := w.cover() | 1295 | c := w.cover() | ||
| 1278 | - | return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c)) | 1296 | + | return c != 0 && (w.policy().equalCells || w.isFloat(c)) |
| 1279 | } | 1297 | } | ||
| 1280 | 1298 | ||||
| 1281 | // leaveCover runs a focus move from the covering fullscreen window as if it | 1299 | // leaveCover runs a focus move from the covering fullscreen window as if it | ||
| @@ -1333,12 +1351,12 @@ func (w *Workspace) cover() WindowID { | |||||
| 1333 | if w.fullscreen == 0 { | 1351 | if w.fullscreen == 0 { | ||
| 1334 | return 0 | 1352 | return 0 | ||
| 1335 | } | 1353 | } | ||
| 1336 | - | if w.isFloat(w.fullscreen) || w.Overflow == OverflowFixed { | 1354 | + | if w.isFloat(w.fullscreen) || w.policy().equalCells { |
| 1337 | return w.fullscreen | 1355 | return w.fullscreen | ||
| 1338 | } | 1356 | } | ||
| 1339 | // Scroll overflow: only while its column is aligned on the output. | 1357 | // Scroll overflow: only while its column is aligned on the output. | ||
| 1340 | for i, c := range w.Columns { | 1358 | for i, c := range w.Columns { | ||
| 1341 | - | if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.ViewX == 0 { | 1359 | + | if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.View == 0 { |
| 1342 | return w.fullscreen | 1360 | return w.fullscreen | ||
| 1343 | } | 1361 | } | ||
| 1344 | } | 1362 | } | ||
| @@ -1349,7 +1367,7 @@ func (w *Workspace) cover() WindowID { | |||||
| 1349 | // column's layout: the others (fixed) or its own column (scroll). Column | 1367 | // column's layout: the others (fixed) or its own column (scroll). Column | ||
| 1350 | // edits would change what the user cannot see. | 1368 | // edits would change what the user cannot see. | ||
| 1351 | func (w *Workspace) fullscreenHides() bool { | 1369 | func (w *Workspace) fullscreenHides() bool { | ||
| 1352 | - | return w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) | 1370 | + | return w.fullscreen != 0 && (w.policy().equalCells || w.fullscreenColumn(w.Focus)) |
| 1353 | } | 1371 | } | ||
| 1354 | 1372 | ||||
| 1355 | func (w *Workspace) fullscreenColumn(i int) bool { | 1373 | func (w *Workspace) fullscreenColumn(i int) bool { | ||
| @@ -1373,7 +1391,10 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int { | |||||
| 1373 | } | 1391 | } | ||
| 1374 | g := w.gap() | 1392 | g := w.gap() | ||
| 1375 | // Fixed overflow never scrolls, so presets would push columns off screen. | 1393 | // Fixed overflow never scrolls, so presets would push columns off screen. | ||
| 1376 | - | if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed { | 1394 | + | if w.Overflow == OverflowCascade { |
| 1395 | + | return max((w.Usable.W-g*(max(w.MaxColumns, 1)+1))/max(w.MaxColumns, 1), 0) | |||
| 1396 | + | } | |||
| 1397 | + | if w.Columns[i].Width == (Width{}) || w.policy().equalCells { | |||
| 1377 | if len(w.Columns) == 1 { | 1398 | if len(w.Columns) == 1 { | ||
| 1378 | return max(w.Usable.W-2*g, 0) | 1399 | return max(w.Usable.W-2*g, 0) | ||
| 1379 | } | 1400 | } | ||
| @@ -1391,27 +1412,31 @@ func (w *Workspace) columnX(i int) int { | |||||
| 1391 | return x | 1412 | return x | ||
| 1392 | } | 1413 | } | ||
| 1393 | func (w *Workspace) scroll() { | 1414 | func (w *Workspace) scroll() { | ||
| 1394 | - | before := w.ViewX | 1415 | + | before := w.View |
| 1395 | defer func() { w.retarget(before) }() | 1416 | defer func() { w.retarget(before) }() | ||
| 1417 | + | if w.Overflow == OverflowCascade { | |||
| 1418 | + | w.View = w.band(w.Focus) * w.Usable.H | |||
| 1419 | + | return | |||
| 1420 | + | } | |||
| 1396 | if w.Overflow == OverflowFixed { | 1421 | if w.Overflow == OverflowFixed { | ||
| 1397 | - | w.ViewX = 0 | 1422 | + | w.View = 0 |
| 1398 | return | 1423 | return | ||
| 1399 | } | 1424 | } | ||
| 1400 | if len(w.Columns) == 0 { | 1425 | if len(w.Columns) == 0 { | ||
| 1401 | return | 1426 | return | ||
| 1402 | } | 1427 | } | ||
| 1403 | - | left := w.columnX(w.Focus) - w.ViewX | 1428 | + | left := w.columnX(w.Focus) - w.View |
| 1404 | width := w.columnWidth(w.Focus) | 1429 | width := w.columnWidth(w.Focus) | ||
| 1405 | if w.fullscreenColumn(w.Focus) { | 1430 | if w.fullscreenColumn(w.Focus) { | ||
| 1406 | - | w.ViewX += left - w.Output.X | 1431 | + | w.View += left - w.Output.X |
| 1407 | return | 1432 | return | ||
| 1408 | } | 1433 | } | ||
| 1409 | minX := w.Usable.X + w.gap() | 1434 | minX := w.Usable.X + w.gap() | ||
| 1410 | maxX := w.Usable.X + w.Usable.W - w.gap() | 1435 | maxX := w.Usable.X + w.Usable.W - w.gap() | ||
| 1411 | if width > maxX-minX || left < minX { | 1436 | if width > maxX-minX || left < minX { | ||
| 1412 | - | w.ViewX += left - minX | 1437 | + | w.View += left - minX |
| 1413 | } else if left+width > maxX { | 1438 | } else if left+width > maxX { | ||
| 1414 | - | w.ViewX += left + width - maxX | 1439 | + | w.View += left + width - maxX |
| 1415 | } | 1440 | } | ||
| 1416 | } | 1441 | } | ||
| 1417 | 1442 | ||||
| @@ -1430,10 +1455,13 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect { | |||||
| 1430 | // when too small): the result aliases dst, so a caller passing a reused | 1455 | // when too small): the result aliases dst, so a caller passing a reused | ||
| 1431 | // buffer must be done with the previous result. | 1456 | // buffer must be done with the previous result. | ||
| 1432 | func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | 1457 | func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | ||
| 1458 | + | if w.Overflow == OverflowCascade { | |||
| 1459 | + | return w.cascadeRectsInto(dst, ignoreFullWidth) | |||
| 1460 | + | } | |||
| 1433 | g := w.gap() | 1461 | g := w.gap() | ||
| 1434 | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | 1462 | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | ||
| 1435 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | 1463 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | ||
| 1436 | - | view := w.ViewX + w.shiftPixels() | 1464 | + | view := w.View + w.shiftPixels() |
| 1437 | x := w.Usable.X + g | 1465 | x := w.Usable.X + g | ||
| 1438 | for i := range w.Columns { | 1466 | for i := range w.Columns { | ||
| 1439 | width := w.columnWidthFor(i, ignoreFullWidth) | 1467 | width := w.columnWidthFor(i, ignoreFullWidth) | ||
| @@ -1618,12 +1646,12 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement { | |||||
| 1618 | full := w.fullscreen == id && id != 0 | 1646 | full := w.fullscreen == id && id != 0 | ||
| 1619 | // Fixed overflow cannot scroll to other columns while one fills | 1647 | // Fixed overflow cannot scroll to other columns while one fills | ||
| 1620 | // the view. Keep them in place, but out of the scene. | 1648 | // the view. Keep them in place, but out of the scene. | ||
| 1621 | - | maximized := w.Overflow == OverflowFixed && w.Columns[w.Focus].FullWidth && i != w.Focus | 1649 | + | maximized := (w.Overflow == OverflowFixed || w.Overflow == OverflowCascade) && w.Columns[w.Focus].FullWidth && i != w.Focus && (w.Overflow == OverflowFixed || w.band(i) == w.band(w.Focus)) |
| 1622 | - | hidden := (cover != 0 && id != cover) || ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized | 1650 | + | hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized |
| 1623 | if full { | 1651 | if full { | ||
| 1624 | // Scroll mode aligns the view on the column; fixed never scrolls. | 1652 | // Scroll mode aligns the view on the column; fixed never scrolls. | ||
| 1625 | r = w.Output | 1653 | r = w.Output | ||
| 1626 | - | if w.Overflow != OverflowFixed { | 1654 | + | if w.Overflow == OverflowScroll || w.Overflow == "" { |
| 1627 | r.X = col.X | 1655 | r.X = col.X | ||
| 1628 | } | 1656 | } | ||
| 1629 | } | 1657 | } | ||
A internal/core/layout_policy.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | // layoutAxis is a logical navigation axis, independent of rendering. | |
| 4 | + | type layoutAxis uint8 | |
| 5 | + | ||
| 6 | + | const ( | |
| 7 | + | verticalAxis layoutAxis = iota | |
| 8 | + | horizontalAxis | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func (a layoutAxis) span(r Rect) int { | |
| 12 | + | if a == verticalAxis { | |
| 13 | + | return r.H | |
| 14 | + | } | |
| 15 | + | return r.W | |
| 16 | + | } | |
| 17 | + | func (a layoutAxis) offset(r *Rect, pixels int) { | |
| 18 | + | if a == verticalAxis { | |
| 19 | + | r.Y += pixels | |
| 20 | + | } else { | |
| 21 | + | r.X += pixels | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // layoutPolicy keeps geometry capabilities distinct from workspace navigation. | |
| 26 | + | // Window state remains owned by Workspace; policies contain no mutable state. | |
| 27 | + | type layoutPolicy struct { | |
| 28 | + | content, workspace layoutAxis | |
| 29 | + | equalCells, appendLast, spiral bool | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (w *Workspace) policy() layoutPolicy { | |
| 33 | + | if w.Overflow == OverflowCascade { | |
| 34 | + | return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, appendLast: true} | |
| 35 | + | } | |
| 36 | + | if w.Overflow == OverflowFixed { | |
| 37 | + | return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true, spiral: true} | |
| 38 | + | } | |
| 39 | + | return layoutPolicy{content: horizontalAxis, workspace: verticalAxis} | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | // layoutAxis is a logical navigation axis, independent of rendering. | |||
| 4 | + | type layoutAxis uint8 | |||
| 5 | + | ||||
| 6 | + | const ( | |||
| 7 | + | verticalAxis layoutAxis = iota | |||
| 8 | + | horizontalAxis | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func (a layoutAxis) span(r Rect) int { | |||
| 12 | + | if a == verticalAxis { | |||
| 13 | + | return r.H | |||
| 14 | + | } | |||
| 15 | + | return r.W | |||
| 16 | + | } | |||
| 17 | + | func (a layoutAxis) offset(r *Rect, pixels int) { | |||
| 18 | + | if a == verticalAxis { | |||
| 19 | + | r.Y += pixels | |||
| 20 | + | } else { | |||
| 21 | + | r.X += pixels | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // layoutPolicy keeps geometry capabilities distinct from workspace navigation. | |||
| 26 | + | // Window state remains owned by Workspace; policies contain no mutable state. | |||
| 27 | + | type layoutPolicy struct { | |||
| 28 | + | content, workspace layoutAxis | |||
| 29 | + | equalCells, appendLast, spiral bool | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (w *Workspace) policy() layoutPolicy { | |||
| 33 | + | if w.Overflow == OverflowCascade { | |||
| 34 | + | return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, appendLast: true} | |||
| 35 | + | } | |||
| 36 | + | if w.Overflow == OverflowFixed { | |||
| 37 | + | return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true, spiral: true} | |||
| 38 | + | } | |||
| 39 | + | return layoutPolicy{content: horizontalAxis, workspace: verticalAxis} | |||
| 40 | + | } | |||
M internal/core/layout_test.go +11 -11
| @@ -48,16 +48,16 @@ func TestOperations(t *testing.T) { | |||
| 48 | 48 | w.AddWindow(2) | |
| 49 | 49 | w.AddWindow(3) | |
| 50 | 50 | // Three auto columns, two visible: each (100-3*5)/2 = 42 wide. | |
| 51 | - | if w.Focus != 2 || w.ViewX != 46 || w.columnWidth(0) != 42 { | |
| 51 | + | if w.Focus != 2 || w.View != 46 || w.columnWidth(0) != 42 { | |
| 52 | 52 | t.Fatalf("scroll: %+v", w) | |
| 53 | 53 | } | |
| 54 | 54 | w.FocusColumn(-1) | |
| 55 | - | if w.ViewX != 46 { | |
| 56 | - | t.Fatal(w.ViewX) | |
| 55 | + | if w.View != 46 { | |
| 56 | + | t.Fatal(w.View) | |
| 57 | 57 | } | |
| 58 | 58 | w.FocusColumn(-1) | |
| 59 | - | if w.ViewX != 0 { | |
| 60 | - | t.Fatal(w.ViewX) | |
| 59 | + | if w.View != 0 { | |
| 60 | + | t.Fatal(w.View) | |
| 61 | 61 | } | |
| 62 | 62 | w.MoveColumn(1) | |
| 63 | 63 | if w.Focus != 1 || w.Columns[1].Windows[0] != 1 { | |
| @@ -264,7 +264,7 @@ func FuzzWorkspaceOps(f *testing.F) { | |||
| 264 | 264 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | |
| 265 | 265 | t.Fatal("focus") | |
| 266 | 266 | } | |
| 267 | - | left := w.columnX(w.Focus) - w.ViewX | |
| 267 | + | left := w.columnX(w.Focus) - w.View | |
| 268 | 268 | width := w.columnWidth(w.Focus) | |
| 269 | 269 | minX, maxX := w.Usable.X+w.gap(), w.Usable.X+w.Usable.W-w.gap() | |
| 270 | 270 | if w.fullscreenColumn(w.Focus) { | |
| @@ -357,13 +357,13 @@ func TestUnchangedUsablePreservesViewX(t *testing.T) { | |||
| 357 | 357 | w.AddWindow(1) | |
| 358 | 358 | w.AddWindow(2) | |
| 359 | 359 | w.AddWindow(3) | |
| 360 | - | before := w.ViewX | |
| 360 | + | before := w.View | |
| 361 | 361 | if before == 0 { | |
| 362 | 362 | t.Fatal("expected a scrolled workspace") | |
| 363 | 363 | } | |
| 364 | 364 | w.SetUsable(w.Usable) | |
| 365 | - | if w.ViewX != before { | |
| 366 | - | t.Fatalf("ViewX changed from %d to %d", before, w.ViewX) | |
| 365 | + | if w.View != before { | |
| 366 | + | t.Fatalf("ViewX changed from %d to %d", before, w.View) | |
| 367 | 367 | } | |
| 368 | 368 | } | |
| 369 | 369 | ||
| @@ -428,8 +428,8 @@ func TestFixedOverflowSpiral(t *testing.T) { | |||
| 428 | 428 | if !reflect.DeepEqual(got, want) { | |
| 429 | 429 | t.Fatalf("got %v\nwant %v", got, want) | |
| 430 | 430 | } | |
| 431 | - | if w.ViewX != 0 { | |
| 432 | - | t.Fatal("fixed overflow scrolled", w.ViewX) | |
| 431 | + | if w.View != 0 { | |
| 432 | + | t.Fatal("fixed overflow scrolled", w.View) | |
| 433 | 433 | } | |
| 434 | 434 | // Up to max-columns it is the plain equal split. | |
| 435 | 435 | w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed} | |
| @@ -48,16 +48,16 @@ func TestOperations(t *testing.T) { | |||||
| 48 | w.AddWindow(2) | 48 | w.AddWindow(2) | ||
| 49 | w.AddWindow(3) | 49 | w.AddWindow(3) | ||
| 50 | // Three auto columns, two visible: each (100-3*5)/2 = 42 wide. | 50 | // Three auto columns, two visible: each (100-3*5)/2 = 42 wide. | ||
| 51 | - | if w.Focus != 2 || w.ViewX != 46 || w.columnWidth(0) != 42 { | 51 | + | if w.Focus != 2 || w.View != 46 || w.columnWidth(0) != 42 { |
| 52 | t.Fatalf("scroll: %+v", w) | 52 | t.Fatalf("scroll: %+v", w) | ||
| 53 | } | 53 | } | ||
| 54 | w.FocusColumn(-1) | 54 | w.FocusColumn(-1) | ||
| 55 | - | if w.ViewX != 46 { | 55 | + | if w.View != 46 { |
| 56 | - | t.Fatal(w.ViewX) | 56 | + | t.Fatal(w.View) |
| 57 | } | 57 | } | ||
| 58 | w.FocusColumn(-1) | 58 | w.FocusColumn(-1) | ||
| 59 | - | if w.ViewX != 0 { | 59 | + | if w.View != 0 { |
| 60 | - | t.Fatal(w.ViewX) | 60 | + | t.Fatal(w.View) |
| 61 | } | 61 | } | ||
| 62 | w.MoveColumn(1) | 62 | w.MoveColumn(1) | ||
| 63 | if w.Focus != 1 || w.Columns[1].Windows[0] != 1 { | 63 | if w.Focus != 1 || w.Columns[1].Windows[0] != 1 { | ||
| @@ -264,7 +264,7 @@ func FuzzWorkspaceOps(f *testing.F) { | |||||
| 264 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | 264 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | ||
| 265 | t.Fatal("focus") | 265 | t.Fatal("focus") | ||
| 266 | } | 266 | } | ||
| 267 | - | left := w.columnX(w.Focus) - w.ViewX | 267 | + | left := w.columnX(w.Focus) - w.View |
| 268 | width := w.columnWidth(w.Focus) | 268 | width := w.columnWidth(w.Focus) | ||
| 269 | minX, maxX := w.Usable.X+w.gap(), w.Usable.X+w.Usable.W-w.gap() | 269 | minX, maxX := w.Usable.X+w.gap(), w.Usable.X+w.Usable.W-w.gap() | ||
| 270 | if w.fullscreenColumn(w.Focus) { | 270 | if w.fullscreenColumn(w.Focus) { | ||
| @@ -357,13 +357,13 @@ func TestUnchangedUsablePreservesViewX(t *testing.T) { | |||||
| 357 | w.AddWindow(1) | 357 | w.AddWindow(1) | ||
| 358 | w.AddWindow(2) | 358 | w.AddWindow(2) | ||
| 359 | w.AddWindow(3) | 359 | w.AddWindow(3) | ||
| 360 | - | before := w.ViewX | 360 | + | before := w.View |
| 361 | if before == 0 { | 361 | if before == 0 { | ||
| 362 | t.Fatal("expected a scrolled workspace") | 362 | t.Fatal("expected a scrolled workspace") | ||
| 363 | } | 363 | } | ||
| 364 | w.SetUsable(w.Usable) | 364 | w.SetUsable(w.Usable) | ||
| 365 | - | if w.ViewX != before { | 365 | + | if w.View != before { |
| 366 | - | t.Fatalf("ViewX changed from %d to %d", before, w.ViewX) | 366 | + | t.Fatalf("ViewX changed from %d to %d", before, w.View) |
| 367 | } | 367 | } | ||
| 368 | } | 368 | } | ||
| 369 | 369 | ||||
| @@ -428,8 +428,8 @@ func TestFixedOverflowSpiral(t *testing.T) { | |||||
| 428 | if !reflect.DeepEqual(got, want) { | 428 | if !reflect.DeepEqual(got, want) { | ||
| 429 | t.Fatalf("got %v\nwant %v", got, want) | 429 | t.Fatalf("got %v\nwant %v", got, want) | ||
| 430 | } | 430 | } | ||
| 431 | - | if w.ViewX != 0 { | 431 | + | if w.View != 0 { |
| 432 | - | t.Fatal("fixed overflow scrolled", w.ViewX) | 432 | + | t.Fatal("fixed overflow scrolled", w.View) |
| 433 | } | 433 | } | ||
| 434 | // Up to max-columns it is the plain equal split. | 434 | // Up to max-columns it is the plain equal split. | ||
| 435 | w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed} | 435 | w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed} | ||
M internal/core/monitor.go +2 -0
| @@ -37,6 +37,8 @@ type Monitor struct { | |||
| 37 | 37 | // switchView slides the view between numbered workspaces: the view is | |
| 38 | 38 | // at Active+switchView.off, in workspaces. | |
| 39 | 39 | switchView slide | |
| 40 | + | // switchAxis is frozen from the source workspace for each transition. | |
| 41 | + | switchAxis layoutAxis | |
| 40 | 42 | // switchList is the numbered list a landing slide measures from (the | |
| 41 | 43 | // one its swipe began on); switchView.off is then from the current | |
| 42 | 44 | // workspace's place in it. | |
| @@ -37,6 +37,8 @@ type Monitor struct { | |||||
| 37 | // switchView slides the view between numbered workspaces: the view is | 37 | // switchView slides the view between numbered workspaces: the view is | ||
| 38 | // at Active+switchView.off, in workspaces. | 38 | // at Active+switchView.off, in workspaces. | ||
| 39 | switchView slide | 39 | switchView slide | ||
| 40 | + | // switchAxis is frozen from the source workspace for each transition. | |||
| 41 | + | switchAxis layoutAxis | |||
| 40 | // switchList is the numbered list a landing slide measures from (the | 42 | // switchList is the numbered list a landing slide measures from (the | ||
| 41 | // one its swipe began on); switchView.off is then from the current | 43 | // one its swipe began on); switchView.off is then from the current | ||
| 42 | // workspace's place in it. | 44 | // workspace's place in it. | ||
M internal/core/monitor_test.go +2 -2
| @@ -196,8 +196,8 @@ func TestEqualSharesAndBorderless(t *testing.T) { | |||
| 196 | 196 | } | |
| 197 | 197 | // A fourth column scrolls: still 1/3 each. | |
| 198 | 198 | m.AddWindow(4) | |
| 199 | - | if w := m.Current(); w.columnWidth(3) != 33 || w.ViewX == 0 { | |
| 200 | - | t.Fatal(w.ViewX) | |
| 199 | + | if w := m.Current(); w.columnWidth(3) != 33 || w.View == 0 { | |
| 200 | + | t.Fatal(w.View) | |
| 201 | 201 | } | |
| 202 | 202 | // A preset on one column is kept; auto columns keep their share. | |
| 203 | 203 | m.SetPresets([]Width{{Num: 1, Den: 2}}) | |
| @@ -196,8 +196,8 @@ func TestEqualSharesAndBorderless(t *testing.T) { | |||||
| 196 | } | 196 | } | ||
| 197 | // A fourth column scrolls: still 1/3 each. | 197 | // A fourth column scrolls: still 1/3 each. | ||
| 198 | m.AddWindow(4) | 198 | m.AddWindow(4) | ||
| 199 | - | if w := m.Current(); w.columnWidth(3) != 33 || w.ViewX == 0 { | 199 | + | if w := m.Current(); w.columnWidth(3) != 33 || w.View == 0 { |
| 200 | - | t.Fatal(w.ViewX) | 200 | + | t.Fatal(w.View) |
| 201 | } | 201 | } | ||
| 202 | // A preset on one column is kept; auto columns keep their share. | 202 | // A preset on one column is kept; auto columns keep their share. | ||
| 203 | m.SetPresets([]Width{{Num: 1, Den: 2}}) | 203 | m.SetPresets([]Width{{Num: 1, Den: 2}}) | ||
M internal/core/motion_test.go +3 -3
| @@ -433,14 +433,14 @@ func TestMotionVelocity(t *testing.T) { | |||
| 433 | 433 | ||
| 434 | 434 | func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) { | |
| 435 | 435 | start := time.Unix(10, 0) | |
| 436 | - | w := &Workspace{ViewX: 100, view: slide{off: -300}} | |
| 436 | + | w := &Workspace{View: 100, view: slide{off: -300}} | |
| 437 | 437 | w.view.motion = newMotion(viewSpring(w.view.off, 2000), start, 2) | |
| 438 | 438 | w.view.motion.at(start.Add(20 * time.Millisecond)) | |
| 439 | 439 | v := w.view.motion.velocity() | |
| 440 | 440 | if v <= 0 { | |
| 441 | 441 | t.Fatalf("velocity %v", v) | |
| 442 | 442 | } | |
| 443 | - | w.ViewX = 40 | |
| 443 | + | w.View = 40 | |
| 444 | 444 | w.retarget(100) | |
| 445 | 445 | if w.view.off != -240 || !w.view.motion.on || w.view.motion.slow != 2 { | |
| 446 | 446 | t.Fatalf("shift %v motion %+v", w.view.off, w.view.motion) | |
| @@ -452,7 +452,7 @@ func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) { | |||
| 452 | 452 | t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v) | |
| 453 | 453 | } | |
| 454 | 454 | var idle Workspace | |
| 455 | - | idle.ViewX = 5 | |
| 455 | + | idle.View = 5 | |
| 456 | 456 | idle.retarget(0) | |
| 457 | 457 | if idle.view.motion.on || idle.view.off != 0 { | |
| 458 | 458 | t.Fatal("retarget without a slide must not start one") | |
| @@ -433,14 +433,14 @@ func TestMotionVelocity(t *testing.T) { | |||||
| 433 | 433 | ||||
| 434 | func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) { | 434 | func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) { | ||
| 435 | start := time.Unix(10, 0) | 435 | start := time.Unix(10, 0) | ||
| 436 | - | w := &Workspace{ViewX: 100, view: slide{off: -300}} | 436 | + | w := &Workspace{View: 100, view: slide{off: -300}} |
| 437 | w.view.motion = newMotion(viewSpring(w.view.off, 2000), start, 2) | 437 | w.view.motion = newMotion(viewSpring(w.view.off, 2000), start, 2) | ||
| 438 | w.view.motion.at(start.Add(20 * time.Millisecond)) | 438 | w.view.motion.at(start.Add(20 * time.Millisecond)) | ||
| 439 | v := w.view.motion.velocity() | 439 | v := w.view.motion.velocity() | ||
| 440 | if v <= 0 { | 440 | if v <= 0 { | ||
| 441 | t.Fatalf("velocity %v", v) | 441 | t.Fatalf("velocity %v", v) | ||
| 442 | } | 442 | } | ||
| 443 | - | w.ViewX = 40 | 443 | + | w.View = 40 |
| 444 | w.retarget(100) | 444 | w.retarget(100) | ||
| 445 | if w.view.off != -240 || !w.view.motion.on || w.view.motion.slow != 2 { | 445 | if w.view.off != -240 || !w.view.motion.on || w.view.motion.slow != 2 { | ||
| 446 | t.Fatalf("shift %v motion %+v", w.view.off, w.view.motion) | 446 | t.Fatalf("shift %v motion %+v", w.view.off, w.view.motion) | ||
| @@ -452,7 +452,7 @@ func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) { | |||||
| 452 | t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v) | 452 | t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v) | ||
| 453 | } | 453 | } | ||
| 454 | var idle Workspace | 454 | var idle Workspace | ||
| 455 | - | idle.ViewX = 5 | 455 | + | idle.View = 5 |
| 456 | idle.retarget(0) | 456 | idle.retarget(0) | ||
| 457 | if idle.view.motion.on || idle.view.off != 0 { | 457 | if idle.view.motion.on || idle.view.off != 0 { | ||
| 458 | t.Fatal("retarget without a slide must not start one") | 458 | t.Fatal("retarget without a slide must not start one") | ||
M internal/core/overview.go +6 -1
| @@ -748,7 +748,12 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 748 | 748 | maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth | |
| 749 | 749 | for i, c := range w.Columns { | |
| 750 | 750 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | |
| 751 | - | if w.Overflow == OverflowFixed { | |
| 751 | + | if w.Overflow == OverflowCascade { | |
| 752 | + | k := max(w.MaxColumns, 1) | |
| 753 | + | width := max((w.Usable.W-g*(k+1))/k, 0) | |
| 754 | + | r.X = w.band(i)*w.Usable.W + g + (i%k)*(width+g) | |
| 755 | + | r.W = width | |
| 756 | + | } else if w.Overflow == OverflowFixed { | |
| 752 | 757 | r = rects[i] | |
| 753 | 758 | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | |
| 754 | 759 | } else if w.fullscreenColumn(i) { | |
| @@ -748,7 +748,12 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 748 | maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth | 748 | maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth | ||
| 749 | for i, c := range w.Columns { | 749 | for i, c := range w.Columns { | ||
| 750 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | 750 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | ||
| 751 | - | if w.Overflow == OverflowFixed { | 751 | + | if w.Overflow == OverflowCascade { |
| 752 | + | k := max(w.MaxColumns, 1) | |||
| 753 | + | width := max((w.Usable.W-g*(k+1))/k, 0) | |||
| 754 | + | r.X = w.band(i)*w.Usable.W + g + (i%k)*(width+g) | |||
| 755 | + | r.W = width | |||
| 756 | + | } else if w.Overflow == OverflowFixed { | |||
| 752 | r = rects[i] | 757 | r = rects[i] | ||
| 753 | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | 758 | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | ||
| 754 | } else if w.fullscreenColumn(i) { | 759 | } else if w.fullscreenColumn(i) { | ||
M internal/core/overview_behavior_test.go +3 -3
| @@ -158,14 +158,14 @@ func TestOverviewBrowsedRowsRestored(t *testing.T) { | |||
| 158 | 158 | for _, ov := range []Overflow{OverflowScroll, OverflowFixed} { | |
| 159 | 159 | for name, path := range paths { | |
| 160 | 160 | m, ws2 := browsedMonitor(ov) | |
| 161 | - | view := ws2.ViewX | |
| 161 | + | view := ws2.View | |
| 162 | 162 | m.ToggleOverview() | |
| 163 | 163 | for _, d := range path { | |
| 164 | 164 | m.OverviewMove(d[0], d[1]) | |
| 165 | 165 | } | |
| 166 | 166 | m.CancelOverview() | |
| 167 | - | if id, _ := ws2.Focused(); id != 5 || ws2.ViewX != view || m.Current() != m.Workspaces[0] { | |
| 168 | - | t.Fatalf("%v %s: focus %d view %v (want %v) current %v", ov, name, id, ws2.ViewX, view, m.Active) | |
| 167 | + | if id, _ := ws2.Focused(); id != 5 || ws2.View != view || m.Current() != m.Workspaces[0] { | |
| 168 | + | t.Fatalf("%v %s: focus %d view %v (want %v) current %v", ov, name, id, ws2.View, view, m.Active) | |
| 169 | 169 | } | |
| 170 | 170 | } | |
| 171 | 171 | } | |
| @@ -158,14 +158,14 @@ func TestOverviewBrowsedRowsRestored(t *testing.T) { | |||||
| 158 | for _, ov := range []Overflow{OverflowScroll, OverflowFixed} { | 158 | for _, ov := range []Overflow{OverflowScroll, OverflowFixed} { | ||
| 159 | for name, path := range paths { | 159 | for name, path := range paths { | ||
| 160 | m, ws2 := browsedMonitor(ov) | 160 | m, ws2 := browsedMonitor(ov) | ||
| 161 | - | view := ws2.ViewX | 161 | + | view := ws2.View |
| 162 | m.ToggleOverview() | 162 | m.ToggleOverview() | ||
| 163 | for _, d := range path { | 163 | for _, d := range path { | ||
| 164 | m.OverviewMove(d[0], d[1]) | 164 | m.OverviewMove(d[0], d[1]) | ||
| 165 | } | 165 | } | ||
| 166 | m.CancelOverview() | 166 | m.CancelOverview() | ||
| 167 | - | if id, _ := ws2.Focused(); id != 5 || ws2.ViewX != view || m.Current() != m.Workspaces[0] { | 167 | + | if id, _ := ws2.Focused(); id != 5 || ws2.View != view || m.Current() != m.Workspaces[0] { |
| 168 | - | t.Fatalf("%v %s: focus %d view %v (want %v) current %v", ov, name, id, ws2.ViewX, view, m.Active) | 168 | + | t.Fatalf("%v %s: focus %d view %v (want %v) current %v", ov, name, id, ws2.View, view, m.Active) |
| 169 | } | 169 | } | ||
| 170 | } | 170 | } | ||
| 171 | } | 171 | } | ||
M internal/core/separators_test.go +1 -1
| @@ -125,7 +125,7 @@ func TestSeparatorsScroll(t *testing.T) { | |||
| 125 | 125 | if before[1] != 0 || before[3]&ports.SideRight != 0 { | |
| 126 | 126 | t.Fatalf("scrolled tiles reserve off-output lines: %v", before) | |
| 127 | 127 | } | |
| 128 | - | if w.ViewX == 0 { | |
| 128 | + | if w.View == 0 { | |
| 129 | 129 | t.Fatal("expected a scrolled view") | |
| 130 | 130 | } | |
| 131 | 131 | m.Apply(ActionFocusColumnLeft) | |
| @@ -125,7 +125,7 @@ func TestSeparatorsScroll(t *testing.T) { | |||||
| 125 | if before[1] != 0 || before[3]&ports.SideRight != 0 { | 125 | if before[1] != 0 || before[3]&ports.SideRight != 0 { | ||
| 126 | t.Fatalf("scrolled tiles reserve off-output lines: %v", before) | 126 | t.Fatalf("scrolled tiles reserve off-output lines: %v", before) | ||
| 127 | } | 127 | } | ||
| 128 | - | if w.ViewX == 0 { | 128 | + | if w.View == 0 { |
| 129 | t.Fatal("expected a scrolled view") | 129 | t.Fatal("expected a scrolled view") | ||
| 130 | } | 130 | } | ||
| 131 | m.Apply(ActionFocusColumnLeft) | 131 | m.Apply(ActionFocusColumnLeft) | ||
M internal/core/slide.go +13 -7
| @@ -47,10 +47,10 @@ func (w *Workspace) slidable() bool { | |||
| 47 | 47 | // before during a landing slide: the slide heads for the new view. | |
| 48 | 48 | // Without a landing slide the view moves at once, as it always has. | |
| 49 | 49 | func (w *Workspace) retarget(before int) { | |
| 50 | - | if w.ViewX == before || !w.view.motion.on { | |
| 50 | + | if w.View == before || !w.view.motion.on { | |
| 51 | 51 | return | |
| 52 | 52 | } | |
| 53 | - | w.view.off += float64(before - w.ViewX) | |
| 53 | + | w.view.off += float64(before - w.View) | |
| 54 | 54 | w.view.motion = newMotion(viewSpring(w.view.off, w.view.motion.velocity()), time.Time{}, w.view.motion.slow) | |
| 55 | 55 | } | |
| 56 | 56 | ||
| @@ -61,7 +61,7 @@ func (w *Workspace) shiftPixels() int { return int(math.Round(w.view.off)) } | |||
| 61 | 61 | // (slideLayout rounds the offset times the height). | |
| 62 | 62 | func (m *Monitor) switchSpring(off, velocity float64) spring { | |
| 63 | 63 | s := workspaceSpring(off, velocity) | |
| 64 | - | if h := m.template.Output.H; h > 0 { | |
| 64 | + | if h := m.switchAxis.span(m.template.Output); h > 0 { | |
| 65 | 65 | s.Snap = pixelSnap / float64(h) | |
| 66 | 66 | } | |
| 67 | 67 | return s | |
| @@ -70,7 +70,7 @@ func (m *Monitor) switchSpring(off, velocity float64) spring { | |||
| 70 | 70 | // swipeScale turns touchpad distance into pixels: viewSwipeMovement | |
| 71 | 71 | // scrolls one usable width. | |
| 72 | 72 | func (w *Workspace) swipeScale() float64 { | |
| 73 | - | return float64(w.Usable.W) / viewSwipeMovement | |
| 73 | + | return float64(w.policy().content.span(w.Usable)) / viewSwipeMovement | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | // snapPoints are the sorted views that align a column edge with the usable | |
| @@ -78,6 +78,9 @@ func (w *Workspace) swipeScale() float64 { | |||
| 78 | 78 | // snapSpacing of the usable width merge into the first: a swipe step | |
| 79 | 79 | // always moves the view visibly. | |
| 80 | 80 | func (w *Workspace) snapPoints() []float64 { | |
| 81 | + | if w.Overflow == OverflowCascade { | |
| 82 | + | return w.cascadePoints() | |
| 83 | + | } | |
| 81 | 84 | g := w.gap() | |
| 82 | 85 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | |
| 83 | 86 | last := len(w.Columns) - 1 | |
| @@ -113,13 +116,16 @@ const snapSpacing = 0.1 | |||
| 113 | 116 | // fully shown at the current view, else the furthest fully shown column | |
| 114 | 117 | // toward the swipe (forward is rightward). | |
| 115 | 118 | func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | |
| 119 | + | if w.Overflow == OverflowCascade { | |
| 120 | + | return w.cascadeFocus(view) | |
| 121 | + | } | |
| 116 | 122 | g := w.gap() | |
| 117 | 123 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | |
| 118 | 124 | full := func(i int) bool { | |
| 119 | 125 | x := w.columnX(i) - view | |
| 120 | 126 | return x >= minX && x+w.columnWidth(i) <= maxX | |
| 121 | 127 | } | |
| 122 | - | if full(w.Focus) && view == w.ViewX { | |
| 128 | + | if full(w.Focus) && view == w.View { | |
| 123 | 129 | return w.Focus | |
| 124 | 130 | } | |
| 125 | 131 | focus = -1 | |
| @@ -183,13 +189,13 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement { | |||
| 183 | 189 | if base < 0 { | |
| 184 | 190 | return cur | |
| 185 | 191 | } | |
| 186 | - | h := float64(m.template.Output.H) | |
| 192 | + | h := float64(m.switchAxis.span(m.template.Output)) | |
| 187 | 193 | pos := float64(base) + m.switchView.off | |
| 188 | 194 | offset := func(p []Placement, k int) []Placement { | |
| 189 | 195 | dy := int(math.Round((float64(k) - pos) * h)) | |
| 190 | 196 | for i := range p { | |
| 191 | 197 | if !p[i].Hidden { | |
| 192 | - | p[i].Rect.Y += dy | |
| 198 | + | m.switchAxis.offset(&p[i].Rect, dy) | |
| 193 | 199 | } | |
| 194 | 200 | } | |
| 195 | 201 | return p | |
| @@ -47,10 +47,10 @@ func (w *Workspace) slidable() bool { | |||||
| 47 | // before during a landing slide: the slide heads for the new view. | 47 | // before during a landing slide: the slide heads for the new view. | ||
| 48 | // Without a landing slide the view moves at once, as it always has. | 48 | // Without a landing slide the view moves at once, as it always has. | ||
| 49 | func (w *Workspace) retarget(before int) { | 49 | func (w *Workspace) retarget(before int) { | ||
| 50 | - | if w.ViewX == before || !w.view.motion.on { | 50 | + | if w.View == before || !w.view.motion.on { |
| 51 | return | 51 | return | ||
| 52 | } | 52 | } | ||
| 53 | - | w.view.off += float64(before - w.ViewX) | 53 | + | w.view.off += float64(before - w.View) |
| 54 | w.view.motion = newMotion(viewSpring(w.view.off, w.view.motion.velocity()), time.Time{}, w.view.motion.slow) | 54 | w.view.motion = newMotion(viewSpring(w.view.off, w.view.motion.velocity()), time.Time{}, w.view.motion.slow) | ||
| 55 | } | 55 | } | ||
| 56 | 56 | ||||
| @@ -61,7 +61,7 @@ func (w *Workspace) shiftPixels() int { return int(math.Round(w.view.off)) } | |||||
| 61 | // (slideLayout rounds the offset times the height). | 61 | // (slideLayout rounds the offset times the height). | ||
| 62 | func (m *Monitor) switchSpring(off, velocity float64) spring { | 62 | func (m *Monitor) switchSpring(off, velocity float64) spring { | ||
| 63 | s := workspaceSpring(off, velocity) | 63 | s := workspaceSpring(off, velocity) | ||
| 64 | - | if h := m.template.Output.H; h > 0 { | 64 | + | if h := m.switchAxis.span(m.template.Output); h > 0 { |
| 65 | s.Snap = pixelSnap / float64(h) | 65 | s.Snap = pixelSnap / float64(h) | ||
| 66 | } | 66 | } | ||
| 67 | return s | 67 | return s | ||
| @@ -70,7 +70,7 @@ func (m *Monitor) switchSpring(off, velocity float64) spring { | |||||
| 70 | // swipeScale turns touchpad distance into pixels: viewSwipeMovement | 70 | // swipeScale turns touchpad distance into pixels: viewSwipeMovement | ||
| 71 | // scrolls one usable width. | 71 | // scrolls one usable width. | ||
| 72 | func (w *Workspace) swipeScale() float64 { | 72 | func (w *Workspace) swipeScale() float64 { | ||
| 73 | - | return float64(w.Usable.W) / viewSwipeMovement | 73 | + | return float64(w.policy().content.span(w.Usable)) / viewSwipeMovement |
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | // snapPoints are the sorted views that align a column edge with the usable | 76 | // snapPoints are the sorted views that align a column edge with the usable | ||
| @@ -78,6 +78,9 @@ func (w *Workspace) swipeScale() float64 { | |||||
| 78 | // snapSpacing of the usable width merge into the first: a swipe step | 78 | // snapSpacing of the usable width merge into the first: a swipe step | ||
| 79 | // always moves the view visibly. | 79 | // always moves the view visibly. | ||
| 80 | func (w *Workspace) snapPoints() []float64 { | 80 | func (w *Workspace) snapPoints() []float64 { | ||
| 81 | + | if w.Overflow == OverflowCascade { | |||
| 82 | + | return w.cascadePoints() | |||
| 83 | + | } | |||
| 81 | g := w.gap() | 84 | g := w.gap() | ||
| 82 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | 85 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | ||
| 83 | last := len(w.Columns) - 1 | 86 | last := len(w.Columns) - 1 | ||
| @@ -113,13 +116,16 @@ const snapSpacing = 0.1 | |||||
| 113 | // fully shown at the current view, else the furthest fully shown column | 116 | // fully shown at the current view, else the furthest fully shown column | ||
| 114 | // toward the swipe (forward is rightward). | 117 | // toward the swipe (forward is rightward). | ||
| 115 | func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | 118 | func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | ||
| 119 | + | if w.Overflow == OverflowCascade { | |||
| 120 | + | return w.cascadeFocus(view) | |||
| 121 | + | } | |||
| 116 | g := w.gap() | 122 | g := w.gap() | ||
| 117 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | 123 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | ||
| 118 | full := func(i int) bool { | 124 | full := func(i int) bool { | ||
| 119 | x := w.columnX(i) - view | 125 | x := w.columnX(i) - view | ||
| 120 | return x >= minX && x+w.columnWidth(i) <= maxX | 126 | return x >= minX && x+w.columnWidth(i) <= maxX | ||
| 121 | } | 127 | } | ||
| 122 | - | if full(w.Focus) && view == w.ViewX { | 128 | + | if full(w.Focus) && view == w.View { |
| 123 | return w.Focus | 129 | return w.Focus | ||
| 124 | } | 130 | } | ||
| 125 | focus = -1 | 131 | focus = -1 | ||
| @@ -183,13 +189,13 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement { | |||||
| 183 | if base < 0 { | 189 | if base < 0 { | ||
| 184 | return cur | 190 | return cur | ||
| 185 | } | 191 | } | ||
| 186 | - | h := float64(m.template.Output.H) | 192 | + | h := float64(m.switchAxis.span(m.template.Output)) |
| 187 | pos := float64(base) + m.switchView.off | 193 | pos := float64(base) + m.switchView.off | ||
| 188 | offset := func(p []Placement, k int) []Placement { | 194 | offset := func(p []Placement, k int) []Placement { | ||
| 189 | dy := int(math.Round((float64(k) - pos) * h)) | 195 | dy := int(math.Round((float64(k) - pos) * h)) | ||
| 190 | for i := range p { | 196 | for i := range p { | ||
| 191 | if !p[i].Hidden { | 197 | if !p[i].Hidden { | ||
| 192 | - | p[i].Rect.Y += dy | 198 | + | m.switchAxis.offset(&p[i].Rect, dy) |
| 193 | } | 199 | } | ||
| 194 | } | 200 | } | ||
| 195 | return p | 201 | return p | ||
M internal/core/stash_test.go +3 -3
| @@ -334,9 +334,9 @@ func TestActivateHiddenStashEndsScrollFullscreen(t *testing.T) { | |||
| 334 | 334 | if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 { | |
| 335 | 335 | t.Fatalf("focused %d fullscreen %d", id, w.fullscreen) | |
| 336 | 336 | } | |
| 337 | - | view := w.ViewX | |
| 337 | + | view := w.View | |
| 338 | 338 | w.scroll() | |
| 339 | - | if w.ViewX != view { | |
| 340 | - | t.Fatalf("view %d, settled %d", view, w.ViewX) | |
| 339 | + | if w.View != view { | |
| 340 | + | t.Fatalf("view %d, settled %d", view, w.View) | |
| 341 | 341 | } | |
| 342 | 342 | } | |
| @@ -334,9 +334,9 @@ func TestActivateHiddenStashEndsScrollFullscreen(t *testing.T) { | |||||
| 334 | if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 { | 334 | if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 { | ||
| 335 | t.Fatalf("focused %d fullscreen %d", id, w.fullscreen) | 335 | t.Fatalf("focused %d fullscreen %d", id, w.fullscreen) | ||
| 336 | } | 336 | } | ||
| 337 | - | view := w.ViewX | 337 | + | view := w.View |
| 338 | w.scroll() | 338 | w.scroll() | ||
| 339 | - | if w.ViewX != view { | 339 | + | if w.View != view { |
| 340 | - | t.Fatalf("view %d, settled %d", view, w.ViewX) | 340 | + | t.Fatalf("view %d, settled %d", view, w.View) |
| 341 | } | 341 | } | ||
| 342 | } | 342 | } | ||
M internal/core/transition.go +9 -3
| @@ -37,6 +37,7 @@ type viewShot struct { | |||
| 37 | 37 | // one), not at their last frame. An action may stop them | |
| 38 | 38 | // (Monitor.Focus), so the transition reads them here. | |
| 39 | 39 | viewV, switchV float64 | |
| 40 | + | switchAxis layoutAxis | |
| 40 | 41 | // stashShown, stashAt, stashLen and stashPos are whether the stash was | |
| 41 | 42 | // on screen, its selection, size and drawn position (selection plus | |
| 42 | 43 | // stashView.off, in stash windows); stashV is its slide's speed. | |
| @@ -621,7 +622,11 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 621 | 622 | s.ws, s.overview = w, m.ov.open | |
| 622 | 623 | if !m.ov.open { | |
| 623 | 624 | // The overview does not scroll or slide: no camera. | |
| 624 | - | s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.view.off | |
| 625 | + | s.viewX, s.view = w.View, float64(w.View)+w.view.off | |
| 626 | + | s.switchAxis = w.policy().workspace | |
| 627 | + | if m.switchView.busy() { | |
| 628 | + | s.switchAxis = m.switchAxis | |
| 629 | + | } | |
| 625 | 630 | } | |
| 626 | 631 | if w.view.motion.on { | |
| 627 | 632 | _, s.viewV = w.view.motion.sampleAt(now) | |
| @@ -728,12 +733,12 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) { | |||
| 728 | 733 | w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, b.stashV), now) | |
| 729 | 734 | return | |
| 730 | 735 | } | |
| 731 | - | if w.ViewX == b.viewX || !w.slidable() { | |
| 736 | + | if w.View == b.viewX || !w.slidable() { | |
| 732 | 737 | return | |
| 733 | 738 | } | |
| 734 | 739 | // A running landing slide was retargeted by scroll(): its velocity | |
| 735 | 740 | // carries over, and the spring starts now like every action's. | |
| 736 | - | w.view.off = b.view - float64(w.ViewX) | |
| 741 | + | w.view.off = b.view - float64(w.View) | |
| 737 | 742 | w.view.motion = c.spring(viewSpring(w.view.off, b.viewV), now) | |
| 738 | 743 | case b.ok && m.shown == nil && movedFrom(b, before) == nil: | |
| 739 | 744 | // A screen that received its workspace from another monitor does | |
| @@ -748,6 +753,7 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) { | |||
| 748 | 753 | // The snapshot's list is reused by the next action: the slide keeps | |
| 749 | 754 | // its own copy. | |
| 750 | 755 | m.switchView.off, m.switchList = off, slices.Clone(b.list) | |
| 756 | + | m.switchAxis = b.switchAxis | |
| 751 | 757 | if off == 0 || m.framedSwitch() { | |
| 752 | 758 | m.stopSwitch() | |
| 753 | 759 | return | |
| @@ -37,6 +37,7 @@ type viewShot struct { | |||||
| 37 | // one), not at their last frame. An action may stop them | 37 | // one), not at their last frame. An action may stop them | ||
| 38 | // (Monitor.Focus), so the transition reads them here. | 38 | // (Monitor.Focus), so the transition reads them here. | ||
| 39 | viewV, switchV float64 | 39 | viewV, switchV float64 | ||
| 40 | + | switchAxis layoutAxis | |||
| 40 | // stashShown, stashAt, stashLen and stashPos are whether the stash was | 41 | // stashShown, stashAt, stashLen and stashPos are whether the stash was | ||
| 41 | // on screen, its selection, size and drawn position (selection plus | 42 | // on screen, its selection, size and drawn position (selection plus | ||
| 42 | // stashView.off, in stash windows); stashV is its slide's speed. | 43 | // stashView.off, in stash windows); stashV is its slide's speed. | ||
| @@ -621,7 +622,11 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||||
| 621 | s.ws, s.overview = w, m.ov.open | 622 | s.ws, s.overview = w, m.ov.open | ||
| 622 | if !m.ov.open { | 623 | if !m.ov.open { | ||
| 623 | // The overview does not scroll or slide: no camera. | 624 | // The overview does not scroll or slide: no camera. | ||
| 624 | - | s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.view.off | 625 | + | s.viewX, s.view = w.View, float64(w.View)+w.view.off |
| 626 | + | s.switchAxis = w.policy().workspace | |||
| 627 | + | if m.switchView.busy() { | |||
| 628 | + | s.switchAxis = m.switchAxis | |||
| 629 | + | } | |||
| 625 | } | 630 | } | ||
| 626 | if w.view.motion.on { | 631 | if w.view.motion.on { | ||
| 627 | _, s.viewV = w.view.motion.sampleAt(now) | 632 | _, s.viewV = w.view.motion.sampleAt(now) | ||
| @@ -728,12 +733,12 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) { | |||||
| 728 | w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, b.stashV), now) | 733 | w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, b.stashV), now) | ||
| 729 | return | 734 | return | ||
| 730 | } | 735 | } | ||
| 731 | - | if w.ViewX == b.viewX || !w.slidable() { | 736 | + | if w.View == b.viewX || !w.slidable() { |
| 732 | return | 737 | return | ||
| 733 | } | 738 | } | ||
| 734 | // A running landing slide was retargeted by scroll(): its velocity | 739 | // A running landing slide was retargeted by scroll(): its velocity | ||
| 735 | // carries over, and the spring starts now like every action's. | 740 | // carries over, and the spring starts now like every action's. | ||
| 736 | - | w.view.off = b.view - float64(w.ViewX) | 741 | + | w.view.off = b.view - float64(w.View) |
| 737 | w.view.motion = c.spring(viewSpring(w.view.off, b.viewV), now) | 742 | w.view.motion = c.spring(viewSpring(w.view.off, b.viewV), now) | ||
| 738 | case b.ok && m.shown == nil && movedFrom(b, before) == nil: | 743 | case b.ok && m.shown == nil && movedFrom(b, before) == nil: | ||
| 739 | // A screen that received its workspace from another monitor does | 744 | // A screen that received its workspace from another monitor does | ||
| @@ -748,6 +753,7 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) { | |||||
| 748 | // The snapshot's list is reused by the next action: the slide keeps | 753 | // The snapshot's list is reused by the next action: the slide keeps | ||
| 749 | // its own copy. | 754 | // its own copy. | ||
| 750 | m.switchView.off, m.switchList = off, slices.Clone(b.list) | 755 | m.switchView.off, m.switchList = off, slices.Clone(b.list) | ||
| 756 | + | m.switchAxis = b.switchAxis | |||
| 751 | if off == 0 || m.framedSwitch() { | 757 | if off == 0 || m.framedSwitch() { | ||
| 752 | m.stopSwitch() | 758 | m.stopSwitch() | ||
| 753 | return | 759 | return | ||
M internal/core/transition_chain_internal_test.go +2 -2
| @@ -210,11 +210,11 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) { | |||
| 210 | 210 | w.view.off = 25 | |
| 211 | 211 | c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns} | |
| 212 | 212 | before := c.snapshot(ic.now) | |
| 213 | - | viewX := w.ViewX | |
| 213 | + | viewX := w.View | |
| 214 | 214 | for range 3 { | |
| 215 | 215 | c.applyAction(ActionFocusColumnLeft) | |
| 216 | 216 | } | |
| 217 | - | if w.ViewX == viewX { | |
| 217 | + | if w.View == viewX { | |
| 218 | 218 | t.Fatal("the focus did not move the view: the case checks nothing") | |
| 219 | 219 | } | |
| 220 | 220 | c.transition(before, ic.now) | |
| @@ -210,11 +210,11 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) { | |||||
| 210 | w.view.off = 25 | 210 | w.view.off = 25 | ||
| 211 | c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns} | 211 | c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns} | ||
| 212 | before := c.snapshot(ic.now) | 212 | before := c.snapshot(ic.now) | ||
| 213 | - | viewX := w.ViewX | 213 | + | viewX := w.View |
| 214 | for range 3 { | 214 | for range 3 { | ||
| 215 | c.applyAction(ActionFocusColumnLeft) | 215 | c.applyAction(ActionFocusColumnLeft) | ||
| 216 | } | 216 | } | ||
| 217 | - | if w.ViewX == viewX { | 217 | + | if w.View == viewX { |
| 218 | t.Fatal("the focus did not move the view: the case checks nothing") | 218 | t.Fatal("the focus did not move the view: the case checks nothing") | ||
| 219 | } | 219 | } | ||
| 220 | c.transition(before, ic.now) | 220 | c.transition(before, ic.now) | ||
M internal/ports/config.go +2 -2
| @@ -64,8 +64,8 @@ type OutputAnchor struct { | |||
| 64 | 64 | type LayoutRules struct { | |
| 65 | 65 | // MaxColumns is how many columns share the screen at once. | |
| 66 | 66 | MaxColumns int | |
| 67 | - | // Overflow is "scroll" (further columns scroll) or "fixed" (they split | |
| 68 | - | // the last one). | |
| 67 | + | // Overflow is "scroll" (horizontal scrolling), "fixed" (spiral splits), | |
| 68 | + | // or "cascade" (vertically scrolling bands of columns). | |
| 69 | 69 | Overflow string | |
| 70 | 70 | } | |
| 71 | 71 | ||
| @@ -64,8 +64,8 @@ type OutputAnchor struct { | |||||
| 64 | type LayoutRules struct { | 64 | type LayoutRules struct { | ||
| 65 | // MaxColumns is how many columns share the screen at once. | 65 | // MaxColumns is how many columns share the screen at once. | ||
| 66 | MaxColumns int | 66 | MaxColumns int | ||
| 67 | - | // Overflow is "scroll" (further columns scroll) or "fixed" (they split | 67 | + | // Overflow is "scroll" (horizontal scrolling), "fixed" (spiral splits), |
| 68 | - | // the last one). | 68 | + | // or "cascade" (vertically scrolling bands of columns). |
| 69 | Overflow string | 69 | Overflow string | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||