diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b3f4f8e..592e2da 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -77,6 +77,7 @@ import { nuiCall } from '@/utils/nui' import { formatTimer } from '@/utils/clock' import { parsePhonePreferences } from '@/utils/preferences' import { getHairlinePixelStyle } from '@/utils/rendering' +import { isTextInputElement } from '@/utils/textInputFocus' import { isTrustedRootMessageSource } from '@/utils/windowMessages' import SpringboardView from '@/views/SpringboardView.vue' @@ -278,6 +279,7 @@ const isDevelopment = developmentParameters.get('apiBase')?.startsWith('/') === true const developmentLockScreenPreview = isDevelopment && developmentParameters.has('lockScreenPreview') +let textInputFocused = false const phone = usePhoneStore() const account = useAccountStore() @@ -1404,7 +1406,29 @@ function unlockCamera(): void { window.setTimeout(() => void router.push('/apps/camera'), 0) } +function updateTextInputFocus(active: boolean): void { + if (textInputFocused === active) return + textInputFocused = active + void nuiCall('ui:input-focus', { active }) +} + +function onFocusIn(event: FocusEvent): void { + const target = event.target + updateTextInputFocus( + target instanceof HTMLElement && isTextInputElement(target), + ) +} + +function onFocusOut(event: FocusEvent): void { + const nextTarget = event.relatedTarget + updateTextInputFocus( + nextTarget instanceof HTMLElement && isTextInputElement(nextTarget), + ) +} + onMounted(() => { + document.addEventListener('focusin', onFocusIn) + document.addEventListener('focusout', onFocusOut) window.addEventListener('message', onMessage) window.addEventListener('keydown', onKeydown) window.addEventListener('resize', updateViewportScale) @@ -1510,6 +1534,7 @@ watch( (isOpen) => { if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) if (!isOpen) { + updateTextInputFocus(false) cancelUnlockedPhoneDataLoad() appStore.cancelPendingInstalls() activitySuspended.value = false @@ -1567,6 +1592,7 @@ watch( ) onBeforeUnmount(() => { + updateTextInputFocus(false) cancelUnlockedPhoneDataLoad() weather.stop() if (clockTicker) clearInterval(clockTicker) @@ -1581,6 +1607,8 @@ onBeforeUnmount(() => { window.removeEventListener('message', onMessage) window.removeEventListener('keydown', onKeydown) window.removeEventListener('resize', updateViewportScale) + document.removeEventListener('focusin', onFocusIn) + document.removeEventListener('focusout', onFocusOut) systemColorScheme.removeEventListener('change', onSystemColorSchemeChange) }) diff --git a/frontend/src/utils/textInputFocus.test.ts b/frontend/src/utils/textInputFocus.test.ts new file mode 100644 index 0000000..e8aa691 --- /dev/null +++ b/frontend/src/utils/textInputFocus.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { isTextInputElement } from '@/utils/textInputFocus' + +function element( + tagName: string, + attributes: Record = {}, + isContentEditable = false, +) { + return { + getAttribute(name: string) { + return attributes[name] ?? null + }, + isContentEditable, + tagName, + } +} + +describe('text input focus', () => { + it('recognizes fields that accept typed text', () => { + expect(isTextInputElement(element('INPUT'))).toBe(true) + expect(isTextInputElement(element('INPUT', { type: 'number' }))).toBe(true) + expect(isTextInputElement(element('TEXTAREA'))).toBe(true) + expect(isTextInputElement(element('DIV', {}, true))).toBe(true) + expect(isTextInputElement(element('DIV', { role: 'textbox' }))).toBe(true) + }) + + it('ignores non-text and read-only controls', () => { + expect(isTextInputElement(element('INPUT', { type: 'checkbox' }))).toBe( + false, + ) + expect(isTextInputElement(element('INPUT', { type: 'range' }))).toBe(false) + expect(isTextInputElement(element('INPUT', { readonly: '' }))).toBe(false) + expect(isTextInputElement(element('BUTTON'))).toBe(false) + }) +}) diff --git a/frontend/src/utils/textInputFocus.ts b/frontend/src/utils/textInputFocus.ts new file mode 100644 index 0000000..d1ed08f --- /dev/null +++ b/frontend/src/utils/textInputFocus.ts @@ -0,0 +1,34 @@ +const nonTextInputTypes = new Set([ + 'button', + 'checkbox', + 'color', + 'file', + 'hidden', + 'image', + 'radio', + 'range', + 'reset', + 'submit', +]) + +type FocusableElement = Pick< + HTMLElement, + 'getAttribute' | 'isContentEditable' | 'tagName' +> + +export function isTextInputElement(element: FocusableElement): boolean { + if ( + element.getAttribute('readonly') !== null || + element.getAttribute('aria-readonly') === 'true' + ) { + return false + } + if (element.isContentEditable || element.getAttribute('role') === 'textbox') { + return true + } + if (element.tagName === 'TEXTAREA') return true + if (element.tagName !== 'INPUT') return false + + const inputType = element.getAttribute('type')?.toLowerCase() ?? 'text' + return !nonTextInputTypes.has(inputType) +} diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 2dd3530..328056d 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -22,6 +22,7 @@ const lifecycleEndpoints = new Set([ 'device:notification-open', 'notification:focus', 'sim:picker-close', + 'ui:input-focus', 'ui:opened', 'ui:ready', ]) diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index d55b427..ec2c6ee 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -1156,6 +1156,7 @@ async function main() { 'device:notification-open', 'notification:focus', 'sim:picker-close', + 'ui:input-focus', 'ui:opened', 'ui:ready', ] diff --git a/sky_phone/source/client/focus.lua b/sky_phone/source/client/focus.lua index 01a3bd3..1a3b582 100644 --- a/sky_phone/source/client/focus.lua +++ b/sky_phone/source/client/focus.lua @@ -1,6 +1,6 @@ SkyPhoneFocus = {} -local blocked_phone_controls = { 19, 24, 140, 141, 142, 257, 263, 264 } +local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 } local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 } local focused_control_groups = { 0, 1, 2 } @@ -39,10 +39,10 @@ function SkyPhoneFocus.Resolve(state) or state.payphone_focus or state.sim_picker_open or (state.camera_active and state.camera_nui_focused) - local cursor = focused and not (game_input and state.cursor_disabled) + local cursor = focused return { - block_game = cursor, - block_look = game_input and not state.cursor_disabled, + block_game = cursor and (not game_input or state.text_input_focused), + block_look = game_input, cursor = cursor, focused = focused, game_input = game_input, diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 72824fc..074fe4e 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -15,7 +15,7 @@ local activity_suspended = false local phone_block_game = false local phone_block_look = false local phone_game_input = false -local phone_cursor_disabled = false +local phone_text_input_focused = false SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsOpen", function() return is_open @@ -327,20 +327,17 @@ local function update_nui_focus() call_focus = call_focus, camera_active = camera_active, camera_nui_focused = camera_nui_focused, - cursor_disabled = phone_cursor_disabled, is_open = is_open, notification_focus = notification_focus, payphone_focus = payphone_focus, sim_picker_open = sim_picker_open, + text_input_focused = phone_text_input_focused, }) SetNuiFocus(focus.focused, focus.cursor) SetNuiFocusKeepInput(focus.keep_input) phone_block_game = focus.block_game == true phone_block_look = focus.block_look == true phone_game_input = focus.game_input - if not phone_game_input and not phone_block_game then - phone_cursor_disabled = false - end TriggerEvent("sky_phone:client:cameraFocusApplied", { active = camera_active, cursor = focus.cursor, @@ -357,10 +354,6 @@ CreateThread(function() else SkyPhoneFocus.ApplyGameInputControls(phone_block_look) end - if IsDisabledControlJustPressed(0, 19) then - phone_cursor_disabled = not phone_cursor_disabled - update_nui_focus() - end Wait(0) else Wait(250) @@ -418,6 +411,7 @@ local function close_phone(close_device_session) open_requested = false call_focus = false activity_suspended = false + phone_text_input_focused = false TriggerEvent("sky_phone:animation:phone", false) is_open = false if was_open then @@ -500,6 +494,7 @@ RegisterNUICallback("ui:ready", function(data, cb) -- Browser state is recreated on a CEF reload. A notification focus claim -- cannot survive unless its notification is replayed as part of this handshake. notification_focus = false + phone_text_input_focused = false Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true }) SkyPhoneApps.SendCatalog() if open_requested and device_payload then @@ -555,6 +550,16 @@ RegisterNUICallback("ui:opened", function(data, cb) cb({ success = true }) end) +RegisterNUICallback("ui:input-focus", function(data, cb) + if type(data) ~= "table" or type(data.active) ~= "boolean" then + cb({ success = false, error = "invalid_request" }) + return + end + phone_text_input_focused = data.active and (is_open or open_requested) + update_nui_focus() + cb({ success = true }) +end) + RegisterNUICallback("close", function(data, cb) if type(data) ~= "table" then cb({ success = false, error = "invalid_request" }) @@ -1086,7 +1091,7 @@ AddEventHandler("onResourceStop", function(resource_name) active_call_payload = nil activity_suspended = false phone_game_input = false - phone_cursor_disabled = false + phone_text_input_focused = false SetNuiFocusKeepInput(false) SetNuiFocus(false, false) diff --git a/tests/client_focus.lua b/tests/client_focus.lua index 081434f..5b883d9 100644 --- a/tests/client_focus.lua +++ b/tests/client_focus.lua @@ -29,11 +29,11 @@ local function resolve(overrides) call_focus = false, camera_active = false, camera_nui_focused = true, - cursor_disabled = false, is_open = false, notification_focus = false, payphone_focus = false, sim_picker_open = false, + text_input_focused = false, } for key, value in pairs(overrides or {}) do state[key] = value @@ -71,22 +71,23 @@ assert( and movable_phone.focused and movable_phone.keep_input and movable_phone.game_input - and movable_phone.block_game, - "an open phone with the cursor active must block GTA hotkeys while keeping NUI input" + and not movable_phone.block_game + and movable_phone.block_look, + "an open phone must allow movement without hiding the NUI cursor" ) -local cursor_disabled_phone = resolve({ +local typing_phone = resolve({ allow_movement = true, - cursor_disabled = true, is_open = true, + text_input_focused = true, }) assert( - not cursor_disabled_phone.cursor - and cursor_disabled_phone.focused - and cursor_disabled_phone.keep_input - and cursor_disabled_phone.game_input - and not cursor_disabled_phone.block_game, - "toggling Alt must release the NUI cursor while preserving phone and GTA input" + typing_phone.cursor + and typing_phone.focused + and typing_phone.keep_input + and typing_phone.game_input + and typing_phone.block_game, + "a focused phone text input must block GTA controls without hiding the NUI cursor" ) SkyPhoneFocus.ApplyFocusedControls() @@ -98,9 +99,10 @@ assert(firing_disabled, "focused phone cursor must block attacks while typing") all_controls_disabled = {} firing_disabled = false SkyPhoneFocus.ApplyGameInputControls(true) -for _, control in ipairs({ 19, 24, 140, 141, 142, 257, 263, 264 }) do +for _, control in ipairs({ 24, 140, 141, 142, 257, 263, 264 }) do assert(disabled_controls[control], ("phone control %d must remain disabled"):format(control)) end +assert(not disabled_controls[19], "Alt must remain available while no phone text input is focused") for _, control in ipairs({ 1, 2, 3, 4, 5, 6 }) do assert(disabled_controls[control], ("look control %d must be disabled while the phone cursor is active"):format(control)) end @@ -111,8 +113,8 @@ assert(firing_disabled, "player attacks must remain disabled while the phone is disabled_controls = {} firing_disabled = false SkyPhoneFocus.ApplyGameInputControls(false) -assert(not disabled_controls[1] and not disabled_controls[2], "Alt cursor toggle must restore camera look") -assert(firing_disabled, "player attacks must remain disabled after the cursor is toggled off") +assert(not disabled_controls[1] and not disabled_controls[2], "camera passthrough must preserve camera look") +assert(firing_disabled, "player attacks must remain disabled during camera passthrough") local movable_notification = resolve({ allow_movement = true, notification_focus = true }) assert(