aboutsummaryrefslogtreecommitdiff
path: root/gui/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 00:40:46 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 00:40:46 +0200
commitca0b703526151149d060d6d38f307e9e38c3dcd2 (patch)
tree5d1ccc124e1ffe95ae8fbaf18fcbe44f5b897d57 /gui/internal
parent4744ffac60b9bde27616f34a934f9404bc314efc (diff)
downloadkrino-ca0b703526151149d060d6d38f307e9e38c3dcd2.tar.gz
krino-ca0b703526151149d060d6d38f307e9e38c3dcd2.zip
gui: the preview is as big as you drag it, and PDFs render to suit
Diffstat (limited to 'gui/internal')
-rw-r--r--gui/internal/model/prefs.go13
-rw-r--r--gui/internal/model/preview.go29
-rw-r--r--gui/internal/model/preview_test.go72
-rw-r--r--gui/internal/ui/plan.go85
4 files changed, 163 insertions, 36 deletions
diff --git a/gui/internal/model/prefs.go b/gui/internal/model/prefs.go
index ce9ee42..52ed012 100644
--- a/gui/internal/model/prefs.go
+++ b/gui/internal/model/prefs.go
@@ -22,11 +22,19 @@ type Prefs struct {
Preview bool `json:"preview"`
// SelectAll starts a plan's rows checked, as the terminal review does.
SelectAll bool `json:"select_all"`
+ // PreviewHeight is how tall the preview under the explanation is, in
+ // pixels; the divider between them sets it, and so does Settings.
+ PreviewHeight int `json:"preview_height"`
}
+// DefaultPreviewHeight is the preview's height until one is chosen, and the
+// floor a smaller one is raised to.
+const DefaultPreviewHeight = 280
+
// DefaultPrefs is what a window does before anything is chosen.
func DefaultPrefs() Prefs {
- return Prefs{FontSize: 0, Colours: true, Preview: true, SelectAll: true}
+ return Prefs{FontSize: 0, Colours: true, Preview: true, SelectAll: true,
+ PreviewHeight: DefaultPreviewHeight}
}
// PrefsFile is where they are kept.
@@ -45,6 +53,9 @@ func LoadPrefs() Prefs {
if err := json.Unmarshal(data, &p); err != nil {
return DefaultPrefs()
}
+ if p.PreviewHeight < 80 {
+ p.PreviewHeight = DefaultPreviewHeight
+ }
return p
}
diff --git a/gui/internal/model/preview.go b/gui/internal/model/preview.go
index d2a2830..4ec0569 100644
--- a/gui/internal/model/preview.go
+++ b/gui/internal/model/preview.go
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "strconv"
"strings"
"unicode/utf8"
@@ -47,9 +48,11 @@ const (
)
// 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 {
+// a rendered PDF page is written; the caller removes it. px is how tall the
+// preview will be shown, so a page is rendered to suit it rather than to a
+// fixed size - 0 takes the default. Nothing is written anywhere else, and
+// the file itself is only read.
+func MakePreview(ctx context.Context, path, tmp string, px int) Preview {
fi, err := os.Stat(path)
if err != nil {
return Preview{Note: err.Error()}
@@ -65,7 +68,7 @@ func MakePreview(ctx context.Context, path, tmp string) Preview {
}
return Preview{Kind: PreviewImage, Image: path, Note: fmt.Sprintf("image, %s", size(fi.Size()))}
case ext == "pdf":
- return pdfPreview(ctx, path, tmp, fi.Size())
+ return pdfPreview(ctx, path, tmp, fi.Size(), px)
}
text, ok := textHead(path)
if !ok {
@@ -82,7 +85,7 @@ func MakePreview(ctx context.Context, path, tmp string) Preview {
// called page-1.png and the wrong one could be picked up - which is what
// happened: a preview showed the page of a PDF looked at earlier (his
// report, 2026-09-17).
-func pdfPreview(ctx context.Context, path, tmp string, sz int64) Preview {
+func pdfPreview(ctx context.Context, path, tmp string, sz int64, px int) Preview {
if _, err := exec.LookPath("pdftoppm"); err == nil {
dir, err := os.MkdirTemp(tmp, "page-")
if err != nil {
@@ -90,7 +93,7 @@ func pdfPreview(ctx context.Context, path, tmp string, sz int64) Preview {
}
out := filepath.Join(dir, "page")
cmd := exec.CommandContext(ctx, "pdftoppm", "-png", "-f", "1", "-l", "1",
- "-scale-to", "700", "--", path, out)
+ "-scale-to", strconv.Itoa(renderPixels(px)), "--", path, out)
if err := cmd.Run(); err == nil {
if rendered := firstMatch(out + "*.png"); rendered != "" {
return Preview{Kind: PreviewImage, Image: rendered, Dir: dir,
@@ -111,6 +114,20 @@ func pdfPreview(ctx context.Context, path, tmp string, sz int64) Preview {
Note: fmt.Sprintf("text of the first pages, %s", size(sz))}
}
+// renderPixels is how large a page is rendered for a preview px tall: twice
+// the size it is shown at, so it stays sharp when the pane is dragged a
+// little wider, within limits that keep the render quick.
+func renderPixels(px int) int {
+ n := px * 2
+ switch {
+ case n < 700:
+ return 700
+ case n > 2400:
+ return 2400
+ }
+ return n
+}
+
// firstMatch is the first file matching a glob, "" when there is none.
func firstMatch(glob string) string {
names, err := filepath.Glob(glob)
diff --git a/gui/internal/model/preview_test.go b/gui/internal/model/preview_test.go
index b620a92..388ff7b 100644
--- a/gui/internal/model/preview_test.go
+++ b/gui/internal/model/preview_test.go
@@ -27,21 +27,21 @@ func TestPreviewKinds(t *testing.T) {
binary := write("a.bin", []byte{0, 1, 2, 3, 0})
tmp := t.TempDir()
- if p := MakePreview(context.Background(), text, tmp); p.Kind != PreviewText ||
+ if p := MakePreview(context.Background(), text, tmp, 0); p.Kind != PreviewText ||
!strings.Contains(p.Text, "second line") {
t.Errorf("text preview = %+v", p)
}
- if p := MakePreview(context.Background(), png, tmp); p.Kind != PreviewImage || p.Image != png {
+ if p := MakePreview(context.Background(), png, tmp, 0); p.Kind != PreviewImage || p.Image != png {
t.Errorf("image preview = %+v", p)
}
- if p := MakePreview(context.Background(), binary, tmp); p.Kind != PreviewNone ||
+ if p := MakePreview(context.Background(), binary, tmp, 0); p.Kind != PreviewNone ||
!strings.Contains(p.Note, "no preview") {
t.Errorf("binary preview = %+v", p)
}
- if p := MakePreview(context.Background(), filepath.Join(dir, "gone.txt"), tmp); p.Kind != PreviewNone {
+ if p := MakePreview(context.Background(), filepath.Join(dir, "gone.txt"), tmp, 0); p.Kind != PreviewNone {
t.Errorf("a missing file = %+v", p)
}
- if p := MakePreview(context.Background(), dir, tmp); p.Kind != PreviewNone {
+ if p := MakePreview(context.Background(), dir, tmp, 0); p.Kind != PreviewNone {
t.Errorf("a directory = %+v", p)
}
}
@@ -59,7 +59,7 @@ func TestPreviewReadsOnly(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- MakePreview(context.Background(), p, t.TempDir())
+ MakePreview(context.Background(), p, t.TempDir(), 0)
after, err := os.Stat(p)
if err != nil {
t.Fatal(err)
@@ -86,7 +86,7 @@ func TestPreviewShowsOnlyTheHead(t *testing.T) {
if err := os.WriteFile(p, []byte(b.String()), 0o644); err != nil {
t.Fatal(err)
}
- pv := MakePreview(context.Background(), p, t.TempDir())
+ pv := MakePreview(context.Background(), p, t.TempDir(), 0)
if pv.Kind != PreviewText {
t.Fatalf("preview = %+v", pv)
}
@@ -129,8 +129,8 @@ func TestPdfPreviewsDoNotMixUp(t *testing.T) {
second := pdf("second.pdf", "The second document")
tmp := t.TempDir()
- a := MakePreview(context.Background(), first, tmp)
- b := MakePreview(context.Background(), second, tmp)
+ a := MakePreview(context.Background(), first, tmp, 0)
+ b := MakePreview(context.Background(), second, tmp, 0)
if a.Kind != PreviewImage || b.Kind != PreviewImage {
t.Fatalf("previews = %+v, %+v", a, b)
}
@@ -152,3 +152,57 @@ func TestPdfPreviewsDoNotMixUp(t *testing.T) {
}
}
}
+
+// pngSize reads a PNG's dimensions from its header, so a test can say how
+// large a page was rendered without decoding it.
+func pngSize(t *testing.T, path string) (w, h int) {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil || len(data) < 24 || string(data[1:4]) != "PNG" {
+ t.Fatalf("%s is not a PNG: %v", path, err)
+ }
+ be := func(b []byte) int {
+ return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
+ }
+ return be(data[16:20]), be(data[20:24])
+}
+
+// TestPreviewRendersToTheSizeAsked: a taller preview gets a larger page, so
+// dragging the pane open does not just magnify a small render (his request,
+// 2026-09-17).
+func TestPreviewRendersToTheSizeAsked(t *testing.T) {
+ if _, err := exec.LookPath("pdftoppm"); err != nil {
+ t.Skip("pdftoppm is not installed")
+ }
+ if _, err := exec.LookPath("groff"); err != nil {
+ t.Skip("groff is not installed, so there is nothing to make a PDF with")
+ }
+ dir := t.TempDir()
+ path := filepath.Join(dir, "doc.pdf")
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("groff", "-Tpdf", "-ms")
+ cmd.Stdin = strings.NewReader(".TL\nA document\n")
+ cmd.Stdout = f
+ if err := cmd.Run(); err != nil {
+ f.Close()
+ t.Skipf("groff cannot write a PDF here: %v", err)
+ }
+ f.Close()
+
+ small := MakePreview(context.Background(), path, t.TempDir(), 200)
+ large := MakePreview(context.Background(), path, t.TempDir(), 900)
+ if small.Kind != PreviewImage || large.Kind != PreviewImage {
+ t.Fatalf("previews = %+v, %+v", small, large)
+ }
+ _, sh := pngSize(t, small.Image)
+ _, lh := pngSize(t, large.Image)
+ if lh <= sh {
+ t.Errorf("asking for a taller preview rendered %d px, no more than %d", lh, sh)
+ }
+ if sh < 700 {
+ t.Errorf("the smallest render is %d px, below the floor", sh)
+ }
+}
diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go
index f4ecb91..87afdff 100644
--- a/gui/internal/ui/plan.go
+++ b/gui/internal/ui/plan.go
@@ -40,12 +40,13 @@ type planView struct {
previewNote *gtk.Label
picture *gtk.Picture
- pictureFrame *gtk.ScrolledWindow
previewText *gtk.TextView
previewScroll *gtk.ScrolledWindow
previewTmp string
previewFor string
+ detailPane *gtk.Paned
renderedDir string
+ previewHeight int
previewOff bool
startSelected bool
menu *gtk.Popover
@@ -128,15 +129,15 @@ func newPlanView(w *Window) *planView {
p.previewNote.SetEllipsize(pango.EllipsizeEnd)
p.previewNote.SetMaxWidthChars(20)
p.previewNote.AddCSSClass("dim-label")
+ // The picture fills whatever the divider leaves it. Inside a scrolled
+ // window it would be given its smallest size instead, which is what
+ // made the page a stamp (his report, 2026-09-17).
p.picture = gtk.NewPicture()
p.picture.SetCanShrink(true)
p.picture.SetContentFit(gtk.ContentFitContain)
p.picture.SetVisible(false)
- pictureFrame := gtk.NewScrolledWindow()
- pictureFrame.SetChild(p.picture)
- pictureFrame.SetSizeRequest(-1, 280)
- pictureFrame.SetVisible(false)
- p.pictureFrame = pictureFrame
+ p.picture.SetVExpand(true)
+ p.picture.SetHExpand(true)
p.previewText = gtk.NewTextView()
p.previewText.SetEditable(false)
p.previewText.SetMonospace(true)
@@ -145,17 +146,28 @@ func newPlanView(w *Window) *planView {
p.previewText.SetTopMargin(6)
previewScroll := gtk.NewScrolledWindow()
previewScroll.SetChild(p.previewText)
- previewScroll.SetSizeRequest(-1, 260)
previewScroll.SetVisible(false)
+ previewScroll.SetVExpand(true)
p.previewScroll = previewScroll
- detailBox := gtk.NewBox(gtk.OrientationVertical, 0)
- detailBox.Append(detailScroll)
- detailBox.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
- detailBox.Append(p.previewNote)
- detailBox.Append(pictureFrame)
- detailBox.Append(previewScroll)
- detailBox.SetSizeRequest(340, -1)
+ previewBox := gtk.NewBox(gtk.OrientationVertical, 0)
+ previewBox.Append(p.previewNote)
+ previewBox.Append(p.picture)
+ previewBox.Append(previewScroll)
+
+ // The divider between the explanation and the preview is the size
+ // control: drag it, and the preview stays that tall (his request,
+ // 2026-09-17).
+ p.detailPane = gtk.NewPaned(gtk.OrientationVertical)
+ p.detailPane.SetStartChild(detailScroll)
+ p.detailPane.SetEndChild(previewBox)
+ p.detailPane.SetResizeStartChild(true)
+ p.detailPane.SetResizeEndChild(false)
+ p.detailPane.SetShrinkEndChild(false)
+ p.detailPane.SetVExpand(true)
+ p.detailPane.SetSizeRequest(340, -1)
+ p.detailPane.Connect("notify::position", p.rememberPreviewHeight)
+ detailBox := p.detailPane
// A pane the user can drag: on a narrow window the list needs the room,
// on a wide one the explanation does.
@@ -549,7 +561,7 @@ func (p *planView) showPreview(rel string) {
return
}
p.previewFor = path
- p.pictureFrame.SetVisible(false)
+ p.picture.SetVisible(false)
p.previewScroll.SetVisible(false)
p.previewNote.SetText("looking at " + escape(rel) + "...")
if p.previewTmp == "" {
@@ -562,7 +574,7 @@ func (p *planView) showPreview(rel string) {
}
var pv model.Preview
runInBackground(func(ctx context.Context) error {
- pv = model.MakePreview(ctx, path, p.previewTmp)
+ pv = model.MakePreview(ctx, path, p.previewTmp, p.previewHeight)
return nil
}, func(error) {
if p.previewFor != path {
@@ -573,7 +585,6 @@ func (p *planView) showPreview(rel string) {
case model.PreviewImage:
p.picture.SetFilename(pv.Image)
p.picture.SetVisible(true)
- p.pictureFrame.SetVisible(true)
// The picture is loaded, so the page rendered for the row
// before it can go.
p.dropRendered()
@@ -588,14 +599,48 @@ func (p *planView) showPreview(rel string) {
})
}
-// applyPrefs turns the file preview on or off, and says how a fresh plan
-// starts.
+// rememberPreviewHeight keeps what a drag of the divider chose, so the next
+// window opens the same way.
+func (p *planView) rememberPreviewHeight() {
+ height := p.detailPane.Height() - p.detailPane.Position()
+ if height < 80 || height == p.previewHeight {
+ return
+ }
+ p.previewHeight = height
+ prefs := p.w.prefs
+ prefs.PreviewHeight = height
+ p.w.prefs = prefs
+ if err := prefs.Save(); err != nil {
+ p.w.setStatus("settings: %v", err)
+ }
+}
+
+// setPreviewHeight puts the divider where a height asks for.
+func (p *planView) setPreviewHeight(height int) {
+ if height < 80 {
+ height = model.DefaultPreviewHeight
+ }
+ p.previewHeight = height
+ if total := p.detailPane.Height(); total > height+80 {
+ p.detailPane.SetPosition(total - height)
+ return
+ }
+ // Before the window is drawn there is no height to subtract from, so
+ // the end child's own request puts the divider in the right place.
+ if child := p.detailPane.EndChild(); child != nil {
+ gtk.BaseWidget(child).SetSizeRequest(-1, height)
+ }
+}
+
+// applyPrefs turns the file preview on or off, sets how tall it is, and
+// says how a fresh plan starts.
func (p *planView) applyPrefs(prefs model.Prefs) {
p.previewOff = !prefs.Preview
p.startSelected = prefs.SelectAll
+ p.setPreviewHeight(prefs.PreviewHeight)
if p.previewOff {
p.previewFor = ""
- p.pictureFrame.SetVisible(false)
+ p.picture.SetVisible(false)
p.previewScroll.SetVisible(false)
p.previewNote.SetText("")
}