move all files

This commit is contained in:
2026-08-10 21:45:47 +02:00
parent 94b4ed7fb3
commit 26d3530b86
24 changed files with 47 additions and 48 deletions
@@ -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);
}
}
}
}
+310
View File
@@ -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>
+324
View File
@@ -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);
}
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace MarcerGameDvdLauncher
{
class Program
{
static void Main(string[] args)
{
Console.Title = "Marcer GameDVD Launcher";
var app = new LauncherApp();
app.Run();
}
}
}
+115
View File
@@ -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}\""
}
}