diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 00:40:46 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 00:40:46 +0200 |
| commit | ca0b703526151149d060d6d38f307e9e38c3dcd2 (patch) | |
| tree | 5d1ccc124e1ffe95ae8fbaf18fcbe44f5b897d57 /gui | |
| parent | 4744ffac60b9bde27616f34a934f9404bc314efc (diff) | |
| download | krino-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')
| -rw-r--r-- | gui/internal/model/prefs.go | 13 | ||||
| -rw-r--r-- | gui/internal/model/preview.go | 29 | ||||
| -rw-r--r-- | gui/internal/model/preview_test.go | 72 | ||||
| -rw-r--r-- | gui/internal/ui/plan.go | 85 |
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("") } |
