feat: Enhance menu rendering and input handling

- Added support for new plugins: IrondashEngineContext and SuperNativeExtensions in the Flutter plugin registrant.
- Updated CMake configuration to include new plugins.
- Introduced a new dependency, super_clipboard, in pubspec.yaml.
- Enhanced the WolfEngine to set the menu background color.
- Implemented keyboard shortcuts for renderer mode toggling and ASCII theme cycling in CLI input handling.
- Updated menu manager to include a universal menu background color.
- Refactored ASCII and Sixel renderers to utilize the new menu background color and improved header drawing logic.
- Simplified the drawing of menu options sidebars and header bars across different renderers.
- Improved the layout and centering of menu titles in the header bar.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 14:46:08 +01:00
parent 297f6f0260
commit 4d5b30f007
15 changed files with 740 additions and 324 deletions

View File

@@ -115,9 +115,8 @@ class CliGameLoop {
return;
}
if (bytes.contains(9)) {
// Tab swaps between renderers so renderer debugging stays available
// without restarting the process.
if (input.matchesRendererToggleShortcut(bytes)) {
// Allow dynamic renderer-switch bindings configured on the CLI input.
_renderer = identical(_renderer, secondaryRenderer)
? primaryRenderer
: secondaryRenderer;
@@ -125,7 +124,7 @@ class CliGameLoop {
return;
}
if (bytes.contains(116) || bytes.contains(84)) {
if (input.matchesAsciiThemeCycleShortcut(bytes)) {
_cycleAsciiTheme();
return;
}
@@ -188,5 +187,37 @@ class CliGameLoop {
engine.tick(elapsed);
stdout.write(_renderer.render(engine));
_writeShortcutHintLine();
}
void _writeShortcutHintLine() {
if (!stdout.hasTerminal) {
return;
}
final int cols = stdout.terminalColumns;
if (cols <= 0) {
return;
}
final String hint = _buildShortcutHintText();
final String visible = hint.length > cols ? hint.substring(0, cols) : hint;
final String padded = visible.padRight(cols);
// Draw an overlay line without disturbing the renderer's cursor position.
stdout.write('\x1b[s\x1b[1;1H\x1b[0m\x1b[2m$padded\x1b[0m\x1b[u');
}
String _buildShortcutHintText() {
final String rendererMode = _renderer is SixelRenderer ? 'sixel' : 'ascii';
final String rendererHint =
'<${input.rendererToggleKeyLabel}> $rendererMode';
if (_renderer is AsciiRenderer) {
final AsciiRenderer ascii = _renderer as AsciiRenderer;
return '$rendererHint <${input.asciiThemeCycleKeyLabel}> ${ascii.activeTheme.name}';
}
return rendererHint;
}
}

View File

@@ -0,0 +1,66 @@
/// Debug tools launcher for art and asset inspection screens.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_gui/screens/sprite_gallery.dart';
import 'package:wolf_3d_gui/screens/vga_gallery.dart';
/// Presents debug-only navigation shortcuts for asset galleries.
class DebugToolsScreen extends StatelessWidget {
/// Shared app facade used to access active game assets.
final Wolf3d wolf3d;
/// Creates the debug tools screen for [wolf3d].
const DebugToolsScreen({super.key, required this.wolf3d});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Debug Tools'),
automaticallyImplyLeading: true,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'Asset Galleries',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Card(
child: ListTile(
leading: const Icon(Icons.image_search),
title: const Text('Sprite Gallery'),
subtitle: const Text('Browse decoded sprite frames.'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SpriteGallery(wolf3d: wolf3d),
),
);
},
),
),
Card(
child: ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('VGA Gallery'),
subtitle: const Text('Browse decoded VGA images.'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => VgaGallery(images: wolf3d.vgaImages),
),
);
},
),
),
],
),
);
}
}

View File

@@ -1,127 +0,0 @@
/// Episode picker and asset-browser entry point for the selected game version.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_gui/screens/game_screen.dart';
import 'package:wolf_3d_gui/screens/sprite_gallery.dart';
import 'package:wolf_3d_gui/screens/vga_gallery.dart';
/// Presents the episode list and shortcuts into the asset gallery screens.
class EpisodeScreen extends StatefulWidget {
/// Shared application facade whose active game must already be set.
final Wolf3d wolf3d;
/// Creates the episode-selection screen for [wolf3d].
const EpisodeScreen({super.key, required this.wolf3d});
@override
State<EpisodeScreen> createState() => _EpisodeScreenState();
}
class _EpisodeScreenState extends State<EpisodeScreen> {
@override
void initState() {
super.initState();
widget.wolf3d.audio.playMenuMusic();
}
/// Persists the chosen episode and lets the engine present difficulty select.
void _selectEpisode(int index) {
widget.wolf3d.setActiveEpisode(index);
widget.wolf3d.clearActiveDifficulty();
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => GameScreen(wolf3d: widget.wolf3d),
),
);
}
@override
Widget build(BuildContext context) {
final List<Episode> episodes = widget.wolf3d.activeGame.episodes;
return Scaffold(
backgroundColor: Colors.teal,
floatingActionButtonLocation:
FloatingActionButtonLocation.miniCenterFloat,
floatingActionButton: Row(
children: [
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return VgaGallery(images: widget.wolf3d.vgaImages);
},
),
);
},
child: Text('VGA Gallery'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return SpriteGallery(
wolf3d: widget.wolf3d,
);
},
),
);
},
child: Text('Sprites'),
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'WHICH EPISODE TO PLAY?',
style: TextStyle(
color: Colors.red,
fontSize: 32,
fontWeight: FontWeight.bold,
fontFamily: 'Courier',
),
),
const SizedBox(height: 40),
ListView.builder(
shrinkWrap: true,
itemCount: episodes.length,
itemBuilder: (context, index) {
final Episode episode = episodes[index];
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 32.0,
),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey[900],
foregroundColor: Colors.white,
minimumSize: const Size(300, 60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
onPressed: () => _selectEpisode(index),
child: Text(
episode.name,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 18),
),
),
);
},
),
],
),
),
);
}
}

