From cb4cb3bc48cfc94fd6ae3484628b253bc4ba7944 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Sat, 15 Aug 2026 03:36:07 -0400 Subject: [PATCH 1/4] GUI: suppress input on text focus, not view focus A focused CEF view suppressed all scripted keybinds and voice capture whether or not it took typed input, so a HUD or menu froze noclip mid-flight and muted the microphone with no indication. The precise signal already existed and was never aggregated: the render process reports form-control focus through OnFocusedNodeChanged, and scripts already receive it as browserInputFocusChange. Track it per view and expose Manager::IsAnyTextInputFocused, then gate on that. The original intent -- push-to-talk defaults to a letter key, so typing must not go out over voice -- is preserved and tightened, since a chat box's reports editable focus directly. Games gating on IsAnyViewFocused for this purpose should move to IsAnyTextInputFocused. --- code/framework/src/gui/manager.cpp | 10 ++++++++++ code/framework/src/gui/manager.h | 4 ++++ code/framework/src/gui/view.cpp | 4 ++++ code/framework/src/gui/view.h | 8 ++++++++ code/framework/src/integrations/client/instance.cpp | 4 +++- 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/code/framework/src/gui/manager.cpp b/code/framework/src/gui/manager.cpp index 50a37c689..60ba96ada 100644 --- a/code/framework/src/gui/manager.cpp +++ b/code/framework/src/gui/manager.cpp @@ -578,6 +578,16 @@ namespace Framework::GUI { } } + bool Manager::IsAnyTextInputFocused() const { + std::scoped_lock lock(_renderMutex); + for (const auto &view : _views) { + if (view->IsTextInputFocused()) { + return true; + } + } + return false; + } + bool Manager::IsAnyViewFocused() const { std::scoped_lock lock(_renderMutex); for (const auto &view : _views) { diff --git a/code/framework/src/gui/manager.h b/code/framework/src/gui/manager.h index ed556fa82..e65bf6ed2 100644 --- a/code/framework/src/gui/manager.h +++ b/code/framework/src/gui/manager.h @@ -81,6 +81,10 @@ namespace Framework::GUI { void CleanupViews(); bool IsAnyViewFocused() const; + + // Whether a page is actually taking typed input, as opposed to merely holding focus. + // The predicate input gating wants: a focused HUD must not eat keybinds or mute voice. + bool IsAnyTextInputFocused() const; bool IsAnyGCViewFocused() const; std::vector GetAllViews() const; diff --git a/code/framework/src/gui/view.cpp b/code/framework/src/gui/view.cpp index c09fe9ea2..3f9c9ebca 100644 --- a/code/framework/src/gui/view.cpp +++ b/code/framework/src/gui/view.cpp @@ -124,6 +124,10 @@ namespace Framework::GUI { _created = true; } + if (data.event == ViewEvent::InputFocusChange) { + _textInputFocused = data.focused; + } + // window object exists from main-frame load start; bind before anything talks to the page if (data.event == ViewEvent::LoadingStart && data.isMainFrame && _sdk && _browser) { (void)_sdk->Init(_browser); diff --git a/code/framework/src/gui/view.h b/code/framework/src/gui/view.h index 8d27a329c..14ebf7458 100644 --- a/code/framework/src/gui/view.h +++ b/code/framework/src/gui/view.h @@ -63,6 +63,7 @@ namespace Framework::GUI { bool _gpuAccelerated = false; bool _hasFocus = false; + bool _textInputFocused = false; int _x; int _y; int _z; @@ -109,6 +110,13 @@ namespace Framework::GUI { } } + // True while an editable element inside the page holds the caret. Distinct from HasFocus: + // a focused HUD captures no text, and this stays true for a view CEF types into without + // the framework's focus flag set (the chat box does exactly that). + bool IsTextInputFocused() const { + return _textInputFocused; + } + bool HasFocus() const { return _hasFocus; } diff --git a/code/framework/src/integrations/client/instance.cpp b/code/framework/src/integrations/client/instance.cpp index 8b2f04216..e8bec57e5 100644 --- a/code/framework/src/integrations/client/instance.cpp +++ b/code/framework/src/integrations/client/instance.cpp @@ -567,7 +567,9 @@ namespace Framework::Integrations::Client { // The chat box and web views belong to the framework, so it enforces this itself // rather than trusting every mod to remember. - _voiceClient.SetInputSuppressed(_chatBox.IsInputActive() || (_webManager && _webManager->IsAnyViewFocused())); + // Only actual text entry, not mere view focus: a focused HUD or menu must not mute the + // player. A page taking keystrokes reports it through ViewEvent::InputFocusChange. + _voiceClient.SetInputSuppressed(_chatBox.IsInputActive() || (_webManager && _webManager->IsAnyTextInputFocused())); // Speaker positions come from the replicated entity set, as the server's voice // router gets them: an owner GUID means a player-controlled entity. Done here so From c713761e186f40f35250a5b1abef21173bb657bf Mon Sep 17 00:00:00 2001 From: KHeartz Date: Sun, 6 Sep 2026 02:38:27 -0400 Subject: [PATCH 2/4] GUI: report editable focus and reset it per document CefDOMNode::IsEditable is the predicate: the form-control test counted buttons, checkboxes and selects as typing and missed contenteditable. Both the renderer's dedupe map and the view's flag reset when a main frame starts loading, since a cross-process navigation never reports the old caret going away. --- code/framework/src/gui/cef/renderer_app.cpp | 10 +++++++++- code/framework/src/gui/cef/renderer_app.h | 2 +- code/framework/src/gui/view.cpp | 6 ++++++ .../src/integrations/client/scripting/builtins/web.cpp | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/code/framework/src/gui/cef/renderer_app.cpp b/code/framework/src/gui/cef/renderer_app.cpp index 1db4347ac..6acd0da04 100644 --- a/code/framework/src/gui/cef/renderer_app.cpp +++ b/code/framework/src/gui/cef/renderer_app.cpp @@ -54,6 +54,12 @@ namespace Framework::GUI::CEF { CefRefPtr handler = new CallEventHandler(browser); CefRefPtr func = CefV8Value::CreateFunction("callEvent", handler); global->SetValue("callEvent", func, V8_PROPERTY_ATTRIBUTE_NONE); + + // Each document starts with nothing focused; without this the dedupe in + // OnFocusedNodeChanged would swallow the first focus after a same-process navigation. + if (frame && frame->IsMain()) { + _inputFocus[browser->GetIdentifier()] = false; + } } // The DOM is only reachable here, so the browser process cannot decide this for itself. @@ -62,7 +68,9 @@ namespace Framework::GUI::CEF { return; } - const bool typing = node && node->GetType() == CefDOMNode::Type::DOM_NODE_TYPE_ELEMENT && node->GetFormControlElementType() != CefDOMNode::FormControlType::DOM_FORM_CONTROL_TYPE_UNSUPPORTED; + // IsEditable: text inputs, textareas and contenteditable. A form-control test would count + // buttons, checkboxes and selects as typing, and miss rich-text editors. + const bool typing = node && node->IsEditable(); bool &previous = _inputFocus[browser->GetIdentifier()]; if (previous == typing) { diff --git a/code/framework/src/gui/cef/renderer_app.h b/code/framework/src/gui/cef/renderer_app.h index 414486114..07bf32b78 100644 --- a/code/framework/src/gui/cef/renderer_app.h +++ b/code/framework/src/gui/cef/renderer_app.h @@ -30,7 +30,7 @@ namespace Framework::GUI::CEF { class RendererApp final: public CefApp, public CefRenderProcessHandler { private: - // browser id -> form control focused; a renderer process can host several browsers + // browser id -> editable node focused; a renderer process can host several browsers std::unordered_map _inputFocus; public: diff --git a/code/framework/src/gui/view.cpp b/code/framework/src/gui/view.cpp index 3f9c9ebca..0ff4900fc 100644 --- a/code/framework/src/gui/view.cpp +++ b/code/framework/src/gui/view.cpp @@ -128,6 +128,12 @@ namespace Framework::GUI { _textInputFocused = data.focused; } + // A new document starts with nothing focused; a cross-process navigation never reports + // the old caret going away. + if (data.event == ViewEvent::LoadingStart && data.isMainFrame) { + _textInputFocused = false; + } + // window object exists from main-frame load start; bind before anything talks to the page if (data.event == ViewEvent::LoadingStart && data.isMainFrame && _sdk && _browser) { (void)_sdk->Init(_browser); diff --git a/code/framework/src/integrations/client/scripting/builtins/web.cpp b/code/framework/src/integrations/client/scripting/builtins/web.cpp index 97d7a7729..da128c7eb 100644 --- a/code/framework/src/integrations/client/scripting/builtins/web.cpp +++ b/code/framework/src/integrations/client/scripting/builtins/web.cpp @@ -369,7 +369,7 @@ namespace Framework::Integrations::Client::Scripting::Builtins { {{"cursor", "string", "CSS-style cursor name, or \"custom\" for shapes without one."}, {"cursorType", "number", "Raw CEF cursor type."}}, "Dispatched when an owned view's requested cursor shape changes."); describe(Framework::GUI::ViewEvent::Tooltip, "BrowserTooltipEvent", "A page wants to display a tooltip.", {{"text", "string", "Tooltip text; empty when the tooltip is dismissed."}}, "Dispatched when an owned view requests a tooltip; windowless rendering draws none, so the script must."); - describe(Framework::GUI::ViewEvent::InputFocusChange, "BrowserInputFocusChangeEvent", "A form control inside a page gained or lost focus.", {{"focused", "boolean", "True while the page holds keyboard input."}}, + describe(Framework::GUI::ViewEvent::InputFocusChange, "BrowserInputFocusChangeEvent", "An editable element inside a page gained or lost focus.", {{"focused", "boolean", "True while the page holds keyboard input."}}, "Dispatched when focus enters or leaves an editable element of an owned view; use it to stop routing keys to the game."); describe(Framework::GUI::ViewEvent::ResourceBlocked, "BrowserResourceBlockedEvent", "A web view refused a request.", {{"url", "string", "URL that was refused."}, {"domain", "string", "Host component of that URL, empty when unparsable."}, {"reason", "\"cross-origin\" | \"invalid-url\" | \"host-filter\" | \"foreign-event\"", "Why the request was refused."}}, From 6d34115da38720966e1148b637af85f82e233894 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Sun, 6 Sep 2026 02:38:28 -0400 Subject: [PATCH 3/4] GUI: count text focus only on focused, shown views A view receives keys only while HasFocus() && ShouldDisplay(), so a hidden or unfocused view's caret must not suppress input. The DOM flag itself is kept across Focus(false): the caret survives it and reports no transition when the view is refocused, so clearing it would unmute voice while typing into a reopened chat. --- code/framework/src/gui/manager.cpp | 2 +- code/framework/src/gui/manager.h | 5 +++-- code/framework/src/gui/view.h | 10 +++++----- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/code/framework/src/gui/manager.cpp b/code/framework/src/gui/manager.cpp index 60ba96ada..2559a2bc2 100644 --- a/code/framework/src/gui/manager.cpp +++ b/code/framework/src/gui/manager.cpp @@ -581,7 +581,7 @@ namespace Framework::GUI { bool Manager::IsAnyTextInputFocused() const { std::scoped_lock lock(_renderMutex); for (const auto &view : _views) { - if (view->IsTextInputFocused()) { + if (view->HasFocus() && view->ShouldDisplay() && view->IsTextInputFocused()) { return true; } } diff --git a/code/framework/src/gui/manager.h b/code/framework/src/gui/manager.h index e65bf6ed2..af5a53173 100644 --- a/code/framework/src/gui/manager.h +++ b/code/framework/src/gui/manager.h @@ -82,8 +82,9 @@ namespace Framework::GUI { void CleanupViews(); bool IsAnyViewFocused() const; - // Whether a page is actually taking typed input, as opposed to merely holding focus. - // The predicate input gating wants: a focused HUD must not eat keybinds or mute voice. + // Whether a shown, focused view has the caret in an editable node, as opposed to merely + // holding focus. The predicate input gating wants: a focused HUD must not eat keybinds or + // mute voice. bool IsAnyTextInputFocused() const; bool IsAnyGCViewFocused() const; diff --git a/code/framework/src/gui/view.h b/code/framework/src/gui/view.h index 14ebf7458..215ab511f 100644 --- a/code/framework/src/gui/view.h +++ b/code/framework/src/gui/view.h @@ -61,8 +61,8 @@ namespace Framework::GUI { // CPU renderer fallback std::vector _pixelData; - bool _gpuAccelerated = false; - bool _hasFocus = false; + bool _gpuAccelerated = false; + bool _hasFocus = false; bool _textInputFocused = false; int _x; int _y; @@ -110,9 +110,9 @@ namespace Framework::GUI { } } - // True while an editable element inside the page holds the caret. Distinct from HasFocus: - // a focused HUD captures no text, and this stays true for a view CEF types into without - // the framework's focus flag set (the chat box does exactly that). + // The DOM half of focus: an editable node holds the caret. Keys only reach a page while + // HasFocus() && ShouldDisplay(), so gate on all three. Not cleared by Focus(false): the DOM + // keeps its caret across it, and reports no transition when the view is refocused. bool IsTextInputFocused() const { return _textInputFocused; } From d3beaca000fa1a348916174d4edc4f67fc894bb2 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Sun, 6 Sep 2026 02:38:30 -0400 Subject: [PATCH 4/4] Client: gate local input on text focus, not view focus IsLocalInputAvailable is the gate behind scripted keybinds and the push-to-talk poll in every shipped mod; a focused HUD still froze both while only the voice suppression had moved. Suppression stays in UpdateNetworking, right before the voice update that consumes it. --- code/framework/src/integrations/client/instance.cpp | 10 +++++----- code/framework/src/voice/client/voice_client.h | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/code/framework/src/integrations/client/instance.cpp b/code/framework/src/integrations/client/instance.cpp index e8bec57e5..6d36b44d2 100644 --- a/code/framework/src/integrations/client/instance.cpp +++ b/code/framework/src/integrations/client/instance.cpp @@ -385,7 +385,8 @@ namespace Framework::Integrations::Client { if (!_chatBox.IsSessionActive() || _chatBox.IsInputActive()) { return false; } - if (_webManager && _webManager->IsAnyViewFocused()) { + // Text entry, not view focus: a focused HUD or menu leaves keybinds and push-to-talk alone. + if (_webManager && _webManager->IsAnyTextInputFocused()) { return false; } #ifdef _WIN32 @@ -565,10 +566,9 @@ namespace Framework::Integrations::Client { { FW_PROFILE_SCOPE_N("Client::Voice"); - // The chat box and web views belong to the framework, so it enforces this itself - // rather than trusting every mod to remember. - // Only actual text entry, not mere view focus: a focused HUD or menu must not mute the - // player. A page taking keystrokes reports it through ViewEvent::InputFocusChange. + // The chat box and web views belong to the framework, so it enforces this itself rather + // than trusting every mod to remember. Text entry only: a focused HUD or menu must not + // mute the player. _voiceClient.SetInputSuppressed(_chatBox.IsInputActive() || (_webManager && _webManager->IsAnyTextInputFocused())); // Speaker positions come from the replicated entity set, as the server's voice diff --git a/code/framework/src/voice/client/voice_client.h b/code/framework/src/voice/client/voice_client.h index af5fc5559..d936f505e 100644 --- a/code/framework/src/voice/client/voice_client.h +++ b/code/framework/src/voice/client/voice_client.h @@ -247,7 +247,7 @@ namespace Framework::Voice { void RemoveSpeaker(uint64_t speaker); // Blocks transmission regardless of push-to-talk, cutting the release delay short. Set by - // the client Instance while its chat box has the caret or a web view holds focus. + // the client Instance while its chat box or a web view's editable node has the caret. void SetInputSuppressed(bool suppressed); // The mod-owned half of the same block: window focus, a game menu, locked controls. Ored