Merge pull request 'feat(layout): add Cascade bands and axis-aware navigation' (#8) from feat/cascade into main
Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/8
27 files · +608 -116
M docs/config.md +16 -5
| @@ -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)) | | |
| @@ -139,15 +139,16 @@ bind.Cmd+p = workspace presentation | |||
| 139 | 139 | With `animations = on`, a three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace: | |
| 140 | 140 | ||
| 141 | 141 | - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen. | |
| 142 | - | - Up or down slides to the next numbered workspace. | |
| 142 | + | - Up or down slides to the next numbered workspace in scroll and fixed layouts. | |
| 143 | + | - In [Cascade](#cascade), up/down scrolls one band and left/right switches workspaces. | |
| 143 | 144 | ||
| 144 | 145 | The view sticks a little near each step and catches up between them. Past the next step it resists; lift and swipe again to go further. When the fingers lift, a quick swipe goes on to the next step and a slow one settles on the closest. | |
| 145 | 146 | ||
| 146 | - | With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed. | |
| 147 | + | In scroll and fixed layouts with `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed. | |
| 147 | 148 | ||
| 148 | - | With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe runs `focus-column-left/right` or `focus-workspace-up/down`. | |
| 149 | + | With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe navigates content or workspaces along the layout's axes. Cascade band swipes stop at the first and last band. | |
| 149 | 150 | ||
| 150 | - | Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. Where the view cannot scroll (`fixed` overflow, a floating or fullscreen window, a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift. | |
| 151 | + | Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. In scroll and fixed layouts where the view cannot scroll (a floating or fullscreen window, fixed overflow, or a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift. Cascade keeps horizontal workspace navigation; vertical band swipes do nothing while a floating or fullscreen window has focus. | |
| 151 | 152 | ||
| 152 | 153 | Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia. | |
| 153 | 154 | ||
| @@ -187,6 +188,16 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 187 | 188 | - `none` removes a default bind. | |
| 188 | 189 | - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands). | |
| 189 | 190 | ||
| 191 | + | ### Cascade | |
| 192 | + | ||
| 193 | + | 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. | |
| 194 | + | ||
| 195 | + | 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. | |
| 196 | + | ||
| 197 | + | 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. | |
| 198 | + | ||
| 199 | + | 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. | |
| 200 | + | ||
| 190 | 201 | ### Actions | |
| 191 | 202 | ||
| 192 | 203 | | 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)) | | ||
| @@ -139,15 +139,16 @@ bind.Cmd+p = workspace presentation | |||||
| 139 | With `animations = on`, a three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace: | 139 | With `animations = on`, a three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace: | ||
| 140 | 140 | ||||
| 141 | - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen. | 141 | - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen. | ||
| 142 | - | - Up or down slides to the next numbered workspace. | 142 | + | - Up or down slides to the next numbered workspace in scroll and fixed layouts. |
| 143 | + | - In [Cascade](#cascade), up/down scrolls one band and left/right switches workspaces. | |||
| 143 | 144 | ||||
| 144 | The view sticks a little near each step and catches up between them. Past the next step it resists; lift and swipe again to go further. When the fingers lift, a quick swipe goes on to the next step and a slow one settles on the closest. | 145 | The view sticks a little near each step and catches up between them. Past the next step it resists; lift and swipe again to go further. When the fingers lift, a quick swipe goes on to the next step and a slow one settles on the closest. | ||
| 145 | 146 | ||||
| 146 | - | With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed. | 147 | + | In scroll and fixed layouts with `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed. |
| 147 | 148 | ||||
| 148 | - | With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe runs `focus-column-left/right` or `focus-workspace-up/down`. | 149 | + | With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe navigates content or workspaces along the layout's axes. Cascade band swipes stop at the first and last band. |
| 149 | 150 | ||||
| 150 | - | Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. Where the view cannot scroll (`fixed` overflow, a floating or fullscreen window, a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift. | 151 | + | Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. In scroll and fixed layouts where the view cannot scroll (a floating or fullscreen window, fixed overflow, or a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift. Cascade keeps horizontal workspace navigation; vertical band swipes do nothing while a floating or fullscreen window has focus. |
| 151 | 152 | ||||
| 152 | Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia. | 153 | Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia. | ||
| 153 | 154 | ||||
| @@ -187,6 +188,16 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 187 | - `none` removes a default bind. | 188 | - `none` removes a default bind. | ||
| 188 | - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands). | 189 | - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands). | ||
| 189 | 190 | ||||
| 191 | + | ### Cascade | |||
| 192 | + | ||||
| 193 | + | 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. | |||
| 194 | + | ||||
| 195 | + | 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. | |||
| 196 | + | ||||
| 197 | + | 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. | |||
| 198 | + | ||||
| 199 | + | 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. | |||
| 200 | + | ||||
| 190 | ### Actions | 201 | ### Actions | ||
| 191 | 202 | ||||
| 192 | | Action | Effect | | 203 | | Action | Effect | | ||
A internal/adapters/config/cascade_test.go +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestCascadeLayoutInheritance(t *testing.T) { | |
| 9 | + | c, w := parseString(t, "layout.overflow = cascade\nlayout.DP-1.overflow = cascade\nworkspace.work.overflow = cascade\nbind.cmd+w = workspace work\n") | |
| 10 | + | if len(w) != 0 { | |
| 11 | + | t.Fatalf("warnings=%v", w) | |
| 12 | + | } | |
| 13 | + | if c.Layout.Overflow != "cascade" || c.Layout.Outputs[0].Overflow != "cascade" || c.Workspaces[0].Overflow != "cascade" { | |
| 14 | + | t.Fatalf("cascade inheritance=%+v", c.Layout) | |
| 15 | + | } | |
| 16 | + | _, warnings, err := Parse(strings.NewReader("layout.overflow = unknown\n")) | |
| 17 | + | if err != nil || len(warnings) != 1 || !strings.Contains(warnings[0].Msg, "must be scroll, fixed or cascade") { | |
| 18 | + | t.Fatalf("invalid overflow diagnostic: %v, %v", warnings, err) | |
| 19 | + | } | |
| 20 | + | } | |
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestCascadeLayoutInheritance(t *testing.T) { | |||
| 9 | + | c, w := parseString(t, "layout.overflow = cascade\nlayout.DP-1.overflow = cascade\nworkspace.work.overflow = cascade\nbind.cmd+w = workspace work\n") | |||
| 10 | + | if len(w) != 0 { | |||
| 11 | + | t.Fatalf("warnings=%v", w) | |||
| 12 | + | } | |||
| 13 | + | if c.Layout.Overflow != "cascade" || c.Layout.Outputs[0].Overflow != "cascade" || c.Workspaces[0].Overflow != "cascade" { | |||
| 14 | + | t.Fatalf("cascade inheritance=%+v", c.Layout) | |||
| 15 | + | } | |||
| 16 | + | _, warnings, err := Parse(strings.NewReader("layout.overflow = unknown\n")) | |||
| 17 | + | if err != nil || len(warnings) != 1 || !strings.Contains(warnings[0].Msg, "must be scroll, fixed or cascade") { | |||
| 18 | + | t.Fatalf("invalid overflow diagnostic: %v, %v", warnings, err) | |||
| 19 | + | } | |||
| 20 | + | } | |||
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 +79 -0
| @@ -0,0 +1,79 @@ | |||
| 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) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) } | |
| 7 | + | func (w *Workspace) cellX(i int) int { | |
| 8 | + | return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth()+w.gap()) | |
| 9 | + | } | |
| 10 | + | func (w *Workspace) cascadeBands() (n, spacing int) { | |
| 11 | + | if len(w.Columns) == 0 || w.Usable.H <= 0 { | |
| 12 | + | return 1, 0 | |
| 13 | + | } | |
| 14 | + | return w.band(len(w.Columns)-1) + 1, w.Usable.H | |
| 15 | + | } | |
| 16 | + | func (w *Workspace) cellWidth() int { | |
| 17 | + | k := max(w.MaxColumns, 1) | |
| 18 | + | return max((w.Usable.W-w.gap()*(k+1))/k, 0) | |
| 19 | + | } | |
| 20 | + | func (w *Workspace) cellInBand(b int) int { | |
| 21 | + | k := max(w.MaxColumns, 1) | |
| 22 | + | return min(b*k+w.Focus%k, len(w.Columns)-1) | |
| 23 | + | } | |
| 24 | + | func (w *Workspace) focusBand(dir int) bool { | |
| 25 | + | i := w.bandNeighbor(dir) | |
| 26 | + | if i < 0 { | |
| 27 | + | return false | |
| 28 | + | } | |
| 29 | + | w.unmaximize() | |
| 30 | + | w.Focus = i | |
| 31 | + | w.Columns[i].Focus = 0 | |
| 32 | + | if dir < 0 { | |
| 33 | + | w.Columns[i].Focus = len(w.Columns[i].Windows) - 1 | |
| 34 | + | } | |
| 35 | + | w.raiseColumns() | |
| 36 | + | w.scroll() | |
| 37 | + | return true | |
| 38 | + | } | |
| 39 | + | func (w *Workspace) bandNeighbor(dir int) int { | |
| 40 | + | k := max(w.MaxColumns, 1) | |
| 41 | + | b := w.band(w.Focus) + dir | |
| 42 | + | if b < 0 || b*k >= len(w.Columns) { | |
| 43 | + | return -1 | |
| 44 | + | } | |
| 45 | + | return w.cellInBand(b) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |
| 49 | + | g := w.gap() | |
| 50 | + | width := w.cellWidth() | |
| 51 | + | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 52 | + | view := w.View + w.shiftPixels() | |
| 53 | + | for i := range w.Columns { | |
| 54 | + | r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)} | |
| 55 | + | if w.Columns[i].FullWidth && !ignoreFullWidth { | |
| 56 | + | r.X = w.Usable.X + g | |
| 57 | + | r.W = max(w.Usable.W-2*g, 0) | |
| 58 | + | } | |
| 59 | + | rects[i] = r | |
| 60 | + | } | |
| 61 | + | return rects | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (w *Workspace) cascadePoints() []float64 { | |
| 65 | + | n, spacing := w.cascadeBands() | |
| 66 | + | points := make([]float64, n) | |
| 67 | + | for i := range points { | |
| 68 | + | points[i] = float64(i * spacing) | |
| 69 | + | } | |
| 70 | + | return points | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (w *Workspace) cascadeFocus(view int) int { | |
| 74 | + | if len(w.Columns) == 0 { | |
| 75 | + | return 0 | |
| 76 | + | } | |
| 77 | + | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | |
| 78 | + | return w.cellInBand(b) | |
| 79 | + | } | |
| @@ -0,0 +1,79 @@ | |||||
| 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) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) } | |||
| 7 | + | func (w *Workspace) cellX(i int) int { | |||
| 8 | + | return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth()+w.gap()) | |||
| 9 | + | } | |||
| 10 | + | func (w *Workspace) cascadeBands() (n, spacing int) { | |||
| 11 | + | if len(w.Columns) == 0 || w.Usable.H <= 0 { | |||
| 12 | + | return 1, 0 | |||
| 13 | + | } | |||
| 14 | + | return w.band(len(w.Columns)-1) + 1, w.Usable.H | |||
| 15 | + | } | |||
| 16 | + | func (w *Workspace) cellWidth() int { | |||
| 17 | + | k := max(w.MaxColumns, 1) | |||
| 18 | + | return max((w.Usable.W-w.gap()*(k+1))/k, 0) | |||
| 19 | + | } | |||
| 20 | + | func (w *Workspace) cellInBand(b int) int { | |||
| 21 | + | k := max(w.MaxColumns, 1) | |||
| 22 | + | return min(b*k+w.Focus%k, len(w.Columns)-1) | |||
| 23 | + | } | |||
| 24 | + | func (w *Workspace) focusBand(dir int) bool { | |||
| 25 | + | i := w.bandNeighbor(dir) | |||
| 26 | + | if i < 0 { | |||
| 27 | + | return false | |||
| 28 | + | } | |||
| 29 | + | w.unmaximize() | |||
| 30 | + | w.Focus = i | |||
| 31 | + | w.Columns[i].Focus = 0 | |||
| 32 | + | if dir < 0 { | |||
| 33 | + | w.Columns[i].Focus = len(w.Columns[i].Windows) - 1 | |||
| 34 | + | } | |||
| 35 | + | w.raiseColumns() | |||
| 36 | + | w.scroll() | |||
| 37 | + | return true | |||
| 38 | + | } | |||
| 39 | + | func (w *Workspace) bandNeighbor(dir int) int { | |||
| 40 | + | k := max(w.MaxColumns, 1) | |||
| 41 | + | b := w.band(w.Focus) + dir | |||
| 42 | + | if b < 0 || b*k >= len(w.Columns) { | |||
| 43 | + | return -1 | |||
| 44 | + | } | |||
| 45 | + | return w.cellInBand(b) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||
| 49 | + | g := w.gap() | |||
| 50 | + | width := w.cellWidth() | |||
| 51 | + | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |||
| 52 | + | view := w.View + w.shiftPixels() | |||
| 53 | + | for i := range w.Columns { | |||
| 54 | + | r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)} | |||
| 55 | + | if w.Columns[i].FullWidth && !ignoreFullWidth { | |||
| 56 | + | r.X = w.Usable.X + g | |||
| 57 | + | r.W = max(w.Usable.W-2*g, 0) | |||
| 58 | + | } | |||
| 59 | + | rects[i] = r | |||
| 60 | + | } | |||
| 61 | + | return rects | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (w *Workspace) cascadePoints() []float64 { | |||
| 65 | + | n, spacing := w.cascadeBands() | |||
| 66 | + | points := make([]float64, n) | |||
| 67 | + | for i := range points { | |||
| 68 | + | points[i] = float64(i * spacing) | |||
| 69 | + | } | |||
| 70 | + | return points | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (w *Workspace) cascadeFocus(view int) int { | |||
| 74 | + | if len(w.Columns) == 0 { | |||
| 75 | + | return 0 | |||
| 76 | + | } | |||
| 77 | + | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | |||
| 78 | + | return w.cellInBand(b) | |||
| 79 | + | } | |||
A internal/core/cascade_swipe_test.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestCascadeSwipeAxes(t *testing.T) { | |
| 11 | + | r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3 }) | |
| 12 | + | for i := 1; i <= 7; i++ { | |
| 13 | + | r.mapWindow(t, ports.WindowID(i)) | |
| 14 | + | } | |
| 15 | + | r.begin() | |
| 16 | + | s := r.move(t, 0, -400) | |
| 17 | + | band, ok := rectOf(s, 7) | |
| 18 | + | if !ok || band.X != 0 || band.Y <= 0 { | |
| 19 | + | t.Fatalf("band swipe must move vertically: %v, shown=%v", band, ok) | |
| 20 | + | } | |
| 21 | + | r.end(t, false) | |
| 22 | + | r.settleAll(t, r.outs...) | |
| 23 | + | r.begin() | |
| 24 | + | s = r.move(t, 400, 0) | |
| 25 | + | workspace, ok := rectOf(s, 7) | |
| 26 | + | if !ok || workspace.X >= 0 || workspace.Y != 0 { | |
| 27 | + | t.Fatalf("workspace swipe must move horizontally: %v, shown=%v", workspace, ok) | |
| 28 | + | } | |
| 29 | + | r.end(t, true) | |
| 30 | + | r.settleAll(t, r.outs...) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestCascadeDiscreteSwipeRevealsBand(t *testing.T) { | |
| 34 | + | r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3; c.Animations.On = false }) | |
| 35 | + | for i := 1; i <= 4; i++ { | |
| 36 | + | r.mapWindow(t, ports.WindowID(i)) | |
| 37 | + | } | |
| 38 | + | r.begin() | |
| 39 | + | for range 10 { | |
| 40 | + | r.at += 8 * time.Millisecond | |
| 41 | + | r.input <- ports.SwipeUpdate{DY: -40, Time: r.at} | |
| 42 | + | } | |
| 43 | + | s := r.end(t, false) | |
| 44 | + | p, ok := rectOf(s, 1) | |
| 45 | + | if !ok || p.Y != 0 { | |
| 46 | + | t.Fatalf("band not revealed: %v shown=%v", p, ok) | |
| 47 | + | } | |
| 48 | + | } | |
| @@ -0,0 +1,48 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestCascadeSwipeAxes(t *testing.T) { | |||
| 11 | + | r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3 }) | |||
| 12 | + | for i := 1; i <= 7; i++ { | |||
| 13 | + | r.mapWindow(t, ports.WindowID(i)) | |||
| 14 | + | } | |||
| 15 | + | r.begin() | |||
| 16 | + | s := r.move(t, 0, -400) | |||
| 17 | + | band, ok := rectOf(s, 7) | |||
| 18 | + | if !ok || band.X != 0 || band.Y <= 0 { | |||
| 19 | + | t.Fatalf("band swipe must move vertically: %v, shown=%v", band, ok) | |||
| 20 | + | } | |||
| 21 | + | r.end(t, false) | |||
| 22 | + | r.settleAll(t, r.outs...) | |||
| 23 | + | r.begin() | |||
| 24 | + | s = r.move(t, 400, 0) | |||
| 25 | + | workspace, ok := rectOf(s, 7) | |||
| 26 | + | if !ok || workspace.X >= 0 || workspace.Y != 0 { | |||
| 27 | + | t.Fatalf("workspace swipe must move horizontally: %v, shown=%v", workspace, ok) | |||
| 28 | + | } | |||
| 29 | + | r.end(t, true) | |||
| 30 | + | r.settleAll(t, r.outs...) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestCascadeDiscreteSwipeRevealsBand(t *testing.T) { | |||
| 34 | + | r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3; c.Animations.On = false }) | |||
| 35 | + | for i := 1; i <= 4; i++ { | |||
| 36 | + | r.mapWindow(t, ports.WindowID(i)) | |||
| 37 | + | } | |||
| 38 | + | r.begin() | |||
| 39 | + | for range 10 { | |||
| 40 | + | r.at += 8 * time.Millisecond | |||
| 41 | + | r.input <- ports.SwipeUpdate{DY: -40, Time: r.at} | |||
| 42 | + | } | |||
| 43 | + | s := r.end(t, false) | |||
| 44 | + | p, ok := rectOf(s, 1) | |||
| 45 | + | if !ok || p.Y != 0 { | |||
| 46 | + | t.Fatalf("band not revealed: %v shown=%v", p, ok) | |||
| 47 | + | } | |||
| 48 | + | } | |||
A internal/core/cascade_test.go +213 -0
| @@ -0,0 +1,213 @@ | |||
| 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 TestCascadeClearsMaximizationOnBandChanges(t *testing.T) { | |
| 98 | + | for _, move := range []struct { | |
| 99 | + | name string | |
| 100 | + | run func(*Workspace) | |
| 101 | + | }{ | |
| 102 | + | {"focus", func(w *Workspace) { w.FocusWindow(1) }}, | |
| 103 | + | {"append", func(w *Workspace) { w.AddWindow(5) }}, | |
| 104 | + | {"insert", func(w *Workspace) { w.insertColumn(4, Column{Windows: []WindowID{5}}) }}, | |
| 105 | + | } { | |
| 106 | + | t.Run(move.name, func(t *testing.T) { | |
| 107 | + | w := cascadeWorkspace(4) | |
| 108 | + | w.FocusID(1) | |
| 109 | + | w.ToggleFullWidth() | |
| 110 | + | move.run(w) | |
| 111 | + | for _, col := range w.Columns { | |
| 112 | + | if col.FullWidth { | |
| 113 | + | t.Fatal("stale maximization") | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | }) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestCascadeReloadAndStashRestoreClearMaximization(t *testing.T) { | |
| 121 | + | w := cascadeWorkspace(3) | |
| 122 | + | w.FocusID(1) | |
| 123 | + | w.ToggleFullWidth() | |
| 124 | + | w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1}) | |
| 125 | + | for _, c := range w.Columns { | |
| 126 | + | if c.FullWidth { | |
| 127 | + | t.Fatal("restore retained maximization") | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | m := NewMonitor() | |
| 131 | + | m.SetOutput(900, 600) | |
| 132 | + | for i := WindowID(1); i <= 3; i++ { | |
| 133 | + | m.AddWindow(i) | |
| 134 | + | } | |
| 135 | + | m.Current().FocusID(1) | |
| 136 | + | m.Current().ToggleFullWidth() | |
| 137 | + | m.Current().FocusID(3) | |
| 138 | + | m.SetOverflow(OverflowCascade) | |
| 139 | + | for _, c := range m.Current().Columns { | |
| 140 | + | if c.FullWidth { | |
| 141 | + | t.Fatal("reload retained maximization") | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) { | |
| 147 | + | m := NewMonitor() | |
| 148 | + | m.SetOutput(900, 600) | |
| 149 | + | m.SetOverflow(OverflowCascade) | |
| 150 | + | for i := WindowID(1); i <= 7; i++ { | |
| 151 | + | m.AddWindow(i) | |
| 152 | + | } | |
| 153 | + | g := &swipeGesture{ws: m.Current(), points: m.Current().snapPoints(), horizontal: false, opens: m.overviewOpens} | |
| 154 | + | if n := testing.AllocsPerRun(100, func() { | |
| 155 | + | if g.columnsChanged(m) { | |
| 156 | + | t.Fatal("unchanged gesture invalidated") | |
| 157 | + | } | |
| 158 | + | }); n != 0 { | |
| 159 | + | t.Fatalf("allocations=%v", n) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestCascadeGeometryReusesBuffer(t *testing.T) { | |
| 164 | + | w := cascadeWorkspace(7) | |
| 165 | + | buf := w.columnRectsInto(nil, false) | |
| 166 | + | if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 { | |
| 167 | + | t.Fatalf("allocations=%v", n) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) { | |
| 172 | + | m := NewMonitor() | |
| 173 | + | m.SetOutput(900, 600) | |
| 174 | + | m.Current().Overflow = OverflowCascade | |
| 175 | + | m.AddWindow(1) | |
| 176 | + | m.Focus(1) | |
| 177 | + | m.AddWindow(2) | |
| 178 | + | m.Current().Overflow = OverflowScroll | |
| 179 | + | m.Focus(0) | |
| 180 | + | m.switchAxis = m.Current().policy().workspace | |
| 181 | + | m.switchView.off = .5 | |
| 182 | + | ps := m.layoutInto(nil) | |
| 183 | + | for _, p := range ps { | |
| 184 | + | if p.ID == 1 && (p.Rect.X != -450 || p.Rect.Y != 0) { | |
| 185 | + | t.Fatalf("cascade source offset=%v", p.Rect) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | m.stopSwitch() | |
| 189 | + | m.Focus(1) | |
| 190 | + | m.switchAxis = m.Current().policy().workspace | |
| 191 | + | m.switchView.off = -.5 | |
| 192 | + | ps = m.layoutInto(nil) | |
| 193 | + | for _, p := range ps { | |
| 194 | + | if p.ID == 2 && (p.Rect.X != 0 || p.Rect.Y != 300) { | |
| 195 | + | t.Fatalf("scroll source offset=%v", p.Rect) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestCascadeViewportSnap(t *testing.T) { | |
| 201 | + | w := cascadeWorkspace(7) | |
| 202 | + | w.FocusID(3) | |
| 203 | + | if w.snapFocus(600, true) != 5 { | |
| 204 | + | t.Fatalf("nearest cell=%d", w.snapFocus(600, true)) | |
| 205 | + | } | |
| 206 | + | if w.snapFocus(1200, true) != 6 { | |
| 207 | + | t.Fatal("short band neighbour") | |
| 208 | + | } | |
| 209 | + | w.SetUsable(Rect{}) | |
| 210 | + | if got := w.snapPoints(); len(got) != 1 || got[0] != 0 { | |
| 211 | + | t.Fatalf("zero area=%v", got) | |
| 212 | + | } | |
| 213 | + | } | |
| @@ -0,0 +1,213 @@ | |||||
| 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 TestCascadeClearsMaximizationOnBandChanges(t *testing.T) { | |||
| 98 | + | for _, move := range []struct { | |||
| 99 | + | name string | |||
| 100 | + | run func(*Workspace) | |||
| 101 | + | }{ | |||
| 102 | + | {"focus", func(w *Workspace) { w.FocusWindow(1) }}, | |||
| 103 | + | {"append", func(w *Workspace) { w.AddWindow(5) }}, | |||
| 104 | + | {"insert", func(w *Workspace) { w.insertColumn(4, Column{Windows: []WindowID{5}}) }}, | |||
| 105 | + | } { | |||
| 106 | + | t.Run(move.name, func(t *testing.T) { | |||
| 107 | + | w := cascadeWorkspace(4) | |||
| 108 | + | w.FocusID(1) | |||
| 109 | + | w.ToggleFullWidth() | |||
| 110 | + | move.run(w) | |||
| 111 | + | for _, col := range w.Columns { | |||
| 112 | + | if col.FullWidth { | |||
| 113 | + | t.Fatal("stale maximization") | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | }) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestCascadeReloadAndStashRestoreClearMaximization(t *testing.T) { | |||
| 121 | + | w := cascadeWorkspace(3) | |||
| 122 | + | w.FocusID(1) | |||
| 123 | + | w.ToggleFullWidth() | |||
| 124 | + | w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1}) | |||
| 125 | + | for _, c := range w.Columns { | |||
| 126 | + | if c.FullWidth { | |||
| 127 | + | t.Fatal("restore retained maximization") | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | m := NewMonitor() | |||
| 131 | + | m.SetOutput(900, 600) | |||
| 132 | + | for i := WindowID(1); i <= 3; i++ { | |||
| 133 | + | m.AddWindow(i) | |||
| 134 | + | } | |||
| 135 | + | m.Current().FocusID(1) | |||
| 136 | + | m.Current().ToggleFullWidth() | |||
| 137 | + | m.Current().FocusID(3) | |||
| 138 | + | m.SetOverflow(OverflowCascade) | |||
| 139 | + | for _, c := range m.Current().Columns { | |||
| 140 | + | if c.FullWidth { | |||
| 141 | + | t.Fatal("reload retained maximization") | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) { | |||
| 147 | + | m := NewMonitor() | |||
| 148 | + | m.SetOutput(900, 600) | |||
| 149 | + | m.SetOverflow(OverflowCascade) | |||
| 150 | + | for i := WindowID(1); i <= 7; i++ { | |||
| 151 | + | m.AddWindow(i) | |||
| 152 | + | } | |||
| 153 | + | g := &swipeGesture{ws: m.Current(), points: m.Current().snapPoints(), horizontal: false, opens: m.overviewOpens} | |||
| 154 | + | if n := testing.AllocsPerRun(100, func() { | |||
| 155 | + | if g.columnsChanged(m) { | |||
| 156 | + | t.Fatal("unchanged gesture invalidated") | |||
| 157 | + | } | |||
| 158 | + | }); n != 0 { | |||
| 159 | + | t.Fatalf("allocations=%v", n) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestCascadeGeometryReusesBuffer(t *testing.T) { | |||
| 164 | + | w := cascadeWorkspace(7) | |||
| 165 | + | buf := w.columnRectsInto(nil, false) | |||
| 166 | + | if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 { | |||
| 167 | + | t.Fatalf("allocations=%v", n) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) { | |||
| 172 | + | m := NewMonitor() | |||
| 173 | + | m.SetOutput(900, 600) | |||
| 174 | + | m.Current().Overflow = OverflowCascade | |||
| 175 | + | m.AddWindow(1) | |||
| 176 | + | m.Focus(1) | |||
| 177 | + | m.AddWindow(2) | |||
| 178 | + | m.Current().Overflow = OverflowScroll | |||
| 179 | + | m.Focus(0) | |||
| 180 | + | m.switchAxis = m.Current().policy().workspace | |||
| 181 | + | m.switchView.off = .5 | |||
| 182 | + | ps := m.layoutInto(nil) | |||
| 183 | + | for _, p := range ps { | |||
| 184 | + | if p.ID == 1 && (p.Rect.X != -450 || p.Rect.Y != 0) { | |||
| 185 | + | t.Fatalf("cascade source offset=%v", p.Rect) | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | m.stopSwitch() | |||
| 189 | + | m.Focus(1) | |||
| 190 | + | m.switchAxis = m.Current().policy().workspace | |||
| 191 | + | m.switchView.off = -.5 | |||
| 192 | + | ps = m.layoutInto(nil) | |||
| 193 | + | for _, p := range ps { | |||
| 194 | + | if p.ID == 2 && (p.Rect.X != 0 || p.Rect.Y != 300) { | |||
| 195 | + | t.Fatalf("scroll source offset=%v", p.Rect) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestCascadeViewportSnap(t *testing.T) { | |||
| 201 | + | w := cascadeWorkspace(7) | |||
| 202 | + | w.FocusID(3) | |||
| 203 | + | if w.snapFocus(600, true) != 5 { | |||
| 204 | + | t.Fatalf("nearest cell=%d", w.snapFocus(600, true)) | |||
| 205 | + | } | |||
| 206 | + | if w.snapFocus(1200, true) != 6 { | |||
| 207 | + | t.Fatal("short band neighbour") | |||
| 208 | + | } | |||
| 209 | + | w.SetUsable(Rect{}) | |||
| 210 | + | if got := w.snapPoints(); len(got) != 1 || got[0] != 0 { | |||
| 211 | + | t.Fatalf("zero area=%v", got) | |||
| 212 | + | } | |||
| 213 | + | } | |||
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 +55 -18
| @@ -90,7 +90,7 @@ type swipeGesture struct { | |||
| 90 | 90 | // listChanged reports whether the numbered workspaces or the active one | |
| 91 | 91 | // changed since a workspace slide began, or the overview opened over it. | |
| 92 | 92 | func (g *swipeGesture) listChanged(m *Monitor) bool { | |
| 93 | - | return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list) | |
| 93 | + | return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || g.horizontal != (g.ws.policy().workspace == horizontalAxis) || !slices.Equal(m.Workspaces, g.list) | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | 96 | // columnsChanged reports whether a column swipe lost its workspace: it is | |
| @@ -98,7 +98,22 @@ func (g *swipeGesture) listChanged(m *Monitor) bool { | |||
| 98 | 98 | // panel, a window mapped or resized, other gaps), or the overview opened | |
| 99 | 99 | // over it. | |
| 100 | 100 | func (g *swipeGesture) columnsChanged(m *Monitor) bool { | |
| 101 | - | return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points) | |
| 101 | + | if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || g.horizontal != (g.ws.policy().content == horizontalAxis) { | |
| 102 | + | return true | |
| 103 | + | } | |
| 104 | + | if g.ws.policy().wraps { | |
| 105 | + | n, spacing := g.ws.cascadeBands() | |
| 106 | + | if len(g.points) != n { | |
| 107 | + | return true | |
| 108 | + | } | |
| 109 | + | for i, p := range g.points { | |
| 110 | + | if p != float64(i*spacing) { | |
| 111 | + | return true | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | return false | |
| 115 | + | } | |
| 116 | + | return !slices.Equal(g.ws.snapPoints(), g.points) | |
| 102 | 117 | } | |
| 103 | 118 | ||
| 104 | 119 | // stashChanged reports whether a stash swipe lost its stash: it is no | |
| @@ -191,7 +206,7 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||
| 191 | 206 | g.mode = swipeDropped | |
| 192 | 207 | return true | |
| 193 | 208 | } | |
| 194 | - | g.ws.view.off = g.snap.pos() - float64(g.ws.ViewX) | |
| 209 | + | g.ws.view.off = g.snap.pos() - float64(g.ws.View) | |
| 195 | 210 | return true | |
| 196 | 211 | case swipeWorkspaces: | |
| 197 | 212 | if g.listChanged(m) { | |
| @@ -241,16 +256,21 @@ func (c *Core) decide(g *swipeGesture) { | |||
| 241 | 256 | } | |
| 242 | 257 | w.stashView.motion = motion{} | |
| 243 | 258 | 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(): | |
| 259 | + | case g.horizontal == (w.policy().content == horizontalAxis) && w.slidable(): | |
| 245 | 260 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | |
| 246 | 261 | 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: | |
| 262 | + | 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)))) | |
| 263 | + | case g.horizontal == (w.policy().workspace == horizontalAxis) && m.shown == nil: | |
| 249 | 264 | // A landing slide measured in an older list lands at once first. | |
| 250 | 265 | if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) { | |
| 251 | 266 | m.stopSwitch() | |
| 252 | 267 | } | |
| 253 | 268 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | |
| 269 | + | axis := w.policy().workspace | |
| 270 | + | if m.switchView.busy() && m.switchAxis != axis { | |
| 271 | + | m.stopSwitch() | |
| 272 | + | } | |
| 273 | + | m.switchAxis = axis | |
| 254 | 274 | g.list = slices.Clone(m.Workspaces) | |
| 255 | 275 | m.switchView.motion, m.switchList = motion{}, nil | |
| 256 | 276 | g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) | |
| @@ -282,18 +302,22 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 282 | 302 | w.view.stop() | |
| 283 | 303 | return false | |
| 284 | 304 | } | |
| 285 | - | shown := float64(w.ViewX) + w.view.off | |
| 305 | + | shown := float64(w.View) + w.view.off | |
| 286 | 306 | target, velocity := g.snap.end(e.Cancelled, e.Time) | |
| 287 | 307 | if !e.Cancelled { | |
| 288 | 308 | view := int(math.Round(target)) | |
| 289 | - | w.Focus = w.snapFocus(view, target >= shown) | |
| 290 | - | w.ViewX = view | |
| 309 | + | focus := w.snapFocus(view, target >= shown) | |
| 310 | + | if focus != w.Focus && w.policy().equalCells { | |
| 311 | + | w.unmaximize() | |
| 312 | + | } | |
| 313 | + | w.Focus = focus | |
| 314 | + | w.View = view | |
| 291 | 315 | // A column wider than the view aligns as focus moves align it. | |
| 292 | 316 | w.scroll() | |
| 293 | 317 | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 294 | 318 | c.keyboard.takeBack() | |
| 295 | 319 | } | |
| 296 | - | w.view.off = shown - float64(w.ViewX) | |
| 320 | + | w.view.off = shown - float64(w.View) | |
| 297 | 321 | if c.animOn() { | |
| 298 | 322 | w.view.motion = c.spring(viewSpring(w.view.off, velocity), now) | |
| 299 | 323 | } else { | |
| @@ -380,14 +404,23 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 380 | 404 | if step == 0 { | |
| 381 | 405 | return false | |
| 382 | 406 | } | |
| 383 | - | a := ActionFocusWorkspaceDown | |
| 384 | - | switch { | |
| 385 | - | case g.horizontal && step < 0: | |
| 407 | + | w := g.screen.mon.Current() | |
| 408 | + | p := w.policy() | |
| 409 | + | workspace := g.horizontal == (p.workspace == horizontalAxis) | |
| 410 | + | a := ActionFocusColumnRight | |
| 411 | + | if step < 0 { | |
| 386 | 412 | a = ActionFocusColumnLeft | |
| 387 | - | case g.horizontal: | |
| 388 | - | a = ActionFocusColumnRight | |
| 389 | - | case step < 0: | |
| 390 | - | a = ActionFocusWorkspaceUp | |
| 413 | + | } | |
| 414 | + | if workspace { | |
| 415 | + | a = ActionFocusWorkspaceDown | |
| 416 | + | if step < 0 { | |
| 417 | + | a = ActionFocusWorkspaceUp | |
| 418 | + | } | |
| 419 | + | } else if p.wraps { | |
| 420 | + | // A content swipe cannot escape the last band to another workspace. | |
| 421 | + | if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 { | |
| 422 | + | return false | |
| 423 | + | } | |
| 391 | 424 | } | |
| 392 | 425 | // Focus actions act on the focused screen: the swipe's, unless the | |
| 393 | 426 | // pointer took the focus to another output meanwhile. | |
| @@ -400,7 +433,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 400 | 433 | before := c.cur().mon.Current() | |
| 401 | 434 | shots := c.snapshot(now) | |
| 402 | 435 | c.keyboard.takeBack() | |
| 403 | - | c.applyAction(a) | |
| 436 | + | if p.wraps && !workspace { | |
| 437 | + | w.focusBand(step) | |
| 438 | + | } else { | |
| 439 | + | c.applyAction(a) | |
| 440 | + | } | |
| 404 | 441 | c.transition(shots, now) | |
| 405 | 442 | return c.cur().mon.Current() != before | |
| 406 | 443 | } | |
| @@ -90,7 +90,7 @@ type swipeGesture struct { | |||||
| 90 | // listChanged reports whether the numbered workspaces or the active one | 90 | // listChanged reports whether the numbered workspaces or the active one | ||
| 91 | // changed since a workspace slide began, or the overview opened over it. | 91 | // changed since a workspace slide began, or the overview opened over it. | ||
| 92 | func (g *swipeGesture) listChanged(m *Monitor) bool { | 92 | func (g *swipeGesture) listChanged(m *Monitor) bool { | ||
| 93 | - | return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list) | 93 | + | return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || g.horizontal != (g.ws.policy().workspace == horizontalAxis) || !slices.Equal(m.Workspaces, g.list) |
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | // columnsChanged reports whether a column swipe lost its workspace: it is | 96 | // columnsChanged reports whether a column swipe lost its workspace: it is | ||
| @@ -98,7 +98,22 @@ func (g *swipeGesture) listChanged(m *Monitor) bool { | |||||
| 98 | // panel, a window mapped or resized, other gaps), or the overview opened | 98 | // panel, a window mapped or resized, other gaps), or the overview opened | ||
| 99 | // over it. | 99 | // over it. | ||
| 100 | func (g *swipeGesture) columnsChanged(m *Monitor) bool { | 100 | func (g *swipeGesture) columnsChanged(m *Monitor) bool { | ||
| 101 | - | return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points) | 101 | + | if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || g.horizontal != (g.ws.policy().content == horizontalAxis) { |
| 102 | + | return true | |||
| 103 | + | } | |||
| 104 | + | if g.ws.policy().wraps { | |||
| 105 | + | n, spacing := g.ws.cascadeBands() | |||
| 106 | + | if len(g.points) != n { | |||
| 107 | + | return true | |||
| 108 | + | } | |||
| 109 | + | for i, p := range g.points { | |||
| 110 | + | if p != float64(i*spacing) { | |||
| 111 | + | return true | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | return false | |||
| 115 | + | } | |||
| 116 | + | return !slices.Equal(g.ws.snapPoints(), g.points) | |||
| 102 | } | 117 | } | ||
| 103 | 118 | ||||
| 104 | // stashChanged reports whether a stash swipe lost its stash: it is no | 119 | // stashChanged reports whether a stash swipe lost its stash: it is no | ||
| @@ -191,7 +206,7 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||||
| 191 | g.mode = swipeDropped | 206 | g.mode = swipeDropped | ||
| 192 | return true | 207 | return true | ||
| 193 | } | 208 | } | ||
| 194 | - | g.ws.view.off = g.snap.pos() - float64(g.ws.ViewX) | 209 | + | g.ws.view.off = g.snap.pos() - float64(g.ws.View) |
| 195 | return true | 210 | return true | ||
| 196 | case swipeWorkspaces: | 211 | case swipeWorkspaces: | ||
| 197 | if g.listChanged(m) { | 212 | if g.listChanged(m) { | ||
| @@ -241,16 +256,21 @@ func (c *Core) decide(g *swipeGesture) { | |||||
| 241 | } | 256 | } | ||
| 242 | w.stashView.motion = motion{} | 257 | w.stashView.motion = motion{} | ||
| 243 | g.snap = newSnapSwipe(float64(w.stashAt)+w.stashView.off, float64(w.stashAt), 1/stashSwipeMovement, indexPoints(len(w.Stash)), workspaceBand) | 258 | 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(): | 259 | + | case g.horizontal == (w.policy().content == horizontalAxis) && w.slidable(): |
| 245 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | 260 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | ||
| 246 | w.view.motion = motion{} | 261 | 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))) | 262 | + | 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: | 263 | + | case g.horizontal == (w.policy().workspace == horizontalAxis) && m.shown == nil: |
| 249 | // A landing slide measured in an older list lands at once first. | 264 | // A landing slide measured in an older list lands at once first. | ||
| 250 | if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) { | 265 | if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) { | ||
| 251 | m.stopSwitch() | 266 | m.stopSwitch() | ||
| 252 | } | 267 | } | ||
| 253 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | 268 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | ||
| 269 | + | axis := w.policy().workspace | |||
| 270 | + | if m.switchView.busy() && m.switchAxis != axis { | |||
| 271 | + | m.stopSwitch() | |||
| 272 | + | } | |||
| 273 | + | m.switchAxis = axis | |||
| 254 | g.list = slices.Clone(m.Workspaces) | 274 | g.list = slices.Clone(m.Workspaces) | ||
| 255 | m.switchView.motion, m.switchList = motion{}, nil | 275 | 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) | 276 | g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) | ||
| @@ -282,18 +302,22 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 282 | w.view.stop() | 302 | w.view.stop() | ||
| 283 | return false | 303 | return false | ||
| 284 | } | 304 | } | ||
| 285 | - | shown := float64(w.ViewX) + w.view.off | 305 | + | shown := float64(w.View) + w.view.off |
| 286 | target, velocity := g.snap.end(e.Cancelled, e.Time) | 306 | target, velocity := g.snap.end(e.Cancelled, e.Time) | ||
| 287 | if !e.Cancelled { | 307 | if !e.Cancelled { | ||
| 288 | view := int(math.Round(target)) | 308 | view := int(math.Round(target)) | ||
| 289 | - | w.Focus = w.snapFocus(view, target >= shown) | 309 | + | focus := w.snapFocus(view, target >= shown) |
| 290 | - | w.ViewX = view | 310 | + | if focus != w.Focus && w.policy().equalCells { |
| 311 | + | w.unmaximize() | |||
| 312 | + | } | |||
| 313 | + | w.Focus = focus | |||
| 314 | + | w.View = view | |||
| 291 | // A column wider than the view aligns as focus moves align it. | 315 | // A column wider than the view aligns as focus moves align it. | ||
| 292 | w.scroll() | 316 | w.scroll() | ||
| 293 | c.focusScreen = c.screenIndex(g.screen.name()) | 317 | c.focusScreen = c.screenIndex(g.screen.name()) | ||
| 294 | c.keyboard.takeBack() | 318 | c.keyboard.takeBack() | ||
| 295 | } | 319 | } | ||
| 296 | - | w.view.off = shown - float64(w.ViewX) | 320 | + | w.view.off = shown - float64(w.View) |
| 297 | if c.animOn() { | 321 | if c.animOn() { | ||
| 298 | w.view.motion = c.spring(viewSpring(w.view.off, velocity), now) | 322 | w.view.motion = c.spring(viewSpring(w.view.off, velocity), now) | ||
| 299 | } else { | 323 | } else { | ||
| @@ -380,14 +404,23 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 380 | if step == 0 { | 404 | if step == 0 { | ||
| 381 | return false | 405 | return false | ||
| 382 | } | 406 | } | ||
| 383 | - | a := ActionFocusWorkspaceDown | 407 | + | w := g.screen.mon.Current() |
| 384 | - | switch { | 408 | + | p := w.policy() |
| 385 | - | case g.horizontal && step < 0: | 409 | + | workspace := g.horizontal == (p.workspace == horizontalAxis) |
| 410 | + | a := ActionFocusColumnRight | |||
| 411 | + | if step < 0 { | |||
| 386 | a = ActionFocusColumnLeft | 412 | a = ActionFocusColumnLeft | ||
| 387 | - | case g.horizontal: | 413 | + | } |
| 388 | - | a = ActionFocusColumnRight | 414 | + | if workspace { |
| 389 | - | case step < 0: | 415 | + | a = ActionFocusWorkspaceDown |
| 390 | - | a = ActionFocusWorkspaceUp | 416 | + | if step < 0 { |
| 417 | + | a = ActionFocusWorkspaceUp | |||
| 418 | + | } | |||
| 419 | + | } else if p.wraps { | |||
| 420 | + | // A content swipe cannot escape the last band to another workspace. | |||
| 421 | + | if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 { | |||
| 422 | + | return false | |||
| 423 | + | } | |||
| 391 | } | 424 | } | ||
| 392 | // Focus actions act on the focused screen: the swipe's, unless the | 425 | // Focus actions act on the focused screen: the swipe's, unless the | ||
| 393 | // pointer took the focus to another output meanwhile. | 426 | // pointer took the focus to another output meanwhile. | ||
| @@ -400,7 +433,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 400 | before := c.cur().mon.Current() | 433 | before := c.cur().mon.Current() | ||
| 401 | shots := c.snapshot(now) | 434 | shots := c.snapshot(now) | ||
| 402 | c.keyboard.takeBack() | 435 | c.keyboard.takeBack() | ||
| 403 | - | c.applyAction(a) | 436 | + | if p.wraps && !workspace { |
| 437 | + | w.focusBand(step) | |||
| 438 | + | } else { | |||
| 439 | + | c.applyAction(a) | |||
| 440 | + | } | |||
| 404 | c.transition(shots, now) | 441 | c.transition(shots, now) | ||
| 405 | return c.cur().mon.Current() != before | 442 | return c.cur().mon.Current() != before | ||
| 406 | } | 443 | } | ||
M internal/core/layout.go +49 -30
| @@ -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,13 +511,13 @@ 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 | |
| 516 | 519 | } | |
| 517 | - | if w.Overflow == OverflowFixed && len(w.Columns) > 0 { | |
| 520 | + | if w.policy().equalCells && len(w.Columns) > 0 { | |
| 518 | 521 | w.unmaximize() | |
| 519 | 522 | } | |
| 520 | 523 | w.Columns = append(w.Columns, Column{}) | |
| @@ -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,9 @@ func (w *Workspace) focusWindow(dir int) bool { | |||
| 774 | 777 | w.scroll() | |
| 775 | 778 | return true | |
| 776 | 779 | } | |
| 780 | + | if w.policy().wraps && w.focusBand(dir) { | |
| 781 | + | return true | |
| 782 | + | } | |
| 777 | 783 | if w.onScreenFocus() { | |
| 778 | 784 | if i := w.screenNeighbor(0, dir); i >= 0 { | |
| 779 | 785 | w.Focus = i | |
| @@ -865,7 +871,7 @@ func (w *Workspace) MoveWindow(dir int) { | |||
| 865 | 871 | // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed | |
| 866 | 872 | // overflow shares the width equally: nothing changes there. | |
| 867 | 873 | func (w *Workspace) ResizeColumn(pct int) { | |
| 868 | - | if w.onFloat() || len(w.Columns) == 0 || w.Overflow == OverflowFixed || w.fullscreenHides() { | |
| 874 | + | if w.onFloat() || len(w.Columns) == 0 || w.policy().equalCells || w.fullscreenHides() { | |
| 869 | 875 | return | |
| 870 | 876 | } | |
| 871 | 877 | w.unmaximize() | |
| @@ -973,7 +979,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||
| 973 | 979 | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | |
| 974 | 980 | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | |
| 975 | 981 | if len(w.Columns) == 0 { | |
| 976 | - | w.ViewX = 0 | |
| 982 | + | w.View = 0 | |
| 977 | 983 | } else { | |
| 978 | 984 | w.scroll() | |
| 979 | 985 | } | |
| @@ -983,7 +989,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||
| 983 | 989 | // insertColumn adds a column at index at (clamped) and focuses it. | |
| 984 | 990 | func (w *Workspace) insertColumn(at int, col Column) { | |
| 985 | 991 | at = min(max(at, 0), len(w.Columns)) | |
| 986 | - | if w.Overflow == OverflowFixed && len(w.Columns) > 0 { | |
| 992 | + | if w.policy().equalCells && len(w.Columns) > 0 { | |
| 987 | 993 | w.unmaximize() | |
| 988 | 994 | } | |
| 989 | 995 | w.Columns = slices.Insert(w.Columns, at, col) | |
| @@ -994,6 +1000,9 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||
| 994 | 1000 | // CycleWidth steps the focused column through the presets. Fixed overflow | |
| 995 | 1001 | // ignores presets: it toggles the expanded column instead. | |
| 996 | 1002 | func (w *Workspace) CycleWidth() { | |
| 1003 | + | if w.policy().wraps { | |
| 1004 | + | return | |
| 1005 | + | } | |
| 997 | 1006 | if w.Overflow == OverflowFixed { | |
| 998 | 1007 | w.toggleExpanded() | |
| 999 | 1008 | return | |
| @@ -1047,11 +1056,11 @@ func (w *Workspace) maximize(i int) { | |||
| 1047 | 1056 | // columns in fixed mode (including after Escape changed focus). | |
| 1048 | 1057 | func (w *Workspace) unmaximize() { | |
| 1049 | 1058 | for i := range w.Columns { | |
| 1050 | - | if w.Overflow == OverflowFixed || i == w.Focus { | |
| 1059 | + | if w.policy().equalCells || i == w.Focus { | |
| 1051 | 1060 | w.Columns[i].FullWidth = false | |
| 1052 | 1061 | } | |
| 1053 | 1062 | } | |
| 1054 | - | if w.Overflow == OverflowFixed { | |
| 1063 | + | if w.policy().equalCells { | |
| 1055 | 1064 | w.maximized = nil | |
| 1056 | 1065 | } | |
| 1057 | 1066 | } | |
| @@ -1262,7 +1271,7 @@ func (w *Workspace) mayCover(id WindowID) bool { | |||
| 1262 | 1271 | } | |
| 1263 | 1272 | w.left = 0 | |
| 1264 | 1273 | } | |
| 1265 | - | if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 { | |
| 1274 | + | if !w.policy().equalCells || w.stashHidden && w.stashIndex(id) >= 0 { | |
| 1266 | 1275 | return true | |
| 1267 | 1276 | } | |
| 1268 | 1277 | focused, ok := w.Focused() | |
| @@ -1275,7 +1284,7 @@ func (w *Workspace) mayCover(id WindowID) bool { | |||
| 1275 | 1284 | // tiled neighbor scroll it off and show the target instead. | |
| 1276 | 1285 | func (w *Workspace) pinned() bool { | |
| 1277 | 1286 | c := w.cover() | |
| 1278 | - | return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c)) | |
| 1287 | + | return c != 0 && (w.policy().equalCells || w.isFloat(c)) | |
| 1279 | 1288 | } | |
| 1280 | 1289 | ||
| 1281 | 1290 | // leaveCover runs a focus move from the covering fullscreen window as if it | |
| @@ -1333,12 +1342,12 @@ func (w *Workspace) cover() WindowID { | |||
| 1333 | 1342 | if w.fullscreen == 0 { | |
| 1334 | 1343 | return 0 | |
| 1335 | 1344 | } | |
| 1336 | - | if w.isFloat(w.fullscreen) || w.Overflow == OverflowFixed { | |
| 1345 | + | if w.isFloat(w.fullscreen) || w.policy().equalCells { | |
| 1337 | 1346 | return w.fullscreen | |
| 1338 | 1347 | } | |
| 1339 | 1348 | // Scroll overflow: only while its column is aligned on the output. | |
| 1340 | 1349 | for i, c := range w.Columns { | |
| 1341 | - | if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.ViewX == 0 { | |
| 1350 | + | if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.View == 0 { | |
| 1342 | 1351 | return w.fullscreen | |
| 1343 | 1352 | } | |
| 1344 | 1353 | } | |
| @@ -1349,7 +1358,7 @@ func (w *Workspace) cover() WindowID { | |||
| 1349 | 1358 | // column's layout: the others (fixed) or its own column (scroll). Column | |
| 1350 | 1359 | // edits would change what the user cannot see. | |
| 1351 | 1360 | func (w *Workspace) fullscreenHides() bool { | |
| 1352 | - | return w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) | |
| 1361 | + | return w.fullscreen != 0 && (w.policy().equalCells || w.fullscreenColumn(w.Focus)) | |
| 1353 | 1362 | } | |
| 1354 | 1363 | ||
| 1355 | 1364 | func (w *Workspace) fullscreenColumn(i int) bool { | |
| @@ -1373,7 +1382,10 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int { | |||
| 1373 | 1382 | } | |
| 1374 | 1383 | g := w.gap() | |
| 1375 | 1384 | // Fixed overflow never scrolls, so presets would push columns off screen. | |
| 1376 | - | if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed { | |
| 1385 | + | if w.policy().wraps { | |
| 1386 | + | return w.cellWidth() | |
| 1387 | + | } | |
| 1388 | + | if w.Columns[i].Width == (Width{}) || w.policy().equalCells { | |
| 1377 | 1389 | if len(w.Columns) == 1 { | |
| 1378 | 1390 | return max(w.Usable.W-2*g, 0) | |
| 1379 | 1391 | } | |
| @@ -1391,27 +1403,31 @@ func (w *Workspace) columnX(i int) int { | |||
| 1391 | 1403 | return x | |
| 1392 | 1404 | } | |
| 1393 | 1405 | func (w *Workspace) scroll() { | |
| 1394 | - | before := w.ViewX | |
| 1406 | + | before := w.View | |
| 1395 | 1407 | defer func() { w.retarget(before) }() | |
| 1408 | + | if w.policy().wraps { | |
| 1409 | + | w.View = w.band(w.Focus) * w.Usable.H | |
| 1410 | + | return | |
| 1411 | + | } | |
| 1396 | 1412 | if w.Overflow == OverflowFixed { | |
| 1397 | - | w.ViewX = 0 | |
| 1413 | + | w.View = 0 | |
| 1398 | 1414 | return | |
| 1399 | 1415 | } | |
| 1400 | 1416 | if len(w.Columns) == 0 { | |
| 1401 | 1417 | return | |
| 1402 | 1418 | } | |
| 1403 | - | left := w.columnX(w.Focus) - w.ViewX | |
| 1419 | + | left := w.columnX(w.Focus) - w.View | |
| 1404 | 1420 | width := w.columnWidth(w.Focus) | |
| 1405 | 1421 | if w.fullscreenColumn(w.Focus) { | |
| 1406 | - | w.ViewX += left - w.Output.X | |
| 1422 | + | w.View += left - w.Output.X | |
| 1407 | 1423 | return | |
| 1408 | 1424 | } | |
| 1409 | 1425 | minX := w.Usable.X + w.gap() | |
| 1410 | 1426 | maxX := w.Usable.X + w.Usable.W - w.gap() | |
| 1411 | 1427 | if width > maxX-minX || left < minX { | |
| 1412 | - | w.ViewX += left - minX | |
| 1428 | + | w.View += left - minX | |
| 1413 | 1429 | } else if left+width > maxX { | |
| 1414 | - | w.ViewX += left + width - maxX | |
| 1430 | + | w.View += left + width - maxX | |
| 1415 | 1431 | } | |
| 1416 | 1432 | } | |
| 1417 | 1433 | ||
| @@ -1430,10 +1446,13 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect { | |||
| 1430 | 1446 | // when too small): the result aliases dst, so a caller passing a reused | |
| 1431 | 1447 | // buffer must be done with the previous result. | |
| 1432 | 1448 | func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |
| 1449 | + | if w.policy().wraps { | |
| 1450 | + | return w.cascadeRectsInto(dst, ignoreFullWidth) | |
| 1451 | + | } | |
| 1433 | 1452 | g := w.gap() | |
| 1434 | 1453 | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | |
| 1435 | 1454 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 1436 | - | view := w.ViewX + w.shiftPixels() | |
| 1455 | + | view := w.View + w.shiftPixels() | |
| 1437 | 1456 | x := w.Usable.X + g | |
| 1438 | 1457 | for i := range w.Columns { | |
| 1439 | 1458 | width := w.columnWidthFor(i, ignoreFullWidth) | |
| @@ -1618,12 +1637,12 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement { | |||
| 1618 | 1637 | full := w.fullscreen == id && id != 0 | |
| 1619 | 1638 | // Fixed overflow cannot scroll to other columns while one fills | |
| 1620 | 1639 | // 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 | |
| 1640 | + | maximized := w.policy().equalCells && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus) | |
| 1641 | + | hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized | |
| 1623 | 1642 | if full { | |
| 1624 | 1643 | // Scroll mode aligns the view on the column; fixed never scrolls. | |
| 1625 | 1644 | r = w.Output | |
| 1626 | - | if w.Overflow != OverflowFixed { | |
| 1645 | + | if w.Overflow == OverflowScroll || w.Overflow == "" { | |
| 1627 | 1646 | r.X = col.X | |
| 1628 | 1647 | } | |
| 1629 | 1648 | } | |
| @@ -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,13 +511,13 @@ 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 | ||
| 516 | } | 519 | } | ||
| 517 | - | if w.Overflow == OverflowFixed && len(w.Columns) > 0 { | 520 | + | if w.policy().equalCells && len(w.Columns) > 0 { |
| 518 | w.unmaximize() | 521 | w.unmaximize() | ||
| 519 | } | 522 | } | ||
| 520 | w.Columns = append(w.Columns, Column{}) | 523 | w.Columns = append(w.Columns, Column{}) | ||
| @@ -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,9 @@ func (w *Workspace) focusWindow(dir int) bool { | |||||
| 774 | w.scroll() | 777 | w.scroll() | ||
| 775 | return true | 778 | return true | ||
| 776 | } | 779 | } | ||
| 780 | + | if w.policy().wraps && w.focusBand(dir) { | |||
| 781 | + | return true | |||
| 782 | + | } | |||
| 777 | if w.onScreenFocus() { | 783 | if w.onScreenFocus() { | ||
| 778 | if i := w.screenNeighbor(0, dir); i >= 0 { | 784 | if i := w.screenNeighbor(0, dir); i >= 0 { | ||
| 779 | w.Focus = i | 785 | w.Focus = i | ||
| @@ -865,7 +871,7 @@ func (w *Workspace) MoveWindow(dir int) { | |||||
| 865 | // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed | 871 | // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed | ||
| 866 | // overflow shares the width equally: nothing changes there. | 872 | // overflow shares the width equally: nothing changes there. | ||
| 867 | func (w *Workspace) ResizeColumn(pct int) { | 873 | func (w *Workspace) ResizeColumn(pct int) { | ||
| 868 | - | if w.onFloat() || len(w.Columns) == 0 || w.Overflow == OverflowFixed || w.fullscreenHides() { | 874 | + | if w.onFloat() || len(w.Columns) == 0 || w.policy().equalCells || w.fullscreenHides() { |
| 869 | return | 875 | return | ||
| 870 | } | 876 | } | ||
| 871 | w.unmaximize() | 877 | w.unmaximize() | ||
| @@ -973,7 +979,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||||
| 973 | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | 979 | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | ||
| 974 | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | 980 | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | ||
| 975 | if len(w.Columns) == 0 { | 981 | if len(w.Columns) == 0 { | ||
| 976 | - | w.ViewX = 0 | 982 | + | w.View = 0 |
| 977 | } else { | 983 | } else { | ||
| 978 | w.scroll() | 984 | w.scroll() | ||
| 979 | } | 985 | } | ||
| @@ -983,7 +989,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||||
| 983 | // insertColumn adds a column at index at (clamped) and focuses it. | 989 | // insertColumn adds a column at index at (clamped) and focuses it. | ||
| 984 | func (w *Workspace) insertColumn(at int, col Column) { | 990 | func (w *Workspace) insertColumn(at int, col Column) { | ||
| 985 | at = min(max(at, 0), len(w.Columns)) | 991 | at = min(max(at, 0), len(w.Columns)) | ||
| 986 | - | if w.Overflow == OverflowFixed && len(w.Columns) > 0 { | 992 | + | if w.policy().equalCells && len(w.Columns) > 0 { |
| 987 | w.unmaximize() | 993 | w.unmaximize() | ||
| 988 | } | 994 | } | ||
| 989 | w.Columns = slices.Insert(w.Columns, at, col) | 995 | w.Columns = slices.Insert(w.Columns, at, col) | ||
| @@ -994,6 +1000,9 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||||
| 994 | // CycleWidth steps the focused column through the presets. Fixed overflow | 1000 | // CycleWidth steps the focused column through the presets. Fixed overflow | ||
| 995 | // ignores presets: it toggles the expanded column instead. | 1001 | // ignores presets: it toggles the expanded column instead. | ||
| 996 | func (w *Workspace) CycleWidth() { | 1002 | func (w *Workspace) CycleWidth() { | ||
| 1003 | + | if w.policy().wraps { | |||
| 1004 | + | return | |||
| 1005 | + | } | |||
| 997 | if w.Overflow == OverflowFixed { | 1006 | if w.Overflow == OverflowFixed { | ||
| 998 | w.toggleExpanded() | 1007 | w.toggleExpanded() | ||
| 999 | return | 1008 | return | ||
| @@ -1047,11 +1056,11 @@ func (w *Workspace) maximize(i int) { | |||||
| 1047 | // columns in fixed mode (including after Escape changed focus). | 1056 | // columns in fixed mode (including after Escape changed focus). | ||
| 1048 | func (w *Workspace) unmaximize() { | 1057 | func (w *Workspace) unmaximize() { | ||
| 1049 | for i := range w.Columns { | 1058 | for i := range w.Columns { | ||
| 1050 | - | if w.Overflow == OverflowFixed || i == w.Focus { | 1059 | + | if w.policy().equalCells || i == w.Focus { |
| 1051 | w.Columns[i].FullWidth = false | 1060 | w.Columns[i].FullWidth = false | ||
| 1052 | } | 1061 | } | ||
| 1053 | } | 1062 | } | ||
| 1054 | - | if w.Overflow == OverflowFixed { | 1063 | + | if w.policy().equalCells { |
| 1055 | w.maximized = nil | 1064 | w.maximized = nil | ||
| 1056 | } | 1065 | } | ||
| 1057 | } | 1066 | } | ||
| @@ -1262,7 +1271,7 @@ func (w *Workspace) mayCover(id WindowID) bool { | |||||
| 1262 | } | 1271 | } | ||
| 1263 | w.left = 0 | 1272 | w.left = 0 | ||
| 1264 | } | 1273 | } | ||
| 1265 | - | if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 { | 1274 | + | if !w.policy().equalCells || w.stashHidden && w.stashIndex(id) >= 0 { |
| 1266 | return true | 1275 | return true | ||
| 1267 | } | 1276 | } | ||
| 1268 | focused, ok := w.Focused() | 1277 | focused, ok := w.Focused() | ||
| @@ -1275,7 +1284,7 @@ func (w *Workspace) mayCover(id WindowID) bool { | |||||
| 1275 | // tiled neighbor scroll it off and show the target instead. | 1284 | // tiled neighbor scroll it off and show the target instead. | ||
| 1276 | func (w *Workspace) pinned() bool { | 1285 | func (w *Workspace) pinned() bool { | ||
| 1277 | c := w.cover() | 1286 | c := w.cover() | ||
| 1278 | - | return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c)) | 1287 | + | return c != 0 && (w.policy().equalCells || w.isFloat(c)) |
| 1279 | } | 1288 | } | ||
| 1280 | 1289 | ||||
| 1281 | // leaveCover runs a focus move from the covering fullscreen window as if it | 1290 | // leaveCover runs a focus move from the covering fullscreen window as if it | ||
| @@ -1333,12 +1342,12 @@ func (w *Workspace) cover() WindowID { | |||||
| 1333 | if w.fullscreen == 0 { | 1342 | if w.fullscreen == 0 { | ||
| 1334 | return 0 | 1343 | return 0 | ||
| 1335 | } | 1344 | } | ||
| 1336 | - | if w.isFloat(w.fullscreen) || w.Overflow == OverflowFixed { | 1345 | + | if w.isFloat(w.fullscreen) || w.policy().equalCells { |
| 1337 | return w.fullscreen | 1346 | return w.fullscreen | ||
| 1338 | } | 1347 | } | ||
| 1339 | // Scroll overflow: only while its column is aligned on the output. | 1348 | // Scroll overflow: only while its column is aligned on the output. | ||
| 1340 | for i, c := range w.Columns { | 1349 | for i, c := range w.Columns { | ||
| 1341 | - | if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.ViewX == 0 { | 1350 | + | if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.View == 0 { |
| 1342 | return w.fullscreen | 1351 | return w.fullscreen | ||
| 1343 | } | 1352 | } | ||
| 1344 | } | 1353 | } | ||
| @@ -1349,7 +1358,7 @@ func (w *Workspace) cover() WindowID { | |||||
| 1349 | // column's layout: the others (fixed) or its own column (scroll). Column | 1358 | // column's layout: the others (fixed) or its own column (scroll). Column | ||
| 1350 | // edits would change what the user cannot see. | 1359 | // edits would change what the user cannot see. | ||
| 1351 | func (w *Workspace) fullscreenHides() bool { | 1360 | func (w *Workspace) fullscreenHides() bool { | ||
| 1352 | - | return w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) | 1361 | + | return w.fullscreen != 0 && (w.policy().equalCells || w.fullscreenColumn(w.Focus)) |
| 1353 | } | 1362 | } | ||
| 1354 | 1363 | ||||
| 1355 | func (w *Workspace) fullscreenColumn(i int) bool { | 1364 | func (w *Workspace) fullscreenColumn(i int) bool { | ||
| @@ -1373,7 +1382,10 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int { | |||||
| 1373 | } | 1382 | } | ||
| 1374 | g := w.gap() | 1383 | g := w.gap() | ||
| 1375 | // Fixed overflow never scrolls, so presets would push columns off screen. | 1384 | // Fixed overflow never scrolls, so presets would push columns off screen. | ||
| 1376 | - | if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed { | 1385 | + | if w.policy().wraps { |
| 1386 | + | return w.cellWidth() | |||
| 1387 | + | } | |||
| 1388 | + | if w.Columns[i].Width == (Width{}) || w.policy().equalCells { | |||
| 1377 | if len(w.Columns) == 1 { | 1389 | if len(w.Columns) == 1 { | ||
| 1378 | return max(w.Usable.W-2*g, 0) | 1390 | return max(w.Usable.W-2*g, 0) | ||
| 1379 | } | 1391 | } | ||
| @@ -1391,27 +1403,31 @@ func (w *Workspace) columnX(i int) int { | |||||
| 1391 | return x | 1403 | return x | ||
| 1392 | } | 1404 | } | ||
| 1393 | func (w *Workspace) scroll() { | 1405 | func (w *Workspace) scroll() { | ||
| 1394 | - | before := w.ViewX | 1406 | + | before := w.View |
| 1395 | defer func() { w.retarget(before) }() | 1407 | defer func() { w.retarget(before) }() | ||
| 1408 | + | if w.policy().wraps { | |||
| 1409 | + | w.View = w.band(w.Focus) * w.Usable.H | |||
| 1410 | + | return | |||
| 1411 | + | } | |||
| 1396 | if w.Overflow == OverflowFixed { | 1412 | if w.Overflow == OverflowFixed { | ||
| 1397 | - | w.ViewX = 0 | 1413 | + | w.View = 0 |
| 1398 | return | 1414 | return | ||
| 1399 | } | 1415 | } | ||
| 1400 | if len(w.Columns) == 0 { | 1416 | if len(w.Columns) == 0 { | ||
| 1401 | return | 1417 | return | ||
| 1402 | } | 1418 | } | ||
| 1403 | - | left := w.columnX(w.Focus) - w.ViewX | 1419 | + | left := w.columnX(w.Focus) - w.View |
| 1404 | width := w.columnWidth(w.Focus) | 1420 | width := w.columnWidth(w.Focus) | ||
| 1405 | if w.fullscreenColumn(w.Focus) { | 1421 | if w.fullscreenColumn(w.Focus) { | ||
| 1406 | - | w.ViewX += left - w.Output.X | 1422 | + | w.View += left - w.Output.X |
| 1407 | return | 1423 | return | ||
| 1408 | } | 1424 | } | ||
| 1409 | minX := w.Usable.X + w.gap() | 1425 | minX := w.Usable.X + w.gap() | ||
| 1410 | maxX := w.Usable.X + w.Usable.W - w.gap() | 1426 | maxX := w.Usable.X + w.Usable.W - w.gap() | ||
| 1411 | if width > maxX-minX || left < minX { | 1427 | if width > maxX-minX || left < minX { | ||
| 1412 | - | w.ViewX += left - minX | 1428 | + | w.View += left - minX |
| 1413 | } else if left+width > maxX { | 1429 | } else if left+width > maxX { | ||
| 1414 | - | w.ViewX += left + width - maxX | 1430 | + | w.View += left + width - maxX |
| 1415 | } | 1431 | } | ||
| 1416 | } | 1432 | } | ||
| 1417 | 1433 | ||||
| @@ -1430,10 +1446,13 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect { | |||||
| 1430 | // when too small): the result aliases dst, so a caller passing a reused | 1446 | // when too small): the result aliases dst, so a caller passing a reused | ||
| 1431 | // buffer must be done with the previous result. | 1447 | // buffer must be done with the previous result. | ||
| 1432 | func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | 1448 | func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | ||
| 1449 | + | if w.policy().wraps { | |||
| 1450 | + | return w.cascadeRectsInto(dst, ignoreFullWidth) | |||
| 1451 | + | } | |||
| 1433 | g := w.gap() | 1452 | g := w.gap() | ||
| 1434 | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | 1453 | 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)] | 1454 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | ||
| 1436 | - | view := w.ViewX + w.shiftPixels() | 1455 | + | view := w.View + w.shiftPixels() |
| 1437 | x := w.Usable.X + g | 1456 | x := w.Usable.X + g | ||
| 1438 | for i := range w.Columns { | 1457 | for i := range w.Columns { | ||
| 1439 | width := w.columnWidthFor(i, ignoreFullWidth) | 1458 | width := w.columnWidthFor(i, ignoreFullWidth) | ||
| @@ -1618,12 +1637,12 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement { | |||||
| 1618 | full := w.fullscreen == id && id != 0 | 1637 | full := w.fullscreen == id && id != 0 | ||
| 1619 | // Fixed overflow cannot scroll to other columns while one fills | 1638 | // Fixed overflow cannot scroll to other columns while one fills | ||
| 1620 | // the view. Keep them in place, but out of the scene. | 1639 | // the view. Keep them in place, but out of the scene. | ||
| 1621 | - | maximized := w.Overflow == OverflowFixed && w.Columns[w.Focus].FullWidth && i != w.Focus | 1640 | + | maximized := w.policy().equalCells && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus) |
| 1622 | - | hidden := (cover != 0 && id != cover) || ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized | 1641 | + | hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized |
| 1623 | if full { | 1642 | if full { | ||
| 1624 | // Scroll mode aligns the view on the column; fixed never scrolls. | 1643 | // Scroll mode aligns the view on the column; fixed never scrolls. | ||
| 1625 | r = w.Output | 1644 | r = w.Output | ||
| 1626 | - | if w.Overflow != OverflowFixed { | 1645 | + | if w.Overflow == OverflowScroll || w.Overflow == "" { |
| 1627 | r.X = col.X | 1646 | r.X = col.X | ||
| 1628 | } | 1647 | } | ||
| 1629 | } | 1648 | } | ||
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, wraps 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, wraps: true} | |
| 35 | + | } | |
| 36 | + | if w.Overflow == OverflowFixed { | |
| 37 | + | return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: 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, wraps 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, wraps: true} | |||
| 35 | + | } | |||
| 36 | + | if w.Overflow == OverflowFixed { | |||
| 37 | + | return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true} | |||
| 38 | + | } | |||
| 39 | + | return layoutPolicy{content: horizontalAxis, workspace: verticalAxis} | |||
| 40 | + | } | |||
M internal/core/layout_test.go +12 -12
| @@ -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) { | |
| @@ -352,18 +352,18 @@ func TestLargeWidthAndBounds(t *testing.T) { | |||
| 352 | 352 | t.Fatal(w.Output) | |
| 353 | 353 | } | |
| 354 | 354 | } | |
| 355 | - | func TestUnchangedUsablePreservesViewX(t *testing.T) { | |
| 355 | + | func TestUnchangedUsablePreservesView(t *testing.T) { | |
| 356 | 356 | w := workspace() | |
| 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("View 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) { | ||
| @@ -352,18 +352,18 @@ func TestLargeWidthAndBounds(t *testing.T) { | |||||
| 352 | t.Fatal(w.Output) | 352 | t.Fatal(w.Output) | ||
| 353 | } | 353 | } | ||
| 354 | } | 354 | } | ||
| 355 | - | func TestUnchangedUsablePreservesViewX(t *testing.T) { | 355 | + | func TestUnchangedUsablePreservesView(t *testing.T) { |
| 356 | w := workspace() | 356 | w := workspace() | ||
| 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("View 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 +6 -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. | |
| @@ -508,10 +510,14 @@ func (m *Monitor) applyNamed() { | |||
| 508 | 510 | } | |
| 509 | 511 | m.each(func(w *Workspace) { | |
| 510 | 512 | s := specs[w.Name] | |
| 513 | + | before := w.Overflow | |
| 511 | 514 | w.Overflow = m.template.Overflow | |
| 512 | 515 | if s.Overflow != "" { | |
| 513 | 516 | w.Overflow = s.Overflow | |
| 514 | 517 | } | |
| 518 | + | if before != w.Overflow && w.policy().equalCells { | |
| 519 | + | w.unmaximize() | |
| 520 | + | } | |
| 515 | 521 | w.SetMaxColumns(m.template.MaxColumns) | |
| 516 | 522 | if s.MaxColumns > 0 { | |
| 517 | 523 | w.SetMaxColumns(s.MaxColumns) | |
| @@ -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. | ||
| @@ -508,10 +510,14 @@ func (m *Monitor) applyNamed() { | |||||
| 508 | } | 510 | } | ||
| 509 | m.each(func(w *Workspace) { | 511 | m.each(func(w *Workspace) { | ||
| 510 | s := specs[w.Name] | 512 | s := specs[w.Name] | ||
| 513 | + | before := w.Overflow | |||
| 511 | w.Overflow = m.template.Overflow | 514 | w.Overflow = m.template.Overflow | ||
| 512 | if s.Overflow != "" { | 515 | if s.Overflow != "" { | ||
| 513 | w.Overflow = s.Overflow | 516 | w.Overflow = s.Overflow | ||
| 514 | } | 517 | } | ||
| 518 | + | if before != w.Overflow && w.policy().equalCells { | |||
| 519 | + | w.unmaximize() | |||
| 520 | + | } | |||
| 515 | w.SetMaxColumns(m.template.MaxColumns) | 521 | w.SetMaxColumns(m.template.MaxColumns) | ||
| 516 | if s.MaxColumns > 0 { | 522 | if s.MaxColumns > 0 { | ||
| 517 | w.SetMaxColumns(s.MaxColumns) | 523 | w.SetMaxColumns(s.MaxColumns) | ||
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 -2
| @@ -293,7 +293,7 @@ func restoreRow(w *Workspace, s rowSnapshot) { | |||
| 293 | 293 | // Fixed overflow maximizes at most one column: only the one holding | |
| 294 | 294 | // the anchor window, wherever active binds moved it. Scroll overflow | |
| 295 | 295 | // keeps each column's own width. | |
| 296 | - | if w.Overflow == OverflowFixed { | |
| 296 | + | if w.policy().equalCells { | |
| 297 | 297 | w.unmaximize() | |
| 298 | 298 | for i := range w.Columns { | |
| 299 | 299 | if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) { | |
| @@ -748,7 +748,11 @@ 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.policy().wraps { | |
| 752 | + | width := w.cellWidth() | |
| 753 | + | r.X = w.band(i)*w.Usable.W + w.cellX(i) | |
| 754 | + | r.W = width | |
| 755 | + | } else if w.Overflow == OverflowFixed { | |
| 752 | 756 | r = rects[i] | |
| 753 | 757 | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | |
| 754 | 758 | } else if w.fullscreenColumn(i) { | |
| @@ -293,7 +293,7 @@ func restoreRow(w *Workspace, s rowSnapshot) { | |||||
| 293 | // Fixed overflow maximizes at most one column: only the one holding | 293 | // Fixed overflow maximizes at most one column: only the one holding | ||
| 294 | // the anchor window, wherever active binds moved it. Scroll overflow | 294 | // the anchor window, wherever active binds moved it. Scroll overflow | ||
| 295 | // keeps each column's own width. | 295 | // keeps each column's own width. | ||
| 296 | - | if w.Overflow == OverflowFixed { | 296 | + | if w.policy().equalCells { |
| 297 | w.unmaximize() | 297 | w.unmaximize() | ||
| 298 | for i := range w.Columns { | 298 | for i := range w.Columns { | ||
| 299 | if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) { | 299 | if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) { | ||
| @@ -748,7 +748,11 @@ 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.policy().wraps { |
| 752 | + | width := w.cellWidth() | |||
| 753 | + | r.X = w.band(i)*w.Usable.W + w.cellX(i) | |||
| 754 | + | r.W = width | |||
| 755 | + | } else if w.Overflow == OverflowFixed { | |||
| 752 | r = rects[i] | 756 | r = rects[i] | ||
| 753 | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | 757 | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | ||
| 754 | } else if w.fullscreenColumn(i) { | 758 | } 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/overview_stack.go +1 -1
| @@ -180,7 +180,7 @@ func (m *Monitor) hiddenColumn(w *Workspace) int { | |||
| 180 | 180 | // selectOverviewColumn retains the existing ordinary-row live focus semantics. | |
| 181 | 181 | func (w *Workspace) selectOverviewColumn(i int) { | |
| 182 | 182 | w.floatFocus, w.stashFocus = false, false | |
| 183 | - | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) { | |
| 183 | + | if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) { | |
| 184 | 184 | w.unmaximize() | |
| 185 | 185 | } | |
| 186 | 186 | w.Focus = i | |
| @@ -180,7 +180,7 @@ func (m *Monitor) hiddenColumn(w *Workspace) int { | |||||
| 180 | // selectOverviewColumn retains the existing ordinary-row live focus semantics. | 180 | // selectOverviewColumn retains the existing ordinary-row live focus semantics. | ||
| 181 | func (w *Workspace) selectOverviewColumn(i int) { | 181 | func (w *Workspace) selectOverviewColumn(i int) { | ||
| 182 | w.floatFocus, w.stashFocus = false, false | 182 | w.floatFocus, w.stashFocus = false, false | ||
| 183 | - | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) { | 183 | + | if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) { |
| 184 | w.unmaximize() | 184 | w.unmaximize() | ||
| 185 | } | 185 | } | ||
| 186 | w.Focus = i | 186 | w.Focus = i | ||
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 +15 -9
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | // Slides move what is on screen smoothly: a workspace's columns (view), its | |
| 10 | 10 | // stash (stashView) and the monitor between workspaces (switchView). All | |
| 11 | - | // are pure presentation over the settled state (ViewX, Focus, stashAt, | |
| 11 | + | // are pure presentation over the settled state (View, Focus, stashAt, | |
| 12 | 12 | // Active): the layout ends where the state says as soon as a slide stops. | |
| 13 | 13 | ||
| 14 | 14 | // slide is a view offset that a swipe sets while the fingers follow it, and | |
| @@ -43,14 +43,14 @@ func (w *Workspace) slidable() bool { | |||
| 43 | 43 | return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat() | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | - | // retarget keeps the view where it is on screen when ViewX moved from | |
| 46 | + | // retarget keeps the view where it is on screen when View moved from | |
| 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.policy().wraps { | |
| 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.policy().wraps { | |
| 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 | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | // Slides move what is on screen smoothly: a workspace's columns (view), its | 9 | // Slides move what is on screen smoothly: a workspace's columns (view), its | ||
| 10 | // stash (stashView) and the monitor between workspaces (switchView). All | 10 | // stash (stashView) and the monitor between workspaces (switchView). All | ||
| 11 | - | // are pure presentation over the settled state (ViewX, Focus, stashAt, | 11 | + | // are pure presentation over the settled state (View, Focus, stashAt, |
| 12 | // Active): the layout ends where the state says as soon as a slide stops. | 12 | // Active): the layout ends where the state says as soon as a slide stops. | ||
| 13 | 13 | ||||
| 14 | // slide is a view offset that a swipe sets while the fingers follow it, and | 14 | // slide is a view offset that a swipe sets while the fingers follow it, and | ||
| @@ -43,14 +43,14 @@ func (w *Workspace) slidable() bool { | |||||
| 43 | return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat() | 43 | return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat() | ||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | - | // retarget keeps the view where it is on screen when ViewX moved from | 46 | + | // retarget keeps the view where it is on screen when View moved from |
| 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.policy().wraps { | |||
| 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.policy().wraps { | |||
| 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.go +3 -0
| @@ -132,6 +132,9 @@ func (w *Workspace) restore(id WindowID, back *origPlace) { | |||
| 132 | 132 | } | |
| 133 | 133 | c.Windows = slices.Insert(c.Windows, row, id) | |
| 134 | 134 | c.Focus = row | |
| 135 | + | if w.policy().equalCells && w.Focus != i { | |
| 136 | + | w.unmaximize() | |
| 137 | + | } | |
| 135 | 138 | w.Focus = i | |
| 136 | 139 | w.scroll() | |
| 137 | 140 | return | |
| @@ -132,6 +132,9 @@ func (w *Workspace) restore(id WindowID, back *origPlace) { | |||||
| 132 | } | 132 | } | ||
| 133 | c.Windows = slices.Insert(c.Windows, row, id) | 133 | c.Windows = slices.Insert(c.Windows, row, id) | ||
| 134 | c.Focus = row | 134 | c.Focus = row | ||
| 135 | + | if w.policy().equalCells && w.Focus != i { | |||
| 136 | + | w.unmaximize() | |||
| 137 | + | } | |||
| 135 | w.Focus = i | 138 | w.Focus = i | ||
| 136 | w.scroll() | 139 | w.scroll() | ||
| 137 | return | 140 | return | ||
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 +13 -7
| @@ -28,15 +28,16 @@ type viewShot struct { | |||
| 28 | 28 | // overview is set when the screen showed its overview: ws is then the | |
| 29 | 29 | // row on screen, the camera fields stay zero, and rects are the cards. | |
| 30 | 30 | overview bool | |
| 31 | - | // viewX is ws's settled view and view where its columns were on screen | |
| 32 | - | // (viewX plus shift). | |
| 33 | - | viewX int | |
| 34 | - | view float64 | |
| 31 | + | // settledView is ws's settled view and view where its columns were on screen | |
| 32 | + | // (settledView plus shift). | |
| 33 | + | settledView int | |
| 34 | + | view float64 | |
| 35 | 35 | // viewV and switchV are the speeds the running view and workspace | |
| 36 | 36 | // springs have at the snapshot's time (units per second, 0 without | |
| 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.settledView, 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.settledView || !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 | |
| @@ -28,15 +28,16 @@ type viewShot struct { | |||||
| 28 | // overview is set when the screen showed its overview: ws is then the | 28 | // overview is set when the screen showed its overview: ws is then the | ||
| 29 | // row on screen, the camera fields stay zero, and rects are the cards. | 29 | // row on screen, the camera fields stay zero, and rects are the cards. | ||
| 30 | overview bool | 30 | overview bool | ||
| 31 | - | // viewX is ws's settled view and view where its columns were on screen | 31 | + | // settledView is ws's settled view and view where its columns were on screen |
| 32 | - | // (viewX plus shift). | 32 | + | // (settledView plus shift). |
| 33 | - | viewX int | 33 | + | settledView int |
| 34 | - | view float64 | 34 | + | view float64 |
| 35 | // viewV and switchV are the speeds the running view and workspace | 35 | // viewV and switchV are the speeds the running view and workspace | ||
| 36 | // springs have at the snapshot's time (units per second, 0 without | 36 | // springs have at the snapshot's time (units per second, 0 without | ||
| 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.settledView, 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.settledView || !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 | ||||