mirror of
https://github.com/skoelle/marcer-gamedvd-launcher.git
synced 2026-09-18 03:00:25 +00:00
refactor: decompose LauncherApp, centralize magic strings/constants, configurable colors
- Extract InputController from AppHost (key handling + ReloadGameEntries) - Centralize hardcoded values as code constants (non-configurable): FavoritesRootName, DefaultFileName, DefaultTitle, scroll fractions, AvailableLines helper, removed redundant ArgsTemplate fallback - Make color scheme configurable via 'Colors' section in launcher.config.json with Enum.TryParse + default fallback; MenuRenderer uses constructor injection - Clean up MenuRenderer: remove dead maxRow logic, unify cache sizing via EnsureCache - Document intentional error swallowing in FavoritesService.Save/UIErrorService - Update README + AGENTS.md with configurable color docs - Remove redundant ArgsTemplate fallback (config is single source of truth)
This commit is contained in:
@@ -4,6 +4,25 @@
|
||||
namespace MarcerGameDvdLauncher
|
||||
{
|
||||
// Configuration POCOs separated into their own file for clarity
|
||||
|
||||
/// <summary>
|
||||
/// Color configuration for the menu renderer.
|
||||
/// All properties have default values matching the original hardcoded scheme,
|
||||
/// so omitting any value (or the entire "Colors" section) preserves existing behaviour.
|
||||
/// </summary>
|
||||
public class AppColorConfig
|
||||
{
|
||||
public ConsoleColor FolderBoth { get; set; } = ConsoleColor.Yellow;
|
||||
public ConsoleColor FolderPatchOnly { get; set; } = ConsoleColor.DarkYellow;
|
||||
public ConsoleColor FolderRootOnly { get; set; } = ConsoleColor.Gray;
|
||||
public ConsoleColor ZipBoth { get; set; } = ConsoleColor.Green;
|
||||
public ConsoleColor ZipRootOnly { get; set; } = ConsoleColor.DarkGreen;
|
||||
public ConsoleColor ZipPatchOnly { get; set; } = ConsoleColor.Magenta;
|
||||
public ConsoleColor SelectedForeground { get; set; } = ConsoleColor.Black;
|
||||
public ConsoleColor SelectedBackground { get; set; } = ConsoleColor.DarkCyan;
|
||||
public ConsoleColor VirtualEntry { get; set; } = ConsoleColor.White;
|
||||
}
|
||||
|
||||
public class AppHatariConfig
|
||||
{
|
||||
public string? Executable { get; set; }
|
||||
@@ -16,5 +35,9 @@ namespace MarcerGameDvdLauncher
|
||||
public string? RootDirectory { get; set; }
|
||||
public string? PatchDirectory { get; set; }
|
||||
public AppHatariConfig? Hatari { get; set; }
|
||||
// Ignored during JSON deserialization — parsed manually in LoadConfiguration so that
|
||||
// invalid color strings fall back to defaults instead of throwing.
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public AppColorConfig? Colors { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ namespace MarcerGameDvdLauncher
|
||||
// Manages loading, saving and querying favorite ZIP paths.
|
||||
public class FavoritesService
|
||||
{
|
||||
// Virtual folder name displayed at the root when favorites exist.
|
||||
// Not configurable — fixed UI element.
|
||||
public const string FavoritesRootName = "Favorites";
|
||||
|
||||
// Filename used for persisting favorites to disk.
|
||||
// Not configurable — fixed persistence file.
|
||||
public const string DefaultFileName = "favorites.txt";
|
||||
|
||||
private readonly string _filePath;
|
||||
// Use a SortedSet so favorites are kept in sorted order in memory.
|
||||
private SortedSet<string> _favorites = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -87,7 +95,11 @@ namespace MarcerGameDvdLauncher
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Let callers surface errors (we swallow here to avoid throwing on write failures during UI operations)
|
||||
// Swallowed intentionally ("bewusst still"): a persistence failure
|
||||
// (e.g. disk full, read-only directory) must not crash or interrupt
|
||||
// the UI. The in-memory state is still updated so the user sees
|
||||
// immediate feedback; only the on-disk write is lost. On next
|
||||
// application start the favorites reflect the last successful save.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||
// Licensed under the MIT License. See LICENSE file in project root for details.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace MarcerGameDvdLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles user input (key events) and the associated navigation / drawing logic.
|
||||
/// Extracted from AppHost so that LauncherApp stays focused on lifecycle management.
|
||||
/// No functional change - all key-handling behaviour is preserved exactly.
|
||||
/// </summary>
|
||||
internal class InputController
|
||||
{
|
||||
private readonly OverlayDirectoryBrowser _directoryBrowser;
|
||||
private readonly MenuRenderer _menuRenderer;
|
||||
private readonly NavigationController _navigationController;
|
||||
private readonly HatariLauncher _hatariLauncher;
|
||||
private readonly FavoritesService _favoritesService;
|
||||
private readonly UIErrorService _errorService;
|
||||
|
||||
private List<GameEntry> _gameEntries = new();
|
||||
|
||||
public IReadOnlyList<GameEntry> GameEntries => _gameEntries;
|
||||
|
||||
public InputController(OverlayDirectoryBrowser directoryBrowser, MenuRenderer menuRenderer,
|
||||
NavigationController navigationController, HatariLauncher hatariLauncher,
|
||||
FavoritesService favoritesService, UIErrorService errorService)
|
||||
{
|
||||
_directoryBrowser = directoryBrowser;
|
||||
_menuRenderer = menuRenderer;
|
||||
_navigationController = navigationController;
|
||||
_hatariLauncher = hatariLauncher;
|
||||
_favoritesService = favoritesService;
|
||||
_errorService = errorService;
|
||||
}
|
||||
|
||||
private bool IsFavorite(GameEntry e)
|
||||
{
|
||||
return e.Kind == EntryKind.Zip
|
||||
? _favoritesService.IsFavorite(e.InPatch ? e.PatchPath : e.RootPath)
|
||||
: false;
|
||||
}
|
||||
|
||||
private void DrawMenu(int availableLines)
|
||||
{
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset,
|
||||
_navigationController.SelectedIndex, availableLines, IsFavorite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates scroll offset and redraws the full menu. Called on initial load and console resize.
|
||||
/// </summary>
|
||||
public void RefreshView(int availableLines)
|
||||
{
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines);
|
||||
DrawMenu(availableLines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads entries for the current directory (or the virtual Favorites folder).
|
||||
/// </summary>
|
||||
public void ReloadGameEntries()
|
||||
{
|
||||
try
|
||||
{
|
||||
// If we are at the virtual Favorites folder, produce the flat list from the favorites service
|
||||
if (string.Equals(_navigationController.CurrentRelativePath, FavoritesService.FavoritesRootName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var favs = _favoritesService.GetAll();
|
||||
_gameEntries = new List<GameEntry>();
|
||||
foreach (var p in favs)
|
||||
{
|
||||
_gameEntries.Add(new GameEntry
|
||||
{
|
||||
Name = Path.GetFileName(p),
|
||||
Kind = EntryKind.Zip,
|
||||
InRoot = true,
|
||||
InPatch = false,
|
||||
RootPath = p,
|
||||
PatchPath = string.Empty,
|
||||
IsVirtual = false
|
||||
});
|
||||
}
|
||||
_navigationController.SetEntriesCount(_gameEntries.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise use the overlay directory browser for normal folders
|
||||
_gameEntries = _directoryBrowser.GetEntries(_navigationController.CurrentRelativePath);
|
||||
|
||||
// If we are at the root and there are favorites, prepend a virtual Favorites folder
|
||||
if (string.IsNullOrEmpty(_navigationController.CurrentRelativePath) && _favoritesService.HasFavorites())
|
||||
{
|
||||
var virtualEntry = new GameEntry
|
||||
{
|
||||
Name = FavoritesService.FavoritesRootName,
|
||||
Kind = EntryKind.Directory,
|
||||
InRoot = true,
|
||||
InPatch = false,
|
||||
RootPath = string.Empty,
|
||||
PatchPath = string.Empty,
|
||||
IsVirtual = true
|
||||
};
|
||||
_gameEntries.Insert(0, virtualEntry);
|
||||
}
|
||||
|
||||
_navigationController.SetEntriesCount(_gameEntries.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Show the error to the user and continue with an empty list
|
||||
_errorService.ShowError(ex.Message);
|
||||
_gameEntries = new List<GameEntry>();
|
||||
_navigationController.SetEntriesCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a key press. Returns true if the application should exit.
|
||||
/// </summary>
|
||||
public bool HandleKey(ConsoleKeyInfo key, int availableLines)
|
||||
{
|
||||
if (key.KeyChar == '?')
|
||||
{
|
||||
_menuRenderer.ShowHelpBox(availableLines);
|
||||
Console.ReadKey(intercept: true);
|
||||
_menuRenderer.InvalidateCache();
|
||||
DrawMenu(availableLines);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (key.Key)
|
||||
{
|
||||
case ConsoleKey.UpArrow:
|
||||
{
|
||||
int previousSelectedIndex = _navigationController.SelectedIndex;
|
||||
bool didScroll = _navigationController.MoveUp(_gameEntries, availableLines);
|
||||
if (didScroll)
|
||||
{
|
||||
DrawMenu(availableLines);
|
||||
}
|
||||
else
|
||||
{
|
||||
_menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndex, previousSelectedIndex - _navigationController.ScrollOffset, false, availableLines, IsFavorite);
|
||||
_menuRenderer.RedrawEntry(_gameEntries, _navigationController.SelectedIndex, _navigationController.SelectedIndex - _navigationController.ScrollOffset, true, availableLines, IsFavorite);
|
||||
}
|
||||
// flush input to avoid key repeat
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.DownArrow:
|
||||
{
|
||||
int previousSelectedIndex = _navigationController.SelectedIndex;
|
||||
bool didScroll = _navigationController.MoveDown(_gameEntries, availableLines);
|
||||
if (didScroll)
|
||||
{
|
||||
DrawMenu(availableLines);
|
||||
}
|
||||
else
|
||||
{
|
||||
_menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndex, previousSelectedIndex - _navigationController.ScrollOffset, false, availableLines, IsFavorite);
|
||||
_menuRenderer.RedrawEntry(_gameEntries, _navigationController.SelectedIndex, _navigationController.SelectedIndex - _navigationController.ScrollOffset, true, availableLines, IsFavorite);
|
||||
}
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.Enter:
|
||||
case ConsoleKey.RightArrow:
|
||||
{
|
||||
var oldRelativePath = _navigationController.CurrentRelativePath;
|
||||
var isDirectory = _gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Directory;
|
||||
_navigationController.HandleEnter(_gameEntries);
|
||||
if (isDirectory && oldRelativePath != _navigationController.CurrentRelativePath)
|
||||
{
|
||||
ReloadGameEntries();
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines);
|
||||
}
|
||||
DrawMenu(availableLines);
|
||||
// Only start a ZIP if NOT switching to a directory
|
||||
if (!isDirectory && _gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Zip)
|
||||
{
|
||||
string zipToLaunch = _gameEntries[_navigationController.SelectedIndex].InPatch ? _gameEntries[_navigationController.SelectedIndex].PatchPath : _gameEntries[_navigationController.SelectedIndex].RootPath;
|
||||
try
|
||||
{
|
||||
_hatariLauncher.Launch(zipToLaunch);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorService.ShowError(ex.Message);
|
||||
}
|
||||
}
|
||||
// flush input to avoid leftover key events after an enter/navigation
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.Backspace:
|
||||
case ConsoleKey.LeftArrow:
|
||||
{
|
||||
_navigationController.GoUpDirectory();
|
||||
ReloadGameEntries();
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines);
|
||||
DrawMenu(availableLines);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.PageDown:
|
||||
{
|
||||
_navigationController.PageDown(_gameEntries, availableLines);
|
||||
DrawMenu(availableLines);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.PageUp:
|
||||
{
|
||||
_navigationController.PageUp(_gameEntries, availableLines);
|
||||
DrawMenu(availableLines);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.Multiply:
|
||||
case ConsoleKey.Oem8:
|
||||
{
|
||||
// Toggle favorite for selected ZIP (handles numpad * and some layouts)
|
||||
if (_gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Zip)
|
||||
{
|
||||
var ge = _gameEntries[_navigationController.SelectedIndex];
|
||||
string path = ge.InPatch ? ge.PatchPath : ge.RootPath;
|
||||
try
|
||||
{
|
||||
_favoritesService.Toggle(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorService.ShowError("Failed to toggle favorite: " + ex.Message);
|
||||
}
|
||||
// Redraw the whole menu so the '*' marker updates immediately
|
||||
DrawMenu(availableLines);
|
||||
}
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
}
|
||||
break;
|
||||
case ConsoleKey.Escape:
|
||||
case ConsoleKey.Q:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,245 +69,139 @@ namespace MarcerGameDvdLauncher
|
||||
if (string.IsNullOrWhiteSpace(cfg.Hatari.ArgsTemplate) || !cfg.Hatari.ArgsTemplate.Contains("{zip}"))
|
||||
throw new InvalidOperationException("Hatari.ArgsTemplate must contain the {zip} placeholder.");
|
||||
|
||||
// Parse Colors section manually so invalid values fall back to defaults
|
||||
// instead of crashing the deserialization.
|
||||
cfg.Colors = ParseAppColors(json);
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// Parses the optional "Colors" JSON section into an AppColorConfig.
|
||||
// Each field is resolved with Enum.TryParse<ConsoleColor>; unparseable or
|
||||
// missing values silently fall back to the defaults defined in AppColorConfig.
|
||||
private static AppColorConfig ParseAppColors(string json)
|
||||
{
|
||||
var colors = new AppColorConfig();
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("Colors", out var colorsEl) && colorsEl.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
ParseColorField(colorsEl, "FolderBoth", v => colors.FolderBoth = v);
|
||||
ParseColorField(colorsEl, "FolderPatchOnly", v => colors.FolderPatchOnly = v);
|
||||
ParseColorField(colorsEl, "FolderRootOnly", v => colors.FolderRootOnly = v);
|
||||
ParseColorField(colorsEl, "ZipBoth", v => colors.ZipBoth = v);
|
||||
ParseColorField(colorsEl, "ZipRootOnly", v => colors.ZipRootOnly = v);
|
||||
ParseColorField(colorsEl, "ZipPatchOnly", v => colors.ZipPatchOnly = v);
|
||||
ParseColorField(colorsEl, "SelectedForeground", v => colors.SelectedForeground = v);
|
||||
ParseColorField(colorsEl, "SelectedBackground", v => colors.SelectedBackground = v);
|
||||
ParseColorField(colorsEl, "VirtualEntry", v => colors.VirtualEntry = v);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// On any JSON error, fall back to default colors (already set above)
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
private static void ParseColorField(System.Text.Json.JsonElement colorsEl, string name, Action<ConsoleColor> setter)
|
||||
{
|
||||
if (colorsEl.TryGetProperty(name, out var prop) && prop.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
var str = prop.GetString();
|
||||
if (Enum.TryParse<ConsoleColor>(str ?? string.Empty, ignoreCase: true, out var parsed))
|
||||
setter(parsed);
|
||||
// Invalid color names are silently ignored — defaults are preserved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internal host that keeps state previously stored in Program.cs
|
||||
// Internal host that keeps state and lifecycle management for the application.
|
||||
// Key-handling logic has been extracted into InputController; this class
|
||||
// focuses on component wiring, initialization and the main loop (resize detection + key polling).
|
||||
internal class AppHost(AppConfig cfg)
|
||||
{
|
||||
OverlayDirectoryBrowser? _directoryBrowser;
|
||||
MenuRenderer _menuRenderer = new MenuRenderer();
|
||||
NavigationController _navigationController = new NavigationController();
|
||||
List<GameEntry> _gameEntries = new List<GameEntry>();
|
||||
HatariLauncher? _hatariLauncher;
|
||||
readonly UIErrorService _errorService = new UIErrorService();
|
||||
FavoritesService? _favoritesService;
|
||||
private InputController? _inputController;
|
||||
private MenuRenderer? _menuRenderer;
|
||||
private HatariLauncher? _hatariLauncher;
|
||||
private int _currentAvailableLines;
|
||||
private int _currentWidth;
|
||||
|
||||
public void InitializeComponents()
|
||||
public void InitializeComponents()
|
||||
{
|
||||
var directoryBrowser = new OverlayDirectoryBrowser(cfg.RootDirectory ?? string.Empty, cfg.PatchDirectory ?? string.Empty);
|
||||
|
||||
// Initialize favorites service. Use PatchDirectory if present, otherwise exe dir fallback.
|
||||
string favPath;
|
||||
if (!string.IsNullOrWhiteSpace(cfg.PatchDirectory))
|
||||
{
|
||||
_directoryBrowser = new OverlayDirectoryBrowser(cfg.RootDirectory ?? string.Empty, cfg.PatchDirectory ?? string.Empty);
|
||||
// Initialize favorites service. Use PatchDirectory if present, otherwise exe dir fallback.
|
||||
string favPath;
|
||||
if (!string.IsNullOrWhiteSpace(cfg.PatchDirectory))
|
||||
{
|
||||
favPath = Path.Combine(cfg.PatchDirectory!, "favorites.txt");
|
||||
}
|
||||
else
|
||||
{
|
||||
favPath = Path.Combine(AppContext.BaseDirectory, "favorites.txt");
|
||||
}
|
||||
_favoritesService = new FavoritesService(favPath);
|
||||
try { _favoritesService.Load(); } catch { /* ignore load errors */ }
|
||||
try
|
||||
{
|
||||
_hatariLauncher = new HatariLauncher(cfg.Hatari?.Executable ?? throw new InvalidOperationException("Hatari.Executable not configured"), cfg.Hatari?.ConfigFile ?? string.Empty, cfg.Hatari?.ArgsTemplate ?? "-c \"{cfg}\" --disk-a \"{zip}\"");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ProgramHelpers.ShowConsoleMessage(["Hatari initialization error: " + ex.Message, "Press any key to exit."
|
||||
], ConsoleColor.Red);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
Console.CursorVisible = false;
|
||||
favPath = Path.Combine(cfg.PatchDirectory!, FavoritesService.DefaultFileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
favPath = Path.Combine(AppContext.BaseDirectory, FavoritesService.DefaultFileName);
|
||||
}
|
||||
var favoritesService = new FavoritesService(favPath);
|
||||
try { favoritesService.Load(); } catch { /* ignore load errors */ }
|
||||
|
||||
var errorService = new UIErrorService();
|
||||
try
|
||||
{
|
||||
// ArgsTemplate is validated in LoadConfiguration — it must always contain {zip}.
|
||||
// No hardcoded fallback is needed; the config file is the single source of truth.
|
||||
_hatariLauncher = new HatariLauncher(cfg.Hatari?.Executable ?? throw new InvalidOperationException("Hatari.Executable not configured"), cfg.Hatari?.ConfigFile ?? string.Empty, cfg.Hatari?.ArgsTemplate ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ProgramHelpers.ShowConsoleMessage(["Hatari initialization error: " + ex.Message, "Press any key to exit."
|
||||
], ConsoleColor.Red);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
_menuRenderer = new MenuRenderer(cfg.Colors);
|
||||
var navigationController = new NavigationController();
|
||||
|
||||
_inputController = new InputController(directoryBrowser, _menuRenderer, navigationController,
|
||||
_hatariLauncher!, favoritesService, errorService);
|
||||
|
||||
Console.CursorVisible = false;
|
||||
}
|
||||
|
||||
public void RunDirectoryNavigation()
|
||||
{
|
||||
_currentAvailableLines = ProgramHelpers.AvailableLines;
|
||||
_currentWidth = Console.WindowWidth;
|
||||
|
||||
_inputController!.ReloadGameEntries();
|
||||
_inputController.RefreshView(_currentAvailableLines);
|
||||
|
||||
bool exitRequested = false;
|
||||
ReloadGameEntries();
|
||||
int currentAvailableLines = Console.WindowHeight - 1;
|
||||
int currentWidth = Console.WindowWidth;
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
|
||||
var isFav = new Func<GameEntry, bool>(e => _favoritesService?.IsFavorite(e.Kind == EntryKind.Zip ? (e.InPatch ? e.PatchPath : e.RootPath) ?? string.Empty : string.Empty) ?? false);
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
while (!exitRequested)
|
||||
{
|
||||
// Reloads are performed explicitly when entering or leaving directories (Enter/Backspace)
|
||||
// Reloads are performed explicitly by InputController when entering/leaving directories
|
||||
// Do NOT hit the filesystem here on every loop iteration.
|
||||
|
||||
// detect a change in console height and/or width and redraw immediately
|
||||
int latestAvailableLines = Console.WindowHeight - 1;
|
||||
int latestAvailableLines = ProgramHelpers.AvailableLines;
|
||||
int latestWidth = Console.WindowWidth;
|
||||
if (latestAvailableLines != currentAvailableLines || latestWidth != currentWidth)
|
||||
{
|
||||
currentAvailableLines = latestAvailableLines;
|
||||
currentWidth = latestWidth;
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
}
|
||||
|
||||
// Only block if there's actually a key; otherwise allow resize detection
|
||||
if (!Console.KeyAvailable)
|
||||
{
|
||||
Thread.Sleep(50);
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
if (key.KeyChar == '?')
|
||||
{
|
||||
_menuRenderer.ShowHelpBox(currentAvailableLines);
|
||||
Console.ReadKey(intercept: true);
|
||||
_menuRenderer.InvalidateCache();
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
continue;
|
||||
}
|
||||
switch (key.Key)
|
||||
{
|
||||
case ConsoleKey.UpArrow:
|
||||
int previousSelectedIndexUp = _navigationController.SelectedIndex;
|
||||
bool didScrollUp = _navigationController.MoveUp(_gameEntries, currentAvailableLines);
|
||||
if (didScrollUp)
|
||||
{
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
}
|
||||
else
|
||||
{
|
||||
_menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndexUp, previousSelectedIndexUp - _navigationController.ScrollOffset, false, currentAvailableLines, isFav);
|
||||
_menuRenderer.RedrawEntry(_gameEntries, _navigationController.SelectedIndex, _navigationController.SelectedIndex - _navigationController.ScrollOffset, true, currentAvailableLines, isFav);
|
||||
}
|
||||
// flush input to avoid key repeat
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.DownArrow:
|
||||
int previousSelectedIndexDown = _navigationController.SelectedIndex;
|
||||
bool didScrollDown = _navigationController.MoveDown(_gameEntries, currentAvailableLines);
|
||||
if (didScrollDown)
|
||||
{
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
}
|
||||
else
|
||||
{
|
||||
_menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndexDown, previousSelectedIndexDown - _navigationController.ScrollOffset, false, currentAvailableLines, isFav);
|
||||
_menuRenderer.RedrawEntry(_gameEntries, _navigationController.SelectedIndex, _navigationController.SelectedIndex - _navigationController.ScrollOffset, true, currentAvailableLines, isFav);
|
||||
}
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.Enter:
|
||||
case ConsoleKey.RightArrow:
|
||||
var oldRelativePath = _navigationController.CurrentRelativePath;
|
||||
var isDirectory = _gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Directory;
|
||||
_navigationController.HandleEnter(_gameEntries);
|
||||
if (isDirectory && oldRelativePath != _navigationController.CurrentRelativePath) {
|
||||
ReloadGameEntries();
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
|
||||
}
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
// Only start a ZIP if NOT switching to a directory
|
||||
if (!isDirectory && _gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Zip)
|
||||
{
|
||||
string zipToLaunch = _gameEntries[_navigationController.SelectedIndex].InPatch ? _gameEntries[_navigationController.SelectedIndex].PatchPath : _gameEntries[_navigationController.SelectedIndex].RootPath;
|
||||
try
|
||||
{
|
||||
_hatariLauncher!.Launch(zipToLaunch);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorService.ShowError(ex.Message);
|
||||
}
|
||||
}
|
||||
// flush input to avoid leftover key events after an enter/navigation
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.Backspace:
|
||||
case ConsoleKey.LeftArrow:
|
||||
_navigationController.GoUpDirectory();
|
||||
ReloadGameEntries();
|
||||
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.PageDown:
|
||||
_navigationController.PageDown(_gameEntries, currentAvailableLines);
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.PageUp:
|
||||
_navigationController.PageUp(_gameEntries, currentAvailableLines);
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.Multiply:
|
||||
case ConsoleKey.Oem8:
|
||||
// Toggle favorite for selected ZIP (handles numpad * and some layouts)
|
||||
if (_gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Zip)
|
||||
{
|
||||
var ge = _gameEntries[_navigationController.SelectedIndex];
|
||||
string path = ge.InPatch ? ge.PatchPath : ge.RootPath;
|
||||
try
|
||||
{
|
||||
_favoritesService?.Toggle(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorService.ShowError("Failed to toggle favorite: " + ex.Message);
|
||||
}
|
||||
// Redraw the whole menu so the '*' marker updates immediately
|
||||
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
|
||||
}
|
||||
ProgramHelpers.FlushInputBuffer();
|
||||
break;
|
||||
case ConsoleKey.Escape:
|
||||
case ConsoleKey.Q:
|
||||
exitRequested = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReloadGameEntries()
|
||||
{
|
||||
try
|
||||
if (latestAvailableLines != _currentAvailableLines || latestWidth != _currentWidth)
|
||||
{
|
||||
// If we are at the virtual Favorites folder, produce the flat list from the favorites service
|
||||
if (string.Equals(_navigationController.CurrentRelativePath, "Favorites", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var favs = _favoritesService?.GetAll() ?? new List<string>();
|
||||
_gameEntries = favs.Select(p => new GameEntry
|
||||
{
|
||||
Name = Path.GetFileName(p),
|
||||
Kind = EntryKind.Zip,
|
||||
InRoot = true,
|
||||
InPatch = false,
|
||||
RootPath = p,
|
||||
PatchPath = string.Empty,
|
||||
IsVirtual = false
|
||||
}).ToList();
|
||||
_navigationController.SetEntriesCount(_gameEntries.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise use the overlay directory browser for normal folders
|
||||
_gameEntries = _directoryBrowser!.GetEntries(_navigationController.CurrentRelativePath);
|
||||
|
||||
// If we are at the root and there are favorites, prepend a virtual "Favorites" folder
|
||||
if (string.IsNullOrEmpty(_navigationController.CurrentRelativePath) && (_favoritesService?.HasFavorites() ?? false))
|
||||
{
|
||||
var virtualEntry = new GameEntry
|
||||
{
|
||||
Name = "Favorites",
|
||||
Kind = EntryKind.Directory,
|
||||
InRoot = true,
|
||||
InPatch = false,
|
||||
RootPath = string.Empty,
|
||||
PatchPath = string.Empty,
|
||||
IsVirtual = true
|
||||
};
|
||||
// insert at the beginning
|
||||
_gameEntries.Insert(0, virtualEntry);
|
||||
}
|
||||
|
||||
_navigationController.SetEntriesCount(_gameEntries.Count);
|
||||
_currentAvailableLines = latestAvailableLines;
|
||||
_currentWidth = latestWidth;
|
||||
_inputController.RefreshView(_currentAvailableLines);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
// Only block if there's actually a key; otherwise allow resize detection
|
||||
if (!Console.KeyAvailable)
|
||||
{
|
||||
// Show the error to the user and continue with an empty list
|
||||
_errorService.ShowError(ex.Message);
|
||||
_gameEntries = new List<GameEntry>();
|
||||
_navigationController.SetEntriesCount(0);
|
||||
Thread.Sleep(50);
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
exitRequested = _inputController.HandleKey(key, _currentAvailableLines);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ namespace MarcerGameDvdLauncher
|
||||
private LineState[] _cachedBuffer = Array.Empty<LineState>();
|
||||
private int _cachedWidth = -1;
|
||||
|
||||
// Color configuration (injected; defaults to built-in scheme if null)
|
||||
private readonly AppColorConfig _colors;
|
||||
|
||||
public MenuRenderer(AppColorConfig? colors = null)
|
||||
{
|
||||
_colors = colors ?? new AppColorConfig();
|
||||
}
|
||||
|
||||
// availableLines is provided per-draw so the renderer adapts to console resizes
|
||||
public void DrawMenu(List<GameEntry> entries, int scrollOffset, int selectedIndex, int availableLines, Func<GameEntry, bool>? isFavorite = null)
|
||||
{
|
||||
@@ -43,11 +51,8 @@ namespace MarcerGameDvdLauncher
|
||||
}
|
||||
|
||||
// Diff & write only changed lines
|
||||
// Use the caller-provided availableLines (which should be Console.WindowHeight - 1)
|
||||
int maxRow = Math.Max(0, availableLines - 1);
|
||||
for (int row = 0; row < availableLines; row++)
|
||||
{
|
||||
if (row > maxRow) break;
|
||||
var newLine = newBuffer[row];
|
||||
var oldLine = _cachedBuffer[row];
|
||||
if (oldLine.Text != newLine.Text || oldLine.Fg != newLine.Fg || oldLine.Bg != newLine.Bg)
|
||||
@@ -63,26 +68,16 @@ namespace MarcerGameDvdLauncher
|
||||
{
|
||||
if (entryIdx < 0 || entryIdx >= entries.Count) return;
|
||||
availableLines = Math.Max(1, availableLines);
|
||||
int maxRow = Math.Max(0, availableLines - 1);
|
||||
if (row < 0 || row > maxRow) return;
|
||||
// row is a visual row within the visible window; validate against availableLines
|
||||
if (row < 0 || row >= availableLines) return;
|
||||
|
||||
int width = Console.WindowWidth;
|
||||
// ensure cache is valid for current width/height and the target row
|
||||
EnsureCacheForRow(width, Math.Min(availableLines, Math.Max(1, _cachedBuffer.Length == 0 ? 1 : _cachedBuffer.Length)));
|
||||
// Ensure the cache matches the current dimensions (same as DrawMenu)
|
||||
EnsureCache(width, availableLines);
|
||||
|
||||
var e = entries[entryIdx];
|
||||
var (fg, bg) = GetColors(e, selected);
|
||||
string text = BuildLineText(e, width, isFavorite?.Invoke(e) ?? false);
|
||||
ConsoleColor fg, bg;
|
||||
if (selected)
|
||||
{
|
||||
bg = ConsoleColor.DarkCyan;
|
||||
fg = ConsoleColor.Black;
|
||||
}
|
||||
else
|
||||
{
|
||||
bg = ConsoleColor.Black;
|
||||
fg = GetColorForEntry(e);
|
||||
}
|
||||
|
||||
var newLine = new LineState { Text = text, Fg = fg, Bg = bg };
|
||||
// If cache differs, write
|
||||
@@ -113,18 +108,6 @@ namespace MarcerGameDvdLauncher
|
||||
}
|
||||
}
|
||||
|
||||
// Ensures the cached buffer has at least requiredRows entries and matches width.
|
||||
private void EnsureCacheForRow(int width, int requiredRows)
|
||||
{
|
||||
if (_cachedBuffer.Length < requiredRows || _cachedWidth != width)
|
||||
{
|
||||
int newLen = Math.Max(requiredRows, 1);
|
||||
_cachedBuffer = new LineState[newLen];
|
||||
for (int i = 0; i < newLen; i++) _cachedBuffer[i].Text = null!;
|
||||
_cachedWidth = width;
|
||||
}
|
||||
}
|
||||
|
||||
// Write a line to the console using the centralized logic (handles concurrent resizes safely)
|
||||
private void WriteConsoleLine(int row, LineState newLine)
|
||||
{
|
||||
@@ -229,12 +212,13 @@ namespace MarcerGameDvdLauncher
|
||||
return text + new string(' ', width - text.Length);
|
||||
}
|
||||
|
||||
// Returns foreground and background colors for an entry depending on selection state
|
||||
// Returns foreground and background colors for an entry depending on selection state.
|
||||
// Selected colors and entry colors come from the injected AppColorConfig.
|
||||
private (ConsoleColor fg, ConsoleColor bg) GetColors(GameEntry e, bool selected)
|
||||
{
|
||||
if (selected)
|
||||
{
|
||||
return (ConsoleColor.Black, ConsoleColor.DarkCyan);
|
||||
return (_colors.SelectedForeground, _colors.SelectedBackground);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -306,20 +290,20 @@ namespace MarcerGameDvdLauncher
|
||||
|
||||
private ConsoleColor GetColorForEntry(GameEntry e)
|
||||
{
|
||||
// Virtual entries (like the Favorites pseudo-folder) should be white
|
||||
if (e.IsVirtual) return ConsoleColor.White;
|
||||
// Virtual entries (like the Favorites pseudo-folder) use the configured VirtualEntry color
|
||||
if (e.IsVirtual) return _colors.VirtualEntry;
|
||||
|
||||
if (e.Kind == EntryKind.Directory)
|
||||
{
|
||||
if (e.InRoot && e.InPatch) return ConsoleColor.Yellow; // Both layers
|
||||
if (e.InPatch && !e.InRoot) return ConsoleColor.DarkYellow; // Only patch
|
||||
if (e.InRoot && !e.InPatch) return ConsoleColor.Gray; // Only root
|
||||
if (e.InRoot && e.InPatch) return _colors.FolderBoth; // Both layers
|
||||
if (e.InPatch && !e.InRoot) return _colors.FolderPatchOnly; // Only patch
|
||||
if (e.InRoot && !e.InPatch) return _colors.FolderRootOnly; // Only root
|
||||
}
|
||||
else if (e.Kind == EntryKind.Zip)
|
||||
{
|
||||
if (e.InRoot && e.InPatch) return ConsoleColor.Green; // Both layers
|
||||
if (e.InRoot && !e.InPatch) return ConsoleColor.DarkGreen; // Only root
|
||||
if (e.InPatch && !e.InRoot) return ConsoleColor.Magenta; // Only patch
|
||||
if (e.InRoot && e.InPatch) return _colors.ZipBoth; // Both layers
|
||||
if (e.InRoot && !e.InPatch) return _colors.ZipRootOnly; // Only root
|
||||
if (e.InPatch && !e.InRoot) return _colors.ZipPatchOnly; // Only patch
|
||||
}
|
||||
return ConsoleColor.DarkGray;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ namespace MarcerGameDvdLauncher
|
||||
{
|
||||
public class NavigationController
|
||||
{
|
||||
// Scroll fractions: when the selection cursor reaches 2/3 of the visible window
|
||||
// height from the top, the list scrolls down; when it reaches 1/3, it scrolls up.
|
||||
// Not configurable — these ratios are the established navigation behaviour.
|
||||
private const double BottomScrollFraction = 2.0 / 3.0;
|
||||
private const double TopScrollFraction = 1.0 / 3.0;
|
||||
|
||||
public int SelectedIndex { get; private set; } = 0;
|
||||
public int ScrollOffset { get; private set; } = 0;
|
||||
public string CurrentRelativePath { get; private set; } = "";
|
||||
@@ -99,12 +105,12 @@ namespace MarcerGameDvdLauncher
|
||||
if (availableLines < 1) availableLines = 1;
|
||||
if (entryCount <= availableLines) { ScrollOffset = 0; return; }
|
||||
if (SelectedIndex == 0) { ScrollOffset = 0; return; }
|
||||
int bottomScrollTrigger = ScrollOffset + (int)(availableLines * 2 / 3.0);
|
||||
int topScrollTrigger = ScrollOffset + (int)(availableLines * 1 / 3.0);
|
||||
int bottomScrollTrigger = ScrollOffset + (int)(availableLines * BottomScrollFraction);
|
||||
int topScrollTrigger = ScrollOffset + (int)(availableLines * TopScrollFraction);
|
||||
if (SelectedIndex >= bottomScrollTrigger && (ScrollOffset + availableLines) < entryCount)
|
||||
ScrollOffset = SelectedIndex - (int)(availableLines * 2 / 3.0);
|
||||
ScrollOffset = SelectedIndex - (int)(availableLines * BottomScrollFraction);
|
||||
else if (SelectedIndex < topScrollTrigger && ScrollOffset > 0)
|
||||
ScrollOffset = SelectedIndex - (int)(availableLines * 1 / 3.0);
|
||||
ScrollOffset = SelectedIndex - (int)(availableLines * TopScrollFraction);
|
||||
if (ScrollOffset < 0) ScrollOffset = 0;
|
||||
if (ScrollOffset > entryCount - availableLines)
|
||||
ScrollOffset = entryCount - availableLines;
|
||||
|
||||
@@ -5,9 +5,12 @@ namespace MarcerGameDvdLauncher
|
||||
{
|
||||
class Program
|
||||
{
|
||||
// Console window title. Not configurable — fixed application display name.
|
||||
private const string DefaultTitle = "Marcer GameDVD Launcher";
|
||||
|
||||
static void Main(string[] args)
|
||||
{
|
||||
Console.Title = "Marcer GameDVD Launcher";
|
||||
Console.Title = DefaultTitle;
|
||||
var app = new LauncherApp();
|
||||
app.Run();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ namespace MarcerGameDvdLauncher
|
||||
// Small helpers refactored into their own file to keep Program.cs focused.
|
||||
internal static class ProgramHelpers
|
||||
{
|
||||
// The console window height minus one. The last row is reserved to prevent
|
||||
// auto-scroll / flicker when the cursor reaches the bottom row (project policy).
|
||||
// Centralized here so the policy lives in exactly one place.
|
||||
public static int AvailableLines => Math.Max(0, Console.WindowHeight - 1);
|
||||
|
||||
// Flushes the console input buffer to avoid processing leftover key events
|
||||
// Uses Win32 FlushConsoleInputBuffer on the standard input handle. On non-Windows
|
||||
// environments this will be a no-op.
|
||||
@@ -31,6 +36,8 @@ namespace MarcerGameDvdLauncher
|
||||
}
|
||||
}
|
||||
|
||||
// Win32 API constants — intentionally hardcoded (bewusst hartkodiert).
|
||||
// These are defined by the Windows API and do not change.
|
||||
private const int STD_INPUT_HANDLE = -10;
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
|
||||
@@ -41,6 +48,7 @@ namespace MarcerGameDvdLauncher
|
||||
private static extern bool FlushConsoleInputBuffer(IntPtr hConsoleInput);
|
||||
|
||||
// P/Invoke to query key state (used to detect physical key release)
|
||||
// Win32 virtual-key code — intentionally hardcoded (bewusst hartkodiert).
|
||||
private const int VK_RETURN = 0x0D;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
@@ -55,7 +63,7 @@ namespace MarcerGameDvdLauncher
|
||||
{
|
||||
try
|
||||
{
|
||||
int lastRow = Math.Max(0, Console.WindowHeight - 1);
|
||||
int lastRow = AvailableLines;
|
||||
int width = Console.WindowWidth;
|
||||
string text = message ?? string.Empty;
|
||||
if (text.Length > width) text = text.Substring(0, Math.Max(0, width - 3)) + "...";
|
||||
|
||||
@@ -5,6 +5,10 @@ namespace MarcerGameDvdLauncher;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized service for error and user message output in the console UI.
|
||||
/// Errors are presented to the user via <see cref="ProgramHelpers.ShowConsoleMessage"/>
|
||||
/// and are not rethrown — the caller's context does not allow for meaningful error
|
||||
/// recovery, so the application stays in the navigation loop after the user dismisses
|
||||
/// the message.
|
||||
/// </summary>
|
||||
public class UIErrorService
|
||||
{
|
||||
|
||||
@@ -5,5 +5,16 @@
|
||||
"Executable": "C:\\Tools\\hatari\\hatari.exe",
|
||||
"ConfigFile": "C:\\Tools\\hatari\\hatari-st.cfg",
|
||||
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
|
||||
},
|
||||
"Colors": {
|
||||
"FolderBoth": "Yellow",
|
||||
"FolderPatchOnly": "DarkYellow",
|
||||
"FolderRootOnly": "Gray",
|
||||
"ZipBoth": "Green",
|
||||
"ZipRootOnly": "DarkGreen",
|
||||
"ZipPatchOnly": "Magenta",
|
||||
"SelectedForeground": "Black",
|
||||
"SelectedBackground": "DarkCyan",
|
||||
"VirtualEntry": "White"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user