aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorRose Hogenson <rosehogenson@posteo.net>2026-05-27 21:20:44 -0700
committerRose Hogenson <rosehogenson@posteo.net>2026-05-27 21:20:44 -0700
commit317584d0df3aea8649377f9330cd0c991d8e29ec (patch)
tree5c5c2a9b1304d8bde1f89c91db023e651811941a
downloadimgutil-317584d0df3aea8649377f9330cd0c991d8e29ec.tar.zst
Initial commit
-rw-r--r--README.md7
-rw-r--r--go.mod14
-rw-r--r--go.sum10
-rw-r--r--thumbnailer.go256
4 files changed, 287 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..51a0039
--- /dev/null
+++ b/README.md
@@ -0,0 +1,7 @@
+# Thumbnailer
+
+Like ls but for images
+
+```
+go install roseh.moe/cmd/thumbnailer@latest
+```
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..2e358c5
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,14 @@
+module roseh.moe/cmd/thumbnailer
+
+go 1.24.3
+
+require (
+ github.com/mattn/go-runewidth v0.0.23
+ golang.org/x/image v0.28.0
+ golang.org/x/term v0.32.0
+)
+
+require (
+ github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
+ golang.org/x/sys v0.33.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..82bd425
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,10 @@
+github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
+github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
+github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
+golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
+golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
diff --git a/thumbnailer.go b/thumbnailer.go
new file mode 100644
index 0000000..40fb525
--- /dev/null
+++ b/thumbnailer.go
@@ -0,0 +1,256 @@
+package main
+
+import (
+ "bufio"
+ "errors"
+ "flag"
+ "fmt"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ runewidth "github.com/mattn/go-runewidth"
+ _ "golang.org/x/image/bmp"
+ "golang.org/x/image/draw"
+ _ "golang.org/x/image/tiff"
+ _ "golang.org/x/image/webp"
+ "golang.org/x/term"
+)
+
+var (
+ x = flag.Int("x", 15, "set image width in columns")
+ y = flag.Int("y", 10, "set image height in rows")
+)
+
+func isDir(filename string) (bool, error) {
+ stat, err := os.Stat(filename)
+ if err != nil {
+ return false, err
+ }
+ return stat.IsDir(), nil
+}
+
+func isLink(filename string) (bool, error) {
+ stat, err := os.Lstat(filename)
+ if err != nil {
+ return false, err
+ }
+ return stat.Mode().Type() == fs.ModeSymlink, nil
+}
+
+var errNotAnImage = errors.New("not an image")
+
+type notAnImageError struct {
+ error
+}
+
+func (e *notAnImageError) Is(err error) bool {
+ return err == errNotAnImage
+}
+
+func load(filename string) (image.Image, error) {
+ file := os.Stdin
+ if filename != "-" {
+ var err error
+ file, err = os.Open(filename)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ if isLink, _ := isLink(filename); isLink {
+ return nil, &notAnImageError{err}
+ }
+ }
+ return nil, err
+ }
+ defer file.Close()
+ }
+
+ img, _, err := image.Decode(file)
+ if err != nil {
+ return nil, &notAnImageError{fmt.Errorf("decode %q: %s", filename, err)}
+ }
+
+ return img, nil
+}
+
+func printImg(img image.Image, cols, lines int) (string, int) {
+ // Try not to stretch the image.
+ if lines == 0 || cols != 0 && img.Bounds().Dy()*cols <= img.Bounds().Dx()*lines*5/2 {
+ lines = img.Bounds().Dy() * cols / (img.Bounds().Dx() * 5 / 2)
+ } else {
+ cols = img.Bounds().Dx() * lines * 5 / 2 / img.Bounds().Dy()
+ }
+
+ dst := image.NewRGBA(image.Rect(0, 0, cols, 2*lines))
+ draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
+
+ s := new(strings.Builder)
+ for y := 0; y < 2*lines; y += 2 {
+ if y > 0 {
+ fmt.Fprintln(s)
+ }
+ for x := 0; x < cols; x++ {
+ hiR, hiG, hiB, _ := dst.At(x, y).RGBA()
+ loR, loG, loB, _ := dst.At(x, y+1).RGBA()
+ fmt.Fprintf(s, "\033[38;2;%d;%d;%dm\033[48;2;%d;%d;%dmâ–€",
+ hiR>>8, hiG>>8, hiB>>8,
+ loR>>8, loG>>8, loB>>8)
+ }
+ fmt.Fprint(s, "\033[39m\033[49m")
+ }
+ return s.String(), cols
+}
+
+type img struct {
+ s string
+ width int
+ filename string
+}
+
+func compositeImages(w io.Writer, images []img, imageWidth, imagesPerRow int) {
+ firstRow := true
+ for row := range slices.Chunk(images, imagesPerRow) {
+ if !firstRow {
+ fmt.Fprintln(w)
+ }
+ firstRow = false
+ rowLines := make([][]string, len(row))
+ names := make([][]string, len(row))
+ for i, img := range row {
+ rowLines[i] = strings.Split(img.s, "\n")
+ names[i] = strings.Split(runewidth.Wrap(img.filename, imageWidth), "\n")
+ }
+ maxLen := 0
+ for imgIdx, img := range rowLines {
+ maxLen = max(maxLen, len(img)+len(names[imgIdx]))
+ }
+ for i := 0; i < maxLen; i++ {
+ if i > 0 {
+ fmt.Fprintln(w)
+ }
+ for imgIdx, img := range rowLines {
+ if imgIdx > 0 {
+ fmt.Fprint(w, " ")
+ }
+ if i < len(img) {
+ fmt.Fprintf(w, "%s%*s", img[i], max(imageWidth-row[imgIdx].width, 0), "")
+ } else if i < len(img)+len(names[imgIdx]) {
+ fmt.Fprintf(w, "%s", runewidth.FillRight(names[imgIdx][i-len(img)], imageWidth))
+ } else {
+ fmt.Fprintf(w, "%*s", imageWidth, "")
+ }
+ }
+ }
+ fmt.Fprintln(w)
+ }
+}
+
+func thumbnailFiles(w io.Writer, cols, lines, imagesPerRow int, args []string) error {
+ var images []img
+ for _, arg := range args {
+ image, err := load(arg)
+ if err != nil {
+ if errors.Is(err, errNotAnImage) {
+ continue
+ }
+ return err
+ }
+ imageString, width := printImg(image, cols, lines)
+ images = append(images, img{imageString, width, filepath.Base(arg)})
+ }
+ compositeImages(w, images, cols, imagesPerRow)
+ return nil
+}
+
+func readDir(d string) ([]string, error) {
+ files, err := os.ReadDir(d)
+ if err != nil {
+ return nil, err
+ }
+ filenames := make([]string, len(files))
+ for i, f := range files {
+ filenames[i] = filepath.Join(d, f.Name())
+ }
+ return filenames, nil
+}
+
+func thumbnailer(args []string) error {
+ cols := *x
+ lines := *y
+ termCols, _, err := term.GetSize(1)
+ if err != nil {
+ return fmt.Errorf("terminal size: %s", err)
+ }
+
+ imagesPerRow := termCols / (cols + 1)
+
+ var files, dirs []string
+ switch len(args) {
+ case 0:
+ files, err = readDir(".")
+ if err != nil {
+ return err
+ }
+ case 1:
+ arg := args[0]
+ isDir, err := isDir(arg)
+ if err != nil {
+ return err
+ }
+ if isDir {
+ files, err = readDir(arg)
+ if err != nil {
+ return err
+ }
+ } else {
+ files = []string{arg}
+ }
+ default:
+ for _, arg := range args {
+ isDir, err := isDir(arg)
+ if err != nil {
+ return err
+ }
+ if isDir {
+ dirs = append(dirs, arg)
+ } else {
+ files = append(files, arg)
+ }
+ }
+ }
+ w := bufio.NewWriter(os.Stdout)
+ defer w.Flush()
+ if err := thumbnailFiles(w, cols, lines, imagesPerRow, files); err != nil {
+ return err
+ }
+ for _, d := range dirs {
+ fmt.Fprintf(w, "\n%s:\n", d)
+ dirFiles, err := readDir(d)
+ if err != nil {
+ return err
+ }
+ if err := thumbnailFiles(w, cols, lines, imagesPerRow, dirFiles); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "Usage: icat FILE\n")
+ }
+ flag.Parse()
+
+ if err := thumbnailer(flag.Args()); err != nil {
+ fmt.Fprintf(os.Stderr, "Failed: %s\n", err)
+ os.Exit(1)
+ }
+}