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..1c99636 --- /dev/null +++ b/panel_test.go @@ -0,0 +1,530 @@ +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 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) + 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..c7915bf 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,97 @@ 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 + 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 } -// 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 == "" {