diff --git a/README.md b/README.md index 5fdd8c7..10692d8 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,33 @@ CoolModFiles plays three families of Amiga music: | `/` or `q` | open in-app Help | | `esc` | close the side drawer | +## Sound settings + +The Sound pane (side drawer → Sound) persists three preferences across +sessions: **Amiga emulation** (off / A500 / A1200; also bound to `m`), +**Stereo separation** (slider), and **Filename style**. + +The **Filename style** toggle is display-only and has three options: + +- **Auto** (default) — render filenames verbatim as on disk. +- **Amiga** — render Amiga-native module filenames in scene prefix form + across every catalog: `echoing.mod` → `mod.echoing`, `space.med` → + `med.space`, `quartet.okt` → `okt.quartet`, `dexter.ahx` (and + `dexter.thx`) → `ahx.dexter`. Covers `.mod`, `.med`, `.okt`, `.ahx`, + and `.thx`; PC-era tracker formats are left unchanged. +- **Amiga everywhere** — the same prefix transform, extended to every + supported module format including PC-era trackers: `dreamland.xm` → + `xm.dreamland`, `groove.it` → `it.groove`, `rush.s3m` → `s3m.rush`, + etc. Trades historical accuracy for visual consistency across all + catalog rows. + +TFMX pair rows always render as ` (TFMX)` regardless of the +chosen style — the `(TFMX)` suffix carries the format identity, and the +underlying file shapes (`*.tfx + *.sam`, `mdat.* + smpl.*`, `*.mdat + +*.smpl`) make a single prefix label misleading. Downloads always keep +the canonical on-disk filename, and hovering a row reveals the on-disk +basename in a tooltip so it remains copyable for search/share. + ## Development ```bash diff --git a/components/Player.tsx b/components/Player.tsx index 54eb6f1..2b98e52 100644 --- a/components/Player.tsx +++ b/components/Player.tsx @@ -33,6 +33,8 @@ import { import type { FavoriteTrack } from "./LikedMod"; import type { ModItem } from "../lib/modarchive/types"; import { AudioPlayer, type EngineKind } from "../lib/audio-player"; +import { FilenameStyleProvider } from "../lib/filename/context"; +import { type FilenameStyle } from "../lib/filename/amiga-style"; const DEFAULT_VOLUME = 80; @@ -47,6 +49,16 @@ function readAmigaModel(): AmigaModel { : DEFAULT_AMIGA_MODEL; } +const FILENAME_STYLES: FilenameStyle[] = ["auto", "amiga", "amiga-all"]; +const DEFAULT_FILENAME_STYLE: FilenameStyle = "auto"; + +function readFilenameStyle(): FilenameStyle { + const raw = localStorage.getItem("display.filenameStyle"); + return FILENAME_STYLES.includes(raw as FilenameStyle) + ? (raw as FilenameStyle) + : DEFAULT_FILENAME_STYLE; +} + /** * Pick the file extension for a Mod Archive download by sniffing the * first 4 bytes of the fetched blob. Returns: @@ -284,6 +296,8 @@ function Player({ initialSource, backSideContent, latestId }: PlayerProps) { React.useState(readAmigaModel); const [stereoSeparation, setStereoSeparation] = React.useState(readStereoSeparation); + const [filenameStyle, setFilenameStyle] = + React.useState(readFilenameStyle); // Mirror of AudioPlayer.activeEngine in React state, set synchronously // after each player.play() call so the Sound pane's per-control // gating updates immediately (without waiting for the new worklet's @@ -631,6 +645,10 @@ function Player({ initialSource, backSideContent, latestId }: PlayerProps) { localStorage.setItem("audio.stereoSeparation", String(stereoSeparation)); }, [stereoSeparation]); + React.useEffect(() => { + localStorage.setItem("display.filenameStyle", filenameStyle); + }, [filenameStyle]); + React.useEffect(() => { if (player && playerReady) { playFromSource(playingSource); @@ -1101,6 +1119,7 @@ function Player({ initialSource, backSideContent, latestId }: PlayerProps) { }, [selectedSubsong]); return ( +
{playingSource.type !== "modarchive" && ( @@ -1193,6 +1212,8 @@ function Player({ initialSource, backSideContent, latestId }: PlayerProps) { setStereoSeparation(val); player?.setStereoSeparation(val); }, + filenameStyle, + setFilenameStyle, }} />
@@ -1219,6 +1240,7 @@ function Player({ initialSource, backSideContent, latestId }: PlayerProps) { )} +
); } diff --git a/components/SoundPane.module.scss b/components/SoundPane.module.scss index 039ae79..483d4b3 100644 --- a/components/SoundPane.module.scss +++ b/components/SoundPane.module.scss @@ -17,11 +17,19 @@ // work, and the banner stays full-opacity so the explanation remains // readable. .inactive { - > :not(.note) { + > :not(.note):not(.filenameStyleSection) { opacity: 0.45; } } +// Filename style is a display preference, not an audio preference, so +// it stays fully interactive and visually un-dimmed even when the +// audio-side controls are inactive (e.g. during TFMX playback). +.filenameStyleSection { + // Intentionally empty — exists as a selector hook for .inactive's + // exemption list above. Layout inherits from .stereoSection sibling. +} + .optionDisabled { // pointer-events: none makes the label entirely inert, which is the // right outcome since the inner radio is already disabled. Side diff --git a/components/SoundPane.tsx b/components/SoundPane.tsx index 8ed8853..7ad2b24 100644 --- a/components/SoundPane.tsx +++ b/components/SoundPane.tsx @@ -1,6 +1,7 @@ import React from "react"; import Slider from "rc-slider"; import styles from "./SoundPane.module.scss"; +import { type FilenameStyle } from "../lib/filename/amiga-style"; type AmigaModel = "off" | "a500" | "a1200"; @@ -26,8 +27,24 @@ type SoundPaneProps = { activeEngine?: EngineKind; stereoSeparation: number; setStereoSeparation: (v: number) => void; + filenameStyle: FilenameStyle; + setFilenameStyle: (s: FilenameStyle) => void; }; +const FILENAME_STYLE_OPTIONS: { + value: FilenameStyle; + label: string; + sub: string; +}[] = [ + { value: "auto", label: "Auto", sub: "Render filenames as on disk" }, + { value: "amiga", label: "Amiga", sub: "Prefix form for Amiga-native formats" }, + { + value: "amiga-all", + label: "Amiga everywhere", + sub: "Prefix form for all module formats", + }, +]; + const OPTIONS: { value: AmigaModel; label: string; sub: string }[] = [ { value: "off", label: "Off", sub: "Modern clean resampler" }, { value: "a500", label: "A500", sub: "Warm, ~4.9 kHz filter" }, @@ -40,6 +57,8 @@ function SoundPane({ activeEngine, stereoSeparation, setStereoSeparation, + filenameStyle, + setFilenameStyle, }: SoundPaneProps) { // Per-control disabled predicates per D9. activeEngine === undefined // means "no track yet" — both controls stay live so users can @@ -130,6 +149,33 @@ function SoundPane({ +
+

