mirror of
https://github.com/skoelle/marcer-gamedvd-launcher.git
synced 2026-09-17 18:50:25 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [windows-latest, ubuntu-latest, macos-latest]
|
||||||
|
include:
|
||||||
|
- os: windows-latest
|
||||||
|
rid: win-x64
|
||||||
|
artifact_name: win-x64
|
||||||
|
- os: ubuntu-latest
|
||||||
|
rid: linux-x64
|
||||||
|
artifact_name: linux-x64
|
||||||
|
- os: macos-latest
|
||||||
|
rid: osx-x64
|
||||||
|
artifact_name: osx-x64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet publish -c Release -r ${{ matrix.rid }} -p:Version=${{ steps.version.outputs.version }} --self-contained false
|
||||||
|
|
||||||
|
- name: Create ZIP (Windows)
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
Compress-Archive -Path "HatariZipLauncher/bin/Release/net10.0/${{ matrix.rid }}/*" -DestinationPath "HatariZipLauncher-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}.zip"
|
||||||
|
|
||||||
|
- name: Create ZIP (Linux/macOS)
|
||||||
|
if: matrix.os != 'windows-latest'
|
||||||
|
run: |
|
||||||
|
cd HatariZipLauncher/bin/Release/net10.0/${{ matrix.rid }}
|
||||||
|
zip -r ../../../HatariZipLauncher-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}.zip .
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: HatariZipLauncher-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}
|
||||||
|
path: HatariZipLauncher-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}.zip
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
CURRENT_TAG="${GITHUB_REF_NAME}"
|
||||||
|
PREV_TAG=$(git describe --tags --abbrev=0 ${CURRENT_TAG}^ 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -n "$PREV_TAG" ]; then
|
||||||
|
echo "## Changes since ${PREV_TAG}" > release-notes.md
|
||||||
|
echo "" >> release-notes.md
|
||||||
|
git log --oneline ${PREV_TAG}..${CURRENT_TAG} >> release-notes.md
|
||||||
|
else
|
||||||
|
echo "## Initial release" > release-notes.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
name: HatariZipLauncher ${{ github.ref_name }}
|
||||||
|
body_path: release-notes.md
|
||||||
|
files: artifacts/**/*.zip
|
||||||
|
generate_release_notes: false
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# Ignore build artifacts
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
# User-specific or machine-generated files
|
||||||
|
*.user
|
||||||
|
*.vs
|
||||||
|
*.vscode
|
||||||
|
*.suo
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
# OS junk
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Release-Verzeichnis (keine ZIPs o.ä. ins Git!)
|
||||||
|
# Only ignore top-level release/ directory. Release directories in subfolders are allowed.
|
||||||
|
/release/
|
||||||
|
|
||||||
|
# User-specific configuration (real config, not example)
|
||||||
|
launcher.config.json
|
||||||
|
favorites.txt
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace HatariZipLauncher
|
||||||
|
{
|
||||||
|
// Configuration POCOs separated into their own file for clarity
|
||||||
|
public class AppHatariConfig
|
||||||
|
{
|
||||||
|
public string? Executable { get; set; }
|
||||||
|
public string? ConfigFile { get; set; }
|
||||||
|
public string? ArgsTemplate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AppConfig
|
||||||
|
{
|
||||||
|
public string? RootDirectory { get; set; }
|
||||||
|
public string? PatchDirectory { get; set; }
|
||||||
|
public AppHatariConfig? Hatari { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
namespace HatariZipLauncher
|
||||||
|
{
|
||||||
|
// Manages loading, saving and querying favorite ZIP paths.
|
||||||
|
public class FavoritesService
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
// Use a SortedSet so favorites are kept in sorted order in memory.
|
||||||
|
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)
|
||||||
|
public void Load()
|
||||||
|
{
|
||||||
|
_favorites = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (!File.Exists(_filePath)) return;
|
||||||
|
var lines = File.ReadAllLines(_filePath, System.Text.Encoding.UTF8);
|
||||||
|
foreach (var l in lines)
|
||||||
|
{
|
||||||
|
var t = l?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(t)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var full = Path.GetFullPath(t);
|
||||||
|
_favorites.Add(full);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore invalid paths in the file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsFavorite(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var full = Path.GetFullPath(path);
|
||||||
|
return _favorites.Contains(full);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if any favorites are present
|
||||||
|
public bool HasFavorites() => _favorites.Count > 0;
|
||||||
|
|
||||||
|
// Returns all favorites as absolute paths in sorted order
|
||||||
|
public IReadOnlyList<string> GetAll() => _favorites.ToList();
|
||||||
|
|
||||||
|
// Toggle favorite state. Returns true if added, false if removed.
|
||||||
|
public bool Toggle(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path));
|
||||||
|
var full = Path.GetFullPath(path);
|
||||||
|
bool added;
|
||||||
|
if (_favorites.Contains(full))
|
||||||
|
{
|
||||||
|
_favorites.Remove(full);
|
||||||
|
added = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_favorites.Add(full);
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
Save();
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(_filePath) ?? AppContext.BaseDirectory;
|
||||||
|
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllLines(_filePath, _favorites.OrderBy(x => x), System.Text.Encoding.UTF8);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Let callers surface errors (we swallow here to avoid throwing on write failures during UI operations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
namespace HatariZipLauncher
|
||||||
|
{
|
||||||
|
public class HatariLauncher
|
||||||
|
{
|
||||||
|
private readonly string _exePath;
|
||||||
|
private readonly string _cfgPath;
|
||||||
|
private readonly string _argsTemplate;
|
||||||
|
|
||||||
|
public HatariLauncher(string exePath, string cfgPath, string argsTemplate)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(exePath))
|
||||||
|
throw new ArgumentNullException(nameof(exePath));
|
||||||
|
|
||||||
|
// Defensive validation: ensure the executable exists and looks like an .exe
|
||||||
|
if (!File.Exists(exePath))
|
||||||
|
throw new ArgumentException($"Hatari executable not found: {exePath}", nameof(exePath));
|
||||||
|
if (!string.Equals(Path.GetExtension(exePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new ArgumentException($"Hatari executable must be an .exe file: {exePath}", nameof(exePath));
|
||||||
|
|
||||||
|
_exePath = exePath;
|
||||||
|
_cfgPath = cfgPath;
|
||||||
|
_argsTemplate = argsTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts Hatari with the given ZIP file. ArgsTemplate is used to build the arguments,
|
||||||
|
/// replacing {cfg} and {zip} placeholders.
|
||||||
|
/// </summary>
|
||||||
|
public void Launch(string zipFilePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(zipFilePath))
|
||||||
|
throw new ArgumentException("ZIP archive path must not be empty.", nameof(zipFilePath));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string args = _argsTemplate.Replace("{cfg}", _cfgPath).Replace("{zip}", zipFilePath);
|
||||||
|
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = _exePath,
|
||||||
|
Arguments = args,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(_exePath) ?? string.Empty
|
||||||
|
};
|
||||||
|
System.Diagnostics.Process.Start(psi);
|
||||||
|
// Show a modal indicating the emulator was started and wait until
|
||||||
|
// the user releases the Return key before clearing the modal. This
|
||||||
|
// prevents accidental key repeats from triggering other actions.
|
||||||
|
ProgramHelpers.ShowModalUntilReturnReleased("Hatari started. Release Return to continue...");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Error starting Hatari: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="launcher.config.example.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
namespace HatariZipLauncher
|
||||||
|
{
|
||||||
|
// Encapsulates application lifecycle: load config, initialize components, run navigation
|
||||||
|
public class LauncherApp
|
||||||
|
{
|
||||||
|
public AppConfig? Configuration { get; private set; }
|
||||||
|
|
||||||
|
public void Run()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Configuration = LoadConfiguration();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ProgramHelpers.ShowConsoleMessage(["Error loading configuration: " + ex.Message, "Please place a valid launcher.config.json in the same folder as the EXE.", "Press any key to exit."
|
||||||
|
], ConsoleColor.Red);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup required components
|
||||||
|
var appHost = new AppHost(Configuration);
|
||||||
|
appHost.InitializeComponents();
|
||||||
|
appHost.RunDirectoryNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppConfig LoadConfiguration()
|
||||||
|
{
|
||||||
|
string exeDir = AppContext.BaseDirectory;
|
||||||
|
string configPath = Path.Combine(exeDir, "launcher.config.json");
|
||||||
|
if (!File.Exists(configPath))
|
||||||
|
throw new FileNotFoundException($"Configuration file not found: {configPath}");
|
||||||
|
|
||||||
|
string json = File.ReadAllText(configPath);
|
||||||
|
var options = new System.Text.Json.JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
var cfg = System.Text.Json.JsonSerializer.Deserialize<AppConfig>(json, options);
|
||||||
|
if (cfg == null)
|
||||||
|
throw new InvalidOperationException("Invalid configuration file (empty or malformed)");
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.RootDirectory))
|
||||||
|
throw new InvalidOperationException("RootDirectory must be set in the configuration.");
|
||||||
|
|
||||||
|
// Resolve RootDirectory relative to the EXE directory and validate existence
|
||||||
|
cfg.RootDirectory = ProgramHelpers.ResolveIfRelative(cfg.RootDirectory, exeDir);
|
||||||
|
if (!Directory.Exists(cfg.RootDirectory))
|
||||||
|
throw new InvalidOperationException($"RootDirectory not found: {cfg.RootDirectory}");
|
||||||
|
// Resolve PatchDirectory relative to the EXE directory as well. PatchDirectory is optional
|
||||||
|
// and may be empty; ResolveIfRelative returns an empty string for null/whitespace inputs.
|
||||||
|
cfg.PatchDirectory = ProgramHelpers.ResolveIfRelative(cfg.PatchDirectory, exeDir);
|
||||||
|
if (cfg.Hatari == null)
|
||||||
|
throw new InvalidOperationException("Hatari configuration must be present in launcher.config.json.");
|
||||||
|
|
||||||
|
cfg.Hatari.Executable = ProgramHelpers.ResolveIfRelative(cfg.Hatari.Executable, exeDir);
|
||||||
|
cfg.Hatari.ConfigFile = ProgramHelpers.ResolveIfRelative(cfg.Hatari.ConfigFile, exeDir);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.Hatari.Executable))
|
||||||
|
throw new InvalidOperationException("Hatari.Executable must be set in the configuration.");
|
||||||
|
if (!File.Exists(cfg.Hatari.Executable))
|
||||||
|
throw new InvalidOperationException($"Hatari executable not found: {cfg.Hatari.Executable}");
|
||||||
|
|
||||||
|
// Also validate the Hatari config file (if provided)
|
||||||
|
if (!string.IsNullOrWhiteSpace(cfg.Hatari.ConfigFile) && !File.Exists(cfg.Hatari.ConfigFile))
|
||||||
|
throw new InvalidOperationException($"Hatari configuration file not found: {cfg.Hatari.ConfigFile}");
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.Hatari.ArgsTemplate) || !cfg.Hatari.ArgsTemplate.Contains("{zip}"))
|
||||||
|
throw new InvalidOperationException("Hatari.ArgsTemplate must contain the {zip} placeholder.");
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal host that keeps state previously stored in Program.cs
|
||||||
|
internal class AppHost(AppConfig cfg)
|
||||||
|
{
|
||||||
|
OverlayDirectoryBrowser? _directoryBrowser;
|
||||||
|
MenuRenderer _menuRenderer = new MenuRenderer();
|
||||||
|
NavigationController _navigationController = new NavigationController();
|
||||||
|
List<GameEntry> _gameEntries = new List<GameEntry>();
|
||||||
|
HatariLauncher? _hatariLauncher;
|
||||||
|
readonly UIErrorService _errorService = new UIErrorService();
|
||||||
|
FavoritesService? _favoritesService;
|
||||||
|
|
||||||
|
public void InitializeComponents()
|
||||||
|
{
|
||||||
|
_directoryBrowser = new OverlayDirectoryBrowser(cfg.RootDirectory ?? string.Empty, cfg.PatchDirectory ?? string.Empty);
|
||||||
|
// Initialize favorites service. Use PatchDirectory if present, otherwise exe dir fallback.
|
||||||
|
string favPath;
|
||||||
|
if (!string.IsNullOrWhiteSpace(cfg.PatchDirectory))
|
||||||
|
{
|
||||||
|
favPath = Path.Combine(cfg.PatchDirectory!, "favorites.txt");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
favPath = Path.Combine(AppContext.BaseDirectory, "favorites.txt");
|
||||||
|
}
|
||||||
|
_favoritesService = new FavoritesService(favPath);
|
||||||
|
try { _favoritesService.Load(); } catch { /* ignore load errors */ }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_hatariLauncher = new HatariLauncher(cfg.Hatari?.Executable ?? throw new InvalidOperationException("Hatari.Executable not configured"), cfg.Hatari?.ConfigFile ?? string.Empty, cfg.Hatari?.ArgsTemplate ?? "-c \"{cfg}\" --disk-a \"{zip}\"");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ProgramHelpers.ShowConsoleMessage(["Hatari initialization error: " + ex.Message, "Press any key to exit."
|
||||||
|
], ConsoleColor.Red);
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
Console.CursorVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RunDirectoryNavigation()
|
||||||
|
{
|
||||||
|
bool exitRequested = false;
|
||||||
|
ReloadGameEntries();
|
||||||
|
int currentAvailableLines = Console.WindowHeight - 1;
|
||||||
|
int currentWidth = Console.WindowWidth;
|
||||||
|
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
|
||||||
|
var isFav = new Func<GameEntry, bool>(e => _favoritesService?.IsFavorite(e.Kind == EntryKind.Zip ? (e.InPatch ? e.PatchPath : e.RootPath) ?? string.Empty : string.Empty) ?? false);
|
||||||
|
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||||
|
while (!exitRequested)
|
||||||
|
{
|
||||||
|
// Reloads are performed explicitly when entering or leaving directories (Enter/Backspace)
|
||||||
|
// Do NOT hit the filesystem here on every loop iteration.
|
||||||
|
|
||||||
|
// detect a change in console height and/or width and redraw immediately
|
||||||
|
int latestAvailableLines = Console.WindowHeight - 1;
|
||||||
|
int latestWidth = Console.WindowWidth;
|
||||||
|
if (latestAvailableLines != currentAvailableLines || latestWidth != currentWidth)
|
||||||
|
{
|
||||||
|
currentAvailableLines = latestAvailableLines;
|
||||||
|
currentWidth = latestWidth;
|
||||||
|
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
|
||||||
|
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only block if there's actually a key; otherwise allow resize detection
|
||||||
|
if (!Console.KeyAvailable)
|
||||||
|
{
|
||||||
|
Thread.Sleep(50);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = Console.ReadKey(intercept: true);
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
_navigationController.GoUpDirectory();
|
||||||
|
ReloadGameEntries();
|
||||||
|
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
|
||||||
|
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||||
|
// flush input to avoid leftover key events after directory change
|
||||||
|
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:
|
||||||
|
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,219 @@
|
|||||||
|
namespace HatariZipLauncher
|
||||||
|
{
|
||||||
|
public class MenuRenderer
|
||||||
|
{
|
||||||
|
// Simple line-based double buffer to avoid full Clear() flicker.
|
||||||
|
// cachedBuffer holds the last rendered text and colors for each visible row.
|
||||||
|
private struct LineState { public string Text; public ConsoleColor Fg; public ConsoleColor Bg; }
|
||||||
|
private LineState[] _cachedBuffer = Array.Empty<LineState>();
|
||||||
|
private int _cachedWidth = -1;
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
availableLines = Math.Max(1, availableLines);
|
||||||
|
int width = Console.WindowWidth;
|
||||||
|
// Ensure cache matches current dimensions
|
||||||
|
EnsureCache(width, availableLines);
|
||||||
|
|
||||||
|
var newBuffer = new LineState[availableLines];
|
||||||
|
for (int row = 0; row < availableLines; row++)
|
||||||
|
{
|
||||||
|
int idx = scrollOffset + row;
|
||||||
|
if (idx < entries.Count)
|
||||||
|
{
|
||||||
|
var e = entries[idx];
|
||||||
|
bool selected = (selectedIndex == idx);
|
||||||
|
// format the line text and colors
|
||||||
|
string text = BuildLineText(e, width, isFavorite?.Invoke(e) ?? false);
|
||||||
|
var (fg, bg) = GetColors(e, selected);
|
||||||
|
newBuffer[row].Text = text;
|
||||||
|
newBuffer[row].Fg = fg;
|
||||||
|
newBuffer[row].Bg = bg;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
newBuffer[row].Text = new string(' ', width);
|
||||||
|
newBuffer[row].Fg = ConsoleColor.Gray;
|
||||||
|
newBuffer[row].Bg = ConsoleColor.Black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff & write only changed lines
|
||||||
|
// Use the caller-provided availableLines (which should be Console.WindowHeight - 1)
|
||||||
|
int maxRow = Math.Max(0, availableLines - 1);
|
||||||
|
for (int row = 0; row < availableLines; row++)
|
||||||
|
{
|
||||||
|
if (row > maxRow) break;
|
||||||
|
var newLine = newBuffer[row];
|
||||||
|
var oldLine = _cachedBuffer[row];
|
||||||
|
if (oldLine.Text != newLine.Text || oldLine.Fg != newLine.Fg || oldLine.Bg != newLine.Bg)
|
||||||
|
{
|
||||||
|
WriteConsoleLine(row, newLine);
|
||||||
|
_cachedBuffer[row] = newLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now accepts availableLines so the caller remains the single source of truth
|
||||||
|
public void RedrawEntry(List<GameEntry> entries, int entryIdx, int row, bool selected, int availableLines, Func<GameEntry, bool>? isFavorite = null)
|
||||||
|
{
|
||||||
|
if (entryIdx < 0 || entryIdx >= entries.Count) return;
|
||||||
|
availableLines = Math.Max(1, availableLines);
|
||||||
|
int maxRow = Math.Max(0, availableLines - 1);
|
||||||
|
if (row < 0 || row > maxRow) return;
|
||||||
|
|
||||||
|
int width = Console.WindowWidth;
|
||||||
|
// ensure cache is valid for current width/height and the target row
|
||||||
|
EnsureCacheForRow(width, Math.Min(availableLines, Math.Max(1, _cachedBuffer.Length == 0 ? 1 : _cachedBuffer.Length)));
|
||||||
|
|
||||||
|
var e = entries[entryIdx];
|
||||||
|
string text = BuildLineText(e, width, isFavorite?.Invoke(e) ?? false);
|
||||||
|
ConsoleColor fg, bg;
|
||||||
|
if (selected)
|
||||||
|
{
|
||||||
|
bg = ConsoleColor.DarkCyan;
|
||||||
|
fg = ConsoleColor.Black;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bg = ConsoleColor.Black;
|
||||||
|
fg = GetColorForEntry(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
var newLine = new LineState { Text = text, Fg = fg, Bg = bg };
|
||||||
|
// If cache differs, write
|
||||||
|
if (row < _cachedBuffer.Length)
|
||||||
|
{
|
||||||
|
var old = _cachedBuffer[row];
|
||||||
|
if (old.Text != newLine.Text || old.Fg != newLine.Fg || old.Bg != newLine.Bg)
|
||||||
|
{
|
||||||
|
WriteConsoleLine(row, newLine);
|
||||||
|
_cachedBuffer[row] = newLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// out of cache bounds - attempt a direct write
|
||||||
|
WriteConsoleLine(row, newLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensures the cached buffer has exactly availableLines entries and matches width.
|
||||||
|
private void EnsureCache(int width, int availableLines)
|
||||||
|
{
|
||||||
|
if (_cachedBuffer.Length != availableLines || _cachedWidth != width)
|
||||||
|
{
|
||||||
|
_cachedBuffer = new LineState[availableLines];
|
||||||
|
for (int i = 0; i < availableLines; i++) _cachedBuffer[i].Text = null!;
|
||||||
|
_cachedWidth = width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensures the cached buffer has at least requiredRows entries and matches width.
|
||||||
|
private void EnsureCacheForRow(int width, int requiredRows)
|
||||||
|
{
|
||||||
|
if (_cachedBuffer.Length < requiredRows || _cachedWidth != width)
|
||||||
|
{
|
||||||
|
int newLen = Math.Max(requiredRows, 1);
|
||||||
|
_cachedBuffer = new LineState[newLen];
|
||||||
|
for (int i = 0; i < newLen; i++) _cachedBuffer[i].Text = null!;
|
||||||
|
_cachedWidth = width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a line to the console using the centralized logic (handles concurrent resizes safely)
|
||||||
|
private void WriteConsoleLine(int row, LineState newLine)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Console.SetCursorPosition(0, row);
|
||||||
|
Console.BackgroundColor = newLine.Bg;
|
||||||
|
Console.ForegroundColor = newLine.Fg;
|
||||||
|
Console.Write(newLine.Text);
|
||||||
|
Console.ResetColor();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore potential SetCursorPosition errors due to concurrent resize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns foreground and background colors for an entry depending on selection state
|
||||||
|
private (ConsoleColor fg, ConsoleColor bg) GetColors(GameEntry e, bool selected)
|
||||||
|
{
|
||||||
|
if (selected)
|
||||||
|
{
|
||||||
|
return (ConsoleColor.Black, ConsoleColor.DarkCyan);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return (GetColorForEntry(e), ConsoleColor.Black);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the visible line text for an entry, ensuring it never exceeds the given width.
|
||||||
|
private string BuildLineText(GameEntry e, int width, bool isFavorite)
|
||||||
|
{
|
||||||
|
if (width <= 0) return string.Empty;
|
||||||
|
// Reserve 6 characters for the label area. For directories we show "[DIR] ",
|
||||||
|
// for ZIPs we use the same width and optionally show a leading '*' when favorited.
|
||||||
|
string label;
|
||||||
|
if (e.Kind == EntryKind.Directory)
|
||||||
|
{
|
||||||
|
label = "[DIR] ";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// For ZIPs, show '*' after 4 spaces when favorited (keeps 6-char label area).
|
||||||
|
label = isFavorite ? " * " : new string(' ', 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the console width is smaller than the label, truncate the label
|
||||||
|
if (width <= label.Length)
|
||||||
|
{
|
||||||
|
return label.Substring(0, width);
|
||||||
|
}
|
||||||
|
|
||||||
|
int maxNameLen = width - label.Length; // space left for name
|
||||||
|
string displayName;
|
||||||
|
if (e.Name.Length <= maxNameLen)
|
||||||
|
{
|
||||||
|
displayName = e.Name;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (maxNameLen > 3)
|
||||||
|
displayName = e.Name.Substring(0, maxNameLen - 3) + "...";
|
||||||
|
else
|
||||||
|
displayName = e.Name.Substring(0, Math.Max(0, maxNameLen));
|
||||||
|
}
|
||||||
|
|
||||||
|
int padding = Math.Max(0, width - label.Length - displayName.Length);
|
||||||
|
var result = label + displayName + new string(' ', padding);
|
||||||
|
// Ensure exact width (defensive): truncate or pad if needed
|
||||||
|
if (result.Length > width) return result.Substring(0, width);
|
||||||
|
if (result.Length < width) return result + new string(' ', width - result.Length);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ConsoleColor GetColorForEntry(GameEntry e)
|
||||||
|
{
|
||||||
|
// Virtual entries (like the Favorites pseudo-folder) should be white
|
||||||
|
if (e.IsVirtual) return ConsoleColor.White;
|
||||||
|
if (e.Kind == EntryKind.Directory)
|
||||||
|
{
|
||||||
|
if (e.InRoot && e.InPatch) return ConsoleColor.Yellow; // Both layers
|
||||||
|
if (e.InPatch && !e.InRoot) return ConsoleColor.DarkYellow; // Only patch
|
||||||
|
if (e.InRoot && !e.InPatch) return ConsoleColor.Gray; // Only root
|
||||||
|
}
|
||||||
|
else if (e.Kind == EntryKind.Zip)
|
||||||
|
{
|
||||||
|
if (e.InRoot && e.InPatch) return ConsoleColor.Green;
|
||||||
|
if (e.InRoot && !e.InPatch) return ConsoleColor.DarkGreen;
|
||||||
|
if (e.InPatch && !e.InRoot) return ConsoleColor.Magenta;
|
||||||
|
}
|
||||||
|
return ConsoleColor.DarkGray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
namespace HatariZipLauncher
|
||||||
|
{
|
||||||
|
public class NavigationController
|
||||||
|
{
|
||||||
|
public int SelectedIndex { get; private set; } = 0;
|
||||||
|
public int ScrollOffset { get; private set; } = 0;
|
||||||
|
public string CurrentRelativePath { get; private set; } = "";
|
||||||
|
// Stores the last selection & scroll position for each directory
|
||||||
|
private readonly Dictionary<string, (int sel, int scroll)> _lastSelections = new();
|
||||||
|
|
||||||
|
public void ResetSelection()
|
||||||
|
{
|
||||||
|
SelectedIndex = 0;
|
||||||
|
ScrollOffset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetEntriesCount(int count)
|
||||||
|
{
|
||||||
|
if (SelectedIndex >= count || SelectedIndex < 0) ResetSelection();
|
||||||
|
if (ScrollOffset > count - 1) ScrollOffset = Math.Max(0, count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool MoveUp(List<GameEntry> entries, int availableLines)
|
||||||
|
{
|
||||||
|
int oldScroll = ScrollOffset;
|
||||||
|
if (SelectedIndex > 0) SelectedIndex--;
|
||||||
|
UpdateScrollOffset(entries.Count, availableLines);
|
||||||
|
return oldScroll != ScrollOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool MoveDown(List<GameEntry> entries, int availableLines)
|
||||||
|
{
|
||||||
|
int oldScroll = ScrollOffset;
|
||||||
|
if (SelectedIndex < entries.Count - 1) SelectedIndex++;
|
||||||
|
UpdateScrollOffset(entries.Count, availableLines);
|
||||||
|
return oldScroll != ScrollOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PageUp(List<GameEntry> entries, int availableLines)
|
||||||
|
{
|
||||||
|
if (entries.Count == 0) return;
|
||||||
|
int newSelected = SelectedIndex - availableLines;
|
||||||
|
if (newSelected < 0) newSelected = 0;
|
||||||
|
SelectedIndex = newSelected;
|
||||||
|
UpdateScrollOffset(entries.Count, availableLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PageDown(List<GameEntry> entries, int availableLines)
|
||||||
|
{
|
||||||
|
if (entries.Count == 0) return;
|
||||||
|
int newSelected = SelectedIndex + availableLines;
|
||||||
|
if (newSelected >= entries.Count) newSelected = entries.Count - 1;
|
||||||
|
SelectedIndex = newSelected;
|
||||||
|
UpdateScrollOffset(entries.Count, availableLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HandleEnter(List<GameEntry> entries)
|
||||||
|
{
|
||||||
|
if (entries.Count == 0) return;
|
||||||
|
var entry = entries[SelectedIndex];
|
||||||
|
if (entry.Kind == EntryKind.Directory)
|
||||||
|
{
|
||||||
|
_lastSelections[CurrentRelativePath] = (SelectedIndex, ScrollOffset);
|
||||||
|
CurrentRelativePath = Path.Combine(CurrentRelativePath, entry.Name);
|
||||||
|
if (_lastSelections.TryGetValue(CurrentRelativePath, out var tuple))
|
||||||
|
{
|
||||||
|
SelectedIndex = tuple.sel;
|
||||||
|
ScrollOffset = tuple.scroll;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ResetSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Actual file launch is still caller's responsibility!
|
||||||
|
}
|
||||||
|
|
||||||
|
public void GoUpDirectory()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(CurrentRelativePath)) return;
|
||||||
|
_lastSelections[CurrentRelativePath] = (SelectedIndex, ScrollOffset);
|
||||||
|
CurrentRelativePath = Path.GetDirectoryName(CurrentRelativePath) ?? "";
|
||||||
|
if (_lastSelections.TryGetValue(CurrentRelativePath, out var tuple))
|
||||||
|
{
|
||||||
|
SelectedIndex = tuple.sel;
|
||||||
|
ScrollOffset = tuple.scroll;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ResetSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateScrollOffset(int entryCount, int availableLines)
|
||||||
|
{
|
||||||
|
if (availableLines < 1) availableLines = 1;
|
||||||
|
if (entryCount <= availableLines) { ScrollOffset = 0; return; }
|
||||||
|
if (SelectedIndex == 0) { ScrollOffset = 0; return; }
|
||||||
|
int bottomScrollTrigger = ScrollOffset + (int)(availableLines * 2 / 3.0);
|
||||||
|
int topScrollTrigger = ScrollOffset + (int)(availableLines * 1 / 3.0);
|
||||||
|
if (SelectedIndex >= bottomScrollTrigger && (ScrollOffset + availableLines) < entryCount)
|
||||||
|
ScrollOffset = SelectedIndex - (int)(availableLines * 2 / 3.0);
|
||||||
|
else if (SelectedIndex < topScrollTrigger && ScrollOffset > 0)
|
||||||
|
ScrollOffset = SelectedIndex - (int)(availableLines * 1 / 3.0);
|
||||||
|
if (ScrollOffset < 0) ScrollOffset = 0;
|
||||||
|
if (ScrollOffset > entryCount - availableLines)
|
||||||
|
ScrollOffset = entryCount - availableLines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
namespace HatariZipLauncher
|
||||||
|
{
|
||||||
|
public enum EntryKind { Directory, Zip }
|
||||||
|
|
||||||
|
public class GameEntry
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public EntryKind Kind { get; set; }
|
||||||
|
public bool InRoot { get; set; }
|
||||||
|
public bool InPatch { get; set; }
|
||||||
|
public string RootPath { get; set; } = string.Empty;
|
||||||
|
public string PatchPath { get; set; } = string.Empty;
|
||||||
|
// When true this entry is virtual (not backed by filesystem), e.g. the Favorites root.
|
||||||
|
public bool IsVirtual { get; set; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OverlayDirectoryBrowser(string root, string patch)
|
||||||
|
{
|
||||||
|
public List<GameEntry> GetEntries(string currentRelativePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Normalize and protect against path traversal or absolute paths in the relative path
|
||||||
|
string rel = currentRelativePath ?? string.Empty;
|
||||||
|
// Remove any leading directory separators
|
||||||
|
rel = rel.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
// 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 == ".."))
|
||||||
|
{
|
||||||
|
rel = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
string rootFull = Path.GetFullPath(root);
|
||||||
|
|
||||||
|
// Patch is optional. If not provided or empty, treat as no patch layer.
|
||||||
|
bool hasPatch = !string.IsNullOrWhiteSpace(patch);
|
||||||
|
string patchFull = hasPatch ? Path.GetFullPath(patch) : string.Empty;
|
||||||
|
|
||||||
|
string rootPath = Path.GetFullPath(Path.Combine(rootFull, rel));
|
||||||
|
if (!IsSubPathOf(rootPath, rootFull)) rootPath = rootFull;
|
||||||
|
|
||||||
|
string patchPath = string.Empty;
|
||||||
|
if (hasPatch)
|
||||||
|
{
|
||||||
|
patchPath = Path.GetFullPath(Path.Combine(patchFull, rel));
|
||||||
|
if (!IsSubPathOf(patchPath, patchFull)) patchPath = patchFull;
|
||||||
|
}
|
||||||
|
|
||||||
|
var directoriesRoot = Directory.Exists(rootPath) ? new DirectoryInfo(rootPath).GetDirectories()
|
||||||
|
.Where(d => (d.Attributes & FileAttributes.Hidden) == 0).Select(d => d.Name).ToHashSet(StringComparer.OrdinalIgnoreCase) : new HashSet<string>();
|
||||||
|
|
||||||
|
// If there's no patch configured, keep patch sets empty
|
||||||
|
var directoriesPatch = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var zipsPatch = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(patchPath) && Directory.Exists(patchPath))
|
||||||
|
{
|
||||||
|
directoriesPatch = new DirectoryInfo(patchPath).GetDirectories()
|
||||||
|
.Where(d => (d.Attributes & FileAttributes.Hidden) == 0).Select(d => d.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
zipsPatch = new DirectoryInfo(patchPath).GetFiles("*.zip")
|
||||||
|
.Where(f => (f.Attributes & FileAttributes.Hidden) == 0).Select(f => f.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
var zipsRoot = Directory.Exists(rootPath) ? new DirectoryInfo(rootPath).GetFiles("*.zip")
|
||||||
|
.Where(f => (f.Attributes & FileAttributes.Hidden) == 0).Select(f => f.Name).ToHashSet(StringComparer.OrdinalIgnoreCase) : new HashSet<string>();
|
||||||
|
|
||||||
|
var allDirectories = directoriesRoot.Union(directoriesPatch).OrderBy(x => x, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var allZips = zipsRoot.Union(zipsPatch).OrderBy(x => x, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var result = new List<GameEntry>();
|
||||||
|
foreach (var dir in allDirectories)
|
||||||
|
{
|
||||||
|
result.Add(new GameEntry
|
||||||
|
{
|
||||||
|
Name = dir,
|
||||||
|
Kind = EntryKind.Directory,
|
||||||
|
InRoot = directoriesRoot.Contains(dir),
|
||||||
|
InPatch = directoriesPatch.Contains(dir),
|
||||||
|
RootPath = Path.Combine(rootPath, dir),
|
||||||
|
PatchPath = Path.Combine(patchPath, dir)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
foreach (var zip in allZips)
|
||||||
|
{
|
||||||
|
result.Add(new GameEntry
|
||||||
|
{
|
||||||
|
Name = zip,
|
||||||
|
Kind = EntryKind.Zip,
|
||||||
|
InRoot = zipsRoot.Contains(zip),
|
||||||
|
InPatch = zipsPatch.Contains(zip),
|
||||||
|
RootPath = Path.Combine(rootPath, zip),
|
||||||
|
PatchPath = Path.Combine(patchPath, zip)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Do not silently swallow filesystem exceptions; propagate with context
|
||||||
|
throw new InvalidOperationException($"Failed to read entries for relative path '{currentRelativePath}': {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSubPathOf(string path, string basePath)
|
||||||
|
{
|
||||||
|
var comparison = StringComparison.OrdinalIgnoreCase;
|
||||||
|
// Ensure basePath ends with directory separator for correct prefix check
|
||||||
|
if (!basePath.EndsWith(Path.DirectorySeparatorChar))
|
||||||
|
basePath = basePath + Path.DirectorySeparatorChar;
|
||||||
|
if (!path.EndsWith(Path.DirectorySeparatorChar))
|
||||||
|
path = path + Path.DirectorySeparatorChar;
|
||||||
|
return path.StartsWith(basePath, comparison);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace HatariZipLauncher
|
||||||
|
{
|
||||||
|
class Program
|
||||||
|
{
|
||||||
|
static void Main(string[] args)
|
||||||
|
{
|
||||||
|
Console.Title = "Hatari ZIP Launcher";
|
||||||
|
var app = new LauncherApp();
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace HatariZipLauncher
|
||||||
|
{
|
||||||
|
// Small helpers refactored into their own file to keep Program.cs focused.
|
||||||
|
internal static class ProgramHelpers
|
||||||
|
{
|
||||||
|
// Flushes the console input buffer to avoid processing leftover key events
|
||||||
|
// Uses Win32 FlushConsoleInputBuffer on the standard input handle. On non-Windows
|
||||||
|
// environments this will be a no-op.
|
||||||
|
public static void FlushInputBuffer()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
var handle = GetStdHandle(STD_INPUT_HANDLE);
|
||||||
|
if (handle != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
FlushConsoleInputBuffer(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// swallowing is OK here; flushing input is a best-effort UX improvement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int STD_INPUT_HANDLE = -10;
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
|
||||||
|
private static extern IntPtr GetStdHandle(int nStdHandle);
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
|
||||||
|
private static extern bool FlushConsoleInputBuffer(IntPtr hConsoleInput);
|
||||||
|
|
||||||
|
// P/Invoke to query key state (used to detect physical key release)
|
||||||
|
private const int VK_RETURN = 0x0D;
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern short GetAsyncKeyState(int vKey);
|
||||||
|
|
||||||
|
// Shows a simple modal message on the reserved last console line and
|
||||||
|
// blocks until the Return key is physically released. The message is
|
||||||
|
// then removed and the method returns. This is designed to be a
|
||||||
|
// lightweight modal for the console UI and uses the last console line
|
||||||
|
// which the application reserves for transient messages.
|
||||||
|
public static void ShowModalUntilReturnReleased(string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int lastRow = Math.Max(0, Console.WindowHeight - 1);
|
||||||
|
int width = Console.WindowWidth;
|
||||||
|
string text = message ?? string.Empty;
|
||||||
|
if (text.Length > width) text = text.Substring(0, Math.Max(0, width - 3)) + "...";
|
||||||
|
int padding = Math.Max(0, width - text.Length);
|
||||||
|
string line = text + new string(' ', padding);
|
||||||
|
|
||||||
|
Console.BackgroundColor = ConsoleColor.DarkGray;
|
||||||
|
Console.ForegroundColor = ConsoleColor.White;
|
||||||
|
try { Console.SetCursorPosition(0, lastRow); } catch { }
|
||||||
|
try { Console.Write(line); } catch { }
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
// Wait until Return key is not pressed
|
||||||
|
// GetAsyncKeyState returns a short where the high-order bit is set when key is down
|
||||||
|
while ((GetAsyncKeyState(VK_RETURN) & 0x8000) != 0)
|
||||||
|
{
|
||||||
|
Thread.Sleep(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the line
|
||||||
|
try { Console.SetCursorPosition(0, lastRow); } catch { }
|
||||||
|
try { Console.Write(new string(' ', width)); } catch { }
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore any console errors here
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.ResetColor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ResolveIfRelative(string? path, string exeDir)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return string.Empty;
|
||||||
|
if (Path.IsPathRooted(path))
|
||||||
|
return path;
|
||||||
|
return Path.GetFullPath(Path.Combine(exeDir, path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centralized console message printer to avoid duplicated Console.WriteLine/ReadKey blocks.
|
||||||
|
public static void ShowConsoleMessage(string[] lines, ConsoleColor fg = ConsoleColor.Gray, bool clear = true, bool waitForKey = true)
|
||||||
|
{
|
||||||
|
if (clear) Console.Clear();
|
||||||
|
var prev = Console.ForegroundColor;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Console.ForegroundColor = fg;
|
||||||
|
foreach (var l in lines) Console.WriteLine(l);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.ForegroundColor = prev;
|
||||||
|
}
|
||||||
|
if (waitForKey) Console.ReadKey(intercept: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace HatariZipLauncher;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Centralized service for error and user message output in the console UI.
|
||||||
|
/// </summary>
|
||||||
|
public class UIErrorService
|
||||||
|
{
|
||||||
|
public void ShowError(string message)
|
||||||
|
{
|
||||||
|
ProgramHelpers.ShowConsoleMessage([message], ConsoleColor.Red, clear: false, waitForKey: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"RootDirectory": "C:\\Games\\Hatari\\ROMS",
|
||||||
|
"PatchDirectory": "C:\\Games\\Hatari\\PATCH",
|
||||||
|
"Hatari": {
|
||||||
|
"Executable": "C:\\Tools\\hatari\\hatari.exe",
|
||||||
|
"ConfigFile": "C:\\Tools\\hatari\\hatari-st.cfg",
|
||||||
|
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.6.33424.171
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HatariZipLauncher", "HatariZipLauncher\HatariZipLauncher.csproj", "{B1D2C3E4-F567-48AB-C9D0-1234567890AB}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{B1D2C3E4-F567-48AB-C9D0-1234567890AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B1D2C3E4-F567-48AB-C9D0-1234567890AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B1D2C3E4-F567-48AB-C9D0-1234567890AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B1D2C3E4-F567-48AB-C9D0-1234567890AB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# PLAN
|
||||||
|
|
||||||
|
Ziel: Das Repo aufräumen (Doku, Code, Config)
|
||||||
|
|
||||||
|
## 1. Code-Qualität – Schwächen beheben (harmlos, rein refaktoriell)
|
||||||
|
|
||||||
|
- [ ] **LauncherApp.cs zerlegen:** Die ~300-Zeilen-Klasse in separate Klassen aufteilen
|
||||||
|
(z.B. `InputController`/`KeyHandler` für den switch-Block, Lifecycle bleibt in LauncherApp).
|
||||||
|
Ziel: keine Funktionsänderung, nur Struktur.
|
||||||
|
- [ ] **Magische Strings entfernen / zentralisieren (nur im Code, siehe 2c):**
|
||||||
|
- [ ] Virtueller Ordnername `"Favorites"` (LauncherApp.cs) → gemeinsame Konstante (nicht konfigurierbar).
|
||||||
|
- [ ] Dateiname `"favorites.txt"` (LauncherApp.cs) → Konstante in FavoritesService (nicht konfigurierbar).
|
||||||
|
- [ ] Console-Titel `"Hatari ZIP Launcher"` (Program.cs) → Konstante (nicht konfigurierbar).
|
||||||
|
- [ ] Default-ArgsTemplate `-c "{cfg}" --disk-a "{zip}"` (LauncherApp.cs) → redundanten Fallback
|
||||||
|
entfernen; Config liefert den Template (ist schon in der example definiert, Validation `{zip}` existiert).
|
||||||
|
- [ ] **Scroll-Trigger magische Zahlen** (NavigationController.cs: `2/3`, `1/3`) in benannte Konstanten
|
||||||
|
(z.B. `BottomScrollFraction = 2f/3f`) mit Kommentar aufzählen.
|
||||||
|
- [ ] Redundanz beseitigen: `RedrawEntry` hat unnötige `maxRow`-Logik; `EnsureCacheForRow` ungenau →
|
||||||
|
klarer formulieren.
|
||||||
|
- [ ] **Fehler-Schlucken besprechen:** `FavoritesService.Save()` (catch leer) und `UIErrorService` –
|
||||||
|
entweder Kommentar ergänzen („bewusst still") oder Rückgabewert einführen.
|
||||||
|
- [ ] (Optional) Testprojekt hinzufügen für NavigationController & OverlayDirectoryBrowser –
|
||||||
|
vorab mit Nutzer klären, da Policy bisher keine Tests vorsieht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Hardcoded-Werte – Aufgaben
|
||||||
|
|
||||||
|
### 2a. Colorschema konfigurierbar machen (entschieden)
|
||||||
|
|
||||||
|
- Aktuell: `MenuRenderer.cs` (`GetColorForEntry`) und README/agents-Doku (6 Entry-Typen +
|
||||||
|
Auswahl-Schema schwarz/blau).
|
||||||
|
- Ziel: Farben (Foreground pro Entry-Typ + Selection-Farben) in `launcher.config.json` konfigurierbar
|
||||||
|
machen (**Entscheidung: ja, konfigurierbar**), mit Default-Fallback auf heutige Werte.
|
||||||
|
|
||||||
|
- [ ] `AppConfig` erweitern: neuen Abschnitt z.B. `"Colors"` hinzufügen:
|
||||||
|
- [ ] POCO `AppColorConfig` mit `ConsoleColor`-Werten (als String, z.B. `"Yellow"`):
|
||||||
|
- FolderBoth, FolderPatchOnly, FolderRootOnly,
|
||||||
|
ZipBoth, ZipRootOnly, ZipPatchOnly,
|
||||||
|
SelectedForeground, SelectedBackground, VirtualEntry(vorab optional).
|
||||||
|
- [ ] Deserialisierung per `Enum.TryParse<ConsoleColor>` + Default-Fallback.
|
||||||
|
- [ ] `MenuRenderer` bekommt optional passende `AppColorConfig` (Konstruktor-Injection);
|
||||||
|
`GetColorForEntry`/`GetColors` nutzen Config statt Konstanten.
|
||||||
|
- [ ] Falls Config-Werte fehlen → heutiges Verhalten beibehalten (fallback).
|
||||||
|
- [ ] Doku synchronisieren: README + agents.md Farbtabellen auf Config-Felder verlinken.
|
||||||
|
- [ ] Beispielwerte ins `launcher.config.example.json` aufnehmen.
|
||||||
|
|
||||||
|
### 2b. Benutzer-Config (User-Config) – entschieden
|
||||||
|
|
||||||
|
Ziel: die echte lokale Config gehört NICHT ins Repo, sondern bleibt lokal.
|
||||||
|
|
||||||
|
- [ ] **Entscheidung getroffen (Variante 1):** `launcher.config.json` bleibt im Build-Output/EXE-Ordner
|
||||||
|
(lokal, gitignored). Die `launcher.config.example.json` wird beim Release mitgeliefert und der
|
||||||
|
Nutzer passt sie sich manuell an → kopieren zu `launcher.config.json`.
|
||||||
|
- [ ] KEINE zusätzliche Suchreihenfolge (`%APPDATA%`, `~/.config`) implementieren.
|
||||||
|
- [ ] KEIN CLI-Parameter `--config` einführen.
|
||||||
|
- [ ] `.gitignore` entsprechend ergänzen (echte `launcher.config.json` + `favorites.txt` werden nie committet).
|
||||||
|
- [ ] README: Abschnitt „Configuration" beschreibt nur das manuelle Kopieren der example.
|
||||||
|
|
||||||
|
### 2c. Platzhalterwerte zentralisieren (sehr detailliert)
|
||||||
|
|
||||||
|
**Was bedeutet das Problem?**
|
||||||
|
Einige Werte stehen direkt („hartkodiert") im Code statt an einer zentralen Stelle. Bei Wiederverwendung
|
||||||
|
oder Änderung des Verhaltens sucht man sie an mehreren Stellen im Quelltext — und es passieren schnell
|
||||||
|
Inkonsistenzen. Die folgenden Werte sind betroffen:
|
||||||
|
|
||||||
|
| # | Wert | Aktuelle Stelle | Was es tut | Geplanter Fix |
|
||||||
|
|---|------|-----------------|------------|---------------|
|
||||||
|
| 1 | `"Favorites"` | `LauncherApp.cs` (2×) | Virtueller Ordnername, der dem Nutzer als Eintrag an der Wurzel angezeigt wird. Wenn zwei voneinander abweichende Literale im Codestand bleiben, funktioniert Enter/Anzeige dieses Ordners nicht mehr. | Eine einzige Konstante `const string FavoritesRootName = "Favorites";` (z.B. in `FavoritesService`); beide Verwendungen darauf zurückführen. **Nur Code-Konstante, nicht konfigurierbar.** |
|
||||||
|
| 2 | `"favorites.txt"` | `LauncherApp.cs` | Dateiname der Favoriten-Persistenz (liegt im Patch-Dir oder neben EXE). | Konstante im `FavoritesService` (z.B. `public const string DefaultFileName = "favorites.txt";`); Aufrufer nutzen diese statt Literal. **Nur Code-Konstante, nicht konfigurierbar.** |
|
||||||
|
| 3 | `"Hatari ZIP Launcher"` | `Program.cs` (`Console.Title`) | Konsolen-Titel, der im Fenstertitel erscheint. | Konstante `const string DefaultTitle = "Hatari ZIP Launcher";` (Nutzer-visible, aber **nicht konfigurierbar** — fixer Anzeige-Titel). |
|
||||||
|
| 4 | Default-ArgsTemplate `-c "{cfg}" --disk-a "{zip}"` | `LauncherApp.cs` | Redundanter Fallback, falls `Hatari.ArgsTemplate` in der Config nicht gesetzt ist. Ist bereits in der example definiert → das Hardcode ist nur ein Sicherheitsnetz. | Redundanz entfernen: Fallback streichen und erzwingen, dass die Config den Template liefert (Validation `{zip}` existiert schon). **Config-Feld bleibt die einzige Quelle.** |
|
||||||
|
| 5 | Scroll-Anteile `2/3` und `1/3` | `NavigationController.cs` | Bestimmt, ab welcher relativen Position im sichtbaren Fenster automatisch gescrollt wird (Cursor bei 2/3 unten → scrollen, bei 1/3 oben → zurückscrollen). | Benannte Konstanten `BottomScrollTriggerFraction = 2f/3f` und `TopScrollTriggerFraction = 1f/3f` mit Kommentar; Logik bleibt identisch. **Nur Code-Konstanten, nicht konfigurierbar.** |
|
||||||
|
| 6 | `WindowHeight - 1` / `WindowWidth` | `LauncherApp.cs` / `MenuRenderer.cs` | Die Liste ist immer eine Zeile weniger als die Konsolenhöhe (Policy, verhindert Auto-Scroll am Fensterrand). Wird an mehreren Stellen erneut berechnet. | Zentrale Helfer-Berechnung (z.B. statische Methode/Property `AvailableLines`), damit die Policy an genau einer Stelle kodiert ist; Logik unverändert (Resize-Handling bleibt). **Nur Code, nicht konfigurierbar.** |
|
||||||
|
| 7 | `STD_INPUT_HANDLE = -10`, `VK_RETURN = 0x0D` | `ProgramHelpers.cs` | Win32-Konstanten für FlushConsoleInputBuffer bzw. GetAsyncKeyState. Diese sind per Definition konstant. | KEINE Änderung nötig — nur per Kommentar als „bewusst hartkodiert" markieren (Win32-API-Konstanten). |
|
||||||
|
|
||||||
|
**Entscheidung:** Alle o.g. Werte werden **nur im Code zentralisiert** (Konstanten/Helfer).
|
||||||
|
Sie werden **NICHT** als Konfigurationsfelder in `launcher.config.json` angeboten — es gibt keine
|
||||||
|
externe Anpassungsmöglichkeit dafür.
|
||||||
|
|
||||||
|
**Verbindliche To-dos für 2c:**
|
||||||
|
|
||||||
|
- [ ] Konstanten/Helfer einführen für: Favorites-Ordnername, `favorites.txt`, Konsolen-Titel,
|
||||||
|
Scroll-Anteile, `AvailableLines`-Helfer.
|
||||||
|
- [ ] Default-ArgsTemplate-Fallback entfernen; Config muss `ArgsTemplate` immer liefern.
|
||||||
|
- [ ] Alle vorkommenden String-Literale auf die neuen Konstanten zurückführen (kein doppeltes `"Favorites"` mehr).
|
||||||
|
- [ ] Kommentare ergänzen, die erklären, WARUM der Wert fest ist (z.B. `WindowHeight-1` als Policy).
|
||||||
|
- [ ] Win32-Konstanten (Punkt 7) NICHT anfassen, nur per Kommentar als „bewusst hartkodiert" markieren.
|
||||||
|
- [ ] README/agents nicht um diese rein implementativen Werte erweitern (keine Config-Felder dokumentieren);
|
||||||
|
Doku bleibt bei nutzersichtbaren, konfigurierbaren Werten (Farben, Titel, RootDir/Patch/Hatari).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Abschlusskriterien
|
||||||
|
|
||||||
|
- [ ] Commit mit aussagekräftiger Message (nur echter Autor, kein Co-Author).
|
||||||
|
- [ ] Nach Doku- und Code-Änderungen: `build.cmd` (Windows) bzw. `build.sh` (Linux) läuft fehlerfrei.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# HatariZipLauncher
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- **Overlay/Patch Union (since v0.6):** Recursively merges main and patch directory at every level. Each object/name is shown only once (patch takes precedence).
|
||||||
|
- **Dynamic, practical color scheme:**
|
||||||
|
|
||||||
|
| Entry | ConsoleColor | Meaning
|
||||||
|
|------------------------|--------------|---------------------------------------------|
|
||||||
|
| Folder in both | ConsoleColor.Yellow | Directory in both layers (patch/main)
|
||||||
|
| Patch-only folder | ConsoleColor.DarkYellow | Directory only in patch layer
|
||||||
|
| Main-only folder | ConsoleColor.Gray | Directory only in main layer
|
||||||
|
| ZIP in both | ConsoleColor.Green | ZIP archive in both layers
|
||||||
|
| Main-only ZIP | ConsoleColor.DarkGreen | ZIP archive only in main layer
|
||||||
|
| Patch-only ZIP | ConsoleColor.Magenta | ZIP archive only in patch layer
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### Released Features
|
||||||
|
- **Favorites/bookmark system** (since v0.91):
|
||||||
|
- 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 (as of v0.84):**
|
||||||
|
- 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.
|
||||||
|
- No Console.Clear or full redraw on cursor movement—just targeted SetCursorPosition and Write.
|
||||||
|
- This method (per StackOverflow, Spectre.Console, Terminal.Gui, etc.) is optimal for smooth navigation in large lists.
|
||||||
|
- Further performance can be gained with shadow buffers/string-diffs per line, but currently there's no practical performance issue.
|
||||||
|
- Thus, display performance is at “best practice” level for .NET TUIs.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- **Consistent navigation & controls:**
|
||||||
|
- Arrow up/down: move selection (always visible)
|
||||||
|
- Enter: open folder / launch ZIP with Hatari (patch variant always preferred if present)
|
||||||
|
- Backspace: exactly one level up (never exceeds root)
|
||||||
|
- ESC: exit the program immediately
|
||||||
|
- PageUp/PageDown: jump exactly one screen full (window height - 1)
|
||||||
|
- 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
|
||||||
|
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 `build.cmd`.
|
||||||
|
3. **Linux:** Build via `build.sh` (run `chmod +x build.sh` first to make it executable).
|
||||||
|
4. **Always start using `start.cmd`.**
|
||||||
|
5. In the console, all subfolders and ZIPs in root (and recursively below) will be shown; other file types/hidden files are always ignored.
|
||||||
|
6. Complete navigation/control with arrow keys, Enter, Backspace, ESC, PgUp/PgDn, as described above.
|
||||||
|
7. **IMPORTANT:** Navigation/scroll/backspace:
|
||||||
|
- Backspace never escapes the root
|
||||||
|
- In root, Backspace has no effect
|
||||||
|
- Empty directories are reported (display stays stable)
|
||||||
|
8. **Overlay/patch logic:**
|
||||||
|
- If a ZIP/folder exists in both patch and main, always the patch version opens/launches
|
||||||
|
- Since v0.6, all navigation is relative to root path—for consistent experience
|
||||||
|
|
||||||
|
## System Requirements
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Example `launcher.config.example.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"RootDirectory": "C:\\Games\\Hatari\\ROMS",
|
||||||
|
"PatchDirectory": "C:\\Games\\Hatari\\PATCH",
|
||||||
|
"Hatari": {
|
||||||
|
"Executable": "C:\\Tools\\hatari\\hatari.exe",
|
||||||
|
"ConfigFile": "C:\\Tools\\hatari\\hatari-st.cfg",
|
||||||
|
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- 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).
|
||||||
|
- Hatari.Executable: Full path to `hatari.exe`.
|
||||||
|
- Hatari.ConfigFile: Full path to the Hatari configuration file.
|
||||||
|
- Hatari.ArgsTemplate: Argument template used to start Hatari. Use `{cfg}` for the Hatari config file path and `{zip}` for the ZIP file to launch.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Relative paths are resolved relative to the EXE directory (AppContext.BaseDirectory). This makes behavior consistent when running from the build output folder.
|
||||||
|
- Hatari.Executable is validated at startup: the file must exist and have an .exe extension. Relative paths for Hatari settings are resolved against the EXE folder.
|
||||||
|
- `Hatari.ArgsTemplate` must contain at least the `{zip}` placeholder. Example: `-c "{cfg}" --disk-a "{zip}"`.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Releases are automated via GitHub Actions. When a tag matching `v*` is pushed, the workflow (`.github/workflows/release.yml`) automatically:
|
||||||
|
1. Builds platform-specific artifacts (Windows, Linux, macOS)
|
||||||
|
2. Generates release notes from git log
|
||||||
|
3. Creates a GitHub Release with all ZIPs attached
|
||||||
|
|
||||||
|
**To create a release:**
|
||||||
|
1. Ensure `README.md` and `agents.md` are up to date.
|
||||||
|
2. Commit all changes.
|
||||||
|
3. Create and push a tag: `git tag v{version} && git push origin v{version}`.
|
||||||
|
4. The GitHub Action handles the rest.
|
||||||
|
|
||||||
|
**Local builds** (for development/testing):
|
||||||
|
- **Windows:** `build.cmd`
|
||||||
|
- **Linux/macOS:** `build.sh` (run `chmod +x build.sh` first)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- 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!
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
applyTo: '**'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Overview (HatariZipLauncher)
|
||||||
|
|
||||||
|
The implementation is split into focused modules (files) under the `HatariZipLauncher/` folder. Keep this section up to date when files are added, removed or responsibilities change.
|
||||||
|
|
||||||
|
- HatariZipLauncher/Program.cs: Minimal entry point. Sets console title and starts the application by creating `LauncherApp`.
|
||||||
|
- HatariZipLauncher/LauncherApp.cs: Application lifecycle host — loads configuration, initializes components and runs the main directory navigation loop (contains `AppHost` internal class).
|
||||||
|
- HatariZipLauncher/AppConfiguration.cs: POCO configuration classes (`AppConfig`, `AppHatariConfig`) used to deserialize `launcher.config.json`.
|
||||||
|
- HatariZipLauncher/ProgramHelpers.cs: Small shared helpers (resolve relative paths, centralized console message helper) used across modules.
|
||||||
|
- HatariZipLauncher/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.
|
||||||
|
- HatariZipLauncher/NavigationController.cs: Encapsulates selection, scrolling and relative-path navigation logic (cursor, page up/down, per-directory remembered selection/state).
|
||||||
|
- HatariZipLauncher/MenuRenderer.cs: Console rendering logic — efficient per-line redraw, double-buffering and color selection according to overlay rules.
|
||||||
|
- HatariZipLauncher/HatariLauncher.cs: Responsible for validating the Hatari executable and starting Hatari with the configured argument template (replaces `{cfg}` and `{zip}`).
|
||||||
|
- HatariZipLauncher/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 for Automated Tests/CI:**
|
||||||
|
The Launcher cannot be executed or tested via `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 start.cmd per documentation and policy before delivery.
|
||||||
|
|
||||||
|
**Release Process (automated via GitHub Actions):**
|
||||||
|
- Pushing a tag (`v*`) triggers the GitHub Action workflow (`.github/workflows/release.yml`).
|
||||||
|
- The workflow builds platform-specific artifacts (Windows, Linux, macOS), generates release notes from git log, and creates a GitHub Release with all ZIPs attached.
|
||||||
|
- Developer steps for a release:
|
||||||
|
1. Ensure `README.md` and `agents.md` are up to date.
|
||||||
|
2. Commit all changes.
|
||||||
|
3. Create and push a tag: `git tag v{version} && git push origin v{version}`.
|
||||||
|
4. The GitHub Action handles the rest (build, ZIP, release notes, GitHub Release).
|
||||||
|
- Local release artifacts in `release/` are optional and no longer required for the release process.
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Developer note: Visual Studio Solution
|
||||||
|
- A Visual Studio solution file exists at the repository root: `MarcerLauncher.sln`. Developers may open this solution in Visual Studio to work on the project, debug and build from the IDE. The solution references `HatariZipLauncher\HatariZipLauncher.csproj` and includes Debug and Release configurations. Use `build.cmd` (Windows) or `build.sh` (Linux) and `start.cmd` 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.
|
||||||
|
|
||||||
|
# Requirements for the Hatari ZIP Launcher (agents.md)
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Detailed Requirements
|
||||||
|
|
||||||
|
### Navigation and Display Principles
|
||||||
|
- Start directory (root):
|
||||||
|
The configured RootDirectory from launcher.config.json
|
||||||
|
It must NEVER be possible to navigate outside this directory.
|
||||||
|
- Only display subfolders and ZIP files; ignore other file types and hidden files.
|
||||||
|
- Navigation and control exclusively with these keys:
|
||||||
|
- Arrow down/up: scroll by single entries
|
||||||
|
- Enter: open folder or launch ZIP with Hatari
|
||||||
|
- Backspace: jump to parent directory (never outside root)
|
||||||
|
- ESC: exit the program
|
||||||
|
- PageUp/PageDown: jump by one page up/down through the file list
|
||||||
|
- The file list always shows exactly as many lines as fit the screen – ALWAYS **one line less** than the console height (`Console.WindowHeight - 1`). This avoids overflow at the bottom and ensures the selection never enters the non-visible area.
|
||||||
|
Rationale: writing to the very last console line can cause the Windows console to auto-scroll or produce visual jumps when the cursor reaches the bottom row. Reserving one line prevents unintended scrolling/flicker and keeps the selection cursor strictly within the visible area.
|
||||||
|
Maintenance: when changing rendering or navigation logic, always compute the displayed page size as `availableLines = Console.WindowHeight - 1` and keep this value consistent across MenuRenderer, NavigationController and any other code that references the console height.
|
||||||
|
- There is no information line/path display.
|
||||||
|
- Cursor and scroll logic:
|
||||||
|
- The selection cursor must always remain in the visible area.
|
||||||
|
- If the list is longer than the visible window, scrolling is necessary (cursor moves to bottom/top third as you move through).
|
||||||
|
- The program may be started in the root directory; all subfolders/ZIPs should be accessible from there.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Error Handling & Edge Cases
|
||||||
|
- Paths with special characters or spaces must work robustly.
|
||||||
|
- It must NEVER be possible for navigation logic to leave the configured root folder.
|
||||||
|
- Empty directories must be displayed correctly (or reported correctly).
|
||||||
|
- In the root directory, Backspace must have no effect (no error, do not leave the program).
|
||||||
|
- Navigation (Backspace, Enter, etc.) must remain robust even for very deep or large directory trees.
|
||||||
|
- Hatari.Executable is validated during startup: the path is resolved (relative to the EXE directory when applicable) and must point to an existing .exe file. If validation fails the program must present a clear error and exit.
|
||||||
|
|
||||||
|
### Miscellaneous
|
||||||
|
- Optional: Build and start scripts (build.cmd / build.sh / start.cmd) are present, adapt as needed.
|
||||||
|
- For ALL builds, tests, and releases, ONLY the platform build script may be used: `build.cmd` (Windows) or `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 `start.cmd`.
|
||||||
|
- After making any code changes that affect behavior or touch source files, run the platform build script (`build.cmd` on Windows, `build.sh` on Linux) and ensure the build completes successfully before committing. Additionally, perform a manual functional test using `start.cmd` on a Windows machine prior to pushing a release.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer/Overlay Mode (since v0.6)
|
||||||
|
|
||||||
|
### Overlay Logic
|
||||||
|
- The display always combines the contents of the game root ("main directory") and patch directory.
|
||||||
|
- There is always exactly one overlay browser – at every level, both sources are combined.
|
||||||
|
- Each folder/file is only shown once per name:
|
||||||
|
- If a folder/ZIP exists in both layers, the patch version takes precedence for launching/navigation.
|
||||||
|
- ZIP start/open always uses the patch file path if present, otherwise from root.
|
||||||
|
- Navigation combines both layers **recursively** at every sublevel. Navigation/Backspace is always relative.
|
||||||
|
|
||||||
|
Note on PatchDirectory semantics:
|
||||||
|
- PatchDirectory is optional. If the configuration contains no PatchDirectory or it is empty, the application treats this as "no patch layer" and will not consult any patch paths. In other words: an empty or missing PatchDirectory will not implicitly point to the EXE or any other folder — the overlay layer is simply disabled.
|
||||||
|
|
||||||
|
Implementation note (input flushing):
|
||||||
|
- To avoid undesired key-repeat / input "afterglow" when the user holds navigation keys, the application performs a best-effort flush of the console input buffer after navigation events. This is implemented by ProgramHelpers.FlushInputBuffer(), which uses the Win32 FlushConsoleInputBuffer API on Windows. This behaviour is intentional and required to provide a responsive navigation experience.
|
||||||
|
|
||||||
|
### Color Scheme
|
||||||
|
- Folder in both layers: **ConsoleColor.Yellow**
|
||||||
|
- Folder only in patch layer: **ConsoleColor.DarkYellow**
|
||||||
|
- Folder only in main layer: **ConsoleColor.Gray**
|
||||||
|
- ZIP in both layers: **ConsoleColor.Green**
|
||||||
|
- ZIP only in main layer: **ConsoleColor.DarkGreen**
|
||||||
|
- ZIP only in patch layer: **ConsoleColor.Magenta**
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- Navigation since v0.6 is always based strictly on the **relative path from root** and is consistent on all levels (Backspace always moves up one level, Enter always moves one level deeper, regardless of which layer).
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@echo off
|
||||||
|
|
||||||
|
REM === Build script for HatariZipLauncher (requires .NET SDK 6 or newer) ===
|
||||||
|
echo Building HatariZipLauncher...
|
||||||
|
where dotnet >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] .NET SDK not found. Please install from https://dotnet.microsoft.com/download
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Im aktuellen Ordner (wo build.cmd liegt) bauen
|
||||||
|
cd /d %~dp0
|
||||||
|
cd HatariZipLauncher
|
||||||
|
|
||||||
|
dotnet build -c Release
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Build failed!
|
||||||
|
exit /b 2
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Finden der fertigen .exe (Release-Verzeichnis)
|
||||||
|
for /f "delims=" %%I in ('dir /b /s /a-d bin\Release\*HatariZipLauncher*.exe') do set EXEPATH=%%I
|
||||||
|
if exist "%EXEPATH%" (
|
||||||
|
echo [OK] Build complete. EXE: "%EXEPATH%"
|
||||||
|
) else (
|
||||||
|
echo [WARNING] Build appears successful but .exe not found!
|
||||||
|
)
|
||||||
|
|
||||||
|
pause
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# === Build script for HatariZipLauncher (requires .NET SDK 6 or newer) ===
|
||||||
|
echo "Building HatariZipLauncher..."
|
||||||
|
|
||||||
|
if ! command -v dotnet &> /dev/null; then
|
||||||
|
echo "[ERROR] .NET SDK not found. Please install from https://dotnet.microsoft.com/download"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build in current directory (where build.sh is located)
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# Change to HatariZipLauncher subdirectory
|
||||||
|
cd "HatariZipLauncher"
|
||||||
|
|
||||||
|
dotnet build -c Release
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "[ERROR] Build failed!"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for the built .exe file
|
||||||
|
EXEPATH=$(find "bin/Release" -name "HatariZipLauncher*.exe" -print -quit 2>/dev/null)
|
||||||
|
if [ -n "$EXEPATH" ] && [ -f "$EXEPATH" ]; then
|
||||||
|
echo "[OK] Build complete. EXE: \"$EXEPATH\""
|
||||||
|
else
|
||||||
|
echo "[WARNING] Build appears successful but .exe not found!"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@echo off
|
||||||
|
|
||||||
|
REM Starts HatariZipLauncher.exe from the correct folder
|
||||||
|
setlocal
|
||||||
|
set EXE_PATH=%~dp0HatariZipLauncher\bin\Release\net10.0\HatariZipLauncher.exe
|
||||||
|
|
||||||
|
if not exist "%EXE_PATH%" (
|
||||||
|
echo [ERROR] Application not built. Please run build.cmd first.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
pushd "HatariZipLauncher\bin\Release\net10.0"
|
||||||
|
"HatariZipLauncher.exe"
|
||||||
|
popd
|
||||||
Reference in New Issue
Block a user