Merge pull request 'feat(overview): show cascade columns hidden by a maximized column as cards' (#14) from feat/overview-cascade-hidden-card into main
Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/14
12 files · +587 -79
M docs/config.md +4 -4
| @@ -47,7 +47,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 47 | 47 | | `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse | | |
| 48 | 48 | | `animations.speed` | `normal` | Speed of every spring (not the focus pulse): `slow`, `normal` or `fast` (a view move lands in about 260, 180 or 130 ms), or a number relative to `normal`, from `0.1` (10× slower) to `10` (10× faster) | | |
| 49 | 49 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | |
| 50 | - | | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` | | |
| 50 | + | | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone outside panels (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` | | |
| 51 | 51 | | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs | | |
| 52 | 52 | | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` | | |
| 53 | 53 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back | | |
| @@ -190,15 +190,15 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 190 | 190 | ||
| 191 | 191 | ### Cascade | |
| 192 | 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. | |
| 193 | + | Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its width equally, as in `fixed` overflow: a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations. | |
| 194 | 194 | ||
| 195 | 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 | 196 | ||
| 197 | 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-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour. | |
| 198 | 198 | ||
| 199 | - | In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. | |
| 199 | + | In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. A maximized column shows the columns it hides as dimmed cards behind it, peeking out on their side of the band: the nearest one on each side, then the next ones stacked at the band's edge. Selecting a card (left/right, a three-finger swipe or a click on a visible card) moves the maximization to it; Return keeps it there and Escape gives it back. | |
| 200 | 200 | ||
| 201 | - | In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus crosses into the next band instead). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band. | |
| 201 | + | In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus crosses into the next band instead). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. Over a maximized column that hides cards, horizontal moves one step per gesture like left/right: the next card, then past the band's edge the stash or a workspace. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band. | |
| 202 | 202 | ||
| 203 | 203 | Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. | |
| 204 | 204 | ||
| @@ -47,7 +47,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 47 | | `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse | | 47 | | `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse | | ||
| 48 | | `animations.speed` | `normal` | Speed of every spring (not the focus pulse): `slow`, `normal` or `fast` (a view move lands in about 260, 180 or 130 ms), or a number relative to `normal`, from `0.1` (10× slower) to `10` (10× faster) | | 48 | | `animations.speed` | `normal` | Speed of every spring (not the focus pulse): `slow`, `normal` or `fast` (a view move lands in about 260, 180 or 130 ms), or a number relative to `normal`, from `0.1` (10× slower) to `10` (10× faster) | | ||
| 49 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | 49 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | ||
| 50 | - | | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` | | 50 | + | | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone outside panels (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` | |
| 51 | | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs | | 51 | | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs | | ||
| 52 | | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` | | 52 | | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` | | ||
| 53 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back | | 53 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back | | ||
| @@ -190,15 +190,15 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 190 | 190 | ||||
| 191 | ### Cascade | 191 | ### Cascade | ||
| 192 | 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. | 193 | + | Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its width equally, as in `fixed` overflow: a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations. |
| 194 | 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. | 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 | 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-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour. | 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-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour. | ||
| 198 | 198 | ||||
| 199 | - | In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. | 199 | + | In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. A maximized column shows the columns it hides as dimmed cards behind it, peeking out on their side of the band: the nearest one on each side, then the next ones stacked at the band's edge. Selecting a card (left/right, a three-finger swipe or a click on a visible card) moves the maximization to it; Return keeps it there and Escape gives it back. |
| 200 | 200 | ||||
| 201 | - | In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus crosses into the next band instead). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band. | 201 | + | In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus crosses into the next band instead). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. Over a maximized column that hides cards, horizontal moves one step per gesture like left/right: the next card, then past the band's edge the stash or a workspace. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band. |
| 202 | 202 | ||||
| 203 | Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. | 203 | Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. | ||
| 204 | 204 | ||||
M internal/core/cascade.go +15 -5
| @@ -5,7 +5,7 @@ import "slices" | |||
| 5 | 5 | func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) } | |
| 6 | 6 | func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) } | |
| 7 | 7 | func (w *Workspace) cellX(i int) int { | |
| 8 | - | return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth()+w.gap()) | |
| 8 | + | return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth(i)+w.gap()) | |
| 9 | 9 | } | |
| 10 | 10 | func (w *Workspace) cascadeBands() (n, spacing int) { | |
| 11 | 11 | if len(w.Columns) == 0 || w.Usable.H <= 0 { | |
| @@ -13,8 +13,19 @@ func (w *Workspace) cascadeBands() (n, spacing int) { | |||
| 13 | 13 | } | |
| 14 | 14 | return w.band(len(w.Columns)-1) + 1, w.Usable.H | |
| 15 | 15 | } | |
| 16 | - | func (w *Workspace) cellWidth() int { | |
| 17 | - | k := max(w.MaxColumns, 1) | |
| 16 | + | ||
| 17 | + | // cellWidth is the width of the cell of column i: the columns of its band | |
| 18 | + | // share the usable width equally, as in fixed overflow, so a lone column | |
| 19 | + | // fills it. | |
| 20 | + | func (w *Workspace) cellWidth(i int) int { | |
| 21 | + | first, last := w.laneBounds(w.band(i)) | |
| 22 | + | return w.equalShare(last - first + 1) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // equalShare is the width of each of k columns sharing the usable width | |
| 26 | + | // after gaps; a remainder under k pixels stays empty. | |
| 27 | + | func (w *Workspace) equalShare(k int) int { | |
| 28 | + | k = max(k, 1) | |
| 18 | 29 | return max((w.Usable.W-w.gap()*(k+1))/k, 0) | |
| 19 | 30 | } | |
| 20 | 31 | func (w *Workspace) cellInBand(b int) int { | |
| @@ -38,11 +49,10 @@ func (w *Workspace) focusBand(dir int) bool { | |||
| 38 | 49 | } | |
| 39 | 50 | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |
| 40 | 51 | g := w.gap() | |
| 41 | - | width := w.cellWidth() | |
| 42 | 52 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 43 | 53 | view := w.View + w.shiftPixels() | |
| 44 | 54 | for i := range w.Columns { | |
| 45 | - | 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 | + | r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)} | |
| 46 | 56 | if w.Columns[i].FullWidth && !ignoreFullWidth { | |
| 47 | 57 | r.X = w.Usable.X + g | |
| 48 | 58 | r.W = max(w.Usable.W-2*g, 0) | |
| @@ -5,7 +5,7 @@ import "slices" | |||||
| 5 | func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) } | 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) } | 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 { | 7 | func (w *Workspace) cellX(i int) int { | ||
| 8 | - | return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth()+w.gap()) | 8 | + | return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth(i)+w.gap()) |
| 9 | } | 9 | } | ||
| 10 | func (w *Workspace) cascadeBands() (n, spacing int) { | 10 | func (w *Workspace) cascadeBands() (n, spacing int) { | ||
| 11 | if len(w.Columns) == 0 || w.Usable.H <= 0 { | 11 | if len(w.Columns) == 0 || w.Usable.H <= 0 { | ||
| @@ -13,8 +13,19 @@ func (w *Workspace) cascadeBands() (n, spacing int) { | |||||
| 13 | } | 13 | } | ||
| 14 | return w.band(len(w.Columns)-1) + 1, w.Usable.H | 14 | return w.band(len(w.Columns)-1) + 1, w.Usable.H | ||
| 15 | } | 15 | } | ||
| 16 | - | func (w *Workspace) cellWidth() int { | 16 | + | |
| 17 | - | k := max(w.MaxColumns, 1) | 17 | + | // cellWidth is the width of the cell of column i: the columns of its band |
| 18 | + | // share the usable width equally, as in fixed overflow, so a lone column | |||
| 19 | + | // fills it. | |||
| 20 | + | func (w *Workspace) cellWidth(i int) int { | |||
| 21 | + | first, last := w.laneBounds(w.band(i)) | |||
| 22 | + | return w.equalShare(last - first + 1) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // equalShare is the width of each of k columns sharing the usable width | |||
| 26 | + | // after gaps; a remainder under k pixels stays empty. | |||
| 27 | + | func (w *Workspace) equalShare(k int) int { | |||
| 28 | + | k = max(k, 1) | |||
| 18 | return max((w.Usable.W-w.gap()*(k+1))/k, 0) | 29 | return max((w.Usable.W-w.gap()*(k+1))/k, 0) | ||
| 19 | } | 30 | } | ||
| 20 | func (w *Workspace) cellInBand(b int) int { | 31 | func (w *Workspace) cellInBand(b int) int { | ||
| @@ -38,11 +49,10 @@ func (w *Workspace) focusBand(dir int) bool { | |||||
| 38 | } | 49 | } | ||
| 39 | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | 50 | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | ||
| 40 | g := w.gap() | 51 | g := w.gap() | ||
| 41 | - | width := w.cellWidth() | |||
| 42 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | 52 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | ||
| 43 | view := w.View + w.shiftPixels() | 53 | view := w.View + w.shiftPixels() | ||
| 44 | for i := range w.Columns { | 54 | for i := range w.Columns { | ||
| 45 | - | 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 | + | r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)} |
| 46 | if w.Columns[i].FullWidth && !ignoreFullWidth { | 56 | if w.Columns[i].FullWidth && !ignoreFullWidth { | ||
| 47 | r.X = w.Usable.X + g | 57 | r.X = w.Usable.X + g | ||
| 48 | r.W = max(w.Usable.W-2*g, 0) | 58 | r.W = max(w.Usable.W-2*g, 0) | ||
M internal/core/cascade_test.go +80 -2
| @@ -3,6 +3,8 @@ package core | |||
| 3 | 3 | import ( | |
| 4 | 4 | "slices" | |
| 5 | 5 | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | func cascadeWorkspace(n int) *Workspace { | |
| @@ -14,15 +16,91 @@ func cascadeWorkspace(n int) *Workspace { | |||
| 14 | 16 | return w | |
| 15 | 17 | } | |
| 16 | 18 | ||
| 19 | + | // With gaps and a width that does not divide evenly, the columns of a | |
| 20 | + | // partial band share what the gaps leave, and the configured width follows | |
| 21 | + | // the band as it fills. | |
| 22 | + | func TestCascadeBandWidthWithGaps(t *testing.T) { | |
| 23 | + | w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3} | |
| 24 | + | w.SetOutput(1000, 600) | |
| 25 | + | w.SetGaps(8) | |
| 26 | + | w.AddWindow(1) | |
| 27 | + | if got := w.columnWidth(0); got != 1000-2*8 { | |
| 28 | + | t.Fatalf("lone column width %d, want %d", got, 1000-2*8) | |
| 29 | + | } | |
| 30 | + | w.AddWindow(2) | |
| 31 | + | cell := (1000 - 3*8) / 2 | |
| 32 | + | for i, x := range []int{8, 8 + cell + 8} { | |
| 33 | + | r := w.columnRectsFor(false)[i] | |
| 34 | + | if r.X != x || r.W != cell || w.columnWidth(i) != cell { | |
| 35 | + | t.Fatalf("column %d rect %v width %d, want X %d W %d", i, r, w.columnWidth(i), x, cell) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // A partial last band after a full one shares the band width with gaps: | |
| 41 | + | // each band divides by its own column count. | |
| 42 | + | func TestCascadePartialLastBandWithGaps(t *testing.T) { | |
| 43 | + | w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3} | |
| 44 | + | w.SetOutput(1000, 600) | |
| 45 | + | w.SetGaps(8) | |
| 46 | + | for id := WindowID(1); id <= 5; id++ { | |
| 47 | + | w.AddWindow(id) | |
| 48 | + | } | |
| 49 | + | third, half := (1000-4*8)/3, (1000-3*8)/2 | |
| 50 | + | rects := w.columnRectsFor(false) | |
| 51 | + | for i, want := range []struct{ x, w int }{ | |
| 52 | + | {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), third}, | |
| 53 | + | {8, half}, {8 + half + 8, half}, | |
| 54 | + | } { | |
| 55 | + | if r := rects[i]; r.X != want.x || r.W != want.w { | |
| 56 | + | t.Fatalf("column %d rect %v, want X %d W %d", i, r, want.x, want.w) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | if rects[3].Y-rects[0].Y != 600 { | |
| 60 | + | t.Fatalf("band spacing %d, want 600", rects[3].Y-rects[0].Y) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // A side panel moves and narrows every band: cells start at the usable | |
| 65 | + | // area's X and share its width. | |
| 66 | + | func TestCascadeSidePanel(t *testing.T) { | |
| 67 | + | w := &Workspace{Overflow: OverflowCascade, MaxColumns: 2} | |
| 68 | + | w.SetOutput(1000, 600) | |
| 69 | + | w.SetUsable(Rect{X: 100, W: 900, H: 600}) | |
| 70 | + | for id := WindowID(1); id <= 3; id++ { | |
| 71 | + | w.AddWindow(id) | |
| 72 | + | } | |
| 73 | + | rects := w.columnRectsFor(false) | |
| 74 | + | for i, want := range []Rect{ | |
| 75 | + | {X: 100, Y: 0, W: 450, H: 600}, | |
| 76 | + | {X: 550, Y: 0, W: 450, H: 600}, | |
| 77 | + | {X: 100, Y: 600, W: 900, H: 600}, | |
| 78 | + | } { | |
| 79 | + | if rects[i].X != want.X || rects[i].W != want.W || rects[i].Y-rects[0].Y != want.Y { | |
| 80 | + | t.Fatalf("column %d rect %v, want %v", i, rects[i], want) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | // The panel edge at x=100 is no shared line: column 1 has only its | |
| 84 | + | // right neighbor. | |
| 85 | + | w.FocusID(1) | |
| 86 | + | if p := previewOf(t, w.Layout(), 1); p.Neighbors != ports.SideRight { | |
| 87 | + | t.Fatalf("neighbors %04b, want right only", p.Neighbors) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Each band's columns share its width equally, as in fixed overflow: a | |
| 92 | + | // lone column fills it, a full band of three has thirds. | |
| 17 | 93 | func TestCascadeBandsAndReveal(t *testing.T) { | |
| 18 | - | for _, n := range []int{0, 1, 3, 4, 7} { | |
| 94 | + | for _, n := range []int{0, 1, 2, 3, 4, 5, 7} { | |
| 19 | 95 | w := cascadeWorkspace(n) | |
| 20 | 96 | ps := w.Layout() | |
| 21 | 97 | if len(ps) != n { | |
| 22 | 98 | t.Fatalf("n=%d placements=%d", n, len(ps)) | |
| 23 | 99 | } | |
| 24 | 100 | for i, p := range ps { | |
| 25 | - | want := Rect{X: i % 3 * 300, Y: (i/3)*600 - w.View, W: 300, H: 600} | |
| 101 | + | inBand := min(n-i/3*3, 3) | |
| 102 | + | cell := 900 / inBand | |
| 103 | + | want := Rect{X: i % 3 * cell, Y: (i/3)*600 - w.View, W: cell, H: 600} | |
| 26 | 104 | if p.Rect != want { | |
| 27 | 105 | t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want) | |
| 28 | 106 | } | |
| @@ -3,6 +3,8 @@ package core | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "slices" | 4 | "slices" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 8 | func cascadeWorkspace(n int) *Workspace { | 10 | func cascadeWorkspace(n int) *Workspace { | ||
| @@ -14,15 +16,91 @@ func cascadeWorkspace(n int) *Workspace { | |||||
| 14 | return w | 16 | return w | ||
| 15 | } | 17 | } | ||
| 16 | 18 | ||||
| 19 | + | // With gaps and a width that does not divide evenly, the columns of a | |||
| 20 | + | // partial band share what the gaps leave, and the configured width follows | |||
| 21 | + | // the band as it fills. | |||
| 22 | + | func TestCascadeBandWidthWithGaps(t *testing.T) { | |||
| 23 | + | w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3} | |||
| 24 | + | w.SetOutput(1000, 600) | |||
| 25 | + | w.SetGaps(8) | |||
| 26 | + | w.AddWindow(1) | |||
| 27 | + | if got := w.columnWidth(0); got != 1000-2*8 { | |||
| 28 | + | t.Fatalf("lone column width %d, want %d", got, 1000-2*8) | |||
| 29 | + | } | |||
| 30 | + | w.AddWindow(2) | |||
| 31 | + | cell := (1000 - 3*8) / 2 | |||
| 32 | + | for i, x := range []int{8, 8 + cell + 8} { | |||
| 33 | + | r := w.columnRectsFor(false)[i] | |||
| 34 | + | if r.X != x || r.W != cell || w.columnWidth(i) != cell { | |||
| 35 | + | t.Fatalf("column %d rect %v width %d, want X %d W %d", i, r, w.columnWidth(i), x, cell) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // A partial last band after a full one shares the band width with gaps: | |||
| 41 | + | // each band divides by its own column count. | |||
| 42 | + | func TestCascadePartialLastBandWithGaps(t *testing.T) { | |||
| 43 | + | w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3} | |||
| 44 | + | w.SetOutput(1000, 600) | |||
| 45 | + | w.SetGaps(8) | |||
| 46 | + | for id := WindowID(1); id <= 5; id++ { | |||
| 47 | + | w.AddWindow(id) | |||
| 48 | + | } | |||
| 49 | + | third, half := (1000-4*8)/3, (1000-3*8)/2 | |||
| 50 | + | rects := w.columnRectsFor(false) | |||
| 51 | + | for i, want := range []struct{ x, w int }{ | |||
| 52 | + | {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), third}, | |||
| 53 | + | {8, half}, {8 + half + 8, half}, | |||
| 54 | + | } { | |||
| 55 | + | if r := rects[i]; r.X != want.x || r.W != want.w { | |||
| 56 | + | t.Fatalf("column %d rect %v, want X %d W %d", i, r, want.x, want.w) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | if rects[3].Y-rects[0].Y != 600 { | |||
| 60 | + | t.Fatalf("band spacing %d, want 600", rects[3].Y-rects[0].Y) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // A side panel moves and narrows every band: cells start at the usable | |||
| 65 | + | // area's X and share its width. | |||
| 66 | + | func TestCascadeSidePanel(t *testing.T) { | |||
| 67 | + | w := &Workspace{Overflow: OverflowCascade, MaxColumns: 2} | |||
| 68 | + | w.SetOutput(1000, 600) | |||
| 69 | + | w.SetUsable(Rect{X: 100, W: 900, H: 600}) | |||
| 70 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 71 | + | w.AddWindow(id) | |||
| 72 | + | } | |||
| 73 | + | rects := w.columnRectsFor(false) | |||
| 74 | + | for i, want := range []Rect{ | |||
| 75 | + | {X: 100, Y: 0, W: 450, H: 600}, | |||
| 76 | + | {X: 550, Y: 0, W: 450, H: 600}, | |||
| 77 | + | {X: 100, Y: 600, W: 900, H: 600}, | |||
| 78 | + | } { | |||
| 79 | + | if rects[i].X != want.X || rects[i].W != want.W || rects[i].Y-rects[0].Y != want.Y { | |||
| 80 | + | t.Fatalf("column %d rect %v, want %v", i, rects[i], want) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | // The panel edge at x=100 is no shared line: column 1 has only its | |||
| 84 | + | // right neighbor. | |||
| 85 | + | w.FocusID(1) | |||
| 86 | + | if p := previewOf(t, w.Layout(), 1); p.Neighbors != ports.SideRight { | |||
| 87 | + | t.Fatalf("neighbors %04b, want right only", p.Neighbors) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Each band's columns share its width equally, as in fixed overflow: a | |||
| 92 | + | // lone column fills it, a full band of three has thirds. | |||
| 17 | func TestCascadeBandsAndReveal(t *testing.T) { | 93 | func TestCascadeBandsAndReveal(t *testing.T) { | ||
| 18 | - | for _, n := range []int{0, 1, 3, 4, 7} { | 94 | + | for _, n := range []int{0, 1, 2, 3, 4, 5, 7} { |
| 19 | w := cascadeWorkspace(n) | 95 | w := cascadeWorkspace(n) | ||
| 20 | ps := w.Layout() | 96 | ps := w.Layout() | ||
| 21 | if len(ps) != n { | 97 | if len(ps) != n { | ||
| 22 | t.Fatalf("n=%d placements=%d", n, len(ps)) | 98 | t.Fatalf("n=%d placements=%d", n, len(ps)) | ||
| 23 | } | 99 | } | ||
| 24 | for i, p := range ps { | 100 | for i, p := range ps { | ||
| 25 | - | want := Rect{X: i % 3 * 300, Y: (i/3)*600 - w.View, W: 300, H: 600} | 101 | + | inBand := min(n-i/3*3, 3) |
| 102 | + | cell := 900 / inBand | |||
| 103 | + | want := Rect{X: i % 3 * cell, Y: (i/3)*600 - w.View, W: cell, H: 600} | |||
| 26 | if p.Rect != want { | 104 | if p.Rect != want { | ||
| 27 | t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want) | 105 | t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want) | ||
| 28 | } | 106 | } | ||
M internal/core/core.go +2 -1
| @@ -632,7 +632,8 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 632 | 632 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen) | |
| 633 | 633 | } | |
| 634 | 634 | // A window alone on screen needs no pulse to show it has the focus. | |
| 635 | - | alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1 | |
| 635 | + | // A tile under a panel (the next cascade band) does not count. | |
| 636 | + | alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, sc.mon.Current().Usable) == 1 | |
| 636 | 637 | for k, p := range layout { | |
| 637 | 638 | ps := settled[k] | |
| 638 | 639 | // Only the focused output has an activated window. | |
| @@ -632,7 +632,8 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||||
| 632 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen) | 632 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen) | ||
| 633 | } | 633 | } | ||
| 634 | // A window alone on screen needs no pulse to show it has the focus. | 634 | // A window alone on screen needs no pulse to show it has the focus. | ||
| 635 | - | alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1 | 635 | + | // A tile under a panel (the next cascade band) does not count. |
| 636 | + | alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, sc.mon.Current().Usable) == 1 | |||
| 636 | for k, p := range layout { | 637 | for k, p := range layout { | ||
| 637 | ps := settled[k] | 638 | ps := settled[k] | ||
| 638 | // Only the focused output has an activated window. | 639 | // Only the focused output has an activated window. | ||
M internal/core/layout.go +24 -21
| @@ -122,7 +122,7 @@ type Placement struct { | |||
| 122 | 122 | // Preview is the scale of an overview preview: its buffer is drawn | |
| 123 | 123 | // that much smaller in Rect, the client keeps its size. 0 otherwise. | |
| 124 | 124 | Preview float64 | |
| 125 | - | // Neighbors are the sides touching a visible tile on this output. | |
| 125 | + | // Neighbors are the sides touching a tile visible in the usable area. | |
| 126 | 126 | // Inset reserves room for drawn lines: all sides of a float; for | |
| 127 | 127 | // tiles without gaps, only the right and bottom shared sides. | |
| 128 | 128 | Neighbors, Inset ports.Sides | |
| @@ -1052,6 +1052,13 @@ func (w *Workspace) maximize(i int) { | |||
| 1052 | 1052 | } | |
| 1053 | 1053 | } | |
| 1054 | 1054 | ||
| 1055 | + | // hiddenByMaximized reports whether the maximized focused column hides | |
| 1056 | + | // column i on screen: in equal-cell layouts it hides the other columns of | |
| 1057 | + | // its band (the whole row in fixed overflow). | |
| 1058 | + | func (w *Workspace) hiddenByMaximized(i int) bool { | |
| 1059 | + | return w.policy().equalCells && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus) | |
| 1060 | + | } | |
| 1061 | + | ||
| 1055 | 1062 | // unmaximize clears the focused column in scroll mode, or all maximized | |
| 1056 | 1063 | // columns in fixed mode (including after Escape changed focus). | |
| 1057 | 1064 | func (w *Workspace) unmaximize() { | |
| @@ -1383,15 +1390,10 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int { | |||
| 1383 | 1390 | g := w.gap() | |
| 1384 | 1391 | // Fixed overflow never scrolls, so presets would push columns off screen. | |
| 1385 | 1392 | if w.policy().wraps { | |
| 1386 | - | return w.cellWidth() | |
| 1393 | + | return w.cellWidth(i) | |
| 1387 | 1394 | } | |
| 1388 | 1395 | if w.Columns[i].Width == (Width{}) || w.policy().equalCells { | |
| 1389 | - | if len(w.Columns) == 1 { | |
| 1390 | - | return max(w.Usable.W-2*g, 0) | |
| 1391 | - | } | |
| 1392 | - | // Equal shares of the width left after gaps; a remainder under k pixels stays empty. | |
| 1393 | - | k := min(len(w.Columns), max(w.MaxColumns, 1)) | |
| 1394 | - | return max((w.Usable.W-g*(k+1))/k, 0) | |
| 1396 | + | return w.equalShare(min(len(w.Columns), max(w.MaxColumns, 1))) | |
| 1395 | 1397 | } | |
| 1396 | 1398 | return w.Columns[i].Width.Resolve(w.Usable.W, g) | |
| 1397 | 1399 | } | |
| @@ -1485,7 +1487,7 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||
| 1485 | 1487 | // right; the two strips share the last cell. | |
| 1486 | 1488 | func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect { | |
| 1487 | 1489 | g := w.gap() | |
| 1488 | - | cell := max((w.Usable.W-g*(k+1))/k, 0) | |
| 1490 | + | cell := w.equalShare(k) | |
| 1489 | 1491 | wide := (k-1)*cell + (k-2)*g | |
| 1490 | 1492 | rest := max(w.Usable.W-2*g-wide-g, 0) | |
| 1491 | 1493 | before, after := w.Columns[:e], w.Columns[e+1:] | |
| @@ -1637,8 +1639,7 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement { | |||
| 1637 | 1639 | full := w.fullscreen == id && id != 0 | |
| 1638 | 1640 | // Fixed overflow cannot scroll to other columns while one fills | |
| 1639 | 1641 | // the view. Keep them in place, but out of the scene. | |
| 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 | |
| 1642 | + | hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || w.hiddenByMaximized(i) | |
| 1642 | 1643 | if full { | |
| 1643 | 1644 | // Scroll mode aligns the view on the column; fixed never scrolls. | |
| 1644 | 1645 | r = w.Output | |
| @@ -1652,7 +1653,9 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement { | |||
| 1652 | 1653 | tiles = append(tiles, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden}) | |
| 1653 | 1654 | } | |
| 1654 | 1655 | } | |
| 1655 | - | setVisibleNeighbors(tiles, gap, w.Output) | |
| 1656 | + | // Lines are shared within the usable area: a tile under a panel (the | |
| 1657 | + | // next cascade band starts right below a bottom one) is no neighbor. | |
| 1658 | + | setVisibleNeighbors(tiles, gap, w.Usable) | |
| 1656 | 1659 | // tiles lives in the workspace's scratch until they are copied below. | |
| 1657 | 1660 | w.tileBuf = tiles | |
| 1658 | 1661 | // The column/stash group sits between demoted covering floats and | |
| @@ -1707,11 +1710,11 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement { | |||
| 1707 | 1710 | } | |
| 1708 | 1711 | ||
| 1709 | 1712 | // setVisibleNeighbors reserves client space only for shared lines that | |
| 1710 | - | // are actually on this output. A scrolled-off or hidden tile cannot give a | |
| 1711 | - | // lone visible tile a border (or shrink its client). | |
| 1712 | - | func setVisibleNeighbors(tiles []Placement, gap int, output Rect) { | |
| 1713 | + | // are actually in the usable area. A scrolled-off, hidden or under-panel | |
| 1714 | + | // tile cannot give a lone visible tile a border (or shrink its client). | |
| 1715 | + | func setVisibleNeighbors(tiles []Placement, gap int, area Rect) { | |
| 1713 | 1716 | visible := func(p Placement) bool { | |
| 1714 | - | return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(output) | |
| 1717 | + | return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(area) | |
| 1715 | 1718 | } | |
| 1716 | 1719 | overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 } | |
| 1717 | 1720 | for i := range tiles { | |
| @@ -1725,15 +1728,15 @@ func setVisibleNeighbors(tiles []Placement, gap int, output Rect) { | |||
| 1725 | 1728 | continue | |
| 1726 | 1729 | } | |
| 1727 | 1730 | b, r := tiles[j].Rect, a.Rect | |
| 1728 | - | // Only count a shared edge strictly within this output. | |
| 1731 | + | // Only count a shared edge strictly within the area. | |
| 1729 | 1732 | switch { | |
| 1730 | - | case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > output.X && r.X+r.W < output.X+output.W: | |
| 1733 | + | case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > area.X && r.X+r.W < area.X+area.W: | |
| 1731 | 1734 | a.Neighbors |= ports.SideRight | |
| 1732 | - | case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > output.X && r.X < output.X+output.W: | |
| 1735 | + | case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > area.X && r.X < area.X+area.W: | |
| 1733 | 1736 | a.Neighbors |= ports.SideLeft | |
| 1734 | - | case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > output.Y && r.Y+r.H < output.Y+output.H: | |
| 1737 | + | case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > area.Y && r.Y+r.H < area.Y+area.H: | |
| 1735 | 1738 | a.Neighbors |= ports.SideBottom | |
| 1736 | - | case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > output.Y && r.Y < output.Y+output.H: | |
| 1739 | + | case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > area.Y && r.Y < area.Y+area.H: | |
| 1737 | 1740 | a.Neighbors |= ports.SideTop | |
| 1738 | 1741 | } | |
| 1739 | 1742 | } | |
| @@ -122,7 +122,7 @@ type Placement struct { | |||||
| 122 | // Preview is the scale of an overview preview: its buffer is drawn | 122 | // Preview is the scale of an overview preview: its buffer is drawn | ||
| 123 | // that much smaller in Rect, the client keeps its size. 0 otherwise. | 123 | // that much smaller in Rect, the client keeps its size. 0 otherwise. | ||
| 124 | Preview float64 | 124 | Preview float64 | ||
| 125 | - | // Neighbors are the sides touching a visible tile on this output. | 125 | + | // Neighbors are the sides touching a tile visible in the usable area. |
| 126 | // Inset reserves room for drawn lines: all sides of a float; for | 126 | // Inset reserves room for drawn lines: all sides of a float; for | ||
| 127 | // tiles without gaps, only the right and bottom shared sides. | 127 | // tiles without gaps, only the right and bottom shared sides. | ||
| 128 | Neighbors, Inset ports.Sides | 128 | Neighbors, Inset ports.Sides | ||
| @@ -1052,6 +1052,13 @@ func (w *Workspace) maximize(i int) { | |||||
| 1052 | } | 1052 | } | ||
| 1053 | } | 1053 | } | ||
| 1054 | 1054 | ||||
| 1055 | + | // hiddenByMaximized reports whether the maximized focused column hides | |||
| 1056 | + | // column i on screen: in equal-cell layouts it hides the other columns of | |||
| 1057 | + | // its band (the whole row in fixed overflow). | |||
| 1058 | + | func (w *Workspace) hiddenByMaximized(i int) bool { | |||
| 1059 | + | return w.policy().equalCells && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus) | |||
| 1060 | + | } | |||
| 1061 | + | ||||
| 1055 | // unmaximize clears the focused column in scroll mode, or all maximized | 1062 | // unmaximize clears the focused column in scroll mode, or all maximized | ||
| 1056 | // columns in fixed mode (including after Escape changed focus). | 1063 | // columns in fixed mode (including after Escape changed focus). | ||
| 1057 | func (w *Workspace) unmaximize() { | 1064 | func (w *Workspace) unmaximize() { | ||
| @@ -1383,15 +1390,10 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int { | |||||
| 1383 | g := w.gap() | 1390 | g := w.gap() | ||
| 1384 | // Fixed overflow never scrolls, so presets would push columns off screen. | 1391 | // Fixed overflow never scrolls, so presets would push columns off screen. | ||
| 1385 | if w.policy().wraps { | 1392 | if w.policy().wraps { | ||
| 1386 | - | return w.cellWidth() | 1393 | + | return w.cellWidth(i) |
| 1387 | } | 1394 | } | ||
| 1388 | if w.Columns[i].Width == (Width{}) || w.policy().equalCells { | 1395 | if w.Columns[i].Width == (Width{}) || w.policy().equalCells { | ||
| 1389 | - | if len(w.Columns) == 1 { | 1396 | + | return w.equalShare(min(len(w.Columns), max(w.MaxColumns, 1))) |
| 1390 | - | return max(w.Usable.W-2*g, 0) | |||
| 1391 | - | } | |||
| 1392 | - | // Equal shares of the width left after gaps; a remainder under k pixels stays empty. | |||
| 1393 | - | k := min(len(w.Columns), max(w.MaxColumns, 1)) | |||
| 1394 | - | return max((w.Usable.W-g*(k+1))/k, 0) | |||
| 1395 | } | 1397 | } | ||
| 1396 | return w.Columns[i].Width.Resolve(w.Usable.W, g) | 1398 | return w.Columns[i].Width.Resolve(w.Usable.W, g) | ||
| 1397 | } | 1399 | } | ||
| @@ -1485,7 +1487,7 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||||
| 1485 | // right; the two strips share the last cell. | 1487 | // right; the two strips share the last cell. | ||
| 1486 | func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect { | 1488 | func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect { | ||
| 1487 | g := w.gap() | 1489 | g := w.gap() | ||
| 1488 | - | cell := max((w.Usable.W-g*(k+1))/k, 0) | 1490 | + | cell := w.equalShare(k) |
| 1489 | wide := (k-1)*cell + (k-2)*g | 1491 | wide := (k-1)*cell + (k-2)*g | ||
| 1490 | rest := max(w.Usable.W-2*g-wide-g, 0) | 1492 | rest := max(w.Usable.W-2*g-wide-g, 0) | ||
| 1491 | before, after := w.Columns[:e], w.Columns[e+1:] | 1493 | before, after := w.Columns[:e], w.Columns[e+1:] | ||
| @@ -1637,8 +1639,7 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement { | |||||
| 1637 | full := w.fullscreen == id && id != 0 | 1639 | full := w.fullscreen == id && id != 0 | ||
| 1638 | // Fixed overflow cannot scroll to other columns while one fills | 1640 | // Fixed overflow cannot scroll to other columns while one fills | ||
| 1639 | // the view. Keep them in place, but out of the scene. | 1641 | // the view. Keep them in place, but out of the scene. | ||
| 1640 | - | maximized := w.policy().equalCells && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus) | 1642 | + | hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || w.hiddenByMaximized(i) |
| 1641 | - | hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized | |||
| 1642 | if full { | 1643 | if full { | ||
| 1643 | // Scroll mode aligns the view on the column; fixed never scrolls. | 1644 | // Scroll mode aligns the view on the column; fixed never scrolls. | ||
| 1644 | r = w.Output | 1645 | r = w.Output | ||
| @@ -1652,7 +1653,9 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement { | |||||
| 1652 | tiles = append(tiles, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden}) | 1653 | tiles = append(tiles, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden}) | ||
| 1653 | } | 1654 | } | ||
| 1654 | } | 1655 | } | ||
| 1655 | - | setVisibleNeighbors(tiles, gap, w.Output) | 1656 | + | // Lines are shared within the usable area: a tile under a panel (the |
| 1657 | + | // next cascade band starts right below a bottom one) is no neighbor. | |||
| 1658 | + | setVisibleNeighbors(tiles, gap, w.Usable) | |||
| 1656 | // tiles lives in the workspace's scratch until they are copied below. | 1659 | // tiles lives in the workspace's scratch until they are copied below. | ||
| 1657 | w.tileBuf = tiles | 1660 | w.tileBuf = tiles | ||
| 1658 | // The column/stash group sits between demoted covering floats and | 1661 | // The column/stash group sits between demoted covering floats and | ||
| @@ -1707,11 +1710,11 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement { | |||||
| 1707 | } | 1710 | } | ||
| 1708 | 1711 | ||||
| 1709 | // setVisibleNeighbors reserves client space only for shared lines that | 1712 | // setVisibleNeighbors reserves client space only for shared lines that | ||
| 1710 | - | // are actually on this output. A scrolled-off or hidden tile cannot give a | 1713 | + | // are actually in the usable area. A scrolled-off, hidden or under-panel |
| 1711 | - | // lone visible tile a border (or shrink its client). | 1714 | + | // tile cannot give a lone visible tile a border (or shrink its client). |
| 1712 | - | func setVisibleNeighbors(tiles []Placement, gap int, output Rect) { | 1715 | + | func setVisibleNeighbors(tiles []Placement, gap int, area Rect) { |
| 1713 | visible := func(p Placement) bool { | 1716 | visible := func(p Placement) bool { | ||
| 1714 | - | return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(output) | 1717 | + | return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(area) |
| 1715 | } | 1718 | } | ||
| 1716 | overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 } | 1719 | overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 } | ||
| 1717 | for i := range tiles { | 1720 | for i := range tiles { | ||
| @@ -1725,15 +1728,15 @@ func setVisibleNeighbors(tiles []Placement, gap int, output Rect) { | |||||
| 1725 | continue | 1728 | continue | ||
| 1726 | } | 1729 | } | ||
| 1727 | b, r := tiles[j].Rect, a.Rect | 1730 | b, r := tiles[j].Rect, a.Rect | ||
| 1728 | - | // Only count a shared edge strictly within this output. | 1731 | + | // Only count a shared edge strictly within the area. |
| 1729 | switch { | 1732 | switch { | ||
| 1730 | - | case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > output.X && r.X+r.W < output.X+output.W: | 1733 | + | case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > area.X && r.X+r.W < area.X+area.W: |
| 1731 | a.Neighbors |= ports.SideRight | 1734 | a.Neighbors |= ports.SideRight | ||
| 1732 | - | case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > output.X && r.X < output.X+output.W: | 1735 | + | case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > area.X && r.X < area.X+area.W: |
| 1733 | a.Neighbors |= ports.SideLeft | 1736 | a.Neighbors |= ports.SideLeft | ||
| 1734 | - | case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > output.Y && r.Y+r.H < output.Y+output.H: | 1737 | + | case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > area.Y && r.Y+r.H < area.Y+area.H: |
| 1735 | a.Neighbors |= ports.SideBottom | 1738 | a.Neighbors |= ports.SideBottom | ||
| 1736 | - | case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > output.Y && r.Y < output.Y+output.H: | 1739 | + | case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > area.Y && r.Y < area.Y+area.H: |
| 1737 | a.Neighbors |= ports.SideTop | 1740 | a.Neighbors |= ports.SideTop | ||
| 1738 | } | 1741 | } | ||
| 1739 | } | 1742 | } | ||
M internal/core/overview.go +95 -16
| @@ -450,8 +450,12 @@ func (m *Monitor) overviewAcross(dx int) { | |||
| 450 | 450 | m.ov.card, m.ov.cardOf = 0, nil | |
| 451 | 451 | m.ov.selected = 0 | |
| 452 | 452 | if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 { | |
| 453 | - | first, _ := w.laneBounds(w.laneOf(w.Focus)) | |
| 454 | - | m.selectLaneColumn(w, first) | |
| 453 | + | // The front column: a maximized one keeps its cards behind. | |
| 454 | + | i := w.Focus | |
| 455 | + | if !w.hasBandCards() { | |
| 456 | + | i, _ = w.laneBounds(w.laneOf(w.Focus)) | |
| 457 | + | } | |
| 458 | + | m.selectLaneColumn(w, i) | |
| 455 | 459 | } | |
| 456 | 460 | return | |
| 457 | 461 | } | |
| @@ -596,16 +600,18 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||
| 596 | 600 | } else if item, ok := w.itemOf(id); ok { | |
| 597 | 601 | m.setFront(w, item) | |
| 598 | 602 | if i := w.columnOf(id); i >= 0 { | |
| 599 | - | if item.kind == stackColumns && !w.overviewMaximized() { | |
| 600 | - | w.selectOverviewColumn(i) | |
| 601 | - | } | |
| 602 | - | m.ov.selected = id | |
| 603 | + | // The clicked window first: a maximization moving to its | |
| 604 | + | // column records it in the maximize history. | |
| 603 | 605 | for j, v := range w.Columns[i].Windows { | |
| 604 | 606 | if v == id { | |
| 605 | 607 | w.Columns[i].Focus = j | |
| 606 | 608 | break | |
| 607 | 609 | } | |
| 608 | 610 | } | |
| 611 | + | if item.kind == stackColumns && !w.overviewMaximized() { | |
| 612 | + | w.selectOverviewColumn(i) | |
| 613 | + | } | |
| 614 | + | m.ov.selected = id | |
| 609 | 615 | } | |
| 610 | 616 | } | |
| 611 | 617 | m.closeOverview() | |
| @@ -970,17 +976,38 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 970 | 976 | rects = w.colBuf | |
| 971 | 977 | span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile. | |
| 972 | 978 | } | |
| 973 | - | maximized := w.policy().equalCells && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth | |
| 979 | + | if w.hasBandCards() { | |
| 980 | + | // The columns the maximized one hides are cards behind it, | |
| 981 | + | // farthest first so the nearest paint over them. | |
| 982 | + | first, last := w.laneBounds(w.laneOf(w.Focus)) | |
| 983 | + | for d := last - first; d > 0; d-- { | |
| 984 | + | if i := w.Focus - d; i >= first { | |
| 985 | + | tiles = w.appendBandCard(tiles, i) | |
| 986 | + | } | |
| 987 | + | if i := w.Focus + d; i <= last { | |
| 988 | + | tiles = w.appendBandCard(tiles, i) | |
| 989 | + | } | |
| 990 | + | } | |
| 991 | + | } | |
| 974 | 992 | for i, c := range w.Columns { | |
| 975 | 993 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | |
| 976 | 994 | if w.policy().wraps { | |
| 995 | + | if w.hiddenByMaximized(i) { | |
| 996 | + | continue // A card, above. | |
| 997 | + | } | |
| 977 | 998 | // The band is the lane: it is encoded in Y, one screen apart. | |
| 978 | - | // A maximized column fills its band as on screen. | |
| 999 | + | // A maximized column fills its band as on screen, inset for | |
| 1000 | + | // the cards it hides. | |
| 979 | 1001 | r.X = w.cellX(i) | |
| 980 | 1002 | r.Y = g + w.band(i)*w.Usable.H | |
| 981 | - | r.W = w.cellWidth() | |
| 1003 | + | r.W = w.cellWidth(i) | |
| 982 | 1004 | if c.FullWidth { | |
| 983 | - | r.X, r.W = g, max(w.Usable.W-2*g, 0) | |
| 1005 | + | in := 0 | |
| 1006 | + | if i == w.Focus && w.hasBandCards() { | |
| 1007 | + | // Only the focused column has cards behind it. | |
| 1008 | + | in = 2 * w.bandCardStep() | |
| 1009 | + | } | |
| 1010 | + | r.X, r.W = g+in, max(w.Usable.W-2*g-2*in, 0) | |
| 984 | 1011 | } | |
| 985 | 1012 | } else if w.Overflow == OverflowFixed { | |
| 986 | 1013 | r = rects[i] | |
| @@ -989,10 +1016,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 989 | 1016 | // A fullscreen column shows at the width it had in the row. | |
| 990 | 1017 | r.W = max(w.Usable.W-2*g, 0) | |
| 991 | 1018 | } | |
| 992 | - | if maximized && i != w.Focus && w.sameBand(i, w.Focus) { | |
| 993 | - | // FullWidth hides the other columns of its band on screen. | |
| 994 | - | // Their old buffers cannot fit their current rects; show only | |
| 995 | - | // the maximized column until navigation restores the layout. | |
| 1019 | + | if w.hiddenByMaximized(i) { | |
| 1020 | + | // Fixed overflow shows the hidden columns on their own card | |
| 1021 | + | // (previewItem). Their old buffers cannot fit their current | |
| 1022 | + | // rects; show only the maximized column here. | |
| 996 | 1023 | for _, id := range c.Windows { | |
| 997 | 1024 | tiles = append(tiles, Placement{ID: id, Hidden: true}) | |
| 998 | 1025 | } | |
| @@ -1015,6 +1042,46 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 1015 | 1042 | return tiles, span, sel | |
| 1016 | 1043 | } | |
| 1017 | 1044 | ||
| 1045 | + | // bandCardStep is the unscaled step between the cards of a cascade band | |
| 1046 | + | // whose maximized column hides the others: the stack peek step. | |
| 1047 | + | func (w *Workspace) bandCardStep() int { | |
| 1048 | + | return max(1, int(math.Round(float64(w.Usable.H)*overviewPeekStep))) | |
| 1049 | + | } | |
| 1050 | + | ||
| 1051 | + | // hasBandCards reports whether the focused column of a cascade is | |
| 1052 | + | // maximized and hides other columns of its band: the overview shows them | |
| 1053 | + | // as cards behind it, the maximized column inset by two card steps on | |
| 1054 | + | // each side. | |
| 1055 | + | func (w *Workspace) hasBandCards() bool { | |
| 1056 | + | if !w.policy().wraps || w.Focus < 0 || w.Focus >= len(w.Columns) || !w.Columns[w.Focus].FullWidth { | |
| 1057 | + | return false | |
| 1058 | + | } | |
| 1059 | + | first, last := w.laneBounds(w.laneOf(w.Focus)) | |
| 1060 | + | return first < last | |
| 1061 | + | } | |
| 1062 | + | ||
| 1063 | + | // appendBandCard appends hidden column i of a cascade band as a dimmed card | |
| 1064 | + | // at its cell size behind the maximized column, on its side: the nearest | |
| 1065 | + | // column one step out from the maximized one, the next ones two steps out | |
| 1066 | + | // (on the band's edge), like the cards of a stack. | |
| 1067 | + | func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement { | |
| 1068 | + | g, s, c := w.gap(), w.bandCardStep(), w.Columns[i] | |
| 1069 | + | r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)} | |
| 1070 | + | k := min(max(i-w.Focus, w.Focus-i), 2) | |
| 1071 | + | if i < w.Focus { | |
| 1072 | + | r.X = g + (2-k)*s | |
| 1073 | + | } else { | |
| 1074 | + | r.X = w.Usable.W - g - (2-k)*s - r.W | |
| 1075 | + | } | |
| 1076 | + | w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g) | |
| 1077 | + | for j, t := range w.rowBuf { | |
| 1078 | + | p := Placement{ID: c.Windows[j], Rect: t} | |
| 1079 | + | p.peeking(1) | |
| 1080 | + | tiles = append(tiles, p) | |
| 1081 | + | } | |
| 1082 | + | return tiles | |
| 1083 | + | } | |
| 1084 | + | ||
| 1018 | 1085 | // previewRow scales the tiles of w into a row placed in slot, centred | |
| 1019 | 1086 | // in the slot's width, or scrolled so the focused column shows when the | |
| 1020 | 1087 | // row is wider, right of the stash pile. Dimmed rows (neighbor | |
| @@ -1284,8 +1351,14 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||
| 1284 | 1351 | case math.Abs(m.ov.scrollX) >= stepX: | |
| 1285 | 1352 | d := sign(m.ov.scrollX) | |
| 1286 | 1353 | if cascade && finger { | |
| 1287 | - | // One workspace per gesture, even over a stash card. | |
| 1288 | - | m.overviewStep(d) | |
| 1354 | + | // One step per gesture: a card behind a maximized | |
| 1355 | + | // column first, like the arrows, else one workspace, | |
| 1356 | + | // even over a stash card. | |
| 1357 | + | if m.bandCardsAt(m.Current()) { | |
| 1358 | + | m.OverviewMove(d, 0) | |
| 1359 | + | } else { | |
| 1360 | + | m.overviewStep(d) | |
| 1361 | + | } | |
| 1289 | 1362 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | |
| 1290 | 1363 | return true | |
| 1291 | 1364 | } | |
| @@ -1305,6 +1378,12 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||
| 1305 | 1378 | } | |
| 1306 | 1379 | } | |
| 1307 | 1380 | ||
| 1381 | + | // bandCardsAt reports whether the selection of w is a maximized cascade | |
| 1382 | + | // column hiding cards of its band: not a stash card nor a covering float. | |
| 1383 | + | func (m *Monitor) bandCardsAt(w *Workspace) bool { | |
| 1384 | + | return m.cardAt(w) < 0 && w.hasBandCards() && m.stackFront(w).kind == stackColumns | |
| 1385 | + | } | |
| 1386 | + | ||
| 1308 | 1387 | // overviewTurned reports whether a scroll step turned the overview from the | |
| 1309 | 1388 | // axis before. A finger scroll then waits for the fingers to lift, so it | |
| 1310 | 1389 | // cannot bounce back. | |
| @@ -450,8 +450,12 @@ func (m *Monitor) overviewAcross(dx int) { | |||||
| 450 | m.ov.card, m.ov.cardOf = 0, nil | 450 | m.ov.card, m.ov.cardOf = 0, nil | ||
| 451 | m.ov.selected = 0 | 451 | m.ov.selected = 0 | ||
| 452 | if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 { | 452 | if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 { | ||
| 453 | - | first, _ := w.laneBounds(w.laneOf(w.Focus)) | 453 | + | // The front column: a maximized one keeps its cards behind. |
| 454 | - | m.selectLaneColumn(w, first) | 454 | + | i := w.Focus |
| 455 | + | if !w.hasBandCards() { | |||
| 456 | + | i, _ = w.laneBounds(w.laneOf(w.Focus)) | |||
| 457 | + | } | |||
| 458 | + | m.selectLaneColumn(w, i) | |||
| 455 | } | 459 | } | ||
| 456 | return | 460 | return | ||
| 457 | } | 461 | } | ||
| @@ -596,16 +600,18 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||||
| 596 | } else if item, ok := w.itemOf(id); ok { | 600 | } else if item, ok := w.itemOf(id); ok { | ||
| 597 | m.setFront(w, item) | 601 | m.setFront(w, item) | ||
| 598 | if i := w.columnOf(id); i >= 0 { | 602 | if i := w.columnOf(id); i >= 0 { | ||
| 599 | - | if item.kind == stackColumns && !w.overviewMaximized() { | 603 | + | // The clicked window first: a maximization moving to its |
| 600 | - | w.selectOverviewColumn(i) | 604 | + | // column records it in the maximize history. |
| 601 | - | } | |||
| 602 | - | m.ov.selected = id | |||
| 603 | for j, v := range w.Columns[i].Windows { | 605 | for j, v := range w.Columns[i].Windows { | ||
| 604 | if v == id { | 606 | if v == id { | ||
| 605 | w.Columns[i].Focus = j | 607 | w.Columns[i].Focus = j | ||
| 606 | break | 608 | break | ||
| 607 | } | 609 | } | ||
| 608 | } | 610 | } | ||
| 611 | + | if item.kind == stackColumns && !w.overviewMaximized() { | |||
| 612 | + | w.selectOverviewColumn(i) | |||
| 613 | + | } | |||
| 614 | + | m.ov.selected = id | |||
| 609 | } | 615 | } | ||
| 610 | } | 616 | } | ||
| 611 | m.closeOverview() | 617 | m.closeOverview() | ||
| @@ -970,17 +976,38 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 970 | rects = w.colBuf | 976 | rects = w.colBuf | ||
| 971 | span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile. | 977 | span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile. | ||
| 972 | } | 978 | } | ||
| 973 | - | maximized := w.policy().equalCells && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth | 979 | + | if w.hasBandCards() { |
| 980 | + | // The columns the maximized one hides are cards behind it, | |||
| 981 | + | // farthest first so the nearest paint over them. | |||
| 982 | + | first, last := w.laneBounds(w.laneOf(w.Focus)) | |||
| 983 | + | for d := last - first; d > 0; d-- { | |||
| 984 | + | if i := w.Focus - d; i >= first { | |||
| 985 | + | tiles = w.appendBandCard(tiles, i) | |||
| 986 | + | } | |||
| 987 | + | if i := w.Focus + d; i <= last { | |||
| 988 | + | tiles = w.appendBandCard(tiles, i) | |||
| 989 | + | } | |||
| 990 | + | } | |||
| 991 | + | } | |||
| 974 | for i, c := range w.Columns { | 992 | for i, c := range w.Columns { | ||
| 975 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | 993 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | ||
| 976 | if w.policy().wraps { | 994 | if w.policy().wraps { | ||
| 995 | + | if w.hiddenByMaximized(i) { | |||
| 996 | + | continue // A card, above. | |||
| 997 | + | } | |||
| 977 | // The band is the lane: it is encoded in Y, one screen apart. | 998 | // The band is the lane: it is encoded in Y, one screen apart. | ||
| 978 | - | // A maximized column fills its band as on screen. | 999 | + | // A maximized column fills its band as on screen, inset for |
| 1000 | + | // the cards it hides. | |||
| 979 | r.X = w.cellX(i) | 1001 | r.X = w.cellX(i) | ||
| 980 | r.Y = g + w.band(i)*w.Usable.H | 1002 | r.Y = g + w.band(i)*w.Usable.H | ||
| 981 | - | r.W = w.cellWidth() | 1003 | + | r.W = w.cellWidth(i) |
| 982 | if c.FullWidth { | 1004 | if c.FullWidth { | ||
| 983 | - | r.X, r.W = g, max(w.Usable.W-2*g, 0) | 1005 | + | in := 0 |
| 1006 | + | if i == w.Focus && w.hasBandCards() { | |||
| 1007 | + | // Only the focused column has cards behind it. | |||
| 1008 | + | in = 2 * w.bandCardStep() | |||
| 1009 | + | } | |||
| 1010 | + | r.X, r.W = g+in, max(w.Usable.W-2*g-2*in, 0) | |||
| 984 | } | 1011 | } | ||
| 985 | } else if w.Overflow == OverflowFixed { | 1012 | } else if w.Overflow == OverflowFixed { | ||
| 986 | r = rects[i] | 1013 | r = rects[i] | ||
| @@ -989,10 +1016,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 989 | // A fullscreen column shows at the width it had in the row. | 1016 | // A fullscreen column shows at the width it had in the row. | ||
| 990 | r.W = max(w.Usable.W-2*g, 0) | 1017 | r.W = max(w.Usable.W-2*g, 0) | ||
| 991 | } | 1018 | } | ||
| 992 | - | if maximized && i != w.Focus && w.sameBand(i, w.Focus) { | 1019 | + | if w.hiddenByMaximized(i) { |
| 993 | - | // FullWidth hides the other columns of its band on screen. | 1020 | + | // Fixed overflow shows the hidden columns on their own card |
| 994 | - | // Their old buffers cannot fit their current rects; show only | 1021 | + | // (previewItem). Their old buffers cannot fit their current |
| 995 | - | // the maximized column until navigation restores the layout. | 1022 | + | // rects; show only the maximized column here. |
| 996 | for _, id := range c.Windows { | 1023 | for _, id := range c.Windows { | ||
| 997 | tiles = append(tiles, Placement{ID: id, Hidden: true}) | 1024 | tiles = append(tiles, Placement{ID: id, Hidden: true}) | ||
| 998 | } | 1025 | } | ||
| @@ -1015,6 +1042,46 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 1015 | return tiles, span, sel | 1042 | return tiles, span, sel | ||
| 1016 | } | 1043 | } | ||
| 1017 | 1044 | ||||
| 1045 | + | // bandCardStep is the unscaled step between the cards of a cascade band | |||
| 1046 | + | // whose maximized column hides the others: the stack peek step. | |||
| 1047 | + | func (w *Workspace) bandCardStep() int { | |||
| 1048 | + | return max(1, int(math.Round(float64(w.Usable.H)*overviewPeekStep))) | |||
| 1049 | + | } | |||
| 1050 | + | ||||
| 1051 | + | // hasBandCards reports whether the focused column of a cascade is | |||
| 1052 | + | // maximized and hides other columns of its band: the overview shows them | |||
| 1053 | + | // as cards behind it, the maximized column inset by two card steps on | |||
| 1054 | + | // each side. | |||
| 1055 | + | func (w *Workspace) hasBandCards() bool { | |||
| 1056 | + | if !w.policy().wraps || w.Focus < 0 || w.Focus >= len(w.Columns) || !w.Columns[w.Focus].FullWidth { | |||
| 1057 | + | return false | |||
| 1058 | + | } | |||
| 1059 | + | first, last := w.laneBounds(w.laneOf(w.Focus)) | |||
| 1060 | + | return first < last | |||
| 1061 | + | } | |||
| 1062 | + | ||||
| 1063 | + | // appendBandCard appends hidden column i of a cascade band as a dimmed card | |||
| 1064 | + | // at its cell size behind the maximized column, on its side: the nearest | |||
| 1065 | + | // column one step out from the maximized one, the next ones two steps out | |||
| 1066 | + | // (on the band's edge), like the cards of a stack. | |||
| 1067 | + | func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement { | |||
| 1068 | + | g, s, c := w.gap(), w.bandCardStep(), w.Columns[i] | |||
| 1069 | + | r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)} | |||
| 1070 | + | k := min(max(i-w.Focus, w.Focus-i), 2) | |||
| 1071 | + | if i < w.Focus { | |||
| 1072 | + | r.X = g + (2-k)*s | |||
| 1073 | + | } else { | |||
| 1074 | + | r.X = w.Usable.W - g - (2-k)*s - r.W | |||
| 1075 | + | } | |||
| 1076 | + | w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g) | |||
| 1077 | + | for j, t := range w.rowBuf { | |||
| 1078 | + | p := Placement{ID: c.Windows[j], Rect: t} | |||
| 1079 | + | p.peeking(1) | |||
| 1080 | + | tiles = append(tiles, p) | |||
| 1081 | + | } | |||
| 1082 | + | return tiles | |||
| 1083 | + | } | |||
| 1084 | + | ||||
| 1018 | // previewRow scales the tiles of w into a row placed in slot, centred | 1085 | // previewRow scales the tiles of w into a row placed in slot, centred | ||
| 1019 | // in the slot's width, or scrolled so the focused column shows when the | 1086 | // in the slot's width, or scrolled so the focused column shows when the | ||
| 1020 | // row is wider, right of the stash pile. Dimmed rows (neighbor | 1087 | // row is wider, right of the stash pile. Dimmed rows (neighbor | ||
| @@ -1284,8 +1351,14 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||||
| 1284 | case math.Abs(m.ov.scrollX) >= stepX: | 1351 | case math.Abs(m.ov.scrollX) >= stepX: | ||
| 1285 | d := sign(m.ov.scrollX) | 1352 | d := sign(m.ov.scrollX) | ||
| 1286 | if cascade && finger { | 1353 | if cascade && finger { | ||
| 1287 | - | // One workspace per gesture, even over a stash card. | 1354 | + | // One step per gesture: a card behind a maximized |
| 1288 | - | m.overviewStep(d) | 1355 | + | // column first, like the arrows, else one workspace, |
| 1356 | + | // even over a stash card. | |||
| 1357 | + | if m.bandCardsAt(m.Current()) { | |||
| 1358 | + | m.OverviewMove(d, 0) | |||
| 1359 | + | } else { | |||
| 1360 | + | m.overviewStep(d) | |||
| 1361 | + | } | |||
| 1289 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | 1362 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | ||
| 1290 | return true | 1363 | return true | ||
| 1291 | } | 1364 | } | ||
| @@ -1305,6 +1378,12 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||||
| 1305 | } | 1378 | } | ||
| 1306 | } | 1379 | } | ||
| 1307 | 1380 | ||||
| 1381 | + | // bandCardsAt reports whether the selection of w is a maximized cascade | |||
| 1382 | + | // column hiding cards of its band: not a stash card nor a covering float. | |||
| 1383 | + | func (m *Monitor) bandCardsAt(w *Workspace) bool { | |||
| 1384 | + | return m.cardAt(w) < 0 && w.hasBandCards() && m.stackFront(w).kind == stackColumns | |||
| 1385 | + | } | |||
| 1386 | + | ||||
| 1308 | // overviewTurned reports whether a scroll step turned the overview from the | 1387 | // overviewTurned reports whether a scroll step turned the overview from the | ||
| 1309 | // axis before. A finger scroll then waits for the fingers to lift, so it | 1388 | // axis before. A finger scroll then waits for the fingers to lift, so it | ||
| 1310 | // cannot bounce back. | 1389 | // cannot bounce back. | ||
M internal/core/overview_cascade_test.go +285 -20
| @@ -75,39 +75,171 @@ func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) { | |||
| 75 | 75 | } | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | - | // A maximized column fills its band in the preview as on screen, and hides | |
| 79 | - | // only the other columns of its band. | |
| 80 | - | func TestOverviewCascadeMaximizedColumnFillsBand(t *testing.T) { | |
| 78 | + | // A maximized column fills its band in the preview, inset for the columns | |
| 79 | + | // it hides: they are dimmed cards at their cell size behind it, on their | |
| 80 | + | // side, the nearest one step out, the farther ones two (the band's edge). | |
| 81 | + | // Other bands keep their cells. | |
| 82 | + | func TestOverviewCascadeMaximizedColumnCards(t *testing.T) { | |
| 81 | 83 | // Four columns, three per band: band 0 holds 0..2, band 1 holds 3. | |
| 82 | 84 | for _, tc := range []struct { | |
| 83 | 85 | n, max int | |
| 84 | - | hidden []bool | |
| 86 | + | cards []int // per column: 0 not a card, -k/+k k steps left/right | |
| 87 | + | order []int // paint order of band 0's columns | |
| 85 | 88 | }{ | |
| 86 | - | {n: 1, max: 0, hidden: []bool{false}}, | |
| 87 | - | {n: 4, max: 0, hidden: []bool{false, true, true, false}}, | |
| 88 | - | {n: 4, max: 3, hidden: []bool{false, false, false, false}}, | |
| 89 | + | {n: 1, max: 0, cards: []int{0}, order: []int{0}}, | |
| 90 | + | {n: 4, max: 0, cards: []int{0, 1, 2, 0}, order: []int{2, 1, 0}}, | |
| 91 | + | {n: 4, max: 1, cards: []int{-1, 0, 1, 0}, order: []int{0, 2, 1}}, | |
| 92 | + | {n: 4, max: 3, cards: []int{0, 0, 0, 0}}, | |
| 93 | + | // Partial bands: the card has its band's half width. | |
| 94 | + | {n: 2, max: 0, cards: []int{0, 1}, order: []int{1, 0}}, | |
| 95 | + | {n: 5, max: 4, cards: []int{0, 0, 0, -1, 0}}, | |
| 89 | 96 | } { | |
| 90 | 97 | w := cascadeWorkspace(tc.n) | |
| 91 | 98 | w.Focus = tc.max | |
| 92 | 99 | w.maximize(tc.max) | |
| 93 | - | tiles, _, _ := w.previewTiles() | |
| 94 | - | g, h := w.gap(), w.Usable.H-2*w.gap() | |
| 95 | - | for i, p := range tiles { | |
| 96 | - | if p.Hidden != tc.hidden[i] { | |
| 97 | - | t.Fatalf("n=%d max=%d column %d hidden %v, want %v", tc.n, tc.max, i, p.Hidden, tc.hidden[i]) | |
| 98 | - | } | |
| 99 | - | if p.Hidden { | |
| 100 | - | continue | |
| 100 | + | tiles, _, sel := w.previewTiles() | |
| 101 | + | g, h, s := w.gap(), w.Usable.H-2*w.gap(), w.bandCardStep() | |
| 102 | + | in := 0 | |
| 103 | + | if slices.ContainsFunc(tc.cards, func(k int) bool { return k != 0 }) { | |
| 104 | + | in = 2 * s | |
| 105 | + | } | |
| 106 | + | var order []int | |
| 107 | + | for _, p := range tiles { | |
| 108 | + | i := int(p.ID) - 1 | |
| 109 | + | if i/3 == 0 && tc.order != nil { | |
| 110 | + | order = append(order, i) | |
| 101 | 111 | } | |
| 102 | 112 | band := i / 3 | |
| 103 | - | want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h} | |
| 104 | - | if i == tc.max { | |
| 105 | - | want.X, want.W = g, w.Usable.W-2*g | |
| 113 | + | want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h} | |
| 114 | + | switch k := tc.cards[i]; { | |
| 115 | + | case i == tc.max: | |
| 116 | + | want.X, want.W = g+in, w.Usable.W-2*g-2*in | |
| 117 | + | case k < 0: | |
| 118 | + | want.X = g + (2+k)*s | |
| 119 | + | case k > 0: | |
| 120 | + | want.X = w.Usable.W - g - (2-k)*s - want.W | |
| 121 | + | } | |
| 122 | + | if p.Hidden || p.Peek != (tc.cards[i] != 0) || p.Focused != (i == tc.max) || p.Rect != want { | |
| 123 | + | t.Fatalf("n=%d max=%d column %d: %+v, want rect %+v card %v", tc.n, tc.max, i, p, want, tc.cards[i] != 0) | |
| 124 | + | } | |
| 125 | + | // The 900-wide band split among its columns, no gaps. | |
| 126 | + | if inBand := min(tc.n-band*3, 3); i != tc.max && p.Rect.W != 900/inBand { | |
| 127 | + | t.Fatalf("n=%d max=%d column %d width %d, want %d", tc.n, tc.max, i, p.Rect.W, 900/inBand) | |
| 106 | 128 | } | |
| 107 | - | if p.Rect != want { | |
| 108 | - | t.Fatalf("n=%d max=%d column %d rect %+v, want %+v", tc.n, tc.max, i, p.Rect, want) | |
| 129 | + | if i == tc.max && sel != want { | |
| 130 | + | t.Fatalf("n=%d max=%d sel %+v, want %+v", tc.n, tc.max, sel, want) | |
| 109 | 131 | } | |
| 110 | 132 | } | |
| 133 | + | if tc.order != nil && !slices.Equal(order, tc.order) { | |
| 134 | + | t.Fatalf("n=%d max=%d paint order %v, want %v", tc.n, tc.max, order, tc.order) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // Moving onto a card behind a maximized column hands it the maximization; | |
| 140 | + | // Return keeps it there, Escape gives it back. Moving to another band | |
| 141 | + | // unmaximizes as before. | |
| 142 | + | func TestOverviewCascadeMaximizedCardSelection(t *testing.T) { | |
| 143 | + | for _, d := range cascadeDrivers { | |
| 144 | + | t.Run(d.name, func(t *testing.T) { | |
| 145 | + | m := cascadeOverview() | |
| 146 | + | w := m.Current() | |
| 147 | + | w.FocusID(9) // band 2 holds 7, 8, 9 | |
| 148 | + | w.ToggleFullWidth() | |
| 149 | + | m.ToggleOverview() | |
| 150 | + | for _, id := range []WindowID{7, 8} { | |
| 151 | + | if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek { | |
| 152 | + | t.Fatalf("card %d: %+v", id, p) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | d.move(m, -1, 0) | |
| 156 | + | if focusedID(m) != 8 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth { | |
| 157 | + | t.Fatalf("left: focus %d, maximization not moved", focusedID(m)) | |
| 158 | + | } | |
| 159 | + | if p := previewOf(t, m.Layout(), 9); !p.Peek { | |
| 160 | + | t.Fatalf("former maximized column not a card: %+v", p) | |
| 161 | + | } | |
| 162 | + | m.CancelOverview() | |
| 163 | + | if focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(8)].FullWidth { | |
| 164 | + | t.Fatalf("escape: focus %d, maximization not restored", focusedID(m)) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | m.ToggleOverview() | |
| 168 | + | d.move(m, -1, 0) | |
| 169 | + | d.move(m, -1, 0) | |
| 170 | + | m.ToggleOverview() // return | |
| 171 | + | if focusedID(m) != 7 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth { | |
| 172 | + | t.Fatalf("return: focus %d, maximization not on 7", focusedID(m)) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | m.ToggleOverview() | |
| 176 | + | d.move(m, 0, -1) | |
| 177 | + | if focusedID(m) != 4 || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth }) { | |
| 178 | + | t.Fatalf("up: focus %d, a column still maximized", focusedID(m)) | |
| 179 | + | } | |
| 180 | + | }) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // A click on a window of a card's column records that window in the | |
| 185 | + | // maximize history, not the column's former focus. | |
| 186 | + | func TestOverviewCascadeMaximizedCardPickRecordsWindow(t *testing.T) { | |
| 187 | + | m := cascadeOverview() | |
| 188 | + | w := m.Current() | |
| 189 | + | // Column 8 stacks 8 over 50, 8 focused. | |
| 190 | + | w.Columns[w.columnOf(8)].Windows = append(w.Columns[w.columnOf(8)].Windows, 50) | |
| 191 | + | w.FocusID(7) | |
| 192 | + | w.ToggleFullWidth() | |
| 193 | + | m.ToggleOverview() | |
| 194 | + | m.OverviewPick(50) | |
| 195 | + | if len(w.maximized) == 0 || w.maximized[0] != 50 || focusedID(m) != 50 { | |
| 196 | + | t.Fatalf("history %v, focus %d; want 50 first", w.maximized, focusedID(m)) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Only the focused maximized column is inset for cards: a FullWidth column | |
| 201 | + | // elsewhere fills its band. | |
| 202 | + | func TestOverviewCascadeUnfocusedMaximizedNotInset(t *testing.T) { | |
| 203 | + | w := cascadeWorkspace(6) | |
| 204 | + | w.SetGaps(8) | |
| 205 | + | w.Focus = 4 | |
| 206 | + | w.maximize(4) | |
| 207 | + | w.Columns[0].FullWidth = true | |
| 208 | + | tiles, _, _ := w.previewTiles() | |
| 209 | + | g := w.gap() | |
| 210 | + | p := previewOf(t, tiles, 1) | |
| 211 | + | if p.Hidden || p.Rect.X != g || p.Rect.W != w.Usable.W-2*g { | |
| 212 | + | t.Fatalf("unfocused maximized column %+v, want X %d W %d", p, g, w.Usable.W-2*g) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // A click on a card behind a maximized cascade column hands it the | |
| 217 | + | // maximization. | |
| 218 | + | func TestOverviewCascadeMaximizedCardPick(t *testing.T) { | |
| 219 | + | m := cascadeOverview() | |
| 220 | + | w := m.Current() | |
| 221 | + | w.FocusID(7) | |
| 222 | + | w.ToggleFullWidth() | |
| 223 | + | m.ToggleOverview() | |
| 224 | + | // Each card's strip right of the maximized column hits it: 8 next to | |
| 225 | + | // it, 9 at the band's edge. | |
| 226 | + | ps := m.Layout() | |
| 227 | + | front, near, far := previewOf(t, ps, 7).Rect, previewOf(t, ps, 8).Rect, previewOf(t, ps, 9).Rect | |
| 228 | + | for _, c := range []struct { | |
| 229 | + | x0, x1 int | |
| 230 | + | want WindowID | |
| 231 | + | }{{front.X + front.W, near.X + near.W, 8}, {near.X + near.W, far.X + far.W, 9}} { | |
| 232 | + | x := (c.x0 + c.x1) / 2 | |
| 233 | + | if c.x0 >= c.x1 { | |
| 234 | + | t.Fatalf("no strip for %d: front %v near %v far %v", c.want, front, near, far) | |
| 235 | + | } | |
| 236 | + | if got := overviewIn(ps, float64(x), float64(centreY(far))); got != c.want { | |
| 237 | + | t.Fatalf("strip at x=%d hits %d, want %d", x, got, c.want) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | m.OverviewPick(9) | |
| 241 | + | if m.ov.open || focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(7)].FullWidth { | |
| 242 | + | t.Fatalf("pick: open %v, focus %d, maximization not moved", m.ov.open, focusedID(m)) | |
| 111 | 243 | } | |
| 112 | 244 | } | |
| 113 | 245 | ||
| @@ -503,6 +635,139 @@ func TestOverviewCascadeThreeFinger(t *testing.T) { | |||
| 503 | 635 | } | |
| 504 | 636 | } | |
| 505 | 637 | ||
| 638 | + | // Three fingers sideways walk the cards behind a maximized column one per | |
| 639 | + | // gesture, like the arrows, then change workspace past the band's edge. | |
| 640 | + | func TestOverviewCascadeThreeFingerMaximizedCards(t *testing.T) { | |
| 641 | + | c, m := cascadeCore(t) | |
| 642 | + | w := m.Current() | |
| 643 | + | w.ToggleFullWidth() // Column 2 hides 3, the other column of its band. | |
| 644 | + | m.ToggleOverview() | |
| 645 | + | swipe := func(dx float64) { | |
| 646 | + | t.Helper() | |
| 647 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 648 | + | for range 4 { | |
| 649 | + | c.swipeUpdate(ports.SwipeUpdate{DX: dx}) | |
| 650 | + | } | |
| 651 | + | c.swipeEnd(ports.SwipeEnd{}) | |
| 652 | + | } | |
| 653 | + | swipe(40) | |
| 654 | + | if m.Current() != w || m.overviewTarget() != 3 || !w.Columns[w.Focus].FullWidth { | |
| 655 | + | t.Fatalf("swipe right: workspace %d, target %d, want maximized 3", indexOf(m.Workspaces, m.Current()), m.overviewTarget()) | |
| 656 | + | } | |
| 657 | + | swipe(-40) | |
| 658 | + | if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth { | |
| 659 | + | t.Fatalf("swipe left: workspace %d, target %d, want maximized 2", indexOf(m.Workspaces, m.Current()), m.overviewTarget()) | |
| 660 | + | } | |
| 661 | + | swipe(40) | |
| 662 | + | swipe(40) | |
| 663 | + | if m.Current() != m.Workspaces[2] { | |
| 664 | + | t.Fatalf("swipe right past the band: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | // A covering float in front: the swipe changes workspace, as without | |
| 668 | + | // a maximized column. | |
| 669 | + | m.CancelOverview() | |
| 670 | + | w.AddFloating(90, 300, 200) | |
| 671 | + | m.ToggleOverview() | |
| 672 | + | if f := m.stackFront(w); f.kind != stackFloat || !w.Columns[w.Focus].FullWidth { | |
| 673 | + | t.Fatalf("setup: front %+v", f) | |
| 674 | + | } | |
| 675 | + | swipe(-40) | |
| 676 | + | if m.Current() != m.Workspaces[0] { | |
| 677 | + | t.Fatalf("swipe left over a float: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 678 | + | } | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | // Left from the first column of a band whose maximized column hides it: | |
| 682 | + | // the arrows and three fingers walk the cards, then enter the stash, or | |
| 683 | + | // change workspace without one. | |
| 684 | + | func TestOverviewCascadeMaximizedLeftEdge(t *testing.T) { | |
| 685 | + | for _, stash := range []bool{false, true} { | |
| 686 | + | c, m := cascadeCore(t) | |
| 687 | + | w := m.Current() | |
| 688 | + | if stash { | |
| 689 | + | w.AddWindow(40) | |
| 690 | + | w.FocusID(40) | |
| 691 | + | w.ToggleWindowStash() | |
| 692 | + | } | |
| 693 | + | w.FocusID(3) // Band {2, 3}: 3 hides 2. | |
| 694 | + | w.ToggleFullWidth() | |
| 695 | + | m.ToggleOverview() | |
| 696 | + | swipe := func() { | |
| 697 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 698 | + | for range 4 { | |
| 699 | + | c.swipeUpdate(ports.SwipeUpdate{DX: -40}) | |
| 700 | + | } | |
| 701 | + | c.swipeEnd(ports.SwipeEnd{}) | |
| 702 | + | } | |
| 703 | + | swipe() | |
| 704 | + | if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth { | |
| 705 | + | t.Fatalf("stash %v: first swipe: target %d", stash, m.overviewTarget()) | |
| 706 | + | } | |
| 707 | + | swipe() | |
| 708 | + | if stash { | |
| 709 | + | if m.Current() != w || m.cardAt(w) < 0 { | |
| 710 | + | t.Fatalf("past the band: workspace %d, card %d; want the stash", indexOf(m.Workspaces, m.Current()), m.cardAt(w)) | |
| 711 | + | } | |
| 712 | + | } else if m.Current() != m.Workspaces[0] { | |
| 713 | + | t.Fatalf("past the band: workspace %d, want 0", indexOf(m.Workspaces, m.Current())) | |
| 714 | + | } | |
| 715 | + | } | |
| 716 | + | } | |
| 717 | + | ||
| 718 | + | // With four or more columns in a band, the cards three or more columns | |
| 719 | + | // away sit under the two-step card: arrows still reach every one, in | |
| 720 | + | // order, each taking the maximization. | |
| 721 | + | func TestOverviewCascadeFarCardsByArrows(t *testing.T) { | |
| 722 | + | m := newMonitor("", "") | |
| 723 | + | m.SetOutput(1200, 600) | |
| 724 | + | m.SetMaxColumns(5) | |
| 725 | + | w := m.Current() | |
| 726 | + | w.Overflow = OverflowCascade | |
| 727 | + | for id := WindowID(1); id <= 5; id++ { | |
| 728 | + | m.AddWindow(id) | |
| 729 | + | } | |
| 730 | + | w.FocusID(1) | |
| 731 | + | w.ToggleFullWidth() | |
| 732 | + | m.ToggleOverview() | |
| 733 | + | ps := m.Layout() | |
| 734 | + | edge := previewOf(t, ps, 3).Rect | |
| 735 | + | for _, id := range []WindowID{4, 5} { | |
| 736 | + | if p := previewOf(t, ps, id); p.Hidden || !p.Peek || p.Rect.X != edge.X { | |
| 737 | + | t.Fatalf("far card %d: %+v, want a card under 3 at x %d", id, p, edge.X) | |
| 738 | + | } | |
| 739 | + | } | |
| 740 | + | for _, want := range []WindowID{2, 3, 4, 5} { | |
| 741 | + | m.OverviewMove(1, 0) | |
| 742 | + | if m.Current() != w || m.overviewTarget() != want || !w.Columns[w.Focus].FullWidth { | |
| 743 | + | t.Fatalf("right: target %d, want maximized %d", m.overviewTarget(), want) | |
| 744 | + | } | |
| 745 | + | } | |
| 746 | + | m.CancelOverview() | |
| 747 | + | if focusedID(m) != 1 || !w.Columns[w.Focus].FullWidth { | |
| 748 | + | t.Fatalf("escape: focus %d, want maximized 1", focusedID(m)) | |
| 749 | + | } | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | // Right from a stash card lands on the maximized column, which keeps its | |
| 753 | + | // maximization, not on the first card of its band. | |
| 754 | + | func TestOverviewCascadeStashToMaximized(t *testing.T) { | |
| 755 | + | m := cascadeOverview() | |
| 756 | + | withStash(m, 1) | |
| 757 | + | w := m.Current() | |
| 758 | + | w.FocusID(9) | |
| 759 | + | w.ToggleFullWidth() | |
| 760 | + | m.ToggleOverview() | |
| 761 | + | m.selectCard(w, w.stashAt) // As a click on the pile does. | |
| 762 | + | if m.cardAt(w) < 0 { | |
| 763 | + | t.Fatalf("setup: no stash card, target %d", m.overviewTarget()) | |
| 764 | + | } | |
| 765 | + | m.OverviewMove(1, 0) | |
| 766 | + | if m.overviewTarget() != 9 || !w.Columns[w.Focus].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 9 { | |
| 767 | + | t.Fatalf("target %d, maximized %v; want 9", m.overviewTarget(), w.maximized) | |
| 768 | + | } | |
| 769 | + | } | |
| 770 | + | ||
| 506 | 771 | // T8: a named boundary of a cascade overview is a vertical rule strictly | |
| 507 | 772 | // between the two slots. | |
| 508 | 773 | func TestOverviewCascadeNamedDivider(t *testing.T) { | |
| @@ -75,39 +75,171 @@ func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) { | |||||
| 75 | } | 75 | } | ||
| 76 | } | 76 | } | ||
| 77 | 77 | ||||
| 78 | - | // A maximized column fills its band in the preview as on screen, and hides | 78 | + | // A maximized column fills its band in the preview, inset for the columns |
| 79 | - | // only the other columns of its band. | 79 | + | // it hides: they are dimmed cards at their cell size behind it, on their |
| 80 | - | func TestOverviewCascadeMaximizedColumnFillsBand(t *testing.T) { | 80 | + | // side, the nearest one step out, the farther ones two (the band's edge). |
| 81 | + | // Other bands keep their cells. | |||
| 82 | + | func TestOverviewCascadeMaximizedColumnCards(t *testing.T) { | |||
| 81 | // Four columns, three per band: band 0 holds 0..2, band 1 holds 3. | 83 | // Four columns, three per band: band 0 holds 0..2, band 1 holds 3. | ||
| 82 | for _, tc := range []struct { | 84 | for _, tc := range []struct { | ||
| 83 | n, max int | 85 | n, max int | ||
| 84 | - | hidden []bool | 86 | + | cards []int // per column: 0 not a card, -k/+k k steps left/right |
| 87 | + | order []int // paint order of band 0's columns | |||
| 85 | }{ | 88 | }{ | ||
| 86 | - | {n: 1, max: 0, hidden: []bool{false}}, | 89 | + | {n: 1, max: 0, cards: []int{0}, order: []int{0}}, |
| 87 | - | {n: 4, max: 0, hidden: []bool{false, true, true, false}}, | 90 | + | {n: 4, max: 0, cards: []int{0, 1, 2, 0}, order: []int{2, 1, 0}}, |
| 88 | - | {n: 4, max: 3, hidden: []bool{false, false, false, false}}, | 91 | + | {n: 4, max: 1, cards: []int{-1, 0, 1, 0}, order: []int{0, 2, 1}}, |
| 92 | + | {n: 4, max: 3, cards: []int{0, 0, 0, 0}}, | |||
| 93 | + | // Partial bands: the card has its band's half width. | |||
| 94 | + | {n: 2, max: 0, cards: []int{0, 1}, order: []int{1, 0}}, | |||
| 95 | + | {n: 5, max: 4, cards: []int{0, 0, 0, -1, 0}}, | |||
| 89 | } { | 96 | } { | ||
| 90 | w := cascadeWorkspace(tc.n) | 97 | w := cascadeWorkspace(tc.n) | ||
| 91 | w.Focus = tc.max | 98 | w.Focus = tc.max | ||
| 92 | w.maximize(tc.max) | 99 | w.maximize(tc.max) | ||
| 93 | - | tiles, _, _ := w.previewTiles() | 100 | + | tiles, _, sel := w.previewTiles() |
| 94 | - | g, h := w.gap(), w.Usable.H-2*w.gap() | 101 | + | g, h, s := w.gap(), w.Usable.H-2*w.gap(), w.bandCardStep() |
| 95 | - | for i, p := range tiles { | 102 | + | in := 0 |
| 96 | - | if p.Hidden != tc.hidden[i] { | 103 | + | if slices.ContainsFunc(tc.cards, func(k int) bool { return k != 0 }) { |
| 97 | - | t.Fatalf("n=%d max=%d column %d hidden %v, want %v", tc.n, tc.max, i, p.Hidden, tc.hidden[i]) | 104 | + | in = 2 * s |
| 98 | - | } | 105 | + | } |
| 99 | - | if p.Hidden { | 106 | + | var order []int |
| 100 | - | continue | 107 | + | for _, p := range tiles { |
| 108 | + | i := int(p.ID) - 1 | |||
| 109 | + | if i/3 == 0 && tc.order != nil { | |||
| 110 | + | order = append(order, i) | |||
| 101 | } | 111 | } | ||
| 102 | band := i / 3 | 112 | band := i / 3 | ||
| 103 | - | want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h} | 113 | + | want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h} |
| 104 | - | if i == tc.max { | 114 | + | switch k := tc.cards[i]; { |
| 105 | - | want.X, want.W = g, w.Usable.W-2*g | 115 | + | case i == tc.max: |
| 116 | + | want.X, want.W = g+in, w.Usable.W-2*g-2*in | |||
| 117 | + | case k < 0: | |||
| 118 | + | want.X = g + (2+k)*s | |||
| 119 | + | case k > 0: | |||
| 120 | + | want.X = w.Usable.W - g - (2-k)*s - want.W | |||
| 121 | + | } | |||
| 122 | + | if p.Hidden || p.Peek != (tc.cards[i] != 0) || p.Focused != (i == tc.max) || p.Rect != want { | |||
| 123 | + | t.Fatalf("n=%d max=%d column %d: %+v, want rect %+v card %v", tc.n, tc.max, i, p, want, tc.cards[i] != 0) | |||
| 124 | + | } | |||
| 125 | + | // The 900-wide band split among its columns, no gaps. | |||
| 126 | + | if inBand := min(tc.n-band*3, 3); i != tc.max && p.Rect.W != 900/inBand { | |||
| 127 | + | t.Fatalf("n=%d max=%d column %d width %d, want %d", tc.n, tc.max, i, p.Rect.W, 900/inBand) | |||
| 106 | } | 128 | } | ||
| 107 | - | if p.Rect != want { | 129 | + | if i == tc.max && sel != want { |
| 108 | - | t.Fatalf("n=%d max=%d column %d rect %+v, want %+v", tc.n, tc.max, i, p.Rect, want) | 130 | + | t.Fatalf("n=%d max=%d sel %+v, want %+v", tc.n, tc.max, sel, want) |
| 109 | } | 131 | } | ||
| 110 | } | 132 | } | ||
| 133 | + | if tc.order != nil && !slices.Equal(order, tc.order) { | |||
| 134 | + | t.Fatalf("n=%d max=%d paint order %v, want %v", tc.n, tc.max, order, tc.order) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // Moving onto a card behind a maximized column hands it the maximization; | |||
| 140 | + | // Return keeps it there, Escape gives it back. Moving to another band | |||
| 141 | + | // unmaximizes as before. | |||
| 142 | + | func TestOverviewCascadeMaximizedCardSelection(t *testing.T) { | |||
| 143 | + | for _, d := range cascadeDrivers { | |||
| 144 | + | t.Run(d.name, func(t *testing.T) { | |||
| 145 | + | m := cascadeOverview() | |||
| 146 | + | w := m.Current() | |||
| 147 | + | w.FocusID(9) // band 2 holds 7, 8, 9 | |||
| 148 | + | w.ToggleFullWidth() | |||
| 149 | + | m.ToggleOverview() | |||
| 150 | + | for _, id := range []WindowID{7, 8} { | |||
| 151 | + | if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek { | |||
| 152 | + | t.Fatalf("card %d: %+v", id, p) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | d.move(m, -1, 0) | |||
| 156 | + | if focusedID(m) != 8 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth { | |||
| 157 | + | t.Fatalf("left: focus %d, maximization not moved", focusedID(m)) | |||
| 158 | + | } | |||
| 159 | + | if p := previewOf(t, m.Layout(), 9); !p.Peek { | |||
| 160 | + | t.Fatalf("former maximized column not a card: %+v", p) | |||
| 161 | + | } | |||
| 162 | + | m.CancelOverview() | |||
| 163 | + | if focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(8)].FullWidth { | |||
| 164 | + | t.Fatalf("escape: focus %d, maximization not restored", focusedID(m)) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | m.ToggleOverview() | |||
| 168 | + | d.move(m, -1, 0) | |||
| 169 | + | d.move(m, -1, 0) | |||
| 170 | + | m.ToggleOverview() // return | |||
| 171 | + | if focusedID(m) != 7 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth { | |||
| 172 | + | t.Fatalf("return: focus %d, maximization not on 7", focusedID(m)) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | m.ToggleOverview() | |||
| 176 | + | d.move(m, 0, -1) | |||
| 177 | + | if focusedID(m) != 4 || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth }) { | |||
| 178 | + | t.Fatalf("up: focus %d, a column still maximized", focusedID(m)) | |||
| 179 | + | } | |||
| 180 | + | }) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // A click on a window of a card's column records that window in the | |||
| 185 | + | // maximize history, not the column's former focus. | |||
| 186 | + | func TestOverviewCascadeMaximizedCardPickRecordsWindow(t *testing.T) { | |||
| 187 | + | m := cascadeOverview() | |||
| 188 | + | w := m.Current() | |||
| 189 | + | // Column 8 stacks 8 over 50, 8 focused. | |||
| 190 | + | w.Columns[w.columnOf(8)].Windows = append(w.Columns[w.columnOf(8)].Windows, 50) | |||
| 191 | + | w.FocusID(7) | |||
| 192 | + | w.ToggleFullWidth() | |||
| 193 | + | m.ToggleOverview() | |||
| 194 | + | m.OverviewPick(50) | |||
| 195 | + | if len(w.maximized) == 0 || w.maximized[0] != 50 || focusedID(m) != 50 { | |||
| 196 | + | t.Fatalf("history %v, focus %d; want 50 first", w.maximized, focusedID(m)) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // Only the focused maximized column is inset for cards: a FullWidth column | |||
| 201 | + | // elsewhere fills its band. | |||
| 202 | + | func TestOverviewCascadeUnfocusedMaximizedNotInset(t *testing.T) { | |||
| 203 | + | w := cascadeWorkspace(6) | |||
| 204 | + | w.SetGaps(8) | |||
| 205 | + | w.Focus = 4 | |||
| 206 | + | w.maximize(4) | |||
| 207 | + | w.Columns[0].FullWidth = true | |||
| 208 | + | tiles, _, _ := w.previewTiles() | |||
| 209 | + | g := w.gap() | |||
| 210 | + | p := previewOf(t, tiles, 1) | |||
| 211 | + | if p.Hidden || p.Rect.X != g || p.Rect.W != w.Usable.W-2*g { | |||
| 212 | + | t.Fatalf("unfocused maximized column %+v, want X %d W %d", p, g, w.Usable.W-2*g) | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // A click on a card behind a maximized cascade column hands it the | |||
| 217 | + | // maximization. | |||
| 218 | + | func TestOverviewCascadeMaximizedCardPick(t *testing.T) { | |||
| 219 | + | m := cascadeOverview() | |||
| 220 | + | w := m.Current() | |||
| 221 | + | w.FocusID(7) | |||
| 222 | + | w.ToggleFullWidth() | |||
| 223 | + | m.ToggleOverview() | |||
| 224 | + | // Each card's strip right of the maximized column hits it: 8 next to | |||
| 225 | + | // it, 9 at the band's edge. | |||
| 226 | + | ps := m.Layout() | |||
| 227 | + | front, near, far := previewOf(t, ps, 7).Rect, previewOf(t, ps, 8).Rect, previewOf(t, ps, 9).Rect | |||
| 228 | + | for _, c := range []struct { | |||
| 229 | + | x0, x1 int | |||
| 230 | + | want WindowID | |||
| 231 | + | }{{front.X + front.W, near.X + near.W, 8}, {near.X + near.W, far.X + far.W, 9}} { | |||
| 232 | + | x := (c.x0 + c.x1) / 2 | |||
| 233 | + | if c.x0 >= c.x1 { | |||
| 234 | + | t.Fatalf("no strip for %d: front %v near %v far %v", c.want, front, near, far) | |||
| 235 | + | } | |||
| 236 | + | if got := overviewIn(ps, float64(x), float64(centreY(far))); got != c.want { | |||
| 237 | + | t.Fatalf("strip at x=%d hits %d, want %d", x, got, c.want) | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | m.OverviewPick(9) | |||
| 241 | + | if m.ov.open || focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(7)].FullWidth { | |||
| 242 | + | t.Fatalf("pick: open %v, focus %d, maximization not moved", m.ov.open, focusedID(m)) | |||
| 111 | } | 243 | } | ||
| 112 | } | 244 | } | ||
| 113 | 245 | ||||
| @@ -503,6 +635,139 @@ func TestOverviewCascadeThreeFinger(t *testing.T) { | |||||
| 503 | } | 635 | } | ||
| 504 | } | 636 | } | ||
| 505 | 637 | ||||
| 638 | + | // Three fingers sideways walk the cards behind a maximized column one per | |||
| 639 | + | // gesture, like the arrows, then change workspace past the band's edge. | |||
| 640 | + | func TestOverviewCascadeThreeFingerMaximizedCards(t *testing.T) { | |||
| 641 | + | c, m := cascadeCore(t) | |||
| 642 | + | w := m.Current() | |||
| 643 | + | w.ToggleFullWidth() // Column 2 hides 3, the other column of its band. | |||
| 644 | + | m.ToggleOverview() | |||
| 645 | + | swipe := func(dx float64) { | |||
| 646 | + | t.Helper() | |||
| 647 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 648 | + | for range 4 { | |||
| 649 | + | c.swipeUpdate(ports.SwipeUpdate{DX: dx}) | |||
| 650 | + | } | |||
| 651 | + | c.swipeEnd(ports.SwipeEnd{}) | |||
| 652 | + | } | |||
| 653 | + | swipe(40) | |||
| 654 | + | if m.Current() != w || m.overviewTarget() != 3 || !w.Columns[w.Focus].FullWidth { | |||
| 655 | + | t.Fatalf("swipe right: workspace %d, target %d, want maximized 3", indexOf(m.Workspaces, m.Current()), m.overviewTarget()) | |||
| 656 | + | } | |||
| 657 | + | swipe(-40) | |||
| 658 | + | if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth { | |||
| 659 | + | t.Fatalf("swipe left: workspace %d, target %d, want maximized 2", indexOf(m.Workspaces, m.Current()), m.overviewTarget()) | |||
| 660 | + | } | |||
| 661 | + | swipe(40) | |||
| 662 | + | swipe(40) | |||
| 663 | + | if m.Current() != m.Workspaces[2] { | |||
| 664 | + | t.Fatalf("swipe right past the band: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 665 | + | } | |||
| 666 | + | ||||
| 667 | + | // A covering float in front: the swipe changes workspace, as without | |||
| 668 | + | // a maximized column. | |||
| 669 | + | m.CancelOverview() | |||
| 670 | + | w.AddFloating(90, 300, 200) | |||
| 671 | + | m.ToggleOverview() | |||
| 672 | + | if f := m.stackFront(w); f.kind != stackFloat || !w.Columns[w.Focus].FullWidth { | |||
| 673 | + | t.Fatalf("setup: front %+v", f) | |||
| 674 | + | } | |||
| 675 | + | swipe(-40) | |||
| 676 | + | if m.Current() != m.Workspaces[0] { | |||
| 677 | + | t.Fatalf("swipe left over a float: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 678 | + | } | |||
| 679 | + | } | |||
| 680 | + | ||||
| 681 | + | // Left from the first column of a band whose maximized column hides it: | |||
| 682 | + | // the arrows and three fingers walk the cards, then enter the stash, or | |||
| 683 | + | // change workspace without one. | |||
| 684 | + | func TestOverviewCascadeMaximizedLeftEdge(t *testing.T) { | |||
| 685 | + | for _, stash := range []bool{false, true} { | |||
| 686 | + | c, m := cascadeCore(t) | |||
| 687 | + | w := m.Current() | |||
| 688 | + | if stash { | |||
| 689 | + | w.AddWindow(40) | |||
| 690 | + | w.FocusID(40) | |||
| 691 | + | w.ToggleWindowStash() | |||
| 692 | + | } | |||
| 693 | + | w.FocusID(3) // Band {2, 3}: 3 hides 2. | |||
| 694 | + | w.ToggleFullWidth() | |||
| 695 | + | m.ToggleOverview() | |||
| 696 | + | swipe := func() { | |||
| 697 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 698 | + | for range 4 { | |||
| 699 | + | c.swipeUpdate(ports.SwipeUpdate{DX: -40}) | |||
| 700 | + | } | |||
| 701 | + | c.swipeEnd(ports.SwipeEnd{}) | |||
| 702 | + | } | |||
| 703 | + | swipe() | |||
| 704 | + | if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth { | |||
| 705 | + | t.Fatalf("stash %v: first swipe: target %d", stash, m.overviewTarget()) | |||
| 706 | + | } | |||
| 707 | + | swipe() | |||
| 708 | + | if stash { | |||
| 709 | + | if m.Current() != w || m.cardAt(w) < 0 { | |||
| 710 | + | t.Fatalf("past the band: workspace %d, card %d; want the stash", indexOf(m.Workspaces, m.Current()), m.cardAt(w)) | |||
| 711 | + | } | |||
| 712 | + | } else if m.Current() != m.Workspaces[0] { | |||
| 713 | + | t.Fatalf("past the band: workspace %d, want 0", indexOf(m.Workspaces, m.Current())) | |||
| 714 | + | } | |||
| 715 | + | } | |||
| 716 | + | } | |||
| 717 | + | ||||
| 718 | + | // With four or more columns in a band, the cards three or more columns | |||
| 719 | + | // away sit under the two-step card: arrows still reach every one, in | |||
| 720 | + | // order, each taking the maximization. | |||
| 721 | + | func TestOverviewCascadeFarCardsByArrows(t *testing.T) { | |||
| 722 | + | m := newMonitor("", "") | |||
| 723 | + | m.SetOutput(1200, 600) | |||
| 724 | + | m.SetMaxColumns(5) | |||
| 725 | + | w := m.Current() | |||
| 726 | + | w.Overflow = OverflowCascade | |||
| 727 | + | for id := WindowID(1); id <= 5; id++ { | |||
| 728 | + | m.AddWindow(id) | |||
| 729 | + | } | |||
| 730 | + | w.FocusID(1) | |||
| 731 | + | w.ToggleFullWidth() | |||
| 732 | + | m.ToggleOverview() | |||
| 733 | + | ps := m.Layout() | |||
| 734 | + | edge := previewOf(t, ps, 3).Rect | |||
| 735 | + | for _, id := range []WindowID{4, 5} { | |||
| 736 | + | if p := previewOf(t, ps, id); p.Hidden || !p.Peek || p.Rect.X != edge.X { | |||
| 737 | + | t.Fatalf("far card %d: %+v, want a card under 3 at x %d", id, p, edge.X) | |||
| 738 | + | } | |||
| 739 | + | } | |||
| 740 | + | for _, want := range []WindowID{2, 3, 4, 5} { | |||
| 741 | + | m.OverviewMove(1, 0) | |||
| 742 | + | if m.Current() != w || m.overviewTarget() != want || !w.Columns[w.Focus].FullWidth { | |||
| 743 | + | t.Fatalf("right: target %d, want maximized %d", m.overviewTarget(), want) | |||
| 744 | + | } | |||
| 745 | + | } | |||
| 746 | + | m.CancelOverview() | |||
| 747 | + | if focusedID(m) != 1 || !w.Columns[w.Focus].FullWidth { | |||
| 748 | + | t.Fatalf("escape: focus %d, want maximized 1", focusedID(m)) | |||
| 749 | + | } | |||
| 750 | + | } | |||
| 751 | + | ||||
| 752 | + | // Right from a stash card lands on the maximized column, which keeps its | |||
| 753 | + | // maximization, not on the first card of its band. | |||
| 754 | + | func TestOverviewCascadeStashToMaximized(t *testing.T) { | |||
| 755 | + | m := cascadeOverview() | |||
| 756 | + | withStash(m, 1) | |||
| 757 | + | w := m.Current() | |||
| 758 | + | w.FocusID(9) | |||
| 759 | + | w.ToggleFullWidth() | |||
| 760 | + | m.ToggleOverview() | |||
| 761 | + | m.selectCard(w, w.stashAt) // As a click on the pile does. | |||
| 762 | + | if m.cardAt(w) < 0 { | |||
| 763 | + | t.Fatalf("setup: no stash card, target %d", m.overviewTarget()) | |||
| 764 | + | } | |||
| 765 | + | m.OverviewMove(1, 0) | |||
| 766 | + | if m.overviewTarget() != 9 || !w.Columns[w.Focus].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 9 { | |||
| 767 | + | t.Fatalf("target %d, maximized %v; want 9", m.overviewTarget(), w.maximized) | |||
| 768 | + | } | |||
| 769 | + | } | |||
| 770 | + | ||||
| 506 | // T8: a named boundary of a cascade overview is a vertical rule strictly | 771 | // T8: a named boundary of a cascade overview is a vertical rule strictly | ||
| 507 | // between the two slots. | 772 | // between the two slots. | ||
| 508 | func TestOverviewCascadeNamedDivider(t *testing.T) { | 773 | func TestOverviewCascadeNamedDivider(t *testing.T) { | ||
M internal/core/overview_stack.go +5 -1
| @@ -214,7 +214,11 @@ func (m *Monitor) hiddenColumn(w *Workspace) int { | |||
| 214 | 214 | // selectOverviewColumn retains the existing ordinary-row live focus semantics. | |
| 215 | 215 | func (w *Workspace) selectOverviewColumn(i int) { | |
| 216 | 216 | w.floatFocus, w.stashFocus = false, false | |
| 217 | - | if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) { | |
| 217 | + | if w.policy().wraps && w.hiddenByMaximized(i) { | |
| 218 | + | // A card behind a maximized cascade column takes the | |
| 219 | + | // maximization; Escape gives it back (restoreRow). | |
| 220 | + | w.transferMaximization(i) | |
| 221 | + | } else if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) { | |
| 218 | 222 | w.unmaximize() | |
| 219 | 223 | } | |
| 220 | 224 | w.Focus = i | |
| @@ -214,7 +214,11 @@ func (m *Monitor) hiddenColumn(w *Workspace) int { | |||||
| 214 | // selectOverviewColumn retains the existing ordinary-row live focus semantics. | 214 | // selectOverviewColumn retains the existing ordinary-row live focus semantics. | ||
| 215 | func (w *Workspace) selectOverviewColumn(i int) { | 215 | func (w *Workspace) selectOverviewColumn(i int) { | ||
| 216 | w.floatFocus, w.stashFocus = false, false | 216 | w.floatFocus, w.stashFocus = false, false | ||
| 217 | - | if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) { | 217 | + | if w.policy().wraps && w.hiddenByMaximized(i) { |
| 218 | + | // A card behind a maximized cascade column takes the | |||
| 219 | + | // maximization; Escape gives it back (restoreRow). | |||
| 220 | + | w.transferMaximization(i) | |||
| 221 | + | } else if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) { | |||
| 218 | w.unmaximize() | 222 | w.unmaximize() | ||
| 219 | } | 223 | } | ||
| 220 | w.Focus = i | 224 | w.Focus = i | ||
M internal/core/pulse.go +4 -4
| @@ -15,7 +15,7 @@ import ( | |||
| 15 | 15 | // the window that pulsed last does not pulse again within pulseCooldown | |
| 16 | 16 | // (switching back after another window held the focus is a new change). | |
| 17 | 17 | // Fast switching therefore shows nothing. Fullscreen windows, a window alone | |
| 18 | - | // on screen (e.g. a maximized column), overview previews, a window focused as | |
| 18 | + | // in the usable area (e.g. a maximized column), overview previews, a window focused as | |
| 19 | 19 | // it maps and a protected session never pulse. | |
| 20 | 20 | ||
| 21 | 21 | const ( | |
| @@ -151,11 +151,11 @@ func (m *Monitor) settled() bool { | |||
| 151 | 151 | return true | |
| 152 | 152 | } | |
| 153 | 153 | ||
| 154 | - | // visibleCount is the number of windows of layout shown in frame. | |
| 155 | - | func visibleCount(layout []Placement, frame Rect) int { | |
| 154 | + | // visibleCount is the number of windows of layout shown in area. | |
| 155 | + | func visibleCount(layout []Placement, area Rect) int { | |
| 156 | 156 | n := 0 | |
| 157 | 157 | for _, p := range layout { | |
| 158 | - | if p.Preview == 0 && onScreen(p, frame) { | |
| 158 | + | if p.Preview == 0 && onScreen(p, area) { | |
| 159 | 159 | n++ | |
| 160 | 160 | } | |
| 161 | 161 | } | |
| @@ -15,7 +15,7 @@ import ( | |||||
| 15 | // the window that pulsed last does not pulse again within pulseCooldown | 15 | // the window that pulsed last does not pulse again within pulseCooldown | ||
| 16 | // (switching back after another window held the focus is a new change). | 16 | // (switching back after another window held the focus is a new change). | ||
| 17 | // Fast switching therefore shows nothing. Fullscreen windows, a window alone | 17 | // Fast switching therefore shows nothing. Fullscreen windows, a window alone | ||
| 18 | - | // on screen (e.g. a maximized column), overview previews, a window focused as | 18 | + | // in the usable area (e.g. a maximized column), overview previews, a window focused as |
| 19 | // it maps and a protected session never pulse. | 19 | // it maps and a protected session never pulse. | ||
| 20 | 20 | ||||
| 21 | const ( | 21 | const ( | ||
| @@ -151,11 +151,11 @@ func (m *Monitor) settled() bool { | |||||
| 151 | return true | 151 | return true | ||
| 152 | } | 152 | } | ||
| 153 | 153 | ||||
| 154 | - | // visibleCount is the number of windows of layout shown in frame. | 154 | + | // visibleCount is the number of windows of layout shown in area. |
| 155 | - | func visibleCount(layout []Placement, frame Rect) int { | 155 | + | func visibleCount(layout []Placement, area Rect) int { |
| 156 | n := 0 | 156 | n := 0 | ||
| 157 | for _, p := range layout { | 157 | for _, p := range layout { | ||
| 158 | - | if p.Preview == 0 && onScreen(p, frame) { | 158 | + | if p.Preview == 0 && onScreen(p, area) { |
| 159 | n++ | 159 | n++ | ||
| 160 | } | 160 | } | ||
| 161 | } | 161 | } | ||
M internal/core/pulse_test.go +16 -0
| @@ -185,6 +185,22 @@ func TestFocusPulseSkipsAloneOnScreen(t *testing.T) { | |||
| 185 | 185 | } | |
| 186 | 186 | } | |
| 187 | 187 | ||
| 188 | + | // A cascade window alone in its band above a bottom panel is alone on | |
| 189 | + | // screen: the next band, under the panel, does not count. | |
| 190 | + | func TestFocusPulseSkipsAloneAbovePanel(t *testing.T) { | |
| 191 | + | c, ic := pulseCore(t) | |
| 192 | + | m := c.cur().mon | |
| 193 | + | m.SetOverflow(OverflowCascade) | |
| 194 | + | m.SetMaxColumns(1) | |
| 195 | + | m.SetUsable(Rect{W: 300, H: 180}) | |
| 196 | + | m.Current().FocusID(1) | |
| 197 | + | indicatorScene(t, c) | |
| 198 | + | ic.now = ic.now.Add(pulseSettle) | |
| 199 | + | if c.pulseTick() || c.animating() { | |
| 200 | + | t.Fatal("a window alone in its band above a panel pulses") | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 188 | 204 | // A swipe, its landing slide or a transition still shows the neighbours: the | |
| 189 | 205 | // pulse waits for the layout to settle, then decides on it. | |
| 190 | 206 | func TestFocusPulseWaitsForSlide(t *testing.T) { | |
| @@ -185,6 +185,22 @@ func TestFocusPulseSkipsAloneOnScreen(t *testing.T) { | |||||
| 185 | } | 185 | } | ||
| 186 | } | 186 | } | ||
| 187 | 187 | ||||
| 188 | + | // A cascade window alone in its band above a bottom panel is alone on | |||
| 189 | + | // screen: the next band, under the panel, does not count. | |||
| 190 | + | func TestFocusPulseSkipsAloneAbovePanel(t *testing.T) { | |||
| 191 | + | c, ic := pulseCore(t) | |||
| 192 | + | m := c.cur().mon | |||
| 193 | + | m.SetOverflow(OverflowCascade) | |||
| 194 | + | m.SetMaxColumns(1) | |||
| 195 | + | m.SetUsable(Rect{W: 300, H: 180}) | |||
| 196 | + | m.Current().FocusID(1) | |||
| 197 | + | indicatorScene(t, c) | |||
| 198 | + | ic.now = ic.now.Add(pulseSettle) | |||
| 199 | + | if c.pulseTick() || c.animating() { | |||
| 200 | + | t.Fatal("a window alone in its band above a panel pulses") | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 188 | // A swipe, its landing slide or a transition still shows the neighbours: the | 204 | // A swipe, its landing slide or a transition still shows the neighbours: the | ||
| 189 | // pulse waits for the layout to settle, then decides on it. | 205 | // pulse waits for the layout to settle, then decides on it. | ||
| 190 | func TestFocusPulseWaitsForSlide(t *testing.T) { | 206 | func TestFocusPulseWaitsForSlide(t *testing.T) { | ||
M internal/core/separators.go +8 -5
| @@ -8,10 +8,11 @@ import "github.com/bnema/neferwl/internal/ports" | |||
| 8 | 8 | // sides of the focused tile, including corners. With exactly two visible | |
| 9 | 9 | // tiles and no gaps, each lights its own half of the shared line (left/top | |
| 10 | 10 | // first). With gaps, each tile owns its neighbor-facing lines and lights | |
| 11 | - | // them whole. Hidden, scrolled-off, other-workspace and other-output tiles | |
| 12 | - | // do not count. Lines at output edges are omitted. Floating windows differ | |
| 13 | - | // from tmux tiles: each gets its own full border, drawn with that window | |
| 14 | - | // (Separator.Window), even when it is the only visible window. Only the | |
| 11 | + | // them whole. Hidden, scrolled-off, under-panel, other-workspace and | |
| 12 | + | // other-output tiles do not count, nor do tiles that share no line. Lines | |
| 13 | + | // at the edges of the usable area (output or panels) are omitted. Floating windows differ from tmux tiles: | |
| 14 | + | // each gets its own full border, drawn with that window (Separator.Window), | |
| 15 | + | // even when it is the only visible window. Only the | |
| 15 | 16 | // focused output's focused window can light lines; keyboard grabs by popups | |
| 16 | 17 | // and layers do not change the underlying window's visual focus. | |
| 17 | 18 | ||
| @@ -54,7 +55,9 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa | |||
| 54 | 55 | if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) { | |
| 55 | 56 | continue | |
| 56 | 57 | } | |
| 57 | - | if !p.Floating { | |
| 58 | + | // Tiles sharing a line: Neighbors only counts tiles visible in | |
| 59 | + | // the usable area, not those under a panel. | |
| 60 | + | if !p.Floating && p.Neighbors != 0 { | |
| 58 | 61 | tiles++ | |
| 59 | 62 | } | |
| 60 | 63 | for _, s := range sides { | |
| @@ -8,10 +8,11 @@ import "github.com/bnema/neferwl/internal/ports" | |||||
| 8 | // sides of the focused tile, including corners. With exactly two visible | 8 | // sides of the focused tile, including corners. With exactly two visible | ||
| 9 | // tiles and no gaps, each lights its own half of the shared line (left/top | 9 | // tiles and no gaps, each lights its own half of the shared line (left/top | ||
| 10 | // first). With gaps, each tile owns its neighbor-facing lines and lights | 10 | // first). With gaps, each tile owns its neighbor-facing lines and lights | ||
| 11 | - | // them whole. Hidden, scrolled-off, other-workspace and other-output tiles | 11 | + | // them whole. Hidden, scrolled-off, under-panel, other-workspace and |
| 12 | - | // do not count. Lines at output edges are omitted. Floating windows differ | 12 | + | // other-output tiles do not count, nor do tiles that share no line. Lines |
| 13 | - | // from tmux tiles: each gets its own full border, drawn with that window | 13 | + | // at the edges of the usable area (output or panels) are omitted. Floating windows differ from tmux tiles: |
| 14 | - | // (Separator.Window), even when it is the only visible window. Only the | 14 | + | // each gets its own full border, drawn with that window (Separator.Window), |
| 15 | + | // even when it is the only visible window. Only the | |||
| 15 | // focused output's focused window can light lines; keyboard grabs by popups | 16 | // focused output's focused window can light lines; keyboard grabs by popups | ||
| 16 | // and layers do not change the underlying window's visual focus. | 17 | // and layers do not change the underlying window's visual focus. | ||
| 17 | 18 | ||||
| @@ -54,7 +55,9 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa | |||||
| 54 | if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) { | 55 | if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) { | ||
| 55 | continue | 56 | continue | ||
| 56 | } | 57 | } | ||
| 57 | - | if !p.Floating { | 58 | + | // Tiles sharing a line: Neighbors only counts tiles visible in |
| 59 | + | // the usable area, not those under a panel. | |||
| 60 | + | if !p.Floating && p.Neighbors != 0 { | |||
| 58 | tiles++ | 61 | tiles++ | ||
| 59 | } | 62 | } | ||
| 60 | for _, s := range sides { | 63 | for _, s := range sides { | ||
M internal/core/separators_test.go +49 -0
| @@ -147,6 +147,55 @@ func TestSeparatorsScroll(t *testing.T) { | |||
| 147 | 147 | } | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | + | // A scroll column scrolled under a side panel is no neighbor: the column | |
| 151 | + | // next to it in the usable area gets no line on that side. | |
| 152 | + | // With a gap, the edge the two share lies inside the usable area: only | |
| 153 | + | // the tile under the panel being out of it keeps the line away. | |
| 154 | + | func TestSeparatorsScrollUnderSidePanel(t *testing.T) { | |
| 155 | + | for _, gap := range []int{0, 8} { | |
| 156 | + | ps := []Placement{ | |
| 157 | + | {ID: 1, Rect: Rect{X: 0, W: 100, H: 600}}, | |
| 158 | + | {ID: 2, Rect: Rect{X: 100 + gap, W: 400, H: 600}, Focused: true}, | |
| 159 | + | {ID: 3, Rect: Rect{X: 500 + 2*gap, W: 380, H: 600}}, | |
| 160 | + | } | |
| 161 | + | setVisibleNeighbors(ps, gap, Rect{X: 100, W: 900, H: 600}) | |
| 162 | + | if ps[0].Neighbors != 0 { | |
| 163 | + | t.Fatalf("gap %d: tile under the panel: neighbors %04b, want none", gap, ps[0].Neighbors) | |
| 164 | + | } | |
| 165 | + | if ps[1].Neighbors != ports.SideRight { | |
| 166 | + | t.Fatalf("gap %d: tile at the panel: neighbors %04b, want right only", gap, ps[1].Neighbors) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // A cascade band above a bottom panel: the next band starts under the | |
| 172 | + | // panel, so the focused tile gets no line along the panel, only the one it | |
| 173 | + | // shares with its band neighbor. | |
| 174 | + | func TestSeparatorsCascadeBottomPanel(t *testing.T) { | |
| 175 | + | w := &Workspace{Overflow: OverflowCascade, MaxColumns: 2} | |
| 176 | + | w.SetOutput(900, 600) | |
| 177 | + | w.SetUsable(Rect{W: 900, H: 580}) | |
| 178 | + | for id := WindowID(1); id <= 3; id++ { | |
| 179 | + | w.AddWindow(id) | |
| 180 | + | } | |
| 181 | + | w.FocusID(1) | |
| 182 | + | ps := w.Layout() | |
| 183 | + | if p := previewOf(t, ps, 1); p.Neighbors != ports.SideRight { | |
| 184 | + | t.Fatalf("neighbors %04b, want right only", p.Neighbors) | |
| 185 | + | } | |
| 186 | + | seps := separators(ps, 2, 0, w.Output, true) | |
| 187 | + | if got := colorAt(seps, 200, 578); got != "" { | |
| 188 | + | t.Fatalf("line along the panel: %v", seps) | |
| 189 | + | } | |
| 190 | + | // Two tiles on screen: each lights its half of the shared line. | |
| 191 | + | if got := colorAt(seps, 449, 100); got != "lit" { | |
| 192 | + | t.Fatalf("shared line (449,100) = %q; %v", got, seps) | |
| 193 | + | } | |
| 194 | + | if got := colorAt(seps, 449, 400); got != "gray" { | |
| 195 | + | t.Fatalf("shared line (449,400) = %q; %v", got, seps) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 150 | 199 | // With gaps each tile has its own line: the focused one lights it whole. | |
| 151 | 200 | func TestSeparatorsGaps(t *testing.T) { | |
| 152 | 201 | ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}} | |
| @@ -147,6 +147,55 @@ func TestSeparatorsScroll(t *testing.T) { | |||||
| 147 | } | 147 | } | ||
| 148 | } | 148 | } | ||
| 149 | 149 | ||||
| 150 | + | // A scroll column scrolled under a side panel is no neighbor: the column | |||
| 151 | + | // next to it in the usable area gets no line on that side. | |||
| 152 | + | // With a gap, the edge the two share lies inside the usable area: only | |||
| 153 | + | // the tile under the panel being out of it keeps the line away. | |||
| 154 | + | func TestSeparatorsScrollUnderSidePanel(t *testing.T) { | |||
| 155 | + | for _, gap := range []int{0, 8} { | |||
| 156 | + | ps := []Placement{ | |||
| 157 | + | {ID: 1, Rect: Rect{X: 0, W: 100, H: 600}}, | |||
| 158 | + | {ID: 2, Rect: Rect{X: 100 + gap, W: 400, H: 600}, Focused: true}, | |||
| 159 | + | {ID: 3, Rect: Rect{X: 500 + 2*gap, W: 380, H: 600}}, | |||
| 160 | + | } | |||
| 161 | + | setVisibleNeighbors(ps, gap, Rect{X: 100, W: 900, H: 600}) | |||
| 162 | + | if ps[0].Neighbors != 0 { | |||
| 163 | + | t.Fatalf("gap %d: tile under the panel: neighbors %04b, want none", gap, ps[0].Neighbors) | |||
| 164 | + | } | |||
| 165 | + | if ps[1].Neighbors != ports.SideRight { | |||
| 166 | + | t.Fatalf("gap %d: tile at the panel: neighbors %04b, want right only", gap, ps[1].Neighbors) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // A cascade band above a bottom panel: the next band starts under the | |||
| 172 | + | // panel, so the focused tile gets no line along the panel, only the one it | |||
| 173 | + | // shares with its band neighbor. | |||
| 174 | + | func TestSeparatorsCascadeBottomPanel(t *testing.T) { | |||
| 175 | + | w := &Workspace{Overflow: OverflowCascade, MaxColumns: 2} | |||
| 176 | + | w.SetOutput(900, 600) | |||
| 177 | + | w.SetUsable(Rect{W: 900, H: 580}) | |||
| 178 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 179 | + | w.AddWindow(id) | |||
| 180 | + | } | |||
| 181 | + | w.FocusID(1) | |||
| 182 | + | ps := w.Layout() | |||
| 183 | + | if p := previewOf(t, ps, 1); p.Neighbors != ports.SideRight { | |||
| 184 | + | t.Fatalf("neighbors %04b, want right only", p.Neighbors) | |||
| 185 | + | } | |||
| 186 | + | seps := separators(ps, 2, 0, w.Output, true) | |||
| 187 | + | if got := colorAt(seps, 200, 578); got != "" { | |||
| 188 | + | t.Fatalf("line along the panel: %v", seps) | |||
| 189 | + | } | |||
| 190 | + | // Two tiles on screen: each lights its half of the shared line. | |||
| 191 | + | if got := colorAt(seps, 449, 100); got != "lit" { | |||
| 192 | + | t.Fatalf("shared line (449,100) = %q; %v", got, seps) | |||
| 193 | + | } | |||
| 194 | + | if got := colorAt(seps, 449, 400); got != "gray" { | |||
| 195 | + | t.Fatalf("shared line (449,400) = %q; %v", got, seps) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 150 | // With gaps each tile has its own line: the focused one lights it whole. | 199 | // With gaps each tile has its own line: the focused one lights it whole. | ||
| 151 | func TestSeparatorsGaps(t *testing.T) { | 200 | func TestSeparatorsGaps(t *testing.T) { | ||
| 152 | ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}} | 201 | ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}} | ||