Skip to content

Commit

Permalink
Truecolor and ansi 216+16 colors support (#31)
Browse files Browse the repository at this point in the history
* Support truecolor terminals

* Support ansi 216+16 colors

* update sshot

* Fixed Apple terminal 256 bug/issue

* comment about issue with mac's terminal
  • Loading branch information
ldemailly authored Sep 24, 2024
1 parent 3491485 commit 8d75a03
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 9 deletions.
3 changes: 3 additions & 0 deletions ansipixels/ansipixels.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ type AnsiPixels struct {
W, H int // Width and Height
x, y int // Cursor position
C chan os.Signal
// Should image be monochrome, 256 or true color
TrueColor bool
Color bool // 256 (216) color mode
}

func NewAnsiPixels() *AnsiPixels {
Expand Down
25 changes: 23 additions & 2 deletions ansipixels/fps/fps.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,23 @@ func animate(ap *ansipixels.AnsiPixels, frame uint) {
//go:embed fps.jpg
var fpsJpg []byte

func Main() int {
//go:embed fps_colors.jpg
var fpsColorsJpg []byte

func Main() int { //nolint:funlen // color if/else are a bit long.
defaultTrueColor := false
if os.Getenv("COLORTERM") != "" {
defaultTrueColor = true
}
defaultColor := false
if os.Getenv("TERM") == "xterm-256color" {
defaultColor = true
}
imgFlag := flag.String("image", "", "Image file to display in monochrome in the background instead of the default one")
colorFlag := flag.Bool("color", defaultColor,
"If your terminal supports color, this will load image in (216) colors instead of monochrome")
trueColorFlag := flag.Bool("truecolor", defaultTrueColor,
"If your terminal supports truecolor, this will load image in truecolor (24bits) instead of monochrome")
cli.MinArgs = 0
cli.MaxArgs = 1
cli.ArgsHelp = "[maxfps]"
Expand All @@ -109,6 +124,8 @@ func Main() int {
if err := ap.Open(); err != nil {
log.Fatalf("Not a terminal: %v", err)
}
ap.TrueColor = *trueColorFlag
ap.Color = *colorFlag
defer func() {
ap.ShowCursor()
ap.MoveCursor(0, ap.H-2)
Expand All @@ -122,7 +139,11 @@ func Main() int {
var background *image.RGBA
var err error
if *imgFlag == "" {
background, err = ap.DecodeImage(bytes.NewReader(fpsJpg))
if *trueColorFlag || *colorFlag {
background, err = ap.DecodeImage(bytes.NewReader(fpsColorsJpg))
} else {
background, err = ap.DecodeImage(bytes.NewReader(fpsJpg))
}
} else {
background, err = ap.ReadImage(*imgFlag)
}
Expand Down
Binary file added ansipixels/fps/fps_colors.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
124 changes: 117 additions & 7 deletions ansipixels/image.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ansipixels

import (
"fmt"
"image"
"image/color"
_ "image/gif" // Import GIF decoder
Expand All @@ -10,6 +11,7 @@ import (
"os"

"fortio.org/log"
"fortio.org/safecast"
"golang.org/x/image/draw"
)

Expand All @@ -25,13 +27,113 @@ const (
BottomHalfPixel = '▄'
)

func (ap *AnsiPixels) DrawImage(sx, sy int, img *image.Gray, color string) error {
func (ap *AnsiPixels) DrawTrueColorImage(sx, sy int, img *image.RGBA) error {
ap.MoveCursor(sx, sy)
var err error
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y += 2 {
prev1 := color.RGBA{}
prev2 := color.RGBA{}
ap.WriteAt(sx, sy, "\033[38;5;%dm\033[48;5;%dm", 0, 0)
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
pixel1 := img.RGBAAt(x, y)
pixel2 := img.RGBAAt(x, y+1)
switch {
case pixel1 == pixel2:
if pixel1 == prev1 {
_, _ = ap.Out.WriteRune('█')
continue // we haven't changed color
}
if pixel2 == prev2 {
_, _ = ap.Out.WriteRune(' ')
continue // we haven't changed color
}
_, _ = ap.Out.WriteString(fmt.Sprintf("\033[38;2;%d;%d;%dm█", pixel1.R, pixel1.G, pixel1.B))
prev1 = pixel1
continue
case pixel1 == prev1 && pixel2 == prev2:
_, _ = ap.Out.WriteRune('▀')
default:
_, _ = ap.Out.WriteString(fmt.Sprintf("\033[38;2;%d;%d;%dm\033[48;2;%d;%d;%dm▀",
pixel1.R, pixel1.G, pixel1.B,
pixel2.R, pixel2.G, pixel2.B))
}
prev1 = pixel1
prev2 = pixel2
}
sy++
ap.MoveCursor(sx, sy)
}
_, err = ap.Out.WriteString("\033[0m") // reset color
return err
}

func convertColorTo216(pixel color.RGBA) uint8 {
// Check if grayscale
shift := 2
if (pixel.R>>shift) == (pixel.G>>shift) && (pixel.G>>shift) == (pixel.B>>shift) {
// Bugged:
// lum := safecast.MustConvert[uint8](max(255, math.Round(0.299*float64(pixel.R)+
// 0.587*float64(pixel.G)+0.114*float64(pixel.B))))
lum := (uint16(pixel.R) + uint16(pixel.G) + uint16(pixel.B)) / 3
return 232 + safecast.MustConvert[uint8](lum*23/255)
}
// 6x6x6 color cube
col := 16 + 36*(pixel.R/51) + 6*(pixel.G/51) + pixel.B/51
return col
}

func (ap *AnsiPixels) Draw216ColorImage(sx, sy int, img *image.RGBA) error {
var err error
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y += 2 {
prevFg := uint8(0)
prevBg := uint8(0)
ap.WriteAt(sx, sy, "\033[38;5;%dm\033[48;5;%dm", 0, 0)
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
pixel1 := img.RGBAAt(x, y)
pixel2 := img.RGBAAt(x, y+1)
fgColor := convertColorTo216(pixel1)
bgColor := convertColorTo216(pixel2)
switch {
case fgColor == prevFg && bgColor == prevBg:
_, _ = ap.Out.WriteRune('▄')
/*
case fgColor == bgColor:
if fgColor == prevFg {
_, _ = ap.Out.WriteRune('█')
continue // we haven't changed bg color
}
if bgColor == prevBg {
_, _ = ap.Out.WriteRune(' ')
continue // we haven't changed fg color
}
_, _ = ap.Out.WriteString(fmt.Sprintf("\033[38;5;%dm█", fgColor))
prevFg = fgColor
continue
case fgColor == prevFg:
_, _ = ap.Out.WriteString(fmt.Sprintf("\033[48;5;%dm▄", bgColor))
case bgColor == prevBg:
_, _ = ap.Out.WriteString(fmt.Sprintf("\033[38;5;%dm▄", fgColor))
*/
default:
// Apple's macOS terminal needs lower half pixel or there are gaps where the background shows.
_, _ = ap.Out.WriteString(fmt.Sprintf("\033[38;5;%dm\033[48;5;%dm▄", bgColor, fgColor))
}
prevFg = fgColor
prevBg = bgColor
}
sy++
}
_, err = ap.Out.WriteString("\033[0m") // reset color
return err
}

func (ap *AnsiPixels) DrawMonoImage(sx, sy int, img *image.Gray, color string) error {
ap.WriteAtStr(sx, sy, color)
var err error
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y += 2 {
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
pixel1 := img.GrayAt(x, y).Y > 127
pixel2 := img.GrayAt(x, y+1).Y > 127
pixel1 := img.GrayAt(x, y).Y > 90
pixel2 := img.GrayAt(x, y+1).Y > 90
switch {
case pixel1 && pixel2:
_, _ = ap.Out.WriteRune(FullPixel)
Expand Down Expand Up @@ -67,7 +169,7 @@ func grayScaleImage(rgbaImg *image.RGBA) *image.Gray {
return grayImg
}

func resizeAndCenter(img *image.Gray, maxW, maxH int) *image.Gray {
func resizeAndCenter(img *image.RGBA, maxW, maxH int) *image.RGBA {
// Get original image dimensions
origBounds := img.Bounds()
origW := origBounds.Dx()
Expand All @@ -82,14 +184,14 @@ func resizeAndCenter(img *image.Gray, maxW, maxH int) *image.Gray {
newW := int(float64(origW) * scale)
newH := int(float64(origH) * scale)

canvas := image.NewGray(image.Rect(0, 0, maxW, maxH)) // transparent background (aka black for ANSI)
canvas := image.NewRGBA(image.Rect(0, 0, maxW, maxH))

// Calculate the offset to center the image
offsetX := (maxW - newW) / 2
offsetY := (maxH - newH) / 2

// Resize the image
resized := image.NewGray(image.Rect(0, 0, newW, newH))
resized := image.NewRGBA(image.Rect(0, 0, newW, newH))
draw.CatmullRom.Scale(resized, resized.Bounds(), img, origBounds, draw.Over, nil)
draw.Draw(canvas, image.Rect(offsetX, offsetY, offsetX+newW, offsetY+newH), resized, image.Point{}, draw.Over)
return canvas
Expand Down Expand Up @@ -124,10 +226,18 @@ func (ap *AnsiPixels) DecodeImage(data io.Reader) (*image.RGBA, error) {
return convertToRGBA(img), nil
}

// Color string is the fallback mono color to use when AnsiPixels.TrueColor is false.
func (ap *AnsiPixels) ShowImage(imgRGBA *image.RGBA, colorString string) error {
err := ap.GetSize()
if err != nil {
return err
}
return ap.DrawImage(1, 1, resizeAndCenter(grayScaleImage(imgRGBA), ap.W-2, 2*ap.H-2), colorString)
switch {
case ap.TrueColor:
return ap.DrawTrueColorImage(1, 1, resizeAndCenter(imgRGBA, ap.W-2, 2*ap.H-2))
case ap.Color:
return ap.Draw216ColorImage(1, 1, resizeAndCenter(imgRGBA, ap.W-2, 2*ap.H-2))
default:
return ap.DrawMonoImage(1, 1, grayScaleImage(resizeAndCenter(imgRGBA, ap.W-2, 2*ap.H-2)), colorString)
}
}
Binary file modified fps_sshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 8d75a03

Please sign in to comment.