feat: Implement Change View and Renderer Options menus

- Added functionality to display and navigate the Change View menu in SixelRenderer and SoftwareRenderer.
- Introduced methods to draw the Change View and Renderer Options menus, including handling cursor and selection states.
- Updated WolfClassicMenuArt to include a customize label for the new menu.
- Enhanced WolfMenuScreen to support new menu states.
- Created tests for Change View menu interactions, ensuring proper transitions and renderer settings toggling.
- Implemented persistence for renderer settings in Flutter, allowing settings to be saved and loaded from a local file.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 20:49:37 +01:00
parent 45e5302eac
commit 3270338f44
20 changed files with 2223 additions and 140 deletions
@@ -642,6 +642,163 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) {
_drawCustomizeMenuHeader(art, headingColor, bgColor);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
const int rowYStart = 66;
const int rowStep = 18;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
final entries = engine.menuManager.changeViewEntries;
final optionEntries = engine.menuManager.rendererOptionEntries;
final int modeCount = entries.length;
final int optionCount = optionEntries.length;
const int modesPanelY = 52;
final int modesContentHeight = modeCount <= 0
? 0
: ((modeCount - 1) * rowStep) + 12;
final int modesPanelHeight = math.max(56, modesContentHeight + 14);
final int sectionHeaderY = modesPanelY + modesPanelHeight + 6;
final int optionsPanelY = sectionHeaderY + 14;
final int optionsContentHeight = optionCount <= 0
? 0
: ((optionCount - 1) * 15) + 12;
final int optionsPanelHeight = math.max(30, optionsContentHeight + 10);
_fillRect320(46, modesPanelY, 228, modesPanelHeight, panelColor);
_fillRect320(46, optionsPanelY, 228, optionsPanelHeight, panelColor);
for (int i = 0; i < entries.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected = i == engine.menuManager.selectedChangeViewIndex;
if (isSelected && cursor != null) {
_blitVgaImageAscii(cursor, cursorX, y - 2);
}
final entry = entries[i];
final marker = entry.isChecked ? selectedMarker : unselectedMarker;
if (marker != null) {
_blitVgaImageAscii(marker, markerX, y);
}
final int textColor = entry.isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor;
_drawMenuLabelAdaptive(
typography: menuTypography,
text: entry.label,
textX320: textX,
y200: y + 1,
color: textColor,
panelX320: 46,
panelW320: 228,
panelColor: panelColor,
);
}
_drawMenuSectionHeader(
text: engine.menuManager.rendererOptionsTitle,
y200: sectionHeaderY,
);
const int optionsRowStep = 15;
final int optionsRowsHeight = optionCount <= 0
? 0
: ((optionCount - 1) * optionsRowStep) + 10;
final int optionsRowStart =
optionsPanelY +
((optionsPanelHeight - optionsRowsHeight) ~/ 2).clamp(0, 200);
for (int i = 0; i < optionEntries.length; i++) {
final int optionIndex = modeCount + i;
final bool isSelected =
optionIndex == engine.menuManager.selectedChangeViewIndex;
final int y = optionsRowStart + (i * optionsRowStep);
if (isSelected && cursor != null) {
_blitVgaImageAscii(cursor, cursorX, y - 2);
}
final entry = optionEntries[i];
final marker = entry.isChecked ? selectedMarker : unselectedMarker;
if (marker != null) {
_blitVgaImageAscii(marker, markerX, y);
}
final int textColor = entry.isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor;
_drawMenuLabelAdaptive(
typography: menuTypography,
text: entry.label,
textX320: textX,
y200: y + 1,
color: textColor,
panelX320: 46,
panelW320: 228,
panelColor: panelColor,
);
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) {
_drawCustomizeMenuHeader(art, headingColor, bgColor);
_fillRect320(56, 52, 208, 120, panelColor);
_drawMenuTextCentered(
engine.menuManager.rendererOptionsTitle,
46,
headingColor,
scale: 1,
);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
const int rowYStart = 68;
const int rowStep = 20;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
final entries = engine.menuManager.rendererOptionEntries;
for (int i = 0; i < entries.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected =
i == engine.menuManager.selectedRendererOptionIndex;
final entry = entries[i];
if (isSelected && cursor != null) {
_blitVgaImageAscii(cursor, cursorX, y - 2);
}
final marker = entry.isChecked ? selectedMarker : unselectedMarker;
if (marker != null) {
_blitVgaImageAscii(marker, markerX, y);
}
final int textColor = entry.isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor;
_drawMenuLabelAdaptive(
typography: menuTypography,
text: entry.label,
textX320: textX,
y200: y + 1,
color: textColor,
panelX320: 56,
panelW320: 208,
panelColor: panelColor,
);
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
@@ -997,7 +1154,10 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
int scale = 1,
}) {
if (y200 == _headerHeadingY) {
y200 = _centerHeaderTitleInBlackBand(defaultY: y200, scale: scale);
y200 = _centerHeaderTitleInBlackBand(
defaultY: y200,
scale: scale,
);
}
final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
final int x320 = ((320 - textWidth) ~/ 2).clamp(0, 319);
@@ -1156,6 +1316,103 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
}
void _drawCustomizeMenuHeader(
WolfClassicMenuArt art,
int headingColor,
int backgroundColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: backgroundColor,
barColor: ColorPalette.vga32Bit[0],
);
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
if (heading != null) {
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
_blitVgaImageAscii(heading, headingX, 0);
return;
}
_drawMenuTextCentered('CUSTOMIZE', _headerHeadingY, headingColor, scale: 2);
}
void _drawMenuSectionHeader({
required String text,
required int y200,
}) {
final int textW = WolfMenuFont.measureTextWidth(text, 1);
final int boxW = (textW + 28).clamp(110, 250);
final int x = ((320 - boxW) ~/ 2).clamp(0, 319);
final int outer = _rgbToPaletteColor(0x727272);
final int inner = _rgbToPaletteColor(0x8E8E8E);
final int fill = _rgbToPaletteColor(0xC9C9C9);
final int textColor = _rgbToPaletteColor(0x5A5A5A);
_fillRect320(x, y200, boxW, 12, outer);
_fillRect320(x + 1, y200 + 1, boxW - 2, 10, inner);
_fillRect320(x + 2, y200 + 2, boxW - 4, 8, fill);
_drawMenuLabelAdaptive(
typography: _resolveMenuTypography(),
text: text,
textX320: x + 8,
y200: y200 + 2,
color: textColor,
panelX320: x + 2,
panelW320: boxW - 4,
panelColor: fill,
centerWhenCompact: true,
);
}
void _drawMenuLabelAdaptive({
required _AsciiMenuTypography typography,
required String text,
required int textX320,
required int y200,
required int color,
required int panelX320,
required int panelW320,
required int panelColor,
bool centerWhenCompact = false,
}) {
if (!typography.usesCompactRows) {
_drawMenuText(text, textX320, y200, color);
return;
}
if (centerWhenCompact) {
final int panelX = _menuX320ToColumn(panelX320);
final int panelW = math.max(
1,
((panelW320 / 320.0) * projectionWidth).toInt(),
);
final int textW = text.length;
final int centeredLeft = panelX + math.max(0, ((panelW - textW) ~/ 2));
_writeLeftClipped(
_menuY200ToRow(y200),
text,
color,
panelColor,
math.max(1, panelW - 2),
centeredLeft,
);
return;
}
_drawMinimalMenuRows(
rows: <String>[text],
selectedIndex: -1,
rowYStart200: y200,
rowStep200: 1,
textX320: textX320,
panelX320: panelX320,
panelW320: panelW320,
panelColor: panelColor,
colorForRow: (_, _) => color,
);
}
void _drawCenteredMenuFooter() {
if (_usesTerminalLayout && !_emitAnsi) {
_drawFlutterGridMenuFooter();
@@ -526,6 +526,146 @@ class SixelRenderer extends CliRendererBackend<String> {
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) {
_drawCustomizeMenuHeader(art, headingIndex, bgColor);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
const int rowYStart = 66;
const int rowStep = 18;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
final entries = engine.menuManager.changeViewEntries;
final optionEntries = engine.menuManager.rendererOptionEntries;
final int modeCount = entries.length;
final int optionCount = optionEntries.length;
const int modesPanelY = 52;
final int modesContentHeight = modeCount <= 0
? 0
: ((modeCount - 1) * rowStep) + 12;
final int modesPanelHeight = math.max(56, modesContentHeight + 14);
final int sectionHeaderY = modesPanelY + modesPanelHeight + 6;
final int optionsPanelY = sectionHeaderY + 14;
final int optionsContentHeight = optionCount <= 0
? 0
: ((optionCount - 1) * 15) + 12;
final int optionsPanelHeight = math.max(30, optionsContentHeight + 10);
_fillRect320(46, modesPanelY, 228, modesPanelHeight, panelColor);
_fillRect320(46, optionsPanelY, 228, optionsPanelHeight, panelColor);
for (int i = 0; i < entries.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected = i == engine.menuManager.selectedChangeViewIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, cursorX, y - 2);
}
final entry = entries[i];
final marker = entry.isChecked ? selectedMarker : unselectedMarker;
if (marker != null) {
_blitVgaImage(marker, markerX, y);
}
_drawMenuText(
entry.label,
textX,
y + 1,
entry.isEnabled
? (isSelected ? selectedTextIndex : unselectedTextIndex)
: disabledTextIndex,
scale: 1,
);
}
_drawMenuSectionHeader(
text: engine.menuManager.rendererOptionsTitle,
y200: sectionHeaderY,
);
const int optionsRowStep = 15;
final int optionsRowsHeight = optionCount <= 0
? 0
: ((optionCount - 1) * optionsRowStep) + 10;
final int optionsRowStart =
optionsPanelY +
((optionsPanelHeight - optionsRowsHeight) ~/ 2).clamp(0, 200);
for (int i = 0; i < optionEntries.length; i++) {
final int optionIndex = modeCount + i;
final bool isSelected =
optionIndex == engine.menuManager.selectedChangeViewIndex;
final int y = optionsRowStart + (i * optionsRowStep);
if (isSelected && cursor != null) {
_blitVgaImage(cursor, cursorX, y - 2);
}
final entry = optionEntries[i];
final marker = entry.isChecked ? selectedMarker : unselectedMarker;
if (marker != null) {
_blitVgaImage(marker, markerX, y);
}
_drawMenuText(
entry.label,
textX,
y + 1,
entry.isEnabled
? (isSelected ? selectedTextIndex : unselectedTextIndex)
: disabledTextIndex,
scale: 1,
);
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) {
_drawCustomizeMenuHeader(art, headingIndex, bgColor);
_fillRect320(56, 52, 208, 120, panelColor);
_drawMenuTextCentered(
engine.menuManager.rendererOptionsTitle,
46,
headingIndex,
scale: 1,
);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
const int rowYStart = 68;
const int rowStep = 20;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
final entries = engine.menuManager.rendererOptionEntries;
for (int i = 0; i < entries.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected =
i == engine.menuManager.selectedRendererOptionIndex;
final entry = entries[i];
if (isSelected && cursor != null) {
_blitVgaImage(cursor, cursorX, y - 2);
}
final marker = entry.isChecked ? selectedMarker : unselectedMarker;
if (marker != null) {
_blitVgaImage(marker, markerX, y);
}
_drawMenuText(
entry.label,
textX,
y + 1,
entry.isEnabled
? (isSelected ? selectedTextIndex : unselectedTextIndex)
: disabledTextIndex,
scale: 1,
);
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
_drawHeaderBarStack(
@@ -580,6 +720,45 @@ class SixelRenderer extends CliRendererBackend<String> {
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
}
void _drawCustomizeMenuHeader(
WolfClassicMenuArt art,
int headingIndex,
int backgroundColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: backgroundColor,
barColor: 0,
);
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
if (heading != null) {
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
_blitVgaImage(heading, headingX, 0);
return;
}
_drawMenuTextCentered('CUSTOMIZE', _headerHeadingY, headingIndex, scale: 2);
}
void _drawMenuSectionHeader({
required String text,
required int y200,
}) {
final int textW = WolfMenuFont.measureTextWidth(text, 1);
final int boxW = (textW + 28).clamp(110, 250);
final int x = ((320 - boxW) ~/ 2).clamp(0, 319);
final int outer = _rgbToPaletteIndex(0x727272);
final int inner = _rgbToPaletteIndex(0x8E8E8E);
final int fill = _rgbToPaletteIndex(0xC9C9C9);
final int textColor = _rgbToPaletteIndex(0x5A5A5A);
_fillRect320(x, y200, boxW, 12, outer);
_fillRect320(x + 1, y200 + 1, boxW - 2, 10, inner);
_fillRect320(x + 2, y200 + 2, boxW - 4, 8, fill);
_drawMenuTextCentered(text, y200 + 2, textColor, scale: 1);
}
void _drawMenuFooterArt(WolfClassicMenuArt art) {
final bottom = art.mappedPic(15);
if (bottom == null) {
@@ -209,6 +209,28 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
unselectedTextColor,
);
break;
case WolfMenuScreen.changeView:
_drawChangeViewMenu(
engine,
art,
panelColor,
headingColor,
selectedTextColor,
unselectedTextColor,
disabledTextColor,
);
break;
case WolfMenuScreen.rendererOptions:
_drawRendererOptionsMenu(
engine,
art,
panelColor,
headingColor,
selectedTextColor,
unselectedTextColor,
disabledTextColor,
);
break;
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
@@ -410,6 +432,216 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
}
}
void _drawChangeViewMenu(
WolfEngine engine,
WolfClassicMenuArt art,
int panelColor,
int headingColor,
int selectedTextColor,
int unselectedTextColor,
int disabledTextColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
const int modesPanelX = 46;
const int modesPanelY = 52;
const int modesPanelW = 228;
const int modesPanelH = 74;
_fillCanonicalRect(
modesPanelX,
modesPanelY,
modesPanelW,
modesPanelH,
panelColor,
);
const int optionsPanelX = 46;
const int optionsPanelY = 146;
const int optionsPanelW = 228;
const int optionsPanelH = 42;
_fillCanonicalRect(
optionsPanelX,
optionsPanelY,
optionsPanelW,
optionsPanelH,
panelColor,
);
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
if (heading != null) {
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
_blitVgaImage(heading, headingX, 0);
} else {
_drawCanonicalMenuTextCentered(
'CUSTOMIZE',
_headerHeadingY,
headingColor,
scale: 2,
);
}
final VgaImage? selectedMarker = art.selectedMarker;
final VgaImage? unselectedMarker = art.unselectedMarker;
final VgaImage? cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 66;
const int rowStep = 18;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
final entries = engine.menuManager.changeViewEntries;
final optionEntries = engine.menuManager.rendererOptionEntries;
final int modeCount = entries.length;
final int selectedIndex = engine.menuManager.selectedChangeViewIndex;
for (int i = 0; i < entries.length; i++) {
final bool isSelected = i == selectedIndex;
final int y = rowYStart + (i * rowStep);
if (isSelected && cursor != null) {
_blitVgaImage(cursor, cursorX, y - 2);
}
final entry = entries[i];
final VgaImage? marker = entry.isChecked
? selectedMarker
: unselectedMarker;
if (marker != null) {
_blitVgaImage(marker, markerX, y);
}
_drawCanonicalMenuText(
entry.label,
textX,
y + 1,
entry.isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor,
);
}
_drawMenuSectionHeader(
text: engine.menuManager.rendererOptionsTitle,
y200: 132,
textColor: ColorPalette.vga32Bit[8],
);
const int optionsRowStart = 159;
const int optionsRowStep = 15;
for (int i = 0; i < optionEntries.length; i++) {
final int optionIndex = modeCount + i;
final bool isSelected = optionIndex == selectedIndex;
final int y = optionsRowStart + (i * optionsRowStep);
if (isSelected && cursor != null) {
_blitVgaImage(cursor, cursorX, y - 2);
}
final entry = optionEntries[i];
final VgaImage? marker = entry.isChecked
? selectedMarker
: unselectedMarker;
if (marker != null) {
_blitVgaImage(marker, markerX, y);
}
_drawCanonicalMenuText(
entry.label,
textX,
y + 1,
entry.isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor,
);
}
}
void _drawRendererOptionsMenu(
WolfEngine engine,
WolfClassicMenuArt art,
int panelColor,
int headingColor,
int selectedTextColor,
int unselectedTextColor,
int disabledTextColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
const int panelX = 56;
const int panelY = 52;
const int panelW = 208;
const int panelH = 120;
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
if (heading != null) {
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
_blitVgaImage(heading, headingX, 0);
} else {
_drawCanonicalMenuTextCentered(
'CUSTOMIZE',
_headerHeadingY,
headingColor,
scale: 2,
);
}
final VgaImage? selectedMarker = art.selectedMarker;
final VgaImage? unselectedMarker = art.unselectedMarker;
final VgaImage? cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
_drawCanonicalMenuTextCentered(
engine.menuManager.rendererOptionsTitle,
46,
headingColor,
scale: 1,
);
const int rowYStart = 68;
const int rowStep = 20;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
final entries = engine.menuManager.rendererOptionEntries;
final int selectedIndex = engine.menuManager.selectedRendererOptionIndex;
for (int i = 0; i < entries.length; i++) {
final entry = entries[i];
final bool isSelected = i == selectedIndex;
final int y = rowYStart + (i * rowStep);
if (isSelected && cursor != null) {
_blitVgaImage(cursor, cursorX, y - 2);
}
final VgaImage? marker = entry.isChecked
? selectedMarker
: unselectedMarker;
if (marker != null) {
_blitVgaImage(marker, markerX, y);
}
_drawCanonicalMenuText(
entry.label,
textX,
y + 1,
entry.isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor,
);
}
}
void _drawGameSelectMenu(
WolfEngine engine,
WolfClassicMenuArt art,
@@ -461,6 +693,26 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
}
}
void _drawMenuSectionHeader({
required String text,
required int y200,
required int textColor,
}) {
final int textW = WolfMenuFont.measureTextWidth(text, 1);
final int boxW = (textW + 28).clamp(110, 250);
final int x = ((320 - boxW) ~/ 2).clamp(0, 319);
final int y = y200.clamp(0, 199);
final int outer = _rgbToFrameColor(0x727272);
final int inner = _rgbToFrameColor(0x8E8E8E);
final int fill = _rgbToFrameColor(0xC9C9C9);
final int menuTextColor = _rgbToFrameColor(0x5A5A5A);
_fillCanonicalRect(x, y, boxW, 12, outer);
_fillCanonicalRect(x + 1, y + 1, boxW - 2, 10, inner);
_fillCanonicalRect(x + 2, y + 2, boxW - 4, 8, fill);
_drawCanonicalMenuTextCentered(text, y + 2, menuTextColor, scale: 1);
}
void _drawEpisodeSelectMenu(
WolfEngine engine,
WolfClassicMenuArt art,