View File

@@ -12,6 +12,7 @@ import 'package:wolf_3d_dart/wolf_3d_input.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
import 'package:wolf_3d_gui/screens/debug_tools_screen.dart';
import 'package:wolf_3d_renderer/wolf_3d_ascii_renderer.dart';
import 'package:wolf_3d_renderer/wolf_3d_flutter_renderer.dart';
import 'package:wolf_3d_renderer/wolf_3d_glsl_renderer.dart';
@@ -173,6 +174,11 @@ class _GameScreenState extends State<GameScreen> {
}
},
child: Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: _openDebugTools,
tooltip: 'Open Debug Tools',
child: const Icon(Icons.bug_report),
),
body: LayoutBuilder(
builder: (context, constraints) {
return Listener(
@@ -336,6 +342,14 @@ class _GameScreenState extends State<GameScreen> {
_glslEffectsEnabled = !_glslEffectsEnabled;
}
void _openDebugTools() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => DebugToolsScreen(wolf3d: widget.wolf3d),
),
);
}
bool _handleHostShortcut(KeyEvent event) {
final HostShortcutHandler? customHandler = widget.hostShortcutHandler;
if (customHandler != null) {

View File

@@ -62,11 +62,13 @@ class SpriteGallery extends StatelessWidget {
textAlign: TextAlign.center,
),
Expanded(
child: Center(
child: AspectRatio(
aspectRatio: 4 / 3,
aspectRatio: 1,
child: WolfAssetPainter.sprite(wolf3d.sprites[index]),
),
),
),
],
),
);

View File

@@ -1,7 +1,12 @@
/// Visual browser for decoded VGA pictures and UI art.
library;
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:super_clipboard/super_clipboard.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_renderer/wolf_3d_asset_painter.dart';
@@ -13,6 +18,110 @@ class VgaGallery extends StatelessWidget {
/// Creates the gallery for [images].
const VgaGallery({super.key, required this.images});
Future<ui.Image> _buildVgaUiImage(VgaImage image) {
final completer = Completer<ui.Image>();
final Uint8List pixels = Uint8List(image.width * image.height * 4);
for (int y = 0; y < image.height; y++) {
for (int x = 0; x < image.width; x++) {
final int colorByte = image.decodePixel(x, y);
final int abgr = ColorPalette.vga32Bit[colorByte];
final int offset = (y * image.width + x) * 4;
pixels[offset] = abgr & 0xFF; // R
pixels[offset + 1] = (abgr >> 8) & 0xFF; // G
pixels[offset + 2] = (abgr >> 16) & 0xFF; // B
pixels[offset + 3] = (abgr >> 24) & 0xFF; // A
}
}
ui.decodeImageFromPixels(
pixels,
image.width,
image.height,
ui.PixelFormat.rgba8888,
(ui.Image decoded) => completer.complete(decoded),
);
return completer.future;
}
Future<void> _copyImageToClipboard(BuildContext context, int index) async {
final clipboard = SystemClipboard.instance;
if (clipboard == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Clipboard API is unavailable here.')),
);
}
return;
}
final VgaImage image = images[index];
ui.Image? uiImage;
try {
uiImage = await _buildVgaUiImage(image);
final ByteData? pngData = await uiImage.toByteData(
format: ui.ImageByteFormat.png,
);
if (pngData == null) {
throw StateError('Failed to encode image as PNG.');
}
final item = DataWriterItem();
item.add(Formats.png(pngData.buffer.asUint8List()));
await clipboard.write([item]);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Copied VGA image #$index to clipboard.')),
);
}
} catch (error) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Copy failed: $error')),
);
}
} finally {
uiImage?.dispose();
}
}
void _showImagePreviewDialog(BuildContext context, int index) {
final VgaImage image = images[index];
showDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(
'Index: $index ${image.width} x ${image.height}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Close'),
),
ElevatedButton.icon(
onPressed: () => _copyImageToClipboard(dialogContext, index),
icon: const Icon(Icons.copy),
label: const Text('Copy'),
),
],
content: Center(
child: AspectRatio(
aspectRatio: image.width / image.height,
child: WolfAssetPainter.vga(image),
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -28,6 +137,8 @@ class VgaGallery extends StatelessWidget {
itemBuilder: (context, index) {
return Card(
color: Colors.blueGrey,
child: InkWell(
onTap: () => _showImagePreviewDialog(context, index),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
@@ -39,11 +150,15 @@ class VgaGallery extends StatelessWidget {
),
Expanded(
child: Center(
child: AspectRatio(
aspectRatio: images[index].width / images[index].height,
child: WolfAssetPainter.vga(images[index]),
),
),
),
],
),
),
);
},
),

