diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6481483..251a8b1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1060,7 +1060,7 @@ jobs: # in somebody else's file. run: | set -euo pipefail - watched='internal/format/registry.go internal/damage/damage.go cmd/tfg/main.go internal/gui/window/run.go internal/gui/run_cgo.go internal/gui/window/tidy.go internal/audit/parallel.go internal/engine/parallel.go internal/tool/tool.go internal/gui/window/tools.go internal/gui/window/work.go internal/gui/window/closing.go internal/format/video/ahead.go go.mod .github/workflows/ci.yml .github/build-tags .github/race-weights .github/scripts/race_parts.py' + watched='internal/format/registry.go internal/damage/damage.go cmd/tfg/main.go internal/gui/window/run.go internal/gui/run_cgo.go internal/gui/window/tidy.go internal/audit/parallel.go internal/engine/parallel.go internal/tool/tool.go internal/gui/window/tools.go internal/gui/window/work.go internal/gui/window/closing.go internal/gui/window/dropping.go internal/format/video/ahead.go go.mod .github/workflows/ci.yml .github/build-tags .github/race-weights .github/scripts/race_parts.py' # On a pull request there is no "before" - the field belongs to a push # - so this asked for something empty and every pull request answered # "touched". That quietly undid the decision of 2026-08-20, because diff --git a/CHANGELOG.md b/CHANGELOG.md index af84e19f..a76a81ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,14 @@ because it turns other people's test suites red. ### Added +- **Drop a folder or a file onto the window.** A folder dropped onto Single + batch, Presets or Several batches becomes the output directory. On the Tools + screen it goes into the box of the chosen tool when the tool takes that + kind: a file for `archive-scan`, `checksum` and `checksum-check`, a folder + for `checksum-write`. A file where a folder goes, several things at once or + a path that is not there is refused under the box, and the box keeps its + value. An empty path box says it takes a drop. + - **A new format: `otlp`, OpenTelemetry traces, logs and metrics as OTLP JSON.** `tfg generate --format otlp` writes them to an exact size, one batch on each line as the OTLP file exporter does (`.jsonl`), or one request for @@ -201,6 +209,10 @@ because it turns other people's test suites red. ### Changed +- **On Windows, a path picked with Choose... has backslashes.** It is written + the way Windows writes paths, the same text a drop of that folder or file + puts in the box. It came with forward slashes before. + - **`empty-and-minimal` now includes `otlp`.** The preset builds the smallest file of every format this build has, so with `formats` left at `all` it gains an `otlp` target of 1266 B, and the recipe `tfg preset eject @@ -255,6 +267,9 @@ because it turns other people's test suites red. ### Fixed +- **Choose... is off while files are being made.** The output directory box + was frozen during a run and the button beside it was not, so a folder picked + mid run went into the box while the run went on writing into the old one. - **An AVIF file asked for just above the size where its picture grows is written.** In 0.5.0 about a quarter of the files asked for at 16389 or 16439 B were not written at all, and the run ended with "1 file could not be diff --git a/README.md b/README.md index 27d7c4b5..6795705c 100644 --- a/README.md +++ b/README.md @@ -824,6 +824,12 @@ behind. The window speaks your system's language when it has it (English and Polish today) and English otherwise. Preferences lets you choose another, and says what the window keeps between runs. +A folder dropped onto the window becomes the output directory, and on the +Tools screen a file or a folder dropped onto it goes into the box of the chosen +tool, the same as picking it with Choose... Drop one at a time. Something the +box cannot take is refused with a sentence under the box, and the box keeps +what it had. +

