mirror of https://github.com/rivo/tview.git
Started implementation of Image widget.
This commit is contained in:
parent
9c04916f4e
commit
cdf60bc79f
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
4
util.go
4
util.go
|
@ -10,11 +10,13 @@ import (
|
||||||
"github.com/rivo/uniseg"
|
"github.com/rivo/uniseg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Text alignment within a box.
|
// Text alignment within a box. Also used to align images.
|
||||||
const (
|
const (
|
||||||
AlignLeft = iota
|
AlignLeft = iota
|
||||||
AlignCenter
|
AlignCenter
|
||||||
AlignRight
|
AlignRight
|
||||||
|
AlignTop = 0
|
||||||
|
AlignBottom = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
// Common regular expressions.
|
// Common regular expressions.
|
||||||
|
|
Loading…
Reference in New Issue