current commit
261022.4 · perf(cell): reuse the drawn width when putting back the same text
// 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 "unicode/utf8"

type cell struct {
	currStr   string
	lastStr   string
	currStyle Style
	lastStyle Style
	width     int
	lock      bool
	lastWidth int8 // width of lastStr, or 0 when lastStr marks a redraw
}

func (c *cell) setDirty(dirty bool) {
	if dirty {
		// Empty cells use currStr == "" until they are first drawn, at which
		// point SetDirty(false) normalizes them to a space.  Using "" as the
		// dirty marker for an untouched empty cell would therefore leave
		// lastStr == currStr and fail to force a redraw.
		if c.currStr == "" {
			c.lastStr = " "
		} else {
			c.lastStr = ""
		}
		c.lastWidth = 0
	} else {
		if c.currStr == "" {
			c.currStr = " "
		}
		c.lastStr = c.currStr
		c.lastStyle = c.currStyle
		c.lastWidth = int8(c.width)
	}
}

// CellBuffer represents a two-dimensional array of character cells.
// This is primarily intended for use by Screen implementers; it
// contains much of the common code they need.  To create one, just
// declare a variable of its type; no explicit initialization is necessary.
//
// CellBuffer is not thread safe.
type CellBuffer struct {
	w               int
	h               int
	cells           []cell
	sanitizeContent bool
}

// Put a single styled grapheme using the given string and style
// at the same location.  Note that only the first grapheme in the string
// will be displayed, using only the 1 or 2 (depending on width) cells
// located at x, y. It returns the rest of the string, and the width used.
func (cb *CellBuffer) Put(x int, y int, str string, style Style) (string, int) {
	if cb.sanitizeContent {
		str = stripOSCControlsIfNeeded(str)
	}
	return cb.put(x, y, str, style)
}

func (cb *CellBuffer) put(x int, y int, str string, style Style) (string, int) {
	var width int = 0
	if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
		var cl string
		c := &cb.cells[(y*cb.w)+x]
		if str == c.currStr && c.width > 0 {
			// Identical re-Put (a full-screen redraw): the grapheme split is
			// unchanged, so reuse the measured width instead of segmenting.
			cl, width, str = str, c.width, ""
		} else if len(str) > 0 && str[0] >= ' ' && str[0] <= '~' && (len(str) == 1 || str[1] < utf8.RuneSelf) {
			// Printable ASCII followed by ASCII cannot be part of a larger
			// grapheme cluster, so avoid constructing a grapheme iterator.
			cl, width, str = str[:1], 1, str[1:]
		} else if str == c.lastStr && c.lastWidth > 0 {
			// Putting back what is on screen, as after a Clear: reuse the
			// width measured when it was drawn.
			cl, width, str = str, int(c.lastWidth), ""
		} else {
			g := textWidthOptions.StringGraphemes(str)
			for width == 0 && g.Next() {
				cluster := g.Value()
				cl += cluster
				width = g.Width()
				str = str[len(cluster):]
			}
		}

		// Wide characters: we want to mark the "wide" cells
		// dirty as well as the base cell, to make sure we consider
		// both cells as dirty together.  We only need to do this
		// if we're changing content: compare with what is on screen, since
		// a Clear before the redraw leaves currStr as a space.
		if width > 1 && cl != c.lastStr {
			// Prevent unnecessary bounds checks for first cell, since we already
			// received that one.
			c.setDirty(true)
			for i := 1; i < width; i++ {
				cb.SetDirty(x+i, y, true)
			}
		}

		c.currStr = cl
		c.width = width

		if style.fg == ColorNone {
			style.fg = c.currStyle.fg
		}
		if style.bg == ColorNone {
			style.bg = c.currStyle.bg
		}
		c.currStyle = style
	}
	return str, width
}

// Get the contents of a character cell (or two adjacent cells), including the
// the style and the display width in cells.  (The width can be either 1, normally,
// or 2 for East Asian full-width characters.  If the width is 0, then the cell is
// is empty.)
func (cb *CellBuffer) Get(x, y int) (string, Style, int) {
	var style Style
	var width int
	var str string
	if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
		c := &cb.cells[(y*cb.w)+x]
		str, style = c.currStr, c.currStyle
		if width = c.width; width == 0 || str == "" {
			width = 1
			str = " "
		}
	}
	return str, style, width
}

// Size returns the (width, height) in cells of the buffer.
func (cb *CellBuffer) Size() (int, int) {
	return cb.w, cb.h
}

// Invalidate marks all characters within the buffer as dirty.
func (cb *CellBuffer) Invalidate() {
	for i := range cb.cells {
		cb.cells[i].setDirty(true)
	}
}

