From 62968dbcd1c1892988eaf0e417b570853bf61753 Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Tue, 11 Aug 2026 01:33:33 +0200 Subject: [PATCH] refactor: decompose LauncherApp, centralize magic strings/constants, configurable colors - Extract InputController from AppHost (key handling + ReloadGameEntries) - Centralize hardcoded values as code constants (non-configurable): FavoritesRootName, DefaultFileName, DefaultTitle, scroll fractions, AvailableLines helper, removed redundant ArgsTemplate fallback - Make color scheme configurable via 'Colors' section in launcher.config.json with Enum.TryParse + default fallback; MenuRenderer uses constructor injection - Clean up MenuRenderer: remove dead maxRow logic, unify cache sizing via EnsureCache - Document intentional error swallowing in FavoritesService.Save/UIErrorService - Update README + AGENTS.md with configurable color docs - Remove redundant ArgsTemplate fallback (config is single source of truth) --- AGENTS.md | 37 +- PLAN.md | 68 ++-- README.md | 39 ++- src/MarcerGameDvdLauncher/AppConfiguration.cs | 23 ++ src/MarcerGameDvdLauncher/FavoritesService.cs | 14 +- src/MarcerGameDvdLauncher/InputController.cs | 255 ++++++++++++++ src/MarcerGameDvdLauncher/LauncherApp.cs | 326 ++++++------------ src/MarcerGameDvdLauncher/MenuRenderer.cs | 64 ++-- .../NavigationController.cs | 14 +- src/MarcerGameDvdLauncher/Program.cs | 5 +- src/MarcerGameDvdLauncher/ProgramHelpers.cs | 10 +- src/MarcerGameDvdLauncher/UIErrorService.cs | 4 + .../launcher.config.example.json | 11 + 13 files changed, 551 insertions(+), 319 deletions(-) create mode 100644 src/MarcerGameDvdLauncher/InputController.cs diff --git a/AGENTS.md b/AGENTS.md index 98980a0..3071562 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 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/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) 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}`). -- 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/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. @@ -114,15 +114,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` 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). diff --git a/PLAN.md b/PLAN.md index 7a00ef4..bf162de 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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] 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` + 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` + 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,13 +73,12 @@ 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). --- diff --git a/README.md b/README.md index a705973..8f810bd 100644 --- a/README.md +++ b/README.md @@ -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 | --- @@ -69,6 +73,17 @@ The launcher reads `launcher.config.json` from the same directory as the executa "Executable": "C:\\Tools\\hatari\\hatari.exe", "ConfigFile": "C:\\Tools\\hatari\\hatari-st.cfg", "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" } } ``` @@ -80,6 +95,28 @@ The launcher reads `launcher.config.json` from the same directory as the executa | `Hatari.Executable` | ✅ | Path to the Hatari executable. Validated at startup. | | `Hatari.ConfigFile` | ✅ | Path to the Hatari configuration file. | | `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. diff --git a/src/MarcerGameDvdLauncher/AppConfiguration.cs b/src/MarcerGameDvdLauncher/AppConfiguration.cs index a701202..f2e2127 100644 --- a/src/MarcerGameDvdLauncher/AppConfiguration.cs +++ b/src/MarcerGameDvdLauncher/AppConfiguration.cs @@ -4,6 +4,25 @@ namespace MarcerGameDvdLauncher { // Configuration POCOs separated into their own file for clarity + + /// + /// 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. + /// + 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; } } } diff --git a/src/MarcerGameDvdLauncher/FavoritesService.cs b/src/MarcerGameDvdLauncher/FavoritesService.cs index eb3de12..e825881 100644 --- a/src/MarcerGameDvdLauncher/FavoritesService.cs +++ b/src/MarcerGameDvdLauncher/FavoritesService.cs @@ -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 _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. } } } diff --git a/src/MarcerGameDvdLauncher/InputController.cs b/src/MarcerGameDvdLauncher/InputController.cs new file mode 100644 index 0000000..123c4de --- /dev/null +++ b/src/MarcerGameDvdLauncher/InputController.cs @@ -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 +{ + /// + /// 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. + /// + 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 _gameEntries = new(); + + public IReadOnlyList 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); + } + + /// + /// Updates scroll offset and redraws the full menu. Called on initial load and console resize. + /// + public void RefreshView(int availableLines) + { + _navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines); + DrawMenu(availableLines); + } + + /// + /// Loads entries for the current directory (or the virtual Favorites folder). + /// + 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(); + 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(); + _navigationController.SetEntriesCount(0); + } + } + + /// + /// Handles a key press. Returns true if the application should exit. + /// + 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; + } + } +} diff --git a/src/MarcerGameDvdLauncher/LauncherApp.cs b/src/MarcerGameDvdLauncher/LauncherApp.cs index 296d67c..4cffe86 100644 --- a/src/MarcerGameDvdLauncher/LauncherApp.cs +++ b/src/MarcerGameDvdLauncher/LauncherApp.cs @@ -69,245 +69,139 @@ namespace MarcerGameDvdLauncher 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; 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 setter) + { + if (colorsEl.TryGetProperty(name, out var prop) && prop.ValueKind == System.Text.Json.JsonValueKind.String) + { + var str = prop.GetString(); + if (Enum.TryParse(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 _gameEntries = new List(); - 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(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(); - _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(); - _navigationController.SetEntriesCount(0); + Thread.Sleep(50); + continue; } + + var key = Console.ReadKey(intercept: true); + exitRequested = _inputController.HandleKey(key, _currentAvailableLines); } } + } } diff --git a/src/MarcerGameDvdLauncher/MenuRenderer.cs b/src/MarcerGameDvdLauncher/MenuRenderer.cs index 07796b0..265ba1c 100644 --- a/src/MarcerGameDvdLauncher/MenuRenderer.cs +++ b/src/MarcerGameDvdLauncher/MenuRenderer.cs @@ -11,6 +11,14 @@ namespace MarcerGameDvdLauncher private LineState[] _cachedBuffer = Array.Empty(); 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 entries, int scrollOffset, int selectedIndex, int availableLines, Func? 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 @@ -113,18 +108,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 +212,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 +290,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; } diff --git a/src/MarcerGameDvdLauncher/NavigationController.cs b/src/MarcerGameDvdLauncher/NavigationController.cs index 76afa90..f464bf2 100644 --- a/src/MarcerGameDvdLauncher/NavigationController.cs +++ b/src/MarcerGameDvdLauncher/NavigationController.cs @@ -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; diff --git a/src/MarcerGameDvdLauncher/Program.cs b/src/MarcerGameDvdLauncher/Program.cs index 2a1e957..8a72cf2 100644 --- a/src/MarcerGameDvdLauncher/Program.cs +++ b/src/MarcerGameDvdLauncher/Program.cs @@ -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(); } diff --git a/src/MarcerGameDvdLauncher/ProgramHelpers.cs b/src/MarcerGameDvdLauncher/ProgramHelpers.cs index 5e3c78e..d0e0e38 100644 --- a/src/MarcerGameDvdLauncher/ProgramHelpers.cs +++ b/src/MarcerGameDvdLauncher/ProgramHelpers.cs @@ -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)) + "..."; diff --git a/src/MarcerGameDvdLauncher/UIErrorService.cs b/src/MarcerGameDvdLauncher/UIErrorService.cs index 6e55c5a..a2b5e38 100644 --- a/src/MarcerGameDvdLauncher/UIErrorService.cs +++ b/src/MarcerGameDvdLauncher/UIErrorService.cs @@ -5,6 +5,10 @@ namespace MarcerGameDvdLauncher; /// /// Centralized service for error and user message output in the console UI. +/// Errors are presented to the user via +/// 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. /// public class UIErrorService { diff --git a/src/MarcerGameDvdLauncher/launcher.config.example.json b/src/MarcerGameDvdLauncher/launcher.config.example.json index 1b136ca..2c8e2c8 100644 --- a/src/MarcerGameDvdLauncher/launcher.config.example.json +++ b/src/MarcerGameDvdLauncher/launcher.config.example.json @@ -5,5 +5,16 @@ "Executable": "C:\\Tools\\hatari\\hatari.exe", "ConfigFile": "C:\\Tools\\hatari\\hatari-st.cfg", "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" } }