From 81b07bea0c620357af558f6ec4be506d1f1bbfdf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:59:10 +0000 Subject: [PATCH] feat(canvas): F11 borderless fullscreen toggle for Canvas windows Add F11/Escape keyboard shortcut to toggle borderless fullscreen in both Canvas window types (WebView2-based CanvasWindow and native A2UICanvasWindow). Implementation: - Use AppWindow.SetPresenter(AppWindowPresenterKind.FullScreen) to enter/exit fullscreen (WinUI 3 AppWindow API via Microsoft.UI.Windowing) - A2UICanvasWindow: KeyboardAccelerators on RootGrid (XAML tree handles F11/Esc natively) - CanvasWindow: KeyboardAccelerators on content root for XAML-focused state, plus JavaScript injection via AddScriptToExecuteOnDocumentCreatedAsync so F11/Escape are also captured when WebView2 content has focus and routed via the existing web bridge (postMessage) - Bridge messages 'fullscreen-toggle' and 'fullscreen-exit' are intercepted before forwarding to external BridgeMessageReceived subscribers - ExitFullScreen() called on window close to restore normal presenter state Closes #669 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Windows/A2UICanvasWindow.xaml.cs | 45 ++++++++++ .../Windows/CanvasWindow.xaml.cs | 85 ++++++++++++++++++- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Tray.WinUI/Windows/A2UICanvasWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/A2UICanvasWindow.xaml.cs index 4188ab23..0fe005fb 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/A2UICanvasWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/A2UICanvasWindow.xaml.cs @@ -5,8 +5,10 @@ using System.Text.Json.Nodes; using System.Threading.Tasks; using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media.Imaging; using OpenClaw.Shared; using OpenClawTray.A2UI.Actions; @@ -16,6 +18,7 @@ using WinUIEx; using Windows.Graphics.Imaging; using Windows.Storage.Streams; +using Windows.System; namespace OpenClawTray.Windows; @@ -45,6 +48,7 @@ public sealed partial class A2UICanvasWindow : WindowEx private readonly DispatcherQueue _dispatcher; private readonly A2UIRouter _router; private readonly DataModelStore _dataModel; + private bool _isFullScreen; public bool IsClosed { get; private set; } @@ -69,6 +73,22 @@ public A2UICanvasWindow(IActionSink actions, MediaResolver media, IOpenClawLogge _router.SurfaceCreated += OnSurfaceCreated; _router.SurfaceDeleted += OnSurfaceDeleted; + // F11 toggles borderless fullscreen; Escape exits it. + var f11Accel = new KeyboardAccelerator { Key = VirtualKey.F11 }; + f11Accel.Invoked += (_, args) => + { + args.Handled = true; + ToggleFullScreen(); + }; + var escAccel = new KeyboardAccelerator { Key = VirtualKey.Escape }; + escAccel.Invoked += (_, args) => + { + if (_isFullScreen) { args.Handled = true; ExitFullScreen(); } + }; + RootGrid.KeyboardAccelerators.Add(f11Accel); + RootGrid.KeyboardAccelerators.Add(escAccel); + RootGrid.KeyboardAcceleratorPlacementMode = KeyboardAcceleratorPlacementMode.Hidden; + // Explicit teardown: unsubscribe router events and reset surfaces so // the router's component subscriptions don't outlive the window. The // router holds back-references via event delegates; without this, a @@ -77,6 +97,7 @@ public A2UICanvasWindow(IActionSink actions, MediaResolver media, IOpenClawLogge Closed += (_, _) => { IsClosed = true; + ExitFullScreen(); try { _router.SurfaceCreated -= OnSurfaceCreated; } catch (Exception ex) { OpenClawTray.Services.Logger.Debug($"A2UICanvasWindow: unsubscribe SurfaceCreated failed: {ex.Message}"); } try { _router.SurfaceDeleted -= OnSurfaceDeleted; } @@ -338,6 +359,30 @@ public string GetStateSnapshot() return snapshot.ToJsonString(); } + // ── Fullscreen ────────────────────────────────────────────────────────── + + /// Toggle borderless fullscreen on F11. + public void ToggleFullScreen() + { + if (_isFullScreen) ExitFullScreen(); + else EnterFullScreen(); + } + + private void EnterFullScreen() + { + _isFullScreen = true; + try { AppWindow.SetPresenter(AppWindowPresenterKind.FullScreen); } + catch (Exception ex) { OpenClawTray.Services.Logger.Debug($"A2UICanvasWindow: EnterFullScreen failed: {ex.Message}"); } + } + + private void ExitFullScreen() + { + if (!_isFullScreen) return; + _isFullScreen = false; + try { AppWindow.SetPresenter(AppWindowPresenterKind.Default); } + catch (Exception ex) { OpenClawTray.Services.Logger.Debug($"A2UICanvasWindow: ExitFullScreen failed: {ex.Message}"); } + } + public void BringToFront(bool keepTopMost = false) { try diff --git a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs index 02f08666..15256a78 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs @@ -5,8 +5,10 @@ using System.Threading.Tasks; using System.Runtime.InteropServices; using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Input; using Microsoft.Web.WebView2.Core; using OpenClaw.Shared; using OpenClawTray.Helpers; @@ -14,6 +16,7 @@ using WinUIEx; using Windows.Foundation; using Windows.Storage.Streams; +using Windows.System; namespace OpenClawTray.Windows; @@ -39,6 +42,7 @@ public sealed partial class CanvasWindow : WindowEx private const uint SWP_SHOWWINDOW = 0x0040; private bool _isWebViewInitialized; + private bool _isFullScreen; private string? _pendingUrl; private string? _pendingHtml; private readonly TaskCompletionSource _webViewReadyTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -235,7 +239,30 @@ public CanvasWindow() this.SetIcon("Assets\\openclaw.ico"); _dispatcherQueue = DispatcherQueue; this.Closed += OnWindowClosed; - + + // F11 toggles borderless fullscreen; Escape exits it. + // KeyboardAccelerators on the root content get first-class handling + // when focus is in the XAML tree (title bar, toolbar, etc.). + // When focus is inside the WebView2, F11/Escape are also intercepted + // via an injected content script that posts bridge messages. + if (this.Content is FrameworkElement contentRoot) + { + var f11Accel = new KeyboardAccelerator { Key = VirtualKey.F11 }; + f11Accel.Invoked += (_, args) => + { + args.Handled = true; + ToggleFullScreen(); + }; + var escAccel = new KeyboardAccelerator { Key = VirtualKey.Escape }; + escAccel.Invoked += (_, args) => + { + if (_isFullScreen) { args.Handled = true; ExitFullScreen(); } + }; + contentRoot.KeyboardAccelerators.Add(f11Accel); + contentRoot.KeyboardAccelerators.Add(escAccel); + contentRoot.KeyboardAcceleratorPlacementMode = KeyboardAcceleratorPlacementMode.Hidden; + } + // Initialize WebView2 InitializeWebViewAsync(); } @@ -284,6 +311,24 @@ private async Task InitializeWebViewCoreAsync() CanvasWebView.CoreWebView2.Settings.IsStatusBarEnabled = false; CanvasWebView.CoreWebView2.Settings.AreDevToolsEnabled = false; + // Inject F11/Escape fullscreen bridge: intercepts these keys when + // WebView2 content has focus and routes them as bridge messages so + // the native window can toggle its presenter the same way the XAML + // keyboard accelerators do when focus is in the title bar. + await CanvasWebView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(""" + (function () { + document.addEventListener('keydown', function (e) { + if (!window.chrome || !window.chrome.webview) return; + if (e.key === 'F11') { + e.preventDefault(); + window.chrome.webview.postMessage(JSON.stringify({ type: 'fullscreen-toggle' })); + } else if (e.key === 'Escape') { + window.chrome.webview.postMessage(JSON.stringify({ type: 'fullscreen-exit' })); + } + }, true); + })(); + """); + // Wire the bidirectional native↔SPA bridge // SPA → native: window.chrome.webview.postMessage({ type, payload }) _webMessageReceivedHandler = (s, e) => @@ -297,6 +342,19 @@ private async Task InitializeWebViewCoreAsync() var msg = WebBridgeMessage.TryParse(e.WebMessageAsJson); if (msg != null) { + // Fullscreen control messages are handled natively and not + // forwarded to external bridge subscribers. + if (msg.Type == "fullscreen-toggle") + { + _dispatcherQueue?.TryEnqueue(ToggleFullScreen); + return; + } + if (msg.Type == "fullscreen-exit") + { + _dispatcherQueue?.TryEnqueue(ExitFullScreen); + return; + } + Logger.Debug($"[Canvas] bridge message from SPA, type={SanitizeBridgeLogValue(msg.Type)}"); BridgeMessageReceived?.Invoke(this, msg); } @@ -473,6 +531,7 @@ private void OnCanvasFileChanged(object sender, FileSystemEventArgs e) private void OnWindowClosed(object sender, WindowEventArgs args) { IsClosed = true; + ExitFullScreen(); _gatewayToken = null; if (CanvasWebView.CoreWebView2 != null) @@ -659,6 +718,30 @@ public void BringToFront(bool keepTopMost) } } + // ── Fullscreen ────────────────────────────────────────────────────────── + + /// Toggle borderless fullscreen on F11. + public void ToggleFullScreen() + { + if (_isFullScreen) ExitFullScreen(); + else EnterFullScreen(); + } + + private void EnterFullScreen() + { + _isFullScreen = true; + try { AppWindow.SetPresenter(AppWindowPresenterKind.FullScreen); } + catch (Exception ex) { Logger.Debug($"[Canvas] EnterFullScreen failed: {ex.Message}"); } + } + + private void ExitFullScreen() + { + if (!_isFullScreen) return; + _isFullScreen = false; + try { AppWindow.SetPresenter(AppWindowPresenterKind.Default); } + catch (Exception ex) { Logger.Debug($"[Canvas] ExitFullScreen failed: {ex.Message}"); } + } + public async Task EnsureA2UIHostAsync(string url) { await EnsureWebViewReadyAsync();