perf(renderer): reuse cell styles and support downward scroll deltas (#29)
* perf(renderer): reuse cell styles and support downward scroll deltas * fix(renderer): validate newly exposed scroll cells
- commit
- 8e4df5c081447325640e4df6ee7558d6b7a6fd99
- author
- bnema · · 2026-09-05 12:36 UTC
- committer
- GitHub · · 2026-09-05 12:36 UTC
- parent
- 31affde
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.5.1
10 files · +284 -15
M ansi/damage.go +1 -0
| @@ -9,6 +9,7 @@ const ( | |||
| 9 | 9 | DamageText = core.DamageText | |
| 10 | 10 | DamageClear = core.DamageClear | |
| 11 | 11 | DamageScrollUp = core.DamageScrollUp | |
| 12 | + | DamageScrollDown = core.DamageScrollDown | |
| 12 | 13 | DamageFullRedraw = core.DamageFullRedraw | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -9,6 +9,7 @@ const ( | |||||
| 9 | DamageText = core.DamageText | 9 | DamageText = core.DamageText | ||
| 10 | DamageClear = core.DamageClear | 10 | DamageClear = core.DamageClear | ||
| 11 | DamageScrollUp = core.DamageScrollUp | 11 | DamageScrollUp = core.DamageScrollUp | ||
| 12 | + | DamageScrollDown = core.DamageScrollDown | |||
| 12 | DamageFullRedraw = core.DamageFullRedraw | 13 | DamageFullRedraw = core.DamageFullRedraw | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
M ansi/delta_plan.go +8 -3
| @@ -7,11 +7,12 @@ type Span struct { | |||
| 7 | 7 | Width int | |
| 8 | 8 | } | |
| 9 | 9 | ||
| 10 | - | // Scroll identifies a safe full-width upward scroll. | |
| 10 | + | // Scroll identifies a safe full-width scroll. | |
| 11 | 11 | type Scroll struct { | |
| 12 | 12 | Y int | |
| 13 | 13 | Height int | |
| 14 | 14 | Count int | |
| 15 | + | Down bool | |
| 15 | 16 | } | |
| 16 | 17 | ||
| 17 | 18 | // DeltaPlan describes how to advance a committed frame to the candidate frame. | |
| @@ -66,7 +67,7 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) ( | |||
| 66 | 67 | var plan DeltaPlan | |
| 67 | 68 | var skip *Damage | |
| 68 | 69 | if scroll, ok := findSafeScroll(frame, damage); ok && canApplyScrollAgainst(frame, scroll, damage, committed) { | |
| 69 | - | plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count} | |
| 70 | + | plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown} | |
| 70 | 71 | skip = &scroll | |
| 71 | 72 | } else if hasScrollDamage(damage) { | |
| 72 | 73 | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |
| @@ -202,7 +203,11 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||
| 202 | 203 | } | |
| 203 | 204 | if c.Plan.Scroll.Height != 0 { | |
| 204 | 205 | scroll := c.Plan.Scroll | |
| 205 | - | dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count) | |
| 206 | + | if scroll.Down { | |
| 207 | + | dst.ScrollDown(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count) | |
| 208 | + | } else { | |
| 209 | + | dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count) | |
| 210 | + | } | |
| 206 | 211 | } | |
| 207 | 212 | for _, span := range c.Plan.Spans { | |
| 208 | 213 | for x := span.X; x < span.X+span.Width; x++ { | |
| @@ -7,11 +7,12 @@ type Span struct { | |||||
| 7 | Width int | 7 | Width int | ||
| 8 | } | 8 | } | ||
| 9 | 9 | ||||
| 10 | - | // Scroll identifies a safe full-width upward scroll. | 10 | + | // Scroll identifies a safe full-width scroll. |
| 11 | type Scroll struct { | 11 | type Scroll struct { | ||
| 12 | Y int | 12 | Y int | ||
| 13 | Height int | 13 | Height int | ||
| 14 | Count int | 14 | Count int | ||
| 15 | + | Down bool | |||
| 15 | } | 16 | } | ||
| 16 | 17 | ||||
| 17 | // DeltaPlan describes how to advance a committed frame to the candidate frame. | 18 | // DeltaPlan describes how to advance a committed frame to the candidate frame. | ||
| @@ -66,7 +67,7 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) ( | |||||
| 66 | var plan DeltaPlan | 67 | var plan DeltaPlan | ||
| 67 | var skip *Damage | 68 | var skip *Damage | ||
| 68 | if scroll, ok := findSafeScroll(frame, damage); ok && canApplyScrollAgainst(frame, scroll, damage, committed) { | 69 | if scroll, ok := findSafeScroll(frame, damage); ok && canApplyScrollAgainst(frame, scroll, damage, committed) { | ||
| 69 | - | plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count} | 70 | + | plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown} |
| 70 | skip = &scroll | 71 | skip = &scroll | ||
| 71 | } else if hasScrollDamage(damage) { | 72 | } else if hasScrollDamage(damage) { | ||
| 72 | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | 73 | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | ||
| @@ -202,7 +203,11 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||||
| 202 | } | 203 | } | ||
| 203 | if c.Plan.Scroll.Height != 0 { | 204 | if c.Plan.Scroll.Height != 0 { | ||
| 204 | scroll := c.Plan.Scroll | 205 | scroll := c.Plan.Scroll | ||
| 205 | - | dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count) | 206 | + | if scroll.Down { |
| 207 | + | dst.ScrollDown(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count) | |||
| 208 | + | } else { | |||
| 209 | + | dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count) | |||
| 210 | + | } | |||
| 206 | } | 211 | } | ||
| 207 | for _, span := range c.Plan.Spans { | 212 | for _, span := range c.Plan.Spans { | ||
| 208 | for x := span.X; x < span.X+span.Width; x++ { | 213 | for x := span.X; x < span.X+span.Width; x++ { | ||
M ansi/renderer.go +6 -2
| @@ -122,7 +122,11 @@ func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (Prepa | |||
| 122 | 122 | } else { | |
| 123 | 123 | if plan.Scroll.Height != 0 { | |
| 124 | 124 | scroll := plan.Scroll | |
| 125 | - | emitScrollUp(buf, Damage{Kind: DamageScrollUp, X: 0, Y: scroll.Y, Width: columns, Height: scroll.Height, Count: scroll.Count}) | |
| 125 | + | kind := DamageScrollUp | |
| 126 | + | if scroll.Down { | |
| 127 | + | kind = DamageScrollDown | |
| 128 | + | } | |
| 129 | + | emitScroll(buf, Damage{Kind: kind, X: 0, Y: scroll.Y, Width: columns, Height: scroll.Height, Count: scroll.Count}) | |
| 126 | 130 | } | |
| 127 | 131 | for _, span := range plan.Spans { | |
| 128 | 132 | r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st) | |
| @@ -186,7 +190,7 @@ func needsFull(damage []Damage) bool { | |||
| 186 | 190 | ||
| 187 | 191 | func hasScrollDamage(damage []Damage) bool { | |
| 188 | 192 | for _, d := range damage { | |
| 189 | - | if d.Kind == DamageScrollUp { | |
| 193 | + | if d.Kind == DamageScrollUp || d.Kind == DamageScrollDown { | |
| 190 | 194 | return true | |
| 191 | 195 | } | |
| 192 | 196 | } | |
| @@ -122,7 +122,11 @@ func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (Prepa | |||||
| 122 | } else { | 122 | } else { | ||
| 123 | if plan.Scroll.Height != 0 { | 123 | if plan.Scroll.Height != 0 { | ||
| 124 | scroll := plan.Scroll | 124 | scroll := plan.Scroll | ||
| 125 | - | emitScrollUp(buf, Damage{Kind: DamageScrollUp, X: 0, Y: scroll.Y, Width: columns, Height: scroll.Height, Count: scroll.Count}) | 125 | + | kind := DamageScrollUp |
| 126 | + | if scroll.Down { | |||
| 127 | + | kind = DamageScrollDown | |||
| 128 | + | } | |||
| 129 | + | emitScroll(buf, Damage{Kind: kind, X: 0, Y: scroll.Y, Width: columns, Height: scroll.Height, Count: scroll.Count}) | |||
| 126 | } | 130 | } | ||
| 127 | for _, span := range plan.Spans { | 131 | for _, span := range plan.Spans { | ||
| 128 | r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st) | 132 | r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st) | ||
| @@ -186,7 +190,7 @@ func needsFull(damage []Damage) bool { | |||||
| 186 | 190 | ||||
| 187 | func hasScrollDamage(damage []Damage) bool { | 191 | func hasScrollDamage(damage []Damage) bool { | ||
| 188 | for _, d := range damage { | 192 | for _, d := range damage { | ||
| 189 | - | if d.Kind == DamageScrollUp { | 193 | + | if d.Kind == DamageScrollUp || d.Kind == DamageScrollDown { |
| 190 | return true | 194 | return true | ||
| 191 | } | 195 | } | ||
| 192 | } | 196 | } | ||
M ansi/scroll.go +39 -6
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) { | |
| 9 | 9 | for _, d := range damage { | |
| 10 | - | if d.Kind == DamageScrollUp && isSafeScroll(frame, d) { | |
| 10 | + | if (d.Kind == DamageScrollUp || d.Kind == DamageScrollDown) && isSafeScroll(frame, d) { | |
| 11 | 11 | return d, true | |
| 12 | 12 | } | |
| 13 | 13 | } | |
| @@ -18,7 +18,7 @@ func isSafeScroll(frame CellSource, d Damage) bool { | |||
| 18 | 18 | return d.X == 0 && d.Width == frame.Columns() && d.Y >= 0 && d.Y <= frame.Rows() && d.Height > 0 && d.Height <= frame.Rows()-d.Y && d.Count > 0 && d.Count < d.Height | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | - | func emitScrollUp(out *bytes.Buffer, d Damage) { | |
| 21 | + | func emitScroll(out *bytes.Buffer, d Damage) { | |
| 22 | 22 | out.WriteString("\x1b[0m") | |
| 23 | 23 | // \x1b[%d;%dr — set scroll region | |
| 24 | 24 | out.WriteString("\x1b[") | |
| @@ -29,6 +29,15 @@ func emitScrollUp(out *bytes.Buffer, d Damage) { | |||
| 29 | 29 | n = strconv.AppendInt(b[:0], int64(d.Y+d.Height), 10) | |
| 30 | 30 | out.Write(n) | |
| 31 | 31 | out.WriteByte('r') | |
| 32 | + | // SD scrolls down without moving content outside the chosen region. | |
| 33 | + | if d.Kind == DamageScrollDown { | |
| 34 | + | out.WriteString("\x1b[") | |
| 35 | + | n = strconv.AppendInt(b[:0], int64(d.Count), 10) | |
| 36 | + | out.Write(n) | |
| 37 | + | out.WriteByte('T') | |
| 38 | + | out.WriteString("\x1b[r") | |
| 39 | + | return | |
| 40 | + | } | |
| 32 | 41 | // Position cursor at bottom of scroll region. | |
| 33 | 42 | writeCursor(out, d.Y+d.Height-1, 0) | |
| 34 | 43 | if d.Count == 1 { | |
| @@ -43,16 +52,32 @@ func emitScrollUp(out *bytes.Buffer, d Damage) { | |||
| 43 | 52 | } | |
| 44 | 53 | ||
| 45 | 54 | func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool { | |
| 55 | + | // Scrolling fills exposed rows with default-style blanks. Every target | |
| 56 | + | // cell that differs from that fill must be repainted, in either direction. | |
| 57 | + | exposed := scroll.Y + scroll.Height - scroll.Count | |
| 58 | + | if scroll.Kind == DamageScrollDown { | |
| 59 | + | exposed = scroll.Y | |
| 60 | + | } | |
| 61 | + | blank := BlankCell() | |
| 62 | + | for y := exposed; y < exposed+scroll.Count; y++ { | |
| 63 | + | for x := range scroll.Width { | |
| 64 | + | column := scroll.X + x | |
| 65 | + | if !damageCoversCell(damage, column, y) && !frame.Cell(column, y).Equal(blank) { | |
| 66 | + | return false | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 46 | 70 | switch frame := frame.(type) { | |
| 47 | 71 | case Frame: | |
| 48 | 72 | return canApplyDenseScrollAgainst(frame, scroll, damage, committed) | |
| 49 | 73 | case *Frame: | |
| 50 | 74 | return canApplyDenseScrollAgainst(*frame, scroll, damage, committed) | |
| 51 | 75 | } | |
| 52 | - | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | |
| 76 | + | start, end, offset := scrollRetainedRows(scroll) | |
| 77 | + | for y := start; y < end; y++ { | |
| 53 | 78 | for x := range scroll.Width { | |
| 54 | 79 | column := scroll.X + x | |
| 55 | - | committedCell, frameCell := committed.Cell(column, y+scroll.Count), frame.Cell(column, y) | |
| 80 | + | committedCell, frameCell := committed.Cell(column, y+offset), frame.Cell(column, y) | |
| 56 | 81 | if committedCell == frameCell || committedCell.Equal(frameCell) { | |
| 57 | 82 | continue | |
| 58 | 83 | } | |
| @@ -65,10 +90,11 @@ func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, com | |||
| 65 | 90 | } | |
| 66 | 91 | ||
| 67 | 92 | func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool { | |
| 68 | - | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | |
| 93 | + | start, end, offset := scrollRetainedRows(scroll) | |
| 94 | + | for y := start; y < end; y++ { | |
| 69 | 95 | for x := range scroll.Width { | |
| 70 | 96 | column := scroll.X + x | |
| 71 | - | committedCell, frameCell := committed.At(column, y+scroll.Count), frame.At(column, y) | |
| 97 | + | committedCell, frameCell := committed.At(column, y+offset), frame.At(column, y) | |
| 72 | 98 | if committedCell == frameCell || committedCell.Equal(frameCell) { | |
| 73 | 99 | continue | |
| 74 | 100 | } | |
| @@ -80,6 +106,13 @@ func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, com | |||
| 80 | 106 | return true | |
| 81 | 107 | } | |
| 82 | 108 | ||
| 109 | + | func scrollRetainedRows(scroll Damage) (start, end, offset int) { | |
| 110 | + | if scroll.Kind == DamageScrollDown { | |
| 111 | + | return scroll.Y + scroll.Count, scroll.Y + scroll.Height, -scroll.Count | |
| 112 | + | } | |
| 113 | + | return scroll.Y, scroll.Y + scroll.Height - scroll.Count, scroll.Count | |
| 114 | + | } | |
| 115 | + | ||
| 83 | 116 | func damageCoversCell(damage []Damage, x, y int) bool { | |
| 84 | 117 | for _, d := range damage { | |
| 85 | 118 | if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height { | |
| @@ -7,7 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) { | 8 | func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) { | ||
| 9 | for _, d := range damage { | 9 | for _, d := range damage { | ||
| 10 | - | if d.Kind == DamageScrollUp && isSafeScroll(frame, d) { | 10 | + | if (d.Kind == DamageScrollUp || d.Kind == DamageScrollDown) && isSafeScroll(frame, d) { |
| 11 | return d, true | 11 | return d, true | ||
| 12 | } | 12 | } | ||
| 13 | } | 13 | } | ||
| @@ -18,7 +18,7 @@ func isSafeScroll(frame CellSource, d Damage) bool { | |||||
| 18 | return d.X == 0 && d.Width == frame.Columns() && d.Y >= 0 && d.Y <= frame.Rows() && d.Height > 0 && d.Height <= frame.Rows()-d.Y && d.Count > 0 && d.Count < d.Height | 18 | return d.X == 0 && d.Width == frame.Columns() && d.Y >= 0 && d.Y <= frame.Rows() && d.Height > 0 && d.Height <= frame.Rows()-d.Y && d.Count > 0 && d.Count < d.Height | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | - | func emitScrollUp(out *bytes.Buffer, d Damage) { | 21 | + | func emitScroll(out *bytes.Buffer, d Damage) { |
| 22 | out.WriteString("\x1b[0m") | 22 | out.WriteString("\x1b[0m") | ||
| 23 | // \x1b[%d;%dr — set scroll region | 23 | // \x1b[%d;%dr — set scroll region | ||
| 24 | out.WriteString("\x1b[") | 24 | out.WriteString("\x1b[") | ||
| @@ -29,6 +29,15 @@ func emitScrollUp(out *bytes.Buffer, d Damage) { | |||||
| 29 | n = strconv.AppendInt(b[:0], int64(d.Y+d.Height), 10) | 29 | n = strconv.AppendInt(b[:0], int64(d.Y+d.Height), 10) | ||
| 30 | out.Write(n) | 30 | out.Write(n) | ||
| 31 | out.WriteByte('r') | 31 | out.WriteByte('r') | ||
| 32 | + | // SD scrolls down without moving content outside the chosen region. | |||
| 33 | + | if d.Kind == DamageScrollDown { | |||
| 34 | + | out.WriteString("\x1b[") | |||
| 35 | + | n = strconv.AppendInt(b[:0], int64(d.Count), 10) | |||
| 36 | + | out.Write(n) | |||
| 37 | + | out.WriteByte('T') | |||
| 38 | + | out.WriteString("\x1b[r") | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 32 | // Position cursor at bottom of scroll region. | 41 | // Position cursor at bottom of scroll region. | ||
| 33 | writeCursor(out, d.Y+d.Height-1, 0) | 42 | writeCursor(out, d.Y+d.Height-1, 0) | ||
| 34 | if d.Count == 1 { | 43 | if d.Count == 1 { | ||
| @@ -43,16 +52,32 @@ func emitScrollUp(out *bytes.Buffer, d Damage) { | |||||
| 43 | } | 52 | } | ||
| 44 | 53 | ||||
| 45 | func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool { | 54 | func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool { | ||
| 55 | + | // Scrolling fills exposed rows with default-style blanks. Every target | |||
| 56 | + | // cell that differs from that fill must be repainted, in either direction. | |||
| 57 | + | exposed := scroll.Y + scroll.Height - scroll.Count | |||
| 58 | + | if scroll.Kind == DamageScrollDown { | |||
| 59 | + | exposed = scroll.Y | |||
| 60 | + | } | |||
| 61 | + | blank := BlankCell() | |||
| 62 | + | for y := exposed; y < exposed+scroll.Count; y++ { | |||
| 63 | + | for x := range scroll.Width { | |||
| 64 | + | column := scroll.X + x | |||
| 65 | + | if !damageCoversCell(damage, column, y) && !frame.Cell(column, y).Equal(blank) { | |||
| 66 | + | return false | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 46 | switch frame := frame.(type) { | 70 | switch frame := frame.(type) { | ||
| 47 | case Frame: | 71 | case Frame: | ||
| 48 | return canApplyDenseScrollAgainst(frame, scroll, damage, committed) | 72 | return canApplyDenseScrollAgainst(frame, scroll, damage, committed) | ||
| 49 | case *Frame: | 73 | case *Frame: | ||
| 50 | return canApplyDenseScrollAgainst(*frame, scroll, damage, committed) | 74 | return canApplyDenseScrollAgainst(*frame, scroll, damage, committed) | ||
| 51 | } | 75 | } | ||
| 52 | - | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | 76 | + | start, end, offset := scrollRetainedRows(scroll) |
| 77 | + | for y := start; y < end; y++ { | |||
| 53 | for x := range scroll.Width { | 78 | for x := range scroll.Width { | ||
| 54 | column := scroll.X + x | 79 | column := scroll.X + x | ||
| 55 | - | committedCell, frameCell := committed.Cell(column, y+scroll.Count), frame.Cell(column, y) | 80 | + | committedCell, frameCell := committed.Cell(column, y+offset), frame.Cell(column, y) |
| 56 | if committedCell == frameCell || committedCell.Equal(frameCell) { | 81 | if committedCell == frameCell || committedCell.Equal(frameCell) { | ||
| 57 | continue | 82 | continue | ||
| 58 | } | 83 | } | ||
| @@ -65,10 +90,11 @@ func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, com | |||||
| 65 | } | 90 | } | ||
| 66 | 91 | ||||
| 67 | func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool { | 92 | func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool { | ||
| 68 | - | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | 93 | + | start, end, offset := scrollRetainedRows(scroll) |
| 94 | + | for y := start; y < end; y++ { | |||
| 69 | for x := range scroll.Width { | 95 | for x := range scroll.Width { | ||
| 70 | column := scroll.X + x | 96 | column := scroll.X + x | ||
| 71 | - | committedCell, frameCell := committed.At(column, y+scroll.Count), frame.At(column, y) | 97 | + | committedCell, frameCell := committed.At(column, y+offset), frame.At(column, y) |
| 72 | if committedCell == frameCell || committedCell.Equal(frameCell) { | 98 | if committedCell == frameCell || committedCell.Equal(frameCell) { | ||
| 73 | continue | 99 | continue | ||
| 74 | } | 100 | } | ||
| @@ -80,6 +106,13 @@ func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, com | |||||
| 80 | return true | 106 | return true | ||
| 81 | } | 107 | } | ||
| 82 | 108 | ||||
| 109 | + | func scrollRetainedRows(scroll Damage) (start, end, offset int) { | |||
| 110 | + | if scroll.Kind == DamageScrollDown { | |||
| 111 | + | return scroll.Y + scroll.Count, scroll.Y + scroll.Height, -scroll.Count | |||
| 112 | + | } | |||
| 113 | + | return scroll.Y, scroll.Y + scroll.Height - scroll.Count, scroll.Count | |||
| 114 | + | } | |||
| 115 | + | ||||
| 83 | func damageCoversCell(damage []Damage, x, y int) bool { | 116 | func damageCoversCell(damage []Damage, x, y int) bool { | ||
| 84 | for _, d := range damage { | 117 | for _, d := range damage { | ||
| 85 | if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height { | 118 | if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height { | ||
A ansi/scroll_down_test.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package ansi_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | vt "github.com/bnema/vev-vt" | |
| 8 | + | "github.com/bnema/vev-vt/ansi" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestBidirectionalScrollReplay(t *testing.T) { | |
| 12 | + | for _, down := range []bool{false, true} { | |
| 13 | + | for _, count := range []int{1, 3} { | |
| 14 | + | frame := vt.NewFrame(12, 10) | |
| 15 | + | for y := range frame.Height { | |
| 16 | + | frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: rune('a' + y), Style: vt.Style{Foreground: y, Background: -1}}) | |
| 17 | + | } | |
| 18 | + | r := ansi.New(ansi.Capabilities{}) | |
| 19 | + | terminal := vt.NewScreen(frame.Width, frame.Height) | |
| 20 | + | initial, err := r.Draw(frame, nil) | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | terminal.Write(initial) | |
| 25 | + | kind, exposed := vt.DamageScrollUp, 8-count | |
| 26 | + | if down { | |
| 27 | + | kind, exposed = vt.DamageScrollDown, 2 | |
| 28 | + | frame.ScrollDown(2, 7, count) | |
| 29 | + | } else { | |
| 30 | + | frame.ScrollUp(2, 7, count) | |
| 31 | + | } | |
| 32 | + | for y := exposed; y < exposed+count; y++ { | |
| 33 | + | frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}) | |
| 34 | + | } | |
| 35 | + | damage := []vt.Damage{ | |
| 36 | + | {Kind: kind, Y: 2, Width: 12, Height: 6, Count: count}, | |
| 37 | + | {Kind: vt.DamageText, Y: exposed, Width: 12, Height: count}, | |
| 38 | + | } | |
| 39 | + | prepared, err := r.Prepare(frame, damage, false) | |
| 40 | + | if err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | if !bytes.Contains(prepared.Bytes(), []byte("\x1b[3;8r")) { | |
| 44 | + | t.Fatalf("missing scroll region: %q", prepared.Bytes()) | |
| 45 | + | } | |
| 46 | + | terminal.Write(prepared.Bytes()) | |
| 47 | + | prepared.Commit() | |
| 48 | + | for y := range frame.Height { | |
| 49 | + | for x := range frame.Width { | |
| 50 | + | if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) { | |
| 51 | + | t.Fatalf("down=%v count=%d cell %d,%d mismatch", down, count, x, y) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | if err := frame.CheckInvariants(); err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | unchanged, err := r.Draw(frame, nil) | |
| 59 | + | if err != nil || len(unchanged) != 0 { | |
| 60 | + | t.Fatalf("committed shadow differs: %q, %v", unchanged, err) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestDownwardScrollMismatchFallsBack(t *testing.T) { | |
| 67 | + | committed := vt.NewFrame(10, 8) | |
| 68 | + | frame := committed.Clone() | |
| 69 | + | frame.Set(0, 4, vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}) | |
| 70 | + | for _, width := range []int{9, 10} { | |
| 71 | + | candidate, err := ansi.PlanDelta(frame, []vt.Damage{{Kind: vt.DamageScrollDown, Y: 1, Width: width, Height: 6, Count: 1}}, committed, false) | |
| 72 | + | if err != nil { | |
| 73 | + | t.Fatal(err) | |
| 74 | + | } | |
| 75 | + | if !candidate.Plan.Snapshot || candidate.Plan.Scroll.Height != 0 { | |
| 76 | + | t.Fatalf("unsafe scroll accepted: %+v", candidate.Plan) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| @@ -0,0 +1,79 @@ | |||||
| 1 | + | package ansi_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | vt "github.com/bnema/vev-vt" | |||
| 8 | + | "github.com/bnema/vev-vt/ansi" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestBidirectionalScrollReplay(t *testing.T) { | |||
| 12 | + | for _, down := range []bool{false, true} { | |||
| 13 | + | for _, count := range []int{1, 3} { | |||
| 14 | + | frame := vt.NewFrame(12, 10) | |||
| 15 | + | for y := range frame.Height { | |||
| 16 | + | frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: rune('a' + y), Style: vt.Style{Foreground: y, Background: -1}}) | |||
| 17 | + | } | |||
| 18 | + | r := ansi.New(ansi.Capabilities{}) | |||
| 19 | + | terminal := vt.NewScreen(frame.Width, frame.Height) | |||
| 20 | + | initial, err := r.Draw(frame, nil) | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | terminal.Write(initial) | |||
| 25 | + | kind, exposed := vt.DamageScrollUp, 8-count | |||
| 26 | + | if down { | |||
| 27 | + | kind, exposed = vt.DamageScrollDown, 2 | |||
| 28 | + | frame.ScrollDown(2, 7, count) | |||
| 29 | + | } else { | |||
| 30 | + | frame.ScrollUp(2, 7, count) | |||
| 31 | + | } | |||
| 32 | + | for y := exposed; y < exposed+count; y++ { | |||
| 33 | + | frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}) | |||
| 34 | + | } | |||
| 35 | + | damage := []vt.Damage{ | |||
| 36 | + | {Kind: kind, Y: 2, Width: 12, Height: 6, Count: count}, | |||
| 37 | + | {Kind: vt.DamageText, Y: exposed, Width: 12, Height: count}, | |||
| 38 | + | } | |||
| 39 | + | prepared, err := r.Prepare(frame, damage, false) | |||
| 40 | + | if err != nil { | |||
| 41 | + | t.Fatal(err) | |||
| 42 | + | } | |||
| 43 | + | if !bytes.Contains(prepared.Bytes(), []byte("\x1b[3;8r")) { | |||
| 44 | + | t.Fatalf("missing scroll region: %q", prepared.Bytes()) | |||
| 45 | + | } | |||
| 46 | + | terminal.Write(prepared.Bytes()) | |||
| 47 | + | prepared.Commit() | |||
| 48 | + | for y := range frame.Height { | |||
| 49 | + | for x := range frame.Width { | |||
| 50 | + | if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) { | |||
| 51 | + | t.Fatalf("down=%v count=%d cell %d,%d mismatch", down, count, x, y) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | if err := frame.CheckInvariants(); err != nil { | |||
| 56 | + | t.Fatal(err) | |||
| 57 | + | } | |||
| 58 | + | unchanged, err := r.Draw(frame, nil) | |||
| 59 | + | if err != nil || len(unchanged) != 0 { | |||
| 60 | + | t.Fatalf("committed shadow differs: %q, %v", unchanged, err) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestDownwardScrollMismatchFallsBack(t *testing.T) { | |||
| 67 | + | committed := vt.NewFrame(10, 8) | |||
| 68 | + | frame := committed.Clone() | |||
| 69 | + | frame.Set(0, 4, vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}) | |||
| 70 | + | for _, width := range []int{9, 10} { | |||
| 71 | + | candidate, err := ansi.PlanDelta(frame, []vt.Damage{{Kind: vt.DamageScrollDown, Y: 1, Width: width, Height: 6, Count: 1}}, committed, false) | |||
| 72 | + | if err != nil { | |||
| 73 | + | t.Fatal(err) | |||
| 74 | + | } | |||
| 75 | + | if !candidate.Plan.Snapshot || candidate.Plan.Scroll.Height != 0 { | |||
| 76 | + | t.Fatalf("unsafe scroll accepted: %+v", candidate.Plan) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | } | |||
A ansi/scroll_exposed_test.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package ansi_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | vt "github.com/bnema/vev-vt" | |
| 8 | + | "github.com/bnema/vev-vt/ansi" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // An embedded source exercises the generic path rather than Frame dispatch. | |
| 12 | + | type scrollCellSource struct{ vt.CellSource } | |
| 13 | + | ||
| 14 | + | func TestScrollExposedCellsRequireBlankOrRepaint(t *testing.T) { | |
| 15 | + | for _, down := range []bool{false, true} { | |
| 16 | + | for _, sourceKind := range []string{"frame", "pointer", "generic"} { | |
| 17 | + | for _, tc := range []struct { | |
| 18 | + | name string | |
| 19 | + | cell vt.Cell | |
| 20 | + | coverage int | |
| 21 | + | kind vt.DamageKind | |
| 22 | + | fallback bool | |
| 23 | + | }{ | |
| 24 | + | {"blank", vt.BlankCell(), 0, vt.DamageText, false}, | |
| 25 | + | {"uncovered text", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 0, vt.DamageText, true}, | |
| 26 | + | {"uncovered styled blank", vt.Cell{Rune: ' ', Style: vt.Style{Bold: true}}, 0, vt.DamageText, true}, | |
| 27 | + | {"partial coverage", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 1, vt.DamageText, true}, | |
| 28 | + | {"text coverage", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 2, vt.DamageText, false}, | |
| 29 | + | {"clear coverage", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 2, vt.DamageClear, false}, | |
| 30 | + | } { | |
| 31 | + | t.Run(fmt.Sprintf("down=%v/%s/%s", down, sourceKind, tc.name), func(t *testing.T) { | |
| 32 | + | committed := vt.NewFrame(12, 10) | |
| 33 | + | for y := range committed.Height { | |
| 34 | + | committed.FillRow(y, 0, committed.Width, vt.Cell{Rune: rune('a' + y), Style: vt.DefaultStyle()}) | |
| 35 | + | } | |
| 36 | + | frame := committed.Clone() | |
| 37 | + | kind, exposed := vt.DamageScrollUp, 6 | |
| 38 | + | if down { | |
| 39 | + | kind, exposed = vt.DamageScrollDown, 2 | |
| 40 | + | frame.ScrollDown(2, 7, 2) | |
| 41 | + | } else { | |
| 42 | + | frame.ScrollUp(2, 7, 2) | |
| 43 | + | } | |
| 44 | + | for y := exposed; y < exposed+2; y++ { | |
| 45 | + | frame.Set(0, y, tc.cell) | |
| 46 | + | } | |
| 47 | + | damage := []vt.Damage{{Kind: kind, Y: 2, Width: 12, Height: 6, Count: 2}} | |
| 48 | + | if tc.coverage > 0 { | |
| 49 | + | damage = append(damage, vt.Damage{Kind: tc.kind, Y: exposed, Width: 12, Height: tc.coverage}) | |
| 50 | + | } | |
| 51 | + | var source vt.CellSource = frame | |
| 52 | + | switch sourceKind { | |
| 53 | + | case "pointer": | |
| 54 | + | source = &frame | |
| 55 | + | case "generic": | |
| 56 | + | source = scrollCellSource{frame} | |
| 57 | + | } | |
| 58 | + | candidate, err := ansi.PlanDelta(source, damage, committed, false) | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | if candidate.Plan.Snapshot != tc.fallback { | |
| 63 | + | t.Fatalf("plan = %+v, want fallback %v", candidate.Plan, tc.fallback) | |
| 64 | + | } | |
| 65 | + | r := ansi.New(ansi.Capabilities{}) | |
| 66 | + | terminal := vt.NewScreen(12, 10) | |
| 67 | + | initial, err := r.Draw(committed, nil) | |
| 68 | + | if err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | terminal.Write(initial) | |
| 72 | + | data, err := r.Draw(source, damage) | |
| 73 | + | if err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | terminal.Write(data) | |
| 77 | + | for y := range frame.Height { | |
| 78 | + | for x := range frame.Width { | |
| 79 | + | if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) { | |
| 80 | + | t.Fatalf("replay differs at %d,%d", x, y) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | data, err = r.Draw(source, nil) | |
| 85 | + | if err != nil || len(data) != 0 { | |
| 86 | + | t.Fatalf("committed shadow differs: %q, %v", data, err) | |
| 87 | + | } | |
| 88 | + | }) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package ansi_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | vt "github.com/bnema/vev-vt" | |||
| 8 | + | "github.com/bnema/vev-vt/ansi" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // An embedded source exercises the generic path rather than Frame dispatch. | |||
| 12 | + | type scrollCellSource struct{ vt.CellSource } | |||
| 13 | + | ||||
| 14 | + | func TestScrollExposedCellsRequireBlankOrRepaint(t *testing.T) { | |||
| 15 | + | for _, down := range []bool{false, true} { | |||
| 16 | + | for _, sourceKind := range []string{"frame", "pointer", "generic"} { | |||
| 17 | + | for _, tc := range []struct { | |||
| 18 | + | name string | |||
| 19 | + | cell vt.Cell | |||
| 20 | + | coverage int | |||
| 21 | + | kind vt.DamageKind | |||
| 22 | + | fallback bool | |||
| 23 | + | }{ | |||
| 24 | + | {"blank", vt.BlankCell(), 0, vt.DamageText, false}, | |||
| 25 | + | {"uncovered text", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 0, vt.DamageText, true}, | |||
| 26 | + | {"uncovered styled blank", vt.Cell{Rune: ' ', Style: vt.Style{Bold: true}}, 0, vt.DamageText, true}, | |||
| 27 | + | {"partial coverage", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 1, vt.DamageText, true}, | |||
| 28 | + | {"text coverage", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 2, vt.DamageText, false}, | |||
| 29 | + | {"clear coverage", vt.Cell{Rune: 'X', Style: vt.DefaultStyle()}, 2, vt.DamageClear, false}, | |||
| 30 | + | } { | |||
| 31 | + | t.Run(fmt.Sprintf("down=%v/%s/%s", down, sourceKind, tc.name), func(t *testing.T) { | |||
| 32 | + | committed := vt.NewFrame(12, 10) | |||
| 33 | + | for y := range committed.Height { | |||
| 34 | + | committed.FillRow(y, 0, committed.Width, vt.Cell{Rune: rune('a' + y), Style: vt.DefaultStyle()}) | |||
| 35 | + | } | |||
| 36 | + | frame := committed.Clone() | |||
| 37 | + | kind, exposed := vt.DamageScrollUp, 6 | |||
| 38 | + | if down { | |||
| 39 | + | kind, exposed = vt.DamageScrollDown, 2 | |||
| 40 | + | frame.ScrollDown(2, 7, 2) | |||
| 41 | + | } else { | |||
| 42 | + | frame.ScrollUp(2, 7, 2) | |||
| 43 | + | } | |||
| 44 | + | for y := exposed; y < exposed+2; y++ { | |||
| 45 | + | frame.Set(0, y, tc.cell) | |||
| 46 | + | } | |||
| 47 | + | damage := []vt.Damage{{Kind: kind, Y: 2, Width: 12, Height: 6, Count: 2}} | |||
| 48 | + | if tc.coverage > 0 { | |||
| 49 | + | damage = append(damage, vt.Damage{Kind: tc.kind, Y: exposed, Width: 12, Height: tc.coverage}) | |||
| 50 | + | } | |||
| 51 | + | var source vt.CellSource = frame | |||
| 52 | + | switch sourceKind { | |||
| 53 | + | case "pointer": | |||
| 54 | + | source = &frame | |||
| 55 | + | case "generic": | |||
| 56 | + | source = scrollCellSource{frame} | |||
| 57 | + | } | |||
| 58 | + | candidate, err := ansi.PlanDelta(source, damage, committed, false) | |||
| 59 | + | if err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | if candidate.Plan.Snapshot != tc.fallback { | |||
| 63 | + | t.Fatalf("plan = %+v, want fallback %v", candidate.Plan, tc.fallback) | |||
| 64 | + | } | |||
| 65 | + | r := ansi.New(ansi.Capabilities{}) | |||
| 66 | + | terminal := vt.NewScreen(12, 10) | |||
| 67 | + | initial, err := r.Draw(committed, nil) | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | terminal.Write(initial) | |||
| 72 | + | data, err := r.Draw(source, damage) | |||
| 73 | + | if err != nil { | |||
| 74 | + | t.Fatal(err) | |||
| 75 | + | } | |||
| 76 | + | terminal.Write(data) | |||
| 77 | + | for y := range frame.Height { | |||
| 78 | + | for x := range frame.Width { | |||
| 79 | + | if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) { | |||
| 80 | + | t.Fatalf("replay differs at %d,%d", x, y) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | data, err = r.Draw(source, nil) | |||
| 85 | + | if err != nil || len(data) != 0 { | |||
| 86 | + | t.Fatalf("committed shadow differs: %q, %v", data, err) | |||
| 87 | + | } | |||
| 88 | + | }) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | } | |||
M core/damage.go +1 -0
| @@ -7,6 +7,7 @@ const ( | |||
| 7 | 7 | DamageClear | |
| 8 | 8 | DamageScrollUp | |
| 9 | 9 | DamageFullRedraw | |
| 10 | + | DamageScrollDown | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | type Damage struct { | |
| @@ -7,6 +7,7 @@ const ( | |||||
| 7 | DamageClear | 7 | DamageClear | ||
| 8 | DamageScrollUp | 8 | DamageScrollUp | ||
| 9 | DamageFullRedraw | 9 | DamageFullRedraw | ||
| 10 | + | DamageScrollDown | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | type Damage struct { | 13 | type Damage struct { | ||
M core/frame.go +5 -4
| @@ -261,10 +261,11 @@ func (f Frame) Set(x, y int, cell Cell) { | |||
| 261 | 261 | payloadID := f.internPayload(cell.Payload) | |
| 262 | 262 | f.releasePayload(f.page.cells[index].payloadID) | |
| 263 | 263 | oldID := f.page.cells[index].styleID | |
| 264 | - | styleID := f.internStyle(cell.Style) | |
| 265 | - | if oldID == styleID { | |
| 266 | - | f.releaseStyle(styleID) // cancel internStyle's temporary reference | |
| 267 | - | } else { | |
| 264 | + | styleID := oldID | |
| 265 | + | // Repainting a cell usually keeps its style. Compare the canonical value | |
| 266 | + | // before hashing it and changing references in the page-local dictionary. | |
| 267 | + | if f.page.styles[oldID].style != cell.Style.Canonical() { | |
| 268 | + | styleID = f.internStyle(cell.Style) | |
| 268 | 269 | f.releaseStyle(oldID) | |
| 269 | 270 | } | |
| 270 | 271 | flags := uint8(0) | |
| @@ -261,10 +261,11 @@ func (f Frame) Set(x, y int, cell Cell) { | |||||
| 261 | payloadID := f.internPayload(cell.Payload) | 261 | payloadID := f.internPayload(cell.Payload) | ||
| 262 | f.releasePayload(f.page.cells[index].payloadID) | 262 | f.releasePayload(f.page.cells[index].payloadID) | ||
| 263 | oldID := f.page.cells[index].styleID | 263 | oldID := f.page.cells[index].styleID | ||
| 264 | - | styleID := f.internStyle(cell.Style) | 264 | + | styleID := oldID |
| 265 | - | if oldID == styleID { | 265 | + | // Repainting a cell usually keeps its style. Compare the canonical value |
| 266 | - | f.releaseStyle(styleID) // cancel internStyle's temporary reference | 266 | + | // before hashing it and changing references in the page-local dictionary. |
| 267 | - | } else { | 267 | + | if f.page.styles[oldID].style != cell.Style.Canonical() { |
| 268 | + | styleID = f.internStyle(cell.Style) | |||
| 268 | f.releaseStyle(oldID) | 269 | f.releaseStyle(oldID) | ||
| 269 | } | 270 | } | ||
| 270 | flags := uint8(0) | 271 | flags := uint8(0) | ||
A core/frame_style_reuse_test.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestSetStyleReuse(t *testing.T) { | |
| 6 | + | for _, rotated := range []bool{false, true} { | |
| 7 | + | f := NewFrame(3, 2) | |
| 8 | + | if rotated { | |
| 9 | + | f.ScrollUp(0, 1, 1) | |
| 10 | + | } | |
| 11 | + | styles := []Style{DefaultStyle(), {Bold: true}, {Italic: true}, {Bold: true}, DefaultStyle()} | |
| 12 | + | for _, style := range styles { | |
| 13 | + | for repeat := range 3 { | |
| 14 | + | for x := range f.Width { | |
| 15 | + | cell := Cell{Rune: rune('a' + repeat), Style: style} | |
| 16 | + | // Inactive RGB fields differ without changing canonical style. | |
| 17 | + | cell.Style.ForegroundRGB = RGB{R: uint8(repeat + 1)} | |
| 18 | + | index := f.offset(x, 0) | |
| 19 | + | oldID := f.page.cells[index].styleID | |
| 20 | + | sameStyle := f.page.styles[oldID].style == style.Canonical() | |
| 21 | + | f.Set(x, 0, cell) | |
| 22 | + | if got := f.page.cells[index].styleID; sameStyle && got != oldID { | |
| 23 | + | t.Fatalf("equivalent style changed ID from %d to %d", oldID, got) | |
| 24 | + | } | |
| 25 | + | if got := f.Cell(x, 0); !got.Equal(cell) { | |
| 26 | + | t.Fatalf("cell = %+v, want %+v", got, cell) | |
| 27 | + | } | |
| 28 | + | if err := f.CheckInvariants(); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func BenchmarkFrameRewriteSameStyle(b *testing.B) { | |
| 38 | + | f := NewFrame(182, 53) | |
| 39 | + | row := make([]Cell, f.Width) | |
| 40 | + | for x := range row { | |
| 41 | + | row[x] = Cell{Rune: 'x', Style: Style{Bold: true}} | |
| 42 | + | } | |
| 43 | + | for y := range f.Height { | |
| 44 | + | f.WriteRow(y, 0, row) | |
| 45 | + | } | |
| 46 | + | b.ReportAllocs() | |
| 47 | + | for b.Loop() { | |
| 48 | + | for y := range f.Height { | |
| 49 | + | f.WriteRow(y, 0, row) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestSetStyleReuse(t *testing.T) { | |||
| 6 | + | for _, rotated := range []bool{false, true} { | |||
| 7 | + | f := NewFrame(3, 2) | |||
| 8 | + | if rotated { | |||
| 9 | + | f.ScrollUp(0, 1, 1) | |||
| 10 | + | } | |||
| 11 | + | styles := []Style{DefaultStyle(), {Bold: true}, {Italic: true}, {Bold: true}, DefaultStyle()} | |||
| 12 | + | for _, style := range styles { | |||
| 13 | + | for repeat := range 3 { | |||
| 14 | + | for x := range f.Width { | |||
| 15 | + | cell := Cell{Rune: rune('a' + repeat), Style: style} | |||
| 16 | + | // Inactive RGB fields differ without changing canonical style. | |||
| 17 | + | cell.Style.ForegroundRGB = RGB{R: uint8(repeat + 1)} | |||
| 18 | + | index := f.offset(x, 0) | |||
| 19 | + | oldID := f.page.cells[index].styleID | |||
| 20 | + | sameStyle := f.page.styles[oldID].style == style.Canonical() | |||
| 21 | + | f.Set(x, 0, cell) | |||
| 22 | + | if got := f.page.cells[index].styleID; sameStyle && got != oldID { | |||
| 23 | + | t.Fatalf("equivalent style changed ID from %d to %d", oldID, got) | |||
| 24 | + | } | |||
| 25 | + | if got := f.Cell(x, 0); !got.Equal(cell) { | |||
| 26 | + | t.Fatalf("cell = %+v, want %+v", got, cell) | |||
| 27 | + | } | |||
| 28 | + | if err := f.CheckInvariants(); err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func BenchmarkFrameRewriteSameStyle(b *testing.B) { | |||
| 38 | + | f := NewFrame(182, 53) | |||
| 39 | + | row := make([]Cell, f.Width) | |||
| 40 | + | for x := range row { | |||
| 41 | + | row[x] = Cell{Rune: 'x', Style: Style{Bold: true}} | |||
| 42 | + | } | |||
| 43 | + | for y := range f.Height { | |||
| 44 | + | f.WriteRow(y, 0, row) | |||
| 45 | + | } | |||
| 46 | + | b.ReportAllocs() | |||
| 47 | + | for b.Loop() { | |||
| 48 | + | for y := range f.Height { | |||
| 49 | + | f.WriteRow(y, 0, row) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | } | |||
M core_model.go +1 -0
| @@ -33,6 +33,7 @@ const ( | |||
| 33 | 33 | DamageText = core.DamageText | |
| 34 | 34 | DamageClear = core.DamageClear | |
| 35 | 35 | DamageScrollUp = core.DamageScrollUp | |
| 36 | + | DamageScrollDown = core.DamageScrollDown | |
| 36 | 37 | DamageFullRedraw = core.DamageFullRedraw | |
| 37 | 38 | ) | |
| 38 | 39 | ||
| @@ -33,6 +33,7 @@ const ( | |||||
| 33 | DamageText = core.DamageText | 33 | DamageText = core.DamageText | ||
| 34 | DamageClear = core.DamageClear | 34 | DamageClear = core.DamageClear | ||
| 35 | DamageScrollUp = core.DamageScrollUp | 35 | DamageScrollUp = core.DamageScrollUp | ||
| 36 | + | DamageScrollDown = core.DamageScrollDown | |||
| 36 | DamageFullRedraw = core.DamageFullRedraw | 37 | DamageFullRedraw = core.DamageFullRedraw | ||
| 37 | ) | 38 | ) | ||
| 38 | 39 | ||||