The Presets screen of the window, with the list of presets open One batch of log files in the window, with the settings the log format takes diff --git a/internal/guard/concurrency_test.go b/internal/guard/concurrency_test.go index f527edfa..2ab99f71 100644 --- a/internal/guard/concurrency_test.go +++ b/internal/guard/concurrency_test.go @@ -42,6 +42,12 @@ var mayBeConcurrent = map[string]string{ // cancels it and waits for it the same way (G7). Added 2026-09-29 with the // Tools tab (docs/NARZEDZIA-SUMY-2026-09-29.md section 12.3). "internal/gui/window/tools.go": "a tool reads beside the window, and closing the window waits for it", + // Asking the disk what a dropped path is took 21.0 s for a network path + // nobody answers for (measured 2026-11-14, docs/DRAG-DROP-2026-11-14.md + // section 12.1), and the window would stand still for all of it. One + // os.Stat on a worker, the answer back through fyne.Do, nothing shared but + // a count read on the interface thread. + "internal/gui/window/dropping.go": "what a dropped path is gets asked beside the window, so a network path that does not answer cannot freeze it", // Signals arrive on a channel by definition, and the handler has to run // beside the work it interrupts. "cmd/tfg/main.go": "the interrupt handler has to run beside the work it stops", diff --git a/internal/guard/dropping_test.go b/internal/guard/dropping_test.go new file mode 100644 index 00000000..89ae4305 --- /dev/null +++ b/internal/guard/dropping_test.go @@ -0,0 +1,433 @@ +package guard + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/storage" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" + "github.com/donislawdev/TestingFilesGenerator/internal/tool/checksum" +) + +// droppedOnto is what a guard needs to drop onto the window: the function +// the window gave for a drop, and the disk's answers about one, kept until +// runAnswers when keepAnswers is set. +type droppedOnto struct { + onDropped func(fyne.Position, []fyne.URI) + keepAnswers bool + answers []func() +} + +// SetOnDropped keeps the function the window gives for a drop. The toolkit's +// own test window throws it away (fyne v2.8.2, test/window.go), so without +// this no guard could drop anything onto a screen. +func (h *fakeHost) SetOnDropped(fn func(fyne.Position, []fyne.URI)) { h.drops.onDropped = fn } + +// AnswerDropLater is how the window brings the disk's answer about a drop back +// to its thread. At once, as fyne.Do does under the test driver, unless a guard +// asked to keep the answers - then they wait until runAnswers. +func (h *fakeHost) AnswerDropLater(answer func()) { + if !h.drops.keepAnswers { + answer() + return + } + h.drops.answers = append(h.drops.answers, answer) +} + +// runAnswers runs the answers kept so far, on the guard's goroutine, and says +// how many there were. +func (h *fakeHost) runAnswers() int { + kept := h.drops.answers + h.drops.answers = nil + for _, answer := range kept { + answer() + } + return len(kept) +} + +// drop drops paths onto the window the way the toolkit's driver hands them +// over - each one through storage.NewFileURI - and waits for the window to +// answer, the disk included. +func (h *fakeHost) drop(t *testing.T, paths ...string) { + t.Helper() + h.dropNow(t, paths...) + join(h) +} + +// dropNow drops without waiting, for a guard looking at the middle of a run, +// which cannot join it. Only a drop of several things is answered at once, +// because only that one is refused without asking the disk. +func (h *fakeHost) dropNow(t *testing.T, paths ...string) { + t.Helper() + if h.drops.onDropped == nil { + t.Fatal("the window gave the host no function for a drop, so nothing dropped onto it can arrive") + } + uris := make([]fyne.URI, len(paths)) + for i, p := range paths { + uris[i] = storage.NewFileURI(p) + } + h.drops.onDropped(fyne.NewPos(10, 10), uris) +} + +// dropAim is the shape of window.AimDrop, so the planted defects below can +// stand in for it. +type dropAim func([]window.DroppedItem, []window.DropBox, float32, float32, bool) window.DropAnswer + +// dropCase is one row: what was dropped, which boxes the screen has, where it +// landed, and what has to come of it. +type dropCase struct { + name string + items []window.DroppedItem + boxes []window.DropBox + x, y float32 + busy bool + want window.DropAnswer +} + +// dropCases are the rules, one row each - see dropaim.go for why each is +// where it is. +func dropCases() []dropCase { + aFolder := []window.DroppedItem{{Path: "dir", Takes: parts.TakesFolder}} + aFile := []window.DroppedItem{{Path: "a.zip", Takes: parts.TakesFile}} + upper, lower := window.Area{X: 0, Y: 100, W: 300, H: 40}, window.Area{X: 0, Y: 200, W: 300, H: 40} + oneFolderBox := []window.DropBox{{Takes: parts.TakesFolder}} + twoFolderBoxes := []window.DropBox{{Takes: parts.TakesFolder, Area: upper}, {Takes: parts.TakesFolder, Area: lower}} + none := window.DropAnswer{Box: -1} + return []dropCase{ + {"nothing dropped", nil, oneFolderBox, 0, 0, false, none}, + {"a screen with no box", aFolder, nil, 0, 0, false, none}, + {"work owns the screen", aFolder, oneFolderBox, 0, 0, true, none}, + {"two things", append(aFolder, aFolder...), oneFolderBox, 0, 0, false, + window.DropAnswer{Box: 0, Why: window.RefusedMany}}, + {"a name that is not text", []window.DroppedItem{{NotText: true}}, oneFolderBox, 0, 0, false, + window.DropAnswer{Box: 0, Why: window.RefusedNotText}}, + {"a path the disk cannot read", []window.DroppedItem{{Path: "x", Unread: errors.New("gone")}}, oneFolderBox, 0, 0, false, + window.DropAnswer{Box: 0, Why: window.RefusedUnread}}, + {"neither a folder nor a file", []window.DroppedItem{{Path: "pipe"}}, oneFolderBox, 0, 0, false, + window.DropAnswer{Box: 0, Why: window.RefusedNeither}}, + {"a folder into the one folder box", aFolder, oneFolderBox, 0, 0, false, + window.DropAnswer{Box: 0, Put: true}}, + {"a file onto a screen that takes a folder", aFile, oneFolderBox, 0, 0, false, + window.DropAnswer{Box: 0, Why: window.RefusedKind}}, + {"the one box of its kind, wherever it landed", aFolder, + []window.DropBox{{Takes: parts.TakesFile, Area: upper}, {Takes: parts.TakesFolder, Area: lower}}, 10, 110, false, + window.DropAnswer{Box: 1, Put: true}}, + {"between two of a kind, the one under it", aFolder, twoFolderBoxes, 10, 210, false, + window.DropAnswer{Box: 1, Put: true}}, + {"between two of a kind, on neither", aFolder, twoFolderBoxes, 10, 10, false, + window.DropAnswer{Box: 0, Why: window.RefusedAimless}}, + {"between two of a kind, onto a box of the other kind", aFolder, + append([]window.DropBox{{Takes: parts.TakesFile, Area: window.Area{X: 0, Y: 300, W: 300, H: 40}}}, twoFolderBoxes...), 10, 310, false, + window.DropAnswer{Box: 0, Why: window.RefusedKind}}, + {"a box that is not drawn is under nothing", aFolder, + []window.DropBox{{Takes: parts.TakesFolder}, {Takes: parts.TakesFolder, Area: lower}}, 0, 0, false, + window.DropAnswer{Box: 0, Why: window.RefusedAimless}}, + } +} + +// dropFailures is every row an aim answers wrongly. +func dropFailures(decide dropAim) []string { + var out []string + for _, c := range dropCases() { + if got := decide(c.items, c.boxes, c.x, c.y, c.busy); got != c.want { + out = append(out, fmt.Sprintf("%s: got %+v, want %+v", c.name, got, c.want)) + } + } + return out +} + +// A drop goes where the rules say, for every rule. +// +// Asked of the decision itself rather than through a screen, because no +// screen today has two boxes of one kind - and the day one does, the rows +// about the position are already answered. The canaries are three defects +// this table has to see: a drop that always goes into the first box, a busy +// screen taking it anyway, and the position asked when only one box takes the +// kind - the defect that would put a folder in the wrong box on a Linux +// desktop where the toolkit's position is stale. +func TestADropGoesWhereItsRulesSay(t *testing.T) { + inside := func(a window.Area, x, y float32) bool { return x >= a.X && x < a.X+a.W && y >= a.Y && y < a.Y+a.H } + planted := map[string]dropAim{ + "always the first box": func(items []window.DroppedItem, boxes []window.DropBox, _, _ float32, _ bool) window.DropAnswer { + if len(items) == 0 || len(boxes) == 0 { + return window.DropAnswer{Box: -1} + } + return window.DropAnswer{Box: 0, Put: true} + }, + "busy ignored": func(items []window.DroppedItem, boxes []window.DropBox, x, y float32, _ bool) window.DropAnswer { + return window.AimDrop(items, boxes, x, y, false) + }, + "the position always asked": func(items []window.DroppedItem, boxes []window.DropBox, x, y float32, busy bool) window.DropAnswer { + got := window.AimDrop(items, boxes, x, y, busy) + if got.Put && len(boxes) > 1 && !inside(boxes[got.Box].Area, x, y) { + return window.DropAnswer{Box: got.Box, Why: window.RefusedAimless} + } + return got + }, + } + for name, decide := range planted { + if len(dropFailures(decide)) == 0 { + t.Errorf("the planted defect %q passes every row, so this table cannot see it", name) + } + } + for _, f := range dropFailures(window.AimDrop) { + t.Error(f) + } +} + +// awkwardFolder is a folder whose name carries what a path loses on the way +// through an address: a hash starts a fragment, a percent sign an escape, a +// space is escaped, and a letter outside ASCII is several bytes. +func awkwardFolder(t *testing.T) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "a#b %41 ś") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + return dir +} + +// A folder dropped onto each screen that writes files becomes its output +// directory, exactly as dropped, and the screens that write nothing take +// nothing. +// +// Through the window as built, with the path turned into the toolkit's address +// and back the way its driver does it, so a path that came out of the box +// different from the one dropped - a hash cut off, a slash turned - is red +// here. About and Preferences have no path box, and the owner's decision of +// 2026-11-14 is that a drop there does nothing: the directory on the work +// screens is asked afterwards, so a drop that went to the wrong screen shows. +func TestAFolderDroppedOnAWorkScreenBecomesItsOutputDirectory(t *testing.T) { + host, _ := screen(t) + before := entryUnder(t, tabNamed(t, host.content, text.TabOneTarget()), text.FieldOutputDir()).Text + for _, tab := range []string{text.TabAbout(), text.TabPreferences()} { + selectTab(t, host.content, tab) + host.drop(t, awkwardFolder(t)) + } + for _, tab := range []string{text.TabOneTarget(), text.TabPresets(), text.TabRecipe()} { + if got := entryUnder(t, tabNamed(t, host.content, tab), text.FieldOutputDir()).Text; got != before { + t.Errorf("a folder dropped onto About or Preferences changed the output directory on %q to %q", tab, got) + } + } + for _, tab := range []string{text.TabOneTarget(), text.TabPresets(), text.TabRecipe()} { + content := selectTab(t, host.content, tab) + dir := awkwardFolder(t) + host.drop(t, dir) + if got := entryUnder(t, content, text.FieldOutputDir()).Text; got != dir { + t.Errorf("a folder dropped onto %q left the output directory as %q rather than %q", tab, got, dir) + } + if said := sayingUnder(t, content, text.FieldOutputDir()); said != "" { + t.Errorf("a folder dropped onto %q was taken and the box still says %q", tab, said) + } + } +} + +// The toolkit's address keeps the paths a drop or a picker can bring, network +// paths on Windows among them, and window.PathOf gives back the same path. +// +// Asked of the toolkit rather than of the disk, because a network path cannot +// be made on a runner. The window relies on storage.NewFileURI keeping the +// path whole and on the slashes being the only change (fyne v2.8.2, +// storage/repository/parse.go), and a toolkit that began escaping would be +// red here before it reached a box. PathOf is what both pickers use too, so +// a folder picked and the same folder dropped put the same text in the box. +func TestADroppedPathComesBackOutOfTheToolkitAsItWent(t *testing.T) { + paths := []string{filepath.Join(t.TempDir(), "a#b %41 ś", "x.zip")} + if runtime.GOOS == "windows" { + paths = append(paths, `\\server\share\a#b\x`, `C:\`) + } + changed := func(back func(fyne.URI) string) []string { + var out []string + for _, p := range paths { + if got := back(storage.NewFileURI(p)); got != p { + out = append(out, fmt.Sprintf("%q came back as %q", p, got)) + } + } + return out + } + // The canary: the whole address, escaped, is not the path - so a check + // that let it through could not see a toolkit that started escaping. + if len(changed(func(u fyne.URI) string { return u.String() })) == 0 { + t.Fatal("the escaped address came back as every path, so this check cannot see escaping") + } + for _, c := range changed(window.PathOf) { + t.Errorf("a path went into the toolkit's address and %s", c) + } +} + +// A drop on the Tools tab goes into the box of the chosen tool when the tool +// takes that kind, and is refused under it when it does not. +// +// The kind comes from the tool's declaration in the engine, so this asks two +// tools that declare different kinds rather than one, and a box that took +// whatever was dropped would be red on the second. +func TestADropOnTheToolsTabFollowsWhatTheChosenToolTakes(t *testing.T) { + host, _ := screen(t) + content := selectTab(t, host.content, text.TabTools()) + folder := awkwardFolder(t) + file := filepath.Join(folder, "x.zip") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + chooseTool(t, content, checksum.ID) + box := entryUnder(t, content, text.SettingLabel(checksum.InputFile)) + host.drop(t, folder) + if box.Text != "" { + t.Errorf("a folder dropped onto a tool that reads a file went into its box as %q", box.Text) + } + if said := sayingUnder(t, content, text.SettingLabel(checksum.InputFile)); !strings.Contains(said, "x.zip") && !strings.Contains(said, filepath.Base(folder)) { + t.Errorf("a folder dropped onto a tool that reads a file was refused with %q, which does not name what was dropped", said) + } + host.drop(t, file) + if box.Text != file { + t.Errorf("a file dropped onto a tool that reads a file left its box as %q rather than %q", box.Text, file) + } + if said := sayingUnder(t, content, text.SettingLabel(checksum.InputFile)); said != "" { + t.Errorf("the file was taken and the box still says %q from the drop before it", said) + } + + chooseTool(t, content, checksum.WriteID) + host.drop(t, folder) + if got := entryUnder(t, content, text.SettingLabel(checksum.InputFolder)).Text; got != folder { + t.Errorf("a folder dropped onto a tool that lists a folder left its box as %q rather than %q", got, folder) + } +} + +// A drop the box cannot take says why under the box and leaves what was in it. +// +// Three ways, each a sentence of its own: more than one thing, a path that is +// not there, and a file where a folder goes. The value is asked after each, +// because a refusal shown over a value that changed anyway is the worst of +// both. +func TestADropTheBoxCannotTakeSaysWhyAndChangesNothing(t *testing.T) { + host, content := screen(t) + before := entryUnder(t, content, text.FieldOutputDir()).Text + folder := awkwardFolder(t) + file := filepath.Join(folder, "report.pdf") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + cases := []struct { + name string + dropped []string + says string + }{ + {"two folders", []string{folder, folder}, text.DropOneAtATime(2)}, + {"a path that is not there", []string{filepath.Join(folder, "gone")}, "gone"}, + {"a file where a folder goes", []string{file}, text.DropTakesFolder(text.FieldOutputDir(), "report.pdf")}, + } + for _, c := range cases { + host.drop(t, c.dropped...) + said := sayingUnder(t, content, text.FieldOutputDir()) + if !strings.Contains(said, c.says) { + t.Errorf("%s: the box says %q, which does not say %q", c.name, said, c.says) + } + if got := entryUnder(t, content, text.FieldOutputDir()).Text; got != before { + t.Errorf("%s: the drop was refused and the output directory changed to %q", c.name, got) + } + } +} + +// A drop while work owns the screen changes nothing and says nothing. +// +// The work read the boxes when it began. Asked during a preview held in the +// middle, with two folders, because that drop is answered at once - a single +// one would be answered after the disk, by when the preview may be over. The +// same drop with the screen idle is the positive control: refused with a +// sentence, so a guard that saw no sentence during the preview saw the rule +// and not a drop that never arrived. +func TestADropWhileWorkOwnsTheScreenChangesNothing(t *testing.T) { + host, content, hold := heldScreen(t) + fill(t, content, text.FieldOutputDir(), t.TempDir()) + fill(t, content, text.FieldCount(), "20000") + before := entryUnder(t, content, text.FieldOutputDir()).Text + folder := awkwardFolder(t) + press(t, content, text.ButtonPreview()) + hold.look(func() { + host.dropNow(t, folder, folder) + if said := sayingUnder(t, content, text.FieldOutputDir()); said != "" { + t.Errorf("a drop during a preview put %q under the output directory, which nothing takes away when the preview ends", said) + } + if got := entryUnder(t, content, text.FieldOutputDir()).Text; got != before { + t.Errorf("a drop during a preview changed the output directory to %q", got) + } + }) + join(host) + host.drop(t, folder, folder) + if said := sayingUnder(t, content, text.FieldOutputDir()); said == "" { + t.Error("the same drop with the screen idle said nothing, so the silence during the preview proves nothing") + } +} + +// A drop made while work owns the screen stays out even when the disk answers +// after the work has ended. +// +// The answer is kept until the preview is over and then run, which is the +// order a slow disk gives - a network path took 21 s on Windows. Asked of the +// answer after the work rather than during it, because during it the screen +// refuses anyway and a guard there cannot tell the two checks apart. The same +// drop on an idle screen, answered the same way, is the positive control. +func TestADropMadeDuringWorkStaysOutWhenTheDiskAnswersAfterIt(t *testing.T) { + host, content, hold := heldScreen(t) + host.drops.keepAnswers = true + fill(t, content, text.FieldOutputDir(), t.TempDir()) + fill(t, content, text.FieldCount(), "20000") + before := entryUnder(t, content, text.FieldOutputDir()).Text + folder := awkwardFolder(t) + press(t, content, text.ButtonPreview()) + hold.look(func() { host.dropNow(t, folder) }) + join(host) + host.runAnswers() + if got := entryUnder(t, content, text.FieldOutputDir()).Text; got != before { + t.Errorf("a folder dropped during a preview went into the output directory as %q once the disk answered after the preview", got) + } + + host.dropNow(t, folder) + join(host) + if host.runAnswers() != 1 { + t.Fatal("a drop on the idle screen was never answered, so the silence above proves nothing") + } + if got := entryUnder(t, content, text.FieldOutputDir()).Text; got != folder { + t.Errorf("the same drop on the idle screen left the output directory as %q, so the guard above saw no drop at all", got) + } +} + +// A path typed into the box while the disk is asked about a drop is not +// written over when the answer comes. +// +// What was typed last is what the person means. The answer is kept, the box +// is filled the way a person types, and then the answer is run. Without the +// typing the same answer lands, which is the positive control. +func TestAPathTypedWhileTheDiskIsAskedIsNotWrittenOver(t *testing.T) { + host, content := screen(t) + host.drops.keepAnswers = true + folder := awkwardFolder(t) + typed := t.TempDir() + + host.dropNow(t, folder) + join(host) + fill(t, content, text.FieldOutputDir(), typed) + if host.runAnswers() != 1 { + t.Fatal("the drop was never answered, so nothing here was asked") + } + if got := entryUnder(t, content, text.FieldOutputDir()).Text; got != typed { + t.Errorf("the box was typed into while the disk was asked, and the drop's answer wrote %q over it", got) + } + + host.dropNow(t, folder) + join(host) + host.runAnswers() + if got := entryUnder(t, content, text.FieldOutputDir()).Text; got != folder { + t.Errorf("with nothing typed the drop left the box as %q, so the guard above saw no drop at all", got) + } +} diff --git a/internal/guard/frozenform_test.go b/internal/guard/frozenform_test.go new file mode 100644 index 00000000..b1a4fdac --- /dev/null +++ b/internal/guard/frozenform_test.go @@ -0,0 +1,78 @@ +package guard + +import ( + "strings" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" +) + +// Every control of a field is off while a run owns the screen, the button +// beside a box as well as the box. +// +// The form freezes so that nothing on it can change while files go into the +// directory it names (O106). Until 2026-11-14 the freeze took the FIRST control +// of a field it could switch off, and the output directory is a box with +// Choose... beside it - so the box went grey and the button stayed live. A +// folder picked during a run went into the grey box, and the screen named a +// directory the run was not writing into, which is the confusion the freeze +// exists to prevent. +// +// Asked of the button by its label, and only after the guard has found it: +// a screen that lost the button would otherwise pass by having nothing to ask. +func TestEveryControlOfAFieldIsOffWhileARunOwnsTheScreen(t *testing.T) { + // The canary first: the check has to see a live button as live, or a + // green answer below says nothing. + if live := liveControls([]fyne.CanvasObject{parts.NewButton(parts.Secondary, "live", func() {})}); len(live) != 1 { + t.Fatalf("the check found %d live controls in a list holding one live button, so it cannot see the defect it is for", len(live)) + } + + host, content, hold := heldScreen(t) + clock := host.holdTheClock() + w := test.NewWindow(host.content) + t.Cleanup(w.Close) + w.Resize(window.LargestOpening) + + fill(t, content, text.FieldOutputDir(), t.TempDir()) + fill(t, content, text.FieldCount(), "20000") + choose := buttonsNamed(content, text.ButtonChoose()) + if len(choose) == 0 { + t.Fatalf("there is no %q button on the screen, so this guard read the wrong tree", text.ButtonChoose()) + } + press(t, content, text.ButtonPreview()) + + hold.look(func() { + clock.fire() + box := entryUnder(t, content, text.FieldOutputDir()) + if !box.Disabled() { + t.Fatal("the clock fired during the preview and the output directory box is not frozen, so this guard is not looking at a frozen form") + } + objects := make([]fyne.CanvasObject, 0, len(choose)) + for _, b := range choose { + objects = append(objects, b) + } + if live := liveControls(objects); len(live) > 0 { + t.Errorf("the form is frozen and %s is still live beside the output directory, so a folder picked now changes a box the run is not reading", strings.Join(live, ", ")) + } + }) +} + +// liveControls names the controls in the list that can still be pressed. +func liveControls(objects []fyne.CanvasObject) []string { + var live []string + for _, o := range objects { + if d, ok := o.(fyne.Disableable); ok && !d.Disabled() { + name := "a control" + if b, isButton := o.(*parts.Button); isButton { + name = "the " + b.Text + " button" + } + live = append(live, name) + } + } + return live +} diff --git a/internal/guard/mutation/entries.py b/internal/guard/mutation/entries.py index 85a5174b..eb323ee2 100644 --- a/internal/guard/mutation/entries.py +++ b/internal/guard/mutation/entries.py @@ -3387,7 +3387,10 @@ # asks - does anything get drawn - and it survives the layout moving # again, because it does not name a layout. "return NewGenerate(h).Object()", - "return container.NewVBox()", + # An empty container written with the package open.go already has: + # container.NewVBox stopped compiling there on 2026-11-14, when the + # path box moved to parts and took the last use of the import with it. + "return &fyne.Container{}", "TestTheWindowActuallyDrawsSomething", ), ( @@ -4243,7 +4246,7 @@ # works. The picker itself needs a real window, so what is provable is # the wiring - and the wiring is the part that goes wrong. "the chosen directory never reaches the field", - "internal/gui/window/open.go", + "internal/gui/parts/pathbox.go", " box.SetText(path)", " _ = path", "TestBrowsingForADirectoryPutsItInTheField", @@ -4252,7 +4255,7 @@ # Cancelling a picker answers with nothing, and a field emptied by # cancelling sends the run at a directory nobody named. "cancelling the picker empties the directory field", - "internal/gui/window/open.go", + "internal/gui/parts/pathbox.go", " if path != \"\" {", " if path == path {", "TestCancellingTheDirectoryPickerLeavesTheFieldAlone", diff --git a/internal/guard/mutationcoverage_test.go b/internal/guard/mutationcoverage_test.go index 026e4f1b..6f301744 100644 --- a/internal/guard/mutationcoverage_test.go +++ b/internal/guard/mutationcoverage_test.go @@ -124,18 +124,43 @@ var notProvenByMutation = map[string]bool{ // provenByProbe are the guards the mutation runner cannot express, broken on // purpose by hand instead. Each entry says what was broken and what happened. // -// Two reasons a guard lands here, and they are different: +// Three reasons a guard lands here, and they are different: // // - there is no product code underneath it. It reads documents and compares // lists, so a substitution in a .go file never reaches it. // - the substitution that would break it is not a literal. Entries in // entries.py are read with ast.literal_eval, so a break that needs ninety // generated lines cannot be written as one. +// - untouchable rule 26, mutation sparingly, the owner's: a guard's +// proof by default is a canary inside it, or a break made by hand once +// while it was written, and a lasting entry is kept for a guard reading +// the project's own files or guarding a user's data, network, secrets or +// permissions - at most three to a piece of work. Every entry in that +// state names its canary or its break, so the proof can be made again. // // A separate list rather than a note in the one above, because "unproven" and // "proven another way" are different states and lumping them together would // send a later session to re-prove what is already proven. var provenByProbe = map[string]string{ + // Broken once each on 2026-11-14 while they were written, one change at a + // time and restored (docs/DRAG-DROP-2026-11-14.md section 13). + "TestEveryControlOfAFieldIsOffWhileARunOwnsTheScreen": "its own canary - a live button must be reported live - and red on 2026-11-14 " + + "before Fields.Freeze walked every control of a field: Choose... stood live beside the frozen output directory.", + "TestADropGoesWhereItsRulesSay": "its own canaries - three planted decisions (always the first box, busy ignored, the position " + + "asked when one box takes the kind) must each fail a row.", + "TestAFolderDroppedOnAWorkScreenBecomesItsOutputDirectory": "broken on 2026-11-14 three times: the drop sent to the first screen " + + "whatever was on show, the path kept with the toolkit's forward slashes, and SetText left out. Red each time.", + "TestADroppedPathComesBackOutOfTheToolkitAsItWent": "its own canary - the escaped address must not come back as the path.", + "TestADropOnTheToolsTabFollowsWhatTheChosenToolTakes": "broken on 2026-11-14 five times: the first screen whatever was on show, " + + "forward slashes kept, SetText left out, a file put into a folder box, and a refusal with no sentence. Red each time.", + "TestADropTheBoxCannotTakeSaysWhyAndChangesNothing": "broken on 2026-11-14 twice: a file put into a folder box, and a refusal " + + "with no sentence. Red both times.", + "TestADropWhileWorkOwnsTheScreenChangesNothing": "its own positive control - the same drop on an idle screen must say something - " + + "and broken on 2026-11-14 by handing the decision false for busy: red.", + "TestADropMadeDuringWorkStaysOutWhenTheDiskAnswersAfterIt": "its own positive control - the same drop on an idle screen must land - " + + "and broken on 2026-11-14 by taking out the busy check before the disk is asked: red (docs/REVIEW-245-2026-11-14.md).", + "TestAPathTypedWhileTheDiskIsAskedIsNotWrittenOver": "its own positive control - with nothing typed the drop must land - " + + "and broken on 2026-11-14 by no longer asking whether a path box was typed into: red (docs/REVIEW-245-2026-11-14.md).", // Broken once each on 2026-11-12 while they were written, one change at a // time and restored (docs/DAMAGE-SETTINGS-LABEL-2026-11-12.md). The first // also carries a canary of its own check. diff --git a/internal/guard/testdata/screens/catalogue.png b/internal/guard/testdata/screens/catalogue.png index e3c42f71..f434d31c 100644 Binary files a/internal/guard/testdata/screens/catalogue.png and b/internal/guard/testdata/screens/catalogue.png differ diff --git a/internal/guard/testdata/screens/catalogue.xml b/internal/guard/testdata/screens/catalogue.xml index 9b30c995..dccf9243 100644 --- a/internal/guard/testdata/screens/catalogue.xml +++ b/internal/guard/testdata/screens/catalogue.xml @@ -1,7 +1,7 @@ - + - - + + @@ -750,7 +750,166 @@ - + + + + + PathBox + + + + + + a folder, empty, with its hint + + + + + + + + + + + Drop a folder here, or type its path + + + + + + + + + + + Choose... + + + + + + + + a file, empty, with its hint + + + + + + + + + + + Drop a file here, or type its path + + + + + + + + + + + Choose... + + + + + + + + holding a path + + + + + + + + + + + /home/someone/tfg-out + + + + + + + + Choose... + + + + + + + + long path + + + + + + + + + + + /home/someone/Write a label inside each generated file, including the ones that are far too small to hold it/Write a label inside each generated file, including the ones that are far too small to hold it.zip + + + + + + + + + + + + + + + + Choose... + + + + + + + + disabled + + + + + + + + + + + /home/someone/tfg-out + + + + + + + + Choose... + + + + + + + + @@ -859,7 +1018,7 @@ - + @@ -999,7 +1158,7 @@ - + @@ -1833,7 +1992,7 @@ - + @@ -2027,7 +2186,7 @@ - + @@ -2469,7 +2628,7 @@ - + @@ -2795,7 +2954,7 @@ - + @@ -2925,7 +3084,7 @@ - + @@ -3039,7 +3198,7 @@ - + @@ -3120,7 +3279,7 @@ - + @@ -3180,7 +3339,7 @@ - + @@ -3229,7 +3388,7 @@ - + @@ -3650,7 +3809,7 @@ - + @@ -3990,7 +4149,7 @@ - + @@ -4501,7 +4660,7 @@ - + @@ -4571,7 +4730,7 @@ - + @@ -5155,7 +5314,7 @@ - + @@ -5739,13 +5898,13 @@ - - - - + + + + Not drawn, and why - - + + • @@ -5847,12 +6006,22 @@ + + • + + + + PathTarget - a value: a path box and the field it stands in, read by the window when something is dropped onto it + + + + - + diff --git a/internal/guard/testdata/screens/tools-refused.png b/internal/guard/testdata/screens/tools-refused.png index 705590a1..479d5b08 100644 Binary files a/internal/guard/testdata/screens/tools-refused.png and b/internal/guard/testdata/screens/tools-refused.png differ diff --git a/internal/guard/testdata/screens/tools-refused.xml b/internal/guard/testdata/screens/tools-refused.xml index b7e5cbf0..c22cb177 100644 --- a/internal/guard/testdata/screens/tools-refused.xml +++ b/internal/guard/testdata/screens/tools-refused.xml @@ -177,7 +177,7 @@ - + Drop a file here, or type its path diff --git a/internal/guard/testdata/screens/tools-write.png b/internal/guard/testdata/screens/tools-write.png index 85bee5da..ac8c2cbd 100644 Binary files a/internal/guard/testdata/screens/tools-write.png and b/internal/guard/testdata/screens/tools-write.png differ diff --git a/internal/guard/testdata/screens/tools-write.xml b/internal/guard/testdata/screens/tools-write.xml index 5624311a..4ebf19d6 100644 --- a/internal/guard/testdata/screens/tools-write.xml +++ b/internal/guard/testdata/screens/tools-write.xml @@ -177,7 +177,7 @@ - + Drop a folder here, or type its path diff --git a/internal/guard/testdata/screens/tools.png b/internal/guard/testdata/screens/tools.png index 2b6bd46e..917ffbf4 100644 Binary files a/internal/guard/testdata/screens/tools.png and b/internal/guard/testdata/screens/tools.png differ diff --git a/internal/guard/testdata/screens/tools.xml b/internal/guard/testdata/screens/tools.xml index 2687311e..3d52e416 100644 --- a/internal/guard/testdata/screens/tools.xml +++ b/internal/guard/testdata/screens/tools.xml @@ -177,7 +177,7 @@ - + Drop a file here, or type its path diff --git a/internal/guard/window_test.go b/internal/guard/window_test.go index afed02f7..f8bc6311 100644 --- a/internal/guard/window_test.go +++ b/internal/guard/window_test.go @@ -105,6 +105,9 @@ type fakeHost struct { file string fileCount int + // drops is what a guard needs to drop onto the window - see dropping_test.go. + drops droppedOnto + kept *keptInMemory remembered window.Remembered diff --git a/internal/gui/catalogue/catalogue.go b/internal/gui/catalogue/catalogue.go index 9d72e4ba..b647e755 100644 --- a/internal/gui/catalogue/catalogue.go +++ b/internal/gui/catalogue/catalogue.go @@ -71,7 +71,7 @@ type Reason struct { // form, then what is only read. func Entries() []Entry { return []Entry{ - button(), chooser(), entry(), toggle(), segments(), + button(), chooser(), entry(), pathBox(), toggle(), segments(), openList(), tabs(), fields(), propertyField(), byteCount(), tips(), errorArea(), verdict(), progress(), folding(), textRanks(), structure(), swatch(), @@ -99,6 +99,7 @@ func NotDrawn() []Reason { {"Returnable", "an interface: a control the window can tell it is coming back to the front, nothing of its own to draw"}, {"Shortcuts", "a keyboard map: nothing to draw, and saying so is the point of this row"}, {"NameTap", "an invisible target over the name beside a box to tick, so pressing the name ticks the box - nothing of its own to draw"}, + {"PathTarget", "a value: a path box and the field it stands in, read by the window when something is dropped onto it"}, } } diff --git a/internal/gui/catalogue/controls.go b/internal/gui/catalogue/controls.go index 29b636e3..18edd8d7 100644 --- a/internal/gui/catalogue/controls.go +++ b/internal/gui/catalogue/controls.go @@ -224,6 +224,35 @@ func entry() Entry { }} } +// pathBox is a box for a path with Choose... beside it, in the two kinds a +// path comes in. Disabled is both halves at once, the way a frozen form draws +// it (Fields.Freeze walks every control of a field). +func pathBox() Entry { + build := func(takes parts.Takes, path string) *fyne.Container { + e := parts.NewEntry() + e.SetText(path) + return parts.NewPathBox(e, takes, func(func(string)) {}) + } + return Entry{Name: "PathBox", Covers: []string{"Takes"}, States: []State{ + {"a folder, empty, with its hint", func() fyne.CanvasObject { return build(parts.TakesFolder, "") }}, + {"a file, empty, with its hint", func() fyne.CanvasObject { return build(parts.TakesFile, "") }}, + {"holding a path", func() fyne.CanvasObject { return build(parts.TakesFolder, "/home/someone/tfg-out") }}, + {"long path", func() fyne.CanvasObject { return build(parts.TakesFile, "/home/someone/"+longText+"/"+longText+".zip") }}, + {"disabled", func() fyne.CanvasObject { return disabledAll(build(parts.TakesFolder, "/home/someone/tfg-out")) }}, + }} +} + +// disabledAll switches off every control in a row of them, the way a frozen +// form does. +func disabledAll(c *fyne.Container) *fyne.Container { + for _, o := range c.Objects { + if d, ok := o.(fyne.Disableable); ok { + d.Disable() + } + } + return c +} + func toggle() Entry { return Entry{Name: "Toggle", Natural: true, States: []State{ {"off", func() fyne.CanvasObject { return parts.NewToggle(func(bool) {}) }}, diff --git a/internal/gui/parts/entry.go b/internal/gui/parts/entry.go index e272f4a3..1435bcf1 100644 --- a/internal/gui/parts/entry.go +++ b/internal/gui/parts/entry.go @@ -50,6 +50,10 @@ type Entry struct { // is held to (O207). Nil until a field wires one, which is every field the // box stands in. ring *Ring + + // takes is the kind of path this box holds, set by NewPathBox, and nought + // for every box that holds something else. See PathTargets. + takes Takes } // NewEntry builds one. diff --git a/internal/gui/parts/fields.go b/internal/gui/parts/fields.go index a9bd997c..aa748259 100644 --- a/internal/gui/parts/fields.go +++ b/internal/gui/parts/fields.go @@ -628,32 +628,35 @@ func (s *Fields) Marked() []string { // registry exists: a field added later is covered without anybody remembering. // Controls that cannot be disabled are skipped rather than reported, because // this is not the place that decides what a control is. +// +// EVERY control under a field, walked through the containers round it. A path +// box is a box with Choose... beside it, and freezing the first control only +// left the button live, so a folder picked mid run landed in the grey box. +// Guarded by TestEveryControlOfAFieldIsOffWhileARunOwnsTheScreen. func (s *Fields) Freeze(frozen bool) { for _, f := range s.list { - // The registered object is sometimes a container fixing the control's - // width rather than the control - see inside, which is where asking - // the wrapper instead cost this a silent no-op. - found := inside(f.Control, func(o fyne.CanvasObject) bool { - _, ok := o.(fyne.Disableable) - return ok - }) - if found == nil { - continue - } - control := found.(fyne.Disableable) - if frozen { - control.Disable() - continue - } - control.Enable() + freezeUnder(f.Control, frozen) } for _, b := range s.bare { - if frozen { - b.control.Disable() - continue + setFrozen(b.control, frozen) + } +} + +// freezeUnder freezes or thaws every control under one field's control. +func freezeUnder(control fyne.CanvasObject, frozen bool) { + walkControls(control, func(o fyne.CanvasObject) { + if d, ok := o.(fyne.Disableable); ok { + setFrozen(d, frozen) } - b.control.Enable() + }) +} + +func setFrozen(d fyne.Disableable, frozen bool) { + if frozen { + d.Disable() + return } + d.Enable() } // Controls is every control a person types into, for a guard comparing the diff --git a/internal/gui/parts/pathbox.go b/internal/gui/parts/pathbox.go new file mode 100644 index 00000000..56d7b3aa --- /dev/null +++ b/internal/gui/parts/pathbox.go @@ -0,0 +1,98 @@ +package parts + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// Takes is what a path box holds: a folder or a file. +// +// Declared once, on the box, and read by everything that puts a path into it: +// the picker beside it opens for that kind, and a folder or a file dropped onto +// the window lands only in a box that takes it. Two declarations - which picker +// and what a drop may bring - would be two answers to one question, and the +// copy is the one that drifts. +type Takes int + +// The two kinds of path. Nought is a box that takes no path at all, which is +// every box that is not a path box. +const ( + TakesFolder Takes = iota + 1 + TakesFile +) + +// NewPathBox is a box for a path with a button beside it that opens a picker. +// +// Typing a path is fine when you have one to hand and hopeless when you do not, +// so the box carries a way to browse to one. The box stays editable: a picker +// that replaced typing would take away pasting a path somebody sent you, which +// is how most of these get filled in. +// +// choose opens the picker for what the box takes and calls back with the path +// picked, or with nothing when the person changed their mind - which leaves the +// box as it was. +// +// One box and one button for every path on every screen, so a path field looks +// and behaves the same whatever it names (GUI rule 2). Moved here from the +// window on 2026-11-14 so that it is a part the catalogue draws and the box can +// say what it takes. +func NewPathBox(box *Entry, takes Takes, choose func(func(string))) *fyne.Container { + box.takes = takes + if box.PlaceHolder == "" { + box.SetPlaceHolder(pathHint(takes)) + } + pick := NewButton(Secondary, text.ButtonChoose(), func() { + choose(func(path string) { + if path != "" { + box.SetText(path) + } + }) + }) + return container.NewBorder(nil, nil, nil, pick, box) +} + +// pathHint is what an empty path box says, which is also the one place on the +// screen that tells somebody a folder or a file can be dropped there. +func pathHint(takes Takes) string { + if takes == TakesFolder { + return text.HintDropFolder() + } + return text.HintDropFile() +} + +// PathTarget is a path box on a screen, with the field it stands in. +type PathTarget struct { + Field *Field + Box *Entry + Takes Takes +} + +// PathTargets is every path box among the fields of a screen, in the order +// they were built - which is the order they stand on the screen. +// +// Read from the registry every time rather than kept, because the registry is +// what a screen rebuilds: the Tools tab draws the boxes of whichever tool is +// chosen, and a list kept from before would point at boxes no longer there. +// A function rather than a method, because Fields stands at its ceiling of +// methods. +func PathTargets(s *Fields) []PathTarget { + var out []PathTarget + for _, f := range s.list { + out = append(out, pathBoxesOf(f)...) + } + return out +} + +// pathBoxesOf is the path boxes under one field, which is one box or none on +// every screen today. +func pathBoxesOf(f *Field) []PathTarget { + var out []PathTarget + for _, box := range boxesIn(f.Control) { + if box.takes != 0 { + out = append(out, PathTarget{Field: f, Box: box, Takes: box.takes}) + } + } + return out +} diff --git a/internal/gui/run_cgo.go b/internal/gui/run_cgo.go index e2666c39..137465e8 100644 --- a/internal/gui/run_cgo.go +++ b/internal/gui/run_cgo.go @@ -221,7 +221,9 @@ func (d desktop) ChooseDirectory(chosen func(string)) { chosen("") return } - chosen(dir.Path()) + // The system's spelling, the same text a drop of this folder puts in + // the box - see window.PathOf. + chosen(window.PathOf(dir)) }, d.Window) } @@ -252,7 +254,7 @@ func pickedPath(file fyne.URIReadCloser, err error) string { if err != nil { return "" } - return file.URI().Path() + return window.PathOf(file.URI()) } // Copy puts text on the clipboard of the application, which is where the diff --git a/internal/gui/text/dropping.go b/internal/gui/text/dropping.go new file mode 100644 index 00000000..3fb34484 --- /dev/null +++ b/internal/gui/text/dropping.go @@ -0,0 +1,55 @@ +package text + +// The words about a folder or a file dropped onto the window. +// +// A drop puts a path into a path box exactly as Choose... does, so most of +// what can be said about it is said by the box itself afterwards. What is here +// is what only a drop can get wrong: the wrong kind, more than one, something +// that is not there, and a screen that is busy. Each is said under the box it +// is about, which is where the person is looking. +// +// A screen whose work has started says nothing about a drop and takes none, +// the way a frozen box takes no typing. A sentence under the box would outlive +// the work it is about, because nothing clears a box when work ends. + +// HintDropFolder and HintDropFile are what an empty path box says: that the +// path can be typed, and that the folder or the file can be dropped there. +// The only place on the screen that tells somebody dropping works at all. +func HintDropFolder() string { return say("HintDropFolder", "Drop a folder here, or type its path") } +func HintDropFile() string { return say("HintDropFile", "Drop a file here, or type its path") } + +// DropTakesFolder is a file dropped where a folder goes, and DropTakesFile the +// other way round. The box is named by the label above it and the dropped +// thing by its name, so the sentence says which of the two was the mistake. +func DropTakesFolder(box, name string) string { + return sayf("DropTakesFolder", "{{.Box}} takes a folder. {{.Name}} is a file.", map[string]any{"Box": box, "Name": name}) +} + +func DropTakesFile(box, name string) string { + return sayf("DropTakesFile", "{{.Box}} takes a file. {{.Name}} is a folder.", map[string]any{"Box": box, "Name": name}) +} + +// DropOneAtATime is several things dropped onto a box that holds one path. +// Nothing is taken: which of them came first is the order a file manager +// listed them in, not a choice anybody made. +func DropOneAtATime(count int) string { + return sayN("DropOneAtATime", "Drop one at a time. {{.Count}} item was dropped.", "Drop one at a time. {{.Count}} items were dropped.", count, nil) +} + +// DropNeither is something dropped that is there and is neither a file nor a +// folder - a device or a pipe on Linux and macOS. +func DropNeither(name string) string { + return sayf("DropNeither", "{{.Name}} is neither a file nor a folder.", map[string]any{"Name": name}) +} + +// DropNotText is a dropped path whose name is not text the box can hold. A +// file system on Linux can hold such names, and a box that showed it would +// show a path that is not the one dropped. Typing it is no way out either - +// a keyboard writes text - so what is left is a name that is text. +func DropNotText() string { + return say("DropNotText", "The name of what was dropped is not text this box can hold. Rename it, then drop it again.") +} + +// DropAimless is a drop onto a screen with more than one box that takes it, +// landing on none of them. +func DropAimless() string { return say("DropAimless", "Drop it onto the box it is for.") } diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index 77f014f9..73e58ece 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -314,6 +314,37 @@ "hash": "sha256-3e96ff18edeb", "other": "{{.Directory}} ({{.Free}} free)" }, + "DropAimless": { + "description": "Shown in the window.", + "hash": "sha256-18e4e580f745", + "other": "Drop it onto the box it is for." + }, + "DropNeither": { + "description": "Shown in the window. Carries one value, {{.Name}}, which has to stay spelled exactly that way.", + "hash": "sha256-f61888808983", + "other": "{{.Name}} is neither a file nor a folder." + }, + "DropNotText": { + "description": "Shown in the window.", + "hash": "sha256-4f9d6aa5f35b", + "other": "The name of what was dropped is not text this box can hold. Rename it, then drop it again." + }, + "DropOneAtATime": { + "description": "Shown in the window. One form per number, and a language may need more than the two English has - add them beside these. Carries one value, {{.Count}}, which has to stay spelled exactly that way.", + "hash": "sha256-d5a5966cef2a", + "one": "Drop one at a time. {{.Count}} item was dropped.", + "other": "Drop one at a time. {{.Count}} items were dropped." + }, + "DropTakesFile": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Box}}, {{.Name}}.", + "hash": "sha256-cdb72cf7cc14", + "other": "{{.Box}} takes a file. {{.Name}} is a folder." + }, + "DropTakesFolder": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Box}}, {{.Name}}.", + "hash": "sha256-826478495ba2", + "other": "{{.Box}} takes a folder. {{.Name}} is a file." + }, "FieldBasePreset": { "description": "The name above a box somebody fills in.", "hash": "sha256-6daf826260dd", @@ -471,6 +502,16 @@ "hash": "sha256-67e5c4c4940a", "other": "Break the files on purpose." }, + "HintDropFile": { + "description": "The line under a field name, saying what the field does.", + "hash": "sha256-ac840e97cad5", + "other": "Drop a file here, or type its path" + }, + "HintDropFolder": { + "description": "The line under a field name, saying what the field does.", + "hash": "sha256-1b07015c8614", + "other": "Drop a folder here, or type its path" + }, "HintExpected": { "description": "The line under a field name, saying what the field does.", "hash": "sha256-a1b4f34d2e0e", diff --git a/internal/gui/text/locale/pl.json b/internal/gui/text/locale/pl.json index a283e1fb..c6042c86 100644 --- a/internal/gui/text/locale/pl.json +++ b/internal/gui/text/locale/pl.json @@ -62,6 +62,12 @@ "DetailTargetID": { "hash": "sha256-1a2680483a4d", "other": "Trafia do manifestu i do nazw plików, więc test odróżni te pliki od reszty przebiegu. Ziarna są z niej wyprowadzane, więc jej zmiana zmienia bajty." }, "DetailTool": { "hash": "sha256-1fdd99516e02", "other": "To, na czym narzędzie pracuje, i jego ustawienia zmieniają się razem z wybranym narzędziem." }, "DirectoryWithFreeSpace": { "hash": "sha256-3e96ff18edeb", "other": "{{.Directory}} (wolne {{.Free}})" }, + "DropAimless": { "hash": "sha256-18e4e580f745", "other": "Upuść to na pole, do którego należy." }, + "DropNeither": { "hash": "sha256-f61888808983", "other": "{{.Name}} nie jest ani plikiem, ani folderem." }, + "DropNotText": { "hash": "sha256-4f9d6aa5f35b", "other": "Nazwa upuszczonego elementu nie jest tekstem, który to pole może pomieścić. Zmień jej nazwę i upuść ponownie." }, + "DropOneAtATime": { "hash": "sha256-d5a5966cef2a", "one": "Upuszczaj po jednym. Upuszczono {{.Count}} element.", "few": "Upuszczaj po jednym. Upuszczono {{.Count}} elementy.", "many": "Upuszczaj po jednym. Upuszczono {{.Count}} elementów.", "other": "Upuszczaj po jednym. Upuszczono {{.Count}} elementu." }, + "DropTakesFile": { "hash": "sha256-cdb72cf7cc14", "other": "{{.Box}} przyjmuje plik. {{.Name}} to folder." }, + "DropTakesFolder": { "hash": "sha256-826478495ba2", "other": "{{.Box}} przyjmuje folder. {{.Name}} to plik." }, "FieldBasePreset": { "hash": "sha256-6daf826260dd", "other": "Preset bazowy" }, "FieldBoundary": { "hash": "sha256-0db0580b20bd", "other": "Testowany limit" }, "FieldBuildOnPreset": { "hash": "sha256-765fe22ce5d4", "other": "Zacznij od presetu" }, @@ -93,6 +99,8 @@ "HintBasePreset": { "hash": "sha256-d75c8b442fd9", "other": "Jego pliki idą pierwsze." }, "HintBoundary": { "hash": "sha256-3ef02abcdddf", "other": "Trzy pliki: bajt poniżej limitu, dokładnie na nim i bajt powyżej." }, "HintDamage": { "hash": "sha256-67e5c4c4940a", "other": "Celowo psuje pliki." }, + "HintDropFile": { "hash": "sha256-ac840e97cad5", "other": "Upuść tu plik albo wpisz jego ścieżkę" }, + "HintDropFolder": { "hash": "sha256-1b07015c8614", "other": "Upuść tu folder albo wpisz jego ścieżkę" }, "HintExpected": { "hash": "sha256-a1b4f34d2e0e", "other": "Co system pod testem powinien zrobić z tymi plikami." }, "HintFormat": { "hash": "sha256-f5616fe5a139", "other": "Jaki rodzaj pliku wytworzyć." }, "HintGroup": { "hash": "sha256-e95a20274902", "other": "Oznacza kilka partii jako jeden rodzaj przypadku." }, diff --git a/internal/gui/window/dropaim.go b/internal/gui/window/dropaim.go new file mode 100644 index 00000000..9c3a1acb --- /dev/null +++ b/internal/gui/window/dropaim.go @@ -0,0 +1,155 @@ +package window + +import "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + +// Where a folder or a file dropped onto the window goes, decided apart from +// the window (GUI rule 15): what was dropped and which boxes the screen has go +// in, an answer comes out, and nothing here draws or reads the disk. Exported +// for the guards, which live in another package. +// +// The rules, in the order they are asked, and why each is where it is: +// +// - A screen with no path box, or a drop of nothing, comes to nothing. The +// owner's decision of 2026-11-14 for About and Preferences. +// - A screen whose work has started takes nothing, the way a frozen box +// takes no typing. Asked before anything else because the work read the +// boxes when it began, so no answer about the drop is worth giving. +// - More than one thing dropped is refused, and nothing is taken: which of +// them comes first is the order a file manager listed them in. The owner's +// decision of 2026-11-14. +// - The one thing dropped has to be a folder or a file that is there, with a +// name that is text. +// - Then the box. Exactly one box taking that kind gets it WHEREVER the drop +// landed, and that is what keeps this working on a Linux desktop that runs +// Wayland natively: there the toolkit's position is where the pointer was +// before the drag began, not where it ended (glfw wl_window.c, +// dataDeviceHandleMotion is empty). Only between two boxes of the same +// kind is the position asked, and a drop on neither is refused rather +// than guessed. See docs/DRAG-DROP-2026-11-14.md. + +// DroppedItem is one thing dropped onto the window, once the disk was asked +// what it is. +type DroppedItem struct { + Path string + // Takes is the kind of path this is, and nought for a thing that is there + // and is neither a folder nor a file. + Takes parts.Takes + // Unread is why the disk could not say what this is, nil when it could. + Unread error + // NotText is a name that is not text a box can hold. + NotText bool +} + +// DropBox is one path box on the screen a drop landed on. +type DropBox struct { + Takes parts.Takes + Area Area +} + +// Area is a rectangle on the canvas. The zero area holds no point: a box that +// is not drawn is never under a drop. +type Area struct{ X, Y, W, H float32 } + +func (a Area) holds(x, y float32) bool { + return a.W > 0 && a.H > 0 && x >= a.X && x < a.X+a.W && y >= a.Y && y < a.Y+a.H +} + +// cut is the part of this area that lies inside another, or the zero area +// when none of it does. +func (a Area) cut(by Area) Area { + left, top := max(a.X, by.X), max(a.Y, by.Y) + right, bottom := min(a.X+a.W, by.X+by.W), min(a.Y+a.H, by.Y+by.H) + if right <= left || bottom <= top { + return Area{} + } + return Area{X: left, Y: top, W: right - left, H: bottom - top} +} + +// DropRefusal is why a drop put nothing into a box, and so which sentence +// goes under it. +type DropRefusal int + +// The reasons, in the order AimDrop asks about them. +const ( + RefusedMany DropRefusal = iota + 1 + RefusedNotText + RefusedUnread + RefusedNeither + RefusedKind + RefusedAimless +) + +// DropAnswer is what a drop comes to. Box is an index into the boxes given, +// or below nought when nothing happens at all. Put says the path goes into +// the box, and otherwise Why says what goes under it. +type DropAnswer struct { + Box int + Put bool + Why DropRefusal +} + +// AimDrop decides where a drop goes. x and y are where the toolkit says it +// landed, asked only between two boxes of the same kind. +func AimDrop(items []DroppedItem, boxes []DropBox, x, y float32, busy bool) DropAnswer { + if len(items) == 0 || len(boxes) == 0 || busy { + return DropAnswer{Box: -1} + } + // A refusal goes under the box the drop landed on, and under the first + // box where it landed on none - a screen with one box has no other choice. + hit := boxAt(boxes, x, y) + under := max(hit, 0) + if len(items) > 1 { + return DropAnswer{Box: under, Why: RefusedMany} + } + item := items[0] + if why := refusalOf(item); why != 0 { + return DropAnswer{Box: under, Why: why} + } + return boxFor(item, boxes, hit) +} + +// refusalOf is what is wrong with a dropped thing whatever box it was aimed at, +// or nought when nothing is. +func refusalOf(item DroppedItem) DropRefusal { + switch { + case item.NotText: + return RefusedNotText + case item.Unread != nil: + return RefusedUnread + case item.Takes == 0: + return RefusedNeither + } + return 0 +} + +// boxFor is the box a folder or a file that is there goes into. hit is the box +// under the drop, below nought when it landed on none. +func boxFor(item DroppedItem, boxes []DropBox, hit int) DropAnswer { + var fitting []int + for i, b := range boxes { + if b.Takes == item.Takes { + fitting = append(fitting, i) + } + } + switch { + case len(fitting) == 1: + return DropAnswer{Box: fitting[0], Put: true} + case hit >= 0 && boxes[hit].Takes == item.Takes: + return DropAnswer{Box: hit, Put: true} + case hit >= 0: + return DropAnswer{Box: hit, Why: RefusedKind} + case len(fitting) == 0: + return DropAnswer{Box: 0, Why: RefusedKind} + } + return DropAnswer{Box: fitting[0], Why: RefusedAimless} +} + +// boxAt is the box under a point, or below nought when there is none. +func boxAt(boxes []DropBox, x, y float32) int { + for i, b := range boxes { + if b.Area.holds(x, y) { + return i + } + } + return -1 +} diff --git a/internal/gui/window/dropping.go b/internal/gui/window/dropping.go new file mode 100644 index 00000000..a60f143c --- /dev/null +++ b/internal/gui/window/dropping.go @@ -0,0 +1,318 @@ +package window + +import ( + "os" + "path/filepath" + "sync" + "unicode/utf8" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// A folder or a file dropped onto the window goes into the path box that takes +// it, as if it had been picked with Choose... beside that box. Owner's decision +// of 2026-11-14, docs/DRAG-DROP-2026-11-14.md. +// +// The toolkit takes ONE callback for the whole window (fyne v2.8.2, window.go: +// SetOnDropped) and has no way for a control to be a drop target of its own. +// So the window holds the callback, asks which screen is being looked at, and +// the screen's path boxes say what they take - the box is the target, and a +// path box added to any screen tomorrow takes drops without a line written +// here. Where it goes is AimDrop's answer, see dropaim.go. + +// dropScreen is what a screen hands the window so a drop can land on it: the +// fields that hold its path boxes, whether work owns it, and the three things +// that bring a box into view. +type dropScreen struct { + fields *parts.Fields + busy *busy + sections *sections + scroll *container.Scroll + // unfold opens whatever the screen put a box away inside. Nil on a screen + // that folds nothing of its own. + unfold func(string) +} + +// takesDrops is a screen that has path boxes. The three screens that run the +// engine hand over their runner's, and the Tools tab its own. +type takesDrops interface{ dropScreen() dropScreen } + +func (g *Generate) dropScreen() dropScreen { return dropOnto(g.runner) } +func (p *Preset) dropScreen() dropScreen { return dropOnto(p.runner) } +func (r *Recipe) dropScreen() dropScreen { return dropOnto(r.runner) } + +// dropScreen is what the Tools tab hands the window. It folds nothing of its +// own beyond its sections, so there is no unfold. +func (t *Tools) dropScreen() dropScreen { + return dropScreen{fields: t.fields, busy: t.busy, sections: t.sections, scroll: t.view.scroll} +} + +// dropOnto is what a screen that runs the engine hands the window. A function +// rather than a method, because the runner stands at its ceiling of methods. +func dropOnto(r *runner) dropScreen { + return dropScreen{fields: r.fields, busy: r.busy, sections: r.sections, scroll: r.scroll, unfold: r.unfold} +} + +// dropping is the window's one answer to a drop. +type dropping struct { + screens map[string]dropScreen + looking *string + + // latest counts drops, so the answer about an older one that the disk + // gave late does not land over a newer one. + latest int + // reading is the disk being asked about a drop, waited for by a guard. + reading sync.WaitGroup + // answer runs what the disk said, already on the interface thread: at + // once, or in the stand in of a guard that keeps it to run at a moment the + // guard chooses (AnswerDropLater) - an optional interface, checked rather + // than required, that nothing in the shipped program implements, the + // shape of offerHolding. It is what lets a guard put work or typing + // between a drop and its answer without racing a goroutine. + answer func(func()) +} + +// atOnce is how the shipped program runs an answer. +func atOnce(answer func()) { answer() } + +// wireDropping puts the window's drop callback on the host. looking is the tab +// on show, read when the drop arrives - the same pointer the keyboard reads. +// +// The screens are the ones the keyboard reaches, each asked whether it takes a +// drop, so the work screens are one list rather than a second one kept beside +// the first: a screen added to the keyboard's map with path boxes on it takes +// drops the day it is added. +func wireDropping(h Host, keyed map[string]keyboardScreen, looking *string) *dropping { + d := &dropping{screens: map[string]dropScreen{}, looking: looking, answer: atOnce} + if later, ok := h.(interface{ AnswerDropLater(func()) }); ok { + d.answer = later.AnswerDropLater + } + for id, screen := range keyed { + if takes, ok := screen.(takesDrops); ok { + d.screens[id] = takes.dropScreen() + } + } + h.SetOnDropped(d.dropped) + return d +} + +// Settled waits for the disk to answer about a drop, for a guard. See +// offerSettling. +func (d *dropping) Settled() { d.reading.Wait() } + +// dropped is the toolkit's callback, on the interface thread. +// +// The disk is asked on a worker, and that is a measurement rather than care: +// os.Stat on a network path nobody answers for took 21.0 s on this machine on +// 2026-11-14, and the window would have stood still for all of it. More than +// one thing is refused without asking the disk at all. +func (d *dropping) dropped(at fyne.Position, uris []fyne.URI) { + screen, takes := d.screens[*d.looking] + if !takes || len(uris) == 0 { + return + } + d.latest++ + // Work owns the screen: nothing, which is AimDrop's answer too. Asked here + // as well, before the disk, because a read slower than the work would + // otherwise answer on a screen that is idle again and put the path in - + // a drop made while the form was frozen would land after all. + if screen.busy.occupied { + return + } + if len(uris) > 1 { + d.land(screen, make([]DroppedItem, len(uris)), at) + return + } + d.read(dropWaiting{mine: d.latest, aimed: *d.looking, screen: screen, at: at, + typed: pathTexts(screen.fields)}, PathOf(uris[0])) +} + +// PathOf is the path a toolkit address names, written the way the system +// writes it. +// +// The toolkit holds a path with forward slashes on every system (fyne v2.8.2, +// storage/repository/parse.go), so on Windows both a picker and a drop hand +// back "C:/Users". One function for the pickers and for a drop, so a folder +// picked with Choose... and the same folder dropped put the same text in the +// box. The pickers handed the forward slashes straight on until 2026-11-14. +func PathOf(u fyne.URI) string { return filepath.FromSlash(u.Path()) } + +// dropWaiting is one drop the disk is being asked about: which drop it was, +// the screen it was aimed at, where it landed, and what the screen's path +// boxes held at that moment. +type dropWaiting struct { + mine int + aimed string + screen dropScreen + at fyne.Position + typed map[*parts.Entry]string +} + +// read asks the disk on a worker and answers on the interface thread. +func (d *dropping) read(drop dropWaiting, path string) { + d.reading.Add(1) + go func() { + defer d.reading.Done() + item := readDropped(path) + fyne.Do(func() { d.answered(drop, item) }) + }() +} + +// answered hands what the disk said to answer, on the interface thread. +func (d *dropping) answered(drop dropWaiting, item DroppedItem) { + d.answer(func() { d.arrived(drop, item) }) +} + +// arrived lands a drop the disk has answered about, unless something since +// has made it an answer nobody is waiting for: a newer drop, another tab, or +// a path box typed into while the disk was asked - what was typed last is +// what the person means, and the drop does not get to write over it. +func (d *dropping) arrived(drop dropWaiting, item DroppedItem) { + if drop.mine != d.latest || *d.looking != drop.aimed || retyped(drop.typed) { + return + } + d.land(drop.screen, []DroppedItem{item}, drop.at) +} + +// pathTexts is what every path box of a screen holds now. +func pathTexts(fields *parts.Fields) map[*parts.Entry]string { + typed := map[*parts.Entry]string{} + for _, t := range parts.PathTargets(fields) { + typed[t.Box] = t.Box.Text + } + return typed +} + +// retyped says whether any of the boxes holds something else now. +func retyped(typed map[*parts.Entry]string) bool { + for box, text := range typed { + if box.Text != text { + return true + } + } + return false +} + +// readDropped asks the disk what a dropped path is. Followed through a link, +// as a path typed into the box is. +func readDropped(path string) DroppedItem { + item := DroppedItem{Path: path} + if !utf8.ValidString(path) { + item.NotText = true + return item + } + info, err := os.Stat(path) + switch { + case err != nil: + item.Unread = err + case info.IsDir(): + item.Takes = parts.TakesFolder + case info.Mode().IsRegular(): + item.Takes = parts.TakesFile + } + return item +} + +// land puts a drop where AimDrop says, on the interface thread, and brings the +// box into view whatever happened to it - a change made off the screen is a +// change nobody saw. +func (d *dropping) land(screen dropScreen, items []DroppedItem, at fyne.Position) { + targets := parts.PathTargets(screen.fields) + answer := AimDrop(items, boxesOf(targets, screen.scroll), at.X, at.Y, screen.busy.occupied) + if answer.Box < 0 { + return + } + target := targets[answer.Box] + if answer.Put { + // What the box said about its last value is not about this one. + target.Field.Clear() + target.Box.SetText(items[0].Path) + } else { + target.Field.Say(dropSentence(answer.Why, target, items)) + } + if screen.unfold != nil { + screen.unfold(target.Field.Setting) + } + screen.sections.openHolding(target.Field.Control) + parts.Reveal(screen.scroll, target.Field.Control) +} + +// boxesOf is the path boxes of a screen as AimDrop takes them. +// +// Where each one stands is asked only when there are two or more, because only +// then is the position read at all - and asking the toolkit where something is +// walks the whole canvas. +func boxesOf(targets []parts.PathTarget, scroll *container.Scroll) []DropBox { + boxes := make([]DropBox, len(targets)) + for i, t := range targets { + boxes[i].Takes = t.Takes + if len(targets) > 1 { + boxes[i].Area = areaOf(t.Field.Control, scroll) + } + } + return boxes +} + +// areaOf is where a control stands on the canvas, cut to the part of the +// screen that scrolls, or the zero area when it is not drawn. +// +// The toolkit answers nought for a control it did not find on a visible +// canvas, which is a control folded away. A box at the very corner of the +// window would read the same, and none stands there: the tabs do. +func areaOf(o fyne.CanvasObject, scroll *container.Scroll) Area { + driver := fyne.CurrentApp().Driver() + at := driver.AbsolutePositionForObject(o) + if at.IsZero() || !o.Visible() { + return Area{} + } + size := o.Size() + a := Area{X: at.X, Y: at.Y, W: size.Width, H: size.Height} + if scroll == nil { + return a + } + view := driver.AbsolutePositionForObject(scroll) + return a.cut(Area{X: view.X, Y: view.Y, W: scroll.Size().Width, H: scroll.Size().Height}) +} + +// dropSentence is what goes under a box a drop did not go into. +func dropSentence(why DropRefusal, target parts.PathTarget, items []DroppedItem) string { + switch why { + case RefusedMany: + return text.DropOneAtATime(len(items)) + case RefusedNotText: + return text.DropNotText() + case RefusedAimless: + return text.DropAimless() + case RefusedUnread: + return text.Refusal(items[0].Unread, target.Field.Label) + case RefusedNeither: + return text.DropNeither(droppedName(items[0].Path)) + case RefusedKind: + return wrongKind(target, items[0]) + } + return "" +} + +// wrongKind is a folder where a file goes, or a file where a folder goes, +// named by the box it was refused under. +func wrongKind(target parts.PathTarget, item DroppedItem) string { + if target.Takes == parts.TakesFolder { + return text.DropTakesFolder(target.Field.Label, droppedName(item.Path)) + } + return text.DropTakesFile(target.Field.Label, droppedName(item.Path)) +} + +// droppedName is the name a sentence calls a dropped path by: the last part of +// it, or the whole of it for the root of a drive, whose last part is a slash. +func droppedName(path string) string { + name := filepath.Base(path) + if name == "." || os.IsPathSeparator(name[0]) { + name = path + } + return core.Shown(name) +} diff --git a/internal/gui/window/generate.go b/internal/gui/window/generate.go index b8c647a7..6a54639b 100644 --- a/internal/gui/window/generate.go +++ b/internal/gui/window/generate.go @@ -57,6 +57,13 @@ type Host interface { // the reason ChooseDirectory gives. ChooseFile(func(string)) + // SetOnDropped takes the one function the toolkit calls when folders or + // files are dropped onto the window, with where they landed. fyne.Window + // has it, and a stand in keeps the function so a guard can drop onto a + // window that has no screen - the toolkit's own test window throws it + // away (fyne v2.8.2, test/window.go). See dropping.go. + SetOnDropped(func(fyne.Position, []fyne.URI)) + // Copy puts text on the system's clipboard, on a press somebody made - the // checksum a tool worked out, so it can be pasted where it is needed. Only // a real window has a clipboard to put it on, and a stand in records it. @@ -431,7 +438,7 @@ func (g *Generate) settingsSection() []fyne.CanvasObject { ), g.sections.section(sectionOutput, text.SectionOutput(), parts.Wide(add(engine.SettingOutDir, text.FieldOutputDir(), text.HintOutputDir(), g.tips.Say(text.DetailOutputDir()), - chooserFor(g.outDir, g.host.ChooseDirectory))), + pathBox(g.host, g.outDir, parts.TakesFolder))), add(engine.SettingSeed, text.FieldSeed(), text.HintSeed(), g.tips.Say(text.DetailSeed()), parts.Numeric(g.seed)), g.fields.AddToggle(engine.SettingLabel, text.FieldLabel(), "", g.tips.Say(text.DetailLabel()), g.label), diff --git a/internal/gui/window/open.go b/internal/gui/window/open.go index 8ac84614..6436d223 100644 --- a/internal/gui/window/open.go +++ b/internal/gui/window/open.go @@ -5,7 +5,6 @@ import ( "os" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/driver/desktop" "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" @@ -149,7 +148,10 @@ func Open(h Host) fyne.Size { for _, b := range busyScreens { b.changed = prefs.BusyChanged } - offerSettling(h, []interface{ Settled() }{gen, pre, rec, tools, out}) + // A folder or a file dropped onto the window goes to the screen being + // looked at, as a key does - wired here, among the work a guard waits for, + // because the disk is asked about it on a worker. See dropping.go. + offerSettling(h, []interface{ Settled() }{gen, pre, rec, tools, out, wireDropping(h, keyed, &looking)}) offerHolding(h, []interface{ HoldBeforeFinishing(func()) }{gen, pre, rec, tools}) wireKeyboard(h, keyed, &looking) @@ -362,29 +364,15 @@ func gitHub(h Host, look parts.Look) *parts.Button { return parts.NewButton(look, text.ButtonGitHub(), func() { h.OpenLink(text.RepositoryURL) }) } -// chooserFor is the output directory box with a way to browse to one. -// -// Typing a path is fine when you have one to hand and hopeless when you do not, -// and the box beside it is the only field on either screen whose value is not -// something a person carries in their head. The button asks the window, which -// is the only thing that can open a picker. -// -// The box stays editable. A picker that replaces typing takes away pasting a -// path somebody sent you, which is how most of these get filled in. -// -// pick is which picker the button opens - Host.ChooseDirectory for where files -// go, Host.ChooseFile for a file a tool reads. One box and one button for -// both, so a path field looks and behaves the same whatever it names (GUI -// rule 2). -func chooserFor(box *parts.Entry, pick func(func(string))) fyne.CanvasObject { - choose := parts.NewButton(parts.Secondary, text.ButtonChoose(), func() { - pick(func(path string) { - if path != "" { - box.SetText(path) - } - }) - }) - return container.NewBorder(nil, nil, nil, choose, box) +// pathBox is a path box with the window's picker for what it takes - see +// parts.NewPathBox. The button asks the window, which is the only thing that +// can open a picker, and what the box takes is the one place that decides +// which picker that is and what a drop may bring. +func pathBox(h Host, box *parts.Entry, takes parts.Takes) fyne.CanvasObject { + if takes == parts.TakesFolder { + return parts.NewPathBox(box, takes, h.ChooseDirectory) + } + return parts.NewPathBox(box, takes, h.ChooseFile) } // startingDirectory is where the output field points before anybody changes it. diff --git a/internal/gui/window/preset.go b/internal/gui/window/preset.go index c8709c5a..a460a964 100644 --- a/internal/gui/window/preset.go +++ b/internal/gui/window/preset.go @@ -112,7 +112,7 @@ func NewPreset(host Host, links ...fyne.CanvasObject) *Preset { p.sections.section(sectionSettings, text.SectionSettings(), p.paramBox), p.sections.section(sectionOutput, text.SectionOutput(), parts.Wide(p.fields.Add(engine.SettingOutDir, text.FieldOutputDir(), text.HintOutputDir(), - p.tips.Say(text.DetailOutputDir()), chooserFor(p.outDir, p.host.ChooseDirectory))), + p.tips.Say(text.DetailOutputDir()), pathBox(p.host, p.outDir, parts.TakesFolder))), p.fields.Add(engine.SettingSeed, text.FieldSeed(), text.HintSeed(), p.tips.Say(text.DetailSeed()), parts.Numeric(p.seed)), ), diff --git a/internal/gui/window/recipe.go b/internal/gui/window/recipe.go index 4741190f..b3bc5840 100644 --- a/internal/gui/window/recipe.go +++ b/internal/gui/window/recipe.go @@ -563,7 +563,7 @@ func (r *Recipe) outputSection() fyne.CanvasObject { r.fields.Require(recipe.KeyOutputDir) return r.sections.section(sectionOutput, text.SectionOutput(), parts.Wide(r.fields.Add(recipe.KeyOutputDir, text.FieldOutputDir(), text.HintOutputDir(), - r.tips.Say(text.DetailOutputDir()), chooserFor(r.outDir, r.host.ChooseDirectory))), + r.tips.Say(text.DetailOutputDir()), pathBox(r.host, r.outDir, parts.TakesFolder))), r.fields.Add(recipe.KeyOutputManifest, text.FieldManifest(), text.HintManifest(), r.tips.Say(text.DetailManifest()), parts.Text(r.manifest)), r.fields.Add(recipe.KeySeed, text.FieldSeed(), text.HintSeed(), diff --git a/internal/gui/window/tools.go b/internal/gui/window/tools.go index 88ea5218..f678b7ca 100644 --- a/internal/gui/window/tools.go +++ b/internal/gui/window/tools.go @@ -240,7 +240,7 @@ func (t *Tools) onChosen(choice string) { box := entry("", "") t.form.inputs[in.Name] = box t.work.Add(parts.Wide(t.fields.Add(in.Name, text.SettingLabel(in.Name), text.ToolInput(d.ID, in.Name, in.Detail), - t.tips.Say(text.ToolInputWrittenAs(d.ID)), chooserFor(box, t.pickerFor(in.Kind))))) + t.tips.Say(text.ToolInputWrittenAs(d.ID)), pathBox(t.host, box, takesOf(in.Kind))))) } settings, objects := parts.DeclaredFields(text.ToolOwner(d.ID), d.Settings, t.fields, t.tips) t.form.settings = settings @@ -251,13 +251,14 @@ func (t *Tools) onChosen(choice string) { t.showResult(d, nil) } -// pickerFor is the window's picker for what an input names: a folder for a -// folder, a file for everything else. -func (t *Tools) pickerFor(kind tool.InputKind) func(func(string)) { +// takesOf is the kind of path box for what an input names: a folder for a +// folder, a file for everything else - the engine's declaration, so the box, +// its picker and what may be dropped on it follow the tool and nothing here. +func takesOf(kind tool.InputKind) parts.Takes { if kind == tool.Folder { - return t.host.ChooseDirectory + return parts.TakesFolder } - return t.host.ChooseFile + return parts.TakesFile } // PressGenerate runs the chosen tool - the name the window's keyboard asks