From cdf60bc79fc90f0ab86b8b9b3c60667be3bbe353 Mon Sep 17 00:00:00 2001 From: Oliver <480930+rivo@users.noreply.github.com> Date: Sun, 25 Dec 2022 11:54:49 +0100 Subject: [PATCH] Started implementation of Image widget. --- image.go | 278 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ util.go | 4 +- 2 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 image.go diff --git a/image.go b/image.go new file mode 100644 index 0000000..88f4b78 --- /dev/null +++ b/image.go @@ -0,0 +1,278 @@ +package tview + +import ( + "image" + "math" +) + +// Types of dithering applied to images. +const ( + ImageDitheringNone = iota // No dithering. + ImageDitheringThreshold // Grey scale thresholding at 50%. + ImageDitheringFloydSteinberg // Floyd-Steinberg dithering (the default). +) + +// The number of colors supported by true color terminals (R*G*B = 256*256*256). +const TrueColor = 16777216 + +// Image implements a widget that displays one image. The original image +// (specified with [SetImage]) is resized according to the widget's size (see +// [SetSize]), using the colors available in the terminal (see [SetColors]), +// applying dithering if necessary (see [SetDithering]). +// +// Images are approximated by graphical characters in the terminal. The +// resolution is therefore limited by the number of characters that can be drawn +// in the terminal and the colors available in the terminal. +// +// Don't rely on the exact pixels drawn by this widget. The image drawing +// algorithm may change in the future to improve the appearance of the image. +type Image struct { + *Box + + // The image to be displayed. If nil, the widget will be empty. + image image.Image + + // The size of the image. If a value is 0, the corresponding size is chosen + // automatically based on the other size while preserving the image's aspect + // ratio. If both are 0, the image uses as much space as possible. A + // negative value represents a percentage, e.g. -50 means 50% of the + // available space. + width, height int + + // The number of colors to use. If 0, the number of colors is chosen based + // on the terminal's capabilities. + colors int + + // The dithering algorithm to use, one of the constants starting with + // "ImageDithering". + dithering int + + // The background color to use (RGB) for transparent pixels. + backgroundColor [3]int8 + + // The width of a terminal's cell divided by its height. + aspectRatio float64 + + // Horizontal and vertical alignment, one of the "Align" constants. + alignHorizontal, alignVertical int + + // The actual image size (in cells) when it was drawn the last time. + lastWidth, lastHeight int + + // The actual image (in cells) when it was drawn the last time. The size of + // this slice is 4 * lastWidth * lastHeight (with a factor of 4 because we + // can draw four pixels per cell), indexed by y*lastWidth*2 + x. Each pixel + // is an RGB value (0-255). + pixels [][3]int +} + +// NewImage returns a new image widget with an empty image (use [SetImage] to +// specify the image to be displayed). The image will use the widget's entire +// available space. The dithering algorithm is set to Floyd-Steinberg dithering. +// The terminal's cell aspect ratio is set to 1. +func NewImage() *Image { + return &Image{ + Box: NewBox(), + dithering: ImageDitheringFloydSteinberg, + aspectRatio: 1, + alignHorizontal: AlignCenter, + alignVertical: AlignCenter, + } +} + +// SetImage sets the image to be displayed. If nil, the widget will be empty. +func (i *Image) SetImage(image image.Image) *Image { + i.image = image + i.lastWidth, i.lastHeight = 0, 0 + return i +} + +// SetSize sets the size of the image. Positive values refer to cells in the +// terminal. Negative values refer to a percentage of the available space (e.g. +// -50 means 50%). A value of 0 means that the corresponding size is chosen +// automatically based on the other size while preserving the image's aspect +// ratio. If both are 0, the image uses as much space as possible while still +// preserving the aspect ratio. +func (i *Image) SetSize(rows, columns int) *Image { + i.width = columns + i.height = rows + return i +} + +// SetColors sets the number of colors to use. This should be the number of +// colors supported by the terminal. If 0, the number of colors is chosen based +// on the $TERM environment variable (which may or may not be reliable). +// +// Only the values 0, 2, 8, 256, and 16777216 ([TrueColor]) are supported. Other +// values will be rounded up to the next supported value, to a maximum of +// 16777216. +// +// The effect of using more colors than supported by the terminal is undefined. +func (i *Image) SetColors(colors int) *Image { + i.colors = colors + i.lastWidth, i.lastHeight = 0, 0 + return i +} + +// SetDithering sets the dithering algorithm to use, one of the constants +// starting with "ImageDithering", for example [ImageDitheringFloydSteinberg]. +func (i *Image) SetDithering(dithering int) *Image { + i.dithering = dithering + i.lastWidth, i.lastHeight = 0, 0 + return i +} + +// SetBackgroundColor sets the background color to use (RGB) for transparent +// pixels in the original image. The default is black (0, 0, 0). +func (i *Image) SetBackgroundColor(r, g, b int8) *Image { + i.backgroundColor = [3]int8{r, g, b} + i.lastWidth, i.lastHeight = 0, 0 + return i +} + +// SetAspectRatio sets the width of a terminal's cell divided by its height. +// You may change the default of 1 if your terminal uses a different aspect +// ratio. This is used to calculate the size of the image if one of the sizes +// is 0. The function will panic if the aspect ratio is 0 or less. +func (i *Image) SetAspectRatio(aspectRatio float64) *Image { + if aspectRatio <= 0 { + panic("aspect ratio must be greater than 0") + } + i.aspectRatio = aspectRatio + i.lastWidth, i.lastHeight = 0, 0 + return i +} + +// SetAlign sets the vertical and horizontal alignment of the image within the +// widget's space. The possible values are [AlignTop], [AlignCenter], and +// [AlignBottom] for vertical alignment and [AlignLeft], [AlignCenter], and +// [AlignRight] for horizontal alignment. The default is [AlignCenter] for both. +func (i *Image) SetAlign(vertical, horizontal int) *Image { + i.alignHorizontal = horizontal + i.alignVertical = vertical + return i +} + +// render re-populates the [Image.pixels] slice besed on the current settings, +// if [Image.lastWidth] and [Image.lastHeight] don't match the current image's +// size. It also sets the new image size in these two variables. +func (i *Image) render() { + // If there is no image, there are no pixels. + if i.image == nil { + i.pixels = nil + return + } + + // Calculate the new (terminal-space) image size. + bounds := i.image.Bounds() + imageWidth, imageHeight := bounds.Dx(), bounds.Dy() + if i.aspectRatio != 1.0 { + imageWidth = int(float64(imageWidth) / i.aspectRatio) + } + width, height := i.width, i.height + _, _, innerWidth, innerHeight := i.GetInnerRect() + if width == 0 && height == 0 { + // Use all available space. + width, height = innerWidth, innerHeight + if adjustedWidth := imageWidth * height / imageHeight; adjustedWidth < width { + width = adjustedWidth + } else { + height = imageHeight * width / imageWidth + } + } else { + // Turn percentages into absolute values. + if width < 0 { + width = innerWidth * -width / 100 + } + if height < 0 { + height = innerHeight * -height / 100 + } + if width == 0 { + // Adjust the width. + width = imageWidth * height / imageHeight + } else if height == 0 { + // Adjust the height. + height = imageHeight * width / imageWidth + } + } + if width <= 0 || height <= 0 { + i.pixels = nil + return + } + + // If nothing has changed, we're done. + if i.lastWidth == width && i.lastHeight == height { + return + } + i.lastWidth, i.lastHeight = width, height // This could still be larger than the available space but that's ok for now. + + // Generate the initial pixels by resizing the image. + i.resize() +} + +// resize resizes the image to the current size and stores the result in +// [Image.pixels]. It is assumed that [Image.lastWidth] and [Image.lastHeight] +// are positive values. +func (i *Image) resize() { + // Because most of the time, we will be downsizing the image, we don't even + // attempt to do any fancy interpolation. For each target pixel, we + // calculate a weighted average of the source pixels using their coverage + // area. + + bounds := i.image.Bounds() + srcWidth, srcHeight := bounds.Dx(), bounds.Dy() + tgtWidth, tgtHeight := i.lastWidth*2, i.lastHeight*2 + coverageWidth, coverageHeight := float64(srcWidth)/float64(tgtWidth), float64(srcHeight)/float64(tgtHeight) + i.pixels = make([][3]int, tgtWidth*tgtHeight) + weights := make([]float64, tgtWidth*tgtHeight) + for srcY := bounds.Min.Y; srcY < bounds.Max.Y; srcY++ { + for srcX := bounds.Min.X; srcX < bounds.Max.X; srcX++ { + r32, g32, b32, _ := i.image.At(srcX, srcY).RGBA() + r, g, b := int(r32>>8), int(g32>>8), int(b32>>8) + + // Iterate over all target pixels. Outer loop is Y. + startY := float64(srcY-bounds.Min.Y) * coverageHeight + endY := startY + coverageHeight + fromY, toY := int(startY), int(endY) + for tgtY := fromY; tgtY <= toY && tgtY < tgtHeight; tgtY++ { + coverageY := 1.0 + if tgtY == fromY { + coverageY -= math.Mod(startY, 1.0) + } + if tgtY == toY { + coverageY -= 1.0 - math.Mod(endY, 1.0) + } + + // Inner loop is X. + startX := float64(srcX-bounds.Min.X) * coverageWidth + endX := startX + coverageWidth + fromX, toX := int(startX), int(endX) + for tgtX := fromX; tgtX <= toX && tgtX < tgtWidth; tgtX++ { + coverageX := 1.0 + if tgtX == fromX { + coverageX -= math.Mod(startX, 1.0) + } + if tgtX == toX { + coverageX -= 1.0 - math.Mod(endX, 1.0) + } + + // Add a weighted contribution to the target pixel. + index := tgtY*tgtWidth + tgtX + i.pixels[index][0] += r + i.pixels[index][1] += g + i.pixels[index][2] += b + weights[index] += coverageX * coverageY + } + } + } + } + + // Normalize the pixels. + for index, weight := range weights { + if weight > 0 { + i.pixels[index][0] = int(float64(i.pixels[index][0]) / weight) + i.pixels[index][1] = int(float64(i.pixels[index][1]) / weight) + i.pixels[index][2] = int(float64(i.pixels[index][2]) / weight) + } + } +} diff --git a/util.go b/util.go index b80b86e..f48f547 100644 --- a/util.go +++ b/util.go @@ -10,11 +10,13 @@ import ( "github.com/rivo/uniseg" ) -// Text alignment within a box. +// Text alignment within a box. Also used to align images. const ( AlignLeft = iota AlignCenter AlignRight + AlignTop = 0 + AlignBottom = 2 ) // Common regular expressions.