// Dirty checks if a character at the given location needs to be
// refreshed on the physical display.  This returns true if the cell
// content is different since the last time it was marked clean.
func (cb *CellBuffer) Dirty(x, y int) bool {
	if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
		c := &cb.cells[(y*cb.w)+x]
		if c.lock {
			return false
		}
		if c.lastStyle != c.currStyle {
			return true
		}
		if c.lastStr != c.currStr {
			return true
		}
	}
	return false
}

// SetDirty is normally used to indicate that a cell has
// been displayed (in which case dirty is false), or to manually
// force a cell to be marked dirty.
func (cb *CellBuffer) SetDirty(x, y int, dirty bool) {
	if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
		c := &cb.cells[(y*cb.w)+x]
		c.setDirty(dirty)
	}
}

// LockCell locks a cell from being drawn, effectively marking it "clean" until
// the lock is removed. This can be used to prevent tcell from drawing a given
// cell, even if the underlying content has changed. For example, when drawing a
// sixel graphic directly to a TTY screen an implementer must lock the region
// underneath the graphic to prevent tcell from drawing on top of the graphic.
func (cb *CellBuffer) LockCell(x, y int) {
	if x < 0 || y < 0 {
		return
	}
	if x >= cb.w || y >= cb.h {
		return
	}
	c := &cb.cells[(y*cb.w)+x]
	c.lock = true
}

// UnlockCell removes a lock from the cell and marks it as dirty
func (cb *CellBuffer) UnlockCell(x, y int) {
	if x < 0 || y < 0 {
		return
	}
	if x >= cb.w || y >= cb.h {
		return
	}
	c := &cb.cells[(y*cb.w)+x]
	c.lock = false
	cb.SetDirty(x, y, true)
}

// Resize is used to resize the cells array, with different dimensions,
// while preserving the original contents.  The cells will be invalidated
// so that they can be redrawn.
func (cb *CellBuffer) Resize(w, h int) {
	if cb.h == h && cb.w == w {
		return
	}

	newc := make([]cell, w*h)
	for y := 0; y < h && y < cb.h; y++ {
		for x := 0; x < w && x < cb.w; x++ {
			oc := &cb.cells[(y*cb.w)+x]
			nc := &newc[(y*w)+x]
			nc.currStr = oc.currStr
			nc.currStyle = oc.currStyle
			nc.width = oc.width
		}
	}
	cb.cells = newc
	cb.h = h
	cb.w = w
	cb.Invalidate()
}

// Fill fills the entire cell buffer array with the specified character
// and style.  Normally choose ' ' to clear the screen.  This API doesn't
// support combining characters, or characters with a width larger than one.
// If either the foreground or background are ColorNone, then the respective
// color is unchanged.
func (cb *CellBuffer) Fill(r rune, style Style) {
	str := string(r)
	for i := range cb.cells {
		c := &cb.cells[i]
		c.currStr = str
		cs := style
		if cs.fg == ColorNone {
			cs.fg = c.currStyle.fg
		}
		if cs.bg == ColorNone {
			cs.bg = c.currStyle.bg
		}
		c.currStyle = cs
		c.width = 1
	}
}

// FillArea fills a rectangular region of the cell buffer with the specified
// character and style.  The region starts at column x, row y and extends w
// columns to the right and h rows down; any part of it lying outside the
// buffer is simply skipped, so callers do not need to clip coordinates
// themselves.  A zero or negative width or height fills nothing.  As with
// Fill, this doesn't support combining characters or wide runes, and a
// ColorNone foreground or background leaves that color unchanged.
func (cb *CellBuffer) FillArea(x, y, w, h int, r rune, style Style) {
	if w <= 0 || h <= 0 {
		return
	}
	x0 := max(x, 0)
	y0 := max(y, 0)
	// Clip the far edge to the buffer without ever evaluating x+w or y+h when
	// they would overflow a signed int.  Because w and h are positive here,
	// x < cb.w-w is equivalent to x+w < cb.w but cannot overflow, and it is
	// only true when x+w is small enough to compute safely.
	x1 := cb.w
	if x < cb.w-w {
		x1 = x + w
	}
	y1 := cb.h
	if y < cb.h-h {
		y1 = y + h
	}
	if x0 >= x1 {
		return
	}
	str := string(r)
	for row := y0; row < y1; row++ {
		cells := cb.cells[row*cb.w+x0 : row*cb.w+x1]
		for i := range cells {
			c := &cells[i]
			c.currStr = str
			fg, bg := c.currStyle.fg, c.currStyle.bg
			c.currStyle = style
			if style.fg == ColorNone {
				c.currStyle.fg = fg
			}
			if style.bg == ColorNone {
				c.currStyle.bg = bg
			}
			c.width = 1
		}
	}
}