From 98407e4b89bbe60055ac7374bf047019b36f6c2b Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Sat, 30 May 2026 17:15:44 -0700 Subject: Rename to ils --- ils.go | 284 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 ils.go (limited to 'ils.go') 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) + } +} -- cgit v1.3.1