diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd2dc42..2f51d435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,9 +102,19 @@ because it turns other people's test suites red. from the size" when leaving it empty means going without - the password of an archive, the author of a PDF. What that means for the file is in the sentence beside the box. +- **The Tools tab names each tool and says more after a run.** The menu + starts every line with the tool's name on the command line, such as + `checksum-write`. A result that matches is green. Copy all puts the whole + result on the clipboard, every file of a long list included, and Open + folder shows the folder the tool worked in. ### Fixed +- **Choosing another tool on the Tools tab left the boxes in the wrong + place.** The section kept the height of the tool chosen before, so a + setting could stand under it with its list hidden, or an empty band was + left. The screen is now laid out again each time. + - **The FAQ on the website said a deliberately broken file was not possible yet.** `tfg damage` has been in the tool since 0.3.0, and the answer still called it a planned feature. It now says how to make one, in every language. diff --git a/internal/guard/boxwidth_test.go b/internal/guard/boxwidth_test.go index df985263..949bd19e 100644 --- a/internal/guard/boxwidth_test.go +++ b/internal/guard/boxwidth_test.go @@ -472,7 +472,7 @@ func TestALongValueTakesTheRowAndOnTheToolsTabNothingElseButAPathDoes(t *testing half := float32(parts.ColumnWidth) / 2 long, short := 0, 0 for _, d := range tool.All() { - menu.SetSelected(text.ToolQuestion(d.ID, d.Question)) + menu.SetSelected(text.ToolChoice(d.ID, d.Question)) layOut() mayTakeTheRow := map[fyne.CanvasObject]bool{} allowed := func(label string) { diff --git a/internal/guard/checksumfolder_test.go b/internal/guard/checksumfolder_test.go index 2a007ceb..2575d68d 100644 --- a/internal/guard/checksumfolder_test.go +++ b/internal/guard/checksumfolder_test.go @@ -449,7 +449,7 @@ func TestTheToolsTabWritesAndChecksAFolder(t *testing.T) { menu := chooserUnder(t, screen, text.FieldTool()) write, _ := tool.Get(checksum.WriteID) - menu.SetSelected(text.ToolQuestion(write.ID, write.Question)) + menu.SetSelected(text.ToolChoice(write.ID, write.Question)) pressNamed(t, screen, text.ButtonChoose()) if host.asked == 0 { t.Fatal("the browse button of checksum-write asked nobody for a folder") @@ -468,7 +468,7 @@ func TestTheToolsTabWritesAndChecksAFolder(t *testing.T) { } check, _ := tool.Get(checksum.CheckID) - menu.SetSelected(text.ToolQuestion(check.ID, check.Question)) + menu.SetSelected(text.ToolChoice(check.ID, check.Question)) host.pickedFile = sums pressNamed(t, screen, text.ButtonChoose()) pressNamed(t, screen, text.ButtonRunTool()) @@ -507,7 +507,7 @@ func TestTheToolsTabCutsALongListShort(t *testing.T) { window.Open(host) screen := selectTab(t, host.content, text.TabTools()) check, _ := tool.Get(checksum.CheckID) - chooserUnder(t, screen, text.FieldTool()).SetSelected(text.ToolQuestion(check.ID, check.Question)) + chooserUnder(t, screen, text.FieldTool()).SetSelected(text.ToolChoice(check.ID, check.Question)) pressNamed(t, screen, text.ButtonChoose()) pressNamed(t, screen, text.ButtonRunTool()) host.waitForWork() diff --git a/internal/guard/screenpixels_test.go b/internal/guard/screenpixels_test.go index b04b1611..d9f5df17 100644 --- a/internal/guard/screenpixels_test.go +++ b/internal/guard/screenpixels_test.go @@ -22,6 +22,7 @@ import ( "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" ) // What this defends. A screen keeps looking the way somebody last looked at it. @@ -335,6 +336,17 @@ func screenScenes() []screenScene { fillField(t, s.tab, text.SettingLabel("expected"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") pressNamed(t, s.tab, text.ButtonRunTool()) }}, + // The two folder tools, since 2026-10-05: checksum-write chosen once + // the screen was laid out with checksum, which is how the owner found + // its section keeping the height of the tool before - so the picture + // is of the change, not of a screen that opened with it. And a check + // that matches, its verdict in the colour of a match. + {name: "tools-write", tab: text.TabTools(), after: chooseToolIn(checksum.WriteID)}, + {name: "tools-check-result", tab: text.TabTools(), set: func(t *testing.T, s scene) { + chooseToolIn(checksum.CheckID)(t, s) + fillField(t, s.tab, text.SettingLabel(checksum.InputChecksumFile), "testdata/checksum-sample.SHA256SUMS") + pressNamed(t, s.tab, text.ButtonRunTool()) + }}, {name: "generate", tab: text.TabOneTarget()}, {name: "generate-empty", tab: text.TabOneTarget(), set: func(t *testing.T, s scene) { fillField(t, s.tab, text.FieldCount(), "0") diff --git a/internal/guard/testdata/checksum-sample.SHA256SUMS b/internal/guard/testdata/checksum-sample.SHA256SUMS new file mode 100644 index 00000000..a3e042db --- /dev/null +++ b/internal/guard/testdata/checksum-sample.SHA256SUMS @@ -0,0 +1 @@ +ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad checksum-sample.txt diff --git a/internal/guard/testdata/screens/catalogue.png b/internal/guard/testdata/screens/catalogue.png index d3556a8e..8f47ffe1 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 8bc577d9..d92263e2 100644 --- a/internal/guard/testdata/screens/catalogue.xml +++ b/internal/guard/testdata/screens/catalogue.xml @@ -1,7 +1,7 @@ - + - - + + @@ -2944,7 +2944,67 @@ - + + + + + Verdict + + + + + + a match + + + + + + + Matches: every file SHA256SUMS lists is what it says. + + + + + + + + + not a match + + + + + + + Does not match: SHA256SUMS lists files that are not what it says. + + + + + + + + + a long match + + + + + + + Matches: the sha512 is + ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd45 + 4d4423643ce80e2a9ac94fa54ca49f, as expected. + + + + + + + + + @@ -2993,7 +3053,7 @@ - + @@ -3414,7 +3474,7 @@ - + @@ -3754,7 +3814,7 @@ - + @@ -4265,7 +4325,7 @@ - + @@ -4335,7 +4395,7 @@ - + @@ -4919,7 +4979,7 @@ - + @@ -5503,7 +5563,7 @@ - + @@ -5616,7 +5676,7 @@ - + diff --git a/internal/guard/testdata/screens/tools-check-result.png b/internal/guard/testdata/screens/tools-check-result.png new file mode 100644 index 00000000..1563e6f1 Binary files /dev/null and b/internal/guard/testdata/screens/tools-check-result.png differ diff --git a/internal/guard/testdata/screens/tools-check-result.xml b/internal/guard/testdata/screens/tools-check-result.xml new file mode 100644 index 00000000..3bc8b2f7 --- /dev/null +++ b/internal/guard/testdata/screens/tools-check-result.xml @@ -0,0 +1,311 @@ + + + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + Tools + + + + + Preferences + + + + + About + + + + + + + + + + + + + + + + Tools + + + + + + + Small things to do with files you already have. Every tool here is on the command line too, as tfg tool. + + + + + + + + + + + + + + + + + + + + + + The question + + + + + + + + + + + + + + Tool + + + + + + + + + + + + + checksum-check - Is every file still what its checksum file says? + + + + + + + + + + + + + + + Reads a checksum file - SHA256SUMS, MD5SUMS or the tagged kind shasum writes - and checks every file it lists in + the folder the checksum file is in. Files it does not list are not looked at, the same as sha256sum -c. + + + + + + + + + + + + + + + + + + + + + + + + + What it works on + + + + + + + + + + + + + + + + Checksum file + + + + + + + + + + + + + + + testdata/checksum-sample.SHA256SUMS + + + + + + + + Choose... + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Result + + + + + + + + + + + + + + + Files checked: 1 + + + + + + + Matches: every file checksum-sample.SHA256SUMS lists is what it says. + + + + + + + + + Copy all + + + + + + + + + + + + + + + + + + + + + + + Run + + + + + Open folder + + + + + + + + + + + + + + + + + + + Donate + + + + + + + + + + + + + + + + diff --git a/internal/guard/testdata/screens/tools-refused.png b/internal/guard/testdata/screens/tools-refused.png index 6909a403..386f4506 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 8453a2b9..263ae7ec 100644 --- a/internal/guard/testdata/screens/tools-refused.xml +++ b/internal/guard/testdata/screens/tools-refused.xml @@ -105,7 +105,7 @@ - Is this file the one it claims to be? + checksum - Is this file the one it claims to be? diff --git a/internal/guard/testdata/screens/tools-result.png b/internal/guard/testdata/screens/tools-result.png index ca35bfe5..404c64c1 100644 Binary files a/internal/guard/testdata/screens/tools-result.png and b/internal/guard/testdata/screens/tools-result.png differ diff --git a/internal/guard/testdata/screens/tools-result.xml b/internal/guard/testdata/screens/tools-result.xml index fdd87f78..e99eb8d1 100644 --- a/internal/guard/testdata/screens/tools-result.xml +++ b/internal/guard/testdata/screens/tools-result.xml @@ -105,7 +105,7 @@ - Is this file the one it claims to be? + checksum - Is this file the one it claims to be? @@ -257,10 +257,10 @@ - - - - + + + + @@ -283,8 +283,8 @@ - - + + @@ -313,10 +313,19 @@ - Matches: the sha256 is ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad, as expected. + Matches: the sha256 is ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad, as expected. + + + + + + Copy all + + + @@ -333,11 +342,16 @@ - + Run + + + + Open folder + diff --git a/internal/guard/testdata/screens/tools-write.png b/internal/guard/testdata/screens/tools-write.png new file mode 100644 index 00000000..af09e6dc Binary files /dev/null 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 new file mode 100644 index 00000000..d83d29dc --- /dev/null +++ b/internal/guard/testdata/screens/tools-write.xml @@ -0,0 +1,325 @@ + + + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + Tools + + + + + Preferences + + + + + About + + + + + + + + + + + + + + + + Tools + + + + + + + Small things to do with files you already have. Every tool here is on the command line too, as tfg tool. + + + + + + + + + + + + + + + + + + + + + + The question + + + + + + + + + + + + + + Tool + + + + + + + + + + + + + checksum-write - How will I know later that nothing in this folder changed? + + + + + + + + + + + + + + + Writes the checksum of every file in a folder into one file beside them, SHA256SUMS for sha256. Check it later with tfg + tool checksum-check, or with sha256sum -c in that folder. + + + + + + + + + + + + + + + + + + + + + + + + + What it works on + + + + + + + + + + + + + + + + Folder + + + + + + + + + + + + + + + + + + + + + + + + + + Choose... + + + + + + + + + + + Algorithm + + + + + + + + + + + + + sha256 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Result + + + + + + + + + + + + + + + Nothing worked out yet. Choose what to work on and press Run. + + + + + + + + + + + + + + + + + + + + + + + Run + + + + + + + + + + + + + + + + + + + + + + + + Donate + + + + + + + + + + + + + + + + diff --git a/internal/guard/testdata/screens/tools.png b/internal/guard/testdata/screens/tools.png index 195cd160..185bb835 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 9d8bfc40..f2cf37d7 100644 --- a/internal/guard/testdata/screens/tools.xml +++ b/internal/guard/testdata/screens/tools.xml @@ -105,7 +105,7 @@ - Is this file the one it claims to be? + checksum - Is this file the one it claims to be? diff --git a/internal/guard/tools_test.go b/internal/guard/tools_test.go index b12f6ac8..8fc02403 100644 --- a/internal/guard/tools_test.go +++ b/internal/guard/tools_test.go @@ -184,7 +184,7 @@ func TestTheToolsScreenOffersEveryToolWithEveryBox(t *testing.T) { t.Fatal("the registry holds no tool, so this guard would pass against an empty screen") } for _, d := range all { - question := text.ToolQuestion(d.ID, d.Question) + question := text.ToolChoice(d.ID, d.Question) menu.SetSelected(question) if menu.Selected != question { t.Errorf("the menu does not offer %s (%q)", d.ID, question) diff --git a/internal/guard/toolsscreen_test.go b/internal/guard/toolsscreen_test.go new file mode 100644 index 00000000..a4048d0e --- /dev/null +++ b/internal/guard/toolsscreen_test.go @@ -0,0 +1,201 @@ +package guard + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "fyne.io/fyne/v2" + + "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" + "github.com/donislawdev/TestingFilesGenerator/internal/tool/checksum" +) + +// The Tools tab as somebody uses it - the owner's list of 2026-10-05 after +// looking at the window with the folder tools in it. + +// chooseToolIn is a step of a scene: the tool with this ID chosen in the menu +// at the head of the Tools tab, the way a person picks it. +func chooseToolIn(id string) func(t *testing.T, s scene) { + return func(t *testing.T, s scene) { + d, err := tool.Get(id) + if err != nil { + t.Fatal(err) + } + menuUnder(t, s.tab, text.FieldTool()).SetSelected(text.ToolChoice(d.ID, d.Question)) + } +} + +// TestChoosingAToolLaysTheScreenOutAsIfItOpenedWithIt chooses every tool on a +// screen already laid out with another one, and holds it to the screen that +// was laid out with that tool from the start - every box where it would be, +// every section as tall as what it holds. +// +// The owner's window of 2026-10-05: after a change of tool the section of +// boxes kept the height of the tool before. Algorithm stood under its section +// with its list hidden by the next one, and a tool with fewer boxes left an +// empty band - the toolkit did not lay the screen out again by itself +// (Tools.relay). +func TestChoosingAToolLaysTheScreenOutAsIfItOpenedWithIt(t *testing.T) { + ids := tool.Names() + if len(ids) < 2 { + t.Fatalf("%d tools, so there is no change of tool to ask about", len(ids)) + } + fresh := map[string]string{} + for _, id := range ids { + _, fresh[id] = renderScene(t, screenScene{tab: text.TabTools(), set: chooseToolIn(id)}) + } + for _, from := range ids { + for _, to := range ids { + if from == to { + continue + } + // Asserted rather than assumed: two tools drawing one screen would + // make this agree with itself whatever the layout did. + if fresh[from] == fresh[to] { + t.Fatalf("%s and %s draw the same screen, so choosing one after the other proves nothing", from, to) + } + _, switched := renderScene(t, screenScene{tab: text.TabTools(), set: chooseToolIn(from), after: chooseToolIn(to)}) + if switched != fresh[to] { + diff, n := differingLines(fresh[to], switched, 8) + t.Errorf("%s chosen after %s is drawn unlike the screen laid out with it - %d lines differ:\n%s", to, from, n, diff) + } + } + } +} + +// TestTheMenuOfToolsNamesEachToolAsTheCommandLineDoes holds every line of the +// menu of tools to starting with the name the tool has after tfg tool, then +// its question. Choosing among three questions, the owner could not tell that +// all three were about checksums (2026-10-05). +func TestTheMenuOfToolsNamesEachToolAsTheCommandLineDoes(t *testing.T) { + host := newFakeHost(t) + window.Open(host) + menu := chooserUnder(t, selectTab(t, host.content, text.TabTools()), text.FieldTool()) + all := tool.All() + if len(all) == 0 || len(menu.Options) != len(all) { + t.Fatalf("%d tools and %d lines in the menu, so the lines cannot be held to the tools", len(all), len(menu.Options)) + } + for i, d := range all { + if line := menu.Options[i]; !strings.HasPrefix(line, d.ID+" ") || !strings.Contains(line, d.Question) { + t.Errorf("the menu line of %s is %q - it should start with the name tfg tool knows it by, then ask its question", d.ID, line) + } + } +} + +// runOnToolsTab chooses a tool on the Tools tab, fills its boxes by their +// names, presses Run and waits for the answer. +func runOnToolsTab(t *testing.T, host *fakeHost, screen fyne.CanvasObject, id string, boxes map[string]string) { + t.Helper() + d, err := tool.Get(id) + if err != nil { + t.Fatal(err) + } + chooserUnder(t, screen, text.FieldTool()).SetSelected(text.ToolChoice(d.ID, d.Question)) + for name, value := range boxes { + fillField(t, screen, text.SettingLabel(name), value) + } + pressNamed(t, screen, text.ButtonRunTool()) + host.waitForWork() +} + +// TestCopyAllPutsTheWholeResultOnTheClipboard presses Copy all under two +// results: every checksum of one file, which was five presses of Copy, and a +// check whose lines had no Copy at all (the owner's list, 2026-10-05). The +// second lists more files than the screen draws, and the clipboard has to +// hold every one of them - a list somebody copies is one they want whole. +func TestCopyAllPutsTheWholeResultOnTheClipboard(t *testing.T) { + dir := folderOf(t, map[string]string{"abc.txt": "abc"}) + host := newFakeHost(t) + window.Open(host) + screen := selectTab(t, host.content, text.TabTools()) + + runOnToolsTab(t, host, screen, checksum.ID, map[string]string{ + checksum.InputFile: filepath.Join(dir, "abc.txt"), checksum.SettingAlgorithm: "all"}) + pressNamed(t, screen, text.ButtonCopyAll()) + held := 0 + for _, k := range knownChecksums { + if k.content != "abc" { + continue + } + held++ + if want := k.algorithm + " " + k.sum; !strings.Contains(host.copied, want) { + t.Errorf("Copy all did not put %q on the clipboard. It put:\n%s", want, host.copied) + } + } + if held < 4 { + t.Fatalf("only %d known checksums of abc, so this asked about too few rows", held) + } + if words := allText(host.content); !strings.Contains(words, text.ToolCopiedAll()) { + t.Errorf("Copy all was pressed and the window does not say what it did. It says:\n%s", words) + } + + var body strings.Builder + listed := parts.NoteItemsShown + 5 + for i := 1; i <= listed; i++ { + body.WriteString(fmt.Sprintf("%s missing-%02d.txt\n", abcSHA256, i)) + } + sums := filepath.Join(dir, "SHA256SUMS") + if err := os.WriteFile(sums, []byte(body.String()), 0o644); err != nil { + t.Fatal(err) + } + runOnToolsTab(t, host, screen, checksum.CheckID, map[string]string{checksum.InputChecksumFile: sums}) + if words := allText(screen); strings.Contains(words, fmt.Sprintf("missing-%02d.txt", listed)) { + t.Fatalf("the screen draws all %d missing files, so it does not show what this guard asks about", listed) + } + pressNamed(t, screen, text.ButtonCopyAll()) + for i := 1; i <= listed; i++ { + if name := fmt.Sprintf("missing-%02d.txt", i); !strings.Contains(host.copied, name) { + t.Errorf("Copy all left %s off the clipboard - the screen cuts the list short, the clipboard must not", name) + } + } + if want := text.ToolListDoesNotMatch("SHA256SUMS"); !strings.Contains(host.copied, want) { + t.Errorf("Copy all left the verdict %q off the clipboard. It put:\n%s", want, host.copied) + } +} + +// TestOpenFolderLeadsToTheFolderTheToolWorkedIn runs the tools and presses +// Open folder after each: the folder checksum-write was given, the folder of +// the file checksum was given. Nothing is offered before a run, after another +// tool is chosen, or after a run that was refused. +func TestOpenFolderLeadsToTheFolderTheToolWorkedIn(t *testing.T) { + dir := folderOf(t, map[string]string{"a.txt": "abc"}) + host := newFakeHost(t) + window.Open(host) + screen := selectTab(t, host.content, text.TabTools()) + if visibleButtonNamed(screen, text.ButtonOpenFolder()) != nil { + t.Fatal("Open folder is offered before anything has run") + } + opens := func(how, want string) { + t.Helper() + open := visibleButtonNamed(screen, text.ButtonOpenFolder()) + if open == nil { + t.Fatalf("%s and Open folder is not offered. The screen says:\n%s", how, allText(screen)) + } + open.OnTapped() + if host.folder != want { + t.Errorf("%s and Open folder showed %q, not %q", how, host.folder, want) + } + } + + runOnToolsTab(t, host, screen, checksum.WriteID, map[string]string{checksum.InputFolder: dir}) + opens("checksum-write wrote into a folder", dir) + runOnToolsTab(t, host, screen, checksum.ID, map[string]string{checksum.InputFile: filepath.Join(dir, "a.txt")}) + opens("checksum read a file", dir) + + check, _ := tool.Get(checksum.CheckID) + chooserUnder(t, screen, text.FieldTool()).SetSelected(text.ToolChoice(check.ID, check.Question)) + if visibleButtonNamed(screen, text.ButtonOpenFolder()) != nil { + t.Error("another tool was chosen and Open folder still leads to where the last one worked") + } + pressNamed(t, screen, text.ButtonRunTool()) + host.waitForWork() + if visibleButtonNamed(screen, text.ButtonOpenFolder()) != nil { + t.Error("a run refused for want of a checksum file offers Open folder") + } +} diff --git a/internal/gui/catalogue/catalogue.go b/internal/gui/catalogue/catalogue.go index ce7b27db..9d72e4ba 100644 --- a/internal/gui/catalogue/catalogue.go +++ b/internal/gui/catalogue/catalogue.go @@ -73,7 +73,7 @@ func Entries() []Entry { return []Entry{ button(), chooser(), entry(), toggle(), segments(), openList(), tabs(), - fields(), propertyField(), byteCount(), tips(), errorArea(), progress(), folding(), + fields(), propertyField(), byteCount(), tips(), errorArea(), verdict(), progress(), folding(), textRanks(), structure(), swatch(), } } diff --git a/internal/gui/catalogue/fields.go b/internal/gui/catalogue/fields.go index f62ea71a..f4e6b3e7 100644 --- a/internal/gui/catalogue/fields.go +++ b/internal/gui/catalogue/fields.go @@ -194,6 +194,23 @@ func errorArea() Entry { }} } +// verdict is the sentence answering a check, both ways it comes out, and a +// long one to see where it wraps. +func verdict() Entry { + return Entry{Name: "Verdict", Covers: []string{"Tone"}, States: []State{ + {"a match", func() fyne.CanvasObject { + return parts.Verdict(parts.Agrees, "Matches: every file SHA256SUMS lists is what it says.") + }}, + {"not a match", func() fyne.CanvasObject { + return parts.Verdict(parts.Disagrees, "Does not match: SHA256SUMS lists files that are not what it says.") + }}, + {"a long match", func() fyne.CanvasObject { + return parts.Verdict(parts.Agrees, "Matches: the sha512 is "+ + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f, as expected.") + }}, + }} +} + func progress() Entry { at := func(value float64) fyne.CanvasObject { p := parts.NewProgress() diff --git a/internal/gui/parts/verdict.go b/internal/gui/parts/verdict.go new file mode 100644 index 00000000..7c305ffc --- /dev/null +++ b/internal/gui/parts/verdict.go @@ -0,0 +1,39 @@ +package parts + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/widget" +) + +// Tone is which way a verdict came out. +type Tone int + +// The two ways a check comes out. +const ( + // Agrees is a check that found what it was told to expect: in the + // palette's success colour. + Agrees Tone = iota + 1 + // Disagrees is one that did not: in its error colour. + Disagrees +) + +// Verdict is the sentence that answers what a check was asked, in the colour +// of the answer. +// +// Green for a match since 2026-10-05, the owner's list after the first look at +// the folder tools: "Matches" was drawn in the grey of every other line under +// it while "Does not match" was red, so the answer somebody ran the tool for +// was the quietest line of the result. The same choice the run screen made +// for "3 files written" on 2026-08-20 (window.toneOfOutcome), named here so +// the two colours are one part rather than a setting repeated beside each +// sentence. The words differ as well, so the colour is never the only carrier +// (UX1). +func Verdict(tone Tone, said string) fyne.CanvasObject { + label := widget.NewLabel(said) + label.Wrapping = fyne.TextWrapWord + label.Importance = widget.DangerImportance + if tone == Agrees { + label.Importance = widget.SuccessImportance + } + return inkTight(label) +} diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index 9ffe3863..913ec299 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -67,6 +67,10 @@ "description": "The words on a button.", "other": "Copy" }, + "ButtonCopyAll": { + "description": "The words on a button.", + "other": "Copy all" + }, "ButtonDonate": { "description": "The words on a button.", "other": "Donate" @@ -733,10 +737,18 @@ "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Count}}, {{.Reason}}.", "other": "this run asks for {{.Count}} files - {{.Reason}}" }, + "ToolChoice": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Tool}}, {{.Question}}.", + "other": "{{.Tool}} - {{.Question}}" + }, "ToolCopied": { "description": "Shown in the window. Carries one value, {{.What}}, which has to stay spelled exactly that way.", "other": "The {{.What}} is on the clipboard." }, + "ToolCopiedAll": { + "description": "Shown in the window.", + "other": "The whole result is on the clipboard." + }, "ToolDoesNotMatch": { "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.About}}, {{.Got}}, {{.Wanted}}.", "other": "Does not match: the {{.About}} is {{.Got}} and {{.Wanted}} was expected." @@ -773,6 +785,10 @@ "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Done}}, {{.Total}}.", "other": "Read {{.Done}} of {{.Total}}." }, + "ToolResultLine": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Name}}, {{.Value}}.", + "other": "{{.Name}} {{.Value}}" + }, "ToolStopped": { "description": "Shown in the window.", "other": "Stopped before it finished. Nothing was written or changed." diff --git a/internal/gui/text/locale/pl.json b/internal/gui/text/locale/pl.json index 8670c275..8c8d85af 100644 --- a/internal/gui/text/locale/pl.json +++ b/internal/gui/text/locale/pl.json @@ -16,6 +16,7 @@ "ButtonCancel": { "other": "Anuluj" }, "ButtonChoose": { "other": "Wybierz..." }, "ButtonCopy": { "other": "Kopiuj" }, + "ButtonCopyAll": { "other": "Kopiuj wszystko" }, "ButtonDonate": { "other": "Wesprzyj" }, "ButtonDuplicateBatch": { "other": "Powiel" }, "ButtonForget": { "other": "Zapomnij" }, @@ -182,7 +183,9 @@ "TabTools": { "other": "Narzędzia" }, "TimeLeft": { "other": "zostało {{.Roughly}}" }, "TooManyFiles": { "other": "liczba plików w tym przebiegu to {{.Count}} - {{.Reason}}" }, + "ToolChoice": { "other": "{{.Tool}} - {{.Question}}" }, "ToolCopied": { "other": "Skopiowano do schowka: {{.What}}." }, + "ToolCopiedAll": { "other": "Skopiowano do schowka cały wynik." }, "ToolDoesNotMatch": { "other": "Nie zgadza się: {{.About}} to {{.Got}}, a oczekiwano {{.Wanted}}." }, "ToolFlag": { "other": "W linii poleceń zapisuje się jako --{{.Key}} po tfg tool." }, "ToolInputWrittenAs": { "other": "W linii poleceń zapisuje się jako ścieżka po tfg tool {{.Tool}}." }, @@ -192,6 +195,7 @@ "ToolMoreItems": { "other": "I jeszcze {{.More}}. tfg tool {{.Tool}} wypisuje wszystkie." }, "ToolNothingYet": { "other": "Jeszcze nic nie policzono. Wybierz, na czym narzędzie ma pracować, i naciśnij Uruchom." }, "ToolReading": { "other": "Przeczytano {{.Done}} z {{.Total}}." }, + "ToolResultLine": { "other": "{{.Name}} {{.Value}}" }, "ToolStopped": { "other": "Zatrzymano przed końcem. Nic nie zostało zapisane ani zmienione." }, "UseSmallestSize": { "other": "Użyj najmniejszego rozmiaru, {{.Size}}" }, "WillGoTo": { "other": "zapis do {{.Directory}}" }, diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index 29ef61d9..5f69c872 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -903,6 +903,15 @@ func DetailTool() string { return say("DetailTool", "What the tool works on and its settings change with the tool chosen.") } +// ToolChoice is one line of the menu of tools: the name the tool has after +// tfg tool, then the question it answers. The name first since 2026-10-05 - +// choosing among three questions, the owner could not tell that all three +// were about checksums - and it is the word typed on the command line, so the +// menu teaches it. +func ToolChoice(id, question string) string { + return sayf("ToolChoice", "{{.Tool}} - {{.Question}}", map[string]any{"Tool": id, "Question": ToolQuestion(id, question)}) +} + // ButtonRunTool starts the tool chosen. One word for every tool, because the // question over it already says what it will do. func ButtonRunTool() string { return say("ButtonRunTool", "Run") } @@ -910,6 +919,19 @@ func ButtonRunTool() string { return say("ButtonRunTool", "Run") } // ButtonCopy puts one value of a result on the clipboard. func ButtonCopy() string { return say("ButtonCopy", "Copy") } +// ButtonCopyAll puts the whole result on the clipboard, as the section shows +// it - five checksums of one file, or every line a check found. +func ButtonCopyAll() string { return say("ButtonCopyAll", "Copy all") } + +// ToolResultLine is one row of a result as Copy all writes it: the name and +// the value two spaces apart, the way tfg tool prints a table. +func ToolResultLine(name, value string) string { + return sayf("ToolResultLine", "{{.Name}} {{.Value}}", map[string]any{"Name": name, "Value": value}) +} + +// ToolCopiedAll says the whole result is on the clipboard. +func ToolCopiedAll() string { return say("ToolCopiedAll", "The whole result is on the clipboard.") } + // ToolNothingYet is the result before the first run, so the section says what // will appear there rather than standing empty. func ToolNothingYet() string { diff --git a/internal/gui/window/tools.go b/internal/gui/window/tools.go index f2d0448e..438b3d90 100644 --- a/internal/gui/window/tools.go +++ b/internal/gui/window/tools.go @@ -35,8 +35,7 @@ type Tools struct { fields *parts.Fields sections *sections - pick *parts.Chooser - titles map[string]string + menu toolMenu about *fyne.Container work *fyne.Container result *fyne.Container @@ -49,7 +48,51 @@ type Tools struct { fault *parts.ErrorArea job *toolJob - body fyne.CanvasObject + view toolsView + offer folderOffer +} + +// toolMenu is the menu at the head of the screen, and which tool each of its +// lines stands for. +type toolMenu struct { + pick *parts.Chooser + titles map[string]string +} + +// toolsView is what the screen is drawn as: the view over the column of +// sections, kept to lay the screen out again (see relay), and the whole with +// the bar under it. +type toolsView struct { + scroll *container.Scroll + body fyne.CanvasObject +} + +// folderOffer is Open folder in the bar: hidden until a run has finished, and +// then leading to the folder that run worked in - the owner's list of +// 2026-10-05, in the bar because that is where Single batch offers its +// folder. +type folderOffer struct { + btn *parts.Button + // folder is where the run worked, kept from the request rather than read + // off the boxes, which can be edited afterwards - the reason + // offers.wroteInto gives. + folder string +} + +// show puts the button up once a run has finished, when there is a folder. +func (o *folderOffer) show(relay func()) { + if o.folder == "" { + return + } + o.btn.Show() + relay() +} + +// withdraw takes it away, for a run starting or another tool chosen. +func (o *folderOffer) withdraw(relay func()) { + o.folder = "" + o.btn.Hide() + relay() } // toolForm is the boxes of the chosen tool: what goes into a request, before @@ -85,14 +128,14 @@ const settingTool = "tool" // NewTools builds the screen, with the first tool of the registry chosen. func NewTools(host Host) *Tools { t := &Tools{host: host, tips: parts.NewTips(), fields: parts.NewFields(), sections: newSections()} - t.titles = map[string]string{} - questions := make([]string, 0, len(tool.Names())) + t.menu.titles = map[string]string{} + choices := make([]string, 0, len(tool.Names())) for _, d := range tool.All() { - q := text.ToolQuestion(d.ID, d.Question) - t.titles[q] = d.ID - questions = append(questions, q) + c := text.ToolChoice(d.ID, d.Question) + t.menu.titles[c] = d.ID + choices = append(choices, c) } - t.pick = parts.NewChooser(questions, t.onChosen) + t.menu.pick = parts.NewChooser(choices, t.onChosen) t.about = parts.FieldColumn() t.work = parts.Grid() t.result = parts.FieldColumn() @@ -101,42 +144,49 @@ func NewTools(host Host) *Tools { cancel := parts.NewButton(parts.Secondary, text.ButtonCancel(), t.PressCancel).InTheBar() cancel.Disable() cancel.Hide() + t.offer.btn = parts.NewButton(parts.Secondary, text.ButtonOpenFolder(), func() { + if t.offer.folder != "" { + host.OpenFolder(t.offer.folder) + } + }) + t.offer.btn.InTheBar().Hide() bar := parts.NewProgress() bar.Hide() t.busy = &busy{fields: t.fields, starters: []*parts.Button{run}, cancel: cancel, bar: bar, later: host.Later} - t.busy.row = parts.ButtonRow(run, cancel) + t.busy.row = parts.ButtonRow(run, cancel, t.offer.btn) t.status = widget.NewLabel("") t.status.Wrapping = fyne.TextWrapWord t.fault = parts.NewErrorArea() - t.body = t.tips.Over(container.NewBorder( + t.view.scroll = container.NewVScroll(parts.Screen( + parts.Titled(text.TabTools(), text.SubtitleTools()), + t.sections.section(sectionTool, text.SectionTool(), + t.fields.Add(settingTool, text.FieldTool(), text.HintTool(), t.tips.Say(text.DetailTool()), t.menu.pick), + t.about, + ), + t.sections.section(sectionToolInput, text.SectionToolInput(), t.work), + t.sections.section(sectionToolResult, text.SectionToolResult(), t.result), + )) + t.view.body = t.tips.Over(container.NewBorder( nil, parts.ActionBar(rail(donateButton(host)), t.busy.row, roomToSpeak(bar, t.status, t.fault)), nil, nil, - container.NewVScroll(parts.Screen( - parts.Titled(text.TabTools(), text.SubtitleTools()), - t.sections.section(sectionTool, text.SectionTool(), - t.fields.Add(settingTool, text.FieldTool(), text.HintTool(), t.tips.Say(text.DetailTool()), t.pick), - t.about, - ), - t.sections.section(sectionToolInput, text.SectionToolInput(), t.work), - t.sections.section(sectionToolResult, text.SectionToolResult(), t.result), - )), + t.view.scroll, )) t.form.fixed = t.fields.Len() - if len(questions) > 0 { - t.pick.SetSelected(questions[0]) + if len(choices) > 0 { + t.menu.pick.SetSelected(choices[0]) } offerSettling(host, []interface{ Settled() }{t}) return t } // Object is the screen, for the tab that holds it. -func (t *Tools) Object() fyne.CanvasObject { return t.body } +func (t *Tools) Object() fyne.CanvasObject { return t.view.body } // FirstField is where the keyboard starts: which tool, because every box under // it is drawn from that answer. -func (t *Tools) FirstField() fyne.Focusable { return t.pick } +func (t *Tools) FirstField() fyne.Focusable { return t.menu.pick } // Fields is the boxes of the screen, for the window to hand its shortcuts to - // so Ctrl+Enter pressed in the box naming the file runs the tool, the way it @@ -167,13 +217,14 @@ func (t *Tools) Settled() { } // onChosen draws what the chosen tool works on and what it takes. -func (t *Tools) onChosen(question string) { - d, err := tool.Get(t.titles[question]) +func (t *Tools) onChosen(choice string) { + d, err := tool.Get(t.menu.titles[choice]) if err != nil { t.fault.Say(core.ShownText(err.Error())) return } t.chosen = d + t.offer.withdraw(t.busy.relay) t.about.RemoveAll() t.about.Add(parts.Prose(text.ToolDetail(d.ID, d.Detail))) t.about.Refresh() @@ -214,7 +265,10 @@ func (t *Tools) PressGenerate() { t.fields.ClearAll() t.fault.Clear() showOn(t.status, "") - t.job = startTool(t.chosen, t.form.request(), t) + t.offer.withdraw(t.busy.relay) + in := t.form.request() + t.offer.folder = ranIn(t.chosen, in) + t.job = startTool(t.chosen, in, t) } // progressed and finished are what a running tool tells the screen, on the @@ -228,10 +282,12 @@ func (t *Tools) finished(d tool.Descriptor, r tool.Result, err error) { t.busy.set(false, busyFace{}) showOn(t.status, "") if err != nil { + t.offer.withdraw(t.busy.relay) t.refuse(err) return } t.showResult(d, &r) + t.offer.show(t.busy.relay) } // refuse puts a refusal under the box it is about, or at the foot of the @@ -244,103 +300,12 @@ func (t *Tools) refuse(err error) { var about interface{ AboutSetting() string } if errors.As(err, &about) && t.fields.Mark(about.AboutSetting(), err) { t.sections.openHolding(t.fields.Lookup(about.AboutSetting()).Control) + t.relay() return } t.fault.Say(core.ShownText(err.Error())) } -// showResult draws what a run found, or what will appear there before one. -// -// The canvas is told once, after the last change - a deferred word to it read -// as coming before the changes to the guard that holds this -// (TestABoxThatGainsOrLosesAPieceSaysSo), which cannot see when a defer runs. -func (t *Tools) showResult(d tool.Descriptor, r *tool.Result) { - t.result.RemoveAll() - for _, o := range t.resultObjects(d, r) { - t.result.Add(o) - } - t.result.Refresh() -} - -// resultObjects is what a run found, as the section shows it: the rows and the -// verdict, or a sentence saying nothing has run yet. -func (t *Tools) resultObjects(d tool.Descriptor, r *tool.Result) []fyne.CanvasObject { - if r == nil { - return []fyne.CanvasObject{parts.Prose(text.ToolNothingYet())} - } - rows := parts.Grid() - for _, row := range r.Rows { - rows.Add(t.resultRow(row)) - } - out := []fyne.CanvasObject{rows} - for _, n := range r.Notes { - out = append(out, noteObjects(d, n)...) - } - switch r.Verdict.Outcome { - case tool.Match: - out = append(out, parts.Prose(verdictSaid(r.Verdict))) - case tool.Mismatch: - verdict := parts.NewErrorArea() - verdict.Say(verdictSaid(r.Verdict)) - out = append(out, verdict.Object()) - case tool.Unasked: - } - return out -} - -// verdictSaid is tool.Verdict.Said in the window's language. -func verdictSaid(v tool.Verdict) string { - switch { - case v.Outcome == tool.Match && v.Listed: - return text.ToolListMatches(v.About) - case v.Outcome == tool.Mismatch && v.Listed: - return text.ToolListDoesNotMatch(v.About) - case v.Outcome == tool.Match: - return text.ToolMatches(v.About, v.Got) - } - return text.ToolDoesNotMatch(v.About, v.Got, v.Wanted) -} - -// noteObjects is one note of a result as the section shows it: its line and -// its items, one item on the same line, several under it - and no more of -// them than NoteItemsShown, with how many were left out and where to see -// them. Each item goes through core.Shown, the same as on the command line, -// since an item is usually a file name and a name may hold a line break. -func noteObjects(d tool.Descriptor, n tool.Noted) []fyne.CanvasObject { - says := text.ToolNote(d.ID, n.ID, d.NoteSays(n.ID)) - if len(n.Items) == 1 { - return []fyne.CanvasObject{parts.Prose(says + " " + core.Shown(n.Items[0]))} - } - out := []fyne.CanvasObject{parts.Prose(says)} - for i, item := range n.Items { - if i == parts.NoteItemsShown { - out = append(out, parts.Prose(text.ToolMoreItems(len(n.Items)-i, d.ID))) - break - } - out = append(out, parts.Prose(core.Shown(item))) - } - return out -} - -// resultRow is one row of a result: its first cell as the name in the column -// of names, the rest beside it, and a way to copy what is beside it. The name -// is data - an algorithm, a file - and is shown as it is (G8). -func (t *Tools) resultRow(row []string) fyne.CanvasObject { - if len(row) == 0 { - return parts.Prose("") - } - value := "" - if len(row) > 1 { - value = row[len(row)-1] - } - copyIt := parts.NewButton(parts.Secondary, text.ButtonCopy(), func() { - t.host.Copy(value) - showOn(t.status, text.ToolCopied(row[0])) - }) - return parts.Wide(t.fields.Named(row[0], parts.NoDetail, - container.NewBorder(nil, nil, nil, copyIt, parts.Prose(value)))) -} - // toolJob is one run of a tool beside the window. // // Beside it because a file of gigabytes takes seconds to read and a window diff --git a/internal/gui/window/toolsresult.go b/internal/gui/window/toolsresult.go new file mode 100644 index 00000000..993cf49a --- /dev/null +++ b/internal/gui/window/toolsresult.go @@ -0,0 +1,110 @@ +package window + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" +) + +// The Result section of the Tools tab: what a run found, drawn, and laid out +// with the rest of the screen. The words are worded in toolstext.go. + +// showResult draws what a run found, or what will appear there before one. +// +// The canvas is told once, after the last change - a deferred word to it read +// as coming before the changes to the guard that holds this +// (TestABoxThatGainsOrLosesAPieceSaysSo), which cannot see when a defer runs. +func (t *Tools) showResult(d tool.Descriptor, r *tool.Result) { + t.result.RemoveAll() + for _, o := range t.resultObjects(d, r) { + t.result.Add(o) + } + t.result.Refresh() + t.relay() +} + +// relay lays the screen out again after a section changed what it holds, +// because the toolkit does not - the same kind of fault as busy.relay. The +// sections kept the heights of the tool chosen before (the owner's window, +// 2026-10-05): Algorithm drawn under its section with its list hidden by the +// next one, and an empty band after a tool with fewer boxes. +// +// The view is told rather than the page under it. Measured in the real window +// the same day, three changes of tool with each build. As it was, all three +// were drawn with the old heights. With the view told, or with the page told, +// all three were right. The view, because it is also what grows the room to scroll +// when a page comes out longer than the window. +func (t *Tools) relay() { + if t.view.scroll == nil { + return + } + t.view.scroll.Refresh() +} + +// resultObjects is what a run found, as the section shows it: the rows, the +// notes, the verdict in the colour of its answer and a way to copy all of it - +// or a sentence saying nothing has run yet. +func (t *Tools) resultObjects(d tool.Descriptor, r *tool.Result) []fyne.CanvasObject { + if r == nil { + return []fyne.CanvasObject{parts.Prose(text.ToolNothingYet())} + } + var out []fyne.CanvasObject + // Only when there are rows: an empty grid still stood in the column and + // cost the gap above the first note (the stored picture of a check, + // 2026-10-05). + if len(r.Rows) > 0 { + rows := parts.Grid() + for _, row := range r.Rows { + rows.Add(t.resultRow(row)) + } + out = append(out, rows) + } + for _, n := range r.Notes { + for _, line := range noteLines(d, n, parts.NoteItemsShown) { + out = append(out, parts.Prose(line)) + } + } + switch r.Verdict.Outcome { + case tool.Match: + out = append(out, parts.Verdict(parts.Agrees, verdictSaid(r.Verdict))) + case tool.Mismatch: + out = append(out, parts.Verdict(parts.Disagrees, verdictSaid(r.Verdict))) + case tool.Unasked: + } + return append(out, t.copyAll(d, *r)) +} + +// copyAll is the button under a result that puts the whole of it on the +// clipboard - the owner's list of 2026-10-05: five checksums of one file were +// five presses of Copy, and the lines of a check could not be copied at all. +// Held to the left edge in a row of its own, the way the Preferences screen +// holds its buttons. +func (t *Tools) copyAll(d tool.Descriptor, r tool.Result) fyne.CanvasObject { + press := parts.NewButton(parts.Secondary, text.ButtonCopyAll(), func() { + t.host.Copy(resultText(d, r)) + showOn(t.status, text.ToolCopiedAll()) + }) + return container.NewHBox(parts.ButtonRow(press)) +} + +// resultRow is one row of a result: its first cell as the name in the column +// of names, the rest beside it, and a way to copy what is beside it. The name +// is data - an algorithm, a file - and is shown as it is (G8). +func (t *Tools) resultRow(row []string) fyne.CanvasObject { + if len(row) == 0 { + return parts.Prose("") + } + value := "" + if len(row) > 1 { + value = row[len(row)-1] + } + copyIt := parts.NewButton(parts.Secondary, text.ButtonCopy(), func() { + t.host.Copy(value) + showOn(t.status, text.ToolCopied(row[0])) + }) + return parts.Wide(t.fields.Named(row[0], parts.NoDetail, + container.NewBorder(nil, nil, nil, copyIt, parts.Prose(value)))) +} diff --git a/internal/gui/window/toolstext.go b/internal/gui/window/toolstext.go new file mode 100644 index 00000000..39f5d0eb --- /dev/null +++ b/internal/gui/window/toolstext.go @@ -0,0 +1,104 @@ +package window + +import ( + "path/filepath" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" +) + +// What the Tools tab says about a run as words rather than as controls: the +// lines of a note, the verdict, what Copy all puts on the clipboard, and the +// folder Open folder leads to. Apart from the screen's type and from the +// toolkit (GUI rule 15), so a line is worded once whether a control draws it +// or the clipboard takes it. + +// noteLines is one note of a result as lines: its sentence with the item on +// the same line when there is one, or followed by a line an item. When most +// is above nought, no more items than that, then how many were left out and +// where to see them. Each item goes through core.Shown, the same as on the +// command line, since an item is usually a file name and a name may hold a +// line break. +func noteLines(d tool.Descriptor, n tool.Noted, most int) []string { + says := text.ToolNote(d.ID, n.ID, d.NoteSays(n.ID)) + if len(n.Items) == 1 { + return []string{says + " " + core.Shown(n.Items[0])} + } + out := []string{says} + for i, item := range n.Items { + if most > 0 && i == most { + out = append(out, text.ToolMoreItems(len(n.Items)-i, d.ID)) + break + } + out = append(out, core.Shown(item)) + } + return out +} + +// verdictSaid is tool.Verdict.Said in the window's language. +func verdictSaid(v tool.Verdict) string { + switch { + case v.Outcome == tool.Match && v.Listed: + return text.ToolListMatches(v.About) + case v.Outcome == tool.Mismatch && v.Listed: + return text.ToolListDoesNotMatch(v.About) + case v.Outcome == tool.Match: + return text.ToolMatches(v.About, v.Got) + } + return text.ToolDoesNotMatch(v.About, v.Got, v.Wanted) +} + +// rowLine is one row of a result as a line: its name and the value the +// section shows beside it. +func rowLine(row []string) string { + if len(row) < 2 { + return strings.Join(row, "") + } + return text.ToolResultLine(row[0], row[len(row)-1]) +} + +// resultText is what Copy all puts on the clipboard: the result as the section +// shows it, in its order and in the window's language (the owner's choice of +// 2026-10-05) - every row, every note, the verdict. +// +// Every item of a note, where the section stops at NoteItemsShown. The +// section cuts a long list short so it stays a screen, and a list somebody +// copies is one they want whole. +func resultText(d tool.Descriptor, r tool.Result) string { + lines := make([]string, 0, len(r.Rows)+len(r.Notes)+1) + for _, row := range r.Rows { + lines = append(lines, rowLine(row)) + } + for _, n := range r.Notes { + lines = append(lines, noteLines(d, n, 0)...) + } + if r.Verdict.Outcome != tool.Unasked { + lines = append(lines, verdictSaid(r.Verdict)) + } + return strings.Join(lines, "\n") +} + +// ranIn is the folder a run of a tool worked in, for Open folder: the folder +// it was given, or the folder of the file it was given - the first thing the +// tool works on, which is the one its question is about. Empty when it was +// given nothing to work on. +func ranIn(d tool.Descriptor, in tool.Request) string { + if len(d.Inputs) == 0 { + return "" + } + first := d.Inputs[0] + named := in.Inputs[first.Name] + if named == "" { + return "" + } + folder := named + if first.Kind != tool.Folder { + folder = filepath.Dir(named) + } + if abs, err := filepath.Abs(folder); err == nil { + folder = abs + } + return folder +}