mirror of
https://github.com/skoelle/marcer-gamedvd-launcher.git
synced 2026-09-18 03:00:25 +00:00
move all files
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
namespace MarcerGameDvdLauncher
|
||||
{
|
||||
// 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 MarcerGameDvdLauncher
|
||||
{
|
||||
// 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,54 @@
|
||||
namespace MarcerGameDvdLauncher
|
||||
{
|
||||
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
|
||||
if (!File.Exists(exePath))
|
||||
throw new ArgumentException($"Hatari executable not found: {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,310 @@
|
||||
namespace MarcerGameDvdLauncher
|
||||
{
|
||||
// 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);
|
||||
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 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,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>MarcerGameDvdLauncher</RootNamespace>
|
||||
<AssemblyName>MarcerGameDvdLauncher</AssemblyName>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="launcher.config.example.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,324 @@
|
||||
namespace MarcerGameDvdLauncher
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidates the internal line cache so the next DrawMenu call
|
||||
// performs a full redraw of every line. Useful after an overlay
|
||||
// (e.g. help box) has overwritten the console directly.
|
||||
public void InvalidateCache()
|
||||
{
|
||||
for (int i = 0; i < _cachedBuffer.Length; i++)
|
||||
_cachedBuffer[i].Text = null!;
|
||||
}
|
||||
|
||||
// Renders a centered, bordered help box with key bindings inside the
|
||||
// available console area. The caller is responsible for waiting on a
|
||||
// key and redrawing the menu afterwards.
|
||||
public void ShowHelpBox(int availableLines)
|
||||
{
|
||||
try
|
||||
{
|
||||
int width = Console.WindowWidth;
|
||||
string[] helpLines = GetHelpLines();
|
||||
int boxHeight = Math.Min(helpLines.Length + 2, Math.Max(3, availableLines));
|
||||
int boxWidth = Math.Max(1, width);
|
||||
int topRow = Math.Max(0, (availableLines - boxHeight) / 2);
|
||||
|
||||
Console.BackgroundColor = ConsoleColor.DarkGray;
|
||||
Console.ForegroundColor = ConsoleColor.White;
|
||||
|
||||
string topBorder = "+" + new string('-', Math.Max(0, boxWidth - 2)) + "+";
|
||||
Console.SetCursorPosition(0, topRow);
|
||||
Console.Write(topBorder);
|
||||
|
||||
for (int i = 0; i < boxHeight - 2; i++)
|
||||
{
|
||||
int row = topRow + 1 + i;
|
||||
string content;
|
||||
if (i < helpLines.Length)
|
||||
{
|
||||
content = PadToWidth(helpLines[i], boxWidth - 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
content = new string(' ', Math.Max(0, boxWidth - 2));
|
||||
}
|
||||
Console.SetCursorPosition(0, row);
|
||||
Console.Write("|" + content + "|");
|
||||
}
|
||||
|
||||
int bottomRow = topRow + boxHeight - 1;
|
||||
if (bottomRow < Console.WindowHeight)
|
||||
{
|
||||
string bottomBorder = "+" + new string('-', Math.Max(0, boxWidth - 2)) + "+";
|
||||
Console.SetCursorPosition(0, bottomRow);
|
||||
Console.Write(bottomBorder);
|
||||
}
|
||||
|
||||
Console.ResetColor();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] GetHelpLines()
|
||||
{
|
||||
return [
|
||||
" Help — Key Bindings",
|
||||
" ",
|
||||
" ↑ / ↓ Move selection up / down",
|
||||
" Enter / → Open folder / launch ZIP with Hatari",
|
||||
" ← / BS Go up one directory (never exceeds root)",
|
||||
" ESC / Q Exit the program",
|
||||
" PgUp Jump one page up",
|
||||
" PgDn Jump one page down",
|
||||
" * Toggle favorite on selected ZIP",
|
||||
" ? Show this help",
|
||||
" ",
|
||||
" Navigation is strictly limited to RootDirectory.",
|
||||
" The overlay shows both root and patch layers combined.",
|
||||
" ",
|
||||
" Press any key to continue...",
|
||||
];
|
||||
}
|
||||
|
||||
private static string PadToWidth(string text, int width)
|
||||
{
|
||||
if (text.Length > width) return text.Substring(0, width);
|
||||
return text + new string(' ', width - text.Length);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
string label = GetLabel(e, isFavorite);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Returns the left label for an entry based on kind, layer status and favorite state.
|
||||
// Format: [LayerLabel][TypeIndicator] where
|
||||
// LayerLabel = [BOTH] / [ROOT] / [PTCH] (7 chars)
|
||||
// TypeIndicator = [DIR] for dirs, " * " or " " for ZIPs (6 chars)
|
||||
private static string GetLabel(GameEntry e, bool isFavorite)
|
||||
{
|
||||
// Layer label (7 chars)
|
||||
string layer;
|
||||
if (e.InRoot && e.InPatch) layer = "[BOTH] ";
|
||||
else if (e.InPatch) layer = "[PTCH] ";
|
||||
else if (e.InRoot) layer = "[ROOT] ";
|
||||
else layer = " ";
|
||||
|
||||
// Type indicator (6 chars)
|
||||
string type;
|
||||
if (e.Kind == EntryKind.Directory)
|
||||
{
|
||||
type = "[DIR] ";
|
||||
}
|
||||
else
|
||||
{
|
||||
type = isFavorite ? " * " : " ";
|
||||
}
|
||||
|
||||
return layer + type; // 13 chars total
|
||||
}
|
||||
|
||||
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; // Both layers
|
||||
if (e.InRoot && !e.InPatch) return ConsoleColor.DarkGreen; // Only root
|
||||
if (e.InPatch && !e.InRoot) return ConsoleColor.Magenta; // Only patch
|
||||
}
|
||||
return ConsoleColor.DarkGray;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
namespace MarcerGameDvdLauncher
|
||||
{
|
||||
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 MarcerGameDvdLauncher
|
||||
{
|
||||
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 MarcerGameDvdLauncher
|
||||
{
|
||||
class Program
|
||||
{
|
||||
static void Main(string[] args)
|
||||
{
|
||||
Console.Title = "Marcer GameDVD Launcher";
|
||||
var app = new LauncherApp();
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MarcerGameDvdLauncher
|
||||
{
|
||||
// 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 MarcerGameDvdLauncher;
|
||||
|
||||
/// <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}\""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user