From 04b4243ad31d144c4caf9be4c5096a0f27a2648e Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 17 Sep 2026 00:23:51 +0200 Subject: gui: syntax colours, a file preview, and a settings window --- gui/internal/model/preview.go | 173 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 gui/internal/model/preview.go (limited to 'gui/internal/model/preview.go') diff --git a/gui/internal/model/preview.go b/gui/internal/model/preview.go new file mode 100644 index 0000000..6ca029e --- /dev/null +++ b/gui/internal/model/preview.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "unicode/utf8" + + "krino/internal/cond" +) + +// PreviewKind is what a preview holds. +type PreviewKind int + +const ( + PreviewNone PreviewKind = iota + PreviewImage + PreviewText +) + +// Preview is what to show of a file beside its explanation: a picture, some +// of its text, or nothing with the reason (his request, 2026-09-16). +type Preview struct { + Kind PreviewKind + Image string // a file to show: the file itself, or a rendered page + Text string + Note string // what this is, or why there is nothing +} + +// previewBytes is how much of a text file is read, and previewLines how +// much of it is shown: enough to recognise a document, not to read it. +const ( + previewBytes = 64 << 10 + previewLines = 200 + // imageLimit is the size above which an image is described rather than + // loaded: decoding a huge photograph would stall the window. + imageLimit = 40 << 20 +) + +// MakePreview looks at one file. tmp is a directory the caller owns, where +// a rendered PDF page is written; the caller removes it. Nothing is written +// anywhere else, and the file itself is only read. +func MakePreview(ctx context.Context, path, tmp string) Preview { + fi, err := os.Stat(path) + if err != nil { + return Preview{Note: err.Error()} + } + if fi.IsDir() { + return Preview{Note: "a directory"} + } + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) + switch { + case isImageExt(ext): + if fi.Size() > imageLimit { + return Preview{Note: fmt.Sprintf("image of %s, too large to show", size(fi.Size()))} + } + return Preview{Kind: PreviewImage, Image: path, Note: fmt.Sprintf("image, %s", size(fi.Size()))} + case ext == "pdf": + return pdfPreview(ctx, path, tmp, fi.Size()) + } + text, ok := textHead(path) + if !ok { + return Preview{Note: fmt.Sprintf(".%s file, %s - no preview", ext, size(fi.Size()))} + } + return Preview{Kind: PreviewText, Text: text, Note: fmt.Sprintf("first lines of %s", size(fi.Size()))} +} + +// pdfPreview renders the first page if poppler can, and falls back to the +// document's text - the same pdftotext krino's own (content ...) tests use. +func pdfPreview(ctx context.Context, path, tmp string, sz int64) Preview { + if _, err := exec.LookPath("pdftoppm"); err == nil { + out := filepath.Join(tmp, "page") + cmd := exec.CommandContext(ctx, "pdftoppm", "-png", "-f", "1", "-l", "1", + "-scale-to", "700", "--", path, out) + if err := cmd.Run(); err == nil { + if rendered := firstMatch(out + "*.png"); rendered != "" { + return Preview{Kind: PreviewImage, Image: rendered, + Note: fmt.Sprintf("page 1, %s", size(sz))} + } + } + } + if _, err := exec.LookPath("pdftotext"); err != nil { + return Preview{Note: fmt.Sprintf("PDF, %s - install poppler-utils to see it", size(sz))} + } + cmd := exec.CommandContext(ctx, "pdftotext", "-l", "2", "--", path, "-") + text, err := cmd.Output() + if err != nil { + return Preview{Note: fmt.Sprintf("PDF, %s - pdftotext cannot read it", size(sz))} + } + return Preview{Kind: PreviewText, Text: head(string(text)), + Note: fmt.Sprintf("text of the first pages, %s", size(sz))} +} + +// firstMatch is the first file matching a glob, "" when there is none. +func firstMatch(glob string) string { + names, err := filepath.Glob(glob) + if err != nil || len(names) == 0 { + return "" + } + return names[0] +} + +// textHead reads the start of a file and reports whether it is text: no NUL +// bytes, and valid UTF-8 as far as it was read. +func textHead(path string) (string, bool) { + f, err := os.Open(path) + if err != nil { + return "", false + } + defer f.Close() + buf := make([]byte, previewBytes) + n, _ := f.Read(buf) + buf = buf[:n] + if n == 0 { + return "", false + } + if strings.IndexByte(string(buf), 0) >= 0 { + return "", false + } + // A cut multi-byte character at the end is not a reason to call a file + // binary, so the last few bytes are dropped before the check. + trimmed := buf + for len(trimmed) > 0 && !utf8.Valid(trimmed) && len(buf)-len(trimmed) < 4 { + trimmed = trimmed[:len(trimmed)-1] + } + if !utf8.Valid(trimmed) { + return "", false + } + return head(string(trimmed)), true +} + +// head is the first previewLines lines of s. +func head(s string) string { + lines := strings.SplitN(s, "\n", previewLines+1) + if len(lines) > previewLines { + lines = lines[:previewLines] + return strings.Join(lines, "\n") + "\n..." + } + return strings.Join(lines, "\n") +} + +// isImageExt reports whether an extension is one krino's (type image) would +// take, minus the formats GTK cannot draw without extra libraries. +func isImageExt(ext string) bool { + switch ext { + case "svg", "ico", "raw", "cr2", "nef", "arw", "dng", "heic", "heif", "avif": + return false + } + for _, e := range cond.Group("image") { + if e == ext { + return true + } + } + return false +} + +// size is a file's size in the units krino's own settings use. +func size(n int64) string { + switch { + case n >= 1<<30: + return fmt.Sprintf("%.1fG", float64(n)/(1<<30)) + case n >= 1<<20: + return fmt.Sprintf("%.1fM", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1fK", float64(n)/(1<<10)) + } + return fmt.Sprintf("%dB", n) +} -- cgit v1.3