aboutsummaryrefslogtreecommitdiffstats
path: root/ils.go
diff options
context:
space:
mode:
authorRose Hogenson <rosehogenson@posteo.net>2026-05-30 17:15:44 -0700
committerRose Hogenson <rosehogenson@posteo.net>2026-05-30 17:15:44 -0700
commit98407e4b89bbe60055ac7374bf047019b36f6c2b (patch)
treef78e9ea2a7a6b2342fac2c8c5cdb164940814a40 /ils.go
parentBump deps (diff)
downloadimgutil-98407e4b89bbe60055ac7374bf047019b36f6c2b.tar.zst
Rename to ils
Diffstat (limited to 'ils.go')
-rw-r--r--ils.go284
1 files changed, 284 insertions, 0 deletions
diff --git a/ils.go b/ils.go
new file mode 100644
index 0000000..55a8eb2
--- /dev/null
+++ b/ils.go
@@ -0,0 +1,284 @@
+package main
+
+import (
+ "bufio"
+ "flag"
+ "fmt"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "io"
+ "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/sys/unix"
+ "roseh.moe/pkg/sixel"
+)
+
+var (
+ x = flag.Int("x", 15, "set image width in columns")
+ m = flagPrintMode(flag.CommandLine, "m", modeSixel, "one of 'block', 'block24', or 'sixel'")
+)
+
+type printMode int
+
+const (
+ modeInvalid printMode = iota
+ modeBlock
+ modeBlock24
+ modeSixel
+)
+
+type printModeValue printMode
+
+func (m *printModeValue) String() string {
+ switch printMode(*m) {
+ case modeBlock:
+ return "block"
+ case modeBlock24:
+ return "block24"
+ case modeSixel:
+ return "sixel"
+ default:
+ return "invalid"
+ }
+}
+
+func (m *printModeValue) Set(s string) error {
+ var mode printMode
+ switch s {
+ case "block":
+ mode = modeBlock
+ case "block24":
+ mode = modeBlock24
+ case "sixel":
+ mode = modeSixel
+ default:
+ return fmt.Errorf("bad mode type %q, should be one of 'block', 'block24', or 'sixel'", s)
+ }
+ *m = printModeValue(mode)
+ return nil
+}
+
+func flagPrintMode(fs *flag.FlagSet, name string, value printMode, usage string) *printMode {
+ fs.Var((*printModeValue)(&value), name, usage)
+ return &value
+}
+
+func isDir(filename string) bool {
+ stat, err := os.Stat(filename)
+ return err == nil && stat.IsDir()
+}
+
+func isImage(filename string) bool {
+ f, err := os.Open(filename)
+ if err != nil {
+ return false
+ }
+ defer f.Close()
+ _, _, err = image.DecodeConfig(f)
+ return err == nil
+}
+
+func imageDimensions(filename string) (width, height int, err error) {
+ f, err := os.Open(filename)
+ if err != nil {
+ return 0, 0, err
+ }
+ defer f.Close()
+ config, _, err := image.DecodeConfig(f)
+ return config.Width, config.Height, nil
+}
+
+func load(filename string) (image.Image, error) {
+ f, err := os.Open(filename)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ img, _, err := image.Decode(f)
+ if err != nil {
+ return nil, fmt.Errorf("decode %q: %s", filename, err)
+ }
+
+ return img, nil
+}
+
+func printImg(w io.Writer, img image.Image) {
+ switch *m {
+ case modeBlock:
+ sixel.PrintXTerm16(w, img)
+ case modeBlock24:
+ sixel.PrintBlock(w, img)
+ case modeSixel:
+ sixel.Print(w, img, nil)
+ default:
+ panic("invalid mode")
+ }
+}
+
+type pixelAspectRatio struct {
+ x, y int
+}
+
+func compositeImageRow(imagePaths []string, width, padding int, aspectRatio pixelAspectRatio) (image.Image, error) {
+ resultWidth := width*len(imagePaths) + padding*(len(imagePaths)-1)
+ maxHeight := 0
+ for _, filename := range imagePaths {
+ imageWidth, imageHeight, err := imageDimensions(filename)
+ if err != nil {
+ return nil, err
+ }
+ maxHeight = max(maxHeight, imageHeight*width/(imageWidth*aspectRatio.y/aspectRatio.x))
+ }
+ bounds := image.Rect(0, 0, resultWidth, maxHeight)
+ result := image.NewRGBA(bounds)
+ offset := 0
+ for _, filename := range imagePaths {
+ img, err := load(filename)
+ if err != nil {
+ return nil, err
+ }
+ height := img.Bounds().Dy() * width / (img.Bounds().Dx() * aspectRatio.y / aspectRatio.x)
+ dstBounds := image.Rect(offset, (maxHeight-height)/2, offset+width, (maxHeight-height)/2+height)
+ draw.BiLinear.Scale(result, dstBounds, img, img.Bounds(), draw.Src, nil)
+ offset += width + padding
+ }
+ return result, nil
+}
+
+func thumbnailFiles(w io.Writer, imagePaths []string, width, imagesPerRow, cellX, cellY int) error {
+ imageWidth := width
+ padding := 1
+ aspectRatio := pixelAspectRatio{1, 1}
+ switch *m {
+ case modeBlock:
+ aspectRatio = pixelAspectRatio{cellX, cellY}
+ case modeBlock24:
+ aspectRatio = pixelAspectRatio{2 * cellX, cellY}
+ case modeSixel:
+ imageWidth = width * cellX
+ padding = cellX
+ }
+ for row := range slices.Chunk(imagePaths, imagesPerRow) {
+ rowImage, err := compositeImageRow(row, imageWidth, padding, aspectRatio)
+ if err != nil {
+ return err
+ }
+ printImg(w, rowImage)
+ names := make([][]string, len(row))
+ maxLen := 0
+ for i, filename := range row {
+ names[i] = strings.Split(runewidth.Wrap(filepath.Base(filename), width), "\n")
+ maxLen = max(maxLen, len(names[i]))
+ }
+ for i := 0; i < maxLen; i++ {
+ fmt.Fprintln(w)
+ for imgIdx, name := range names {
+ if imgIdx > 0 {
+ fmt.Fprint(w, " ")
+ }
+ if i < len(name) {
+ fmt.Fprintf(w, "%s", runewidth.FillRight(name[i], width))
+ } else {
+ fmt.Fprintf(w, "%*s", width, "")
+ }
+ }
+ }
+ fmt.Fprintln(w)
+ }
+ 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 thumbnailDir(w io.Writer, dir string, width, imagesPerRow, cellX, cellY int) error {
+ files, err := readDir(dir)
+ if err != nil {
+ return err
+ }
+ files = slices.DeleteFunc(files, func(filename string) bool { return !isImage(filename) })
+ return thumbnailFiles(w, files, width, imagesPerRow, cellX, cellY)
+}
+
+func thumbnailer(args []string) error {
+ cols := *x
+ if *x <= 0 {
+ return fmt.Errorf("-x must be greater than 0")
+ }
+ ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
+ if err != nil {
+ return fmt.Errorf("terminal size: %s", err)
+ }
+ termCols := int(ws.Col)
+ cellX, cellY := int(ws.Xpixel)/int(ws.Col), int(ws.Ypixel)/int(ws.Row)
+
+ cols = min(cols, termCols)
+ imagesPerRow := (termCols + 1) / (cols + 1)
+
+ var files, dirs []string
+ if len(args) == 0 {
+ dirs = []string{"."}
+ } else {
+ for _, arg := range args {
+ if isDir(arg) {
+ dirs = append(dirs, arg)
+ } else {
+ files = append(files, arg)
+ }
+ }
+ }
+ w := bufio.NewWriter(os.Stdout)
+ defer w.Flush()
+ if err := thumbnailFiles(w, files, cols, imagesPerRow, cellX, cellY); err != nil {
+ return err
+ }
+ if len(files) > 0 && len(dirs) > 0 {
+ fmt.Fprintln(w)
+ }
+ for i, d := range dirs {
+ if i > 0 {
+ fmt.Fprintln(w)
+ }
+ if len(dirs) > 1 {
+ fmt.Fprintf(w, "%s:\n", d)
+ }
+ if err := thumbnailDir(w, d, cols, imagesPerRow, cellX, cellY); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "Usage: thumbnailer [OPTION]... [FILE]...\n")
+ flag.PrintDefaults()
+ }
+ flag.Parse()
+
+ if err := thumbnailer(flag.Args()); err != nil {
+ fmt.Fprintf(os.Stderr, "Failed: %s\n", err)
+ os.Exit(1)
+ }
+}