diff options
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | go.mod | 14 | ||||
| -rw-r--r-- | go.sum | 10 | ||||
| -rw-r--r-- | thumbnailer.go | 256 |
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 +``` @@ -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 +) @@ -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, ¬AnImageError{err} + } + } + return nil, err + } + defer file.Close() + } + + img, _, err := image.Decode(file) + if err != nil { + return nil, ¬AnImageError{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) + } +} |
