feat: Implement host shortcut system for desktop window management and input suppression

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 11:35:22 +01:00
parent c81eb6750d
commit 862191d245
3 changed files with 143 additions and 20 deletions

View File

@@ -29,6 +29,7 @@ void main() async {
); );
} }
/// Whether desktop window-management APIs should be initialized for this host.
bool get _supportsDesktopWindowing { bool get _supportsDesktopWindowing {
if (kIsWeb) { if (kIsWeb) {
return false; return false;

View File

@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.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_flutter.dart';
import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart'; import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
@@ -16,11 +17,92 @@ import 'package:wolf_3d_renderer/wolf_3d_flutter_renderer.dart';
import 'package:wolf_3d_renderer/wolf_3d_glsl_renderer.dart'; import 'package:wolf_3d_renderer/wolf_3d_glsl_renderer.dart';
enum RendererMode { enum RendererMode {
/// Software pixel renderer presented via decoded framebuffer images.
software, software,
/// Text-mode renderer for debugging and retro terminal aesthetics.
ascii, ascii,
/// GLSL renderer with optional CRT-style post processing.
hardware, hardware,
} }
/// Semantic actions that host-level shortcuts can trigger.
///
/// These intents are intentionally UI-host focused (windowing, app shell), not
/// engine gameplay actions. The engine continues to receive input through
/// [Wolf3dFlutterInput].
enum HostShortcutIntent {
/// Toggle desktop fullscreen on/off.
toggleFullscreen,
}
/// Declarative mapping from a key pattern to a host shortcut intent.
///
/// [matches] identifies whether a key event should trigger this binding.
/// [suppressedActions] are one-frame engine actions that should be blocked
/// when the binding is consumed (for example, blocking `interact` on Alt+Enter
/// so Enter does not activate menu selections).
class HostShortcutBinding {
/// Predicate that returns true when this shortcut should fire.
final bool Function(KeyEvent event) matches;
/// Host operation to perform when [matches] succeeds.
final HostShortcutIntent intent;
/// Engine actions to suppress for a single input update tick.
final Set<WolfInputAction> suppressedActions;
/// Creates a host shortcut binding with optional suppressed engine actions.
const HostShortcutBinding({
required this.matches,
required this.intent,
this.suppressedActions = const <WolfInputAction>{},
});
}
/// Ordered set of host shortcut bindings.
///
/// The first binding whose [HostShortcutBinding.matches] returns true wins.
/// This keeps behavior deterministic when multiple shortcuts could overlap.
class HostShortcutRegistry {
/// Ordered bindings consulted for each incoming key-down event.
final List<HostShortcutBinding> bindings;
/// Creates a registry with explicit [bindings].
const HostShortcutRegistry({
required this.bindings,
});
/// Returns the first binding that matches [event], or null when none do.
HostShortcutBinding? match(KeyEvent event) {
for (final HostShortcutBinding binding in bindings) {
if (binding.matches(event)) {
return binding;
}
}
return null;
}
/// Default host shortcuts used by [GameScreen].
///
/// Alt+Enter toggles fullscreen and suppresses the engine `interact` action
/// for one frame so Enter does not also activate menu/game interactions.
static const HostShortcutRegistry defaults = HostShortcutRegistry(
bindings: <HostShortcutBinding>[
HostShortcutBinding(
matches: _isAltEnterShortcut,
intent: HostShortcutIntent.toggleFullscreen,
suppressedActions: <WolfInputAction>{WolfInputAction.interact},
),
],
);
}
/// Optional imperative host shortcut override.
///
/// Return true when the event was fully handled. The handler receives the
/// shared [Wolf3dFlutterInput] so it can suppress engine actions as needed.
typedef HostShortcutHandler = typedef HostShortcutHandler =
bool Function( bool Function(
KeyEvent event, KeyEvent event,
@@ -39,10 +121,14 @@ class GameScreen extends StatefulWidget {
/// engine update loop. /// engine update loop.
final HostShortcutHandler? hostShortcutHandler; final HostShortcutHandler? hostShortcutHandler;
/// Declarative host shortcut registry used when [hostShortcutHandler] is null.
final HostShortcutRegistry hostShortcutRegistry;
/// Creates a gameplay screen driven by [wolf3d]. /// Creates a gameplay screen driven by [wolf3d].
const GameScreen({ const GameScreen({
required this.wolf3d, required this.wolf3d,
this.hostShortcutHandler, this.hostShortcutHandler,
this.hostShortcutRegistry = HostShortcutRegistry.defaults,
super.key, super.key,
}); });
@@ -52,8 +138,14 @@ class GameScreen extends StatefulWidget {
class _GameScreenState extends State<GameScreen> { class _GameScreenState extends State<GameScreen> {
late final WolfEngine _engine; late final WolfEngine _engine;
/// Current active renderer implementation.
RendererMode _rendererMode = RendererMode.hardware; RendererMode _rendererMode = RendererMode.hardware;
/// Active ASCII glyph theme used by [RendererMode.ascii].
AsciiTheme _asciiTheme = AsciiThemes.blocks; AsciiTheme _asciiTheme = AsciiThemes.blocks;
/// Whether CRT post-processing is enabled in [RendererMode.hardware].
bool _glslEffectsEnabled = false; bool _glslEffectsEnabled = false;
@override @override
@@ -174,6 +266,8 @@ class _GameScreenState extends State<GameScreen> {
return; return;
} }
// Host shortcuts must be processed before game actions so they can
// suppress overlapping keys (for example Alt+Enter consuming Enter).
if (_handleHostShortcut(event)) { if (_handleHostShortcut(event)) {
return; return;
} }
@@ -245,34 +339,35 @@ class _GameScreenState extends State<GameScreen> {
bool _handleHostShortcut(KeyEvent event) { bool _handleHostShortcut(KeyEvent event) {
final HostShortcutHandler? customHandler = widget.hostShortcutHandler; final HostShortcutHandler? customHandler = widget.hostShortcutHandler;
if (customHandler != null) { if (customHandler != null) {
// Custom handlers take full precedence to support future menu-driven
// rebinding/override systems without modifying this screen.
return customHandler(event, widget.wolf3d.input); return customHandler(event, widget.wolf3d.input);
} }
if (_isAltEnter(event)) { final HostShortcutBinding? binding = widget.hostShortcutRegistry.match(
// Consume Enter so fullscreen toggling does not also activate menu items. event,
widget.wolf3d.input.suppressInteractOnce(); );
if (binding == null) {
return false;
}
// Suppress conflicting gameplay/menu actions for one update frame.
for (final WolfInputAction action in binding.suppressedActions) {
widget.wolf3d.input.suppressActionOnce(action);
}
switch (binding.intent) {
case HostShortcutIntent.toggleFullscreen:
unawaited(_toggleFullscreen()); unawaited(_toggleFullscreen());
}
return true; return true;
} }
return false; /// Toggles desktop fullscreen state when supported by the host platform.
} ///
/// This no-ops on unsupported targets and safely ignores missing plugin
bool _isAltEnter(KeyEvent event) { /// hosts to keep gameplay input resilient in embedded/test environments.
final bool isEnter =
event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter;
if (!isEnter) {
return false;
}
final Set<LogicalKeyboardKey> pressedKeys =
HardwareKeyboard.instance.logicalKeysPressed;
return pressedKeys.contains(LogicalKeyboardKey.altLeft) ||
pressedKeys.contains(LogicalKeyboardKey.altRight) ||
pressedKeys.contains(LogicalKeyboardKey.alt);
}
Future<void> _toggleFullscreen() async { Future<void> _toggleFullscreen() async {
if (!_supportsDesktopWindowing) { if (!_supportsDesktopWindowing) {
return; return;
@@ -286,6 +381,7 @@ class _GameScreenState extends State<GameScreen> {
} }
} }
/// Whether runtime desktop window management APIs are expected to work.
bool get _supportsDesktopWindowing { bool get _supportsDesktopWindowing {
if (kIsWeb) { if (kIsWeb) {
return false; return false;
@@ -299,3 +395,19 @@ class _GameScreenState extends State<GameScreen> {
}; };
} }
} }
/// Returns true when [event] is Enter/NumpadEnter while Alt is pressed.
bool _isAltEnterShortcut(KeyEvent event) {
final bool isEnter =
event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter;
if (!isEnter) {
return false;
}
final Set<LogicalKeyboardKey> pressedKeys =
HardwareKeyboard.instance.logicalKeysPressed;
return pressedKeys.contains(LogicalKeyboardKey.altLeft) ||
pressedKeys.contains(LogicalKeyboardKey.altRight) ||
pressedKeys.contains(LogicalKeyboardKey.alt);
}

View File

@@ -95,6 +95,9 @@ class Wolf3dFlutterInput extends Wolf3dInput {
double _mouseDeltaY = 0.0; double _mouseDeltaY = 0.0;
bool _previousMouseRightDown = false; bool _previousMouseRightDown = false;
bool _queuedBack = false; bool _queuedBack = false;
// One-frame suppression lets host shortcuts (for example Alt+Enter) consume
// overlapping gameplay keys before the engine reads keyboard state.
final Set<WolfInputAction> _suppressedActionsOnce = <WolfInputAction>{}; final Set<WolfInputAction> _suppressedActionsOnce = <WolfInputAction>{};
// Mouse-look is optional so touch or keyboard-only hosts can keep the same // Mouse-look is optional so touch or keyboard-only hosts can keep the same
@@ -155,6 +158,9 @@ class Wolf3dFlutterInput extends Wolf3dInput {
} }
/// Suppresses [action] for the next [update] tick only. /// Suppresses [action] for the next [update] tick only.
///
/// This is primarily used by host-level shortcuts that share physical keys
/// with gameplay/menu actions.
void suppressActionOnce(WolfInputAction action) { void suppressActionOnce(WolfInputAction action) {
_suppressedActionsOnce.add(action); _suppressedActionsOnce.add(action);
} }
@@ -166,6 +172,7 @@ class Wolf3dFlutterInput extends Wolf3dInput {
/// Returns whether any bound key for [action] is currently pressed. /// Returns whether any bound key for [action] is currently pressed.
bool _isActive(WolfInputAction action, Set<LogicalKeyboardKey> pressedKeys) { bool _isActive(WolfInputAction action, Set<LogicalKeyboardKey> pressedKeys) {
// Host-consumed actions should appear inactive for exactly one update.
if (_suppressedActionsOnce.contains(action)) { if (_suppressedActionsOnce.contains(action)) {
return false; return false;
} }
@@ -177,6 +184,7 @@ class Wolf3dFlutterInput extends Wolf3dInput {
WolfInputAction action, WolfInputAction action,
Set<LogicalKeyboardKey> newlyPressed, Set<LogicalKeyboardKey> newlyPressed,
) { ) {
// Suppressed actions also block edge-triggered press detection.
if (_suppressedActionsOnce.contains(action)) { if (_suppressedActionsOnce.contains(action)) {
return false; return false;
} }
@@ -240,6 +248,8 @@ class Wolf3dFlutterInput extends Wolf3dInput {
_previousKeys = Set.from(pressedKeys); _previousKeys = Set.from(pressedKeys);
_previousMouseRightDown = isMouseRightDown; _previousMouseRightDown = isMouseRightDown;
_queuedBack = false; _queuedBack = false;
// Clear one-shot suppression so the action behaves normally next frame.
_suppressedActionsOnce.clear(); _suppressedActionsOnce.clear();
} }
} }