From e5c57a47dcedb31878a67ddc624d68ee43edd1f0 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 27 Aug 2026 21:24:07 +0200 Subject: [PATCH 1/2] The strip becomes a panel, and the rest of the page verbs arrive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip held nine things and the library behind it holds about twenty-five. A tenth button already reached two thirds of the way across, and most of what is left cannot be a button at all: it has to be told which pages, or how many to a sheet, or what to write, and a row of buttons has nowhere to say it. So the verbs are grouped, and a group opens a panel BESIDE the page rather than instead of it. Beside is the whole design decision. Every verb here changes the document, and this workbench draws the document by writing it out and reading it back — so typing a crop box and watching the page come back cropped is what the control is for. A panel over the page would hide the one thing worth looking at. WHAT IS NOW REACHABLE Pages which pages to keep or drop, turn them a quarter / a half / three quarters, reverse the order, move the page on the screen, crop to a box, insert a blank page, split into files of n pages Sheet n pages to a sheet, fold into a booklet, add another file after this one, lay another file over it Marks write a watermark across every page, in words somebody chose File strip what runs rather than shows Two up, Watermark and Sanitize leave the strip for the group each belongs to. Two up becomes a number, so it is also three up and sixteen up. THREE THINGS THAT WERE DEAD AND ARE NOT ANY MORE None of this could have worked without them, and none of them was visible before there was a panel to notice it in. A press arrived at a widget in SURFACE coordinates, and a container reads the point it is given as local to itself before adding its own origin back on. On a strip eight pixels from the top that is a near miss the sweep in the test papered over. On a panel that starts forty-six pixels down it is a press on the wrong row. The view got no events at all: handleClick reached only the toolbar. The Fill in panel, which is a panel of real widgets bound through observables, could not be pressed in a browser — it was a picture of controls. Char() returned false, always. Nothing could be typed anywhere, so 76.3% of the fields in the form panel — the text ones — had no way in. The scroll view underneath was the fourth: toolkit v0.250.0 did not forward a press to what is inside one, so every control in a scrolling panel was dead to the mouse. That is fixed upstream in v0.267.0, which is why this bumps the toolkit to v0.272.0. A gap that is not fixed: neither ScrollView nor FormField implements the unexported focusableChildren, so the toolkit's own focus walk stops at the scroll view and cannot find a box inside it. The workbench therefore hands a keystroke straight to the box it knows was last pressed. It works and it is honest, but Tab does not walk the panel. WHAT WAS MEASURED Eleven verbs over 400 real forms taken evenly from the eleven issuing bodies in /Users/Shared/pdfforms — 400 of the 1 633 real documents, the 635 vendor fixtures left out. 4 400 outputs: every one written by ops, read back by our own reader, parsed by pdfinfo and drawn by pdftoppm with ink on the page. 4 311 of 4 311 applicable outputs passed all four 89 not applicable: one-page documents where deleting page one leaves a document with none, which the panel refuses for that reason Two apparent failures in the first run were the measurement's own and are worth recording: pdftoppm pads the page number to the width of the page count, so page-1.png is not always what it writes; and a blank page inserted at the front is MEANT to come out blank, so the ink has to be looked for on page two. Read the output, not the exit code. In a real browser, with nothing eyeballed: the check now presses along the strip from the right until the right-hand band of the canvas lights up, presses down that panel until one of its verbs changes the band where the page is, saves, and parses what came back to find (DRAFT) Tj in it. A panel that is painted and gets no events looks exactly like one that works. 100% of statements, and every control in every group is reached by pressing it where it was drawn rather than by calling its handler. Co-authored-by: Claude Opus 5 --- README.md | 34 ++- app.go | 4 +- browsercheck/check.go | 206 ++++++++++++++++- browsercheck/main.go | 5 +- form.go | 5 + go.mod | 2 +- go.sum | 4 +- panel.go | 461 ++++++++++++++++++++++++++++++++++++++ panel_test.go | 500 ++++++++++++++++++++++++++++++++++++++++++ scene.go | 208 ++++++++++++++---- scene_test.go | 19 +- 11 files changed, 1381 insertions(+), 67 deletions(-) create mode 100644 panel.go create mode 100644 panel_test.go diff --git a/README.md b/README.md index 31c48c4..5f49b1f 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,30 @@ loaded, since nothing is fetched after start-up. A file is read through the browser's own picker and handed back as a download; the bytes never touch a server, and there is no server to touch. -## What is on the strip - -**Open** · **Save** · **<** · **>** · **Rotate** · **Delete** · -**Two up** · **Watermark** · **Sanitize**. The arrow keys turn pages too. +## What is on the strip, and what is beside the page + +**Open** · **Save** · **<** · **>** · **Rotate** · **Delete**, and then +one control per group of verbs: **Pages** · **Sheet** · **Marks** · **File**, +with **Fill in** at the end for a document that carries a form. The arrow keys +turn pages too. + +A strip cannot hold the rest. The verbs the library offers mostly have to be +told something first — which pages, how many to a sheet, what to write — and +there is nowhere on a row of buttons to say it. So a group opens a panel +**beside** the page rather than instead of it: + +- **Pages** — which pages to keep or drop, turn them a quarter, a half or + three quarters, reverse the order, move the one on the screen, crop to a + box, put a blank page in, split into files of *n* pages. +- **Sheet** — *n* pages to a sheet, fold into a booklet, add another file + after this one, lay another file over it. +- **Marks** — write a watermark across every page. +- **File** — strip what runs rather than shows. + +Beside, because every one of those changes the document and the document is +drawn from what would be saved: typing a crop box and watching the page come +back cropped is the whole point of the control, and a panel over the page +would hide the one thing worth looking at. Every change is applied to the document, written out, and read back before it is drawn — so what is on the screen is what would come out of Save, @@ -53,7 +73,11 @@ Then in a real browser, over the DevTools protocol with nothing eyeballed: Chrome loads the shell, the wasm starts, the file picker is intercepted and handed a PDF, and the canvas pixels are read back to prove the page arrived — which is how it was found that a file arriving from the browser needs a -frame of its own, having no event to be drawn on. +frame of its own, having no event to be drawn on. The same check then presses +along the strip until a panel opens beside the page, presses down that panel +until one of its verbs changes what is drawn, saves, and parses what came back +to find the mark in it: a panel that is painted and gets no events looks +exactly like one that works. ## Building diff --git a/app.go b/app.go index e41c1c0..4abcf9d 100644 --- a/app.go +++ b/app.go @@ -34,8 +34,8 @@ func (a workbench) Release(x, y int) bool { return a.s.handleRelease(x, y) } // one: nothing here has a second meaning. func (a workbench) Context(x, y int) bool { return a.s.handleClick(x, y) } -// Char forwards a printable character; the workbench has nothing to type into. -func (a workbench) Char(string) bool { return false } +// Char forwards a printable character to whatever box is being typed into. +func (a workbench) Char(text string) bool { return a.s.handleChar(text) } // KeyDown forwards a named key, which is how the pages are turned. func (a workbench) KeyDown(key string) bool { return a.s.handleKeyDown(key) } diff --git a/browsercheck/check.go b/browsercheck/check.go index 368ac5b..a466006 100644 --- a/browsercheck/check.go +++ b/browsercheck/check.go @@ -1,12 +1,15 @@ package main import ( + "bytes" "context" "encoding/json" "fmt" "os" "path/filepath" "time" + + "github.com/go-pdfkit/reader" ) // check is the protocol: boot, look, open a file the way a person does, look @@ -137,7 +140,186 @@ func check(ctx context.Context, c *conn, page, sample, shot, dl string) error { return fmt.Errorf("nothing that looks like a PDF was downloaded: %w", err) } fmt.Printf("the tab handed back a %d byte PDF\n", len(out)) - return os.WriteFile(shot+".pdf", out, 0o644) + if err := os.WriteFile(shot+".pdf", out, 0o644); err != nil { + return err + } + + // The strip is no longer the whole workbench: most of the verbs now live + // in a panel that a group opens beside the page. That panel has to be + // driven here too, because a panel that is drawn and gets no events looks + // exactly like one that works — which is what it was until the toolkit + // underneath learned to hand a press to what is inside a scroll view. + marked, err := drivePanel(ctx, c, sid) + if err != nil { + say(c) + return err + } + fmt.Println("a verb pressed in the panel changed the page:", marked) + + // And what the tab hands back now carries the mark, which is the whole + // claim: what is on the screen is what comes out of Save. + drain(c) + if err := click(ctx, c, sid, savedAt, 8+15); err != nil { + return err + } + again, err := waitForPDF(ctx, dl, out) + if err != nil { + return err + } + fmt.Printf("the tab handed back a %d byte PDF after the panel was used\n", len(again)) + doc, err := reader.Open(again) + if err != nil { + return fmt.Errorf("what the tab saved after the panel was used does not open: %w", err) + } + content, err := doc.PageContent(1) + if err != nil { + return fmt.Errorf("the first page of it cannot be read: %w", err) + } + if !bytes.Contains(content, []byte("(DRAFT) Tj")) { + return fmt.Errorf("the mark the panel wrote is not in the file the tab handed back") + } + fmt.Println("the mark written from the panel is in the saved file") + return nil +} + +// Where the panel and the page are on the canvas, and how far apart the +// presses that look for them are. The panel is three hundred of the canvas's +// thousand pixels wide, at the right hand end. +const ( + pageBand = 0.66 + panelMid = 840 + panelTop = 55 + panelFoot = 690 + stripY = 8 + 15 + // The leftmost control worth pressing from this end: everything to the + // left of it is Open, Save, the arrows and the two that change the + // document without being asked anything, and none of those opens a panel. + stripStop = 306 +) + +// drivePanel opens a group of verbs from the strip and presses the verbs in it +// until one of them changes the page, which is what says the panel is wired to +// the document rather than merely painted next to it. +// +// It works from the right hand end of the strip, so that the sweep never +// presses the controls that drop a page. +func drivePanel(ctx context.Context, c *conn, sid string) (string, error) { + quiet, err := lookIn(ctx, c, sid, pageBand, 1) + if err != nil { + return "", err + } + // A control is wider than the step, so the same group opens several times + // running. Its panel is told apart by what it looks like, and one that has + // already been pressed all the way down is not pressed again. + tried := map[int]bool{} + for x := 996; x > stripStop; x -= 8 { + if err := click(ctx, c, sid, x, stripY); err != nil { + return "", err + } + opened, err := changedIn(ctx, c, sid, pageBand, 1, quiet.Hash) + if err != nil { + return "", err + } + if !opened { + continue + } + panel, err := lookIn(ctx, c, sid, pageBand, 1) + if err != nil { + return "", err + } + if tried[panel.Hash] { + if err := click(ctx, c, sid, x, stripY); err != nil { + return "", err + } + if _, err := changedIn(ctx, c, sid, pageBand, 1, quiet.Hash); err != nil { + return "", err + } + continue + } + tried[panel.Hash] = true + fmt.Printf("a panel opened from x=%d\n", x) + hit, err := pressDownPanel(ctx, c, sid) + if err != nil { + return "", err + } + if hit != "" { + return hit, nil + } + // Nothing in this group changes what is drawn. Put it away and carry + // on along the strip. + if err := click(ctx, c, sid, x, stripY); err != nil { + return "", err + } + if _, err := changedIn(ctx, c, sid, pageBand, 1, quiet.Hash); err != nil { + return "", err + } + } + return "", fmt.Errorf("no group on the strip opened a panel with a verb in it") +} + +// pressDownPanel presses down the open panel until the page beside it changes, +// and says where that press was. +func pressDownPanel(ctx context.Context, c *conn, sid string) (string, error) { + page, err := lookIn(ctx, c, sid, 0, pageBand) + if err != nil { + return "", err + } + for y := panelTop; y < panelFoot; y += 16 { + if err := click(ctx, c, sid, panelMid, y); err != nil { + return "", err + } + changed, err := changedIn(ctx, c, sid, 0, pageBand, page.Hash) + if err != nil { + return "", err + } + if changed { + return fmt.Sprintf("pressed at y=%d", y), nil + } + } + return "", nil +} + +// changedIn waits a moment for the canvas to catch up and reports whether the +// band changed. +// +// The wait is the point: the canvas is repainted on an animation frame rather +// than on the press itself, so a look taken straight after a click is a look +// at what was on the screen before it. +func changedIn(ctx context.Context, c *conn, sid string, from, to float64, was int) (bool, error) { + deadline := time.Now().Add(250 * time.Millisecond) + for { + v, err := lookIn(ctx, c, sid, from, to) + if err != nil { + return false, err + } + if v.Hash != was { + return true, nil + } + if time.Now().After(deadline) { + return false, nil + } + } +} + +// waitForPDF waits for a PDF to land in the download directory that is not the +// one already seen. +func waitForPDF(ctx context.Context, dl string, notThis []byte) ([]byte, error) { + var out []byte + err := until(ctx, 30*time.Second, func() (bool, error) { + files, _ := os.ReadDir(dl) + for _, f := range files { + b, err := os.ReadFile(filepath.Join(dl, f.Name())) + if err == nil && len(b) > 4 && string(b[:4]) == "%PDF" && !bytes.Equal(b, notThis) { + out = b + return true, nil + } + } + return false, nil + }) + if err != nil { + return nil, fmt.Errorf("no second PDF was handed back: %w", err) + } + return out, nil } // say prints whatever the tab said for itself, which is where a program that @@ -163,11 +345,25 @@ type canvas struct { Hash int `json:"hash"` } -// look reads the pixels out of the canvas in the tab. +// look reads the pixels out of the whole canvas in the tab. func look(ctx context.Context, c *conn, sid string) (canvas, error) { - const js = `(() => { + return lookIn(ctx, c, sid, 0, 1) +} + +// lookIn reads the pixels out of a band of the canvas, given as fractions of +// its width. It is how the two halves of the workbench are told apart with +// nothing eyeballed: opening a group of verbs lights up the right hand band, +// and pressing one of them changes the left hand one, where the page is. +func lookIn(ctx context.Context, c *conn, sid string, from, to float64) (canvas, error) { + // The band runs from under the strip to above the status line. Both of + // those change for reasons that are not what is being asked about — a + // control lights up under the pointer, a message is written at the bottom + // — and counting them would answer a different question. + js := fmt.Sprintf(`(() => { const c = document.getElementById('screen'); - const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; + const x0 = Math.floor(c.width * %g), x1 = Math.ceil(c.width * %g); + const y0 = Math.floor(c.height * 0.07), y1 = Math.floor(c.height * 0.95); + const d = c.getContext('2d').getImageData(x0, y0, x1 - x0, y1 - y0).data; const r0 = d[0], g0 = d[1], b0 = d[2]; let n = 0, dark = 0, hash = 0; for (let i = 0; i < d.length; i += 4) { @@ -176,7 +372,7 @@ func look(ctx context.Context, c *conn, sid string) (canvas, error) { hash = (hash * 31 + d[i] + d[i+1] * 3 + d[i+2] * 7) | 0; } return JSON.stringify({n, dark, hash}); - })()` + })()`, from, to) var out canvas s, err := eval(ctx, c, sid, js) if err != nil { diff --git a/browsercheck/main.go b/browsercheck/main.go index dbaf30f..bc9636e 100644 --- a/browsercheck/main.go +++ b/browsercheck/main.go @@ -123,7 +123,10 @@ func run() error { } fmt.Println("devtools at", wsURL) - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + // Long enough for the panel to be driven as well as the strip: the sweep + // that finds a group and then a verb inside it is a few hundred presses, + // each of which waits for the canvas to catch up. + ctx, cancel := context.WithTimeout(context.Background(), 360*time.Second) defer cancel() c, err := dial(ctx, wsURL) if err != nil { diff --git a/form.go b/form.go index 4aa2b87..26bbeec 100644 --- a/form.go +++ b/form.go @@ -59,6 +59,11 @@ func (s *state) showForm() { return } s.showingForm = !s.showingForm + // The form takes the whole view: a document with a hundred fields in it + // has no room to spare for a panel of verbs beside them, and every one of + // those verbs would destroy the form anyway. + s.tools.open = "" + s.settle() s.refresh() } diff --git a/go.mod b/go.mod index fe6a7e0..3eda1ec 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/go-pdfkit/reader v0.6.0 github.com/go-pdfkit/render v0.11.0 github.com/go-widgets/painter v0.11.0 - github.com/go-widgets/toolkit v0.250.0 + github.com/go-widgets/toolkit v0.272.0 github.com/go-widgets/webcanvas v0.1.0 ) diff --git a/go.sum b/go.sum index e0afb4c..a595e39 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,8 @@ github.com/go-widgets/mvvm v0.5.0 h1:o5hh6HAxbApONcbZxmyV9q12pCAPGRd6L24aS3gaCbA github.com/go-widgets/mvvm v0.5.0/go.mod h1:Phdrd434RLxXW1D6dL1PPQH1tABwYLIN2X7jQVC4TbY= github.com/go-widgets/painter v0.11.0 h1:xsj4zTz8B43rOZnWrx7ZsaUBMgJyvgaE/Pq2FfcG2Sw= github.com/go-widgets/painter v0.11.0/go.mod h1:IPRLqdUJuJX8sfuHeYLZCzjoLvA0ApbOlyIAVmguJDQ= -github.com/go-widgets/toolkit v0.250.0 h1:bBesSXvk59WhY3AgbZ8DgfTUbkoe09cYueBNyq1QQQY= -github.com/go-widgets/toolkit v0.250.0/go.mod h1:eBfiAf9RI6uqmW6NoyFIBjNJgVmpjgVtVuJYS/8N5kI= +github.com/go-widgets/toolkit v0.272.0 h1:TksXi4e3L8cuk37IfcdNgRzWFYqel+j0G7nZSLGJBTo= +github.com/go-widgets/toolkit v0.272.0/go.mod h1:eBfiAf9RI6uqmW6NoyFIBjNJgVmpjgVtVuJYS/8N5kI= github.com/go-widgets/webcanvas v0.1.0 h1:fSGllghHlFZSC7CBb1oetj/PKR/S3ds/piDrpr0Kmg0= github.com/go-widgets/webcanvas v0.1.0/go.mod h1:UAoPu9dO6ZzcFFwQbVeW4Jm4X67XFKVJab9A6qWwsMM= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= diff --git a/panel.go b/panel.go new file mode 100644 index 0000000..6ad81ec --- /dev/null +++ b/panel.go @@ -0,0 +1,461 @@ +// The tool panel: the verbs that do not fit on a strip, and the controls each +// of them needs. +// +// The strip held nine things. The library behind it holds about twenty-five, +// and most of them need to be told something before they can run — which pages, +// how many to a sheet, what to write, which password. A strip of buttons has +// nowhere to put any of that, and a thirtieth button would not fit on it +// anyway: the nine already reached two thirds of the way across. +// +// So the verbs are grouped, and a group opens a panel beside the page rather +// than instead of it. Beside, because every verb here changes the document and +// the document is redrawn from what would be saved: setting a crop box and +// watching the page come back cropped is the whole point, and a panel that +// covered the page would hide the one thing worth looking at. + +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/go-pdfkit/ops" + "github.com/go-widgets/toolkit" +) + +// panelW is how wide the tool panel is, and gap the space between it and the +// page beside it. +const ( + panelW = 300 + gap = toolkit.DefaultBoxSpacing +) + +// The groups, in the order they appear on the strip. A group is named by what +// it does to the file rather than by which library call it makes: somebody +// looking for "two to a sheet" is thinking about the sheet, not about NUp. +const ( + groupPages = "Pages" + groupSheet = "Sheet" + groupMarks = "Marks" + groupFile = "File" +) + +// groupNames is the order they are offered in. +var groupNames = []string{groupPages, groupSheet, groupMarks, groupFile} + +// tools is the tool panel: which group is open, the widgets of every group +// that has been opened, and what those widgets currently say. +// +// The widgets are built once per group and kept, because they hold what +// somebody has typed: rebuilding them on every change would empty the boxes +// under their hands. What they say is kept here rather than read out of them, +// through a subscription made when each one is built — so a value arrives when +// it changes rather than being copied across every frame. +type tools struct { + open string + built map[string]toolkit.Widget + + // The Pages group. + spec string // which pages, empty for the one on the screen + turn int // a quarter turn, in degrees + moveTo int // where the page on the screen is to go + box string // a crop box, as four numbers + before int // where a blank page goes + every int // how many pages a split file holds + + // The Sheet group. + up int // how many pages to a sheet + + // The Marks group. + mark string // what a watermark says +} + +// newTools builds the panel's state with the defaults each control starts at. +func newTools() *tools { + return &tools{ + built: map[string]toolkit.Widget{}, + turn: 90, + moveTo: 1, + before: 1, + every: 1, + up: 2, + mark: "DRAFT", + } +} + +// showGroup opens a group of verbs beside the page, or closes it when it is +// the one already open. +func (s *state) showGroup(name string) { + if s.tools.open == name { + s.tools.open = "" + } else { + s.tools.open = name + s.showingForm = false + } + s.settle() + s.note = "" + s.refresh() +} + +// body is the panel of the group now open, built the first time it is asked +// for and kept afterwards. +func (s *state) body() toolkit.Widget { + if w, ok := s.tools.built[s.tools.open]; ok { + return w + } + var rows *column + switch s.tools.open { + case groupPages: + rows = s.pagesGroup() + case groupSheet: + rows = s.sheetGroup() + case groupMarks: + rows = s.marksGroup() + default: + rows = s.fileGroup() + } + w := rows.scroller() + s.tools.built[s.tools.open] = w + return w +} + +// A column is a stack of panel rows that remembers how tall it has grown. +// +// It has to: a ScrollView keeps its child's own width and height and scrolls +// the painting rather than the child, so a child nobody ever gave a size to is +// drawn as nothing at all — an empty panel with a scrollbar down the side of +// it, which is what this looked like the first time it was rendered. +type column struct { + box *toolkit.VBox + h int +} + +// Room around and between the rows, and the width they are laid out at: the +// panel less its frame and the scrollbar down its edge. +const ( + rowGap = 6 + rowsW = panelW - 34 + labelledH = 52 + bareH = 30 +) + +// newColumn starts an empty stack. +func newColumn() *column { + box := toolkit.NewVBox() + box.Spacing = rowGap + return &column{box: box} +} + +// add puts a row at the bottom and counts what it cost. +func (c *column) add(w toolkit.Widget, h int) { + c.box.AddFixed(w, h) + if c.h > 0 { + c.h += rowGap + } + c.h += h +} + +// scroller is the stack in a view that can be scrolled when it is taller than +// the panel, told how tall it is. +func (c *column) scroller() *toolkit.ScrollView { + c.box.SetBounds(toolkit.Rect{W: rowsW, H: c.h}) + sv := toolkit.NewScrollView(c.box) + sv.SetContentSize(rowsW, c.h) + return sv +} + +// pagesGroup is everything that changes which pages there are and what order +// they come in. +func (s *state) pagesGroup() *column { + box := newColumn() + box.add(s.entryRow("Which pages", "1-3,7 — empty means this one", "", + func(v string) { s.tools.spec = v }), labelledH) + box.add(buttons( + button("Keep only these", toolkit.ButtonDefault, s.selectPages), + button("Delete these", toolkit.ButtonDanger, s.deleteRange), + ), bareH) + + turns := toolkit.NewCycleButton("a quarter", "a half", "three quarters") + turns.Index().Subscribe(func(i int) { s.tools.turn = 90 * (i + 1) }) + box.add(buttons(turns, button("Turn them", toolkit.ButtonDefault, s.turnRange)), bareH) + box.add(button("Reverse the order", toolkit.ButtonDefault, s.reverse), bareH) + + box.add(s.spinRow("Move this page to", 1, s.tools.moveTo, + func(v int) { s.tools.moveTo = v }), labelledH) + box.add(button("Move it there", toolkit.ButtonDefault, s.movePage), bareH) + + box.add(s.entryRow("Crop to, in points", "x0,y0,x1,y1", "", + func(v string) { s.tools.box = v }), labelledH) + box.add(button("Crop them", toolkit.ButtonDefault, s.crop), bareH) + + box.add(s.spinRow("Put a blank page before", 1, s.tools.before, + func(v int) { s.tools.before = v }), labelledH) + box.add(button("Insert it", toolkit.ButtonDefault, s.insertBlank), bareH) + + box.add(s.spinRow("Split into files of", 1, s.tools.every, + func(v int) { s.tools.every = v }), labelledH) + box.add(button("Split and hand them over", toolkit.ButtonProminent, s.split), bareH) + return box +} + +// sheetGroup is everything that puts more than one page's worth on a sheet, or +// more than one file into this one. +func (s *state) sheetGroup() *column { + box := newColumn() + box.add(s.spinRow("Pages to a sheet", 1, s.tools.up, func(v int) { s.tools.up = v }), labelledH) + box.add(button("Lay them out", toolkit.ButtonDefault, s.nUp), bareH) + box.add(button("Fold it into a booklet", toolkit.ButtonDefault, s.booklet), bareH) + box.add(button("Add a file after this one", toolkit.ButtonDefault, s.merge), bareH) + box.add(button("Lay a file over this one", toolkit.ButtonDefault, s.overlay), bareH) + return box +} + +// marksGroup is what gets written on top of what the pages already show. +func (s *state) marksGroup() *column { + box := newColumn() + box.add(s.entryRow("Watermark", "what it says", s.tools.mark, + func(v string) { s.tools.mark = v }), labelledH) + box.add(button("Write it across every page", toolkit.ButtonDefault, s.watermark), bareH) + return box +} + +// fileGroup is what happens to the file rather than to any page of it. +func (s *state) fileGroup() *column { + box := newColumn() + box.add(button("Sanitize", toolkit.ButtonDefault, s.sanitize), bareH) + return box +} + +// entryRow is a named box to type in, bound to where what is typed goes. +func (s *state) entryRow(label, hint, initial string, to func(string)) toolkit.Widget { + e := toolkit.NewEntry(initial) + e.Placeholder = hint + e.Text().Subscribe(to) + s.typing = append(s.typing, e) + return toolkit.NewFormField(label, e) +} + +// spinRow is a named number, bound to where the number goes. +func (s *state) spinRow(label string, min, initial int, to func(int)) toolkit.Widget { + sp := toolkit.NewSpinButton(min, pageCeiling, initial, 1) + sp.Value().Subscribe(to) + return toolkit.NewFormField(label, sp) +} + +// pageCeiling is as high as any of these numbers is allowed to go. It is not a +// page count: the document changes under the control, and a number that is too +// large is refused by the operation itself, which is the one place that knows. +const pageCeiling = 9999 + +// button is one control of the panel or the strip. +func button(label string, style toolkit.ButtonStyle, on func()) *toolkit.Button { + b := toolkit.NewButton(label, on) + b.Style = style + return b +} + +// buttons puts controls side by side on one row, sharing the width. +func buttons(ws ...toolkit.Widget) toolkit.Widget { + row := toolkit.NewHBox() + for _, w := range ws { + row.AddFlex(w, 1) + } + return row +} + +// settle takes the focus out of every box, which is what has to happen when a +// panel is put away or another one takes its place: a box nobody can see any +// more must not go on taking the arrow keys that turn the pages. +func (s *state) settle() { + for _, e := range s.typing { + e.SetFocused(false) + } +} + +// where is the range the Pages group acts on: what was typed, or the page on +// the screen when nothing was. +func (s *state) where() string { + if strings.TrimSpace(s.tools.spec) == "" { + return pageSpec(s.at) + } + return s.tools.spec +} + +// selectPages keeps the pages the range names and drops the rest. +func (s *state) selectPages() { + spec := s.where() + s.changeSaying("kept "+spec, func(d *ops.Doc) error { return d.Select(spec) }) +} + +// deleteRange drops the pages the range names, unless that would be all of +// them: a document needs a page, and one with none cannot be shown, saved or +// opened again. +func (s *state) deleteRange() { + spec := s.where() + if s.doc != nil && emptied(s.doc, spec) { + s.fail("that would delete every page, and a document needs one") + return + } + s.changeSaying("deleted "+spec, func(d *ops.Doc) error { return d.Delete(spec) }) +} + +// emptied reports whether deleting the pages a range names would leave none. A +// range that cannot be read at all is not this function's to complain about: +// the operation itself says what is wrong with it, in its own words. +func emptied(d *ops.Doc, spec string) bool { + nums, err := ops.ParseRange(spec, d.PageCount()) + if err != nil { + return false + } + left := d.PageCount() + gone := map[int]bool{} + for _, n := range nums { + if !gone[n] { + gone[n] = true + left-- + } + } + return left == 0 +} + +// turnRange turns the pages the range names. +func (s *state) turnRange() { + spec, by := s.where(), s.tools.turn + s.changeSaying(fmt.Sprintf("turned %s by %d degrees", spec, by), + func(d *ops.Doc) error { return d.Rotate(spec, by) }) +} + +// reverse puts the pages in the opposite order. +func (s *state) reverse() { + s.changeSaying("reversed", func(d *ops.Doc) error { + d.Reverse() + return nil + }) +} + +// movePage takes the page on the screen somewhere else in the order, and +// follows it there. +func (s *state) movePage() { + from, to := s.at, s.tools.moveTo + if !s.changeSaying(fmt.Sprintf("moved page %d to %d", from, to), + func(d *ops.Doc) error { return d.Move(from, to) }) { + return + } + // Follow the page: somebody who moved the one they were looking at is + // still looking at it. + s.at = to + s.refresh() +} + +// crop cuts the pages the range names down to a box. +func (s *state) crop() { + box, err := parseBox(s.tools.box) + if err != nil { + s.fail(err.Error()) + return + } + spec := s.where() + s.changeSaying("cropped "+spec, func(d *ops.Doc) error { return d.Crop(spec, box) }) +} + +// parseBox reads a crop box written as four numbers. +func parseBox(spec string) ([4]float64, error) { + var box [4]float64 + parts := strings.Split(strings.TrimSpace(spec), ",") + if len(parts) != 4 { + return box, fmt.Errorf("a crop box is four numbers: x0,y0,x1,y1") + } + for i, p := range parts { + v, err := strconv.ParseFloat(strings.TrimSpace(p), 64) + if err != nil { + return box, fmt.Errorf("%q is not a number", strings.TrimSpace(p)) + } + box[i] = v + } + return box, nil +} + +// insertBlank puts an empty page of the same size before another one. +func (s *state) insertBlank() { + at := s.tools.before + s.changeSaying(fmt.Sprintf("a blank page before %d", at), + func(d *ops.Doc) error { return d.InsertBlank(at) }) +} + +// split hands over one file per piece. Nothing is changed here: what was open +// stays open, and the pieces are copies of it. +func (s *state) split() { + if s.doc == nil { + s.fail("open a document first") + return + } + parts, err := s.doc.Split(s.tools.every) + if err != nil { + s.fail(err.Error()) + return + } + for i, part := range parts { + out, perr := docBytes(part) + if perr != nil { + s.fail("part " + strconv.Itoa(i+1) + " cannot be written: " + perr.Error()) + return + } + s.host.Save(partName(s.name, i+1), out) + } + s.note = fmt.Sprintf("handed over %d files — a browser may ask before it takes more than one", len(parts)) + s.refresh() +} + +// partName is what one piece of a split document is offered under. +func partName(name string, n int) string { + return fmt.Sprintf("%s-%03d.pdf", strings.TrimSuffix(saveName(name), "-edited.pdf"), n) +} + +// nUp lays several pages on one sheet. +func (s *state) nUp() { + n := s.tools.up + if !s.changeSaying(fmt.Sprintf("%d to a sheet", n), func(d *ops.Doc) error { return d.NUp(n) }) { + return + } + s.at = 1 + s.refresh() +} + +// booklet lays the pages out so that the sheets fold into a booklet. +func (s *state) booklet() { + if !s.changeSaying("folded", func(d *ops.Doc) error { return d.Booklet() }) { + return + } + s.at = 1 + s.refresh() +} + +// merge adds another file's pages after this one's. +func (s *state) merge() { + s.withAnother("added", func(d, other *ops.Doc) error { d.Append(other); return nil }) +} + +// overlay draws another file's pages on top of this one's. +func (s *state) overlay() { + s.withAnother("laid over", func(d, other *ops.Doc) error { return d.Overlay(other) }) +} + +// withAnother asks for a second file and joins it to the one already open. +// The picker is asked for the same way Open asks: the press that reaches it is +// the one somebody made, which is what a browser requires before it will show +// a file chooser at all. +func (s *state) withAnother(said string, join func(d, other *ops.Doc) error) { + if s.doc == nil { + s.fail("open a document first") + return + } + s.host.Open(func(name string, data []byte) { + other, err := ops.Open(data) + if err != nil { + s.fail("cannot open " + name + ": " + err.Error()) + return + } + s.changeSaying(said+" "+name, func(d *ops.Doc) error { return join(d, other) }) + }) +} diff --git a/panel_test.go b/panel_test.go new file mode 100644 index 0000000..adff4e8 --- /dev/null +++ b/panel_test.go @@ -0,0 +1,500 @@ +package main + +import ( + "errors" + "strings" + "testing" + + "github.com/go-pdfkit/ops" + "github.com/go-pdfkit/reader" +) + +// The heights of the rows of each group, in the order they are built, so that +// a test can press a control where it was drawn rather than call its handler. +// Pressing is the whole point: it is what says the thing on the screen is +// wired to the thing it claims to be. +var ( + pagesRows = []int{labelledH, bareH, bareH, bareH, labelledH, bareH, + labelledH, bareH, labelledH, bareH, labelledH, bareH} + sheetRows = []int{labelledH, bareH, bareH, bareH, bareH} + marksRows = []int{labelledH, bareH} + fileRows = []int{bareH} +) + +// openGroup shows a group and lays it out, which is what gives its controls +// the bounds a press is tested against. +func openGroup(t *testing.T, s *state, name string) { + t.Helper() + s.showGroup(name) + s.draw(buffer()) + if s.tools.open != name { + t.Fatalf("the %s group did not open", name) + } +} + +// rowAt is a point inside the nth row of the panel now open, across is where +// along it: 0 is the left quarter and 1 the middle. +func rowAt(t *testing.T, s *state, rows []int, n int, across int) (int, int) { + t.Helper() + b := s.tools.built[s.tools.open].Bounds() + if b.W == 0 { + t.Fatal("the panel has no bounds, so nothing can be pressed on it") + } + y := b.Y + for i := 0; i < n; i++ { + y += rows[i] + rowGap + } + x := b.X + b.W/2 + if across == 0 { + x = b.X + b.W/4 + } + return x, y + rows[n]/2 +} + +// plusAt is the + of the spin button in the nth row, which is at the right +// hand end of the control, under the row's name. +func plusAt(t *testing.T, s *state, rows []int, n int) (int, int) { + t.Helper() + x, y := rowAt(t, s, rows, n, 1) + return x + 120, y - 8 +} + +// press puts a press on the panel and redraws, the way a frame follows an +// event in the browser. +func press(s *state, x, y int) { + s.handleClick(x, y) + s.draw(buffer()) +} + +func TestTheStripOpensAndClosesEachGroup(t *testing.T) { + s, _ := opened(t, 3) + s.draw(buffer()) + // Every group is reachable by pressing its name on the strip, and + // pressing it again puts it away. + for _, name := range groupNames { + var at int + for x := margin; x < surfaceW-margin; x += 2 { + s.handleClick(x, margin+toolbarH/2) + if s.tools.open == name { + at = x + break + } + } + if at == 0 { + t.Fatalf("no press on the strip opened %q", name) + } + s.draw(buffer()) + if s.pageW() >= viewW { + t.Error("the page kept the whole width with a panel beside it") + } + s.handleClick(at, margin+toolbarH/2) + if s.tools.open != "" { + t.Errorf("pressing %q again left it open", name) + } + } +} + +func TestEveryControlInThePagesPanelIsWiredToItsVerb(t *testing.T) { + s, _ := opened(t, 6) + openGroup(t, s, groupPages) + + // The box takes what is typed into it, one character at a time, and an + // arrow key then belongs to the box rather than to the pages. + x, y := rowAt(t, s, pagesRows, 0, 1) + press(s, x, y) + if !s.editing() { + t.Fatal("pressing the box did not put the caret in it") + } + for _, c := range []string{"2", "-", "3"} { + if !s.handleChar(c) { + t.Fatal("a character was refused by the box that has the caret") + } + } + if s.tools.spec != "2-3" { + t.Fatalf("the box holds %q", s.tools.spec) + } + at := s.at + if !s.handleKeyDown("ArrowRight") || s.at != at { + t.Error("an arrow key turned the page while a box was being typed into") + } + if !s.handleKeyDown("Backspace") || s.tools.spec != "2-" { + t.Errorf("after a backspace the box holds %q", s.tools.spec) + } + s.tools.spec = "2-3" + + // Turning: the left half of that row cycles how far, the right half does + // it. One press of the cycle takes a quarter turn to a half. + x, y = rowAt(t, s, pagesRows, 2, 0) + press(s, x, y) + if s.tools.turn != 180 { + t.Fatalf("the turn is %d degrees", s.tools.turn) + } + x, y = rowAt(t, s, pagesRows, 2, 1) + press(s, x, y) + if got, _ := s.doc.Rotation(2); got != 180 { + t.Errorf("page two is turned %d degrees", got) + } + if got, _ := s.doc.Rotation(1); got != 0 { + t.Errorf("page one was turned too, to %d", got) + } + + // Reversing, which needs no telling. + x, y = rowAt(t, s, pagesRows, 3, 1) + press(s, x, y) + if got, _ := s.doc.Rotation(5); got != 180 { + t.Error("the order was not reversed: the turned page did not move") + } + + // Keeping only the pages the box names. + x, y = rowAt(t, s, pagesRows, 1, 0) + press(s, x, y) + if s.doc.PageCount() != 2 { + t.Errorf("keeping 2-3 left %d pages", s.doc.PageCount()) + } + if !strings.Contains(s.note, "2-3") { + t.Errorf("the status line says %q", s.note) + } + + // And dropping them. + s.tools.spec = "1" + x, y = rowAt(t, s, pagesRows, 1, 1) + press(s, x, y) + if s.doc.PageCount() != 1 { + t.Errorf("deleting page one left %d pages", s.doc.PageCount()) + } +} + +func TestMovingCroppingBlankingAndSplittingFromThePanel(t *testing.T) { + s, h := opened(t, 4) + openGroup(t, s, groupPages) + + // A number is pressed up before the verb beside it is pressed. + x, y := plusAt(t, s, pagesRows, 4) + press(s, x, y) + if s.tools.moveTo != 2 { + t.Fatalf("the spin button says %d", s.tools.moveTo) + } + s.at = 1 + x, y = rowAt(t, s, pagesRows, 5, 1) + press(s, x, y) + if s.at != 2 { + t.Errorf("the view did not follow the page it moved, and shows %d", s.at) + } + if !strings.Contains(s.note, "moved") { + t.Errorf("the status line says %q", s.note) + } + + // Cropping: what is typed is a box, and what is drawn afterwards is the + // page as it would be saved. + x, y = rowAt(t, s, pagesRows, 6, 1) + press(s, x, y) + for _, c := range strings.Split("0,0,150,200", "") { + if !s.handleChar(c) { + t.Fatal("a character was refused by the crop box") + } + } + if s.tools.box != "0,0,150,200" { + t.Fatalf("the crop box holds %q", s.tools.box) + } + before := s.fitScale(s.src) + x, y = rowAt(t, s, pagesRows, 7, 1) + press(s, x, y) + if s.fitScale(s.src) <= before { + t.Error("cropping the page did not change how it is drawn") + } + + // A blank page goes in where the number says. + was := s.doc.PageCount() + x, y = plusAt(t, s, pagesRows, 8) + press(s, x, y) + if s.tools.before != 2 { + t.Fatalf("the blank page is to go before %d", s.tools.before) + } + x, y = rowAt(t, s, pagesRows, 9, 1) + press(s, x, y) + if s.doc.PageCount() != was+1 { + t.Errorf("%d pages after inserting a blank one", s.doc.PageCount()) + } + + // Splitting hands over one file per piece and changes nothing. Two pages + // to a piece, pressed up from one. + pages := s.doc.PageCount() + x, y = plusAt(t, s, pagesRows, 10) + press(s, x, y) + if s.tools.every != 2 { + t.Fatalf("a piece is to hold %d pages", s.tools.every) + } + x, y = rowAt(t, s, pagesRows, 11, 1) + press(s, x, y) + if s.doc.PageCount() != pages { + t.Error("splitting changed the document it was asked about") + } + want := (pages + 1) / 2 + if h.savedAll != want { + t.Errorf("%d files were handed over for %d pages, two to a piece", h.savedAll, pages) + } + if h.as != partName("sample.pdf", want) { + t.Errorf("the last piece was called %q", h.as) + } + // The last piece holds whatever was left over, and reads back as a + // document of its own. + last := pages - 2*(want-1) + if back, err := reader.Open(h.saved); err != nil || back.PageCount() != last { + t.Errorf("the last piece does not read back as %d pages: %v", last, err) + } +} + +func TestTheSheetPanel(t *testing.T) { + s, _ := opened(t, 4) + openGroup(t, s, groupSheet) + + // Two to a sheet is what the number starts at; pressed up it is three, + // which puts four pages on two sheets. + x, y := plusAt(t, s, sheetRows, 0) + press(s, x, y) + if s.tools.up != 3 { + t.Fatalf("the number says %d to a sheet", s.tools.up) + } + s.tools.up = 2 + x, y = rowAt(t, s, sheetRows, 1, 1) + press(s, x, y) + if s.doc.PageCount() != 2 || s.at != 1 { + t.Errorf("%d sheets, showing %d", s.doc.PageCount(), s.at) + } + + // A booklet reorders the sheets rather than adding any. + s2, _ := opened(t, 4) + openGroup(t, s2, groupSheet) + x, y = rowAt(t, s2, sheetRows, 2, 1) + press(s2, x, y) + if s2.doc.PageCount() != 2 { + t.Errorf("a booklet of four pages came to %d sheets", s2.doc.PageCount()) + } +} + +func TestAddingAndOverlayingAnotherFile(t *testing.T) { + s, h := opened(t, 2) + openGroup(t, s, groupSheet) + h.file = samplePDF(t, 3) + + x, y := rowAt(t, s, sheetRows, 3, 1) + press(s, x, y) + if s.doc.PageCount() != 5 { + t.Errorf("adding a three page file to a two page one gave %d", s.doc.PageCount()) + } + if !strings.Contains(s.note, "added") { + t.Errorf("the status line says %q", s.note) + } + + x, y = rowAt(t, s, sheetRows, 4, 1) + press(s, x, y) + if s.doc.PageCount() != 5 { + t.Errorf("laying a file over this one changed the page count to %d", s.doc.PageCount()) + } + if !strings.Contains(s.note, "laid over") { + t.Errorf("the status line says %q", s.note) + } + + // A second file that is not a PDF is said so, and changes nothing. + h.file = []byte("this is not a PDF") + press(s, x, y) + if !strings.Contains(s.note, "cannot open") { + t.Errorf("the status line says %q", s.note) + } + // And one the person did not choose leaves the document alone. + h.file = nil + press(s, x, y) + if s.doc.PageCount() != 5 { + t.Errorf("changing one's mind about a second file gave %d pages", s.doc.PageCount()) + } +} + +func TestTheMarksAndFilePanels(t *testing.T) { + s, _ := opened(t, 2) + openGroup(t, s, groupMarks) + x, y := rowAt(t, s, marksRows, 0, 1) + press(s, x, y) + for _, c := range []string{"O", "K"} { + s.handleChar(c) + } + if s.tools.mark != "DRAFTOK" { + t.Fatalf("the box holds %q", s.tools.mark) + } + x, y = rowAt(t, s, marksRows, 1, 1) + press(s, x, y) + out, err := s.doc.Bytes() + if err != nil { + t.Fatal(err) + } + back, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + content, err := back.PageContent(1) + if err != nil { + t.Fatal(err) + } + if !contains(content, "(DRAFTOK) Tj") { + t.Error("what was typed is not on the page") + } + + openGroup(t, s, groupFile) + x, y = rowAt(t, s, fileRows, 0, 1) + press(s, x, y) + if s.note == "" { + t.Error("sanitising said nothing for itself") + } +} + +func TestAPanelPutAwayLetsGoOfTheKeys(t *testing.T) { + s, _ := opened(t, 3) + openGroup(t, s, groupPages) + x, y := rowAt(t, s, pagesRows, 0, 1) + press(s, x, y) + if !s.editing() { + t.Fatal("the caret is not in the box") + } + // Putting the panel away, or opening the form panel instead, takes the + // caret out of a box nobody can see any more. + s.showGroup(groupPages) + if s.editing() { + t.Error("a box nobody can see still has the caret") + } + if !s.handleKeyDown("ArrowRight") || s.at != 2 { + t.Errorf("the arrow keys did not come back to the pages: page %d", s.at) + } + // The panel is built once: what was typed into it is still there when it + // comes back. + openGroup(t, s, groupPages) + if s.tools.built[groupPages] == nil { + t.Error("the panel was not kept") + } +} + +func TestTheFormPanelClosesAnyGroup(t *testing.T) { + s := newState(surfaceW, surfaceH, &fakeHost{name: "form.pdf", file: formPDF(t)}) + s.open() + openGroup(t, s, groupPages) + s.showForm() + if s.tools.open != "" || !s.showingForm { + t.Errorf("group %q open, form %v", s.tools.open, s.showingForm) + } + // And opening a group puts the form away again. + s.showGroup(groupSheet) + if s.showingForm { + t.Error("the form stayed up beside a group of verbs") + } +} + +func TestWhatAVerbSaysWhenItCannotRun(t *testing.T) { + empty := newState(surfaceW, surfaceH, &fakeHost{}) + for _, act := range []func(){ + empty.selectPages, empty.deleteRange, empty.turnRange, empty.reverse, + empty.movePage, empty.crop, empty.insertBlank, empty.split, + empty.nUp, empty.booklet, empty.merge, empty.overlay, empty.watermark, + } { + empty.note = "" + empty.tools.box = "0,0,10,10" + act() + if empty.note == "" { + t.Error("a verb with no document said nothing") + } + } + + s, _ := opened(t, 3) + // A range that names every page is refused: a document needs one. + s.tools.spec = "1-3,3" + s.deleteRange() + if s.doc.PageCount() != 3 { + t.Errorf("every page was deleted, leaving %d", s.doc.PageCount()) + } + if !strings.Contains(s.note, "needs one") { + t.Errorf("the status line says %q", s.note) + } + // A range that is not a range at all is the operation's to complain about. + s.tools.spec = "nonsense" + s.deleteRange() + if s.note == "" || s.doc.PageCount() != 3 { + t.Errorf("a nonsense range said %q and left %d pages", s.note, s.doc.PageCount()) + } + // Moving a page nowhere it can go. + s.tools.moveTo = 99 + s.at = 1 + s.movePage() + if s.at != 1 { + t.Errorf("the view followed a move that did not happen, to %d", s.at) + } + // Laying out no pages to a sheet, and folding a document that cannot be. + s.tools.up = 0 + s.nUp() + if s.doc.PageCount() != 3 { + t.Errorf("a nonsense n-up left %d pages", s.doc.PageCount()) + } + s.tools.every = 0 + s.split() + if s.note == "" { + t.Error("a nonsense split said nothing") + } +} + +func TestABookletThatCannotBeFolded(t *testing.T) { + // Booklet needs a document it can pair the pages of; one that cannot be + // folded says so rather than being folded wrongly. + s, _ := opened(t, 1) + s.doc, _ = ops.Open(samplePDF(t, 1)) + if err := s.doc.Delete("1"); err != nil { + t.Fatal(err) + } + s.booklet() + if s.note == "" { + t.Error("a booklet that could not be folded said nothing") + } +} + +func TestReadingACropBox(t *testing.T) { + if _, err := parseBox("1,2,3"); err == nil { + t.Error("three numbers were taken for a box") + } + if _, err := parseBox("1,2,3,x"); err == nil { + t.Error("a box was read out of something that is not a number") + } + box, err := parseBox(" 1 , 2 ,3, 4 ") + if err != nil || box != [4]float64{1, 2, 3, 4} { + t.Errorf("parseBox gave %v, %v", box, err) + } + s, _ := opened(t, 2) + s.tools.box = "not a box" + s.crop() + if s.note == "" { + t.Error("a crop box that cannot be read said nothing") + } +} + +func TestWhatAPieceOfASplitDocumentIsCalled(t *testing.T) { + for in, want := range map[string]string{ + "report.pdf": "report-001.pdf", + "report": "report-001.pdf", + "": "document.pdf-001.pdf", + } { + if got := partName(in, 1); got != want { + t.Errorf("partName(%q) = %q, want %q", in, got, want) + } + } +} + +func TestAPieceThatCannotBeWritten(t *testing.T) { + // None of this should ever happen to a document that opened, which is + // exactly why it is worth being able to see what the workbench does. + s, h := opened(t, 4) + was := docBytes + docBytes = func(*ops.Doc) ([]byte, error) { return nil, errors.New("no") } + defer func() { docBytes = was }() + s.tools.every = 1 + s.split() + if !strings.Contains(s.note, "cannot be written") { + t.Errorf("the status line says %q", s.note) + } + if h.savedAll != 0 { + t.Errorf("%d pieces were handed over anyway", h.savedAll) + } +} diff --git a/scene.go b/scene.go index 8f8552f..d79e55b 100644 --- a/scene.go +++ b/scene.go @@ -52,9 +52,12 @@ type state struct { toolbar *toolkit.HBox status *toolkit.Statusbar - view *toolkit.Frame - page *toolkit.Image - empty *toolkit.Label + // view is the band under the strip: the page, or the form, with the tool + // panel beside it when a group of verbs is open — so it is a widget of + // whatever kind that arrangement needs rather than always a frame. + view toolkit.Widget + page *toolkit.Image + empty *toolkit.Label // doc is what every operation acts on, and src is the same document // parsed, which is what gets drawn. They are rebuilt together after every @@ -69,8 +72,13 @@ type state struct { // and showingForm says the panel is up instead of the page. form *filling showingForm bool - at int // the page being shown, counting from one - note string + // tools is the panel of verbs beside the page, and typing every box on + // the screen that takes characters — which is what says whether an arrow + // key belongs to a word somebody is writing or to the pages. + tools *tools + typing []*toolkit.Entry + at int // the page being shown, counting from one + note string // dirty says something has changed since the canvas last showed it. A // file arrives from the browser long after the press that asked for it, @@ -80,26 +88,28 @@ type state struct { // newState builds the workbench. func newState(w, h int, h2 host) *state { - s := &state{w: w, h: h, theme: toolkit.DefaultLight(), host: h2, at: 1} + s := &state{w: w, h: h, theme: toolkit.DefaultLight(), host: h2, at: 1, tools: newTools()} s.empty = toolkit.NewLabel("Open a PDF to begin — nothing leaves this tab.") s.view = toolkit.NewFrame(s.empty) s.status = toolkit.NewStatusbar([]string{"no document", "", ""}) - s.toolbar = s.tools() + s.toolbar = s.strip() s.refresh() return s } -// tools is every control on the strip, in the order they appear. They are +// strip is every control at the top, in the order they appear. They are // buttons rather than a Toolbar because a Toolbar is a strip of square icon // cells that shows only a label's first letter, and these controls are named -// by their words: "Two up" and "Sanitize" both begin with the letter the other -// would be reduced to. -func (s *state) tools() *toolkit.HBox { +// by their words: "Pages" and "Sheet" would both be reduced to an S. +// +// What is on it is what needs no telling: opening, saving, turning to another +// page, turning the one on the screen over, dropping it. Everything else has +// to be told which pages, or how many, or what to write, and lives in the +// panel that a group opens beside the page. +func (s *state) strip() *toolkit.HBox { box := toolkit.NewHBox() add := func(label string, style toolkit.ButtonStyle, on func()) { - b := toolkit.NewButton(label, on) - b.Style = style - box.AddFixed(b, buttonWidth(label)) + box.AddFixed(button(label, style, on), buttonWidth(label)) } add("Open", toolkit.ButtonProminent, s.open) add("Save", toolkit.ButtonProminent, s.save) @@ -107,9 +117,12 @@ func (s *state) tools() *toolkit.HBox { add(">", toolkit.ButtonDefault, func() { s.step(1) }) add("Rotate", toolkit.ButtonDefault, s.rotate) add("Delete", toolkit.ButtonDanger, s.deletePage) - add("Two up", toolkit.ButtonDefault, s.twoUp) - add("Watermark", toolkit.ButtonDefault, s.watermark) - add("Sanitize", toolkit.ButtonDefault, s.sanitize) + // Then one control per group of verbs. What each one opens is a panel + // beside the page rather than another handful of buttons, because most of + // what is left needs to be told something first. + for _, name := range groupNames { + add(name, toolkit.ButtonDefault, func() { s.showGroup(name) }) + } add("Fill in", toolkit.ButtonDefault, s.showForm) return box } @@ -206,21 +219,16 @@ func (s *state) deletePage() { s.change(func(d *ops.Doc) error { return d.Delete(pageSpec(at)) }) } -// twoUp lays the pages out two to a sheet. -func (s *state) twoUp() { - s.change(func(d *ops.Doc) error { return d.NUp(2) }) - s.at = 1 - s.refresh() -} - // watermark writes across every page. func (s *state) watermark() { - s.change(func(d *ops.Doc) error { return d.Watermark("all", "DRAFT") }) + text := s.tools.mark + s.changeSaying("wrote "+text+" across every page", + func(d *ops.Doc) error { return d.Watermark("all", text) }) } // sanitize strips whatever in the file runs rather than shows. func (s *state) sanitize() { - s.change(func(d *ops.Doc) error { + s.changeSaying("stripped what runs rather than shows", func(d *ops.Doc) error { d.Sanitize() return nil }) @@ -230,17 +238,24 @@ func (s *state) sanitize() { func pageSpec(at int) string { return fmt.Sprintf("%d", at) } // change applies an operation and shows the result, or says why it could not. -func (s *state) change(apply func(*ops.Doc) error) { +func (s *state) change(apply func(*ops.Doc) error) bool { return s.changeSaying("", apply) } + +// changeSaying is change with something to say for itself when it worked. The +// note is set before the redraw rather than after it, because the redraw is +// what builds the line it appears on — and because a page that ran out of time +// being drawn has more to say than the verb that changed it did. +func (s *state) changeSaying(said string, apply func(*ops.Doc) error) bool { if s.doc == nil { s.fail("open a document first") - return + return false } if err := apply(s.doc); err != nil { s.fail(err.Error()) - return + return false } - s.note = "" + s.note = said s.refresh() + return true } // fail puts a message on the status line. @@ -329,16 +344,16 @@ var ( func (s *state) renderPage() { s.page = nil if s.doc == nil { - s.view = toolkit.NewFrame(s.empty) + s.show(s.empty) return } if s.showingForm && s.form != nil { - s.view = toolkit.NewFrame(s.form.panel(s)) + s.show(s.form.panel(s)) return } src, msg := s.reopen() if msg != "" { - s.view = toolkit.NewFrame(toolkit.NewLabel(msg)) + s.show(toolkit.NewLabel(msg)) return } s.src = src @@ -369,7 +384,49 @@ func (s *state) renderPage() { s.note = fmt.Sprintf("this page was still being drawn after %s; this is as far as it got", pageBudget) } s.page = toolkit.NewImageFit(img.Pix, img.W, img.H) - s.view = toolkit.NewFrame(s.page) + s.show(s.page) +} + +// show puts a widget in the view band, with the tool panel beside it when a +// group is open. +// +// Beside, and not over it: every verb in the panel changes the document, and +// the document is drawn from what would come out of Save — so setting a crop +// box and watching the page come back cropped is the whole of what the control +// is for, and a panel that covered the page would hide it. +func (s *state) show(w toolkit.Widget) { + s.view = s.arrange(w) + // Laid out here rather than only when it is painted, because a press can + // arrive before the next frame does: the view is built afresh by every + // change, and a widget nobody has given bounds to is under no point at + // all, so the press after a change would land on nothing. + s.view.SetBounds(painter.Rect{X: margin, Y: viewTop, W: viewW, H: viewH}) +} + +// arrange is the view band's contents: the page, and the tool panel beside it +// when a group is open. +func (s *state) arrange(w toolkit.Widget) toolkit.Widget { + page := toolkit.NewFrame(w) + if s.tools.open == "" { + return page + } + // An HBox, which is what puts two things side by side; the page takes + // whatever the panel leaves. + row := toolkit.NewHBox() + row.Spacing = gap + row.AddFlex(page, 1) + row.AddFixed(toolkit.NewFrame(s.body()), panelW) + return row +} + +// pageW is how much width the page has: the whole band, less the panel when +// one is open. The page is scaled to fit what is left, so opening a group +// shrinks the page rather than pushing it off the edge. +func (s *state) pageW() int { + if s.tools.open == "" { + return viewW + } + return viewW - panelW - gap } // pageBudget is how long one page may be drawn for before what has been drawn @@ -386,7 +443,7 @@ func (s *state) fitScale(src *reader.Document) float64 { // falls back on a real paper size, so neither can go wrong here. page, _ := src.Page(s.at) w, h := pageSize(src, page) - byWidth := float64(viewW-2*margin) / w + byWidth := float64(s.pageW()-2*margin) / w byHeight := float64(viewH-2*margin) / h if byWidth < byHeight { return byWidth @@ -449,26 +506,87 @@ func (s *state) draw(buf []byte) { s.status.Draw(p, s.theme) } -// handleClick routes a press to whatever is under it. -func (s *state) handleClick(x, y int) bool { - s.toolbar.OnEvent(toolkit.Event{Kind: toolkit.EventClick, X: x, Y: y}) +// pointer sends a press, a move or a release to the strip and to the view. +// +// Both, because the view is where every control that is not on the strip now +// lives — a panel that got no events would be a picture of controls rather +// than controls — and because a widget that is not under the point ignores +// what it is handed anyway. +func (s *state) pointer(kind toolkit.EventKind, x, y int) bool { + view := s.view // a control may put another view in its place + hit(s.toolbar, kind, x, y) + hit(view, kind, x, y) return true } -// handleMove routes a pointer move. -func (s *state) handleMove(x, y int) bool { - s.toolbar.OnEvent(toolkit.Event{Kind: toolkit.EventMouseMove, X: x, Y: y}) - return true +// hit sends a pointer event to a widget in that widget's own coordinates. +// +// A container reads the point it is given as local to itself and adds its own +// origin back on before hit-testing its children, so what has to arrive is the +// press with that origin taken off. Handing over the surface point instead +// moves every press down the screen by the widget's own top edge: on a strip +// eight pixels from the top that is a near miss, and on a panel that starts +// forty-six pixels down it is a press on the wrong row. +func hit(w toolkit.Widget, kind toolkit.EventKind, x, y int) { + b := w.Bounds() + w.OnEvent(toolkit.Event{Kind: kind, X: x - b.X, Y: y - b.Y}) } +// handleClick routes a press to whatever is under it. +func (s *state) handleClick(x, y int) bool { return s.pointer(toolkit.EventClick, x, y) } + +// handleMove routes a pointer move. +func (s *state) handleMove(x, y int) bool { return s.pointer(toolkit.EventMouseMove, x, y) } + // handleRelease routes a release. -func (s *state) handleRelease(x, y int) bool { - s.toolbar.OnEvent(toolkit.Event{Kind: toolkit.EventMouseUp, X: x, Y: y}) +func (s *state) handleRelease(x, y int) bool { return s.pointer(toolkit.EventMouseUp, x, y) } + +// handleChar puts a printable character into the box being typed into; with +// nothing being typed into, the workbench has nowhere to put it. +func (s *state) handleChar(text string) bool { + return s.toCaret(toolkit.Event{Kind: toolkit.EventChar, Code: text}) +} + +// toCaret hands a keystroke to the box that has the caret, and reports whether +// there was one to hand it to. +// +// The box is addressed directly rather than through the toolkit's own focus +// walk, because that walk cannot reach it: it descends through a widget only +// when the widget can enumerate its focusable children, and neither ScrollView +// nor FormField does — so a control inside a scrolling panel, which is where +// every control here lives, is invisible to it. The workbench knows which +// boxes it built and which one was last pressed, so it says so. +func (s *state) toCaret(ev toolkit.Event) bool { + e := s.caret() + if e == nil { + return false + } + e.OnEvent(ev) + s.dirty = true return true } -// handleKeyDown moves between pages with the arrow keys. +// caret is the box being typed into, or nil when nothing is. It is what +// decides who an arrow key belongs to: a word somebody is in the middle of +// writing, or the pages. +func (s *state) caret() *toolkit.Entry { + for _, e := range s.typing { + if e.Focused() { + return e + } + } + return nil +} + +// editing reports whether anything is being typed into. +func (s *state) editing() bool { return s.caret() != nil } + +// handleKeyDown moves between pages with the arrow keys, unless something is +// being typed into, in which case the key belongs to that. func (s *state) handleKeyDown(key string) bool { + if s.toCaret(toolkit.Event{Kind: toolkit.EventKeyDown, Code: key}) { + return true + } switch key { case "ArrowLeft", "PageUp": s.step(-1) diff --git a/scene_test.go b/scene_test.go index f6da0cc..7e5899e 100644 --- a/scene_test.go +++ b/scene_test.go @@ -21,6 +21,9 @@ type fakeHost struct { saved []byte as string asked int + // savedAll counts everything handed back, which is what says a verb that + // produces several files produced them all. + savedAll int } func (h *fakeHost) Open(done func(string, []byte)) { @@ -31,7 +34,10 @@ func (h *fakeHost) Open(done func(string, []byte)) { done(h.name, h.file) } -func (h *fakeHost) Save(name string, data []byte) { h.as, h.saved = name, data } +func (h *fakeHost) Save(name string, data []byte) { + h.as, h.saved = name, data + h.savedAll++ +} // samplePDF builds a document of n pages, each carrying a black square and its // own number, so a rendered page can be told from a blank one. @@ -231,7 +237,8 @@ func TestDeletingThePageOnTheScreen(t *testing.T) { func TestLayingPagesOutTwoUp(t *testing.T) { s, _ := opened(t, 4) - s.twoUp() + s.tools.up = 2 + s.nUp() if s.doc.PageCount() != 2 { t.Errorf("%d sheets", s.doc.PageCount()) } @@ -243,12 +250,12 @@ func TestLayingPagesOutTwoUp(t *testing.T) { func TestWatermarkingAndSanitising(t *testing.T) { s, _ := opened(t, 2) s.watermark() - if s.note != "" { + if !strings.Contains(s.note, "DRAFT") { t.Errorf("watermarking said %q", s.note) } s.sanitize() - if s.note != "" { - t.Errorf("sanitising said %q", s.note) + if s.note == "" { + t.Error("sanitising said nothing for itself") } out, err := s.doc.Bytes() if err != nil { @@ -352,7 +359,7 @@ func TestEveryControlIsReachableByClicking(t *testing.T) { func TestAControlWithNothingOpenSaysSo(t *testing.T) { s := newState(surfaceW, surfaceH, &fakeHost{}) - for _, act := range []func(){s.rotate, s.deletePage, s.twoUp, s.watermark, s.sanitize} { + for _, act := range []func(){s.rotate, s.deletePage, s.nUp, s.watermark, s.sanitize} { s.note = "" act() if s.note == "" { From a67e0f9c26a06f977d85c0de878c322d18ac2298 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 27 Aug 2026 21:35:42 +0200 Subject: [PATCH 2/2] Take the caret off the box the last press left it in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two boxes in one panel and every letter typed into the second went into the first. A press on a box focuses it; nothing ever defocuses the one that had the caret before, so both are focused at once and the workbench hands the keystroke to whichever it finds first. The toolkit does have a rule for this — a click walks the container for its focusable descendants and focuses exactly the one it hit — but the walk needs each container to enumerate its focusable children, and neither ScrollView nor FormField does. Every control in this panel is inside both, so the walk finds nothing and defocuses nothing. So a press clears the caret from every box before it is routed, which leaves it in exactly the box the press landed in, and in none at all when it landed somewhere else. Co-authored-by: Claude Opus 5 --- panel_test.go | 30 ++++++++++++++++++++++++++++++ scene.go | 10 ++++++++++ 2 files changed, 40 insertions(+) diff --git a/panel_test.go b/panel_test.go index adff4e8..1c99636 100644 --- a/panel_test.go +++ b/panel_test.go @@ -346,6 +346,36 @@ func TestTheMarksAndFilePanels(t *testing.T) { } } +func TestOnlyTheBoxLastPressedTakesWhatIsTyped(t *testing.T) { + // Two boxes in one panel: the range and the crop box. A press on the + // second has to take the caret off the first, or every letter meant for + // the second goes into the first — which is what happened, because the + // toolkit's own focus walk cannot see into a scroll view. + s, _ := opened(t, 3) + openGroup(t, s, groupPages) + x, y := rowAt(t, s, pagesRows, 0, 1) + press(s, x, y) + for _, c := range []string{"1", "-", "2"} { + s.handleChar(c) + } + x, y = rowAt(t, s, pagesRows, 6, 1) + press(s, x, y) + for _, c := range []string{"0", ",", "0", ",", "9", ",", "9"} { + s.handleChar(c) + } + if s.tools.spec != "1-2" { + t.Errorf("the range box holds %q", s.tools.spec) + } + if s.tools.box != "0,0,9,9" { + t.Errorf("the crop box holds %q", s.tools.box) + } + // And a press that lands on neither leaves both of them alone. + press(s, margin+2, viewTop+viewH-2) + if s.editing() { + t.Error("a press on nothing left a box with the caret") + } +} + func TestAPanelPutAwayLetsGoOfTheKeys(t *testing.T) { s, _ := opened(t, 3) openGroup(t, s, groupPages) diff --git a/scene.go b/scene.go index d79e55b..c7915bf 100644 --- a/scene.go +++ b/scene.go @@ -514,6 +514,16 @@ func (s *state) draw(buf []byte) { // what it is handed anyway. func (s *state) pointer(kind toolkit.EventKind, x, y int) bool { view := s.view // a control may put another view in its place + if kind == toolkit.EventClick { + // Nothing under the pointer takes the caret away from the box that + // has it. The toolkit moves focus on a click by walking the container + // for its focusable descendants, and that walk cannot see into a + // scroll view or through a form field — so a press on a second box + // leaves the first one focused too, and every letter typed after it + // goes into the first. Clearing them all first leaves exactly the one + // this press lands in. + s.settle() + } hit(s.toolbar, kind, x, y) hit(view, kind, x, y) return true