mirror of
https://github.com/skoelle/marcer-gamedvd-launcher.git
synced 2026-09-18 11:10:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c7592582c | ||
|
|
5e2ab6581c | ||
|
|
e839c01a1e | ||
|
|
0117ba90c3 | ||
|
|
9649910df6 | ||
|
|
7971385beb | ||
|
|
410da6a98b | ||
|
|
dd17b60784 | ||
|
|
5d8db1347b | ||
|
|
7b40cbc331 | ||
|
|
4660cc6be8 | ||
|
|
148c2bb484 | ||
|
|
607d0df28c | ||
|
|
4b3321e812 | ||
|
|
6a4be2e397 | ||
|
|
61a8ae8457 | ||
|
|
5995a96516 | ||
|
|
e704261ea4 | ||
|
|
1634ce806c | ||
|
|
292b642d76 | ||
|
|
e8c43a9d52 | ||
|
|
7f4df30ae7 | ||
|
|
7a75c635f6 | ||
|
|
62968dbcd1 | ||
|
|
7dca82baa8 | ||
|
|
9f12ec9075 | ||
|
|
c79ff80a84 | ||
|
|
8cb3b1a624 | ||
|
|
393290e37e | ||
|
|
ba8004ef50 | ||
|
|
6bb086516c | ||
|
|
48a08f9bb1 | ||
|
|
d5cf0cd723 | ||
|
|
45a7c3ace2 | ||
|
|
eabf2066b6 | ||
|
|
86dcf7bd32 | ||
|
|
c8229dd0e1 |
@@ -26,10 +26,10 @@ jobs:
|
|||||||
artifact_name: osx-x64
|
artifact_name: osx-x64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v6
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
zip -r $GITHUB_WORKSPACE/MarcerGameDvdLauncher-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}.zip .
|
zip -r $GITHUB_WORKSPACE/MarcerGameDvdLauncher-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}.zip .
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: MarcerGameDvdLauncher-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}
|
name: MarcerGameDvdLauncher-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}
|
||||||
path: MarcerGameDvdLauncher-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}.zip
|
path: MarcerGameDvdLauncher-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}.zip
|
||||||
@@ -66,12 +66,12 @@ jobs:
|
|||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|
||||||
@@ -81,7 +81,14 @@ jobs:
|
|||||||
CURRENT_TAG="${GITHUB_REF_NAME}"
|
CURRENT_TAG="${GITHUB_REF_NAME}"
|
||||||
PREV_TAG=$(git describe --tags --abbrev=0 ${CURRENT_TAG}^ 2>/dev/null || echo "")
|
PREV_TAG=$(git describe --tags --abbrev=0 ${CURRENT_TAG}^ 2>/dev/null || echo "")
|
||||||
|
|
||||||
if [ -n "$PREV_TAG" ]; then
|
# Get the full commit message body of the release commit
|
||||||
|
# (the empty commit created by /create-release)
|
||||||
|
COMMIT_HASH=$(git rev-list -n 1 ${CURRENT_TAG})
|
||||||
|
BODY=$(git log -1 --format=%b ${COMMIT_HASH})
|
||||||
|
|
||||||
|
if [ -n "$BODY" ]; then
|
||||||
|
echo "$BODY" > release-notes.md
|
||||||
|
elif [ -n "$PREV_TAG" ]; then
|
||||||
echo "## Changes since ${PREV_TAG}" > release-notes.md
|
echo "## Changes since ${PREV_TAG}" > release-notes.md
|
||||||
echo "" >> release-notes.md
|
echo "" >> release-notes.md
|
||||||
git log --oneline ${PREV_TAG}..${CURRENT_TAG} >> release-notes.md
|
git log --oneline ${PREV_TAG}..${CURRENT_TAG} >> release-notes.md
|
||||||
@@ -90,7 +97,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
name: MarcerGameDvdLauncher ${{ github.ref_name }}
|
name: MarcerGameDvdLauncher ${{ github.ref_name }}
|
||||||
body_path: release-notes.md
|
body_path: release-notes.md
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ obj/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Python virtual environment
|
||||||
|
.venv/
|
||||||
|
|
||||||
# Release-Verzeichnis (keine ZIPs o.ä. ins Git!)
|
# Release-Verzeichnis (keine ZIPs o.ä. ins Git!)
|
||||||
# Only ignore top-level release/ directory. Release directories in subfolders are allowed.
|
# Only ignore top-level release/ directory. Release directories in subfolders are allowed.
|
||||||
/release/
|
/release/
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
description: Create and push a release tag (e.g. /create-release 1.0.0)
|
||||||
|
---
|
||||||
|
|
||||||
|
Create a release tag and push it to origin. The GitHub Action will automatically build for all platforms and create the GitHub Release.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Validate the version argument ($ARGUMENTS):
|
||||||
|
- Must be provided, otherwise show error and stop
|
||||||
|
- Must match semver format (e.g. 1.0.0, 0.9.1, 2.0.0-beta.1)
|
||||||
|
|
||||||
|
2. Check for uncommitted changes:
|
||||||
|
- Run `git status --porcelain`
|
||||||
|
- If any output, warn the user and stop (commit first)
|
||||||
|
|
||||||
|
3. Find the previous tag dynamically:
|
||||||
|
- Run `git describe --tags --abbrev=0 HEAD` to find the most recent tag before HEAD
|
||||||
|
- If no previous tag exists (first release), note this and skip to step 5
|
||||||
|
- Report the previous tag to the user (e.g. "Previous tag found: v0.9.5")
|
||||||
|
|
||||||
|
4. Analyze changes since the previous tag:
|
||||||
|
- Run `git log --oneline <PREV_TAG>..HEAD` to list all commits between the previous tag and HEAD
|
||||||
|
- Read the changed files to understand context
|
||||||
|
- Write a concise, well-structured release summary in English with:
|
||||||
|
- A one-line overview
|
||||||
|
- Bullet points for each notable change (features, fixes, breaking changes)
|
||||||
|
- Keep it developer-friendly, no fluff
|
||||||
|
|
||||||
|
5. Create release commit with the summary as message:
|
||||||
|
- Run `git commit --allow-empty -m "release v$ARGUMENTS\n\n<summary>"`
|
||||||
|
- The commit message IS the release notes — the GitHub Action picks it up automatically
|
||||||
|
|
||||||
|
6. Create annotated tag on that commit:
|
||||||
|
- Run `git tag -a v$ARGUMENTS -m "Release v$ARGUMENTS"`
|
||||||
|
|
||||||
|
7. Push commit and tag:
|
||||||
|
- Run `git push origin main --tags` (or current branch)
|
||||||
|
|
||||||
|
8. Confirm success with the version number
|
||||||
@@ -6,19 +6,21 @@ 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.
|
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/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).
|
- 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`) used to deserialize `launcher.config.json`.
|
- 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) used across modules.
|
- 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/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/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, color selection according to overlay rules, and the help box overlay.
|
- MarcerGameDvdLauncher/MenuRenderer.cs: Console rendering logic — efficient per-line redraw, double-buffering, configurable color selection via injected `AppColorConfig`, 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/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.
|
- 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.
|
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.
|
||||||
**Note for Automated Tests/CI:**
|
**Note for Automated Tests/CI:**
|
||||||
The Launcher cannot be executed or tested via `scripts/start.cmd` from this environment (build system, automation agent) since no Windows console environment is present. For release workflows and developer validation, it is ALWAYS required to do a manual test run via scripts/start.cmd per documentation and policy before delivery.
|
The Launcher cannot be executed or tested via `scripts/start.cmd` from this environment (build system, automation agent) since no Windows console environment is present. For release workflows and developer validation, it is ALWAYS required to do a manual test run via `scripts/start.cmd` (Windows) or `scripts/start.sh` (Linux/macOS) per documentation and policy before delivery.
|
||||||
|
|
||||||
**Release Process (automated via GitHub Actions):**
|
**Release Process (automated via GitHub Actions):**
|
||||||
- Pushing a tag (`v*`) triggers the GitHub Action workflow (`.github/workflows/release.yml`).
|
- Pushing a tag (`v*`) triggers the GitHub Action workflow (`.github/workflows/release.yml`).
|
||||||
@@ -35,14 +37,14 @@ Additional policy:
|
|||||||
- README.md must be written in English. Any functional change that affects usage, configuration, or behavior MUST update README.md in English immediately after the change. If there are consequential changes to developer-facing policies, build steps, or requirements, AGENTS.md must be updated as well.
|
- README.md must be written in English. Any functional change that affects usage, configuration, or behavior MUST update README.md in English immediately after the change. If there are consequential changes to developer-facing policies, build steps, or requirements, AGENTS.md must be updated as well.
|
||||||
|
|
||||||
Developer note: Visual Studio Solution
|
Developer note: Visual Studio Solution
|
||||||
- A Visual Studio solution file exists at `src/marcer-gamedvd-launcher.sln`. Developers may open this solution in Visual Studio to work on the project, debug and build from the IDE. The solution references `MarcerGameDvdLauncher\MarcerGameDvdLauncher.csproj` and includes Debug and Release configurations. Use `scripts/build.cmd` (Windows) or `scripts/build.sh` (Linux) and `scripts/start.cmd` for consistent command-line builds/releases as described elsewhere in this document.
|
- A Visual Studio solution file exists at `src/marcer-gamedvd-launcher.sln`. Developers may open this solution in Visual Studio to work on the project, debug and build from the IDE. The solution references `MarcerGameDvdLauncher\MarcerGameDvdLauncher.csproj` and includes Debug and Release configurations. Use `scripts/build.cmd` (Windows) or `scripts/build.sh` (Linux/macOS) and `scripts/start.cmd` (Windows) or `scripts/start.sh` (Linux/macOS) for consistent command-line builds/releases as described elsewhere in this document.
|
||||||
|
|
||||||
With this, it is ensured that binary/release files never end up in git, and the release process is always traceable and performed exclusively manually in the web interface.
|
With this, it is ensured that binary/release files never end up in git, and the release process is always traceable and performed exclusively manually in the web interface.
|
||||||
|
|
||||||
# Requirements for the Marcer GameDVD Launcher (AGENTS.md)
|
# Requirements for the Marcer GameDVD Launcher (AGENTS.md)
|
||||||
|
|
||||||
## Basic Function / Purpose
|
## Basic Function / Purpose
|
||||||
The console launcher is meant for browsing a games directory and can launch ZIP files with the Hatari emulator under Windows. Control is exclusively via keyboard in the console window.
|
The console launcher is meant for browsing a games directory and can launch ZIP files with the Hatari emulator. It runs on Windows, Linux, and macOS. Control is exclusively via keyboard in the console window.
|
||||||
|
|
||||||
## Detailed Requirements
|
## Detailed Requirements
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ The console launcher is meant for browsing a games directory and can launch ZIP
|
|||||||
- `?`: show a help box with key bindings
|
- `?`: 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.
|
- 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.
|
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.
|
- There is no information line/path display.
|
||||||
- Cursor and scroll logic:
|
- Cursor and scroll logic:
|
||||||
- The selection cursor must always remain in the visible area.
|
- The selection cursor must always remain in the visible area.
|
||||||
@@ -69,6 +71,7 @@ The console launcher is meant for browsing a games directory and can launch ZIP
|
|||||||
|
|
||||||
### Launching a Game
|
### 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.
|
- 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
|
### Error Handling & Edge Cases
|
||||||
- Paths with special characters or spaces must work robustly.
|
- Paths with special characters or spaces must work robustly.
|
||||||
@@ -77,13 +80,13 @@ The console launcher is meant for browsing a games directory and can launch ZIP
|
|||||||
- In the root directory, Backspace must have no effect (no error, do not leave the program).
|
- 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.
|
- 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
|
### Miscellaneous
|
||||||
- Optional: Build and start scripts (`scripts/build.cmd` / `scripts/build.sh` / `scripts/start.cmd` / `scripts/start.sh`) are present, adapt as needed.
|
- Build and start scripts (`scripts/build.cmd` / `scripts/build.sh` / `scripts/start.cmd` / `scripts/start.sh`) are present and must be used.
|
||||||
- For ALL builds, tests, and releases, ONLY the platform build script may be used: `scripts/build.cmd` (Windows) or `scripts/build.sh` (Linux). Direct `dotnet build`/`dotnet run` calls are NOT allowed, as they can lead to version/runtime conflicts. The application must always be started and tested using `scripts/start.cmd`.
|
- For ALL builds, tests, and releases, ONLY the platform build script may be used: `scripts/build.cmd` (Windows) or `scripts/build.sh` (Linux/macOS). Direct `dotnet build`/`dotnet run` calls are NOT allowed, as they can lead to version/runtime conflicts. The application must always be started and tested using `scripts/start.cmd` (Windows) or `scripts/start.sh` (Linux/macOS).
|
||||||
- After making any code changes that affect behavior or touch source files, run the platform build script (`scripts/build.cmd` on Windows, `scripts/build.sh` on Linux) and ensure the build completes successfully before committing. Additionally, perform a manual functional test using `scripts/start.cmd` on a Windows machine prior to pushing a release.
|
- After making any code changes that affect behavior or touch source files, run the platform build script (`scripts/build.cmd` on Windows, `scripts/build.sh` on Linux/macOS) and ensure the build completes successfully before committing. Additionally, perform a manual functional test on a Windows, Linux, or macOS machine prior to pushing a release.
|
||||||
- The console window can have any number of lines; display/navigation must adapt dynamically.
|
- The console window can have any number of lines; display/navigation must adapt dynamically.
|
||||||
- After each build for a release, the entire build output directory (`bin/Release/net10.0/`) must be zipped in the `release/` directory, and the ZIP must be uploaded as a release asset in Gitea.
|
|
||||||
- For every release, a Release Notes file must be maintained that summarizes all changes, bugfixes, and new features in that version; Release Notes must be provided with the release asset.
|
- For every release, a Release Notes file must be maintained that summarizes all changes, bugfixes, and new features in that version; Release Notes must be provided with the release asset.
|
||||||
|
|
||||||
- **IMPORTANT:** With any functional change to the launcher, BOTH this file (AGENTS.md) AND the README.md must always be updated and kept current. Immediately after, a successful build must be executed. This is mandatory for all development on the project.
|
- **IMPORTANT:** With any functional change to the launcher, BOTH this file (AGENTS.md) AND the README.md must always be updated and kept current. Immediately after, a successful build must be executed. This is mandatory for all development on the project.
|
||||||
@@ -113,17 +116,26 @@ Each entry is displayed with a left label indicating its layer status:
|
|||||||
- **`[ROOT]`**: Entry exists only in main (root) layer
|
- **`[ROOT]`**: Entry exists only in main (root) layer
|
||||||
- **`[PTCH]`**: Entry exists only in patch layer
|
- **`[PTCH]`**: Entry exists only in patch layer
|
||||||
|
|
||||||
Color mapping:
|
Color mapping (now configurable via the `Colors` section in `launcher.config.json`; defaults shown below):
|
||||||
- Folder in both layers: **ConsoleColor.Yellow** (`[BOTH]`)
|
- Folder in both layers: **Yellow** (`[BOTH]`) → `FolderBoth`
|
||||||
- Folder only in patch layer: **ConsoleColor.DarkYellow** (`[PTCH]`)
|
- Folder only in patch layer: **DarkYellow** (`[PTCH]`) → `FolderPatchOnly`
|
||||||
- Folder only in main layer: **ConsoleColor.Gray** (`[ROOT]`)
|
- Folder only in main layer: **Gray** (`[ROOT]`) → `FolderRootOnly`
|
||||||
- ZIP in both layers: **ConsoleColor.Green** (`[BOTH]`)
|
- ZIP in both layers: **Green** (`[BOTH]`) → `ZipBoth`
|
||||||
- ZIP only in main layer: **ConsoleColor.DarkGreen** (`[ROOT]`)
|
- ZIP only in main layer: **DarkGreen** (`[ROOT]`) → `ZipRootOnly`
|
||||||
- ZIP only in patch layer: **ConsoleColor.Magenta** (`[PTCH]`)
|
- 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
|
||||||
- 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).
|
- 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
- Full text in `LICENSE`
|
||||||
|
- License headers in all source code files
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -4,21 +4,18 @@ Ziel: Das Repo aufräumen (Doku, Code, Config)
|
|||||||
|
|
||||||
## 1. Code-Qualität – Schwächen beheben (harmlos, rein refaktoriell)
|
## 1. Code-Qualität – Schwächen beheben (harmlos, rein refaktoriell)
|
||||||
|
|
||||||
- [ ] **LauncherApp.cs zerlegen:** Die ~300-Zeilen-Klasse in separate Klassen aufteilen
|
- [x] **LauncherApp.cs zerlegen:** Die ~300-Zeilen-Klasse in separate Klassen aufteilen
|
||||||
(z.B. `InputController`/`KeyHandler` für den switch-Block, Lifecycle bleibt in LauncherApp).
|
(InputController.cs extrahiert mit Key-Handling + ReloadGameEntries; Lifecycle & Main-Loop bleiben in AppHost).
|
||||||
Ziel: keine Funktionsänderung, nur Struktur.
|
Ziel: keine Funktionsänderung, nur Struktur.
|
||||||
- [ ] **Magische Strings entfernen / zentralisieren (nur im Code, siehe 2c):**
|
- [x] **Magische Strings entfernen / zentralisieren (nur im Code, siehe 2c):**
|
||||||
- [ ] Virtueller Ordnername `"Favorites"` (LauncherApp.cs) → gemeinsame Konstante (nicht konfigurierbar).
|
- [x] Virtueller Ordnername `"Favorites"` → `FavoritesService.FavoritesRootName` Konstante (nicht konfigurierbar).
|
||||||
- [ ] Dateiname `"favorites.txt"` (LauncherApp.cs) → Konstante in FavoritesService (nicht konfigurierbar).
|
- [x] Dateiname `"favorites.txt"` → `FavoritesService.DefaultFileName` Konstante (nicht konfigurierbar).
|
||||||
- [ ] Console-Titel `"Marcer GameDVD Launcher"` (Program.cs) → Konstante (nicht konfigurierbar).
|
- [x] Console-Titel `"Marcer GameDVD Launcher"` (Program.cs) → `DefaultTitle` Konstante (nicht konfigurierbar).
|
||||||
- [ ] Default-ArgsTemplate `-c "{cfg}" --disk-a "{zip}"` (LauncherApp.cs) → redundanten Fallback
|
- [x] Default-ArgsTemplate `-c "{cfg}" --disk-a "{zip}"` (LauncherApp.cs) → redundanten Fallback entfernt; Config liefert das Template (Validation `{zip}` existiert schon).
|
||||||
entfernen; Config liefert den Template (ist schon in der example definiert, Validation `{zip}` existiert).
|
- [x] **Scroll-Trigger magische Zahlen** (NavigationController.cs: `2/3`, `1/3`) in benannte Konstanten
|
||||||
- [ ] **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.
|
||||||
(z.B. `BottomScrollFraction = 2f/3f`) mit Kommentar aufzählen.
|
- [x] Redundanz beseitigen: `RedrawEntry` — überflüssige `maxRow`-Logik entfernt, `EnsureCacheForRow` durch `EnsureCache` ersetzt (klare Formulierung).
|
||||||
- [ ] Redundanz beseitigen: `RedrawEntry` hat unnötige `maxRow`-Logik; `EnsureCacheForRow` ungenau →
|
- [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).
|
||||||
klarer formulieren.
|
|
||||||
- [ ] **Fehler-Schlucken besprechen:** `FavoritesService.Save()` (catch leer) und `UIErrorService` –
|
|
||||||
entweder Kommentar ergänzen („bewusst still") oder Rückgabewert einführen.
|
|
||||||
- [ ] (Optional) Testprojekt hinzufügen für NavigationController & OverlayDirectoryBrowser –
|
- [ ] (Optional) Testprojekt hinzufügen für NavigationController & OverlayDirectoryBrowser –
|
||||||
vorab mit Nutzer klären, da Policy bisher keine Tests vorsieht.
|
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
|
- Ziel: Farben (Foreground pro Entry-Typ + Selection-Farben) in `launcher.config.json` konfigurierbar
|
||||||
machen (**Entscheidung: ja, konfigurierbar**), mit Default-Fallback auf heutige Werte.
|
machen (**Entscheidung: ja, konfigurierbar**), mit Default-Fallback auf heutige Werte.
|
||||||
|
|
||||||
- [ ] `AppConfig` erweitern: neuen Abschnitt z.B. `"Colors"` hinzufügen:
|
- [x] `AppConfig` erweitern: neuen Abschnitt `"Colors"` hinzugefügt:
|
||||||
- [ ] POCO `AppColorConfig` mit `ConsoleColor`-Werten (als String, z.B. `"Yellow"`):
|
- [x] POCO `AppColorConfig` mit `ConsoleColor`-Werten (als String, z.B. `"Yellow"`):
|
||||||
- FolderBoth, FolderPatchOnly, FolderRootOnly,
|
FolderBoth, FolderPatchOnly, FolderRootOnly, ZipBoth, ZipRootOnly, ZipPatchOnly,
|
||||||
ZipBoth, ZipRootOnly, ZipPatchOnly,
|
SelectedForeground, SelectedBackground, VirtualEntry.
|
||||||
SelectedForeground, SelectedBackground, VirtualEntry(vorab optional).
|
- [x] Deserialisierung per `Enum.TryParse<ConsoleColor>` + Default-Fallback (manuell via `ParseAppColors`/`ParseColorField`).
|
||||||
- [ ] Deserialisierung per `Enum.TryParse<ConsoleColor>` + Default-Fallback.
|
- [x] `MenuRenderer` bekommt `AppColorConfig` via Konstruktor-Injection; `GetColorForEntry`/`GetColors` nutzen Config statt Konstanten.
|
||||||
- [ ] `MenuRenderer` bekommt optional passende `AppColorConfig` (Konstruktor-Injection);
|
- [x] Falls Config-Werte fehlen → heutiges Verhalten beibehalten (fallback).
|
||||||
`GetColorForEntry`/`GetColors` nutzen Config statt Konstanten.
|
- [x] Doku synchronisiert: README + AGENTS.md Farbtabellen auf Config-Felder verlinkt.
|
||||||
- [ ] Falls Config-Werte fehlen → heutiges Verhalten beibehalten (fallback).
|
- [x] Beispielwerte ins `launcher.config.example.json` aufgenommen.
|
||||||
- [ ] Doku synchronisieren: README + AGENTS.md Farbtabellen auf Config-Felder verlinken.
|
|
||||||
- [ ] Beispielwerte ins `launcher.config.example.json` aufnehmen.
|
|
||||||
|
|
||||||
### 2b. Benutzer-Config (User-Config) – entschieden
|
### 2b. Benutzer-Config (User-Config) – entschieden
|
||||||
|
|
||||||
Ziel: die echte lokale Config gehört NICHT ins Repo, sondern bleibt lokal.
|
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
|
- [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`.
|
||||||
(lokal, gitignored). Die `launcher.config.example.json` wird beim Release mitgeliefert und der
|
- [x] KEINE zusätzliche Suchreihenfolge (`%APPDATA%`, `~/.config`) implementiert — Pfad ist ausschließlich `AppContext.BaseDirectory`.
|
||||||
Nutzer passt sie sich manuell an → kopieren zu `launcher.config.json`.
|
- [x] KEIN CLI-Parameter `--config` eingeführt.
|
||||||
- [ ] KEINE zusätzliche Suchreihenfolge (`%APPDATA%`, `~/.config`) implementieren.
|
- [x] `.gitignore` ergänzt: `launcher.config.json` und `favorites.txt` werden nie committet.
|
||||||
- [ ] KEIN CLI-Parameter `--config` einführen.
|
- [x] README: Abschnitt „Configuration" beschreibt manuelles Kopieren der example.
|
||||||
- [ ] `.gitignore` entsprechend ergänzen (echte `launcher.config.json` + `favorites.txt` werden nie committet).
|
|
||||||
- [ ] README: Abschnitt „Configuration" beschreibt nur das manuelle Kopieren der example.
|
|
||||||
|
|
||||||
### 2c. Platzhalterwerte zentralisieren (sehr detailliert)
|
### 2c. Platzhalterwerte zentralisieren (sehr detailliert)
|
||||||
|
|
||||||
@@ -80,18 +73,17 @@ externe Anpassungsmöglichkeit dafür.
|
|||||||
|
|
||||||
**Verbindliche To-dos für 2c:**
|
**Verbindliche To-dos für 2c:**
|
||||||
|
|
||||||
- [ ] Konstanten/Helfer einführen für: Favorites-Ordnername, `favorites.txt`, Konsolen-Titel,
|
- [x] Konstanten/Helfer eingeführt für: `FavoritesRootName` (FavoritesService), `DefaultFileName` (FavoritesService), `DefaultTitle` (Program), `BottomScrollFraction`/`TopScrollFraction` (NavigationController), `AvailableLines`-Helfer (ProgramHelpers).
|
||||||
Scroll-Anteile, `AvailableLines`-Helfer.
|
- [x] Default-ArgsTemplate-Fallback entfernt; Config muss `ArgsTemplate` immer liefern (Validation existiert bereits).
|
||||||
- [ ] Default-ArgsTemplate-Fallback entfernen; Config muss `ArgsTemplate` immer liefern.
|
- [x] Alle String-Literale auf Konstanten zurückgeführt (kein doppeltes `"Favorites"` mehr — nur die Konstantendefinition).
|
||||||
- [ ] Alle vorkommenden String-Literale auf die neuen Konstanten zurückführen (kein doppeltes `"Favorites"` mehr).
|
- [x] Kommentare ergänzt, die erklären, WARUM Werte fest sind (`AvailableLines` als Policy, Win32-Konstanten als API-konstant, Scroll-Anteile als feste Navigation).
|
||||||
- [ ] Kommentare ergänzen, die erklären, WARUM der Wert fest ist (z.B. `WindowHeight-1` als Policy).
|
- [x] Win32-Konstanten (Punkt 7) NICHT angefasst, nur per Kommentar als „bewusst hartkodiert" markiert.
|
||||||
- [ ] Win32-Konstanten (Punkt 7) NICHT anfassen, nur per Kommentar als „bewusst hartkodiert" markieren.
|
- [x] README/agents nicht um diese rein implementativen Werte erweitert (keine Config-Felder dokumentiert);
|
||||||
- [ ] README/agents nicht um diese rein implementativen Werte erweitern (keine Config-Felder dokumentieren);
|
|
||||||
Doku bleibt bei nutzersichtbaren, konfigurierbaren Werten (Farben, Titel, RootDir/Patch/Hatari).
|
Doku bleibt bei nutzersichtbaren, konfigurierbaren Werten (Farben, Titel, RootDir/Patch/Hatari).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Abschlusskriterien
|
## 4. Abschlusskriterien
|
||||||
|
|
||||||
- [ ] Commit mit aussagekräftiger Message (nur echter Autor, kein Co-Author).
|
- [x] 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] Nach Doku- und Code-Änderungen: `scripts/build.sh` (Linux) läuft fehlerfrei.
|
||||||
|
|||||||
@@ -1,102 +1,93 @@
|
|||||||
# Marcer GameDVD Launcher
|
# Marcer GameDVD Launcher
|
||||||
|
|
||||||
A performant, consistent console launcher for the Hatari emulator on Windows. Control is entirely via keyboard—using only the arrow keys, Enter, Backspace, ESC, PageUp, and PageDown, you can browse your game archive quickly and comfortably. Navigation is strictly limited to the configured root directory. ZIPs are seamlessly launched via Hatari. Thanks to overlay/patch mode, a consistent color scheme, and robust cursor/scroll logic, even the largest archives or deeply nested directory trees are handled smoothly and reliably.
|
A fast, keyboard-driven console launcher for the Hatari emulator. Browse your Atari ST game archive, navigate folders, and launch ZIPs — all from the terminal. Supports overlay/patch mode for comparing and merging game directories.
|
||||||
|
|
||||||
## Features
|
Built for the [Marcer GameDVD](https://www.facebook.com/groups/360493904888475/) community on Facebook.
|
||||||
- **Overlay/Patch Union:** Recursively merges main and patch directory at every level. Each object/name is shown only once (patch takes precedence).
|
|
||||||
- **Dynamic, practical color scheme with layer labels:**
|
|
||||||
|
|
||||||
| Entry | Label | ConsoleColor | Meaning
|
|
||||||
|------------------------|-----------|--------------|---------------------------------------------|
|
|
||||||
| Folder in both | `[BOTH]` | Yellow | Directory in both layers (patch/main)
|
|
||||||
| Patch-only folder | `[PTCH]` | DarkYellow | Directory only in patch layer
|
|
||||||
| Main-only folder | `[ROOT]` | Gray | Directory only in main layer
|
|
||||||
| ZIP in both | `[BOTH]` | Green | ZIP archive in both layers
|
|
||||||
| Main-only ZIP | `[ROOT]` | DarkGreen | ZIP archive only in main layer
|
|
||||||
| Patch-only ZIP | `[PTCH]` | Magenta | ZIP archive only in patch layer
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
### Released Features
|
|
||||||
- **Favorites/bookmark system:**
|
|
||||||
- Press `*` on a ZIP to toggle it as a favorite. Favorites are shown in a virtual `Favorites` folder at the top of the root listing when any favorites exist.
|
|
||||||
- Favorites are persisted in `favorites.txt` in the configured `PatchDirectory`, or next to the EXE if no patch directory is set.
|
|
||||||
|
|
||||||
### Planned Features
|
|
||||||
- **ZIP database & metadata extraction** (from version 2.0)
|
|
||||||
- Builds a database (e.g. as a local file), stores all known ZIPs
|
|
||||||
- Enables full-text search, filters, later analysis
|
|
||||||
- **Search/filter (quicksearch) over ZIPs** (from v2.0, via database)
|
|
||||||
- Fast name search inside launcher, with history
|
|
||||||
- **History/list of recently launched games** (from v2.0, via database)
|
|
||||||
- Automatic access to recently played titles
|
|
||||||
- **Overlay hot swap** (from version 3.0)
|
|
||||||
- Overlay/patch folder can be switched at runtime, instant comparison
|
|
||||||
|
|
||||||
**More ideas will be added iteratively!**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Display Performance Note:**
|
## 🎮 Features
|
||||||
- The current rendering logic follows state-of-the-art principles for performant C# console apps:
|
|
||||||
- Minimal redraw: Only the truly changed line is redrawn, never the whole screen.
|
- **Overlay/Patch Mode:** Recursively merges a main game directory with an optional patch directory. If a file or folder exists in both, the patch version takes precedence.
|
||||||
- No Console.Clear or full redraw on cursor movement—just targeted SetCursorPosition and Write.
|
- **Layer Labels:** Each entry shows its source — `[BOTH]`, `[ROOT]`, or `[PTCH]` — with a matching color scheme.
|
||||||
- This method (per StackOverflow, Spectre.Console, Terminal.Gui, etc.) is optimal for smooth navigation in large lists.
|
- **Favorites:** Press `*` on any ZIP to bookmark it. Bookmarked games appear in a virtual `Favorites` folder at the top of the root listing.
|
||||||
- Further performance can be gained with shadow buffers/string-diffs per line, but currently there's no practical performance issue.
|
- **Robust Navigation:** Cursor position is remembered per directory. Scrolling and page jumps adapt dynamically to any console height.
|
||||||
- Thus, display performance is at “best practice” level for .NET TUIs.
|
- **Minimal Redraw:** Only changed lines are redrawn — no flicker, no `Console.Clear`, smooth even in huge directory trees.
|
||||||
- Long file names and narrow console widths are handled defensively: MenuRenderer truncates file names so that each rendered line is exactly Console.WindowWidth characters long. This prevents Console.Write from overflowing the line and avoids visual artifacts when names are longer than the available width.
|
|
||||||
|
### Color Scheme
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| Main-only folder | `[ROOT]` | Gray | Only in main layer |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
### Features we will NOT implement
|
|
||||||
- User-configurable key bindings (keymap)
|
|
||||||
- Display & import of screenshots/cover images
|
|
||||||
- Music/Sound player integration (YM/MOD/SND, etc.)
|
|
||||||
- Persistent UI settings, window size management (not relevant in console mode)
|
|
||||||
|
|
||||||
## Operation and Display
|
## 🕹️ End Users
|
||||||
|
|
||||||
- **Consistent navigation & controls:**
|
### 📥 Download
|
||||||
- Arrow up/down: move selection (always visible)
|
|
||||||
- Arrow right: open folder / launch ZIP with Hatari (same as Enter)
|
|
||||||
- Arrow left: exactly one level up (same as Backspace)
|
|
||||||
- Enter: open folder / launch ZIP with Hatari (patch variant always preferred if present)
|
|
||||||
- Backspace: exactly one level up (never exceeds root)
|
|
||||||
- ESC or Q: exit the program immediately
|
|
||||||
- PageUp/PageDown: jump exactly one screen full (window height - 1)
|
|
||||||
- `*`: toggle favorite on selected ZIP
|
|
||||||
- `?`: show a help box with key bindings
|
|
||||||
- Display always one line less than console height; no overflow/cut-off
|
|
||||||
- **Cursor position saving per directory:**
|
|
||||||
- The last position/selection of each directory is retained, even after Backspace
|
|
||||||
- **Robust, smooth redraw:**
|
|
||||||
- Optimized full redraw on scrolling/paging
|
|
||||||
- Minimal redraw on cursor move
|
|
||||||
- **Minimal resource usage (handles huge trees efficiently)**
|
|
||||||
- **Navigation can NEVER leave the configured root**
|
|
||||||
|
|
||||||
## Usage
|
Download the ZIP for your platform from the [Releases](https://github.com/anomalyco/marcer-gamedvd-launcher/releases) page:
|
||||||
1. Edit `launcher.config.example.json` to set your `RootDirectory`, optional `PatchDirectory` and the `Hatari` settings, then copy it to `launcher.config.json` for local use. Relative paths are resolved against the EXE folder (build output).
|
|
||||||
2. **Windows:** Build via `scripts/build.cmd`, start via `scripts/start.cmd`.
|
|
||||||
3. **Linux/macOS:** Build via `scripts/build.sh` (run `chmod +x scripts/*.sh` first to make it executable), start via `scripts/start.sh`.
|
|
||||||
4. In the console, all subfolders and ZIPs in root (and recursively below) will be shown; other file types/hidden files are always ignored.
|
|
||||||
5. Complete navigation/control with arrow keys, Enter, Backspace, ESC, PgUp/PgDn, as described above.
|
|
||||||
6. **IMPORTANT:** Navigation/scroll/backspace:
|
|
||||||
- Backspace never escapes the root
|
|
||||||
- In root, Backspace has no effect
|
|
||||||
- Empty directories are reported (display stays stable)
|
|
||||||
7. **Overlay/patch logic:**
|
|
||||||
- If a ZIP/folder exists in both patch and main, always the patch version opens/launches
|
|
||||||
- All navigation is relative to root path—for consistent experience
|
|
||||||
|
|
||||||
## System Requirements
|
| Platform | Archive |
|
||||||
- **Windows:** .NET Desktop Runtime 10 or later, Hatari Emulator with configured CFG
|
|---|---|
|
||||||
- **Linux:** .NET Runtime 10 or later, Hatari Emulator with configured CFG (Wine/compatible version)
|
| Windows | `*-win-x64.zip` |
|
||||||
|
| Linux | `*-linux-x64.zip` |
|
||||||
|
| macOS | `*-osx-x64.zip` |
|
||||||
|
|
||||||
## Configuration
|
### 💻 System Requirements
|
||||||
|
|
||||||
The application reads settings from `launcher.config.json` (the local, user-specific file). A template `launcher.config.example.json` is shipped with the release — copy it to `launcher.config.json` and adjust the paths for your environment.
|
- **.NET Runtime 10** or later ([download](https://dotnet.microsoft.com/download/dotnet/10.0))
|
||||||
|
- **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.
|
||||||
|
- **TOS 1.04** ROM image (`TOS.IMG`) — required by Hatari to boot the Atari ST system.
|
||||||
|
|
||||||
Example `launcher.config.example.json`:
|
### 🕹️ Setting Up Hatari
|
||||||
|
|
||||||
|
Before using the launcher, you need to install Hatari and provide a TOS ROM:
|
||||||
|
|
||||||
|
1. **Download Hatari** from the official site: [https://hatari.tuxfamily.org/download.html](https://hatari.tuxfamily.org/download.html)
|
||||||
|
- **Windows:** Download and extract the latest Windows binary.
|
||||||
|
- **Linux:** Install via your package manager (e.g. `sudo apt install hatari`) or build from source.
|
||||||
|
- **macOS:** Download the macOS build or install via Homebrew: `brew install hatari`.
|
||||||
|
|
||||||
|
2. **Get TOS 1.04** (also known as TOS UK or TOS 1.04):
|
||||||
|
- TOS is copyrighted Atari ROM software — you must own a legal copy.
|
||||||
|
- Place the ROM file as `TOS.IMG` in the same directory as the launcher executable (or the Hatari working directory).
|
||||||
|
- The bundled `MarcerGameDvd-Hatari.cfg` is preconfigured to look for `TOS.IMG` in the current directory.
|
||||||
|
|
||||||
|
3. **Configure the path** in `launcher.config.json`:
|
||||||
|
```json
|
||||||
|
"Hatari": {
|
||||||
|
"Executable": "C:\\Tools\\hatari\\hatari.exe",
|
||||||
|
"ConfigFile": "",
|
||||||
|
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Set `Hatari.Executable` to the full path of your `hatari` (or `hatari.exe`) binary.
|
||||||
|
- Leave `Hatari.ConfigFile` empty to use the bundled configuration automatically.
|
||||||
|
|
||||||
|
### ⚡ Quick Start
|
||||||
|
|
||||||
|
1. Extract the release ZIP to any folder.
|
||||||
|
2. Copy `launcher.config.example.json` to `launcher.config.json`.
|
||||||
|
3. Edit `launcher.config.json` — set your game directory and Hatari paths (see [Configuration](#%EF%B8%8F-configuration) below).
|
||||||
|
4. Run the launcher:
|
||||||
|
- **Windows:** Double-click `MarcerGameDvdLauncher.exe` or run from a terminal.
|
||||||
|
- **Linux:** `chmod +x MarcerGameDvdLauncher && ./MarcerGameDvdLauncher`
|
||||||
|
- **macOS:** `chmod +x MarcerGameDvdLauncher && ./MarcerGameDvdLauncher`
|
||||||
|
5. Browse and launch games with your keyboard.
|
||||||
|
|
||||||
|
### ⚙️ Configuration
|
||||||
|
|
||||||
|
The launcher reads `launcher.config.json` from the same directory as the executable. A template is included in the release — copy it and adjust:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -104,49 +95,197 @@ Example `launcher.config.example.json`:
|
|||||||
"PatchDirectory": "C:\\Games\\Hatari\\PATCH",
|
"PatchDirectory": "C:\\Games\\Hatari\\PATCH",
|
||||||
"Hatari": {
|
"Hatari": {
|
||||||
"Executable": "C:\\Tools\\hatari\\hatari.exe",
|
"Executable": "C:\\Tools\\hatari\\hatari.exe",
|
||||||
"ConfigFile": "C:\\Tools\\hatari\\hatari-st.cfg",
|
"ConfigFile": "",
|
||||||
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Fields:
|
| Field | Required | Description |
|
||||||
- RootDirectory: Absolute (or relative) path to the game root. Navigation must never leave this root directory.
|
|---|---|---|
|
||||||
- PatchDirectory: Optional overlay/patch directory (merged with the main root at runtime).
|
| `RootDirectory` | ✅ | Game root folder. Navigation never leaves this directory. |
|
||||||
- Hatari.Executable: Full path to the Hatari executable.
|
| `PatchDirectory` | ❌ | Optional overlay/patch directory merged at runtime. |
|
||||||
- Hatari.ConfigFile: Full path to the Hatari configuration file.
|
| `Hatari.Executable` | ✅ | Path to the Hatari executable. Validated at startup. |
|
||||||
- Hatari.ArgsTemplate: Argument template used to start Hatari. Use `{cfg}` for the Hatari config file path and `{zip}` for the ZIP file to launch.
|
| `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:
|
**Notes:**
|
||||||
- Relative paths are resolved relative to the EXE directory (AppContext.BaseDirectory). This makes behavior consistent when running from the build output folder.
|
- Relative paths are resolved relative to the executable's directory.
|
||||||
- Hatari.Executable is validated at startup: the file must exist. Relative paths for Hatari settings are resolved against the EXE folder.
|
- Include quotes around `{cfg}` and `{zip}` in the template if your paths contain spaces.
|
||||||
- `Hatari.ArgsTemplate` must contain at least the `{zip}` placeholder. Example: `-c "{cfg}" --disk-a "{zip}"`.
|
- After editing `launcher.config.json`, restart the application.
|
||||||
- The program performs a straight string substitution of `{cfg}` and `{zip}`; it does not add additional quoting logic. Therefore include quotes around placeholders in the template if your paths contain spaces (as in the example).
|
|
||||||
- `launcher.config.example.json` is copied to the output directory by the csproj (`CopyToOutputDirectory=PreserveNewest`).
|
|
||||||
- After modifying `launcher.config.json`, restart the application for changes to take effect.
|
|
||||||
|
|
||||||
## Release Workflow
|
### 🎨 Color Scheme
|
||||||
|
|
||||||
Releases are automated via GitHub Actions. When a tag matching `v*` is pushed, the workflow (`.github/workflows/release.yml`) automatically:
|
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:
|
||||||
1. Builds platform-specific artifacts (Windows, Linux, macOS)
|
|
||||||
2. Generates release notes from git log
|
| Field | Default | Applies to |
|
||||||
3. Creates a GitHub Release with all ZIPs attached
|
|---|---|---|
|
||||||
|
| `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.
|
||||||
|
- Include quotes around `{cfg}` and `{zip}` in the template if your paths contain spaces.
|
||||||
|
- After editing `launcher.config.json`, restart the application.
|
||||||
|
|
||||||
|
### ⌨️ Controls
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `↑` / `↓` | Move selection |
|
||||||
|
| `Enter` / `→` | Open folder or launch ZIP |
|
||||||
|
| `Backspace` / `←` | Go up one directory level |
|
||||||
|
| `PageUp` / `PageDown` | Jump one page |
|
||||||
|
| `*` | Toggle favorite on selected ZIP |
|
||||||
|
| `?` | Show help overlay |
|
||||||
|
| `ESC` / `Q` | Exit |
|
||||||
|
|
||||||
|
**Navigation rules:**
|
||||||
|
- Backspace in the root directory has no effect — you can never leave it.
|
||||||
|
- Empty directories are displayed correctly.
|
||||||
|
- When a ZIP or folder exists in both layers, the patch version is always launched/opened.
|
||||||
|
|
||||||
|
### 🔄 Keeping Your Patch Directory Updated
|
||||||
|
|
||||||
|
The community uses [ftp-sync](https://github.com/slippyex/ftp-sync) to keep the patch directory in sync with Marcer's FTP server. This downloads only changed or new files — fast and bandwidth-friendly.
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
|
||||||
|
1. Clone and install ftp-sync:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/slippyex/ftp-sync.git
|
||||||
|
cd ftp-sync
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a `config.json` with your paths and the FTP credentials from the community:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ftpConfig": {
|
||||||
|
"host": "<ftp-host>",
|
||||||
|
"user": "<username>",
|
||||||
|
"password": "<password>",
|
||||||
|
"port": 2121
|
||||||
|
},
|
||||||
|
"localDir": "C:\\Games\\MarcersGameDVD\\",
|
||||||
|
"remoteDir": "/GameDVD",
|
||||||
|
"patchDir": "C:\\Games\\MarcersGameDVD-Patch\\"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> 💡 Ask in the [Facebook group](https://www.facebook.com/groups/360493904888475/) for the current FTP credentials.
|
||||||
|
|
||||||
|
3. Run the sync:
|
||||||
|
```bash
|
||||||
|
npm run sync config.json
|
||||||
|
```
|
||||||
|
Press `s` to start syncing. Press `q` to exit when done.
|
||||||
|
|
||||||
|
4. Point the launcher's `PatchDirectory` in `launcher.config.json` to the `patchDir` from your ftp-sync config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Developers
|
||||||
|
|
||||||
|
### 📋 Prerequisites
|
||||||
|
|
||||||
|
- [.NET SDK 10](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||||
|
- Windows: `build.cmd` / `start.cmd`
|
||||||
|
- Linux / macOS: `build.sh` / `start.sh` (run `chmod +x scripts/*.sh` first)
|
||||||
|
|
||||||
|
### 🔨 Build & Run
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
```bat
|
||||||
|
scripts\build.cmd
|
||||||
|
scripts\start.cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux / macOS:**
|
||||||
|
```bash
|
||||||
|
scripts/build.sh
|
||||||
|
scripts/start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ Do **not** use `dotnet build` or `dotnet run` directly — always use the platform build script to ensure consistent output.
|
||||||
|
|
||||||
|
### 🧪 Demo Mode
|
||||||
|
|
||||||
|
`demo.sh` creates a complete test environment with fake ZIPs and two layers (root + patch), then launches the launcher:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/demo.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds the project, generates a structured `.demo/` directory with sample folders and ZIPs, writes a matching `launcher.config.json`, and starts the launcher. Useful for quickly testing overlay behavior and navigation without setting up real game files.
|
||||||
|
|
||||||
|
> ⚠️ Windows is not supported for `demo.sh`. Use `start.cmd` with your own game files instead.
|
||||||
|
|
||||||
|
### 🚀 Release Process
|
||||||
|
|
||||||
|
Releases are automated via GitHub Actions (`.github/workflows/release.yml`).
|
||||||
|
|
||||||
**To create a release:**
|
**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.
|
1. Ensure `README.md` and `AGENTS.md` are up to date.
|
||||||
2. Commit all changes.
|
2. Commit all changes.
|
||||||
3. Create and push a tag: `git tag v{version} && git push origin v{version}`.
|
3. Tag and push:
|
||||||
4. The GitHub Action handles the rest.
|
```bash
|
||||||
|
git tag v1.2.3
|
||||||
|
git push origin v1.2.3
|
||||||
|
```
|
||||||
|
4. The workflow builds platform ZIPs, generates release notes, and creates a GitHub Release.
|
||||||
|
|
||||||
**Local builds** (for development/testing):
|
---
|
||||||
- **Windows:** `scripts/build.cmd` to build, `scripts/start.cmd` to run
|
|
||||||
- **Linux/macOS:** `scripts/build.sh` to build, `scripts/start.sh` to run (run `chmod +x scripts/*.sh` first)
|
|
||||||
|
|
||||||
## Community
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
This launcher was built for the [Marcer GameDVD](https://www.facebook.com/groups/360493904888475/) community on Facebook. If you have questions, suggestions, or want to discuss Hatari and Atari ST gaming, join the group!
|
| Version | Feature |
|
||||||
|
|---|---|
|
||||||
|
| 2.0 | ZIP database & metadata extraction |
|
||||||
|
| 2.0 | Quicksearch / filter over ZIPs |
|
||||||
|
| 2.0 | History of recently launched games |
|
||||||
|
| 3.0 | Overlay hot-swap at runtime |
|
||||||
|
|
||||||
## Notes
|
**Not planned:** Configurable keybindings, screenshot/cover display, sound/music integration, persistent UI settings.
|
||||||
- Full requirements, features and build rules are always up to date in `AGENTS.md`.
|
|
||||||
- After every code or feature change and every release, README.md and AGENTS.md must be reviewed and kept up to date.
|
---
|
||||||
- For every release, release notes **must** be present listing all changes and bugfixes; this is required by AGENTS.md!
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
- Full technical requirements and build rules are in [`AGENTS.md`](AGENTS.md).
|
||||||
|
- After any functional change, both `README.md` and `AGENTS.md` must be updated.
|
||||||
|
- Every release **must** include release notes listing all changes and bugfixes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
# Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
# === Demo setup and run script for MarcerGameDvdLauncher ===
|
# === Demo setup and run script for MarcerGameDvdLauncher ===
|
||||||
# Creates a structured test directory with main and patch layers,
|
# Creates a structured test directory with main and patch layers,
|
||||||
# populates them with fake ZIPs, then launches the launcher.
|
# populates them with fake ZIPs, then launches the launcher.
|
||||||
@@ -10,11 +13,11 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
DEMO_DIR="$SCRIPT_DIR/.demo"
|
DEMO_DIR="$SCRIPT_DIR/.demo"
|
||||||
ROOT_DIR="$DEMO_DIR/root"
|
ROOT_DIR="$DEMO_DIR/root"
|
||||||
PATCH_DIR="$DEMO_DIR/patch"
|
PATCH_DIR="$DEMO_DIR/patch"
|
||||||
CONFIG_FILE="$SCRIPT_DIR/launcher.config.json"
|
CONFIG_FILE="$DEMO_DIR/launcher.config.json"
|
||||||
|
|
||||||
# --- Step 1: Build ---
|
# --- Step 1: Build ---
|
||||||
echo "=== Building MarcerGameDvdLauncher ==="
|
echo "=== Building MarcerGameDvdLauncher ==="
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR/src/MarcerGameDvdLauncher"
|
||||||
dotnet build -c Release --verbosity quiet
|
dotnet build -c Release --verbosity quiet
|
||||||
echo "[OK] Build successful."
|
echo "[OK] Build successful."
|
||||||
|
|
||||||
@@ -80,6 +83,11 @@ fake_zip "$ROOT_DIR/E/Racing/Pole Position.zip"
|
|||||||
fake_zip "$ROOT_DIR/E/Racing/Out Run.zip"
|
fake_zip "$ROOT_DIR/E/Racing/Out Run.zip"
|
||||||
fake_zip "$ROOT_DIR/E/Racing/Daytona USA.zip"
|
fake_zip "$ROOT_DIR/E/Racing/Daytona USA.zip"
|
||||||
|
|
||||||
|
# === Folders G-Z: empty placeholder folders ===
|
||||||
|
for letter in {G..Z}; do
|
||||||
|
mkdir -p "$ROOT_DIR/$letter"
|
||||||
|
done
|
||||||
|
|
||||||
# --- PATCH layer (overlay/additions) ---
|
# --- PATCH layer (overlay/additions) ---
|
||||||
echo "Creating patch layer..."
|
echo "Creating patch layer..."
|
||||||
|
|
||||||
@@ -157,7 +165,7 @@ echo "=== Writing launcher.config.json ==="
|
|||||||
HATARI_FAKE="$DEMO_DIR/hatari.exe"
|
HATARI_FAKE="$DEMO_DIR/hatari.exe"
|
||||||
cat > "$HATARI_FAKE" <<'HATEXEC'
|
cat > "$HATARI_FAKE" <<'HATEXEC'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
echo "[DEMO] Hatari would launch with: $@"
|
echo "[DEMO] Hatari would launch with: $0 $@"
|
||||||
HATEXEC
|
HATEXEC
|
||||||
chmod +x "$HATARI_FAKE"
|
chmod +x "$HATARI_FAKE"
|
||||||
|
|
||||||
@@ -168,7 +176,7 @@ cat > "$CONFIG_FILE" <<EOF
|
|||||||
"Hatari": {
|
"Hatari": {
|
||||||
"Executable": "$HATARI_FAKE",
|
"Executable": "$HATARI_FAKE",
|
||||||
"ConfigFile": "",
|
"ConfigFile": "",
|
||||||
"ArgsTemplate": "{zip}"
|
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
@@ -185,5 +193,5 @@ echo "=== Launching MarcerGameDvdLauncher ==="
|
|||||||
echo "Controls: Arrow keys, Enter, Backspace, ESC to exit"
|
echo "Controls: Arrow keys, Enter, Backspace, ESC to exit"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR/src/MarcerGameDvdLauncher"
|
||||||
dotnet run --project src/MarcerGameDvdLauncher -c Release
|
dotnet run -c Release
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"schedule": ["before 6am on Monday"],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchManagers": ["nuget"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "nuget minor/patch",
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["github-actions"],
|
||||||
|
"groupName": "GitHub Actions",
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["dockerfile"],
|
||||||
|
"groupName": "Docker",
|
||||||
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["docker-compose"],
|
||||||
|
"groupName": "Docker Compose",
|
||||||
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"labels": ["major-update"],
|
||||||
|
"automerge": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
REM Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
REM Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
@echo off
|
@echo off
|
||||||
|
|
||||||
REM === Build script for MarcerGameDvdLauncher (requires .NET SDK 6 or newer) ===
|
REM === Build script for MarcerGameDvdLauncher (requires .NET SDK 6 or newer) ===
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
# Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
# === Build script for MarcerGameDvdLauncher (requires .NET SDK 6 or newer) ===
|
# === Build script for MarcerGameDvdLauncher (requires .NET SDK 6 or newer) ===
|
||||||
echo "Building MarcerGameDvdLauncher..."
|
echo "Building MarcerGameDvdLauncher..."
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
REM Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
REM Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
@echo off
|
@echo off
|
||||||
|
|
||||||
REM Starts MarcerGameDvdLauncher.exe (script is in scripts/, code in src/)
|
REM Starts MarcerGameDvdLauncher.exe (script is in scripts/, code in src/)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
# Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
# Starts MarcerGameDvdLauncher (script is in scripts/, code in src/)
|
# Starts MarcerGameDvdLauncher (script is in scripts/, code in src/)
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
EXE_PATH="$SCRIPT_DIR/../src/MarcerGameDvdLauncher/bin/Release/net10.0/MarcerGameDvdLauncher"
|
EXE_PATH="$SCRIPT_DIR/../src/MarcerGameDvdLauncher/bin/Release/net10.0/MarcerGameDvdLauncher"
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
namespace MarcerGameDvdLauncher
|
namespace MarcerGameDvdLauncher
|
||||||
{
|
{
|
||||||
// Configuration POCOs separated into their own file for clarity
|
// 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 class AppHatariConfig
|
||||||
{
|
{
|
||||||
public string? Executable { get; set; }
|
public string? Executable { get; set; }
|
||||||
@@ -13,5 +35,9 @@ namespace MarcerGameDvdLauncher
|
|||||||
public string? RootDirectory { get; set; }
|
public string? RootDirectory { get; set; }
|
||||||
public string? PatchDirectory { get; set; }
|
public string? PatchDirectory { get; set; }
|
||||||
public AppHatariConfig? Hatari { 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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
namespace MarcerGameDvdLauncher
|
namespace MarcerGameDvdLauncher
|
||||||
{
|
{
|
||||||
// Manages loading, saving and querying favorite ZIP paths.
|
// Manages loading, saving and querying favorite ZIP paths.
|
||||||
public class FavoritesService
|
public class FavoritesService(string filePath)
|
||||||
{
|
{
|
||||||
private readonly string _filePath;
|
// 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 = filePath ?? throw new ArgumentNullException(nameof(filePath));
|
||||||
// Use a SortedSet so favorites are kept in sorted order in memory.
|
// Use a SortedSet so favorites are kept in sorted order in memory.
|
||||||
private SortedSet<string> _favorites = new(StringComparer.OrdinalIgnoreCase);
|
private SortedSet<string> _favorites = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public FavoritesService(string filePath)
|
|
||||||
{
|
|
||||||
_filePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load favorites from disk (no-op if file missing)
|
// Load favorites from disk (no-op if file missing)
|
||||||
public void Load()
|
public void Load()
|
||||||
{
|
{
|
||||||
@@ -20,7 +26,7 @@ namespace MarcerGameDvdLauncher
|
|||||||
var lines = File.ReadAllLines(_filePath, System.Text.Encoding.UTF8);
|
var lines = File.ReadAllLines(_filePath, System.Text.Encoding.UTF8);
|
||||||
foreach (var l in lines)
|
foreach (var l in lines)
|
||||||
{
|
{
|
||||||
var t = l?.Trim();
|
var t = l.Trim();
|
||||||
if (string.IsNullOrEmpty(t)) continue;
|
if (string.IsNullOrEmpty(t)) continue;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -60,14 +66,13 @@ namespace MarcerGameDvdLauncher
|
|||||||
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path));
|
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path));
|
||||||
var full = Path.GetFullPath(path);
|
var full = Path.GetFullPath(path);
|
||||||
bool added;
|
bool added;
|
||||||
if (_favorites.Contains(full))
|
if (!_favorites.Add(full))
|
||||||
{
|
{
|
||||||
_favorites.Remove(full);
|
_favorites.Remove(full);
|
||||||
added = false;
|
added = false;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_favorites.Add(full);
|
|
||||||
added = true;
|
added = true;
|
||||||
}
|
}
|
||||||
Save();
|
Save();
|
||||||
@@ -84,7 +89,11 @@ namespace MarcerGameDvdLauncher
|
|||||||
}
|
}
|
||||||
catch
|
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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
namespace MarcerGameDvdLauncher
|
namespace MarcerGameDvdLauncher
|
||||||
{
|
{
|
||||||
public class HatariLauncher
|
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.
|
||||||
|
private const string DEFAULT_CONFIG_FILE = "MarcerGameDvd-Hatari.cfg";
|
||||||
|
|
||||||
private readonly string _exePath;
|
private readonly string _exePath;
|
||||||
private readonly string _cfgPath;
|
private readonly string _cfgPath;
|
||||||
private readonly string _argsTemplate;
|
private readonly string _argsTemplate;
|
||||||
@@ -15,6 +23,12 @@ namespace MarcerGameDvdLauncher
|
|||||||
if (!File.Exists(exePath))
|
if (!File.Exists(exePath))
|
||||||
throw new ArgumentException($"Hatari executable not found: {exePath}", nameof(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));
|
||||||
|
if (string.IsNullOrWhiteSpace(argsTemplate) || !argsTemplate.Contains("{cfg}"))
|
||||||
|
throw new ArgumentException("Hatari.ArgsTemplate must contain the {cfg} placeholder.", nameof(argsTemplate));
|
||||||
|
|
||||||
_exePath = exePath;
|
_exePath = exePath;
|
||||||
_cfgPath = cfgPath;
|
_cfgPath = cfgPath;
|
||||||
_argsTemplate = argsTemplate;
|
_argsTemplate = argsTemplate;
|
||||||
@@ -30,20 +44,21 @@ namespace MarcerGameDvdLauncher
|
|||||||
throw new ArgumentException("ZIP archive path must not be empty.", nameof(zipFilePath));
|
throw new ArgumentException("ZIP archive path must not be empty.", nameof(zipFilePath));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string args = _argsTemplate.Replace("{cfg}", _cfgPath).Replace("{zip}", zipFilePath);
|
var args = _argsTemplate;
|
||||||
|
args = args.Replace("{cfg}", !string.IsNullOrWhiteSpace(_cfgPath) ? _cfgPath : Path.Combine(Directory.GetCurrentDirectory(),DEFAULT_CONFIG_FILE));
|
||||||
|
args = args.Replace("{zip}", zipFilePath);
|
||||||
|
|
||||||
var psi = new System.Diagnostics.ProcessStartInfo
|
var psi = new System.Diagnostics.ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = _exePath,
|
FileName = _exePath,
|
||||||
Arguments = args,
|
Arguments = args,
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
WorkingDirectory = Path.GetDirectoryName(_exePath) ?? string.Empty
|
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||||
};
|
};
|
||||||
System.Diagnostics.Process.Start(psi);
|
var process = System.Diagnostics.Process.Start(psi);
|
||||||
// Show a modal indicating the emulator was started and wait until
|
// Show a modal indicating the emulator is running and wait until
|
||||||
// the user releases the Return key before clearing the modal. This
|
// the process has exited before clearing the modal.
|
||||||
// prevents accidental key repeats from triggering other actions.
|
ProgramHelpers.ShowModalUntilProcessExited(process, "Hatari is running...");
|
||||||
ProgramHelpers.ShowModalUntilReturnReleased("Hatari started. Release Return to continue...");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
|
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(
|
||||||
|
OverlayDirectoryBrowser directoryBrowser,
|
||||||
|
MenuRenderer menuRenderer,
|
||||||
|
NavigationController navigationController,
|
||||||
|
HatariLauncher hatariLauncher,
|
||||||
|
FavoritesService favoritesService,
|
||||||
|
UIErrorService errorService)
|
||||||
|
{
|
||||||
|
private List<GameEntry> _gameEntries = new();
|
||||||
|
|
||||||
|
private bool IsFavorite(GameEntry e)
|
||||||
|
{
|
||||||
|
return e.Kind == EntryKind.Zip && favoritesService.IsFavorite(e.InPatch ? e.PatchPath : e.RootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// Redraw menu after Hatari has exited
|
||||||
|
DrawMenu(availableLines);
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
namespace MarcerGameDvdLauncher
|
namespace MarcerGameDvdLauncher
|
||||||
{
|
{
|
||||||
// Encapsulates application lifecycle: load config, initialize components, run navigation
|
// Encapsulates application lifecycle: load config, initialize components, run navigation
|
||||||
@@ -60,45 +63,96 @@ namespace MarcerGameDvdLauncher
|
|||||||
if (!File.Exists(cfg.Hatari.Executable))
|
if (!File.Exists(cfg.Hatari.Executable))
|
||||||
throw new InvalidOperationException($"Hatari executable not found: {cfg.Hatari.Executable}");
|
throw new InvalidOperationException($"Hatari executable not found: {cfg.Hatari.Executable}");
|
||||||
|
|
||||||
// Also validate the Hatari config file (if provided)
|
// Validate the Hatari config file (either user-specified or bundled fallback)
|
||||||
if (!string.IsNullOrWhiteSpace(cfg.Hatari.ConfigFile) && !File.Exists(cfg.Hatari.ConfigFile))
|
if (!string.IsNullOrEmpty(cfg.Hatari.ConfigFile) && !File.Exists(cfg.Hatari.ConfigFile))
|
||||||
throw new InvalidOperationException($"Hatari configuration file not found: {cfg.Hatari.ConfigFile}");
|
throw new InvalidOperationException($"Hatari configuration file not found: {cfg.Hatari.ConfigFile}");
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.Hatari.ArgsTemplate) || !cfg.Hatari.ArgsTemplate.Contains("{cfg}"))
|
||||||
|
throw new InvalidOperationException("Hatari.ArgsTemplate must contain the {cfg} placeholder.");
|
||||||
if (string.IsNullOrWhiteSpace(cfg.Hatari.ArgsTemplate) || !cfg.Hatari.ArgsTemplate.Contains("{zip}"))
|
if (string.IsNullOrWhiteSpace(cfg.Hatari.ArgsTemplate) || !cfg.Hatari.ArgsTemplate.Contains("{zip}"))
|
||||||
throw new InvalidOperationException("Hatari.ArgsTemplate must contain the {zip} placeholder.");
|
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;
|
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)
|
internal class AppHost(AppConfig cfg)
|
||||||
{
|
{
|
||||||
OverlayDirectoryBrowser? _directoryBrowser;
|
private InputController? _inputController;
|
||||||
MenuRenderer _menuRenderer = new MenuRenderer();
|
private MenuRenderer? _menuRenderer;
|
||||||
NavigationController _navigationController = new NavigationController();
|
private HatariLauncher? _hatariLauncher;
|
||||||
List<GameEntry> _gameEntries = new List<GameEntry>();
|
private int _currentAvailableLines;
|
||||||
HatariLauncher? _hatariLauncher;
|
private int _currentWidth;
|
||||||
readonly UIErrorService _errorService = new UIErrorService();
|
|
||||||
FavoritesService? _favoritesService;
|
|
||||||
|
|
||||||
public void InitializeComponents()
|
public void InitializeComponents()
|
||||||
{
|
{
|
||||||
_directoryBrowser = new OverlayDirectoryBrowser(cfg.RootDirectory ?? string.Empty, cfg.PatchDirectory ?? string.Empty);
|
var directoryBrowser = new OverlayDirectoryBrowser(cfg.RootDirectory ?? string.Empty, cfg.PatchDirectory ?? string.Empty);
|
||||||
|
|
||||||
// Initialize favorites service. Use PatchDirectory if present, otherwise exe dir fallback.
|
// Initialize favorites service. Use PatchDirectory if present, otherwise exe dir fallback.
|
||||||
string favPath;
|
string favPath;
|
||||||
if (!string.IsNullOrWhiteSpace(cfg.PatchDirectory))
|
if (!string.IsNullOrWhiteSpace(cfg.PatchDirectory))
|
||||||
{
|
{
|
||||||
favPath = Path.Combine(cfg.PatchDirectory!, "favorites.txt");
|
favPath = Path.Combine(cfg.PatchDirectory!, FavoritesService.DefaultFileName);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
favPath = Path.Combine(AppContext.BaseDirectory, "favorites.txt");
|
favPath = Path.Combine(AppContext.BaseDirectory, FavoritesService.DefaultFileName);
|
||||||
}
|
}
|
||||||
_favoritesService = new FavoritesService(favPath);
|
var favoritesService = new FavoritesService(favPath);
|
||||||
try { _favoritesService.Load(); } catch { /* ignore load errors */ }
|
try { favoritesService.Load(); } catch { /* ignore load errors */ }
|
||||||
|
|
||||||
|
var errorService = new UIErrorService();
|
||||||
try
|
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}\"");
|
// 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -106,32 +160,38 @@ namespace MarcerGameDvdLauncher
|
|||||||
], ConsoleColor.Red);
|
], ConsoleColor.Red);
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_menuRenderer = new MenuRenderer(cfg.Colors);
|
||||||
|
var navigationController = new NavigationController();
|
||||||
|
|
||||||
|
_inputController = new InputController(directoryBrowser, _menuRenderer, navigationController,
|
||||||
|
_hatariLauncher!, favoritesService, errorService);
|
||||||
|
|
||||||
Console.CursorVisible = false;
|
Console.CursorVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RunDirectoryNavigation()
|
public void RunDirectoryNavigation()
|
||||||
{
|
{
|
||||||
|
_currentAvailableLines = ProgramHelpers.AvailableLines;
|
||||||
|
_currentWidth = Console.WindowWidth;
|
||||||
|
|
||||||
|
_inputController!.ReloadGameEntries();
|
||||||
|
_inputController.RefreshView(_currentAvailableLines);
|
||||||
|
|
||||||
bool exitRequested = false;
|
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)
|
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.
|
// Do NOT hit the filesystem here on every loop iteration.
|
||||||
|
|
||||||
// detect a change in console height and/or width and redraw immediately
|
// 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;
|
int latestWidth = Console.WindowWidth;
|
||||||
if (latestAvailableLines != currentAvailableLines || latestWidth != currentWidth)
|
if (latestAvailableLines != _currentAvailableLines || latestWidth != _currentWidth)
|
||||||
{
|
{
|
||||||
currentAvailableLines = latestAvailableLines;
|
_currentAvailableLines = latestAvailableLines;
|
||||||
currentWidth = latestWidth;
|
_currentWidth = latestWidth;
|
||||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
|
_inputController.RefreshView(_currentAvailableLines);
|
||||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only block if there's actually a key; otherwise allow resize detection
|
// Only block if there's actually a key; otherwise allow resize detection
|
||||||
@@ -142,168 +202,7 @@ namespace MarcerGameDvdLauncher
|
|||||||
}
|
}
|
||||||
|
|
||||||
var key = Console.ReadKey(intercept: true);
|
var key = Console.ReadKey(intercept: true);
|
||||||
if (key.KeyChar == '?')
|
exitRequested = _inputController.HandleKey(key, _currentAvailableLines);
|
||||||
{
|
|
||||||
_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 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);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
[Log]
|
||||||
|
sLogFileName = stderr
|
||||||
|
sTraceFileName = stderr
|
||||||
|
nTextLogLevel = 3
|
||||||
|
nAlertDlgLogLevel = 1
|
||||||
|
bConfirmQuit = TRUE
|
||||||
|
bNatFeats = FALSE
|
||||||
|
bConsoleWindow = FALSE
|
||||||
|
|
||||||
|
[Debugger]
|
||||||
|
nNumberBase = 10
|
||||||
|
nSymbolLines = -1
|
||||||
|
nMemdumpLines = -1
|
||||||
|
nFindLines = -1
|
||||||
|
nDisasmLines = -1
|
||||||
|
nBacktraceLines = 0
|
||||||
|
nExceptionDebugMask = 1073741830
|
||||||
|
nDisasmOptions = 7
|
||||||
|
nMemConvLocale = FALSE
|
||||||
|
bDisasmUAE = TRUE
|
||||||
|
nSymbolsAutoLoad = 1
|
||||||
|
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
|
||||||
|
szScreenShotDir =
|
||||||
|
bUseVsync = FALSE
|
||||||
|
|
||||||
|
[Joystick0]
|
||||||
|
nJoystickMode = 0
|
||||||
|
bEnableAutoFire = FALSE
|
||||||
|
bEnableJumpOnFire2 = TRUE
|
||||||
|
nJoyId = 0
|
||||||
|
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 = 0
|
||||||
|
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 = 0
|
||||||
|
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 = 0
|
||||||
|
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 = 0
|
||||||
|
nJoyBut1Index = 0
|
||||||
|
nJoyBut2Index = 1
|
||||||
|
nJoyBut3Index = 2
|
||||||
|
kUp = Up
|
||||||
|
kDown = Down
|
||||||
|
kLeft = Left
|
||||||
|
kRight = Right
|
||||||
|
kFire = Right Ctrl
|
||||||
|
|
||||||
|
[Keyboard]
|
||||||
|
bFastForwardKeyRepeat = TRUE
|
||||||
|
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]
|
||||||
|
bUseDevice0 = FALSE
|
||||||
|
sDeviceFile0 =
|
||||||
|
nBlockSize0 = 512
|
||||||
|
nAcsiVersion0 = 1
|
||||||
|
|
||||||
|
|
||||||
|
[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
|
||||||
|
nRtcYear = 0
|
||||||
|
bPatchTimerD = FALSE
|
||||||
|
bFastBoot = FALSE
|
||||||
|
bFastForward = FALSE
|
||||||
|
bAddressSpace24 = TRUE
|
||||||
|
bCycleExactCpu = TRUE
|
||||||
|
bCpuDataCache = TRUE
|
||||||
|
n_FPUType = 0
|
||||||
|
bSoftFloatFPU = FALSE
|
||||||
|
bMMU = TRUE
|
||||||
|
VideoTiming = 3
|
||||||
|
|
||||||
|
[Video]
|
||||||
|
AviRecordVcodec = 2
|
||||||
|
AviRecordFps = 0
|
||||||
|
AviRecordFile =
|
||||||
|
|
||||||
@@ -14,6 +14,13 @@
|
|||||||
<Content Include="launcher.config.example.json">
|
<Content Include="launcher.config.example.json">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="MarcerGameDvd-Hatari.cfg">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Include="../../LICENSE">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<Link>LICENSE</Link>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
namespace MarcerGameDvdLauncher
|
namespace MarcerGameDvdLauncher
|
||||||
{
|
{
|
||||||
public class MenuRenderer
|
public class MenuRenderer(AppColorConfig? colors = null)
|
||||||
{
|
{
|
||||||
// Simple line-based double buffer to avoid full Clear() flicker.
|
// Simple line-based double buffer to avoid full Clear() flicker.
|
||||||
// cachedBuffer holds the last rendered text and colors for each visible row.
|
// cachedBuffer holds the last rendered text and colors for each visible row.
|
||||||
@@ -8,6 +11,9 @@ namespace MarcerGameDvdLauncher
|
|||||||
private LineState[] _cachedBuffer = Array.Empty<LineState>();
|
private LineState[] _cachedBuffer = Array.Empty<LineState>();
|
||||||
private int _cachedWidth = -1;
|
private int _cachedWidth = -1;
|
||||||
|
|
||||||
|
// Color configuration (injected; defaults to built-in scheme if null)
|
||||||
|
private readonly AppColorConfig _colors = colors ?? new AppColorConfig();
|
||||||
|
|
||||||
// availableLines is provided per-draw so the renderer adapts to console resizes
|
// 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)
|
public void DrawMenu(List<GameEntry> entries, int scrollOffset, int selectedIndex, int availableLines, Func<GameEntry, bool>? isFavorite = null)
|
||||||
{
|
{
|
||||||
@@ -40,11 +46,8 @@ namespace MarcerGameDvdLauncher
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Diff & write only changed lines
|
// 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++)
|
for (int row = 0; row < availableLines; row++)
|
||||||
{
|
{
|
||||||
if (row > maxRow) break;
|
|
||||||
var newLine = newBuffer[row];
|
var newLine = newBuffer[row];
|
||||||
var oldLine = _cachedBuffer[row];
|
var oldLine = _cachedBuffer[row];
|
||||||
if (oldLine.Text != newLine.Text || oldLine.Fg != newLine.Fg || oldLine.Bg != newLine.Bg)
|
if (oldLine.Text != newLine.Text || oldLine.Fg != newLine.Fg || oldLine.Bg != newLine.Bg)
|
||||||
@@ -60,26 +63,16 @@ namespace MarcerGameDvdLauncher
|
|||||||
{
|
{
|
||||||
if (entryIdx < 0 || entryIdx >= entries.Count) return;
|
if (entryIdx < 0 || entryIdx >= entries.Count) return;
|
||||||
availableLines = Math.Max(1, availableLines);
|
availableLines = Math.Max(1, availableLines);
|
||||||
int maxRow = Math.Max(0, availableLines - 1);
|
// row is a visual row within the visible window; validate against availableLines
|
||||||
if (row < 0 || row > maxRow) return;
|
if (row < 0 || row >= availableLines) return;
|
||||||
|
|
||||||
int width = Console.WindowWidth;
|
int width = Console.WindowWidth;
|
||||||
// ensure cache is valid for current width/height and the target row
|
// Ensure the cache matches the current dimensions (same as DrawMenu)
|
||||||
EnsureCacheForRow(width, Math.Min(availableLines, Math.Max(1, _cachedBuffer.Length == 0 ? 1 : _cachedBuffer.Length)));
|
EnsureCache(width, availableLines);
|
||||||
|
|
||||||
var e = entries[entryIdx];
|
var e = entries[entryIdx];
|
||||||
|
var (fg, bg) = GetColors(e, selected);
|
||||||
string text = BuildLineText(e, width, isFavorite?.Invoke(e) ?? false);
|
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 };
|
var newLine = new LineState { Text = text, Fg = fg, Bg = bg };
|
||||||
// If cache differs, write
|
// If cache differs, write
|
||||||
@@ -95,9 +88,13 @@ namespace MarcerGameDvdLauncher
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// out of cache bounds - attempt a direct write
|
// out of cache bounds - attempt a direct write
|
||||||
|
// Validate row against current console height to prevent ArgumentOutOfRangeException
|
||||||
|
if (row < Console.WindowHeight)
|
||||||
|
{
|
||||||
WriteConsoleLine(row, newLine);
|
WriteConsoleLine(row, newLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensures the cached buffer has exactly availableLines entries and matches width.
|
// Ensures the cached buffer has exactly availableLines entries and matches width.
|
||||||
private void EnsureCache(int width, int availableLines)
|
private void EnsureCache(int width, int availableLines)
|
||||||
@@ -110,18 +107,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)
|
// Write a line to the console using the centralized logic (handles concurrent resizes safely)
|
||||||
private void WriteConsoleLine(int row, LineState newLine)
|
private void WriteConsoleLine(int row, LineState newLine)
|
||||||
{
|
{
|
||||||
@@ -150,54 +135,41 @@ namespace MarcerGameDvdLauncher
|
|||||||
|
|
||||||
// Renders a centered, bordered help box with key bindings inside the
|
// Renders a centered, bordered help box with key bindings inside the
|
||||||
// available console area. The caller is responsible for waiting on a
|
// available console area. The caller is responsible for waiting on a
|
||||||
// key and redrawing the menu afterwards.
|
// key and redrawing the menu afterward.
|
||||||
public void ShowHelpBox(int availableLines)
|
public void ShowHelpBox(int availableLines)
|
||||||
{
|
{
|
||||||
try
|
var width = Console.WindowWidth;
|
||||||
{
|
var helpLines = GetHelpLines();
|
||||||
int width = Console.WindowWidth;
|
var boxHeight = Math.Min(helpLines.Length + 2, Math.Max(3, availableLines));
|
||||||
string[] helpLines = GetHelpLines();
|
var boxWidth = Math.Max(1, width);
|
||||||
int boxHeight = Math.Min(helpLines.Length + 2, Math.Max(3, availableLines));
|
var topRow = Math.Max(0, (availableLines - boxHeight) / 2);
|
||||||
int boxWidth = Math.Max(1, width);
|
|
||||||
int topRow = Math.Max(0, (availableLines - boxHeight) / 2);
|
|
||||||
|
|
||||||
Console.BackgroundColor = ConsoleColor.DarkGray;
|
Console.BackgroundColor = ConsoleColor.DarkGray;
|
||||||
Console.ForegroundColor = ConsoleColor.White;
|
Console.ForegroundColor = ConsoleColor.White;
|
||||||
|
|
||||||
string topBorder = "+" + new string('-', Math.Max(0, boxWidth - 2)) + "+";
|
var topBorder = "+" + new string('-', Math.Max(0, boxWidth - 2)) + "+";
|
||||||
Console.SetCursorPosition(0, topRow);
|
Console.SetCursorPosition(0, topRow);
|
||||||
Console.Write(topBorder);
|
Console.Write(topBorder);
|
||||||
|
|
||||||
for (int i = 0; i < boxHeight - 2; i++)
|
for (var i = 0; i < boxHeight - 2; i++)
|
||||||
{
|
{
|
||||||
int row = topRow + 1 + i;
|
var row = topRow + 1 + i;
|
||||||
string content;
|
var content = i < helpLines.Length
|
||||||
if (i < helpLines.Length)
|
? PadToWidth(helpLines[i], boxWidth - 2)
|
||||||
{
|
: new string(' ', Math.Max(0, boxWidth - 2));
|
||||||
content = PadToWidth(helpLines[i], boxWidth - 2);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
content = new string(' ', Math.Max(0, boxWidth - 2));
|
|
||||||
}
|
|
||||||
Console.SetCursorPosition(0, row);
|
Console.SetCursorPosition(0, row);
|
||||||
Console.Write("|" + content + "|");
|
Console.Write("|" + content + "|");
|
||||||
}
|
}
|
||||||
|
|
||||||
int bottomRow = topRow + boxHeight - 1;
|
var bottomRow = topRow + boxHeight - 1;
|
||||||
if (bottomRow < Console.WindowHeight)
|
if (bottomRow < Console.WindowHeight)
|
||||||
{
|
{
|
||||||
string bottomBorder = "+" + new string('-', Math.Max(0, boxWidth - 2)) + "+";
|
var bottomBorder = "+" + new string('-', Math.Max(0, boxWidth - 2)) + "+";
|
||||||
Console.SetCursorPosition(0, bottomRow);
|
Console.SetCursorPosition(0, bottomRow);
|
||||||
Console.Write(bottomBorder);
|
Console.Write(bottomBorder);
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.ResetColor();
|
Console.ResetColor();
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string[] GetHelpLines()
|
private static string[] GetHelpLines()
|
||||||
{
|
{
|
||||||
@@ -226,12 +198,13 @@ namespace MarcerGameDvdLauncher
|
|||||||
return text + new string(' ', width - text.Length);
|
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)
|
private (ConsoleColor fg, ConsoleColor bg) GetColors(GameEntry e, bool selected)
|
||||||
{
|
{
|
||||||
if (selected)
|
if (selected)
|
||||||
{
|
{
|
||||||
return (ConsoleColor.Black, ConsoleColor.DarkCyan);
|
return (_colors.SelectedForeground, _colors.SelectedBackground);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -244,7 +217,7 @@ namespace MarcerGameDvdLauncher
|
|||||||
{
|
{
|
||||||
if (width <= 0) return string.Empty;
|
if (width <= 0) return string.Empty;
|
||||||
|
|
||||||
string label = GetLabel(e, isFavorite);
|
var label = GetLabel(e, isFavorite);
|
||||||
|
|
||||||
// If the console width is smaller than the label, truncate the label
|
// If the console width is smaller than the label, truncate the label
|
||||||
if (width <= label.Length)
|
if (width <= label.Length)
|
||||||
@@ -252,7 +225,7 @@ namespace MarcerGameDvdLauncher
|
|||||||
return label.Substring(0, width);
|
return label.Substring(0, width);
|
||||||
}
|
}
|
||||||
|
|
||||||
int maxNameLen = width - label.Length; // space left for name
|
var maxNameLen = width - label.Length; // space left for name
|
||||||
string displayName;
|
string displayName;
|
||||||
if (e.Name.Length <= maxNameLen)
|
if (e.Name.Length <= maxNameLen)
|
||||||
{
|
{
|
||||||
@@ -266,7 +239,7 @@ namespace MarcerGameDvdLauncher
|
|||||||
displayName = e.Name.Substring(0, Math.Max(0, maxNameLen));
|
displayName = e.Name.Substring(0, Math.Max(0, maxNameLen));
|
||||||
}
|
}
|
||||||
|
|
||||||
int padding = Math.Max(0, width - label.Length - displayName.Length);
|
var padding = Math.Max(0, width - label.Length - displayName.Length);
|
||||||
var result = label + displayName + new string(' ', padding);
|
var result = label + displayName + new string(' ', padding);
|
||||||
// Ensure exact width (defensive): truncate or pad if needed
|
// Ensure exact width (defensive): truncate or pad if needed
|
||||||
if (result.Length > width) return result.Substring(0, width);
|
if (result.Length > width) return result.Substring(0, width);
|
||||||
@@ -282,7 +255,7 @@ namespace MarcerGameDvdLauncher
|
|||||||
{
|
{
|
||||||
// Layer label (7 chars)
|
// Layer label (7 chars)
|
||||||
string layer;
|
string layer;
|
||||||
if (e.InRoot && e.InPatch) layer = "[BOTH] ";
|
if (e is {InRoot: true, InPatch: true}) layer = "[BOTH] ";
|
||||||
else if (e.InPatch) layer = "[PTCH] ";
|
else if (e.InPatch) layer = "[PTCH] ";
|
||||||
else if (e.InRoot) layer = "[ROOT] ";
|
else if (e.InRoot) layer = "[ROOT] ";
|
||||||
else layer = " ";
|
else layer = " ";
|
||||||
@@ -303,20 +276,20 @@ namespace MarcerGameDvdLauncher
|
|||||||
|
|
||||||
private ConsoleColor GetColorForEntry(GameEntry e)
|
private ConsoleColor GetColorForEntry(GameEntry e)
|
||||||
{
|
{
|
||||||
// Virtual entries (like the Favorites pseudo-folder) should be white
|
// Virtual entries (like the Favorites pseudo-folder) use the configured VirtualEntry color
|
||||||
if (e.IsVirtual) return ConsoleColor.White;
|
if (e.IsVirtual) return _colors.VirtualEntry;
|
||||||
|
|
||||||
if (e.Kind == EntryKind.Directory)
|
if (e.Kind == EntryKind.Directory)
|
||||||
{
|
{
|
||||||
if (e.InRoot && e.InPatch) return ConsoleColor.Yellow; // Both layers
|
if (e is {InRoot: true, InPatch: true}) return _colors.FolderBoth; // Both layers
|
||||||
if (e.InPatch && !e.InRoot) return ConsoleColor.DarkYellow; // Only patch
|
if (e is {InPatch: true, InRoot: false}) return _colors.FolderPatchOnly; // Only patch
|
||||||
if (e.InRoot && !e.InPatch) return ConsoleColor.Gray; // Only root
|
if (e is {InRoot: true, InPatch: false}) return _colors.FolderRootOnly; // Only root
|
||||||
}
|
}
|
||||||
else if (e.Kind == EntryKind.Zip)
|
else if (e.Kind == EntryKind.Zip)
|
||||||
{
|
{
|
||||||
if (e.InRoot && e.InPatch) return ConsoleColor.Green; // Both layers
|
if (e is {InRoot: true, InPatch: true}) return _colors.ZipBoth; // Both layers
|
||||||
if (e.InRoot && !e.InPatch) return ConsoleColor.DarkGreen; // Only root
|
if (e is { InRoot: true, InPatch: false }) return _colors.ZipRootOnly; // Only root
|
||||||
if (e.InPatch && !e.InRoot) return ConsoleColor.Magenta; // Only patch
|
if (e is {InPatch: true, InRoot: false}) return _colors.ZipPatchOnly; // Only patch
|
||||||
}
|
}
|
||||||
return ConsoleColor.DarkGray;
|
return ConsoleColor.DarkGray;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
namespace MarcerGameDvdLauncher
|
namespace MarcerGameDvdLauncher
|
||||||
{
|
{
|
||||||
public class NavigationController
|
public class NavigationController
|
||||||
{
|
{
|
||||||
public int SelectedIndex { get; private set; } = 0;
|
// Scroll fractions: when the selection cursor reaches 2/3 of the visible window
|
||||||
public int ScrollOffset { get; private set; } = 0;
|
// 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 BOTTOM_SCROLL_FRACTION = 2.0 / 3.0;
|
||||||
|
private const double TOP_SCROLL_FRACTION = 1.0 / 3.0;
|
||||||
|
|
||||||
|
public int SelectedIndex { get; private set; }
|
||||||
|
public int ScrollOffset { get; private set; }
|
||||||
public string CurrentRelativePath { get; private set; } = "";
|
public string CurrentRelativePath { get; private set; } = "";
|
||||||
// Stores the last selection & scroll position for each directory
|
// Stores the last selection & scroll position for each directory
|
||||||
private readonly Dictionary<string, (int sel, int scroll)> _lastSelections = new();
|
private readonly Dictionary<string, (int sel, int scroll)> _lastSelections = new();
|
||||||
@@ -96,12 +105,12 @@ namespace MarcerGameDvdLauncher
|
|||||||
if (availableLines < 1) availableLines = 1;
|
if (availableLines < 1) availableLines = 1;
|
||||||
if (entryCount <= availableLines) { ScrollOffset = 0; return; }
|
if (entryCount <= availableLines) { ScrollOffset = 0; return; }
|
||||||
if (SelectedIndex == 0) { ScrollOffset = 0; return; }
|
if (SelectedIndex == 0) { ScrollOffset = 0; return; }
|
||||||
int bottomScrollTrigger = ScrollOffset + (int)(availableLines * 2 / 3.0);
|
int bottomScrollTrigger = ScrollOffset + (int)(availableLines * BOTTOM_SCROLL_FRACTION);
|
||||||
int topScrollTrigger = ScrollOffset + (int)(availableLines * 1 / 3.0);
|
int topScrollTrigger = ScrollOffset + (int)(availableLines * TOP_SCROLL_FRACTION);
|
||||||
if (SelectedIndex >= bottomScrollTrigger && (ScrollOffset + availableLines) < entryCount)
|
if (SelectedIndex >= bottomScrollTrigger && (ScrollOffset + availableLines) < entryCount)
|
||||||
ScrollOffset = SelectedIndex - (int)(availableLines * 2 / 3.0);
|
ScrollOffset = SelectedIndex - (int)(availableLines * BOTTOM_SCROLL_FRACTION);
|
||||||
else if (SelectedIndex < topScrollTrigger && ScrollOffset > 0)
|
else if (SelectedIndex < topScrollTrigger && ScrollOffset > 0)
|
||||||
ScrollOffset = SelectedIndex - (int)(availableLines * 1 / 3.0);
|
ScrollOffset = SelectedIndex - (int)(availableLines * TOP_SCROLL_FRACTION);
|
||||||
if (ScrollOffset < 0) ScrollOffset = 0;
|
if (ScrollOffset < 0) ScrollOffset = 0;
|
||||||
if (ScrollOffset > entryCount - availableLines)
|
if (ScrollOffset > entryCount - availableLines)
|
||||||
ScrollOffset = entryCount - availableLines;
|
ScrollOffset = entryCount - availableLines;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
namespace MarcerGameDvdLauncher
|
namespace MarcerGameDvdLauncher
|
||||||
{
|
{
|
||||||
public enum EntryKind { Directory, Zip }
|
public enum EntryKind { Directory, Zip }
|
||||||
@@ -16,16 +19,16 @@ namespace MarcerGameDvdLauncher
|
|||||||
|
|
||||||
public class OverlayDirectoryBrowser(string root, string patch)
|
public class OverlayDirectoryBrowser(string root, string patch)
|
||||||
{
|
{
|
||||||
public List<GameEntry> GetEntries(string currentRelativePath)
|
public List<GameEntry> GetEntries(string? currentRelativePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Normalize and protect against path traversal or absolute paths in the relative path
|
// Normalize and protect against path traversal or absolute paths in the relative path
|
||||||
string rel = currentRelativePath ?? string.Empty;
|
var rel = currentRelativePath ?? string.Empty;
|
||||||
// Remove any leading directory separators
|
// Remove any leading directory separators
|
||||||
rel = rel.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
rel = rel.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
// If the relative path is rooted or contains parent directory segments, reset to root
|
// If the relative path is rooted or contains parent directory segments, reset to root
|
||||||
if (Path.IsPathRooted(rel) || rel.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries).Any(p => p == ".."))
|
if (Path.IsPathRooted(rel) || rel.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries).Any(p => p == ".."))
|
||||||
{
|
{
|
||||||
rel = string.Empty;
|
rel = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
namespace MarcerGameDvdLauncher
|
namespace MarcerGameDvdLauncher
|
||||||
{
|
{
|
||||||
class Program
|
class Program
|
||||||
{
|
{
|
||||||
|
// Console window title. Not configurable — fixed application display name.
|
||||||
|
private const string DEFAULT_TITLE = "Marcer GameDVD Launcher";
|
||||||
|
|
||||||
static void Main(string[] args)
|
static void Main(string[] args)
|
||||||
{
|
{
|
||||||
Console.Title = "Marcer GameDVD Launcher";
|
Console.Title = DEFAULT_TITLE;
|
||||||
var app = new LauncherApp();
|
var app = new LauncherApp();
|
||||||
app.Run();
|
app.Run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System;
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace MarcerGameDvdLauncher
|
namespace MarcerGameDvdLauncher
|
||||||
@@ -6,6 +8,11 @@ namespace MarcerGameDvdLauncher
|
|||||||
// Small helpers refactored into their own file to keep Program.cs focused.
|
// Small helpers refactored into their own file to keep Program.cs focused.
|
||||||
internal static class ProgramHelpers
|
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
|
// Flushes the console input buffer to avoid processing leftover key events
|
||||||
// Uses Win32 FlushConsoleInputBuffer on the standard input handle. On non-Windows
|
// Uses Win32 FlushConsoleInputBuffer on the standard input handle. On non-Windows
|
||||||
// environments this will be a no-op.
|
// environments this will be a no-op.
|
||||||
@@ -28,52 +35,54 @@ 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;
|
private const int STD_INPUT_HANDLE = -10;
|
||||||
|
|
||||||
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
|
[DllImport("kernel32.dll")]
|
||||||
private static extern IntPtr GetStdHandle(int nStdHandle);
|
private static extern IntPtr GetStdHandle(int nStdHandle);
|
||||||
|
|
||||||
[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool FlushConsoleInputBuffer(IntPtr hConsoleInput);
|
private static extern bool FlushConsoleInputBuffer(IntPtr hConsoleInput);
|
||||||
|
|
||||||
// P/Invoke to query key state (used to detect physical key release)
|
// 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;
|
private const int VK_RETURN = 0x0D;
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
private static extern short GetAsyncKeyState(int vKey);
|
private static extern short GetAsyncKeyState(int vKey);
|
||||||
|
|
||||||
// Shows a simple modal message on the reserved last console line and
|
// Shows a simple modal message in the center of the console and
|
||||||
// blocks until the Return key is physically released. The message is
|
// blocks until the given process has exited. The message is then
|
||||||
// then removed and the method returns. This is designed to be a
|
// removed and the method returns.
|
||||||
// lightweight modal for the console UI and uses the last console line
|
public static void ShowModalUntilProcessExited(System.Diagnostics.Process? process, string? message)
|
||||||
// which the application reserves for transient messages.
|
|
||||||
public static void ShowModalUntilReturnReleased(string message)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
int lastRow = Math.Max(0, Console.WindowHeight - 1);
|
|
||||||
int width = Console.WindowWidth;
|
int width = Console.WindowWidth;
|
||||||
string text = message ?? string.Empty;
|
int height = Console.WindowHeight;
|
||||||
if (text.Length > width) text = text.Substring(0, Math.Max(0, width - 3)) + "...";
|
int centerRow = height / 2;
|
||||||
int padding = Math.Max(0, width - text.Length);
|
|
||||||
string line = text + new string(' ', padding);
|
var text = message ?? string.Empty;
|
||||||
|
if (text.Length > width - 4) text = text.Substring(0, Math.Max(0, width - 7)) + "...";
|
||||||
|
int leftPad = Math.Max(0, (width - text.Length) / 2);
|
||||||
|
var line = new string(' ', leftPad) + text;
|
||||||
|
|
||||||
Console.BackgroundColor = ConsoleColor.DarkGray;
|
Console.BackgroundColor = ConsoleColor.DarkGray;
|
||||||
Console.ForegroundColor = ConsoleColor.White;
|
Console.ForegroundColor = ConsoleColor.White;
|
||||||
try { Console.SetCursorPosition(0, lastRow); } catch { }
|
try { Console.SetCursorPosition(0, centerRow); } catch { }
|
||||||
try { Console.Write(line); } catch { }
|
try { Console.Write(line.PadRight(width)); } catch { }
|
||||||
Console.ResetColor();
|
Console.ResetColor();
|
||||||
|
|
||||||
// Wait until Return key is not pressed
|
// Wait until the external process has exited
|
||||||
// GetAsyncKeyState returns a short where the high-order bit is set when key is down
|
while (process != null && !process.HasExited)
|
||||||
while ((GetAsyncKeyState(VK_RETURN) & 0x8000) != 0)
|
|
||||||
{
|
{
|
||||||
Thread.Sleep(10);
|
Thread.Sleep(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the line
|
// Clear the line
|
||||||
try { Console.SetCursorPosition(0, lastRow); } catch { }
|
try { Console.SetCursorPosition(0, centerRow); } catch { }
|
||||||
try { Console.Write(new string(' ', width)); } catch { }
|
try { Console.Write(new string(' ', width)); } catch { }
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
|
||||||
namespace MarcerGameDvdLauncher;
|
namespace MarcerGameDvdLauncher;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Centralized service for error and user message output in the console UI.
|
/// 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>
|
/// </summary>
|
||||||
public class UIErrorService
|
public class UIErrorService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,18 @@
|
|||||||
"PatchDirectory": "C:\\Games\\Hatari\\PATCH",
|
"PatchDirectory": "C:\\Games\\Hatari\\PATCH",
|
||||||
"Hatari": {
|
"Hatari": {
|
||||||
"Executable": "C:\\Tools\\hatari\\hatari.exe",
|
"Executable": "C:\\Tools\\hatari\\hatari.exe",
|
||||||
"ConfigFile": "C:\\Tools\\hatari\\hatari-st.cfg",
|
"ConfigFile": "",
|
||||||
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
|
"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