View File

@@ -7,16 +7,24 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <irondash_engine_context/irondash_engine_context_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <super_native_extensions/super_native_extensions_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin");
irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
g_autoptr(FlPluginRegistrar) super_native_extensions_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin");
super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar);
g_autoptr(FlPluginRegistrar) window_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);

View File

@@ -4,7 +4,9 @@
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
irondash_engine_context
screen_retriever_linux
super_native_extensions
window_manager
)

View File

@@ -16,6 +16,7 @@ dependencies:
flutter:
sdk: flutter
super_clipboard: ^0.9.1
dev_dependencies:
flutter_test:

View File

@@ -43,6 +43,7 @@ class WolfEngine {
if (_availableGames.isEmpty) {
throw StateError('WolfEngine requires at least one game data set.');
}
menuManager.menuBackgroundRgb = menuBackgroundRgb;
}
/// Total milliseconds elapsed since the engine was initialized.

View File

@@ -6,6 +6,60 @@ import 'package:wolf_3d_dart/wolf_3d_entities.dart';
/// Buffers one-frame terminal key presses for consumption by the engine loop.
class CliInput extends Wolf3dInput {
/// Keyboard shortcut used by the CLI host to cycle renderer modes.
String rendererToggleKey = 'r';
/// Keyboard shortcut used by the CLI host to cycle ASCII themes.
String asciiThemeCycleKey = 't';
/// Human-friendly label for [rendererToggleKey] shown in CLI hints.
String get rendererToggleKeyLabel => _formatShortcutLabel(rendererToggleKey);
/// Human-friendly label for [asciiThemeCycleKey] shown in CLI hints.
String get asciiThemeCycleKeyLabel =>
_formatShortcutLabel(asciiThemeCycleKey);
/// Returns true when [bytes] triggers the renderer-toggle shortcut.
bool matchesRendererToggleShortcut(List<int> bytes) =>
_matchesShortcut(bytes, rendererToggleKey);
/// Returns true when [bytes] triggers the ASCII-theme shortcut.
bool matchesAsciiThemeCycleShortcut(List<int> bytes) =>
_matchesShortcut(bytes, asciiThemeCycleKey);
String _formatShortcutLabel(String key) {
final String trimmed = key.trim();
if (trimmed.isEmpty) {
return 'KEY';
}
if (trimmed == ' ') {
return 'SPACE';
}
return trimmed.toUpperCase();
}
bool _matchesShortcut(List<int> bytes, String key) {
final String trimmed = key.trim();
if (trimmed.isEmpty) {
return false;
}
if (trimmed == ' ') {
return bytes.length == 1 && bytes[0] == 32;
}
if (bytes.length != 1) {
return false;
}
final int expected = trimmed.codeUnitAt(0);
final int actual = bytes[0];
return actual == expected ||
actual ==
(expected >= 97 && expected <= 122 ? expected - 32 : expected) ||
actual == (expected >= 65 && expected <= 90 ? expected + 32 : expected);
}
// Raw stdin arrives asynchronously, so presses are staged here until the
// next engine frame snapshots them into the active state.
bool _pForward = false;

View File

@@ -93,6 +93,9 @@ class MenuManager {
bool _prevConfirm = false;
bool _prevBack = false;
/// Universal menu background color in 24-bit RGB used by menu screens.
int menuBackgroundRgb = 0x890000;
WolfMenuScreen get activeMenu => _activeMenu;
bool get isTransitioning => _transitionTarget != null;

View File

@@ -110,6 +110,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
static const int _menuHintKeyPaletteIndex = 12;
static const int _menuHintLabelPaletteIndex = 4;
static const int _menuHintBackgroundPaletteIndex = 0;
static const int _headerHeadingY = 24;
AsciiRenderer({
this.activeTheme = AsciiThemes.blocks,
@@ -129,6 +130,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
late List<List<ColoredChar>> _screen;
late List<List<int>> _scenePixels;
List<int>? _mainMenuBandFirstColumn;
String? _lastLoggedThemeName;
static const List<String> _quadrantByMask = <String>[
@@ -403,7 +405,9 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
@override
void drawMenu(WolfEngine engine) {
final int bgColor = _rgbToPaletteColor(engine.menuBackgroundRgb);
final int bgColor = _rgbToPaletteColor(
engine.menuManager.menuBackgroundRgb,
);
final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb);
final int headingColor = WolfMenuPalette.headerTextColor;
final int selectedTextColor = WolfMenuPalette.selectedTextColor;
@@ -418,6 +422,10 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
final art = WolfClassicMenuArt(engine.data);
final optionsLabel = art.optionsLabel;
if (optionsLabel != null) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
}
if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) {
_drawIntroSplash(engine, art, menuTypography);
@@ -433,9 +441,14 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
_blitVgaImageAscii(optionsLabel, optionsX, 0);
} else {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: ColorPalette.vga32Bit[0],
);
_drawMenuTextCentered(
'OPTIONS',
24,
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
@@ -480,6 +493,11 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: ColorPalette.vga32Bit[0],
);
_fillRect320(28, 58, 264, 104, panelColor);
final cursor = art.mappedPic(
@@ -492,7 +510,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
.toList(growable: false);
_drawMenuTextCentered(
'SELECT GAME',
48,
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
@@ -525,6 +543,11 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.episodeSelect) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: ColorPalette.vga32Bit[0],
);
_fillRect320(12, 18, 296, 168, panelColor);
final cursor = art.mappedPic(
@@ -539,7 +562,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
if (menuTypography.usesCompactRows) {
_drawMenuTextCentered(
'WHICH EPISODE TO PLAY?',
8,
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
@@ -580,7 +603,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_drawMenuTextCentered(
'WHICH EPISODE TO PLAY?',
8,
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
@@ -622,6 +645,12 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: ColorPalette.vga32Bit[0],
);
_fillRect320(28, 70, 264, 82, panelColor);
final face = art.difficultyOption(
@@ -640,7 +669,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
if (menuTypography.usesCompactRows) {
_drawMenuTextCentered(
Difficulty.menuText,
48,
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
@@ -671,7 +700,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_drawMenuTextCentered(
Difficulty.menuText,
48,
_headerHeadingY,
headingColor,
scale: menuTypography.headingScale,
);
@@ -719,7 +748,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
};
int splashBg = _rgbToPaletteColor(engine.menuManager.introBackgroundRgb);
int splashBg = _rgbToPaletteColor(engine.menuManager.menuBackgroundRgb);
if (engine.menuManager.isIntroPg13Slide &&
image != null &&
image.pixels.isNotEmpty) {
@@ -759,16 +788,18 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final int black = ColorPalette.vga32Bit[0];
final int yellow = ColorPalette.vga32Bit[14];
final int white = ColorPalette.vga32Bit[15];
final int lineColor = ColorPalette.vga32Bit[4];
_fillRect320(0, 0, 320, 22, black);
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: backgroundColor,
barColor: black,
);
_drawMenuTextCentered(
'Attention',
6,
_headerHeadingY,
yellow,
scale: menuTypography.headingScale,
);
_fillRect320(0, 23, 320, 1, lineColor);
if (menuTypography.usesCompactRows) {
final int textLeft = _menuX320ToColumn(40);
@@ -823,6 +854,56 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
return bestIndex;
}
void _drawHeaderBarStack({
required int headingY200,
required int backgroundColor,
required int barColor,
}) {
final List<int>? cachedColumn = _mainMenuBandFirstColumn;
if (cachedColumn != null && cachedColumn.isNotEmpty) {
final int bandHeight = cachedColumn.length.clamp(0, 200);
for (int y = 0; y < bandHeight; y++) {
final int paletteIndex = cachedColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillRect320(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]);
}
return;
}
final int mainBarTop = (headingY200 - 4).clamp(0, 199);
_fillRect320(0, mainBarTop, 320, 18, barColor);
int stripeY = mainBarTop + 18;
for (int i = 0; i < 4; i++) {
_fillRect320(
0,
(stripeY + i).clamp(0, 199),
320,
1,
i.isEven ? barColor : backgroundColor,
);
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
final List<int> firstColumn = _mainMenuBandFirstColumn!;
for (int y = 0; y < optionsLabel.height; y++) {
final int paletteIndex = firstColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillRect320(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]);
}
}
List<int> _cacheFirstColumn(VgaImage image) {
final List<int> column = List<int>.filled(image.height, 0);
for (int y = 0; y < image.height; y++) {
column[y] = image.decodePixel(0, y);
}
return column;
}
void _applyMenuFade(double alpha, int fadeColor) {
if (alpha <= 0.0) {
return;
@@ -891,11 +972,52 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
int color, {
int scale = 1,
}) {
if (y200 == _headerHeadingY) {
y200 = _centerHeaderTitleInBlackBand(defaultY: y200, scale: scale);
}
final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
final int x320 = ((320 - textWidth) ~/ 2).clamp(0, 319);
_drawMenuText(text, x320, y200, color, scale: scale);
}
int _centerHeaderTitleInBlackBand({
required int defaultY,
required int scale,
}) {
final List<int>? column = _mainMenuBandFirstColumn;
if (column == null || column.isEmpty) {
return defaultY;
}
int bestStart = -1;
int bestLength = 0;
int runStart = -1;
for (int i = 0; i <= column.length; i++) {
final bool isBlack = i < column.length && column[i] == 0;
if (isBlack) {
runStart = runStart == -1 ? i : runStart;
continue;
}
if (runStart != -1) {
final int runLength = i - runStart;
if (runLength > bestLength) {
bestLength = runLength;
bestStart = runStart;
}
runStart = -1;
}
}
if (bestStart == -1 || bestLength == 0) {
return defaultY;
}
final int textHeight = 7 * scale;
final int centered = bestStart + ((bestLength - textHeight) ~/ 2);
return centered.clamp(0, 199 - textHeight);
}
_AsciiMenuTypography _resolveMenuTypography() {
final bool usesCompactRows = _menuGlyphHeightInRows(scale: 1) <= 4;
return _AsciiMenuTypography(
@@ -1508,78 +1630,6 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
final int barColor = ColorPalette.vga32Bit[0];
final int leftWidth = optionsX320.clamp(0, 320);
final int rightStart = (optionsX320 + optionsLabel.width).clamp(0, 320);
final int rightWidth = (320 - rightStart).clamp(0, 320);
for (int y = 0; y < optionsLabel.height; y++) {
final int leftEdge = optionsLabel.decodePixel(0, y);
final int rightEdge = optionsLabel.decodePixel(optionsLabel.width - 1, y);
if (leftEdge != 0 || rightEdge != 0) {
continue;
}
if (leftWidth > 0) {
_fillRect320Precise(0, y, leftWidth, y + 1, barColor);
}
if (rightWidth > 0) {
_fillRect320Precise(rightStart, y, 320, y + 1, barColor);
}
}
}
void _fillRect320Precise(
int startX320,
int startY200,
int endX320,
int endY200,
int color,
) {
if (endX320 <= startX320 || endY200 <= startY200) {
return;
}
final double scaleX =
(_usesTerminalLayout ? projectionWidth : width) / 320.0;
final double scaleY =
(_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0;
final int offsetX = _usesTerminalLayout ? projectionOffsetX : 0;
final int startX = offsetX + (startX320 * scaleX).floor();
final int endX = offsetX + (endX320 * scaleX).ceil();
final int startY = (startY200 * scaleY).floor();
final int endY = (endY200 * scaleY).ceil();
if (_usesTerminalLayout) {
for (int y = startY; y < endY; y++) {
if (y < 0 || y >= _terminalPixelHeight) {
continue;
}
for (int x = startX; x < endX; x++) {
if (x < 0 || x >= _terminalSceneWidth) {
continue;
}
_scenePixels[y][x] = color;
}
}
return;
}
for (int y = startY; y < endY; y++) {
if (y < 0 || y >= height) {
continue;
}
for (int x = startX; x < endX; x++) {
if (x < 0 || x >= width) {
continue;
}
_screen[y][x] = ColoredChar(activeTheme.solid, color);
}
}
}
// --- DAMAGE FLASH ---
void _applyDamageFlash() {
for (int y = 0; y < viewHeight; y++) {

View File

@@ -6,6 +6,7 @@ import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:wolf_3d_dart/src/input/cli_input.dart';
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
@@ -32,9 +33,11 @@ class SixelRenderer extends CliRendererBackend<String> {
static const int _maxRenderWidth = 320;
static const int _maxRenderHeight = 240;
static const int _menuFooterBottomMargin = 1;
static const int _headerHeadingY = 24;
static const String _terminalTealBackground = '\x1b[48;2;0;150;136m';
late Uint8List _screen;
List<int>? _mainMenuBandFirstColumn;
int _offsetColumns = 0;
int _offsetRows = 0;
int _outputWidth = 1;
@@ -357,7 +360,9 @@ class SixelRenderer extends CliRendererBackend<String> {
@override
void drawMenu(WolfEngine engine) {
final int bgColor = _rgbToPaletteIndex(engine.menuBackgroundRgb);
final int bgColor = _rgbToPaletteIndex(
engine.menuManager.menuBackgroundRgb,
);
final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb);
final int headingIndex = WolfMenuPalette.headerTextIndex;
final int selectedTextIndex = WolfMenuPalette.selectedTextIndex;
@@ -369,6 +374,10 @@ class SixelRenderer extends CliRendererBackend<String> {
}
final art = WolfClassicMenuArt(engine.data);
final optionsLabel = art.optionsLabel;
if (optionsLabel != null) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
}
// Draw footer first so menu panels can clip overlap in the center.
_drawMenuFooterArt(art);
@@ -386,7 +395,17 @@ class SixelRenderer extends CliRendererBackend<String> {
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
_blitVgaImage(optionsLabel, optionsX, 0);
} else {
_drawMenuTextCentered('OPTIONS', 24, headingIndex, scale: 2);
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: 0,
);
_drawMenuTextCentered(
'OPTIONS',
_headerHeadingY,
headingIndex,
scale: 2,
);
}
final cursor = art.mappedPic(
@@ -415,8 +434,18 @@ class SixelRenderer extends CliRendererBackend<String> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: 0,
);
_fillRect320(28, 58, 264, 104, panelColor);
_drawMenuTextCentered('SELECT GAME', 48, headingIndex, scale: 2);
_drawMenuTextCentered(
'SELECT GAME',
_headerHeadingY,
headingIndex,
scale: 2,
);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
@@ -440,10 +469,15 @@ class SixelRenderer extends CliRendererBackend<String> {
}
if (engine.menuManager.activeMenu == WolfMenuScreen.episodeSelect) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: 0,
);
_fillRect320(12, 20, 296, 158, panelColor);
_drawMenuTextCentered(
'WHICH EPISODE TO PLAY?',
6,
_headerHeadingY,
headingIndex,
scale: 2,
);
@@ -488,6 +522,11 @@ class SixelRenderer extends CliRendererBackend<String> {
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: bgColor,
barColor: 0,
);
_fillRect320(28, 70, 264, 82, panelColor);
if (_useCompactMenuLayout) {
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
@@ -497,7 +536,7 @@ class SixelRenderer extends CliRendererBackend<String> {
_drawMenuTextCentered(
Difficulty.menuText,
48,
_headerHeadingY,
headingIndex,
scale: _menuHeadingScale,
);
@@ -567,7 +606,7 @@ class SixelRenderer extends CliRendererBackend<String> {
WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
};
int splashBg = _rgbToPaletteIndex(engine.menuManager.introBackgroundRgb);
int splashBg = _rgbToPaletteIndex(engine.menuManager.menuBackgroundRgb);
if (engine.menuManager.isIntroPg13Slide &&
image != null &&
image.pixels.isNotEmpty) {
@@ -602,11 +641,13 @@ class SixelRenderer extends CliRendererBackend<String> {
const int black = 0;
const int yellow = 14;
const int white = 15;
const int lineColor = 4;
_fillRect320(0, 0, 320, 22, black);
_drawMenuTextCentered('Attention', 6, yellow, scale: 2);
_fillRect320(0, 23, 320, 1, lineColor);
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: backgroundColor,
barColor: black,
);
_drawMenuTextCentered('Attention', _headerHeadingY, yellow, scale: 2);
_drawMenuText('This game is NOT shareware.', 40, 56, white, scale: 1);
_drawMenuText('Please do not distribute it.', 40, 68, white, scale: 1);
@@ -650,6 +691,56 @@ class SixelRenderer extends CliRendererBackend<String> {
}
}
void _drawHeaderBarStack({
required int headingY200,
required int backgroundColor,
required int barColor,
}) {
final List<int>? cachedColumn = _mainMenuBandFirstColumn;
if (cachedColumn != null && cachedColumn.isNotEmpty) {
final int bandHeight = cachedColumn.length.clamp(0, 200);
for (int y = 0; y < bandHeight; y++) {
final int paletteIndex = cachedColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillRect320(0, y, 320, 1, fillIndex);
}
return;
}
final int mainBarTop = (headingY200 - 4).clamp(0, 199);
_fillRect320(0, mainBarTop, 320, 18, barColor);
int stripeY = mainBarTop + 18;
for (int i = 0; i < 4; i++) {
_fillRect320(
0,
(stripeY + i).clamp(0, 199),
320,
1,
i.isEven ? barColor : backgroundColor,
);
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
final List<int> firstColumn = _mainMenuBandFirstColumn!;
for (int y = 0; y < optionsLabel.height; y++) {
final int paletteIndex = firstColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillRect320(0, y, 320, 1, fillIndex);
}
}
List<int> _cacheFirstColumn(VgaImage image) {
final List<int> column = List<int>.filled(image.height, 0);
for (int y = 0; y < image.height; y++) {
column[y] = image.decodePixel(0, y);
}
return column;
}
bool get _useCompactMenuLayout =>
width < _compactMenuMinWidthPx || height < _compactMenuMinHeightPx;
@@ -730,11 +821,52 @@ class SixelRenderer extends CliRendererBackend<String> {
int colorIndex, {
int scale = 1,
}) {
if (y == _headerHeadingY) {
y = _centerHeaderTitleInBlackBand(defaultY: y, scale: scale);
}
final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
final int x = ((320 - textWidth) ~/ 2).clamp(0, 319);
_drawMenuText(text, x, y, colorIndex, scale: scale);
}
int _centerHeaderTitleInBlackBand({
required int defaultY,
required int scale,
}) {
final List<int>? column = _mainMenuBandFirstColumn;
if (column == null || column.isEmpty) {
return defaultY;
}
int bestStart = -1;
int bestLength = 0;
int runStart = -1;
for (int i = 0; i <= column.length; i++) {
final bool isBlack = i < column.length && column[i] == 0;
if (isBlack) {
runStart = runStart == -1 ? i : runStart;
continue;
}
if (runStart != -1) {
final int runLength = i - runStart;
if (runLength > bestLength) {
bestLength = runLength;
bestStart = runStart;
}
runStart = -1;
}
}
if (bestStart == -1 || bestLength == 0) {
return defaultY;
}
final int textHeight = 7 * scale;
final int centered = bestStart + ((bestLength - textHeight) ~/ 2);
return centered.clamp(0, 199 - textHeight);
}
@override
String finalizeFrame() {
if (!isSixelSupported) {
@@ -764,7 +896,10 @@ class SixelRenderer extends CliRendererBackend<String> {
} catch (_) {}
const String msg1 = "Terminal does not support Sixel.";
const String msg2 = "Press TAB to switch renderers.";
final String shortcutLabel = engine.input is CliInput
? (engine.input as CliInput).rendererToggleKeyLabel
: 'TAB';
final String msg2 = "Press $shortcutLabel to switch renderers.";
final int boxWidth = math.max(msg1.length, msg2.length) + 6;
const int boxHeight = 5;
@@ -969,60 +1104,6 @@ class SixelRenderer extends CliRendererBackend<String> {
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
const int barColor = 0;
final int leftWidth = optionsX320.clamp(0, 320);
final int rightStart = (optionsX320 + optionsLabel.width).clamp(0, 320);
final int rightWidth = (320 - rightStart).clamp(0, 320);
for (int y = 0; y < optionsLabel.height; y++) {
final int leftEdge = optionsLabel.decodePixel(0, y);
final int rightEdge = optionsLabel.decodePixel(optionsLabel.width - 1, y);
if (leftEdge != 0 || rightEdge != 0) {
continue;
}
if (leftWidth > 0) {
_fillRect320Precise(0, y, leftWidth, y + 1, barColor);
}
if (rightWidth > 0) {
_fillRect320Precise(rightStart, y, 320, y + 1, barColor);
}
}
}
void _fillRect320Precise(
int startX320,
int startY200,
int endX320,
int endY200,
int colorIndex,
) {
if (endX320 <= startX320 || endY200 <= startY200) {
return;
}
final double scaleX = width / 320.0;
final double scaleY = height / 200.0;
final int startX = (startX320 * scaleX).floor();
final int endX = (endX320 * scaleX).ceil();
final int startY = (startY200 * scaleY).floor();
final int endY = (endY200 * scaleY).ceil();
for (int y = startY; y < endY; y++) {
if (y < 0 || y >= height) {
continue;
}
final int rowOffset = y * width;
for (int x = startX; x < endX; x++) {
if (x < 0 || x >= width) {
continue;
}
_screen[rowOffset + x] = colorIndex;
}
}
}
/// Maps an RGB color to the nearest VGA palette index.
int _rgbToPaletteIndex(int rgb) {
return ColorPalette.findClosestPaletteIndex(rgb);

View File

@@ -15,8 +15,10 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
static const int _menuFooterBottomMargin = 1;
static const int _menuFooterY = 184;
static const int _menuFooterHeight = 12;
static const int _headerHeadingY = 24;
late FrameBuffer _buffer;
List<int>? _mainMenuBandFirstColumn;
@override
void prepareFrame(WolfEngine engine) {
@@ -143,7 +145,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
@override
void drawMenu(WolfEngine engine) {
final int bgColor = _rgbToFrameColor(engine.menuBackgroundRgb);
final int bgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb);
final int panelColor = _rgbToFrameColor(engine.menuPanelRgb);
final int headingColor = WolfMenuPalette.headerTextColor;
final int selectedTextColor = WolfMenuPalette.selectedTextColor;
@@ -155,6 +157,10 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
}
final art = WolfClassicMenuArt(engine.data);
final optionsLabel = art.optionsLabel;
if (optionsLabel != null) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
}
// Draw footer first so menu panels can clip overlap in the center.
_drawCenteredMenuFooter(art);
@@ -215,7 +221,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
};
int splashBgColor = _rgbToFrameColor(engine.menuManager.introBackgroundRgb);
int splashBgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb);
int? matteIndex;
if (engine.menuManager.isIntroPg13Slide &&
image != null &&
@@ -257,11 +263,18 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
final int black = ColorPalette.vga32Bit[0];
final int yellow = ColorPalette.vga32Bit[14];
final int white = ColorPalette.vga32Bit[15];
final int lineColor = ColorPalette.vga32Bit[4];
_fillCanonicalRect(0, 0, 320, 22, black);
_drawCanonicalMenuTextCentered('Attention', 6, yellow, scale: 2);
_fillCanonicalRect(0, 23, 320, 1, lineColor);
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: backgroundColor,
barColor: black,
);
_drawCanonicalMenuTextCentered(
'Attention',
_headerHeadingY,
yellow,
scale: 2,
);
_drawCanonicalMenuText(
'This game is NOT shareware.',
@@ -358,7 +371,17 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
_drawMainMenuOptionsSideBars(optionsLabel, optionsX);
_blitVgaImage(optionsLabel, optionsX, 0);
} else {
_drawCanonicalMenuTextCentered('OPTIONS', 24, headingColor, scale: 2);
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
_drawCanonicalMenuTextCentered(
'OPTIONS',
_headerHeadingY,
headingColor,
scale: 2,
);
}
final cursor = art.mappedPic(
@@ -395,13 +418,24 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
int selectedTextColor,
int unselectedTextColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
const int panelX = 28;
const int panelY = 58;
const int panelW = 264;
const int panelH = 104;
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
_drawCanonicalMenuTextCentered('SELECT GAME', 38, headingColor, scale: 2);
_drawCanonicalMenuTextCentered(
'SELECT GAME',
_headerHeadingY,
headingColor,
scale: 2,
);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
@@ -435,6 +469,12 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
int selectedTextColor,
int unselectedTextColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
const int panelX = 12;
const int panelY = 20;
const int panelW = 296;
@@ -443,7 +483,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
_drawCanonicalMenuTextCentered(
'WHICH EPISODE TO PLAY?',
6,
_headerHeadingY,
headingColor,
scale: 2,
);
@@ -546,6 +586,12 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
int selectedTextColor,
int unselectedTextColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
const int panelX = 28;
@@ -556,7 +602,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
_drawCanonicalMenuTextCentered(
Difficulty.menuText,
48,
_headerHeadingY,
headingColor,
scale: 2,
);
@@ -655,6 +701,38 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
void _drawHeaderBarStack({
required int headingY200,
required int backgroundColor,
required int barColor,
}) {
final List<int>? cachedColumn = _mainMenuBandFirstColumn;
if (cachedColumn != null && cachedColumn.isNotEmpty) {
final int bandHeight = cachedColumn.length.clamp(0, 200);
for (int y = 0; y < bandHeight; y++) {
final int paletteIndex = cachedColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillCanonicalRect(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]);
}
return;
}
final int mainBarTop = (headingY200 - 4).clamp(0, 199);
_fillCanonicalRect(0, mainBarTop, 320, 18, barColor);
int stripeY = mainBarTop + 18;
for (int i = 0; i < 4; i++) {
_fillCanonicalRect(
0,
(stripeY + i).clamp(0, 199),
320,
1,
i.isEven ? barColor : backgroundColor,
);
}
}
double get _uiScaleX => width / 320.0;
double get _uiScaleY => height / 200.0;
@@ -683,25 +761,21 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
final int barColor = ColorPalette.vga32Bit[0];
final int leftWidth = optionsX320.clamp(0, 320);
final int rightStart = (optionsX320 + optionsLabel.width).clamp(0, 320);
final int rightWidth = (320 - rightStart).clamp(0, 320);
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
final List<int> firstColumn = _mainMenuBandFirstColumn!;
for (int y = 0; y < optionsLabel.height; y++) {
final int leftEdge = optionsLabel.decodePixel(0, y);
final int rightEdge = optionsLabel.decodePixel(optionsLabel.width - 1, y);
if (leftEdge != 0 || rightEdge != 0) {
continue;
final int paletteIndex = firstColumn[y];
final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
_fillCanonicalRect(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]);
}
}
if (leftWidth > 0) {
_fillCanonicalRect(0, y, leftWidth, 1, barColor);
}
if (rightWidth > 0) {
_fillCanonicalRect(rightStart, y, rightWidth, 1, barColor);
}
List<int> _cacheFirstColumn(VgaImage image) {
final List<int> column = List<int>.filled(image.height, 0);
for (int y = 0; y < image.height; y++) {
column[y] = image.decodePixel(0, y);
}
return column;
}
void _drawCanonicalMenuText(
@@ -740,11 +814,52 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
int color, {
int scale = 1,
}) {
if (y200 == _headerHeadingY) {
y200 = _centerHeaderTitleInBlackBand(defaultY: y200, scale: scale);
}
final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
final int x320 = ((320 - textWidth) ~/ 2).clamp(0, 319);
_drawCanonicalMenuText(text, x320, y200, color, scale: scale);
}
int _centerHeaderTitleInBlackBand({
required int defaultY,
required int scale,
}) {
final List<int>? column = _mainMenuBandFirstColumn;
if (column == null || column.isEmpty) {
return defaultY;
}
int bestStart = -1;
int bestLength = 0;
int runStart = -1;
for (int i = 0; i <= column.length; i++) {
final bool isBlack = i < column.length && column[i] == 0;
if (isBlack) {
runStart = runStart == -1 ? i : runStart;
continue;
}
if (runStart != -1) {
final int runLength = i - runStart;
if (runLength > bestLength) {
bestLength = runLength;
bestStart = runStart;
}
runStart = -1;
}
}
if (bestStart == -1 || bestLength == 0) {
return defaultY;
}
final int textHeight = 7 * scale;
final int centered = bestStart + ((bestLength - textHeight) ~/ 2);
return centered.clamp(0, 199 - textHeight);
}
/// Draws bitmap menu text directly into the framebuffer.
void _drawMenuText(
String text,