package main import ( "archive/zip" _ "embed" "fmt" "image" "io" "log" "os" "path/filepath" "slices" "strconv" "strings" "git.yetaga.in/alazyreader/why/filetypes" "github.com/dblezek/tga" "github.com/disintegration/imaging" "github.com/maruel/natural" "golang.org/x/image/webp" tk "modernc.org/tk9.0" ) type state struct { dir string i int images []imagefile } type imagefile struct { contents *image.Image filename string } //go:embed media/noise.png var noise []byte // this is a default image //go:embed media/icon.png var icon []byte // this is the app icon //go:generate go run ./filetypes/cmd/gen.go var validFileTypes = filetypes.Valid var fileList *tk.ToplevelWidget var lb *tk.ListboxWidget var img = tk.Label() var fileListBindVar = tk.Variable("FileList") var directoryState state func (d state) pathToImageAtIndex(i int) string { return filepath.Join(directoryState.dir, directoryState.images[i].filename) } func (d state) imageWithFilename(s string) (imagefile, int, error) { for i, f := range directoryState.images { if f.filename == s { return directoryState.images[i], i, nil } } return imagefile{}, 0, fmt.Errorf("not found") } func (d state) setImage(i int, img *image.Image) { d.images[i].contents = img } func must[T any](t T, err error) T { if err != nil { panic(err) } return t } func checkErr[T any](_ T, err error) bool { return err == nil } func newFileInDirectory() { files := tk.GetOpenFile(tk.Filetypes(filetypes.GetTkTypes(validFileTypes)), tk.Multiple(false)) if len(files) < 1 || files[0] == "" { log.Println("no file chosen") return } // GetOpenFile returns an array split on spaces! file := strings.Join(files, " ") if filepath.Ext(file) == ".cbr" || filepath.Ext(file) == ".cbz" { log.Println("Comic Book Archive!") openArchive(file) return } newBrowse(file) } func openArchive(path string) { r, err := zip.OpenReader(path) if err != nil { log.Println(err) return } clearFileList() directoryState.images = []imagefile{} for _, f := range r.File { log.Println(f.FileInfo().Name()) if f.FileInfo().IsDir() { continue } if f.FileInfo().Name()[0] == '.' { continue } reader, err := f.Open() if err != nil { log.Println(err) continue } i, err := decode(reader, f.FileInfo().Name()) if err != nil { log.Println(err) continue } directoryState.images = append(directoryState.images, imagefile{ contents: &i, filename: f.FileInfo().Name(), }) insertIntoFileList(f.FileInfo().Name()) } moveSelectionInFileList(0) updateImage(directoryState.images[0].filename) } func newDirectory() { dir := tk.ChooseDirectory() if dir == "" { log.Println("no directory chosen") return } dirfiles, err := os.ReadDir(dir) if err != nil { log.Println("could not read chosen directory") return } for _, v := range dirfiles { // loop until we find an image to start the browser with if filetypes.IsImage(v) { newBrowse(filepath.Join(dir, v.Name())) return } } log.Printf("no images found in dir: %s", dir) } func newBrowse(file string) { directoryState.dir = filepath.Dir(file) dirfiles, err := os.ReadDir(directoryState.dir) if err != nil { log.Printf("could not open dir: %v", err) return } // natsort the directory listing first... slices.SortFunc(dirfiles, func(a, b os.DirEntry) int { if natural.Less(a.Name(), b.Name()) { return -1 } return 1 }) // ...that way we only have to do this loop once. clearFileList() directoryState.images = []imagefile{} i := 0 for _, v := range dirfiles { if filetypes.IsImage(v) { directoryState.images = append(directoryState.images, imagefile{filename: v.Name()}) insertIntoFileList(directoryState.images[i].filename) if v.Name() == filepath.Base(file) { directoryState.i = i moveSelectionInFileList(i) } i++ } } updateImage(file) } func updateImage(file string) { var i image.Image entry, index, err := directoryState.imageWithFilename(filepath.Base(file)) if err != nil { log.Println(err.Error()) return } if entry.contents != nil { fmt.Printf("loaded %s from cache\n", file) i = *entry.contents } else { f, err := os.Open(file) if err != nil { log.Printf("error opening image: %v", err) return } defer f.Close() i, err = decode(f, file) if err != nil { log.Printf("error decoding image: %v", err) return } directoryState.setImage(index, &i) } i = imaging.Fit(i, // -50 to give some space to breathe around the edges must(strconv.Atoi(tk.WinfoScreenWidth(tk.App)))-50, must(strconv.Atoi(tk.WinfoScreenHeight(tk.App)))-50, imaging.CatmullRom, ) repaint(filepath.Base(file), tk.Data(i)) } func decode(f io.Reader, name string) (image.Image, error) { ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")) if checkErr(imaging.FormatFromExtension(ext)) { return imaging.Decode(f, imaging.AutoOrientation(true)) } else if ext == "webp" { return webp.Decode(f) } else if ext == "tga" { return tga.Decode(f) } return image.Black, fmt.Errorf("could not parse image") } func repaint(name string, data tk.Opt) { // TODO: sometimes, when going from a big image to a smaller one, // the window remains the same height as the big image, // even as the width shrinks to fit the smaller image. // This only seems to happen when the large image was full-screen height. img.Configure(tk.Image(tk.NewPhoto(data))) if name != "" { tk.App.WmTitle(fmt.Sprintf("why | %s", name)) } else { tk.App.WmTitle("why") } tk.App.Center() } func menuSelect() { if fileListBindVar.Get() == "1" { showFileList() } else if fileListBindVar.Get() == "0" { hideFileList() } } func updateSelection(target int) { directoryState.i = target updateImage(directoryState.pathToImageAtIndex(target)) moveSelectionInFileList(target) } // TODO: by default, the event callback loop in TK is fully synchronous; // each event only arrives after the previous event has finished processing. // I'd like to make it so that multiple arrow presses cancel the in-flight // image update, but because the TK renderer is running with [LockOSThread], // just injecting goroutines willy-nilly results in horrible crashes. // Supporting async handling would require plumbing channels throughout // the render handler and making sure the main goroutine is the one that // exclusively touches the TK API. // // [LockOSThread]: https://go.dev/wiki/LockOSThread func keyPress(e *tk.Event) { curr := directoryState.i switch e.Keysym { case ".": log.Printf("state: %+v", directoryState) case "o": newFileInDirectory() case "d": newDirectory() case "a": if fileListBindVar.Get() == "0" { showFileList() } else { hideFileList() } case "Up", "Left": if curr > 0 { updateSelection(curr - 1) } case "Down", "Right": if curr < len(directoryState.images)-1 && curr != -1 { updateSelection(curr + 1) } } } func constructFileList() { fileList = tk.Toplevel() fileList.WmTitle("Files") tk.WmWithdraw(fileList.Window) lb = fileList.Listbox(tk.Height(0)) tk.Pack(lb) tk.Bind(lb, "<>", tk.Command(func() { updateSelection(lb.Curselection()[0]) })) tk.Bind(fileList, "", tk.Command(func(e *tk.Event) { // wipe the reference so next show event recreates fileList = nil fileListBindVar.Set("0") })) clearFileList() for i := range directoryState.images { insertIntoFileList(directoryState.images[i].filename) if directoryState.i == i { moveSelectionInFileList(i) } } } func showFileList() { if fileList == nil { constructFileList() } tk.WmDeiconify(fileList.Window) fileListBindVar.Set("1") } func hideFileList() { tk.WmWithdraw(fileList.Window) fileListBindVar.Set("0") } func insertIntoFileList(filename string) { if fileList != nil { lb.Insert("end", filename) } } func clearFileList() { if fileList != nil { lb.Delete("0", "end") } } func moveSelectionInFileList(target int) { if fileList != nil { lb.SelectionClear("0", "end") lb.SelectionSet(target) lb.See(target) } } func main() { tk.App.IconPhoto(tk.NewPhoto(tk.Data(icon))) tk.MacOpenDocument(newBrowse) constructFileList() menubar := tk.Menu() fileMenu := menubar.Menu() fileMenu.AddCommand(tk.Lbl("Open File"), tk.Accelerator("O"), tk.Command(newFileInDirectory)) fileMenu.AddCommand(tk.Lbl("Open Directory"), tk.Accelerator("D"), tk.Command(newDirectory)) fileMenu.AddSeparator() checkbox := fileMenu.AddCheckbutton(tk.Lbl("Show Filelist"), tk.Accelerator("A")) fileMenu.EntryConfigure(checkbox, fileListBindVar) menubar.AddCascade(tk.Lbl("File"), tk.Underline(0), tk.Mnu(fileMenu)) tk.App.Configure(tk.Mnu(menubar)) fileListBindVar.Set("0") tk.Bind(fileMenu, "<>", tk.Command(menuSelect)) tk.Bind(tk.App, "", tk.Command(keyPress)) // todo: resize image based on scroll events // tk.Bind(tk.App, "", tk.Command(func(e *tk.Event) { // log.Printf("%v, %v", int16(e.Delta>>16), int16(e.Delta&0xFFFF)) // })) repaint("", tk.Data(noise)) tk.Pack(img) tk.App.Center() tk.App.Wait() }