Filename style

+
+ {FILENAME_STYLE_OPTIONS.map((opt) => ( + + ))} +
+
+ {/* Future Sound-panel sections (interpolation filter length, channel-mute view, ...) drop in below as sibling
s. */} diff --git a/components/SourceDrawer.tsx b/components/SourceDrawer.tsx index cba550c..aea651e 100644 --- a/components/SourceDrawer.tsx +++ b/components/SourceDrawer.tsx @@ -23,6 +23,7 @@ import type { import type { FavoriteTrack } from "./LikedMod"; import type { ModItem } from "../lib/modarchive/types"; import type { EngineKind } from "../lib/audio-player"; +import type { FilenameStyle } from "../lib/filename/amiga-style"; export type DrawerTabId = | "modarchive" @@ -62,6 +63,8 @@ type SoundProps = { activeEngine?: EngineKind; stereoSeparation: number; setStereoSeparation: (v: number) => void; + filenameStyle: FilenameStyle; + setFilenameStyle: (s: FilenameStyle) => void; }; type SourceDrawerProps = { diff --git a/components/library/LibraryCatalog.tsx b/components/library/LibraryCatalog.tsx index 1b542aa..0254e4b 100644 --- a/components/library/LibraryCatalog.tsx +++ b/components/library/LibraryCatalog.tsx @@ -6,6 +6,7 @@ import { type LibrarySource, type TfmxLibrarySource, } from "../sources"; +import { useFilenameStyle } from "../../lib/filename/context"; type PairEntry = { base: string; tfx: string; sam: string }; @@ -35,6 +36,7 @@ function LibraryCatalog({ setCurrentPath, onPlay, }: LibraryCatalogProps) { + const { render, renderPair } = useFilenameStyle(); const [listing, setListing] = React.useState(null); const [error, setError] = React.useState(null); const [searchQuery, setSearchQuery] = React.useState(""); @@ -98,17 +100,26 @@ function LibraryCatalog({
No matches.
) : (
    - {searchResults.map((r) => - r.kind === "mod" ? ( -
  • onPlay(library(r.path))} - title={r.path} - > - {r.path} -
  • - ) : ( + {searchResults.map((r) => { + if (r.kind === "mod") { + const lastSlash = r.path.lastIndexOf("/"); + const dir = + lastSlash === -1 ? "" : r.path.slice(0, lastSlash + 1); + const base = + lastSlash === -1 ? r.path : r.path.slice(lastSlash + 1); + return ( +
  • onPlay(library(r.path))} + title={r.path} + > + {dir} + {render(base)} +
  • + ); + } + return (
  • - {r.base} (TFMX) + {renderPair(r.base)}
  • - ) - )} + ); + })}
)} @@ -192,7 +203,7 @@ function LibraryCatalog({ } title={`${p.tfx} + ${p.sam}`} > - {p.base} (TFMX) + {renderPair(p.base)} ))} {listing.files.map((f) => ( @@ -202,7 +213,7 @@ function LibraryCatalog({ onClick={() => onPlay(library(joinPath([...segments, f])))} title={f} > - {f} + {render(f)} ))} {listing.truncated && ( diff --git a/components/local/LocalCatalog.tsx b/components/local/LocalCatalog.tsx index 8c4db45..87a233c 100644 --- a/components/local/LocalCatalog.tsx +++ b/components/local/LocalCatalog.tsx @@ -9,6 +9,7 @@ import { } from "../sources"; import { detectTfmxPairs } from "./tfmx-pairs"; import { showToast } from "../../utils"; +import { useFilenameStyle } from "../../lib/filename/context"; type LocalCatalogProps = { pickedFiles: File[]; @@ -25,6 +26,7 @@ function LocalCatalog({ setPickedTfmxPairs, onPlay, }: LocalCatalogProps) { + const { render, renderPair } = useFilenameStyle(); const [dragActive, setDragActive] = React.useState(false); const inputRef = React.useRef(null); @@ -135,7 +137,7 @@ function LocalCatalog({ onClick={() => onPlay(pair)} title={`${pair.tfx.name} + ${pair.sam.name}`} > - {pair.base} (TFMX) + {renderPair(pair.base)} ))} {pickedFiles.map((file, idx) => ( @@ -145,7 +147,7 @@ function LocalCatalog({ onClick={() => onPlay(local(file))} title={file.name} > - {file.name} + {render(file.name)} ))} diff --git a/components/modarchive/ChartList.tsx b/components/modarchive/ChartList.tsx index 65292bf..ac5ad08 100644 --- a/components/modarchive/ChartList.tsx +++ b/components/modarchive/ChartList.tsx @@ -11,6 +11,7 @@ import type { ModChartResponse, Pagination, } from "../../lib/modarchive/types"; +import { useFilenameStyle } from "../../lib/filename/context"; type ChartListProps = { kind: Extract; @@ -23,6 +24,7 @@ type State = | { status: "ok"; items: ModItem[]; pagination: Pagination }; function ChartList({ kind, onPick }: ChartListProps) { + const { render } = useFilenameStyle(); const [state, setState] = React.useState({ status: "loading" }); const [reloadCounter, setReloadCounter] = React.useState(0); const [page, setPage] = React.useState(1); @@ -97,7 +99,9 @@ function ChartList({ kind, onPick }: ChartListProps) { )} {item.title} {item.filename && ( - {item.filename} + + {render(item.filename)} + )} ))} diff --git a/components/modarchive/GenreMods.tsx b/components/modarchive/GenreMods.tsx index 13a6283..a7c7c84 100644 --- a/components/modarchive/GenreMods.tsx +++ b/components/modarchive/GenreMods.tsx @@ -10,6 +10,7 @@ import type { ModItem, Pagination, } from "../../lib/modarchive/types"; +import { useFilenameStyle } from "../../lib/filename/context"; type GenreModsProps = { id: number; @@ -22,6 +23,7 @@ type State = | { status: "ok"; items: ModItem[]; pagination: Pagination }; function GenreMods({ id, onPick }: GenreModsProps) { + const { render } = useFilenameStyle(); const [state, setState] = React.useState({ status: "loading" }); const [reloadCounter, setReloadCounter] = React.useState(0); const [page, setPage] = React.useState(1); @@ -93,7 +95,9 @@ function GenreMods({ id, onPick }: GenreModsProps) { > {item.title} {item.filename && ( - {item.filename} + + {render(item.filename)} + )} ))} diff --git a/components/modarchive/PersonMods.tsx b/components/modarchive/PersonMods.tsx index ae5c76e..8953926 100644 --- a/components/modarchive/PersonMods.tsx +++ b/components/modarchive/PersonMods.tsx @@ -10,6 +10,7 @@ import type { Pagination, PersonModsResponse, } from "../../lib/modarchive/types"; +import { useFilenameStyle } from "../../lib/filename/context"; type PersonModsProps = { kind: "artist"; @@ -23,6 +24,7 @@ type State = | { status: "ok"; items: ModItem[]; pagination: Pagination }; function PersonMods({ kind, id, onPick }: PersonModsProps) { + const { render } = useFilenameStyle(); const [state, setState] = React.useState({ status: "loading" }); const [reloadCounter, setReloadCounter] = React.useState(0); const [page, setPage] = React.useState(1); @@ -93,7 +95,9 @@ function PersonMods({ kind, id, onPick }: PersonModsProps) { > {item.title} {item.filename && ( - {item.filename} + + {render(item.filename)} + )} ))} diff --git a/lib/filename/amiga-style.test.mts b/lib/filename/amiga-style.test.mts new file mode 100644 index 0000000..2119946 --- /dev/null +++ b/lib/filename/amiga-style.test.mts @@ -0,0 +1,379 @@ +/* + * Copyright 2026 Ronny Trommer + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { describe, it, expect } from "vitest"; +import { toAmigaStyle, renderTfmxPairLabel } from "./amiga-style.js"; + +describe("toAmigaStyle", () => { + describe("Amiga-native extensions are mapped to prefix form", () => { + it(".mod → mod.", () => { + expect(toAmigaStyle("echoing.mod")).toBe("mod.echoing"); + }); + + it(".med → med.", () => { + expect(toAmigaStyle("space.med")).toBe("med.space"); + }); + + it(".okt → okt.", () => { + expect(toAmigaStyle("quartet.okt")).toBe("okt.quartet"); + }); + + it(".ahx → ahx.", () => { + expect(toAmigaStyle("dexter.ahx")).toBe("ahx.dexter"); + }); + + it(".thx → ahx. (THX is AHX's earlier extension, same engine)", () => { + expect(toAmigaStyle("dexter.thx")).toBe("ahx.dexter"); + }); + }); + + describe("PC-era extensions are returned unchanged", () => { + it(".xm (FastTracker II)", () => { + expect(toAmigaStyle("dreamland.xm")).toBe("dreamland.xm"); + }); + + it(".it (Impulse Tracker)", () => { + expect(toAmigaStyle("groove.it")).toBe("groove.it"); + }); + + it(".s3m (Scream Tracker 3)", () => { + expect(toAmigaStyle("rush.s3m")).toBe("rush.s3m"); + }); + + it(".mptm (OpenMPT-native)", () => { + expect(toAmigaStyle("experiment.mptm")).toBe("experiment.mptm"); + }); + + it(".stm (Scream Tracker)", () => { + expect(toAmigaStyle("classic.stm")).toBe("classic.stm"); + }); + + it(".mtm (MultiTracker)", () => { + expect(toAmigaStyle("tune.mtm")).toBe("tune.mtm"); + }); + + it(".669 (Composer 669)", () => { + expect(toAmigaStyle("retro.669")).toBe("retro.669"); + }); + + it(".ult (UltraTracker)", () => { + expect(toAmigaStyle("rave.ult")).toBe("rave.ult"); + }); + + it("non-module extension (.txt)", () => { + expect(toAmigaStyle("readme.txt")).toBe("readme.txt"); + }); + + it("no extension at all", () => { + expect(toAmigaStyle("echoing")).toBe("echoing"); + }); + }); + + describe("degenerate inputs are returned unchanged", () => { + it("bare .mod extension (would otherwise emit prefix + empty base)", () => { + expect(toAmigaStyle(".mod")).toBe(".mod"); + }); + + it("bare .ahx extension", () => { + expect(toAmigaStyle(".ahx")).toBe(".ahx"); + }); + + it("bare .thx extension (same guard applies)", () => { + expect(toAmigaStyle(".thx")).toBe(".thx"); + }); + + it("empty string", () => { + expect(toAmigaStyle("")).toBe(""); + }); + }); + + describe("already-prefixed canonical names are idempotent", () => { + it("mod.echoing is unchanged", () => { + expect(toAmigaStyle("mod.echoing")).toBe("mod.echoing"); + }); + + it("med.space is unchanged", () => { + expect(toAmigaStyle("med.space")).toBe("med.space"); + }); + + it("okt.quartet is unchanged", () => { + expect(toAmigaStyle("okt.quartet")).toBe("okt.quartet"); + }); + + it("ahx.dexter is unchanged", () => { + expect(toAmigaStyle("ahx.dexter")).toBe("ahx.dexter"); + }); + }); + + describe("double-form names have the redundant suffix stripped", () => { + it("mod.echoing.mod → mod.echoing", () => { + expect(toAmigaStyle("mod.echoing.mod")).toBe("mod.echoing"); + }); + + it("med.space.med → med.space", () => { + expect(toAmigaStyle("med.space.med")).toBe("med.space"); + }); + + it("ahx.dexter.ahx → ahx.dexter", () => { + expect(toAmigaStyle("ahx.dexter.ahx")).toBe("ahx.dexter"); + }); + + it("ahx.dexter.thx → ahx.dexter (THX suffix also stripped, ahx prefix kept)", () => { + expect(toAmigaStyle("ahx.dexter.thx")).toBe("ahx.dexter"); + }); + }); + + describe("uppercase / mixed-case prefixes are canonicalized to lower-case", () => { + it("MOD.echoing → mod.echoing", () => { + expect(toAmigaStyle("MOD.echoing")).toBe("mod.echoing"); + }); + + it("Mod.Echoing → mod.Echoing (base preserved)", () => { + expect(toAmigaStyle("Mod.Echoing")).toBe("mod.Echoing"); + }); + + it("AHX.Dexter → ahx.Dexter", () => { + expect(toAmigaStyle("AHX.Dexter")).toBe("ahx.Dexter"); + }); + }); + + describe("base case is preserved verbatim when transform applies", () => { + it("Echoing.MOD → mod.Echoing", () => { + expect(toAmigaStyle("Echoing.MOD")).toBe("mod.Echoing"); + }); + + it("ECHOING.mod → mod.ECHOING", () => { + expect(toAmigaStyle("ECHOING.mod")).toBe("mod.ECHOING"); + }); + + it("mIxEdCaSe.ahx → ahx.mIxEdCaSe", () => { + expect(toAmigaStyle("mIxEdCaSe.ahx")).toBe("ahx.mIxEdCaSe"); + }); + + it("double-form Mod.Echoing.MOD → mod.Echoing (suffix stripped, prefix canonicalized, base preserved)", () => { + expect(toAmigaStyle("Mod.Echoing.MOD")).toBe("mod.Echoing"); + }); + }); + + describe("basename-only contract", () => { + // The transform itself does not split paths. Callers must split the + // basename off on `/` before calling. These cases document the + // contract — passing a path with `/` produces a result where the `/` + // is treated as part of the "base" only when the transform fires. + it("returns the input unchanged when it contains a slash AND has no allow-list extension", () => { + expect(toAmigaStyle("Hippel/Apidya/readme.txt")).toBe( + "Hippel/Apidya/readme.txt", + ); + }); + + it("treats slash as part of the base if a caller forgets to split (transform still applies the extension rule, demonstrating why callers must split first)", () => { + // Callers using this output verbatim would render + // `mod.Hippel/Apidya/echoing` which is wrong. This test pins the + // current behavior so the contract violation is visible — fix the + // call-site, not the transform. + expect(toAmigaStyle("Hippel/Apidya/echoing.mod")).toBe( + "mod.Hippel/Apidya/echoing", + ); + }); + }); +}); + +describe("renderTfmxPairLabel", () => { + it("renders ' (TFMX)' (style-independent)", () => { + expect(renderTfmxPairLabel("apidya_inflight")).toBe( + "apidya_inflight (TFMX)", + ); + }); + + it("base case is preserved exactly", () => { + expect(renderTfmxPairLabel("Apidya_InFlight")).toBe( + "Apidya_InFlight (TFMX)", + ); + }); +}); + +describe("toAmigaStyle in 'all' mode (Amiga-everywhere)", () => { + describe("PC-era extensions now transform", () => { + it(".xm → xm.", () => { + expect(toAmigaStyle("dreamland.xm", "all")).toBe("xm.dreamland"); + }); + + it(".it → it.", () => { + expect(toAmigaStyle("groove.it", "all")).toBe("it.groove"); + }); + + it(".s3m → s3m.", () => { + expect(toAmigaStyle("rush.s3m", "all")).toBe("s3m.rush"); + }); + + it(".mptm → mptm.", () => { + expect(toAmigaStyle("experiment.mptm", "all")).toBe("mptm.experiment"); + }); + + it(".stm → stm.", () => { + expect(toAmigaStyle("classic.stm", "all")).toBe("stm.classic"); + }); + + it(".mtm → mtm.", () => { + expect(toAmigaStyle("tune.mtm", "all")).toBe("mtm.tune"); + }); + + it(".669 → 669.", () => { + expect(toAmigaStyle("retro.669", "all")).toBe("669.retro"); + }); + + it(".ult → ult.", () => { + expect(toAmigaStyle("rave.ult", "all")).toBe("ult.rave"); + }); + }); + + describe("Amiga-native formats still transform identically in 'all' mode", () => { + it(".mod still → mod.", () => { + expect(toAmigaStyle("echoing.mod", "all")).toBe("mod.echoing"); + }); + + it(".thx still → ahx. (same engine identity)", () => { + expect(toAmigaStyle("dexter.thx", "all")).toBe("ahx.dexter"); + }); + }); + + describe("THX → AHX alias canonicalization", () => { + it("`thx.` prefix form (no extension) is canonicalized to `ahx.`", () => { + expect(toAmigaStyle("thx.dexter")).toBe("ahx.dexter"); + }); + + it("`THX.` mixed-case prefix is canonicalized to lower-case `ahx.`", () => { + expect(toAmigaStyle("THX.dexter")).toBe("ahx.dexter"); + }); + + it("`Thx.Dexter` → `ahx.Dexter` (prefix rewritten, base preserved)", () => { + expect(toAmigaStyle("Thx.Dexter")).toBe("ahx.Dexter"); + }); + + it("`thx.something.ahx` (double-form with thx prefix) → `ahx.something`", () => { + expect(toAmigaStyle("thx.something.ahx")).toBe("ahx.something"); + }); + + it("`thx.dexter.thx` (thx prefix + thx suffix) → `ahx.dexter`", () => { + expect(toAmigaStyle("thx.dexter.thx")).toBe("ahx.dexter"); + }); + + it("alias applies in 'all' mode too", () => { + expect(toAmigaStyle("thx.dexter", "all")).toBe("ahx.dexter"); + }); + }); + + describe("format-identity gate: step 3 alias does not override step 2 suffix", () => { + // When a name carries an unrelated allow-list suffix, the file's + // actual extension is the format identity. Step 3's alias match + // must not override it. + + it("thx.foo.mod preserves .mod identity → mod.thx.foo (NOT ahx.foo)", () => { + expect(toAmigaStyle("thx.foo.mod")).toBe("mod.thx.foo"); + }); + + it("med.foo.mod preserves .mod identity → mod.med.foo (NOT med.foo)", () => { + expect(toAmigaStyle("med.foo.mod")).toBe("mod.med.foo"); + }); + + it("mod.echoing.med (mismatched prefix and suffix) → med.mod.echoing", () => { + expect(toAmigaStyle("mod.echoing.med")).toBe("med.mod.echoing"); + }); + + it("Thx.Foo.Mod (mixed case, same class) → mod.Thx.Foo", () => { + expect(toAmigaStyle("Thx.Foo.Mod")).toBe("mod.Thx.Foo"); + }); + + it("matching prefix+suffix still collapses normally", () => { + expect(toAmigaStyle("mod.echoing.mod")).toBe("mod.echoing"); + }); + + it("alias still fires when no suffix was stripped (thx.dexter → ahx.dexter)", () => { + expect(toAmigaStyle("thx.dexter")).toBe("ahx.dexter"); + }); + + it("thx.foo.ahx (alias agrees with suffix) → ahx.foo", () => { + expect(toAmigaStyle("thx.foo.ahx")).toBe("ahx.foo"); + }); + + it("idempotent under repeat application for thx.foo.mod", () => { + const once = toAmigaStyle("thx.foo.mod"); + const twice = toAmigaStyle(once); + expect(once).toBe("mod.thx.foo"); + expect(twice).toBe(once); + }); + + it("idempotent under repeat application for med.foo.mod", () => { + const once = toAmigaStyle("med.foo.mod"); + const twice = toAmigaStyle(once); + expect(once).toBe("mod.med.foo"); + expect(twice).toBe(once); + }); + }); + + describe("idempotency guard for base-equals-prefix-letters inputs", () => { + // Without the step-2 guard, Mod.Mod would yield mod.Mod on first + // pass and mod.mod on second pass — losing strict idempotency. + + it("`Mod.Mod` round-trips to `mod.Mod` and stays there", () => { + const once = toAmigaStyle("Mod.Mod"); + const twice = toAmigaStyle(once); + expect(once).toBe("mod.Mod"); + expect(twice).toBe(once); + }); + + it("`mod.Mod` is stable on repeat application", () => { + const once = toAmigaStyle("mod.Mod"); + const twice = toAmigaStyle(once); + expect(once).toBe("mod.Mod"); + expect(twice).toBe(once); + }); + + it("`mod.mod` is stable on repeat application", () => { + const once = toAmigaStyle("mod.mod"); + const twice = toAmigaStyle(once); + expect(once).toBe("mod.mod"); + expect(twice).toBe(once); + }); + + it("`Ahx.Ahx` round-trips correctly", () => { + const once = toAmigaStyle("Ahx.Ahx"); + const twice = toAmigaStyle(once); + expect(once).toBe("ahx.Ahx"); + expect(twice).toBe(once); + }); + + it("same guard works in 'all' mode for PC formats", () => { + const once = toAmigaStyle("Xm.Xm", "all"); + const twice = toAmigaStyle(once, "all"); + expect(once).toBe("xm.Xm"); + expect(twice).toBe(once); + }); + }); + + describe("non-module extensions are still left unchanged", () => { + it(".txt is not in the table even in 'all' mode", () => { + expect(toAmigaStyle("readme.txt", "all")).toBe("readme.txt"); + }); + }); + + describe("idempotency and case rules carry over to 'all' mode", () => { + it("already-prefixed PC name is unchanged", () => { + expect(toAmigaStyle("xm.dreamland", "all")).toBe("xm.dreamland"); + }); + + it("uppercase PC prefix canonicalizes", () => { + expect(toAmigaStyle("XM.dreamland", "all")).toBe("xm.dreamland"); + }); + + it("double-form PC name collapses", () => { + expect(toAmigaStyle("xm.dreamland.xm", "all")).toBe("xm.dreamland"); + }); + + it("bare PC extension stays bare", () => { + expect(toAmigaStyle(".xm", "all")).toBe(".xm"); + }); + }); +}); diff --git a/lib/filename/amiga-style.ts b/lib/filename/amiga-style.ts new file mode 100644 index 0000000..850247e --- /dev/null +++ b/lib/filename/amiga-style.ts @@ -0,0 +1,196 @@ +/* + * Copyright 2026 Ronny Trommer + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Amiga prefix transform for file-derived display strings. + * Pure basename transform. Callers with full paths (e.g. Library mod + * search rows whose `r.path` is `Hippel/Apidya/echoing.mod`) must split + * the basename off on `/` before calling and rejoin around the result. + * The transform itself does no path handling. + * + * Spec: openspec/changes/add-amiga-prefix-filenames/specs/filename-display/spec.md + * Algorithm rationale: openspec/changes/add-amiga-prefix-filenames/design.md (Decision 3) + */ + +export type FilenameStyle = "auto" | "amiga" | "amiga-all"; + +// Mode selector for `toAmigaStyle`. `"native"` covers Amiga-native +// formats only; `"all"` extends the table with PC-era tracker formats +// for users who want visual consistency over historical accuracy. +export type AmigaStyleMode = "native" | "all"; + +// Amiga-native extension → prefix. `.thx` is the earlier extension for +// the AHX engine; both map to the same `ahx.` prefix. +const NATIVE_PREFIX_TABLE: Readonly> = Object.freeze({ + ".mod": "mod.", + ".med": "med.", + ".okt": "okt.", + ".ahx": "ahx.", + ".thx": "ahx.", +}); + +// "Amiga everywhere" mode: native table plus PC-era trackers. Each PC +// extension maps to its own extension-derived prefix. The product +// directive trades historical accuracy for visual consistency — users +// who opt into this mode want every catalog row in prefix form. +const ALL_PREFIX_TABLE: Readonly> = Object.freeze({ + ...NATIVE_PREFIX_TABLE, + ".xm": "xm.", + ".it": "it.", + ".s3m": "s3m.", + ".mptm": "mptm.", + ".stm": "stm.", + ".mtm": "mtm.", + ".669": "669.", + ".ult": "ult.", +}); + +// Recognized prefix → canonical output prefix. Identity for native +// prefixes; `thx.` is an alias that canonicalizes to `ahx.` so a file +// already named `thx.something` is rewritten as `ahx.something` rather +// than left alone (the AHX engine identity wins). The same alias is +// active in 'all' mode. +const PREFIX_ALIAS_TABLE: Readonly> = Object.freeze({ + "mod.": "mod.", + "med.": "med.", + "okt.": "okt.", + "ahx.": "ahx.", + "thx.": "ahx.", + "xm.": "xm.", + "it.": "it.", + "s3m.": "s3m.", + "mptm.": "mptm.", + "stm.": "stm.", + "mtm.": "mtm.", + "669.": "669.", + "ult.": "ult.", +}); + +type TableContext = { + table: Readonly>; + extensions: readonly string[]; + // Recognition prefixes active for this mode. Each entry pairs the + // input-side prefix the algorithm matches on with the canonical + // output-side prefix the algorithm emits — identical for most rows, + // distinct only for the THX alias. + aliasEntries: readonly { from: string; to: string }[]; +}; + +function buildContext( + table: Readonly>, +): TableContext { + // Only include alias entries whose canonical output is one of THIS + // mode's allowed output prefixes (so PC-era aliases don't leak into + // 'native' mode). + const allowedOutputs = new Set(Object.values(table)); + const aliasEntries = Object.entries(PREFIX_ALIAS_TABLE) + .filter(([, to]) => allowedOutputs.has(to)) + .map(([from, to]) => ({ from, to })); + return { + table, + extensions: Object.keys(table), + aliasEntries, + }; +} + +const NATIVE_CONTEXT = buildContext(NATIVE_PREFIX_TABLE); +const ALL_CONTEXT = buildContext(ALL_PREFIX_TABLE); + +/** + * Transform a basename to Amiga prefix form. Returns the input verbatim + * for non-Amiga extensions, degenerate inputs, and names already in + * canonical (lower-case) prefix form. Mixed/upper-case prefixes are + * canonicalized to lower-case; the base portion preserves the input's + * case verbatim. Double-form `mod.echoing.mod` collapses to `mod.echoing`. + * + * Algorithm (4 ordered steps; matches design.md Decision 3): + * 1. Bail on a bare allow-list extension (`.mod`, `.MED`, ...). + * 2. Strip an allow-list suffix if present; remember the implied prefix. + * 3. If the working string already starts with an allow-list prefix + * (case-insensitive) plus a non-empty remainder, return + * `.`. This is where + * idempotency, case canonicalization, AND double-form collapse all + * live. + * 4. Otherwise, if step 2 stripped a suffix, prepend the implied + * prefix. If step 2 didn't strip anything, return the input + * unchanged. + */ +export function toAmigaStyle( + name: string, + mode: AmigaStyleMode = "native", +): string { + const ctx = mode === "all" ? ALL_CONTEXT : NATIVE_CONTEXT; + const lower = name.toLowerCase(); + + // Step 1: bare allow-list extension (e.g. `.mod`). Without this guard + // step 4 would emit `mod.` (prefix + empty base). + if (Object.prototype.hasOwnProperty.call(ctx.table, lower)) { + return name; + } + + // Step 2: strip an allow-list suffix if present. + let working = name; + let impliedPrefix: string | null = null; + for (const ext of ctx.extensions) { + if (lower.endsWith(ext)) { + const candidate = name.slice(0, name.length - ext.length); + const prefix = ctx.table[ext]; + // Idempotency guard: if stripping would leave a base whose lower + // case equals the prefix's letter portion (e.g. `Mod.Mod` → base + // `Mod` → lower `mod` matches the `mod.` prefix's letters), the + // input is morally already in prefix form. Do NOT strip — let + // step 3 handle it as already-prefixed so re-running the + // transform is a no-op (would otherwise produce mod.Mod → mod.mod + // → mod.mod on second pass, breaking idempotency). + const prefixLetters = prefix.slice(0, -1); + if (candidate.toLowerCase() === prefixLetters) { + break; + } + working = candidate; + impliedPrefix = prefix; + break; + } + } + + // Step 3: working string already in prefix form? Iterate aliasEntries + // so a recognized prefix (e.g. `thx.`) emits its canonical output + // form (`ahx.`) rather than itself — see PREFIX_ALIAS_TABLE. + // + // Format-identity gate: if step 2 stripped a suffix (impliedPrefix + // !== null), only fire when the alias's canonical output matches the + // implied prefix. Otherwise the alias would override the format + // identity carried by the file's actual extension — e.g. without the + // gate, `thx.foo.mod` (a MOD file whose author happened to name it + // `thx.foo`) would render as `ahx.foo`, silently dropping the .mod + // format identity. With the gate, step 3 skips, step 4 fires, and + // the result is `mod.thx.foo` — preserving the MOD identity and + // treating `thx.` as part of the base. + const workingLower = working.toLowerCase(); + for (const { from, to } of ctx.aliasEntries) { + if ( + workingLower.startsWith(from) && + workingLower.length > from.length && + (impliedPrefix === null || to === impliedPrefix) + ) { + const remainder = working.slice(from.length); + return to + remainder; + } + } + + // Step 4: prepend the implied prefix if step 2 fired, else unchanged. + if (impliedPrefix !== null) { + return impliedPrefix + working; + } + return name; +} + +/** + * Render a TFMX pair label. The label is style-independent: pair rows + * always show ` (TFMX)` regardless of the filename-style setting. + * Per product directive, TFMX is exempt from prefix-form display — the + * `(TFMX)` suffix already conveys the format identity, and `mdat./smpl.` + * pair-form labels mismatched on-disk file naming. + */ +export function renderTfmxPairLabel(base: string): string { + return `${base} (TFMX)`; +} diff --git a/lib/filename/context.tsx b/lib/filename/context.tsx new file mode 100644 index 0000000..3c9a595 --- /dev/null +++ b/lib/filename/context.tsx @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Ronny Trommer + * SPDX-License-Identifier: GPL-3.0-or-later + * + * React context for the filename-style display preference. + * + * The Provider wraps Player's render tree (see components/Player.tsx). + * Consumers (catalog rows in LibraryCatalog, LocalCatalog, and the three + * ModArchive list components) call `useFilenameStyle()` to get a stable + * `render` callback rather than importing toAmigaStyle directly — that + * keeps the auto-mode pass-through branch hidden from call-site code. + * + * Consumers rendered outside the Provider (isolated tests, Storybook, + * future refactors) get the default value: style="auto", pass-through + * render, web-style renderPair. The hook does not throw — this is a + * display preference, not a correctness invariant. + * + * Spec: openspec/changes/add-amiga-prefix-filenames/specs/filename-display/spec.md + */ + +import React from "react"; +import { + toAmigaStyle, + renderTfmxPairLabel, + type FilenameStyle, +} from "./amiga-style"; + +export type FilenameStyleContextValue = { + style: FilenameStyle; + render: (name: string) => string; + renderPair: (base: string) => string; +}; + +const DEFAULT_VALUE: FilenameStyleContextValue = { + style: "auto", + render: (name) => name, + renderPair: (base) => renderTfmxPairLabel(base), +}; + +const FilenameStyleContext = + React.createContext(DEFAULT_VALUE); + +export function FilenameStyleProvider({ + style, + children, +}: { + style: FilenameStyle; + children: React.ReactNode; +}) { + const value = React.useMemo( + () => ({ + style, + render: (name: string) => { + if (style === "amiga") return toAmigaStyle(name, "native"); + if (style === "amiga-all") return toAmigaStyle(name, "all"); + return name; + }, + renderPair: (base: string) => renderTfmxPairLabel(base), + }), + [style], + ); + return ( + + {children} + + ); +} + +export function useFilenameStyle(): FilenameStyleContextValue { + return React.useContext(FilenameStyleContext); +}