feat(core): add lane-scoped column width spans
- commit
- 4e22582251f08f2c232bb5d35baa1088b20dd40e
- author
- brice · · 2026-10-21 05:24 UTC
- committer
- brice · · 2026-10-21 07:11 UTC
- parent
- d458689
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +205 -0
M internal/core/cascade.go +11 -0
| @@ -143,6 +143,17 @@ func (w *Workspace) laneWeight(i, S, u int) int { | |||
| 143 | 143 | // stats of laneStats: the usable width after gaps, split by weight. The | |
| 144 | 144 | // remainder under one pixel per weight stays empty, as in equalShare. | |
| 145 | 145 | func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int { | |
| 146 | + | if w.validSpans(first, last) { | |
| 147 | + | avail := max(w.Usable.W-w.gap()*(last-first+2), 0) | |
| 148 | + | if i == last { | |
| 149 | + | used := 0 | |
| 150 | + | for j := first; j < last; j++ { | |
| 151 | + | used += avail * w.Columns[j].Span / 10000 | |
| 152 | + | } | |
| 153 | + | return avail - used | |
| 154 | + | } | |
| 155 | + | return avail * w.Columns[i].Span / 10000 | |
| 156 | + | } | |
| 146 | 157 | if sum <= 0 { | |
| 147 | 158 | return 0 | |
| 148 | 159 | } | |
| @@ -143,6 +143,17 @@ func (w *Workspace) laneWeight(i, S, u int) int { | |||||
| 143 | // stats of laneStats: the usable width after gaps, split by weight. The | 143 | // stats of laneStats: the usable width after gaps, split by weight. The | ||
| 144 | // remainder under one pixel per weight stays empty, as in equalShare. | 144 | // remainder under one pixel per weight stays empty, as in equalShare. | ||
| 145 | func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int { | 145 | func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int { | ||
| 146 | + | if w.validSpans(first, last) { | |||
| 147 | + | avail := max(w.Usable.W-w.gap()*(last-first+2), 0) | |||
| 148 | + | if i == last { | |||
| 149 | + | used := 0 | |||
| 150 | + | for j := first; j < last; j++ { | |||
| 151 | + | used += avail * w.Columns[j].Span / 10000 | |||
| 152 | + | } | |||
| 153 | + | return avail - used | |||
| 154 | + | } | |||
| 155 | + | return avail * w.Columns[i].Span / 10000 | |||
| 156 | + | } | |||
| 146 | if sum <= 0 { | 157 | if sum <= 0 { | ||
| 147 | return 0 | 158 | return 0 | ||
| 148 | } | 159 | } | ||
M internal/core/layout.go +10 -0
| @@ -19,6 +19,9 @@ const maxCells = 16 | |||
| 19 | 19 | // max-columns-th of the usable width. Zero (auto) shares the width equally | |
| 20 | 20 | // with the other auto columns, up to MaxColumns visible at once. | |
| 21 | 21 | type Column struct { | |
| 22 | + | // Span is a manually sized width in basis points; zero uses Cells. | |
| 23 | + | Span int | |
| 24 | + | spanLane int | |
| 22 | 25 | Windows []WindowID | |
| 23 | 26 | Cells int | |
| 24 | 27 | FullWidth bool | |
| @@ -534,6 +537,7 @@ func (w *Workspace) addWindow(id WindowID, cells int) { | |||
| 534 | 537 | at = min(w.Focus+1, len(w.Columns)) | |
| 535 | 538 | } | |
| 536 | 539 | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells}) | |
| 540 | + | w.dropStaleSpans() | |
| 537 | 541 | return | |
| 538 | 542 | } | |
| 539 | 543 | w.addColumn(Column{Windows: []WindowID{id}, Cells: cells}) | |
| @@ -1051,6 +1055,7 @@ func (w *Workspace) GrowColumn(cycle bool) { | |||
| 1051 | 1055 | return | |
| 1052 | 1056 | } | |
| 1053 | 1057 | defer w.scroll() | |
| 1058 | + | w.clearSpans(w.Focus) | |
| 1054 | 1059 | if w.policy().wraps && w.Columns[w.Focus].FullWidth { | |
| 1055 | 1060 | w.unmaximizeBand() | |
| 1056 | 1061 | } | |
| @@ -1120,6 +1125,7 @@ func (w *Workspace) ShrinkColumn() { | |||
| 1120 | 1125 | return | |
| 1121 | 1126 | } | |
| 1122 | 1127 | defer w.scroll() | |
| 1128 | + | w.clearSpans(w.Focus) | |
| 1123 | 1129 | if w.policy().wraps && w.Columns[w.Focus].FullWidth { | |
| 1124 | 1130 | w.unmaximizeBand() | |
| 1125 | 1131 | } | |
| @@ -1667,6 +1673,9 @@ func (w *Workspace) scrollWidth(i int) int { | |||
| 1667 | 1673 | // Fixed overflow past MaxColumns: the spiral's slots. | |
| 1668 | 1674 | return w.equalShare(k) | |
| 1669 | 1675 | } | |
| 1676 | + | if w.validSpans(0, len(w.Columns)-1) { | |
| 1677 | + | return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000 | |
| 1678 | + | } | |
| 1670 | 1679 | c := w.Columns[i].Cells | |
| 1671 | 1680 | if c <= 0 { | |
| 1672 | 1681 | return w.equalShare(min(len(w.Columns), k)) | |
| @@ -1682,6 +1691,7 @@ func (w *Workspace) columnX(i int) int { | |||
| 1682 | 1691 | return x | |
| 1683 | 1692 | } | |
| 1684 | 1693 | func (w *Workspace) scroll() { | |
| 1694 | + | w.dropStaleSpans() | |
| 1685 | 1695 | before := w.View | |
| 1686 | 1696 | defer func() { w.retarget(before) }() | |
| 1687 | 1697 | if w.policy().wraps { | |
| @@ -19,6 +19,9 @@ const maxCells = 16 | |||||
| 19 | // max-columns-th of the usable width. Zero (auto) shares the width equally | 19 | // max-columns-th of the usable width. Zero (auto) shares the width equally | ||
| 20 | // with the other auto columns, up to MaxColumns visible at once. | 20 | // with the other auto columns, up to MaxColumns visible at once. | ||
| 21 | type Column struct { | 21 | type Column struct { | ||
| 22 | + | // Span is a manually sized width in basis points; zero uses Cells. | |||
| 23 | + | Span int | |||
| 24 | + | spanLane int | |||
| 22 | Windows []WindowID | 25 | Windows []WindowID | ||
| 23 | Cells int | 26 | Cells int | ||
| 24 | FullWidth bool | 27 | FullWidth bool | ||
| @@ -534,6 +537,7 @@ func (w *Workspace) addWindow(id WindowID, cells int) { | |||||
| 534 | at = min(w.Focus+1, len(w.Columns)) | 537 | at = min(w.Focus+1, len(w.Columns)) | ||
| 535 | } | 538 | } | ||
| 536 | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells}) | 539 | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells}) | ||
| 540 | + | w.dropStaleSpans() | |||
| 537 | return | 541 | return | ||
| 538 | } | 542 | } | ||
| 539 | w.addColumn(Column{Windows: []WindowID{id}, Cells: cells}) | 543 | w.addColumn(Column{Windows: []WindowID{id}, Cells: cells}) | ||
| @@ -1051,6 +1055,7 @@ func (w *Workspace) GrowColumn(cycle bool) { | |||||
| 1051 | return | 1055 | return | ||
| 1052 | } | 1056 | } | ||
| 1053 | defer w.scroll() | 1057 | defer w.scroll() | ||
| 1058 | + | w.clearSpans(w.Focus) | |||
| 1054 | if w.policy().wraps && w.Columns[w.Focus].FullWidth { | 1059 | if w.policy().wraps && w.Columns[w.Focus].FullWidth { | ||
| 1055 | w.unmaximizeBand() | 1060 | w.unmaximizeBand() | ||
| 1056 | } | 1061 | } | ||
| @@ -1120,6 +1125,7 @@ func (w *Workspace) ShrinkColumn() { | |||||
| 1120 | return | 1125 | return | ||
| 1121 | } | 1126 | } | ||
| 1122 | defer w.scroll() | 1127 | defer w.scroll() | ||
| 1128 | + | w.clearSpans(w.Focus) | |||
| 1123 | if w.policy().wraps && w.Columns[w.Focus].FullWidth { | 1129 | if w.policy().wraps && w.Columns[w.Focus].FullWidth { | ||
| 1124 | w.unmaximizeBand() | 1130 | w.unmaximizeBand() | ||
| 1125 | } | 1131 | } | ||
| @@ -1667,6 +1673,9 @@ func (w *Workspace) scrollWidth(i int) int { | |||||
| 1667 | // Fixed overflow past MaxColumns: the spiral's slots. | 1673 | // Fixed overflow past MaxColumns: the spiral's slots. | ||
| 1668 | return w.equalShare(k) | 1674 | return w.equalShare(k) | ||
| 1669 | } | 1675 | } | ||
| 1676 | + | if w.validSpans(0, len(w.Columns)-1) { | |||
| 1677 | + | return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000 | |||
| 1678 | + | } | |||
| 1670 | c := w.Columns[i].Cells | 1679 | c := w.Columns[i].Cells | ||
| 1671 | if c <= 0 { | 1680 | if c <= 0 { | ||
| 1672 | return w.equalShare(min(len(w.Columns), k)) | 1681 | return w.equalShare(min(len(w.Columns), k)) | ||
| @@ -1682,6 +1691,7 @@ func (w *Workspace) columnX(i int) int { | |||||
| 1682 | return x | 1691 | return x | ||
| 1683 | } | 1692 | } | ||
| 1684 | func (w *Workspace) scroll() { | 1693 | func (w *Workspace) scroll() { | ||
| 1694 | + | w.dropStaleSpans() | |||
| 1685 | before := w.View | 1695 | before := w.View | ||
| 1686 | defer func() { w.retarget(before) }() | 1696 | defer func() { w.retarget(before) }() | ||
| 1687 | if w.policy().wraps { | 1697 | if w.policy().wraps { | ||
M internal/core/layout_policy.go +42 -0
| @@ -53,3 +53,45 @@ func (w *Workspace) policy() layoutPolicy { | |||
| 53 | 53 | } | |
| 54 | 54 | return layoutPolicy{content: horizontalAxis, workspace: verticalAxis} | |
| 55 | 55 | } | |
| 56 | + | ||
| 57 | + | // validSpans rejects partial stamps and stamps from a differently sized lane. | |
| 58 | + | func (w *Workspace) validSpans(first, last int) bool { | |
| 59 | + | if first > last || w.Overflow == OverflowFixed && !w.laneRow() { | |
| 60 | + | return false | |
| 61 | + | } | |
| 62 | + | total := 0 | |
| 63 | + | for i := first; i <= last; i++ { | |
| 64 | + | c := w.Columns[i] | |
| 65 | + | if c.Span <= 0 || c.spanLane != last-first+1 { | |
| 66 | + | return false | |
| 67 | + | } | |
| 68 | + | total += c.Span | |
| 69 | + | } | |
| 70 | + | return !w.policy().equalCells || total == 10000 | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // dropStaleSpans runs before scroll measures mutated layout geometry. A band | |
| 74 | + | // is independent: invalidating one never discards another band's ratios. | |
| 75 | + | func (w *Workspace) dropStaleSpans() { | |
| 76 | + | for first := 0; first < len(w.Columns); { | |
| 77 | + | _, last := w.laneBounds(w.laneOf(first)) | |
| 78 | + | if !w.validSpans(first, last) { | |
| 79 | + | for i := first; i <= last; i++ { | |
| 80 | + | w.Columns[i].Span = 0 | |
| 81 | + | w.Columns[i].spanLane = 0 | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | first = last + 1 | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (w *Workspace) clearSpans(col int) { | |
| 89 | + | if col < 0 || col >= len(w.Columns) { | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | first, last := w.laneBounds(w.laneOf(col)) | |
| 93 | + | for i := first; i <= last; i++ { | |
| 94 | + | w.Columns[i].Span = 0 | |
| 95 | + | w.Columns[i].spanLane = 0 | |
| 96 | + | } | |
| 97 | + | } | |
| @@ -53,3 +53,45 @@ func (w *Workspace) policy() layoutPolicy { | |||||
| 53 | } | 53 | } | ||
| 54 | return layoutPolicy{content: horizontalAxis, workspace: verticalAxis} | 54 | return layoutPolicy{content: horizontalAxis, workspace: verticalAxis} | ||
| 55 | } | 55 | } | ||
| 56 | + | ||||
| 57 | + | // validSpans rejects partial stamps and stamps from a differently sized lane. | |||
| 58 | + | func (w *Workspace) validSpans(first, last int) bool { | |||
| 59 | + | if first > last || w.Overflow == OverflowFixed && !w.laneRow() { | |||
| 60 | + | return false | |||
| 61 | + | } | |||
| 62 | + | total := 0 | |||
| 63 | + | for i := first; i <= last; i++ { | |||
| 64 | + | c := w.Columns[i] | |||
| 65 | + | if c.Span <= 0 || c.spanLane != last-first+1 { | |||
| 66 | + | return false | |||
| 67 | + | } | |||
| 68 | + | total += c.Span | |||
| 69 | + | } | |||
| 70 | + | return !w.policy().equalCells || total == 10000 | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // dropStaleSpans runs before scroll measures mutated layout geometry. A band | |||
| 74 | + | // is independent: invalidating one never discards another band's ratios. | |||
| 75 | + | func (w *Workspace) dropStaleSpans() { | |||
| 76 | + | for first := 0; first < len(w.Columns); { | |||
| 77 | + | _, last := w.laneBounds(w.laneOf(first)) | |||
| 78 | + | if !w.validSpans(first, last) { | |||
| 79 | + | for i := first; i <= last; i++ { | |||
| 80 | + | w.Columns[i].Span = 0 | |||
| 81 | + | w.Columns[i].spanLane = 0 | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | first = last + 1 | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (w *Workspace) clearSpans(col int) { | |||
| 89 | + | if col < 0 || col >= len(w.Columns) { | |||
| 90 | + | return | |||
| 91 | + | } | |||
| 92 | + | first, last := w.laneBounds(w.laneOf(col)) | |||
| 93 | + | for i := first; i <= last; i++ { | |||
| 94 | + | w.Columns[i].Span = 0 | |||
| 95 | + | w.Columns[i].spanLane = 0 | |||
| 96 | + | } | |||
| 97 | + | } | |||
A internal/core/spans_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func spanWorkspace(mode Overflow, n int) *Workspace { | |
| 6 | + | w := workspace() | |
| 7 | + | w.Overflow = mode | |
| 8 | + | w.MaxColumns = 3 | |
| 9 | + | w.SetOutput(1000, 800) | |
| 10 | + | w.SetGaps(10) | |
| 11 | + | for i := 0; i < n; i++ { | |
| 12 | + | w.AddWindow(WindowID(i + 1)) | |
| 13 | + | } | |
| 14 | + | w.View = 0 | |
| 15 | + | w.view = slide{} | |
| 16 | + | return w | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func stampSpans(w *Workspace, first, last int) { | |
| 20 | + | for i := first; i <= last; i++ { | |
| 21 | + | w.Columns[i].Span = 10000 / (last - first + 1) | |
| 22 | + | w.Columns[i].spanLane = last - first + 1 | |
| 23 | + | } | |
| 24 | + | w.Columns[last].Span += 10000 % (last - first + 1) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestSpansMutations(t *testing.T) { | |
| 28 | + | for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} { | |
| 29 | + | for _, mutation := range []string{"add", "remove", "take", "receive", "swap", "grow", "shrink", "action"} { | |
| 30 | + | t.Run(string(mode)+"/"+mutation, func(t *testing.T) { | |
| 31 | + | w := spanWorkspace(mode, 2) | |
| 32 | + | stampSpans(w, 0, 1) | |
| 33 | + | switch mutation { | |
| 34 | + | case "add": | |
| 35 | + | w.AddWindow(3) | |
| 36 | + | case "remove": | |
| 37 | + | w.RemoveWindow(1) | |
| 38 | + | case "take": | |
| 39 | + | col, ok := w.takeColumn() | |
| 40 | + | if !ok { | |
| 41 | + | t.Fatal("take") | |
| 42 | + | } | |
| 43 | + | other := spanWorkspace(mode, 1) | |
| 44 | + | other.receive(col, -1) | |
| 45 | + | for _, c := range other.Columns { | |
| 46 | + | if c.Span != 0 { | |
| 47 | + | t.Fatal("destination kept stale span") | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | case "receive": | |
| 51 | + | w.receive(Column{Windows: []WindowID{3}}, 1) | |
| 52 | + | case "swap": | |
| 53 | + | w.Focus = 0 | |
| 54 | + | w.MoveColumn(1) | |
| 55 | + | case "grow": | |
| 56 | + | w.GrowColumn(false) | |
| 57 | + | case "shrink": | |
| 58 | + | w.ShrinkColumn() | |
| 59 | + | case "action": | |
| 60 | + | w.Apply("set-column-width +1") | |
| 61 | + | } | |
| 62 | + | for _, c := range w.Columns { | |
| 63 | + | if mutation == "swap" { | |
| 64 | + | if c.Span == 0 { | |
| 65 | + | t.Fatal("swap discarded valid spans") | |
| 66 | + | } | |
| 67 | + | } else if c.Span != 0 || c.spanLane != 0 { | |
| 68 | + | t.Fatal("stale span", c) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestSpansCascadeIsolation(t *testing.T) { | |
| 77 | + | w := spanWorkspace(OverflowCascade, 5) | |
| 78 | + | stampSpans(w, 0, 2) | |
| 79 | + | stampSpans(w, 3, 4) | |
| 80 | + | w.RemoveWindow(w.Columns[0].Windows[0]) | |
| 81 | + | if w.Columns[0].Span != 0 || w.Columns[1].Span != 0 { | |
| 82 | + | t.Fatal("changed band retained spans") | |
| 83 | + | } | |
| 84 | + | if w.Columns[2].Span != 5000 || w.Columns[3].Span != 5000 { | |
| 85 | + | t.Fatal("unrelated band reset") | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestSpanWidths(t *testing.T) { | |
| 90 | + | for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} { | |
| 91 | + | w := spanWorkspace(mode, 2) | |
| 92 | + | stampSpans(w, 0, 1) | |
| 93 | + | w.Columns[0].Span = 3000 | |
| 94 | + | w.Columns[1].Span = 7000 | |
| 95 | + | rs := w.columnRects() | |
| 96 | + | avail := 970 | |
| 97 | + | if mode == OverflowScroll { | |
| 98 | + | avail = 980 | |
| 99 | + | } | |
| 100 | + | if rs[0].W != avail*3/10 { | |
| 101 | + | t.Fatal(mode, rs) | |
| 102 | + | } | |
| 103 | + | w.SetOutput(1400, 800) | |
| 104 | + | if w.columnRects()[0].W != (avail+400)*3/10 { | |
| 105 | + | t.Fatal("ratio lost", mode, w.columnRects()) | |
| 106 | + | } | |
| 107 | + | w.Columns[0].FullWidth = true | |
| 108 | + | if w.columnWidth(0) != 1380 { | |
| 109 | + | t.Fatal("special width lost") | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | w := spanWorkspace(OverflowFixed, 4) | |
| 113 | + | stampSpans(w, 0, 3) | |
| 114 | + | w.scroll() | |
| 115 | + | for _, c := range w.Columns { | |
| 116 | + | if c.Span != 0 { | |
| 117 | + | t.Fatal("spiral spans") | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestSpansDropIntoLane(t *testing.T) { | |
| 123 | + | for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} { | |
| 124 | + | w := spanWorkspace(mode, 2) | |
| 125 | + | stampSpans(w, 0, 1) | |
| 126 | + | src := spanWorkspace(mode, 1) | |
| 127 | + | src.Columns[0].Windows[0] = 9 | |
| 128 | + | m := monitor() | |
| 129 | + | m.Workspaces = []*Workspace{w, src} | |
| 130 | + | sc := &screen{mon: m} | |
| 131 | + | c := &Core{screens: []*screen{sc}} | |
| 132 | + | c.drop(9, dropTarget{kind: dropInsert, screen: sc, ws: w, anchor: w.Columns[1].Windows[0]}) | |
| 133 | + | if len(w.Columns) != 3 { | |
| 134 | + | t.Fatal("not dropped", mode, w.Columns) | |
| 135 | + | } | |
| 136 | + | for _, col := range w.Columns { | |
| 137 | + | if col.Span != 0 || col.spanLane != 0 { | |
| 138 | + | t.Fatal("drop retained stamp", mode, col) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func spanWorkspace(mode Overflow, n int) *Workspace { | |||
| 6 | + | w := workspace() | |||
| 7 | + | w.Overflow = mode | |||
| 8 | + | w.MaxColumns = 3 | |||
| 9 | + | w.SetOutput(1000, 800) | |||
| 10 | + | w.SetGaps(10) | |||
| 11 | + | for i := 0; i < n; i++ { | |||
| 12 | + | w.AddWindow(WindowID(i + 1)) | |||
| 13 | + | } | |||
| 14 | + | w.View = 0 | |||
| 15 | + | w.view = slide{} | |||
| 16 | + | return w | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func stampSpans(w *Workspace, first, last int) { | |||
| 20 | + | for i := first; i <= last; i++ { | |||
| 21 | + | w.Columns[i].Span = 10000 / (last - first + 1) | |||
| 22 | + | w.Columns[i].spanLane = last - first + 1 | |||
| 23 | + | } | |||
| 24 | + | w.Columns[last].Span += 10000 % (last - first + 1) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestSpansMutations(t *testing.T) { | |||
| 28 | + | for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} { | |||
| 29 | + | for _, mutation := range []string{"add", "remove", "take", "receive", "swap", "grow", "shrink", "action"} { | |||
| 30 | + | t.Run(string(mode)+"/"+mutation, func(t *testing.T) { | |||
| 31 | + | w := spanWorkspace(mode, 2) | |||
| 32 | + | stampSpans(w, 0, 1) | |||
| 33 | + | switch mutation { | |||
| 34 | + | case "add": | |||
| 35 | + | w.AddWindow(3) | |||
| 36 | + | case "remove": | |||
| 37 | + | w.RemoveWindow(1) | |||
| 38 | + | case "take": | |||
| 39 | + | col, ok := w.takeColumn() | |||
| 40 | + | if !ok { | |||
| 41 | + | t.Fatal("take") | |||
| 42 | + | } | |||
| 43 | + | other := spanWorkspace(mode, 1) | |||
| 44 | + | other.receive(col, -1) | |||
| 45 | + | for _, c := range other.Columns { | |||
| 46 | + | if c.Span != 0 { | |||
| 47 | + | t.Fatal("destination kept stale span") | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | case "receive": | |||
| 51 | + | w.receive(Column{Windows: []WindowID{3}}, 1) | |||
| 52 | + | case "swap": | |||
| 53 | + | w.Focus = 0 | |||
| 54 | + | w.MoveColumn(1) | |||
| 55 | + | case "grow": | |||
| 56 | + | w.GrowColumn(false) | |||
| 57 | + | case "shrink": | |||
| 58 | + | w.ShrinkColumn() | |||
| 59 | + | case "action": | |||
| 60 | + | w.Apply("set-column-width +1") | |||
| 61 | + | } | |||
| 62 | + | for _, c := range w.Columns { | |||
| 63 | + | if mutation == "swap" { | |||
| 64 | + | if c.Span == 0 { | |||
| 65 | + | t.Fatal("swap discarded valid spans") | |||
| 66 | + | } | |||
| 67 | + | } else if c.Span != 0 || c.spanLane != 0 { | |||
| 68 | + | t.Fatal("stale span", c) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestSpansCascadeIsolation(t *testing.T) { | |||
| 77 | + | w := spanWorkspace(OverflowCascade, 5) | |||
| 78 | + | stampSpans(w, 0, 2) | |||
| 79 | + | stampSpans(w, 3, 4) | |||
| 80 | + | w.RemoveWindow(w.Columns[0].Windows[0]) | |||
| 81 | + | if w.Columns[0].Span != 0 || w.Columns[1].Span != 0 { | |||
| 82 | + | t.Fatal("changed band retained spans") | |||
| 83 | + | } | |||
| 84 | + | if w.Columns[2].Span != 5000 || w.Columns[3].Span != 5000 { | |||
| 85 | + | t.Fatal("unrelated band reset") | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestSpanWidths(t *testing.T) { | |||
| 90 | + | for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} { | |||
| 91 | + | w := spanWorkspace(mode, 2) | |||
| 92 | + | stampSpans(w, 0, 1) | |||
| 93 | + | w.Columns[0].Span = 3000 | |||
| 94 | + | w.Columns[1].Span = 7000 | |||
| 95 | + | rs := w.columnRects() | |||
| 96 | + | avail := 970 | |||
| 97 | + | if mode == OverflowScroll { | |||
| 98 | + | avail = 980 | |||
| 99 | + | } | |||
| 100 | + | if rs[0].W != avail*3/10 { | |||
| 101 | + | t.Fatal(mode, rs) | |||
| 102 | + | } | |||
| 103 | + | w.SetOutput(1400, 800) | |||
| 104 | + | if w.columnRects()[0].W != (avail+400)*3/10 { | |||
| 105 | + | t.Fatal("ratio lost", mode, w.columnRects()) | |||
| 106 | + | } | |||
| 107 | + | w.Columns[0].FullWidth = true | |||
| 108 | + | if w.columnWidth(0) != 1380 { | |||
| 109 | + | t.Fatal("special width lost") | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | w := spanWorkspace(OverflowFixed, 4) | |||
| 113 | + | stampSpans(w, 0, 3) | |||
| 114 | + | w.scroll() | |||
| 115 | + | for _, c := range w.Columns { | |||
| 116 | + | if c.Span != 0 { | |||
| 117 | + | t.Fatal("spiral spans") | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestSpansDropIntoLane(t *testing.T) { | |||
| 123 | + | for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} { | |||
| 124 | + | w := spanWorkspace(mode, 2) | |||
| 125 | + | stampSpans(w, 0, 1) | |||
| 126 | + | src := spanWorkspace(mode, 1) | |||
| 127 | + | src.Columns[0].Windows[0] = 9 | |||
| 128 | + | m := monitor() | |||
| 129 | + | m.Workspaces = []*Workspace{w, src} | |||
| 130 | + | sc := &screen{mon: m} | |||
| 131 | + | c := &Core{screens: []*screen{sc}} | |||
| 132 | + | c.drop(9, dropTarget{kind: dropInsert, screen: sc, ws: w, anchor: w.Columns[1].Windows[0]}) | |||
| 133 | + | if len(w.Columns) != 3 { | |||
| 134 | + | t.Fatal("not dropped", mode, w.Columns) | |||
| 135 | + | } | |||
| 136 | + | for _, col := range w.Columns { | |||
| 137 | + | if col.Span != 0 || col.spanLane != 0 { | |||
| 138 | + | t.Fatal("drop retained stamp", mode, col) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | } | |||