// Copyright 2026 The TCell Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package tcell
import "testing"
func TestCellBufferPutAndSanitize(t *testing.T) {
t.Run("unsanitized", func(t *testing.T) {
cb := &CellBuffer{w: 4, h: 1, cells: make([]cell, 4)}
rest, width := cb.Put(0, 0, "A\x1bB", StyleDefault)
if rest != "\x1bB" {
t.Fatalf("unexpected remainder: %q", rest)
}
if width != 1 {
t.Fatalf("unexpected width: %d", width)
}
if got, _, gotWidth := cb.Get(0, 0); got != "A" || gotWidth != 1 {
t.Fatalf("unexpected cell content: %q width=%d", got, gotWidth)
}
})
t.Run("sanitized", func(t *testing.T) {
cb := &CellBuffer{w: 4, h: 1, cells: make([]cell, 4), sanitizeContent: true}
rest, width := cb.Put(0, 0, "A\x1bB", StyleDefault)
if rest != "B" {
t.Fatalf("unexpected sanitized remainder: %q", rest)
}
if width != 1 {
t.Fatalf("unexpected width: %d", width)
}
if got, _, gotWidth := cb.Get(0, 0); got != "A" || gotWidth != 1 {
t.Fatalf("unexpected sanitized cell content: %q width=%d", got, gotWidth)
}
})
}
func TestCellBufferWideAndColorNone(t *testing.T) {
cb := &CellBuffer{w: 3, h: 1, cells: make([]cell, 3)}
base := StyleDefault.Foreground(ColorRed).Background(ColorBlue)
cb.Put(0, 0, "a", base)
cb.SetDirty(0, 0, false)
cb.Put(1, 0, "z", base)
cb.SetDirty(1, 0, false)
reused := Style{fg: ColorNone, bg: ColorNone}
rest, width := cb.Put(0, 0, "你", reused)
if rest != "" {
t.Fatalf("unexpected remainder for wide rune: %q", rest)
}
if width != 2 {
t.Fatalf("unexpected width for wide rune: %d", width)
}
if !cb.Dirty(0, 0) {
t.Fatalf("wide rune should dirty the base cell")
}
if !cb.Dirty(1, 0) {
t.Fatalf("wide rune should dirty the second cell")
}
got, gotStyle, gotWidth := cb.Get(0, 0)
if got != "你" || gotWidth != 2 {
t.Fatalf("unexpected wide cell content: %q width=%d", got, gotWidth)
}
if gotStyle.GetForeground() != ColorRed || gotStyle.GetBackground() != ColorBlue {
t.Fatalf("ColorNone should preserve existing colors, got fg=%v bg=%v", gotStyle.GetForeground(), gotStyle.GetBackground())
}
}
func TestCellBufferDirtyState(t *testing.T) {
cb := &CellBuffer{w: 2, h: 1, cells: make([]cell, 2)}
cb.cells[0].setDirty(false)
if got := cb.cells[0].lastStr; got != " " {
t.Fatalf("setDirty(false) should normalize empty content to space, got %q", got)
}
if got := cb.cells[0].lastStyle; got != cb.cells[0].currStyle {
t.Fatalf("setDirty(false) should copy style")
}
cb.cells[0].setDirty(true)
if got := cb.cells[0].lastStr; got != "" {
t.Fatalf("setDirty(true) should clear lastStr, got %q", got)
}
cb.Put(0, 0, "x", StyleDefault)
cb.SetDirty(0, 0, false)
if cb.Dirty(0, 0) {
t.Fatalf("clean cell should not be dirty")
}
cb.cells[0].currStyle = cb.cells[0].currStyle.Bold(true)
if !cb.Dirty(0, 0) {
t.Fatalf("style change should make cell dirty")
}
cb.SetDirty(0, 0, false)
cb.cells[0].currStyle = cb.cells[0].lastStyle
cb.cells[0].currStr = "y"
if !cb.Dirty(0, 0) {
t.Fatalf("content change should make cell dirty")
}
}
func TestCellBufferLockResizeFill(t *testing.T) {
cb := &CellBuffer{w: 2, h: 2, cells: make([]cell, 4)}
cb.Fill('x', StyleDefault)
if got, _, _ := cb.Get(1, 1); got != "x" {
t.Fatalf("unexpected fill content: %q", got)
}
cb.LockCell(1, 1)
if cb.Dirty(1, 1) {
t.Fatalf("locked cell should not be dirty")
}
cb.UnlockCell(1, 1)
if !cb.Dirty(1, 1) {
t.Fatalf("unlocked cell should be dirty")
}
cb.Put(0, 0, "ab", StyleDefault)
cb.Resize(3, 1)
if got, _, _ := cb.Get(0, 0); got != "a" {
t.Fatalf("resize should preserve content, got %q", got)
}
if _, _, width := cb.Get(0, 0); width != 1 {
t.Fatalf("unexpected width after resize: %d", width)
}
cb.Invalidate()
if !cb.Dirty(0, 0) {
t.Fatalf("invalidate should mark content dirty")
}
}
func TestCellBufferUnlockUntouchedCellMarksDirty(t *testing.T) {
cb := &CellBuffer{w: 1, h: 1, cells: make([]cell, 1)}
cb.LockCell(0, 0)
cb.UnlockCell(0, 0)
if !cb.Dirty(0, 0) {
t.Fatalf("unlocking an untouched blank cell should mark it dirty")
}
}
func TestCellBufferOutOfRangeAndResizeNoop(t *testing.T) {
cb := &CellBuffer{w: 2, h: 1, cells: make([]cell, 2)}
if rest, width := cb.Put(-1, 0, "x", StyleDefault); rest != "x" || width != 0 {
t.Fatalf("out-of-range put should be a no-op, got rest=%q width=%d", rest, width)
}
cb.Put(0, 0, "x", StyleDefault.Foreground(ColorRed).Background(ColorBlue))
cb.SetDirty(0, 0, false)
if rest, width := cb.Put(0, 0, "", StyleDefault.Foreground(ColorGreen)); rest != "" || width != 0 {
t.Fatalf("empty put should be a no-op, got rest=%q width=%d", rest, width)
}
afterStr, afterStyle, afterWidth := cb.Get(0, 0)
if afterStr != " " || afterStyle.GetForeground() != ColorGreen || afterWidth != 1 {
t.Fatalf("empty put should clear the cell and apply the new style: got=%q/%v/%d", afterStr, afterStyle, afterWidth)
}
if got := cb.Dirty(0, 0); !got {
t.Fatalf("empty put should mark the cell dirty")
}
cb.LockCell(-1, 0)
cb.LockCell(2, 0)
cb.UnlockCell(-1, 0)
cb.UnlockCell(2, 0)
cb.Resize(2, 1)
if w, h := cb.Size(); w != 2 || h != 1 {
t.Fatalf("resize no-op changed size to %dx%d", w, h)
}
}
func TestCellBufferPutRedraw(t *testing.T) {
cb := &CellBuffer{w: 8, h: 1, cells: make([]cell, 8)}
// The first Put measures the cell; a re-Put of identical content takes the
// fast path and must return the same result without segmenting again.
rest1, width1 := cb.Put(0, 0, "x", StyleDefault)
rest2, width2 := cb.Put(0, 0, "x", StyleDefault)
if rest2 != rest1 || width2 != width1 {
t.Fatalf("re-Put mismatch: (%q,%d), want (%q,%d)", rest2, width2, rest1, width1)
}
allocs := testing.AllocsPerRun(100, func() {
cb.Put(0, 0, "x", StyleDefault)
})
if allocs != 0 {
t.Fatalf("re-Put allocated %.0f times, want 0", allocs)
}
}
func TestCellBufferPutASCIIBeforeCombiningCluster(t *testing.T) {
cb := &CellBuffer{w: 2, h: 1, cells: make([]cell, 2)}
rest, width := cb.Put(0, 0, "xe\u0301", StyleDefault)
if rest != "e\u0301" || width != 1 {
t.Fatalf("Put returned (%q,%d), want (%q,1)", rest, width, "e\u0301")
}
rest, width = cb.Put(1, 0, rest, StyleDefault)
if rest != "" || width != 1 {
t.Fatalf("combining Put returned (%q,%d), want (\"\",1)", rest, width)
}
str, _, _ := cb.Get(1, 0)
if str != "e\u0301" {
t.Fatalf("combining cell is %q, want %q", str, "e\u0301")
}
}
func TestCellBufferPutASCIIFastPathMatchesGraphemeIterator(t *testing.T) {
inputs := make([]string, 0, 95*96)
for first := byte(' '); first <= '~'; first++ {
inputs = append(inputs, string(first))
for second := byte(' '); second <= '~'; second++ {
inputs = append(inputs, string([]byte{first, second}))
}
}
for _, input := range inputs {
graphemes := textWidthOptions.StringGraphemes(input)
if !graphemes.Next() {
t.Fatalf("grapheme iterator returned no value for %q", input)
}
wantString := graphemes.Value()
wantWidth := graphemes.Width()
wantRest := input[len(wantString):]
cb := &CellBuffer{w: 1, h: 1, cells: make([]cell, 1)}
gotRest, gotWidth := cb.Put(0, 0, input, StyleDefault)
gotString, _, _ := cb.Get(0, 0)
if gotString != wantString || gotWidth != wantWidth || gotRest != wantRest {
t.Fatalf(
"Put(%q) = (%q,%d,%q), want (%q,%d,%q)",
input, gotString, gotWidth, gotRest, wantString, wantWidth, wantRest,
)
}
}
}
func TestCellBufferFillArea(t *testing.T) {
cb := &CellBuffer{w: 4, h: 3, cells: make([]cell, 12)}
cb.Fill('.', StyleDefault)
red := StyleDefault.Foreground(ColorRed).Background(ColorBlue)
cb.FillArea(1, 1, 2, 1, 'x', red)
// Cells inside the region should have changed.
for _, xy := range [][2]int{{1, 1}, {2, 1}} {
got, style, width := cb.Get(xy[0], xy[1])
if got != "x" || width != 1 || style != red {
t.Fatalf("cell %v not filled: got %q style=%v width=%d", xy, got, style, width)
}
}
// Neighbours just outside the region should be untouched.
for _, xy := range [][2]int{{0, 1}, {3, 1}, {1, 0}, {1, 2}} {
if got, _, _ := cb.Get(xy[0], xy[1]); got != "." {
t.Fatalf("cell %v should be untouched, got %q", xy, got)
}
}
}
func TestCellBufferFillAreaClipping(t *testing.T) {
cb := &CellBuffer{w: 3, h: 3, cells: make([]cell, 9)}
cb.Fill('.', StyleDefault)
// A region that starts off-screen and overflows the far edge should only
// paint the cells that actually fall inside the buffer.
cb.FillArea(-2, -2, 4, 4, '#', StyleDefault)
for y := 0; y < 3; y++ {
for x := 0; x < 3; x++ {
want := "."
if x < 2 && y < 2 {
want = "#"
}
if got, _, _ := cb.Get(x, y); got != want {
t.Fatalf("cell (%d,%d): got %q want %q", x, y, got, want)
}
}
}
// Zero and negative extents must not change anything.
cb.FillArea(0, 0, 0, 3, '!', StyleDefault)
cb.FillArea(0, 0, 3, -1, '!', StyleDefault)
if got, _, _ := cb.Get(0, 0); got != "#" {
t.Fatalf("empty region should be a no-op, got %q", got)
}
}
func TestCellBufferFillAreaOverflow(t *testing.T) {
const maxInt = int(^uint(0) >> 1)
const minInt = -maxInt - 1
// A width that overflows x+w must still fill every in-bounds cell from x
// onward rather than clipping to a wrapped-around negative endpoint.
cb := &CellBuffer{w: 4, h: 3, cells: make([]cell, 12)}
cb.Fill('.', StyleDefault)
cb.FillArea(1, 1, maxInt, maxInt, '#', StyleDefault)
for y := 0; y < 3; y++ {
for x := 0; x < 4; x++ {
want := "."
if x >= 1 && y >= 1 {
want = "#"
}
if got, _, _ := cb.Get(x, y); got != want {
t.Fatalf("cell (%d,%d): got %q want %q", x, y, got, want)
}
}
}
// Negative extents (including the extreme minimum) must never underflow
// into filling cells; the region is empty and nothing changes.
cb2 := &CellBuffer{w: 4, h: 3, cells: make([]cell, 12)}
cb2.Fill('.', StyleDefault)
cb2.FillArea(minInt+1, minInt+1, minInt, minInt, '#', StyleDefault)
cb2.FillArea(0, 0, minInt, 2, '#', StyleDefault)
for y := 0; y < 3; y++ {
for x := 0; x < 4; x++ {
if got, _, _ := cb2.Get(x, y); got != "." {
t.Fatalf("cell (%d,%d) should be untouched, got %q", x, y, got)
}
}
}
}
func TestCellBufferFillAreaColorNone(t *testing.T) {
cb := &CellBuffer{w: 2, h: 1, cells: make([]cell, 2)}
base := StyleDefault.Foreground(ColorRed).Background(ColorBlue)
cb.Fill(' ', base)
// ColorNone should leave the existing fg/bg in place, matching Fill.
cb.FillArea(0, 0, 1, 1, 'y', StyleDefault.Foreground(ColorNone).Background(ColorNone))
if _, style, _ := cb.Get(0, 0); style != base {
t.Fatalf("ColorNone should preserve existing colors, got %v", style)
}
}
func TestCellBufferRePutAfterClear(t *testing.T) {
cb := &CellBuffer{}
cb.Resize(4, 1)
for _, str := range []string{"宽", "🎉", "👩👩👧👦", "e\u0301"} {
_, want := cb.Put(0, 0, str, StyleDefault)
cb.SetDirty(0, 0, false) // drawn
// Putting back what is on screen after a Clear reuses the width
// measured when it was drawn.
cb.Put(0, 0, " ", StyleDefault) // what Clear leaves
if rest, width := cb.Put(0, 0, str, StyleDefault); rest != "" || width != want {
t.Errorf("%q: got %q, %d after clearing, want \"\", %d", str, rest, width, want)
}
}
}
func TestCellBufferRePutAfterRedrawMarker(t *testing.T) {
cb := &CellBuffer{}
cb.Resize(4, 1)
cb.Put(0, 0, "宽", StyleDefault)
cb.SetDirty(0, 0, false) // drawn, two columns wide
// Marking the cell for redraw sets lastStr to "", which must not keep
// the width of what was drawn.
cb.SetDirty(0, 0, true)
if _, width := cb.Put(0, 0, "", StyleDefault); width != 0 {
t.Fatalf("empty string width %d, want 0", width)
}
}