From dedd9710bdf0345f8c4aa881445cd3b0c3f11aaf Mon Sep 17 00:00:00 2001 From: elseform Date: Wed, 12 Aug 2026 13:31:41 +0300 Subject: [PATCH] Add per-category and per-mod output grouping Mod Output Mode can now split compressed output across multiple mod folders instead of one flat folder: - Per texture category: ATAK - Normal Maps, ATAK - Diffuse, etc - Per source mod: ATAK - SomeMod - Both combined: ATAK - SomeMod - Normal Maps Why split at all: - Per-category makes A/B testing a single category trivial - drop or reorder just "ATAK - Normal Maps" in MO2 without touching the rest - Per-mod makes updating a source mod's textures trivial - delete just that mod's output folder and rerun Both are opt-in toggles in Settings. Per-category defaults on (matches prior behavior on this branch); per-mod defaults off. Scan exclusion glob (ATAK - *) now matches all three folder-name shapes so grouped output is never rescanned as a source mod. --- internal/config/config.go | 3 + internal/tui/screens/compress.go | 37 ++-- internal/tui/screens/nav.go | 26 ++- internal/tui/screens/percategory_test.go | 268 +++++++++++++++++++++++ internal/tui/screens/results.go | 34 ++- internal/tui/screens/scan.go | 3 + internal/tui/screens/settings.go | 62 +++++- internal/tui/screens/summary.go | 6 +- 8 files changed, 410 insertions(+), 29 deletions(-) create mode 100644 internal/tui/screens/percategory_test.go diff --git a/internal/config/config.go b/internal/config/config.go index bf14f58..eab3166 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,6 +54,8 @@ type Config struct { // its whole subtree are skipped. ScanExclusions []string `json:"scanExclusions,omitempty"` ModOutputMode bool `json:"modOutputMode"` + PerCategoryModOutput bool `json:"perCategoryModOutput"` + PerModModOutput bool `json:"perModModOutput"` ModOutputName string `json:"modOutputName"` ModlistPath string `json:"modlistPath"` // StripMipsWhenDisabled makes a profile's generateMips:false authoritative: source mip @@ -186,6 +188,7 @@ func defaultConfig() *Config { BackupLevel: 6, ScanExclusions: []string{".*", "downloads", "Downloads", "G.A.M.M.A. UI"}, ModOutputMode: true, + PerCategoryModOutput: true, ModOutputName: "ATAK", ModlistPath: "", CompressionBackend: BackendTexconv, diff --git a/internal/tui/screens/compress.go b/internal/tui/screens/compress.go index 2669406..6a89a74 100644 --- a/internal/tui/screens/compress.go +++ b/internal/tui/screens/compress.go @@ -98,7 +98,7 @@ func (m CompressModel) startCompression() tea.Cmd { jobs := buildJobs(data) primary, fallback := selectBackends(cfg, t) resultCh := compress.RunPool(ctx, primary, fallback, jobs, data.WorkerCount) - opCh, sumCh := compressToOpCh(resultCh, total, data.ModOutputDir) + opCh, sumCh := compressToOpCh(resultCh, total, data.ModOutputDir, data.ModOutputIsPattern) return compressReadyMsg{opCh: opCh, sumCh: sumCh} } } @@ -163,6 +163,7 @@ func compressToOpCh( resultCh <-chan compress.CompressionResult, total int, modOutputDir string, + modOutputIsPattern bool, ) (<-chan components.OperationProgressMsg, <-chan SummaryData) { opCh := make(chan components.OperationProgressMsg, 32) sumCh := make(chan SummaryData, 1) @@ -208,15 +209,16 @@ func compressToOpCh( } } sumCh <- SummaryData{ - Succeeded: succeeded, - Failed: failed, - OutputSkipped: outputSkipped, - OutputDir: modOutputDir, - TotalBefore: totalBefore, - TotalAfter: totalAfter, - Errors: errors, - FallbackCounts: fallbackCounts, - Fallbacks: fallbacks, + Succeeded: succeeded, + Failed: failed, + OutputSkipped: outputSkipped, + OutputDir: modOutputDir, + OutputIsPattern: modOutputIsPattern, + TotalBefore: totalBefore, + TotalAfter: totalAfter, + Errors: errors, + FallbackCounts: fallbackCounts, + Fallbacks: fallbacks, } close(opCh) }() @@ -248,9 +250,18 @@ func buildJobs(data CompressJobData) []compress.Job { MaxTextureSize: g.MaxTextureSize, OutputDir: filepath.Dir(path), } - if data.ModOutputDir != "" && i < len(g.RelPaths) && g.RelPaths[i] != "" { - job.RelPath = g.RelPaths[i] - job.ModOutputDir = data.ModOutputDir + if i < len(g.RelPaths) && g.RelPaths[i] != "" { + switch { + case i < len(g.ModOutputDirs) && g.ModOutputDirs[i] != "": + job.RelPath = g.RelPaths[i] + job.ModOutputDir = g.ModOutputDirs[i] + case g.ModOutputDir != "": + job.RelPath = g.RelPaths[i] + job.ModOutputDir = g.ModOutputDir + case data.ModOutputDir != "": + job.RelPath = g.RelPaths[i] + job.ModOutputDir = data.ModOutputDir + } } jobs = append(jobs, job) } diff --git a/internal/tui/screens/nav.go b/internal/tui/screens/nav.go index fb81332..b86dbc7 100644 --- a/internal/tui/screens/nav.go +++ b/internal/tui/screens/nav.go @@ -52,10 +52,11 @@ type assetRef struct { // CompressJobData is passed from CompressConfig → Compress. type CompressJobData struct { - Groups []ConfiguredGroup - WorkerCount int - ModsDir string // needed to compute RelPath in mod output mode - ModOutputDir string // non-empty enables mod output mode (e.g. /mods/ATAK) + Groups []ConfiguredGroup + WorkerCount int + ModsDir string // needed to compute RelPath in mod output mode + ModOutputDir string // non-empty enables mod output mode (e.g. /mods/ATAK) + ModOutputIsPattern bool // true when ModOutputDir is a display pattern (e.g. "ATAK - *"), not a real single folder } // ConfiguredGroup is a profile group with user-confirmed settings. @@ -69,17 +70,20 @@ type ConfiguredGroup struct { Widths []int // parallel to Paths; source texture width in pixels Heights []int // parallel to Paths; source texture height in pixels OutputDir string // empty means in-place (filepath.Dir of each asset) + ModOutputDir string // overrides data.ModOutputDir per-group when non-empty + ModOutputDirs []string // parallel to Paths; per-asset override when non-empty (per-mod output), takes priority over ModOutputDir } // SummaryData is passed from Compress → Summary. type SummaryData struct { - Succeeded int - Failed int - OutputSkipped int // files skipped because they already exist in mod output dir - OutputDir string // mod output dir path; non-empty when mod output mode was active - TotalBefore int64 - TotalAfter int64 - Errors []string + Succeeded int + Failed int + OutputSkipped int // files skipped because they already exist in mod output dir + OutputDir string // mod output dir path; non-empty when mod output mode was active + OutputIsPattern bool // true when OutputDir is a display pattern (e.g. "ATAK - *"), not a literal deletable folder + TotalBefore int64 + TotalAfter int64 + Errors []string // FallbackCounts is reason (compress.Fallback* const) → count. Only nonzero // keys are populated. Empty map = clean run, no fallbacks fired. FallbackCounts map[string]int diff --git a/internal/tui/screens/percategory_test.go b/internal/tui/screens/percategory_test.go new file mode 100644 index 0000000..6843348 --- /dev/null +++ b/internal/tui/screens/percategory_test.go @@ -0,0 +1,268 @@ +package screens + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/noisethanks/atak/internal/compress" + "github.com/noisethanks/atak/internal/config" + "github.com/noisethanks/atak/internal/scan" +) + +// buildTestGroups returns two profile groups (Normal Maps, Diffuse), each +// containing one asset from "ModA" and one from "ModB" — enough to exercise +// every combination of per-category / per-mod grouping. +func buildTestGroups() []AssetGroup { + return []AssetGroup{ + { + ProfileName: "Normal Maps", + SuggestedFmt: "BC5_UNORM", + Assets: []assetRef{ + {Path: "/mods/ModA/gamedata/textures/x_bump.dds", ModName: "ModA", VirtualRelPath: "gamedata/textures/x_bump.dds"}, + {Path: "/mods/ModB/gamedata/textures/y_bump.dds", ModName: "ModB", VirtualRelPath: "gamedata/textures/y_bump.dds"}, + }, + }, + { + ProfileName: "Diffuse", + SuggestedFmt: "BC3_UNORM", + Assets: []assetRef{ + {Path: "/mods/ModA/gamedata/textures/x_diff.dds", ModName: "ModA", VirtualRelPath: "gamedata/textures/x_diff.dds"}, + {Path: "/mods/ModB/gamedata/textures/y_diff.dds", ModName: "ModB", VirtualRelPath: "gamedata/textures/y_diff.dds"}, + }, + }, + } +} + +// runBuildJobs drives ResultsModel.buildJobs synchronously (it returns a +// tea.Cmd — a func() tea.Msg — so calling that func executes it inline) and +// unwraps the resulting CompressJobData. +func runBuildJobs(t *testing.T, cfg *config.Config) CompressJobData { + t.Helper() + m := ResultsModel{groups: buildTestGroups(), cfg: cfg} + cmd := m.buildJobs(0, "", "") // scope 0 = Run All + msg := cmd() + nav, ok := msg.(NavigateMsg) + if !ok { + t.Fatalf("expected NavigateMsg, got %T", msg) + } + data, ok := nav.Data.(CompressJobData) + if !ok { + t.Fatalf("expected CompressJobData, got %T", nav.Data) + } + return data +} + +// jobDirsByPath maps each job's source path to the ModOutputDir compress.go's +// buildJobs actually resolved for it (group-level, per-asset, or fallback — +// whichever wins per the priority in buildJobs). +func jobDirsByPath(t *testing.T, data CompressJobData) map[string]string { + t.Helper() + jobs := buildJobs(data) + out := make(map[string]string, len(jobs)) + for _, j := range jobs { + out[j.Asset.Path] = j.ModOutputDir + } + return out +} + +func baseCfg(modsDir string) *config.Config { + return &config.Config{ + ModsDir: modsDir, + ModOutputMode: true, + ModOutputName: "ATAK", + } +} + +func TestModOutputGrouping_Off(t *testing.T) { + cfg := baseCfg("/mods") + data := runBuildJobs(t, cfg) + dirs := jobDirsByPath(t, data) + + want := filepath.Join("/mods", "ATAK") + for path, dir := range dirs { + if dir != want { + t.Errorf("%s: got dir %q, want flat %q", path, dir, want) + } + } +} + +func TestModOutputGrouping_PerCategoryOnly(t *testing.T) { + cfg := baseCfg("/mods") + cfg.PerCategoryModOutput = true + data := runBuildJobs(t, cfg) + dirs := jobDirsByPath(t, data) + + wantNormal := filepath.Join("/mods", "ATAK - Normal Maps") + wantDiffuse := filepath.Join("/mods", "ATAK - Diffuse") + + check := []struct { + path string + want string + }{ + {"/mods/ModA/gamedata/textures/x_bump.dds", wantNormal}, + {"/mods/ModB/gamedata/textures/y_bump.dds", wantNormal}, + {"/mods/ModA/gamedata/textures/x_diff.dds", wantDiffuse}, + {"/mods/ModB/gamedata/textures/y_diff.dds", wantDiffuse}, + } + for _, c := range check { + if got := dirs[c.path]; got != c.want { + t.Errorf("%s: got dir %q, want %q", c.path, got, c.want) + } + } + // ModA and ModB must land in the SAME folder per category — that's the point. + if dirs["/mods/ModA/gamedata/textures/x_bump.dds"] != dirs["/mods/ModB/gamedata/textures/y_bump.dds"] { + t.Error("per-category should merge different mods into one folder, they diverged") + } +} + +func TestModOutputGrouping_PerModOnly(t *testing.T) { + cfg := baseCfg("/mods") + cfg.PerModModOutput = true + data := runBuildJobs(t, cfg) + dirs := jobDirsByPath(t, data) + + wantA := filepath.Join("/mods", "ATAK - ModA") + wantB := filepath.Join("/mods", "ATAK - ModB") + + check := []struct { + path string + want string + }{ + {"/mods/ModA/gamedata/textures/x_bump.dds", wantA}, + {"/mods/ModA/gamedata/textures/x_diff.dds", wantA}, + {"/mods/ModB/gamedata/textures/y_bump.dds", wantB}, + {"/mods/ModB/gamedata/textures/y_diff.dds", wantB}, + } + for _, c := range check { + if got := dirs[c.path]; got != c.want { + t.Errorf("%s: got dir %q, want %q", c.path, got, c.want) + } + } + // Normal Maps and Diffuse from the SAME mod must land in the SAME folder. + if dirs["/mods/ModA/gamedata/textures/x_bump.dds"] != dirs["/mods/ModA/gamedata/textures/x_diff.dds"] { + t.Error("per-mod should merge different categories into one folder, they diverged") + } +} + +func TestModOutputGrouping_Both(t *testing.T) { + cfg := baseCfg("/mods") + cfg.PerCategoryModOutput = true + cfg.PerModModOutput = true + data := runBuildJobs(t, cfg) + dirs := jobDirsByPath(t, data) + + want := map[string]string{ + "/mods/ModA/gamedata/textures/x_bump.dds": filepath.Join("/mods", "ATAK - ModA - Normal Maps"), + "/mods/ModB/gamedata/textures/y_bump.dds": filepath.Join("/mods", "ATAK - ModB - Normal Maps"), + "/mods/ModA/gamedata/textures/x_diff.dds": filepath.Join("/mods", "ATAK - ModA - Diffuse"), + "/mods/ModB/gamedata/textures/y_diff.dds": filepath.Join("/mods", "ATAK - ModB - Diffuse"), + } + for path, wantDir := range want { + if got := dirs[path]; got != wantDir { + t.Errorf("%s: got dir %q, want %q", path, got, wantDir) + } + } + // All four must be distinct folders — no accidental merging. + seen := map[string]bool{} + for _, dir := range dirs { + if seen[dir] { + t.Errorf("folder %q reused across mod+category combos, expected all distinct", dir) + } + seen[dir] = true + } +} + +// TestScanExclusionCoversAllGroupingShapes confirms the "ATAK - *" glob added +// in scan.go actually matches every folder-name shape the grouping modes can +// produce, so previously-compressed output is never rescanned as a source mod. +func TestScanExclusionCoversAllGroupingShapes(t *testing.T) { + pattern := "ATAK - *" + names := []string{ + "ATAK - Normal Maps", + "ATAK - ModA", + "ATAK - ModA - Normal Maps", + } + for _, name := range names { + if !scan.ExcludesMod(name, []string{pattern}) { + t.Errorf("pattern %q should exclude mod folder %q, did not", pattern, name) + } + } + // Sanity: an unrelated mod name must NOT be excluded. + if scan.ExcludesMod("SomeOtherMod", []string{pattern}) { + t.Error("pattern should not match an unrelated mod name") + } +} + +// TestIncrementalSkip_PerCategoryIsolatesOutputPaths verifies the worker's +// path-based skip check (worker.go) treats each category's output folder as +// independent — a file already compressed under "ATAK - Normal Maps" must not +// cause a same-named-but-different-category file to be skipped, and vice +// versa. Uses a fake Backend so no real texconv/compressonator binary is +// needed; the fake fails the test if invoked on a job we expect to be skipped. +func TestIncrementalSkip_PerCategoryIsolatesOutputPaths(t *testing.T) { + tmp := t.TempDir() + modsDir := filepath.Join(tmp, "mods") + + normalDir := filepath.Join(modsDir, "ATAK - Normal Maps") + diffuseDir := filepath.Join(modsDir, "ATAK - Diffuse") + relPath := "gamedata/textures/x.dds" + + // Pre-create the output file only under Normal Maps, simulating a prior run. + if err := os.MkdirAll(filepath.Dir(filepath.Join(normalDir, relPath)), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(normalDir, relPath), []byte("fake compressed dds"), 0644); err != nil { + t.Fatal(err) + } + + invoked := map[string]bool{} + fake := &fakeBackend{ + onCompress: func(job compress.Job) { + invoked[job.ModOutputDir] = true + }, + } + + jobs := []compress.Job{ + {Asset: scan.Asset{Path: "/mods/ModA/x.dds"}, RelPath: relPath, ModOutputDir: normalDir}, + {Asset: scan.Asset{Path: "/mods/ModA/x.dds"}, RelPath: relPath, ModOutputDir: diffuseDir}, + } + + ctx := context.Background() + results := compress.RunPool(ctx, fake, nil, jobs, 1) + var skipped, ran int + for r := range results { + if r.OutputSkipped { + skipped++ + } else { + ran++ + } + } + + if skipped != 1 { + t.Errorf("expected exactly 1 skipped job (Normal Maps, pre-existing output), got %d", skipped) + } + if ran != 1 { + t.Errorf("expected exactly 1 job to actually run (Diffuse, no pre-existing output), got %d", ran) + } + if invoked[normalDir] { + t.Error("Normal Maps job should have been skipped, but backend was invoked for it") + } + if !invoked[diffuseDir] { + t.Error("Diffuse job should have run (no pre-existing output), but backend was never invoked") + } +} + +type fakeBackend struct { + onCompress func(job compress.Job) +} + +func (f *fakeBackend) Name() string { return "fake" } + +func (f *fakeBackend) Compress(ctx context.Context, job compress.Job) compress.CompressionResult { + if f.onCompress != nil { + f.onCompress(job) + } + return compress.CompressionResult{Asset: job.Asset, Success: true, Backend: "fake"} +} diff --git a/internal/tui/screens/results.go b/internal/tui/screens/results.go index 42f12a2..42873c7 100644 --- a/internal/tui/screens/results.go +++ b/internal/tui/screens/results.go @@ -214,6 +214,8 @@ func (m ResultsModel) buildJobs(scope int, selectedProfile, selectedMod string) var paths, relPaths []string var widths, heights []int var genMips []bool + var modOutputDirs []string + anyModOutputDir := false for _, a := range g.Assets { paths = append(paths, a.Path) relPaths = append(relPaths, a.VirtualRelPath) @@ -224,8 +226,19 @@ func (m ResultsModel) buildJobs(scope int, selectedProfile, selectedMod string) // flare or reticle keeps its chain while flat UI art stays single-level — // unless StripMipsWhenDisabled makes generateMips:false authoritative. genMips = append(genMips, compress.ShouldGenerateMips(profileMips, a.SourceMipCount, cfg.StripMipsWhenDisabled)) + if cfg.ModOutputMode && cfg.ModOutputName != "" && cfg.PerModModOutput { + name := cfg.ModOutputName + " - " + a.ModName + if cfg.PerCategoryModOutput { + name += " - " + g.ProfileName + } + dir := filepath.Join(cfg.ModsDir, name) + modOutputDirs = append(modOutputDirs, dir) + anyModOutputDir = true + } else { + modOutputDirs = append(modOutputDirs, "") + } } - configured = append(configured, ConfiguredGroup{ + cg := ConfiguredGroup{ ProfileName: g.ProfileName, Format: g.SuggestedFmt, GenerateMips: genMips, @@ -235,7 +248,17 @@ func (m ResultsModel) buildJobs(scope int, selectedProfile, selectedMod string) Widths: widths, Heights: heights, OutputDir: "", - }) + } + if anyModOutputDir { + cg.ModOutputDirs = modOutputDirs + } else if cfg.ModOutputMode && cfg.ModOutputName != "" { + if cfg.PerCategoryModOutput { + cg.ModOutputDir = filepath.Join(cfg.ModsDir, cfg.ModOutputName+" - "+g.ProfileName) + } else { + cg.ModOutputDir = filepath.Join(cfg.ModsDir, cfg.ModOutputName) + } + } + configured = append(configured, cg) } jobData := CompressJobData{ Groups: configured, @@ -243,7 +266,12 @@ func (m ResultsModel) buildJobs(scope int, selectedProfile, selectedMod string) ModsDir: cfg.ModsDir, } if cfg.ModOutputMode && cfg.ModOutputName != "" { - jobData.ModOutputDir = filepath.Join(cfg.ModsDir, cfg.ModOutputName) + if cfg.PerCategoryModOutput || cfg.PerModModOutput { + jobData.ModOutputDir = filepath.Join(cfg.ModsDir, cfg.ModOutputName+" - *") + jobData.ModOutputIsPattern = true + } else { + jobData.ModOutputDir = filepath.Join(cfg.ModsDir, cfg.ModOutputName) + } } return NavigateMsg{To: NavCompress, Data: jobData} } diff --git a/internal/tui/screens/scan.go b/internal/tui/screens/scan.go index 73c653b..b47e4b8 100644 --- a/internal/tui/screens/scan.go +++ b/internal/tui/screens/scan.go @@ -83,6 +83,9 @@ func (m ScanModel) startScan() tea.Cmd { } // Auto-exclude the output folder so it's never scanned. exclusions := append(cfg.ScanExclusions, cfg.ModOutputName) + if cfg.PerCategoryModOutput || cfg.PerModModOutput { + exclusions = append(exclusions, cfg.ModOutputName+" - *") + } // Filter excluded mods before building the virtual FS so they never // win conflicts — analogous to Walk's filepath.SkipDir on directories. // Only whole-mod (name) exclusions apply here; path patterns target diff --git a/internal/tui/screens/settings.go b/internal/tui/screens/settings.go index 200ce1d..f1f4c74 100644 --- a/internal/tui/screens/settings.go +++ b/internal/tui/screens/settings.go @@ -23,6 +23,8 @@ const ( fieldCompressionBackend // two-way selector, hidden on darwin (no compressonator build) fieldModOutputMode // bool toggle — no text input fieldModOutputName // text input, shown only when ModOutputMode is on + fieldPerCategoryModOutput // bool toggle + fieldPerModModOutput // bool toggle fieldModlistPath // text input, shown only when ModOutputMode is on fieldCount ) @@ -52,6 +54,8 @@ type SettingsModel struct { cfg *config.Config inputs [6]textinput.Model // modsDir, backupDir, workers, backupLevel, modOutputName, modlistPath modOutputMode bool + perCategoryModOutput bool + perModModOutput bool focused settingsField errMsg string width int @@ -99,6 +103,8 @@ func NewSettings(cfg *config.Config) SettingsModel { cfg: cfg, inputs: [6]textinput.Model{mods, backup, workers, backupLvl, modOutputName, modlistPath}, modOutputMode: cfg.ModOutputMode, + perCategoryModOutput: cfg.PerCategoryModOutput, + perModModOutput: cfg.PerModModOutput, stripMips: cfg.StripMipsWhenDisabled, compressionBackend: backend, focused: fieldModsDir, @@ -126,6 +132,14 @@ func (m SettingsModel) Update(msg tea.Msg) (SettingsModel, tea.Cmd) { m.modOutputMode = !m.modOutputMode return m, nil } + if m.focused == fieldPerCategoryModOutput { + m.perCategoryModOutput = !m.perCategoryModOutput + return m, nil + } + if m.focused == fieldPerModModOutput { + m.perModModOutput = !m.perModModOutput + return m, nil + } if m.focused == fieldStripMips { m.stripMips = !m.stripMips return m, nil @@ -185,7 +199,7 @@ func (m SettingsModel) prevField() settingsField { } func (m SettingsModel) isVisible(f settingsField) bool { - if f == fieldModOutputName || f == fieldModlistPath { + if f == fieldModOutputName || f == fieldModlistPath || f == fieldPerCategoryModOutput || f == fieldPerModModOutput { return m.modOutputMode } if f == fieldCompressionBackend { @@ -213,6 +227,8 @@ func (m SettingsModel) save() (SettingsModel, tea.Cmd) { updated.WorkerCount = workers updated.BackupLevel = backupLevel updated.ModOutputMode = m.modOutputMode + updated.PerCategoryModOutput = m.perCategoryModOutput + updated.PerModModOutput = m.perModModOutput updated.ModOutputName = modOutputName updated.ModlistPath = strings.TrimSpace(m.inputs[5].Value()) updated.StripMipsWhenDisabled = m.stripMips @@ -320,6 +336,50 @@ func (m SettingsModel) View() string { b.WriteString(style.StyleMuted.Render("Output folder: "+outputFolder) + "\n") b.WriteString(style.StyleMuted.Render("Delete this folder to force recompression on next run.") + "\n\n") + // Per-Category Output toggle. + { + toggleLabel := "Per-Category Output Directories" + toggleValue := "[ off ]" + if m.perCategoryModOutput { + toggleValue = style.StyleSuccess.Render("[ on ]") + } + if m.focused == fieldPerCategoryModOutput { + b.WriteString(style.StyleSelected.Render(toggleLabel) + "\n") + } else { + b.WriteString(style.StyleBody.Render(toggleLabel) + "\n") + } + b.WriteString(toggleValue + "\n") + currentName := strings.TrimSpace(m.inputs[4].Value()) + if currentName == "" { + currentName = "ATAK" + } + b.WriteString(style.StyleMuted.Render("Output each texture profile to a separate mod folder (e.g., "+currentName+" - Normal Maps).") + "\n\n") + } + + // Per-Mod Output toggle. + { + toggleLabel := "Per-Mod Output Directories" + toggleValue := "[ off ]" + if m.perModModOutput { + toggleValue = style.StyleSuccess.Render("[ on ]") + } + if m.focused == fieldPerModModOutput { + b.WriteString(style.StyleSelected.Render(toggleLabel) + "\n") + } else { + b.WriteString(style.StyleBody.Render(toggleLabel) + "\n") + } + b.WriteString(toggleValue + "\n") + currentName := strings.TrimSpace(m.inputs[4].Value()) + if currentName == "" { + currentName = "ATAK" + } + example := currentName + " - SomeModName" + if m.perCategoryModOutput { + example += " - Normal Maps" + } + b.WriteString(style.StyleMuted.Render("Output each source mod to a separate mod folder (e.g., "+example+").") + "\n\n") + } + modlistLabel := style.StyleBody.Render("MO2 modlist.txt Path") if m.focused == fieldModlistPath { modlistLabel = style.StyleSelected.Render("MO2 modlist.txt Path") diff --git a/internal/tui/screens/summary.go b/internal/tui/screens/summary.go index 06a9573..e19fd26 100644 --- a/internal/tui/screens/summary.go +++ b/internal/tui/screens/summary.go @@ -102,7 +102,11 @@ func (m SummaryModel) View() string { if d.OutputSkipped > 0 && d.Succeeded == 0 { b.WriteString(style.StyleWarning.Render("Nothing to compress — all files already exist in output folder.") + "\n") - b.WriteString(style.StyleWarning.Render(fmt.Sprintf("Delete %s to force recompression.", d.OutputDir)) + "\n\n") + if d.OutputIsPattern { + b.WriteString(style.StyleWarning.Render(fmt.Sprintf("Delete the existing output folders matching %s to force recompression.", d.OutputDir)) + "\n\n") + } else { + b.WriteString(style.StyleWarning.Render(fmt.Sprintf("Delete %s to force recompression.", d.OutputDir)) + "\n\n") + } } b.WriteString(style.StyleSuccess.Render(fmt.Sprintf("✓ %d succeeded", d.Succeeded)) + "\n")