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(); private int _cachedWidth = -1; // availableLines is provided per-draw so the renderer adapts to console resizes public void DrawMenu(List entries, int scrollOffset, int selectedIndex, int availableLines, Func? 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 entries, int entryIdx, int row, bool selected, int availableLines, Func? 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; } } }