380 lines
8.9 KiB
Go
380 lines
8.9 KiB
Go
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, "<<ListboxSelect>>", tk.Command(func() {
|
|
updateSelection(lb.Curselection()[0])
|
|
}))
|
|
tk.Bind(fileList, "<Destroy>", 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, "<<MenuSelect>>", tk.Command(menuSelect))
|
|
tk.Bind(tk.App, "<KeyPress>", tk.Command(keyPress))
|
|
// todo: resize image based on scroll events
|
|
// tk.Bind(tk.App, "<TouchpadScroll>", 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()
|
|
}
|