mirror of
https://github.com/skoelle/marcer-gamedvd-launcher.git
synced 2026-09-17 18:50:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4660cc6be8 | ||
|
|
148c2bb484 | ||
|
|
607d0df28c | ||
|
|
4b3321e812 | ||
|
|
6a4be2e397 | ||
|
|
61a8ae8457 | ||
|
|
5995a96516 | ||
|
|
e704261ea4 | ||
|
|
1634ce806c | ||
|
|
292b642d76 | ||
|
|
e8c43a9d52 | ||
|
|
7f4df30ae7 | ||
|
|
7a75c635f6 | ||
|
|
62968dbcd1 | ||
|
|
7dca82baa8 |
@@ -6,16 +6,16 @@ applyTo: '**'
|
||||
|
||||
The implementation is split into focused modules (files) under the `src/MarcerGameDvdLauncher/` folder. Keep this section up to date when files are added, removed or responsibilities change.
|
||||
|
||||
- MarcerGameDvdLauncher/Program.cs: Minimal entry point. Sets console title and starts the application by creating `LauncherApp`.
|
||||
- MarcerGameDvdLauncher/LauncherApp.cs: Application lifecycle host — loads configuration, initializes components and runs the main directory navigation loop (contains `AppHost` internal class).
|
||||
- MarcerGameDvdLauncher/AppConfiguration.cs: POCO configuration classes (`AppConfig`, `AppHatariConfig`) used to deserialize `launcher.config.json`.
|
||||
- MarcerGameDvdLauncher/ProgramHelpers.cs: Small shared helpers (resolve relative paths, centralized console message helper) used across modules.
|
||||
- MarcerGameDvdLauncher/Program.cs: Minimal entry point. Sets the console title (via `DefaultTitle` constant) and starts the application by creating `LauncherApp`.
|
||||
- MarcerGameDvdLauncher/LauncherApp.cs: Application lifecycle host — loads configuration, initializes components and runs the main directory navigation loop (contains `AppHost` internal class). Key-handling logic is delegated to `InputController`.
|
||||
- MarcerGameDvdLauncher/AppConfiguration.cs: POCO configuration classes (`AppConfig`, `AppHatariConfig`, `AppColorConfig`) used to deserialize `launcher.config.json`; color values resolved via `Enum.TryParse<ConsoleColor>` with default fallback.
|
||||
- MarcerGameDvdLauncher/ProgramHelpers.cs: Small shared helpers (resolve relative paths, centralized console message helper, input buffer flushing) used across modules.
|
||||
- MarcerGameDvdLauncher/OverlayDirectoryBrowser.cs: Filesystem overlay and browsing logic — merges root and patch directories, enumerates folders and ZIPs, protects against path traversal and ensures navigation cannot leave the configured roots.
|
||||
- MarcerGameDvdLauncher/NavigationController.cs: Encapsulates selection, scrolling and relative-path navigation logic (cursor, page up/down, per-directory remembered selection/state).
|
||||
- MarcerGameDvdLauncher/MenuRenderer.cs: Console rendering logic — efficient per-line redraw, double-buffering, color selection according to overlay rules, and the help box overlay.
|
||||
- MarcerGameDvdLauncher/HatariLauncher.cs: Responsible for validating the Hatari executable and starting Hatari with the configured argument template (replaces `{cfg}` and `{zip}`).
|
||||
- MarcerGameDvdLauncher/FavoritesService.cs: Manages the favorites/bookmark system — toggling favorites on ZIPs, persisting them to `favorites.txt`, and providing the virtual `Favorites` folder view.
|
||||
- MarcerGameDvdLauncher/DirectoryService.cs: Filesystem service layer — enumerates directories and ZIPs, resolves paths, and provides the underlying I/O operations used by OverlayDirectoryBrowser.
|
||||
- MarcerGameDvdLauncher/NavigationController.cs: Encapsulates selection, scrolling and relative-path navigation logic (cursor, page up/down, per-directory remembered selection/state); uses named scroll-fraction constants.
|
||||
- MarcerGameDvdLauncher/MenuRenderer.cs: Console rendering logic — efficient per-line redraw, double-buffering, configurable color selection via injected `AppColorConfig`, and the help box overlay.
|
||||
- MarcerGameDvdLauncher/InputController.cs: Handles key events (arrow keys, Enter, Backspace, PageUp/Down, `*`, `?`, ESC, RightArrow) and the associated navigation/drawing logic; owns `ReloadGameEntries` and the virtual `Favorites` folder integration.
|
||||
- MarcerGameDvdLauncher/HatariLauncher.cs: Responsible for validating the Hatari executable and starting Hatari with the configured argument template (replaces `{cfg}` and `{zip}`). Exposes `DefaultConfigFile` constant (`MarcerGameDvd-Hatari.cfg`); when `Hatari.ConfigFile` is empty in `launcher.config.json`, the bundled config from the executable directory is used automatically.
|
||||
- MarcerGameDvdLauncher/FavoritesService.cs: Manages the favorites/bookmark system — toggling favorites on ZIPs, persisting them to `favorites.txt` (via `DefaultFileName` constant), and providing the virtual `Favorites` folder view (via `FavoritesRootName` constant).
|
||||
- MarcerGameDvdLauncher/UIErrorService.cs: Centralized UI error presentation using the console message helper.
|
||||
|
||||
Note: This overview is intentionally concise. For behavioral changes (navigation, color scheme, launch command or config schema), update this file (AGENTS.md) and README.md as required by project policy.
|
||||
@@ -62,7 +62,7 @@ The console launcher is meant for browsing a games directory and can launch ZIP
|
||||
- `?`: show a help box with key bindings
|
||||
- The file list always shows exactly as many lines as fit the screen – ALWAYS **one line less** than the console height (`Console.WindowHeight - 1`). This avoids overflow at the bottom and ensures the selection never enters the non-visible area.
|
||||
Rationale: writing to the very last console line can cause the Windows console to auto-scroll or produce visual jumps when the cursor reaches the bottom row. Reserving one line prevents unintended scrolling/flicker and keeps the selection cursor strictly within the visible area.
|
||||
Maintenance: when changing rendering or navigation logic, always compute the displayed page size as `availableLines = Console.WindowHeight - 1` and keep this value consistent across MenuRenderer, NavigationController and any other code that references the console height.
|
||||
Maintenance: when changing rendering or navigation logic, always compute the displayed page size as `availableLines = ProgramHelpers.AvailableLines` (which resolves to `Console.WindowHeight - 1`) and keep this value consistent across MenuRenderer, NavigationController and any other code that references the console height.
|
||||
- There is no information line/path display.
|
||||
- Cursor and scroll logic:
|
||||
- The selection cursor must always remain in the visible area.
|
||||
@@ -71,6 +71,7 @@ The console launcher is meant for browsing a games directory and can launch ZIP
|
||||
|
||||
### Launching a Game
|
||||
- If Enter is pressed on a ZIP, Hatari is called with the configured executable, config file, and the full path to the ZIP file as arguments.
|
||||
- `Hatari.ConfigFile` is optional. When empty, the bundled `MarcerGameDvd-Hatari.cfg` (shipped with the launcher) is used automatically.
|
||||
|
||||
### Error Handling & Edge Cases
|
||||
- Paths with special characters or spaces must work robustly.
|
||||
@@ -78,7 +79,8 @@ The console launcher is meant for browsing a games directory and can launch ZIP
|
||||
- Empty directories must be displayed correctly (or reported correctly).
|
||||
- In the root directory, Backspace must have no effect (no error, do not leave the program).
|
||||
- Navigation (Backspace, Enter, etc.) must remain robust even for very deep or large directory trees.
|
||||
- Hatari.Executable is validated during startup: the path is resolved (relative to the EXE directory when applicable) and must point to an existing file. If validation fails the program must present a clear error and exit.
|
||||
- Hatari.Executable is validated during startup: the path is resolved (relative to the EXE directory when applicable) and must point to an existing file. If validation fails the program must present a clear error and exit.
|
||||
- `Hatari.ConfigFile` (if provided) is resolved relative to the EXE directory and validated. If empty, the bundled `MarcerGameDvd-Hatari.cfg` is used automatically.
|
||||
|
||||
### Miscellaneous
|
||||
- Build and start scripts (`scripts/build.cmd` / `scripts/build.sh` / `scripts/start.cmd` / `scripts/start.sh`) are present and must be used.
|
||||
@@ -114,15 +116,18 @@ Each entry is displayed with a left label indicating its layer status:
|
||||
- **`[ROOT]`**: Entry exists only in main (root) layer
|
||||
- **`[PTCH]`**: Entry exists only in patch layer
|
||||
|
||||
Color mapping:
|
||||
- Folder in both layers: **ConsoleColor.Yellow** (`[BOTH]`)
|
||||
- Folder only in patch layer: **ConsoleColor.DarkYellow** (`[PTCH]`)
|
||||
- Folder only in main layer: **ConsoleColor.Gray** (`[ROOT]`)
|
||||
- ZIP in both layers: **ConsoleColor.Green** (`[BOTH]`)
|
||||
- ZIP only in main layer: **ConsoleColor.DarkGreen** (`[ROOT]`)
|
||||
- ZIP only in patch layer: **ConsoleColor.Magenta** (`[PTCH]`)
|
||||
Color mapping (now configurable via the `Colors` section in `launcher.config.json`; defaults shown below):
|
||||
- Folder in both layers: **Yellow** (`[BOTH]`) → `FolderBoth`
|
||||
- Folder only in patch layer: **DarkYellow** (`[PTCH]`) → `FolderPatchOnly`
|
||||
- Folder only in main layer: **Gray** (`[ROOT]`) → `FolderRootOnly`
|
||||
- ZIP in both layers: **Green** (`[BOTH]`) → `ZipBoth`
|
||||
- ZIP only in main layer: **DarkGreen** (`[ROOT]`) → `ZipRootOnly`
|
||||
- ZIP only in patch layer: **Magenta** (`[PTCH]`) → `ZipPatchOnly`
|
||||
- Selected entry foreground: **Black** → `SelectedForeground`
|
||||
- Selected entry background: **DarkCyan** → `SelectedBackground`
|
||||
- Virtual entry (Favorites pseudo-folder): **White** → `VirtualEntry`
|
||||
|
||||
Note: The ConsoleColor mapping above is authoritative for the application. If you change color values in code (MenuRenderer/GetColorForEntry), update this section to keep documentation and implementation in sync.
|
||||
Note: Color values are resolved in `MenuRenderer` from the injected `AppColorConfig` (populated in `LauncherApp.LoadConfiguration` via `Enum.TryParse<ConsoleColor>` with default fallback). Invalid or missing values fall back to the defaults above. If you change default color values in `AppColorConfig`, update this section and the README color tables to keep documentation and implementation in sync.
|
||||
|
||||
### Navigation
|
||||
- Navigation is always based strictly on the **relative path from root** and is consistent on all levels (Backspace always moves up one level, Enter always moves one level deeper, regardless of which layer).
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Known Bugs & Issues
|
||||
|
||||
## Critical Bugs
|
||||
|
||||
### 1. ArgsTemplate `{cfg}` placeholder not handled when ConfigFile is empty
|
||||
**Location**: `HatariLauncher.cs:36`
|
||||
**Problem**: If `Hatari.ConfigFile` is empty string (allowed per config) but `ArgsTemplate` contains `{cfg}`, the replacement produces `-c "" --disk-a "path"` which Hatari may reject.
|
||||
**Fix**: Conditionally remove `-c "{cfg}"` when `_cfgPath` is empty, or validate template matches config.
|
||||
|
||||
### 2. `MenuRenderer.RedrawEntry()` potential crash on console resize
|
||||
**Location**: `MenuRenderer.cs:67-98`
|
||||
**Problem**: If console resizes smaller since last draw, `row` parameter may exceed new `availableLines`. The `else` branch writes directly via `WriteConsoleLine` without bounds checking against actual console height, risking `ArgumentOutOfRangeException` on `Console.SetCursorPosition`.
|
||||
**Fix**: Validate `row < Console.WindowHeight` before writing, or clamp to valid range.
|
||||
|
||||
## Medium Bugs
|
||||
|
||||
### 3. `HatariLauncher` constructor doesn't validate `ArgsTemplate`
|
||||
**Location**: `HatariLauncher.cs:12-24`
|
||||
**Problem**: Constructor validates `exePath` existence but allows empty/null `argsTemplate`. `Launch()` will fail at runtime with empty string replace. Defense-in-depth validation missing.
|
||||
**Fix**: Add `if (string.IsNullOrWhiteSpace(argsTemplate) || !argsTemplate.Contains("{zip}")) throw ...;`
|
||||
|
||||
### 4. ZIP launch doesn't validate file exists on disk
|
||||
**Location**: `InputController.cs:185-196`
|
||||
**Problem**: `PatchPath` or `RootPath` used directly without checking `File.Exists()`. Overlay logic should prevent this, but no defense-in-depth.
|
||||
**Fix**: Validate `File.Exists(zipToLaunch)` before calling `_hatariLauncher.Launch()`.
|
||||
|
||||
## Documentation Inconsistencies
|
||||
|
||||
### 5. `Hatari.ConfigFile` required status unclear
|
||||
**Location**: `README.md:96` vs `LauncherApp.cs:67-68`
|
||||
**Conflict**: README marks `Hatari.ConfigFile` as required (✅). Code allows empty string and only validates if non-empty. If template uses `{cfg}` but ConfigFile empty → broken args.
|
||||
**Resolution**: ConfigFile is now optional. When empty, the bundled `MarcerGameDvd-Hatari.cfg` (shipped in the release) is used automatically. README updated to reflect this. ✅ Fixed
|
||||
|
||||
### 6. `RightArrow` key binding missing from AGENTS.md requirements
|
||||
**Location**: `AGENTS.md:57-58` vs `InputController.cs:172-173`
|
||||
**Conflict**: AGENTS.md lists only Arrow up/down, Enter, Backspace, ESC, PageUp/Down, `?`. Code also handles `ConsoleKey.RightArrow` as alias for Enter (open folder/launch ZIP). README correctly documents it.
|
||||
**Resolution**: Update AGENTS.md to include RightArrow.
|
||||
|
||||
### 7. `ShowModalUntilReturnReleased` race condition on resize
|
||||
**Location**: `ProgramHelpers.cs:62-98`
|
||||
**Problem**: Captures `lastRow = AvailableLines` at start. If console resizes during the modal wait loop, the clear writes to stale row position.
|
||||
**Fix**: Re-read `AvailableLines` before clearing, or lock console during modal (not easily possible).
|
||||
|
||||
## Code Quality / Minor
|
||||
|
||||
### 8. `ProgramHelpers.ShowConsoleMessage` signature could use `params`
|
||||
**Location**: `ProgramHelpers.cs:110`
|
||||
**Current**: `public static void ShowConsoleMessage(string[] lines, ...)`
|
||||
**Called as**: `ShowConsoleMessage([...], ...)` (collection expression)
|
||||
**Suggestion**: Change to `params string[] lines` for more idiomatic usage.
|
||||
|
||||
### 9. `OverlayDirectoryBrowser` patch path computation order
|
||||
**Location**: `OverlayDirectoryBrowser.cs:47-50`
|
||||
**Observation**: Path traversal protection (`IsSubPathOf`) runs after `Path.Combine` but before `Directory.Exists`. Logic appears correct, but worth verifying with `rel` containing `..` when patch layer enabled.
|
||||
|
||||
---
|
||||
|
||||
## Fixed / Non-Issues (Verified)
|
||||
|
||||
| Item | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `demo.sh` location | ✅ OK | Exists at repo root, README link correct |
|
||||
| Magic strings centralized | ✅ Done | `FavoritesRootName`, `DefaultFileName`, `DefaultTitle` |
|
||||
| Scroll fractions as constants | ✅ Done | `BottomScrollFraction`, `TopScrollFraction` |
|
||||
| `AvailableLines` helper | ✅ Done | `ProgramHelpers.AvailableLines` |
|
||||
| Colors configurable | ✅ Done | `Colors` section in config |
|
||||
| Win32 constants documented | ✅ Done | Marked "intentionally hardcoded" |
|
||||
| `Hatari.ConfigFile` optional with bundled fallback | ✅ Done | Empty ConfigFile falls back to bundled `MarcerGameDvd-Hatari.cfg` |
|
||||
| `FavoritesService.Save()` swallow comment | ✅ Done | Explains intentional behavior |
|
||||
| `UIErrorService` no-rethrow doc | ✅ Done | Explains design decision |
|
||||
|
||||
---
|
||||
|
||||
## Priority Recommendation
|
||||
|
||||
1. **Fix #1 (ArgsTemplate `{cfg}`)** - ✅ Fixed: empty ConfigFile now falls back to bundled config
|
||||
2. **Fix #2 (RedrawEntry bounds)** - Potential crash on resize
|
||||
3. **Fix #3 (HatariLauncher validation)** - ✅ Done (per BUGS.md Fixed table)
|
||||
4. **Fix #4 (ZIP path validation)** - User-facing robustness
|
||||
5. **Sync #5 & #6 (docs)** - ✅ Done: ConfigFile documented as optional with fallback
|
||||
@@ -4,21 +4,18 @@ Ziel: Das Repo aufräumen (Doku, Code, Config)
|
||||
|
||||
## 1. Code-Qualität – Schwächen beheben (harmlos, rein refaktoriell)
|
||||
|
||||
- [ ] **LauncherApp.cs zerlegen:** Die ~300-Zeilen-Klasse in separate Klassen aufteilen
|
||||
(z.B. `InputController`/`KeyHandler` für den switch-Block, Lifecycle bleibt in LauncherApp).
|
||||
- [x] **LauncherApp.cs zerlegen:** Die ~300-Zeilen-Klasse in separate Klassen aufteilen
|
||||
(InputController.cs extrahiert mit Key-Handling + ReloadGameEntries; Lifecycle & Main-Loop bleiben in AppHost).
|
||||
Ziel: keine Funktionsänderung, nur Struktur.
|
||||
- [ ] **Magische Strings entfernen / zentralisieren (nur im Code, siehe 2c):**
|
||||
- [ ] Virtueller Ordnername `"Favorites"` (LauncherApp.cs) → gemeinsame Konstante (nicht konfigurierbar).
|
||||
- [ ] Dateiname `"favorites.txt"` (LauncherApp.cs) → Konstante in FavoritesService (nicht konfigurierbar).
|
||||
- [ ] Console-Titel `"Marcer GameDVD Launcher"` (Program.cs) → Konstante (nicht konfigurierbar).
|
||||
- [ ] Default-ArgsTemplate `-c "{cfg}" --disk-a "{zip}"` (LauncherApp.cs) → redundanten Fallback
|
||||
entfernen; Config liefert den Template (ist schon in der example definiert, Validation `{zip}` existiert).
|
||||
- [ ] **Scroll-Trigger magische Zahlen** (NavigationController.cs: `2/3`, `1/3`) in benannte Konstanten
|
||||
(z.B. `BottomScrollFraction = 2f/3f`) mit Kommentar aufzählen.
|
||||
- [ ] Redundanz beseitigen: `RedrawEntry` hat unnötige `maxRow`-Logik; `EnsureCacheForRow` ungenau →
|
||||
klarer formulieren.
|
||||
- [ ] **Fehler-Schlucken besprechen:** `FavoritesService.Save()` (catch leer) und `UIErrorService` –
|
||||
entweder Kommentar ergänzen („bewusst still") oder Rückgabewert einführen.
|
||||
- [x] **Magische Strings entfernen / zentralisieren (nur im Code, siehe 2c):**
|
||||
- [x] Virtueller Ordnername `"Favorites"` → `FavoritesService.FavoritesRootName` Konstante (nicht konfigurierbar).
|
||||
- [x] Dateiname `"favorites.txt"` → `FavoritesService.DefaultFileName` Konstante (nicht konfigurierbar).
|
||||
- [x] Console-Titel `"Marcer GameDVD Launcher"` (Program.cs) → `DefaultTitle` Konstante (nicht konfigurierbar).
|
||||
- [x] Default-ArgsTemplate `-c "{cfg}" --disk-a "{zip}"` (LauncherApp.cs) → redundanten Fallback entfernt; Config liefert das Template (Validation `{zip}` existiert schon).
|
||||
- [x] **Scroll-Trigger magische Zahlen** (NavigationController.cs: `2/3`, `1/3`) in benannte Konstanten
|
||||
(`BottomScrollFraction = 2.0/3.0`, `TopScrollFraction = 1.0/3.0`) mit Kommentar aufzählen.
|
||||
- [x] Redundanz beseitigen: `RedrawEntry` — überflüssige `maxRow`-Logik entfernt, `EnsureCacheForRow` durch `EnsureCache` ersetzt (klare Formulierung).
|
||||
- [x] **Fehler-Schlucken besprechen:** `FavoritesService.Save()` (catch leer) — Kommentar korrigiert ("bewusst still", erklärt warum Persistence-Fehler nicht zum Absturz führen); `UIErrorService` — DocComment erweitert (Fehler werden gezeigt, nicht regeworfen, App bleibt im Loop).
|
||||
- [ ] (Optional) Testprojekt hinzufügen für NavigationController & OverlayDirectoryBrowser –
|
||||
vorab mit Nutzer klären, da Policy bisher keine Tests vorsieht.
|
||||
|
||||
@@ -33,29 +30,25 @@ Ziel: Das Repo aufräumen (Doku, Code, Config)
|
||||
- Ziel: Farben (Foreground pro Entry-Typ + Selection-Farben) in `launcher.config.json` konfigurierbar
|
||||
machen (**Entscheidung: ja, konfigurierbar**), mit Default-Fallback auf heutige Werte.
|
||||
|
||||
- [ ] `AppConfig` erweitern: neuen Abschnitt z.B. `"Colors"` hinzufügen:
|
||||
- [ ] POCO `AppColorConfig` mit `ConsoleColor`-Werten (als String, z.B. `"Yellow"`):
|
||||
- FolderBoth, FolderPatchOnly, FolderRootOnly,
|
||||
ZipBoth, ZipRootOnly, ZipPatchOnly,
|
||||
SelectedForeground, SelectedBackground, VirtualEntry(vorab optional).
|
||||
- [ ] Deserialisierung per `Enum.TryParse<ConsoleColor>` + Default-Fallback.
|
||||
- [ ] `MenuRenderer` bekommt optional passende `AppColorConfig` (Konstruktor-Injection);
|
||||
`GetColorForEntry`/`GetColors` nutzen Config statt Konstanten.
|
||||
- [ ] Falls Config-Werte fehlen → heutiges Verhalten beibehalten (fallback).
|
||||
- [ ] Doku synchronisieren: README + AGENTS.md Farbtabellen auf Config-Felder verlinken.
|
||||
- [ ] Beispielwerte ins `launcher.config.example.json` aufnehmen.
|
||||
- [x] `AppConfig` erweitern: neuen Abschnitt `"Colors"` hinzugefügt:
|
||||
- [x] POCO `AppColorConfig` mit `ConsoleColor`-Werten (als String, z.B. `"Yellow"`):
|
||||
FolderBoth, FolderPatchOnly, FolderRootOnly, ZipBoth, ZipRootOnly, ZipPatchOnly,
|
||||
SelectedForeground, SelectedBackground, VirtualEntry.
|
||||
- [x] Deserialisierung per `Enum.TryParse<ConsoleColor>` + Default-Fallback (manuell via `ParseAppColors`/`ParseColorField`).
|
||||
- [x] `MenuRenderer` bekommt `AppColorConfig` via Konstruktor-Injection; `GetColorForEntry`/`GetColors` nutzen Config statt Konstanten.
|
||||
- [x] Falls Config-Werte fehlen → heutiges Verhalten beibehalten (fallback).
|
||||
- [x] Doku synchronisiert: README + AGENTS.md Farbtabellen auf Config-Felder verlinkt.
|
||||
- [x] Beispielwerte ins `launcher.config.example.json` aufgenommen.
|
||||
|
||||
### 2b. Benutzer-Config (User-Config) – entschieden
|
||||
|
||||
Ziel: die echte lokale Config gehört NICHT ins Repo, sondern bleibt lokal.
|
||||
|
||||
- [ ] **Entscheidung getroffen (Variante 1):** `launcher.config.json` bleibt im Build-Output/EXE-Ordner
|
||||
(lokal, gitignored). Die `launcher.config.example.json` wird beim Release mitgeliefert und der
|
||||
Nutzer passt sie sich manuell an → kopieren zu `launcher.config.json`.
|
||||
- [ ] KEINE zusätzliche Suchreihenfolge (`%APPDATA%`, `~/.config`) implementieren.
|
||||
- [ ] KEIN CLI-Parameter `--config` einführen.
|
||||
- [ ] `.gitignore` entsprechend ergänzen (echte `launcher.config.json` + `favorites.txt` werden nie committet).
|
||||
- [ ] README: Abschnitt „Configuration" beschreibt nur das manuelle Kopieren der example.
|
||||
- [x] **Entscheidung getroffen (Variante 1):** `launcher.config.json` bleibt im Build-Output/EXE-Ordner (lokal, gitignored). Die `launcher.config.example.json` wird beim Release mitgeliefert und der Nutzer kopiert sie manuell zu `launcher.config.json`.
|
||||
- [x] KEINE zusätzliche Suchreihenfolge (`%APPDATA%`, `~/.config`) implementiert — Pfad ist ausschließlich `AppContext.BaseDirectory`.
|
||||
- [x] KEIN CLI-Parameter `--config` eingeführt.
|
||||
- [x] `.gitignore` ergänzt: `launcher.config.json` und `favorites.txt` werden nie committet.
|
||||
- [x] README: Abschnitt „Configuration" beschreibt manuelles Kopieren der example.
|
||||
|
||||
### 2c. Platzhalterwerte zentralisieren (sehr detailliert)
|
||||
|
||||
@@ -80,18 +73,17 @@ externe Anpassungsmöglichkeit dafür.
|
||||
|
||||
**Verbindliche To-dos für 2c:**
|
||||
|
||||
- [ ] Konstanten/Helfer einführen für: Favorites-Ordnername, `favorites.txt`, Konsolen-Titel,
|
||||
Scroll-Anteile, `AvailableLines`-Helfer.
|
||||
- [ ] Default-ArgsTemplate-Fallback entfernen; Config muss `ArgsTemplate` immer liefern.
|
||||
- [ ] Alle vorkommenden String-Literale auf die neuen Konstanten zurückführen (kein doppeltes `"Favorites"` mehr).
|
||||
- [ ] Kommentare ergänzen, die erklären, WARUM der Wert fest ist (z.B. `WindowHeight-1` als Policy).
|
||||
- [ ] Win32-Konstanten (Punkt 7) NICHT anfassen, nur per Kommentar als „bewusst hartkodiert" markieren.
|
||||
- [ ] README/agents nicht um diese rein implementativen Werte erweitern (keine Config-Felder dokumentieren);
|
||||
- [x] Konstanten/Helfer eingeführt für: `FavoritesRootName` (FavoritesService), `DefaultFileName` (FavoritesService), `DefaultTitle` (Program), `BottomScrollFraction`/`TopScrollFraction` (NavigationController), `AvailableLines`-Helfer (ProgramHelpers).
|
||||
- [x] Default-ArgsTemplate-Fallback entfernt; Config muss `ArgsTemplate` immer liefern (Validation existiert bereits).
|
||||
- [x] Alle String-Literale auf Konstanten zurückgeführt (kein doppeltes `"Favorites"` mehr — nur die Konstantendefinition).
|
||||
- [x] Kommentare ergänzt, die erklären, WARUM Werte fest sind (`AvailableLines` als Policy, Win32-Konstanten als API-konstant, Scroll-Anteile als feste Navigation).
|
||||
- [x] Win32-Konstanten (Punkt 7) NICHT angefasst, nur per Kommentar als „bewusst hartkodiert" markiert.
|
||||
- [x] README/agents nicht um diese rein implementativen Werte erweitert (keine Config-Felder dokumentiert);
|
||||
Doku bleibt bei nutzersichtbaren, konfigurierbaren Werten (Farben, Titel, RootDir/Patch/Hatari).
|
||||
|
||||
---
|
||||
|
||||
## 4. Abschlusskriterien
|
||||
|
||||
- [ ] Commit mit aussagekräftiger Message (nur echter Autor, kein Co-Author).
|
||||
- [ ] Nach Doku- und Code-Änderungen: `scripts/build.cmd` (Windows) bzw. `scripts/build.sh` (Linux) läuft fehlerfrei.
|
||||
- [x] Commit mit aussagekräftiger Message (nur echter Autor, kein Co-Author).
|
||||
- [x] Nach Doku- und Code-Änderungen: `scripts/build.sh` (Linux) läuft fehlerfrei.
|
||||
|
||||
@@ -16,7 +16,9 @@ Built for the [Marcer GameDVD](https://www.facebook.com/groups/360493904888475/)
|
||||
|
||||
### Color Scheme
|
||||
|
||||
| Entry | Label | Color | Meaning |
|
||||
Colors are configurable via the `Colors` section in `launcher.config.json` (see [Configuration](#-configuration)). Defaults are shown below:
|
||||
|
||||
| Entry | Label | Default Color | Meaning |
|
||||
|---|---|---|---|
|
||||
| Folder in both layers | `[BOTH]` | Yellow | Exists in main + patch |
|
||||
| Patch-only folder | `[PTCH]` | DarkYellow | Only in patch layer |
|
||||
@@ -24,6 +26,8 @@ Built for the [Marcer GameDVD](https://www.facebook.com/groups/360493904888475/)
|
||||
| ZIP in both layers | `[BOTH]` | Green | Exists in main + patch |
|
||||
| Main-only ZIP | `[ROOT]` | DarkGreen | Only in main layer |
|
||||
| Patch-only ZIP | `[PTCH]` | Magenta | Only in patch layer |
|
||||
| Selected entry | — | Black on DarkCyan | Highlighted entry |
|
||||
| Virtual entry (Favorites) | — | White | Pseudo-folder |
|
||||
|
||||
---
|
||||
|
||||
@@ -42,9 +46,7 @@ Download the ZIP for your platform from the [Releases](https://github.com/anomal
|
||||
### 💻 System Requirements
|
||||
|
||||
- **.NET Runtime 10** or later ([download](https://dotnet.microsoft.com/download/dotnet/10.0))
|
||||
- **Hatari Emulator** with a working configuration file
|
||||
- Windows: native Hatari
|
||||
- Linux / macOS: Hatari via Wine or native build
|
||||
- **Hatari Emulator** (Windows native, or Linux/macOS via Wine or native build) — a Hatari configuration file (`MarcerGameDvd-Hatari.cfg`) is bundled with the launcher and used automatically when `Hatari.ConfigFile` is empty.
|
||||
|
||||
### ⚡ Quick Start
|
||||
|
||||
@@ -67,8 +69,19 @@ The launcher reads `launcher.config.json` from the same directory as the executa
|
||||
"PatchDirectory": "C:\\Games\\Hatari\\PATCH",
|
||||
"Hatari": {
|
||||
"Executable": "C:\\Tools\\hatari\\hatari.exe",
|
||||
"ConfigFile": "C:\\Tools\\hatari\\hatari-st.cfg",
|
||||
"ConfigFile": "",
|
||||
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
|
||||
},
|
||||
"Colors": {
|
||||
"FolderBoth": "Yellow",
|
||||
"FolderPatchOnly": "DarkYellow",
|
||||
"FolderRootOnly": "Gray",
|
||||
"ZipBoth": "Green",
|
||||
"ZipRootOnly": "DarkGreen",
|
||||
"ZipPatchOnly": "Magenta",
|
||||
"SelectedForeground": "Black",
|
||||
"SelectedBackground": "DarkCyan",
|
||||
"VirtualEntry": "White"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -78,8 +91,30 @@ The launcher reads `launcher.config.json` from the same directory as the executa
|
||||
| `RootDirectory` | ✅ | Game root folder. Navigation never leaves this directory. |
|
||||
| `PatchDirectory` | ❌ | Optional overlay/patch directory merged at runtime. |
|
||||
| `Hatari.Executable` | ✅ | Path to the Hatari executable. Validated at startup. |
|
||||
| `Hatari.ConfigFile` | ✅ | Path to the Hatari configuration file. |
|
||||
| `Hatari.ConfigFile` | ❌ | Path to a Hatari configuration file. If empty, the bundled `MarcerGameDvd-Hatari.cfg` (shipped with the launcher) is used automatically. |
|
||||
| `Hatari.ArgsTemplate` | ✅ | Argument template. Must contain `{zip}`, optionally `{cfg}`. |
|
||||
| `Colors` | ❌ | Optional color overrides. See [Color Scheme](#-color-scheme) below. Missing or invalid values fall back to defaults. |
|
||||
|
||||
**Notes:**
|
||||
- Relative paths are resolved relative to the executable's directory.
|
||||
- Include quotes around `{cfg}` and `{zip}` in the template if your paths contain spaces.
|
||||
- After editing `launcher.config.json`, restart the application.
|
||||
|
||||
### 🎨 Color Scheme
|
||||
|
||||
Colors are fully configurable via the `Colors` section of `launcher.config.json`. Each field accepts a [ConsoleColor](https://learn.microsoft.com/dotnet/api/system.consolecolor) name (case-insensitive). Omitting the entire `Colors` section — or any individual field — falls back to the built-in defaults:
|
||||
|
||||
| Field | Default | Applies to |
|
||||
|---|---|---|
|
||||
| `FolderBoth` | Yellow | Folders in both layers |
|
||||
| `FolderPatchOnly` | DarkYellow | Folders in patch only |
|
||||
| `FolderRootOnly` | Gray | Folders in root only |
|
||||
| `ZipBoth` | Green | ZIPs in both layers |
|
||||
| `ZipRootOnly` | DarkGreen | ZIPs in root only |
|
||||
| `ZipPatchOnly` | Magenta | ZIPs in patch only |
|
||||
| `SelectedForeground` | Black | Foreground for the highlighted entry |
|
||||
| `SelectedBackground` | DarkCyan | Background for the highlighted entry |
|
||||
| `VirtualEntry` | White | Virtual entries (e.g. the Favorites pseudo-folder) |
|
||||
|
||||
**Notes:**
|
||||
- Relative paths are resolved relative to the executable's directory.
|
||||
@@ -183,6 +218,16 @@ This builds the project, generates a structured `.demo/` directory with sample f
|
||||
Releases are automated via GitHub Actions (`.github/workflows/release.yml`).
|
||||
|
||||
**To create a release:**
|
||||
|
||||
You can use the `/create-release` command (via opencode) for a fully automated flow:
|
||||
|
||||
```text
|
||||
/create-release 1.0.0
|
||||
```
|
||||
|
||||
This command validates the version, checks for uncommitted changes, generates a release summary from recent commits, creates a release commit with the summary as the commit message, tags it, and pushes — all in one step. The GitHub Action then builds platform ZIPs and creates the GitHub Release.
|
||||
|
||||
**Manual release:**
|
||||
1. Ensure `README.md` and `AGENTS.md` are up to date.
|
||||
2. Commit all changes.
|
||||
3. Tag and push:
|
||||
|
||||
@@ -4,6 +4,25 @@
|
||||
namespace MarcerGameDvdLauncher
|
||||
{
|
||||
// Configuration POCOs separated into their own file for clarity
|
||||
|
||||
/// <summary>
|
||||
/// Color configuration for the menu renderer.
|
||||
/// All properties have default values matching the original hardcoded scheme,
|
||||
/// so omitting any value (or the entire "Colors" section) preserves existing behaviour.
|
||||
/// </summary>
|
||||
public class AppColorConfig
|
||||
{
|
||||
public ConsoleColor FolderBoth { get; set; } = ConsoleColor.Yellow;
|
||||
public ConsoleColor FolderPatchOnly { get; set; } = ConsoleColor.DarkYellow;
|
||||
public ConsoleColor FolderRootOnly { get; set; } = ConsoleColor.Gray;
|
||||
public ConsoleColor ZipBoth { get; set; } = ConsoleColor.Green;
|
||||
public ConsoleColor ZipRootOnly { get; set; } = ConsoleColor.DarkGreen;
|
||||
public ConsoleColor ZipPatchOnly { get; set; } = ConsoleColor.Magenta;
|
||||
public ConsoleColor SelectedForeground { get; set; } = ConsoleColor.Black;
|
||||
public ConsoleColor SelectedBackground { get; set; } = ConsoleColor.DarkCyan;
|
||||
public ConsoleColor VirtualEntry { get; set; } = ConsoleColor.White;
|
||||
}
|
||||
|
||||
public class AppHatariConfig
|
||||
{
|
||||
public string? Executable { get; set; }
|
||||
@@ -16,5 +35,9 @@ namespace MarcerGameDvdLauncher
|
||||
public string? RootDirectory { get; set; }
|
||||
public string? PatchDirectory { get; set; }
|
||||
public AppHatariConfig? Hatari { get; set; }
|
||||
// Ignored during JSON deserialization — parsed manually in LoadConfiguration so that
|
||||
// invalid color strings fall back to defaults instead of throwing.
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public AppColorConfig? Colors { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ namespace MarcerGameDvdLauncher
|
||||
// Manages loading, saving and querying favorite ZIP paths.
|
||||
public class FavoritesService
|
||||
{
|
||||
// Virtual folder name displayed at the root when favorites exist.
|
||||
// Not configurable — fixed UI element.
|
||||
public const string FavoritesRootName = "Favorites";
|
||||
|
||||
// Filename used for persisting favorites to disk.
|
||||
// Not configurable — fixed persistence file.
|
||||
public const string DefaultFileName = "favorites.txt";
|
||||
|
||||
private readonly string _filePath;
|
||||
// Use a SortedSet so favorites are kept in sorted order in memory.
|
||||
private SortedSet<string> _favorites = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -87,7 +95,11 @@ namespace MarcerGameDvdLauncher
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Let callers surface errors (we swallow here to avoid throwing on write failures during UI operations)
|
||||
// Swallowed intentionally ("bewusst still"): a persistence failure
|
||||
// (e.g. disk full, read-only directory) must not crash or interrupt
|
||||
// the UI. The in-memory state is still updated so the user sees
|
||||
// immediate feedback; only the on-disk write is lost. On next
|
||||
// application start the favorites reflect the last successful save.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ namespace MarcerGameDvdLauncher
|
||||
{
|
||||
public class HatariLauncher
|
||||
{
|
||||
// The Hatari configuration file that is shipped with the launcher.
|
||||
// When Hatari.ConfigFile is not set in launcher.config.json, this file
|
||||
// (resolved relative to the executable directory) is used automatically.
|
||||
public const string DefaultConfigFile = "MarcerGameDvd-Hatari.cfg";
|
||||
|
||||
private readonly string _exePath;
|
||||
private readonly string _cfgPath;
|
||||
private readonly string _argsTemplate;
|
||||
@@ -18,6 +23,10 @@ namespace MarcerGameDvdLauncher
|
||||
if (!File.Exists(exePath))
|
||||
throw new ArgumentException($"Hatari executable not found: {exePath}", nameof(exePath));
|
||||
|
||||
// Validate argsTemplate
|
||||
if (string.IsNullOrWhiteSpace(argsTemplate) || !argsTemplate.Contains("{zip}"))
|
||||
throw new ArgumentException("Hatari.ArgsTemplate must contain the {zip} placeholder.", nameof(argsTemplate));
|
||||
|
||||
_exePath = exePath;
|
||||
_cfgPath = cfgPath;
|
||||
_argsTemplate = argsTemplate;
|
||||
@@ -33,14 +42,25 @@ namespace MarcerGameDvdLauncher
|
||||
throw new ArgumentException("ZIP archive path must not be empty.", nameof(zipFilePath));
|
||||
try
|
||||
{
|
||||
string args = _argsTemplate.Replace("{cfg}", _cfgPath).Replace("{zip}", zipFilePath);
|
||||
// Build arguments: only include config section if cfgPath is not empty
|
||||
string args = _argsTemplate;
|
||||
if (!string.IsNullOrWhiteSpace(_cfgPath))
|
||||
{
|
||||
args = args.Replace("{cfg}", _cfgPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Remove {cfg} placeholder entirely if config file is empty
|
||||
args = args.Replace("{cfg}", string.Empty);
|
||||
}
|
||||
args = args.Replace("{zip}", zipFilePath);
|
||||
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = _exePath,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(_exePath) ?? string.Empty
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
System.Diagnostics.Process.Start(psi);
|
||||
// Show a modal indicating the emulator was started and wait until
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace MarcerGameDvdLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles user input (key events) and the associated navigation / drawing logic.
|
||||
/// Extracted from AppHost so that LauncherApp stays focused on lifecycle management.
|
||||
/// No functional change - all key-handling behaviour is preserved exactly.
|
||||
/// </summary>
|
||||
internal class InputController
|
||||
{
|
||||
private readonly OverlayDirectoryBrowser _directoryBrowser;
|
||||
private readonly MenuRenderer _menuRenderer;
|
||||
private readonly NavigationController _navigationController;
|
||||
private readonly HatariLauncher _hatariLauncher;
|
||||
private readonly FavoritesService _favoritesService;
|
||||
private readonly UIErrorService _errorService;
|
||||
|
||||
private List<GameEntry> _gameEntries = new();
|
||||
|
||||
public IReadOnlyList<GameEntry> GameEntries => _gameEntries;
|
||||
|
||||
public InputController(OverlayDirectoryBrowser directoryBrowser, MenuRenderer menuRenderer,
|
||||
NavigationController navigationController, HatariLauncher hatariLauncher,
|
||||
FavoritesService favoritesService, UIErrorService errorService)
|
||||
{
|
||||
_directoryBrowser = directoryBrowser;
|
||||
_menuRenderer = menuRenderer;
|
||||
_navigationController = navigationController;
|
||||
_hatariLauncher = hatariLauncher;
|
||||
_favoritesService = favoritesService;
|
||||
_errorService = errorService;
|
||||
}
|
||||
|
||||
private bool IsFavorite(GameEntry e)
|
||||
{
|
||||
return e.Kind == EntryKind.Zip
|
||||
? _favoritesService.IsFavorite(e.InPatch ? e.PatchPath : e.RootPath)
|
||||
: false;
|
||||
}
|
||||
|
||||
private void DrawMenu(int availableLines)
|
||||
{
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset,
|
||||
_navigationController.SelectedIndex, availableLines, IsFavorite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates scroll offset and redraws the full menu. Called on initial load and console resize.
|
||||
/// </summary>
|
||||
public void RefreshView(int availableLines)
|
||||
{
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines);
|
||||
DrawMenu(availableLines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads entries for the current directory (or the virtual Favorites folder).
|
||||
/// </summary>
|
||||
public void ReloadGameEntries()
|
||||
{
|
||||
try
|
||||
{
|
||||
// If we are at the virtual Favorites folder, produce the flat list from the favorites service
|
||||
if (string.Equals(_navigationController.CurrentRelativePath, FavoritesService.FavoritesRootName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var favs = _favoritesService.GetAll();
|
||||
_gameEntries = new List<GameEntry>();
|
||||
foreach (var p in favs)
|
||||
{
|
||||
_gameEntries.Add(new GameEntry
|
||||
{
|
||||
Name = Path.GetFileName(p),
|
||||
Kind = EntryKind.Zip,
|
||||
InRoot = true,
|
||||
InPatch = false,
|
||||
RootPath = p,
|
||||
PatchPath = string.Empty,
|
||||
IsVirtual = false
|
||||
});
|
||||
}
|
||||
_navigationController.SetEntriesCount(_gameEntries.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise use the overlay directory browser for normal folders
|
||||
_gameEntries = _directoryBrowser.GetEntries(_navigationController.CurrentRelativePath);
|
||||
|
||||
// If we are at the root and there are favorites, prepend a virtual Favorites folder
|
||||
if (string.IsNullOrEmpty(_navigationController.CurrentRelativePath) && _favoritesService.HasFavorites())
|
||||
{
|
||||
var virtualEntry = new GameEntry
|
||||
{
|
||||
Name = FavoritesService.FavoritesRootName,
|
||||
Kind = EntryKind.Directory,
|
||||
InRoot = true,
|
||||
InPatch = false,
|
||||
RootPath = string.Empty,
|
||||
PatchPath = string.Empty,
|
||||
IsVirtual = true
|
||||
};
|
||||
_gameEntries.Insert(0, virtualEntry);
|
||||
}
|
||||
|
||||
_navigationController.SetEntriesCount(_gameEntries.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Show the error to the user and continue with an empty list
|
||||
_errorService.ShowError(ex.Message);
|
||||
_gameEntries = new List<GameEntry>();
|
||||
_navigationController.SetEntriesCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a key press. Returns true if the application should exit.
|
||||
/// </summary>
|
||||
public bool HandleKey(ConsoleKeyInfo key, int availableLines)
|
||||
{
|
||||
if (key.KeyChar == '?')
|
||||
{
|
||||
_menuRenderer.ShowHelpBox(availableLines);
|
||||
Console.ReadKey(intercept: true);
|
||||
_menuRenderer.InvalidateCache();
|
||||
DrawMenu(availableLines);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (key.Key)
|
||||
{
|
||||
case ConsoleKey.UpArrow:
|
||||
{
|
||||
int previousSelectedIndex = _navigationController.SelectedIndex;
|
||||
bool didScroll = _navigationController.MoveUp(_gameEntries, availableLines);
|
||||
if (didScroll)
|
||||
{
|
||||
DrawMenu(availableLines);
|
||||
}
|
||||
else
|
||||
{
|
||||
_menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndex, previousSelectedIndex - _navigationController.ScrollOffset, false, availableLines, IsFavorite);
|
||||
_menuRenderer.RedrawEntry(_gameEntries, _navigationController.SelectedIndex, _navigationController.SelectedIndex - _navigationController.ScrollOffset, true, availableLines, IsFavorite);
|
||||
}
|
||||
// flush input to avoid key repeat
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.DownArrow:
|
||||
{
|
||||
int previousSelectedIndex = _navigationController.SelectedIndex;
|
||||
bool didScroll = _navigationController.MoveDown(_gameEntries, availableLines);
|
||||
if (didScroll)
|
||||
{
|
||||
DrawMenu(availableLines);
|
||||
}
|
||||
else
|
||||
{
|
||||
_menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndex, previousSelectedIndex - _navigationController.ScrollOffset, false, availableLines, IsFavorite);
|
||||
_menuRenderer.RedrawEntry(_gameEntries, _navigationController.SelectedIndex, _navigationController.SelectedIndex - _navigationController.ScrollOffset, true, availableLines, IsFavorite);
|
||||
}
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.Enter:
|
||||
case ConsoleKey.RightArrow:
|
||||
{
|
||||
var oldRelativePath = _navigationController.CurrentRelativePath;
|
||||
var isDirectory = _gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Directory;
|
||||
_navigationController.HandleEnter(_gameEntries);
|
||||
if (isDirectory && oldRelativePath != _navigationController.CurrentRelativePath)
|
||||
{
|
||||
ReloadGameEntries();
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines);
|
||||
}
|
||||
DrawMenu(availableLines);
|
||||
// Only start a ZIP if NOT switching to a directory
|
||||
if (!isDirectory && _gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Zip)
|
||||
{
|
||||
string zipToLaunch = _gameEntries[_navigationController.SelectedIndex].InPatch ? _gameEntries[_navigationController.SelectedIndex].PatchPath : _gameEntries[_navigationController.SelectedIndex].RootPath;
|
||||
try
|
||||
{
|
||||
_hatariLauncher.Launch(zipToLaunch);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorService.ShowError(ex.Message);
|
||||
}
|
||||
}
|
||||
// flush input to avoid leftover key events after an enter/navigation
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.Backspace:
|
||||
case ConsoleKey.LeftArrow:
|
||||
{
|
||||
_navigationController.GoUpDirectory();
|
||||
ReloadGameEntries();
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines);
|
||||
DrawMenu(availableLines);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.PageDown:
|
||||
{
|
||||
_navigationController.PageDown(_gameEntries, availableLines);
|
||||
DrawMenu(availableLines);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.PageUp:
|
||||
{
|
||||
_navigationController.PageUp(_gameEntries, availableLines);
|
||||
DrawMenu(availableLines);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.Multiply:
|
||||
case ConsoleKey.Oem8:
|
||||
{
|
||||
// Toggle favorite for selected ZIP (handles numpad * and some layouts)
|
||||
if (_gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Zip)
|
||||
{
|
||||
var ge = _gameEntries[_navigationController.SelectedIndex];
|
||||
string path = ge.InPatch ? ge.PatchPath : ge.RootPath;
|
||||
try
|
||||
{
|
||||
_favoritesService.Toggle(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorService.ShowError("Failed to toggle favorite: " + ex.Message);
|
||||
}
|
||||
// Redraw the whole menu so the '*' marker updates immediately
|
||||
DrawMenu(availableLines);
|
||||
}
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.Escape:
|
||||
case ConsoleKey.Q:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,251 +63,153 @@ namespace MarcerGameDvdLauncher
|
||||
if (!File.Exists(cfg.Hatari.Executable))
|
||||
throw new InvalidOperationException($"Hatari executable not found: {cfg.Hatari.Executable}");
|
||||
|
||||
// Also validate the Hatari config file (if provided)
|
||||
if (!string.IsNullOrWhiteSpace(cfg.Hatari.ConfigFile) && !File.Exists(cfg.Hatari.ConfigFile))
|
||||
// If no Hatari config file was specified, fall back to the bundled
|
||||
// MarcerGameDvd-Hatari.cfg that ships with the launcher.
|
||||
// Use current working directory to allow running from different locations
|
||||
if (string.IsNullOrWhiteSpace(cfg.Hatari.ConfigFile))
|
||||
{
|
||||
cfg.Hatari.ConfigFile = Path.Combine(Directory.GetCurrentDirectory(), HatariLauncher.DefaultConfigFile);
|
||||
}
|
||||
|
||||
// Validate the Hatari config file (either user-specified or bundled fallback)
|
||||
if (!File.Exists(cfg.Hatari.ConfigFile))
|
||||
throw new InvalidOperationException($"Hatari configuration file not found: {cfg.Hatari.ConfigFile}");
|
||||
if (string.IsNullOrWhiteSpace(cfg.Hatari.ArgsTemplate) || !cfg.Hatari.ArgsTemplate.Contains("{zip}"))
|
||||
throw new InvalidOperationException("Hatari.ArgsTemplate must contain the {zip} placeholder.");
|
||||
|
||||
// Parse Colors section manually so invalid values fall back to defaults
|
||||
// instead of crashing the deserialization.
|
||||
cfg.Colors = ParseAppColors(json);
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// Parses the optional "Colors" JSON section into an AppColorConfig.
|
||||
// Each field is resolved with Enum.TryParse<ConsoleColor>; unparseable or
|
||||
// missing values silently fall back to the defaults defined in AppColorConfig.
|
||||
private static AppColorConfig ParseAppColors(string json)
|
||||
{
|
||||
var colors = new AppColorConfig();
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("Colors", out var colorsEl) && colorsEl.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
ParseColorField(colorsEl, "FolderBoth", v => colors.FolderBoth = v);
|
||||
ParseColorField(colorsEl, "FolderPatchOnly", v => colors.FolderPatchOnly = v);
|
||||
ParseColorField(colorsEl, "FolderRootOnly", v => colors.FolderRootOnly = v);
|
||||
ParseColorField(colorsEl, "ZipBoth", v => colors.ZipBoth = v);
|
||||
ParseColorField(colorsEl, "ZipRootOnly", v => colors.ZipRootOnly = v);
|
||||
ParseColorField(colorsEl, "ZipPatchOnly", v => colors.ZipPatchOnly = v);
|
||||
ParseColorField(colorsEl, "SelectedForeground", v => colors.SelectedForeground = v);
|
||||
ParseColorField(colorsEl, "SelectedBackground", v => colors.SelectedBackground = v);
|
||||
ParseColorField(colorsEl, "VirtualEntry", v => colors.VirtualEntry = v);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// On any JSON error, fall back to default colors (already set above)
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
private static void ParseColorField(System.Text.Json.JsonElement colorsEl, string name, Action<ConsoleColor> setter)
|
||||
{
|
||||
if (colorsEl.TryGetProperty(name, out var prop) && prop.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
var str = prop.GetString();
|
||||
if (Enum.TryParse<ConsoleColor>(str ?? string.Empty, ignoreCase: true, out var parsed))
|
||||
setter(parsed);
|
||||
// Invalid color names are silently ignored — defaults are preserved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internal host that keeps state previously stored in Program.cs
|
||||
// Internal host that keeps state and lifecycle management for the application.
|
||||
// Key-handling logic has been extracted into InputController; this class
|
||||
// focuses on component wiring, initialization and the main loop (resize detection + key polling).
|
||||
internal class AppHost(AppConfig cfg)
|
||||
{
|
||||
OverlayDirectoryBrowser? _directoryBrowser;
|
||||
MenuRenderer _menuRenderer = new MenuRenderer();
|
||||
NavigationController _navigationController = new NavigationController();
|
||||
List<GameEntry> _gameEntries = new List<GameEntry>();
|
||||
HatariLauncher? _hatariLauncher;
|
||||
readonly UIErrorService _errorService = new UIErrorService();
|
||||
FavoritesService? _favoritesService;
|
||||
private InputController? _inputController;
|
||||
private MenuRenderer? _menuRenderer;
|
||||
private HatariLauncher? _hatariLauncher;
|
||||
private int _currentAvailableLines;
|
||||
private int _currentWidth;
|
||||
|
||||
public void InitializeComponents()
|
||||
public void InitializeComponents()
|
||||
{
|
||||
var directoryBrowser = new OverlayDirectoryBrowser(cfg.RootDirectory ?? string.Empty, cfg.PatchDirectory ?? string.Empty);
|
||||
|
||||
// Initialize favorites service. Use PatchDirectory if present, otherwise exe dir fallback.
|
||||
string favPath;
|
||||
if (!string.IsNullOrWhiteSpace(cfg.PatchDirectory))
|
||||
{
|
||||
_directoryBrowser = new OverlayDirectoryBrowser(cfg.RootDirectory ?? string.Empty, cfg.PatchDirectory ?? string.Empty);
|
||||
// Initialize favorites service. Use PatchDirectory if present, otherwise exe dir fallback.
|
||||
string favPath;
|
||||
if (!string.IsNullOrWhiteSpace(cfg.PatchDirectory))
|
||||
{
|
||||
favPath = Path.Combine(cfg.PatchDirectory!, "favorites.txt");
|
||||
}
|
||||
else
|
||||
{
|
||||
favPath = Path.Combine(AppContext.BaseDirectory, "favorites.txt");
|
||||
}
|
||||
_favoritesService = new FavoritesService(favPath);
|
||||
try { _favoritesService.Load(); } catch { /* ignore load errors */ }
|
||||
try
|
||||
{
|
||||
_hatariLauncher = new HatariLauncher(cfg.Hatari?.Executable ?? throw new InvalidOperationException("Hatari.Executable not configured"), cfg.Hatari?.ConfigFile ?? string.Empty, cfg.Hatari?.ArgsTemplate ?? "-c \"{cfg}\" --disk-a \"{zip}\"");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ProgramHelpers.ShowConsoleMessage(["Hatari initialization error: " + ex.Message, "Press any key to exit."
|
||||
], ConsoleColor.Red);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
Console.CursorVisible = false;
|
||||
favPath = Path.Combine(cfg.PatchDirectory!, FavoritesService.DefaultFileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
favPath = Path.Combine(AppContext.BaseDirectory, FavoritesService.DefaultFileName);
|
||||
}
|
||||
var favoritesService = new FavoritesService(favPath);
|
||||
try { favoritesService.Load(); } catch { /* ignore load errors */ }
|
||||
|
||||
var errorService = new UIErrorService();
|
||||
try
|
||||
{
|
||||
// ArgsTemplate is validated in LoadConfiguration — it must always contain {zip}.
|
||||
// No hardcoded fallback is needed; the config file is the single source of truth.
|
||||
_hatariLauncher = new HatariLauncher(cfg.Hatari?.Executable ?? throw new InvalidOperationException("Hatari.Executable not configured"), cfg.Hatari?.ConfigFile ?? string.Empty, cfg.Hatari?.ArgsTemplate ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ProgramHelpers.ShowConsoleMessage(["Hatari initialization error: " + ex.Message, "Press any key to exit."
|
||||
], ConsoleColor.Red);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
_menuRenderer = new MenuRenderer(cfg.Colors);
|
||||
var navigationController = new NavigationController();
|
||||
|
||||
_inputController = new InputController(directoryBrowser, _menuRenderer, navigationController,
|
||||
_hatariLauncher!, favoritesService, errorService);
|
||||
|
||||
Console.CursorVisible = false;
|
||||
}
|
||||
|
||||
public void RunDirectoryNavigation()
|
||||
{
|
||||
_currentAvailableLines = ProgramHelpers.AvailableLines;
|
||||
_currentWidth = Console.WindowWidth;
|
||||
|
||||
_inputController!.ReloadGameEntries();
|
||||
_inputController.RefreshView(_currentAvailableLines);
|
||||
|
||||
bool exitRequested = false;
|
||||
ReloadGameEntries();
|
||||
int currentAvailableLines = Console.WindowHeight - 1;
|
||||
int currentWidth = Console.WindowWidth;
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
|
||||
var isFav = new Func<GameEntry, bool>(e => _favoritesService?.IsFavorite(e.Kind == EntryKind.Zip ? (e.InPatch ? e.PatchPath : e.RootPath) ?? string.Empty : string.Empty) ?? false);
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
while (!exitRequested)
|
||||
{
|
||||
// Reloads are performed explicitly when entering or leaving directories (Enter/Backspace)
|
||||
// Reloads are performed explicitly by InputController when entering/leaving directories
|
||||
// Do NOT hit the filesystem here on every loop iteration.
|
||||
|
||||
// detect a change in console height and/or width and redraw immediately
|
||||
int latestAvailableLines = Console.WindowHeight - 1;
|
||||
int latestAvailableLines = ProgramHelpers.AvailableLines;
|
||||
int latestWidth = Console.WindowWidth;
|
||||
if (latestAvailableLines != currentAvailableLines || latestWidth != currentWidth)
|
||||
{
|
||||
currentAvailableLines = latestAvailableLines;
|
||||
currentWidth = latestWidth;
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
}
|
||||
|
||||
// Only block if there's actually a key; otherwise allow resize detection
|
||||
if (!Console.KeyAvailable)
|
||||
{
|
||||
Thread.Sleep(50);
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
if (key.KeyChar == '?')
|
||||
{
|
||||
_menuRenderer.ShowHelpBox(currentAvailableLines);
|
||||
Console.ReadKey(intercept: true);
|
||||
_menuRenderer.InvalidateCache();
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
continue;
|
||||
}
|
||||
switch (key.Key)
|
||||
{
|
||||
case ConsoleKey.UpArrow:
|
||||
int previousSelectedIndexUp = _navigationController.SelectedIndex;
|
||||
bool didScrollUp = _navigationController.MoveUp(_gameEntries, currentAvailableLines);
|
||||
if (didScrollUp)
|
||||
{
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
}
|
||||
else
|
||||
{
|
||||
_menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndexUp, previousSelectedIndexUp - _navigationController.ScrollOffset, false, currentAvailableLines, isFav);
|
||||
_menuRenderer.RedrawEntry(_gameEntries, _navigationController.SelectedIndex, _navigationController.SelectedIndex - _navigationController.ScrollOffset, true, currentAvailableLines, isFav);
|
||||
}
|
||||
// flush input to avoid key repeat
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.DownArrow:
|
||||
int previousSelectedIndexDown = _navigationController.SelectedIndex;
|
||||
bool didScrollDown = _navigationController.MoveDown(_gameEntries, currentAvailableLines);
|
||||
if (didScrollDown)
|
||||
{
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
}
|
||||
else
|
||||
{
|
||||
_menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndexDown, previousSelectedIndexDown - _navigationController.ScrollOffset, false, currentAvailableLines, isFav);
|
||||
_menuRenderer.RedrawEntry(_gameEntries, _navigationController.SelectedIndex, _navigationController.SelectedIndex - _navigationController.ScrollOffset, true, currentAvailableLines, isFav);
|
||||
}
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.Enter:
|
||||
case ConsoleKey.RightArrow:
|
||||
var oldRelativePath = _navigationController.CurrentRelativePath;
|
||||
var isDirectory = _gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Directory;
|
||||
_navigationController.HandleEnter(_gameEntries);
|
||||
if (isDirectory && oldRelativePath != _navigationController.CurrentRelativePath) {
|
||||
ReloadGameEntries();
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
|
||||
}
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
// Only start a ZIP if NOT switching to a directory
|
||||
if (!isDirectory && _gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Zip)
|
||||
{
|
||||
string zipToLaunch = _gameEntries[_navigationController.SelectedIndex].InPatch ? _gameEntries[_navigationController.SelectedIndex].PatchPath : _gameEntries[_navigationController.SelectedIndex].RootPath;
|
||||
try
|
||||
{
|
||||
_hatariLauncher!.Launch(zipToLaunch);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorService.ShowError(ex.Message);
|
||||
}
|
||||
}
|
||||
// flush input to avoid leftover key events after an enter/navigation
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.Backspace:
|
||||
case ConsoleKey.LeftArrow:
|
||||
_navigationController.GoUpDirectory();
|
||||
ReloadGameEntries();
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.PageDown:
|
||||
_navigationController.PageDown(_gameEntries, currentAvailableLines);
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.PageUp:
|
||||
_navigationController.PageUp(_gameEntries, currentAvailableLines);
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.Multiply:
|
||||
case ConsoleKey.Oem8:
|
||||
// Toggle favorite for selected ZIP (handles numpad * and some layouts)
|
||||
if (_gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Zip)
|
||||
{
|
||||
var ge = _gameEntries[_navigationController.SelectedIndex];
|
||||
string path = ge.InPatch ? ge.PatchPath : ge.RootPath;
|
||||
try
|
||||
{
|
||||
_favoritesService?.Toggle(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorService.ShowError("Failed to toggle favorite: " + ex.Message);
|
||||
}
|
||||
// Redraw the whole menu so the '*' marker updates immediately
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
}
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.Escape:
|
||||
case ConsoleKey.Q:
|
||||
exitRequested = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReloadGameEntries()
|
||||
{
|
||||
try
|
||||
if (latestAvailableLines != _currentAvailableLines || latestWidth != _currentWidth)
|
||||
{
|
||||
// If we are at the virtual Favorites folder, produce the flat list from the favorites service
|
||||
if (string.Equals(_navigationController.CurrentRelativePath, "Favorites", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var favs = _favoritesService?.GetAll() ?? new List<string>();
|
||||
_gameEntries = favs.Select(p => new GameEntry
|
||||
{
|
||||
Name = Path.GetFileName(p),
|
||||
Kind = EntryKind.Zip,
|
||||
InRoot = true,
|
||||
InPatch = false,
|
||||
RootPath = p,
|
||||
PatchPath = string.Empty,
|
||||
IsVirtual = false
|
||||
}).ToList();
|
||||
_navigationController.SetEntriesCount(_gameEntries.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise use the overlay directory browser for normal folders
|
||||
_gameEntries = _directoryBrowser!.GetEntries(_navigationController.CurrentRelativePath);
|
||||
|
||||
// If we are at the root and there are favorites, prepend a virtual "Favorites" folder
|
||||
if (string.IsNullOrEmpty(_navigationController.CurrentRelativePath) && (_favoritesService?.HasFavorites() ?? false))
|
||||
{
|
||||
var virtualEntry = new GameEntry
|
||||
{
|
||||
Name = "Favorites",
|
||||
Kind = EntryKind.Directory,
|
||||
InRoot = true,
|
||||
InPatch = false,
|
||||
RootPath = string.Empty,
|
||||
PatchPath = string.Empty,
|
||||
IsVirtual = true
|
||||
};
|
||||
// insert at the beginning
|
||||
_gameEntries.Insert(0, virtualEntry);
|
||||
}
|
||||
|
||||
_navigationController.SetEntriesCount(_gameEntries.Count);
|
||||
_currentAvailableLines = latestAvailableLines;
|
||||
_currentWidth = latestWidth;
|
||||
_inputController.RefreshView(_currentAvailableLines);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
// Only block if there's actually a key; otherwise allow resize detection
|
||||
if (!Console.KeyAvailable)
|
||||
{
|
||||
// Show the error to the user and continue with an empty list
|
||||
_errorService.ShowError(ex.Message);
|
||||
_gameEntries = new List<GameEntry>();
|
||||
_navigationController.SetEntriesCount(0);
|
||||
Thread.Sleep(50);
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
exitRequested = _inputController.HandleKey(key, _currentAvailableLines);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
[Log]
|
||||
sLogFileName = stderr
|
||||
sTraceFileName = stderr
|
||||
nTextLogLevel = 3
|
||||
nAlertDlgLogLevel = 1
|
||||
bConfirmQuit = TRUE
|
||||
bNatFeats = FALSE
|
||||
bConsoleWindow = FALSE
|
||||
|
||||
[Debugger]
|
||||
nNumberBase = 10
|
||||
nSymbolLines = -1
|
||||
nMemdumpLines = -1
|
||||
nDisasmLines = -1
|
||||
nBacktraceLines = 0
|
||||
nExceptionDebugMask = 1073741830
|
||||
nDisasmOptions = 7
|
||||
bDisasmUAE = TRUE
|
||||
bSymbolsAutoLoad = TRUE
|
||||
bMatchAllSymbols = FALSE
|
||||
|
||||
[Screen]
|
||||
nMonitorType = 1
|
||||
nFrameSkips = 5
|
||||
bFullScreen = FALSE
|
||||
bKeepResolution = FALSE
|
||||
bResizable = FALSE
|
||||
bAllowOverscan = TRUE
|
||||
nSpec512Threshold = 1
|
||||
bAspectCorrect = TRUE
|
||||
bUseExtVdiResolutions = FALSE
|
||||
nVdiWidth = 1264
|
||||
nVdiHeight = 912
|
||||
nVdiColors = 1
|
||||
bMouseWarp = TRUE
|
||||
bShowStatusbar = TRUE
|
||||
bShowDriveLed = FALSE
|
||||
bCrop = FALSE
|
||||
bForceMax = FALSE
|
||||
nMaxWidth = 1280
|
||||
nMaxHeight = 1024
|
||||
nZoomFactor = 3
|
||||
bUseSdlRenderer = FALSE
|
||||
ScreenShotFormat = 2
|
||||
bUseVsync = FALSE
|
||||
|
||||
[Joystick0]
|
||||
nJoystickMode = 0
|
||||
bEnableAutoFire = FALSE
|
||||
bEnableJumpOnFire2 = TRUE
|
||||
nJoyId = -1
|
||||
nJoyBut1Index = 0
|
||||
nJoyBut2Index = 1
|
||||
nJoyBut3Index = 2
|
||||
kUp = Up
|
||||
kDown = Down
|
||||
kLeft = Left
|
||||
kRight = Right
|
||||
kFire = Right Ctrl
|
||||
|
||||
[Joystick1]
|
||||
nJoystickMode = 1
|
||||
bEnableAutoFire = FALSE
|
||||
bEnableJumpOnFire2 = TRUE
|
||||
nJoyId = 0
|
||||
nJoyBut1Index = 0
|
||||
nJoyBut2Index = 1
|
||||
nJoyBut3Index = 2
|
||||
kUp = Up
|
||||
kDown = Down
|
||||
kLeft = Left
|
||||
kRight = Right
|
||||
kFire = Right Ctrl
|
||||
|
||||
[Joystick2]
|
||||
nJoystickMode = 0
|
||||
bEnableAutoFire = FALSE
|
||||
bEnableJumpOnFire2 = TRUE
|
||||
nJoyId = -1
|
||||
nJoyBut1Index = 0
|
||||
nJoyBut2Index = 1
|
||||
nJoyBut3Index = 2
|
||||
kUp = Up
|
||||
kDown = Down
|
||||
kLeft = Left
|
||||
kRight = Right
|
||||
kFire = Right Ctrl
|
||||
kButtonB = B
|
||||
kButtonC = C
|
||||
kButtonOption = O
|
||||
kButtonPause = P
|
||||
kButtonStar = +
|
||||
kButtonHash = #
|
||||
kButton0 = 0
|
||||
kButton1 = 1
|
||||
kButton2 = 2
|
||||
kButton3 = 3
|
||||
kButton4 = 4
|
||||
kButton5 = 5
|
||||
kButton6 = 6
|
||||
kButton7 = 7
|
||||
kButton8 = 8
|
||||
kButton9 = 9
|
||||
|
||||
[Joystick3]
|
||||
nJoystickMode = 0
|
||||
bEnableAutoFire = FALSE
|
||||
bEnableJumpOnFire2 = TRUE
|
||||
nJoyId = -1
|
||||
nJoyBut1Index = 0
|
||||
nJoyBut2Index = 1
|
||||
nJoyBut3Index = 2
|
||||
kUp = Up
|
||||
kDown = Down
|
||||
kLeft = Left
|
||||
kRight = Right
|
||||
kFire = Right Ctrl
|
||||
kButtonB =
|
||||
kButtonC =
|
||||
kButtonOption =
|
||||
kButtonPause =
|
||||
kButtonStar =
|
||||
kButtonHash =
|
||||
kButton0 =
|
||||
kButton1 =
|
||||
kButton2 =
|
||||
kButton3 =
|
||||
kButton4 =
|
||||
kButton5 =
|
||||
kButton6 =
|
||||
kButton7 =
|
||||
kButton8 =
|
||||
kButton9 =
|
||||
|
||||
[Joystick4]
|
||||
nJoystickMode = 0
|
||||
bEnableAutoFire = FALSE
|
||||
bEnableJumpOnFire2 = TRUE
|
||||
nJoyId = -1
|
||||
nJoyBut1Index = 0
|
||||
nJoyBut2Index = 1
|
||||
nJoyBut3Index = 2
|
||||
kUp = Up
|
||||
kDown = Down
|
||||
kLeft = Left
|
||||
kRight = Right
|
||||
kFire = Right Ctrl
|
||||
|
||||
[Joystick5]
|
||||
nJoystickMode = 0
|
||||
bEnableAutoFire = FALSE
|
||||
bEnableJumpOnFire2 = TRUE
|
||||
nJoyId = -1
|
||||
nJoyBut1Index = 0
|
||||
nJoyBut2Index = 1
|
||||
nJoyBut3Index = 2
|
||||
kUp = Up
|
||||
kDown = Down
|
||||
kLeft = Left
|
||||
kRight = Right
|
||||
kFire = Right Ctrl
|
||||
|
||||
[Keyboard]
|
||||
bDisableKeyRepeat = FALSE
|
||||
nKeymapType = 0
|
||||
nCountryCode = -1
|
||||
nKbdLayout = -1
|
||||
nLanguage = -1
|
||||
szMappingFileName =
|
||||
|
||||
[KeyShortcutsWithMod]
|
||||
kOptions = O
|
||||
kFullScreen = F
|
||||
kBorders = B
|
||||
kMouseMode = M
|
||||
kColdReset = C
|
||||
kWarmReset = R
|
||||
kScreenShot = G
|
||||
kBossKey = I
|
||||
kCursorEmu = J
|
||||
kFastForward = X
|
||||
kRecAnim = A
|
||||
kRecSound = Y
|
||||
kSound = S
|
||||
kPause =
|
||||
kDebugger = Pause
|
||||
kQuit = Q
|
||||
kLoadMem = L
|
||||
kSaveMem = K
|
||||
kInsertDiskA = D
|
||||
kSwitchJoy0 = F1
|
||||
kSwitchJoy1 = F2
|
||||
kSwitchPadA = F3
|
||||
kSwitchPadB = F4
|
||||
|
||||
[KeyShortcutsWithoutMod]
|
||||
kOptions = F12
|
||||
kFullScreen = F11
|
||||
kBorders =
|
||||
kMouseMode =
|
||||
kColdReset =
|
||||
kWarmReset =
|
||||
kScreenShot =
|
||||
kBossKey =
|
||||
kCursorEmu =
|
||||
kFastForward =
|
||||
kRecAnim =
|
||||
kRecSound =
|
||||
kSound =
|
||||
kPause = Pause
|
||||
kDebugger =
|
||||
kQuit =
|
||||
kLoadMem =
|
||||
kSaveMem =
|
||||
kInsertDiskA =
|
||||
kSwitchJoy0 =
|
||||
kSwitchJoy1 =
|
||||
kSwitchPadA =
|
||||
kSwitchPadB =
|
||||
|
||||
[Sound]
|
||||
bEnableMicrophone = TRUE
|
||||
bEnableSound = TRUE
|
||||
bEnableSoundSync = FALSE
|
||||
nPlaybackFreq = 44100
|
||||
nSdlAudioBufferSize = 0
|
||||
szYMCaptureFileName =
|
||||
YmVolumeMixing = 2
|
||||
|
||||
[Memory]
|
||||
nMemorySize = 1024
|
||||
nTTRamSize = 28672
|
||||
bAutoSave = FALSE
|
||||
szMemoryCaptureFileName =
|
||||
szAutoSaveFileName =
|
||||
|
||||
[Floppy]
|
||||
bAutoInsertDiskB = FALSE
|
||||
FastFloppy = FALSE
|
||||
EnableDriveA = TRUE
|
||||
DriveA_NumberOfHeads = 2
|
||||
EnableDriveB = TRUE
|
||||
DriveB_NumberOfHeads = 2
|
||||
nWriteProtection = 0
|
||||
szDiskAZipPath =
|
||||
szDiskAFileName =
|
||||
szDiskBZipPath =
|
||||
szDiskBFileName =
|
||||
szDiskImageDirectory =
|
||||
|
||||
[HardDisk]
|
||||
nGemdosDrive = 0
|
||||
bBootFromHardDisk = FALSE
|
||||
bUseHardDiskDirectory = FALSE
|
||||
szHardDiskDirectory = R:\
|
||||
nGemdosCase = 0
|
||||
nWriteProtection = 0
|
||||
bFilenameConversion = FALSE
|
||||
bGemdosHostTime = FALSE
|
||||
|
||||
[ACSI]
|
||||
|
||||
|
||||
[SCSI]
|
||||
|
||||
|
||||
[IDE]
|
||||
|
||||
[ROM]
|
||||
szTosImageFileName = TOS.IMG
|
||||
bPatchTos = TRUE
|
||||
szCartridgeImageFileName =
|
||||
|
||||
[LILO]
|
||||
Args = root=/dev/ram video=atafb:vga16 load_ramdisk=1
|
||||
Kernel =
|
||||
Symbols =
|
||||
Ramdisk =
|
||||
HaltOnReboot = TRUE
|
||||
KernelToFastRam = TRUE
|
||||
RamdiskToFastRam = TRUE
|
||||
|
||||
[RS232]
|
||||
bEnableRS232 = FALSE
|
||||
szOutFileName =
|
||||
szInFileName =
|
||||
EnableSccA = FALSE
|
||||
SccAOutFileName =
|
||||
SccAInFileName =
|
||||
EnableSccALan = FALSE
|
||||
SccALanOutFileName =
|
||||
SccALanInFileName =
|
||||
EnableSccB = FALSE
|
||||
SccBOutFileName =
|
||||
SccBInFileName =
|
||||
|
||||
[Printer]
|
||||
bEnablePrinting = FALSE
|
||||
szPrintToFileName =
|
||||
|
||||
[Midi]
|
||||
bEnableMidi = FALSE
|
||||
sMidiInFileName =
|
||||
sMidiOutFileName =
|
||||
sMidiInPortName = Off
|
||||
sMidiOutPortName = Off
|
||||
|
||||
[System]
|
||||
nCpuLevel = 0
|
||||
nCpuFreq = 8
|
||||
bCompatibleCpu = TRUE
|
||||
nModelType = 0
|
||||
bBlitter = FALSE
|
||||
nDSPType = 0
|
||||
nVMEType = 1
|
||||
nRtcYear = 0
|
||||
bPatchTimerD = FALSE
|
||||
bFastBoot = FALSE
|
||||
bFastForward = FALSE
|
||||
bAddressSpace24 = TRUE
|
||||
bCycleExactCpu = TRUE
|
||||
n_FPUType = 0
|
||||
bSoftFloatFPU = FALSE
|
||||
bMMU = TRUE
|
||||
VideoTiming = 3
|
||||
|
||||
[Video]
|
||||
AviRecordVcodec = 2
|
||||
AviRecordFps = 0
|
||||
AviRecordFile =
|
||||
@@ -10,10 +10,13 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="launcher.config.example.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="MarcerGameDvd-Hatari.cfg">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="../../LICENSE">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<Link>LICENSE</Link>
|
||||
|
||||
@@ -11,6 +11,14 @@ namespace MarcerGameDvdLauncher
|
||||
private LineState[] _cachedBuffer = Array.Empty<LineState>();
|
||||
private int _cachedWidth = -1;
|
||||
|
||||
// Color configuration (injected; defaults to built-in scheme if null)
|
||||
private readonly AppColorConfig _colors;
|
||||
|
||||
public MenuRenderer(AppColorConfig? colors = null)
|
||||
{
|
||||
_colors = colors ?? new AppColorConfig();
|
||||
}
|
||||
|
||||
// availableLines is provided per-draw so the renderer adapts to console resizes
|
||||
public void DrawMenu(List<GameEntry> entries, int scrollOffset, int selectedIndex, int availableLines, Func<GameEntry, bool>? isFavorite = null)
|
||||
{
|
||||
@@ -43,11 +51,8 @@ namespace MarcerGameDvdLauncher
|
||||
}
|
||||
|
||||
// Diff & write only changed lines
|
||||
// Use the caller-provided availableLines (which should be Console.WindowHeight - 1)
|
||||
int maxRow = Math.Max(0, availableLines - 1);
|
||||
for (int row = 0; row < availableLines; row++)
|
||||
{
|
||||
if (row > maxRow) break;
|
||||
var newLine = newBuffer[row];
|
||||
var oldLine = _cachedBuffer[row];
|
||||
if (oldLine.Text != newLine.Text || oldLine.Fg != newLine.Fg || oldLine.Bg != newLine.Bg)
|
||||
@@ -63,26 +68,16 @@ namespace MarcerGameDvdLauncher
|
||||
{
|
||||
if (entryIdx < 0 || entryIdx >= entries.Count) return;
|
||||
availableLines = Math.Max(1, availableLines);
|
||||
int maxRow = Math.Max(0, availableLines - 1);
|
||||
if (row < 0 || row > maxRow) return;
|
||||
// row is a visual row within the visible window; validate against availableLines
|
||||
if (row < 0 || row >= availableLines) return;
|
||||
|
||||
int width = Console.WindowWidth;
|
||||
// ensure cache is valid for current width/height and the target row
|
||||
EnsureCacheForRow(width, Math.Min(availableLines, Math.Max(1, _cachedBuffer.Length == 0 ? 1 : _cachedBuffer.Length)));
|
||||
// Ensure the cache matches the current dimensions (same as DrawMenu)
|
||||
EnsureCache(width, availableLines);
|
||||
|
||||
var e = entries[entryIdx];
|
||||
var (fg, bg) = GetColors(e, selected);
|
||||
string text = BuildLineText(e, width, isFavorite?.Invoke(e) ?? false);
|
||||
ConsoleColor fg, bg;
|
||||
if (selected)
|
||||
{
|
||||
bg = ConsoleColor.DarkCyan;
|
||||
fg = ConsoleColor.Black;
|
||||
}
|
||||
else
|
||||
{
|
||||
bg = ConsoleColor.Black;
|
||||
fg = GetColorForEntry(e);
|
||||
}
|
||||
|
||||
var newLine = new LineState { Text = text, Fg = fg, Bg = bg };
|
||||
// If cache differs, write
|
||||
@@ -98,7 +93,11 @@ namespace MarcerGameDvdLauncher
|
||||
else
|
||||
{
|
||||
// out of cache bounds - attempt a direct write
|
||||
WriteConsoleLine(row, newLine);
|
||||
// Validate row against current console height to prevent ArgumentOutOfRangeException
|
||||
if (row < Console.WindowHeight)
|
||||
{
|
||||
WriteConsoleLine(row, newLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,18 +112,6 @@ namespace MarcerGameDvdLauncher
|
||||
}
|
||||
}
|
||||
|
||||
// Ensures the cached buffer has at least requiredRows entries and matches width.
|
||||
private void EnsureCacheForRow(int width, int requiredRows)
|
||||
{
|
||||
if (_cachedBuffer.Length < requiredRows || _cachedWidth != width)
|
||||
{
|
||||
int newLen = Math.Max(requiredRows, 1);
|
||||
_cachedBuffer = new LineState[newLen];
|
||||
for (int i = 0; i < newLen; i++) _cachedBuffer[i].Text = null!;
|
||||
_cachedWidth = width;
|
||||
}
|
||||
}
|
||||
|
||||
// Write a line to the console using the centralized logic (handles concurrent resizes safely)
|
||||
private void WriteConsoleLine(int row, LineState newLine)
|
||||
{
|
||||
@@ -229,12 +216,13 @@ namespace MarcerGameDvdLauncher
|
||||
return text + new string(' ', width - text.Length);
|
||||
}
|
||||
|
||||
// Returns foreground and background colors for an entry depending on selection state
|
||||
// Returns foreground and background colors for an entry depending on selection state.
|
||||
// Selected colors and entry colors come from the injected AppColorConfig.
|
||||
private (ConsoleColor fg, ConsoleColor bg) GetColors(GameEntry e, bool selected)
|
||||
{
|
||||
if (selected)
|
||||
{
|
||||
return (ConsoleColor.Black, ConsoleColor.DarkCyan);
|
||||
return (_colors.SelectedForeground, _colors.SelectedBackground);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -306,20 +294,20 @@ namespace MarcerGameDvdLauncher
|
||||
|
||||
private ConsoleColor GetColorForEntry(GameEntry e)
|
||||
{
|
||||
// Virtual entries (like the Favorites pseudo-folder) should be white
|
||||
if (e.IsVirtual) return ConsoleColor.White;
|
||||
// Virtual entries (like the Favorites pseudo-folder) use the configured VirtualEntry color
|
||||
if (e.IsVirtual) return _colors.VirtualEntry;
|
||||
|
||||
if (e.Kind == EntryKind.Directory)
|
||||
{
|
||||
if (e.InRoot && e.InPatch) return ConsoleColor.Yellow; // Both layers
|
||||
if (e.InPatch && !e.InRoot) return ConsoleColor.DarkYellow; // Only patch
|
||||
if (e.InRoot && !e.InPatch) return ConsoleColor.Gray; // Only root
|
||||
if (e.InRoot && e.InPatch) return _colors.FolderBoth; // Both layers
|
||||
if (e.InPatch && !e.InRoot) return _colors.FolderPatchOnly; // Only patch
|
||||
if (e.InRoot && !e.InPatch) return _colors.FolderRootOnly; // Only root
|
||||
}
|
||||
else if (e.Kind == EntryKind.Zip)
|
||||
{
|
||||
if (e.InRoot && e.InPatch) return ConsoleColor.Green; // Both layers
|
||||
if (e.InRoot && !e.InPatch) return ConsoleColor.DarkGreen; // Only root
|
||||
if (e.InPatch && !e.InRoot) return ConsoleColor.Magenta; // Only patch
|
||||
if (e.InRoot && e.InPatch) return _colors.ZipBoth; // Both layers
|
||||
if (e.InRoot && !e.InPatch) return _colors.ZipRootOnly; // Only root
|
||||
if (e.InPatch && !e.InRoot) return _colors.ZipPatchOnly; // Only patch
|
||||
}
|
||||
return ConsoleColor.DarkGray;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ namespace MarcerGameDvdLauncher
|
||||
{
|
||||
public class NavigationController
|
||||
{
|
||||
// Scroll fractions: when the selection cursor reaches 2/3 of the visible window
|
||||
// height from the top, the list scrolls down; when it reaches 1/3, it scrolls up.
|
||||
// Not configurable — these ratios are the established navigation behaviour.
|
||||
private const double BottomScrollFraction = 2.0 / 3.0;
|
||||
private const double TopScrollFraction = 1.0 / 3.0;
|
||||
|
||||
public int SelectedIndex { get; private set; } = 0;
|
||||
public int ScrollOffset { get; private set; } = 0;
|
||||
public string CurrentRelativePath { get; private set; } = "";
|
||||
@@ -99,12 +105,12 @@ namespace MarcerGameDvdLauncher
|
||||
if (availableLines < 1) availableLines = 1;
|
||||
if (entryCount <= availableLines) { ScrollOffset = 0; return; }
|
||||
if (SelectedIndex == 0) { ScrollOffset = 0; return; }
|
||||
int bottomScrollTrigger = ScrollOffset + (int)(availableLines * 2 / 3.0);
|
||||
int topScrollTrigger = ScrollOffset + (int)(availableLines * 1 / 3.0);
|
||||
int bottomScrollTrigger = ScrollOffset + (int)(availableLines * BottomScrollFraction);
|
||||
int topScrollTrigger = ScrollOffset + (int)(availableLines * TopScrollFraction);
|
||||
if (SelectedIndex >= bottomScrollTrigger && (ScrollOffset + availableLines) < entryCount)
|
||||
ScrollOffset = SelectedIndex - (int)(availableLines * 2 / 3.0);
|
||||
ScrollOffset = SelectedIndex - (int)(availableLines * BottomScrollFraction);
|
||||
else if (SelectedIndex < topScrollTrigger && ScrollOffset > 0)
|
||||
ScrollOffset = SelectedIndex - (int)(availableLines * 1 / 3.0);
|
||||
ScrollOffset = SelectedIndex - (int)(availableLines * TopScrollFraction);
|
||||
if (ScrollOffset < 0) ScrollOffset = 0;
|
||||
if (ScrollOffset > entryCount - availableLines)
|
||||
ScrollOffset = entryCount - availableLines;
|
||||
|
||||
@@ -5,9 +5,12 @@ namespace MarcerGameDvdLauncher
|
||||
{
|
||||
class Program
|
||||
{
|
||||
// Console window title. Not configurable — fixed application display name.
|
||||
private const string DefaultTitle = "Marcer GameDVD Launcher";
|
||||
|
||||
static void Main(string[] args)
|
||||
{
|
||||
Console.Title = "Marcer GameDVD Launcher";
|
||||
Console.Title = DefaultTitle;
|
||||
var app = new LauncherApp();
|
||||
app.Run();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ namespace MarcerGameDvdLauncher
|
||||
// Small helpers refactored into their own file to keep Program.cs focused.
|
||||
internal static class ProgramHelpers
|
||||
{
|
||||
// The console window height minus one. The last row is reserved to prevent
|
||||
// auto-scroll / flicker when the cursor reaches the bottom row (project policy).
|
||||
// Centralized here so the policy lives in exactly one place.
|
||||
public static int AvailableLines => Math.Max(0, Console.WindowHeight - 1);
|
||||
|
||||
// Flushes the console input buffer to avoid processing leftover key events
|
||||
// Uses Win32 FlushConsoleInputBuffer on the standard input handle. On non-Windows
|
||||
// environments this will be a no-op.
|
||||
@@ -31,6 +36,8 @@ namespace MarcerGameDvdLauncher
|
||||
}
|
||||
}
|
||||
|
||||
// Win32 API constants — intentionally hardcoded (bewusst hartkodiert).
|
||||
// These are defined by the Windows API and do not change.
|
||||
private const int STD_INPUT_HANDLE = -10;
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
|
||||
@@ -41,6 +48,7 @@ namespace MarcerGameDvdLauncher
|
||||
private static extern bool FlushConsoleInputBuffer(IntPtr hConsoleInput);
|
||||
|
||||
// P/Invoke to query key state (used to detect physical key release)
|
||||
// Win32 virtual-key code — intentionally hardcoded (bewusst hartkodiert).
|
||||
private const int VK_RETURN = 0x0D;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
@@ -55,7 +63,7 @@ namespace MarcerGameDvdLauncher
|
||||
{
|
||||
try
|
||||
{
|
||||
int lastRow = Math.Max(0, Console.WindowHeight - 1);
|
||||
int lastRow = AvailableLines;
|
||||
int width = Console.WindowWidth;
|
||||
string text = message ?? string.Empty;
|
||||
if (text.Length > width) text = text.Substring(0, Math.Max(0, width - 3)) + "...";
|
||||
|
||||
@@ -5,6 +5,10 @@ namespace MarcerGameDvdLauncher;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized service for error and user message output in the console UI.
|
||||
/// Errors are presented to the user via <see cref="ProgramHelpers.ShowConsoleMessage"/>
|
||||
/// and are not rethrown — the caller's context does not allow for meaningful error
|
||||
/// recovery, so the application stays in the navigation loop after the user dismisses
|
||||
/// the message.
|
||||
/// </summary>
|
||||
public class UIErrorService
|
||||
{
|
||||
|
||||
@@ -3,7 +3,18 @@
|
||||
"PatchDirectory": "C:\\Games\\Hatari\\PATCH",
|
||||
"Hatari": {
|
||||
"Executable": "C:\\Tools\\hatari\\hatari.exe",
|
||||
"ConfigFile": "C:\\Tools\\hatari\\hatari-st.cfg",
|
||||
"ConfigFile": "",
|
||||
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
|
||||
},
|
||||
"Colors": {
|
||||
"FolderBoth": "Yellow",
|
||||
"FolderPatchOnly": "DarkYellow",
|
||||
"FolderRootOnly": "Gray",
|
||||
"ZipBoth": "Green",
|
||||
"ZipRootOnly": "DarkGreen",
|
||||
"ZipPatchOnly": "Magenta",
|
||||
"SelectedForeground": "Black",
|
||||
"SelectedBackground": "DarkCyan",
|
||||
"VirtualEntry": "White"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user