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.
+
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 @@
-