From d1249ec601c1f3fcdf791b52f8deae62adc8a10f Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 17 Jul 2026 19:47:36 +0200 Subject: [PATCH] feat(input/platform): isKeyRepeated edge + real Windows clipboard, file dialogs, and window title; unloadModel TS wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isKeyRepeated: OS key auto-repeat surfaced as its OWN one-frame edge (new FFI bloom_is_key_repeated + manifest entry — consumers must clear .perry-cache). isKeyPressed stays initial-press-only, so a held jump key never machine-guns; caret navigation is the consumer. Windows queues repeats off lParam bit 30 in both window procs. - Windows clipboard was a STUB: bloom_get/set_clipboard_text now speak CF_UNICODETEXT for real. So were the file dialogs — the editor's Open/Save buttons have silently done nothing on Windows ever — GetOpenFileNameW/GetSaveFileNameW now run with OFN_NOCHANGEDIR (load-bearing: common dialogs change the process CWD by default, which would break every relative asset path afterward). So was setWindowTitle (read the string, discarded it) — SetWindowTextW now. - unloadModel: the FFI existed for years with no TS wrapper, so nothing could ever call it (the root index even re-exported the nonexistent name). Wrapper added; long-lived tools can finally free models. Verified live in the editor: paste into a text field from the system clipboard, filtered outliner, real window title. 158 editor tests + ui-smoke green. Claude-Session: https://claude.ai/code/session_01PmL9WgNMkAgvpYSHEZga8K --- native/shared/src/ffi_core/input.rs | 10 ++ native/shared/src/input.rs | 24 ++++ native/windows/Cargo.toml | 4 + native/windows/src/lib.rs | 169 ++++++++++++++++++++++++++-- package.json | 7 ++ src/core/index.ts | 11 ++ src/index.ts | 2 +- src/models/index.ts | 10 ++ 8 files changed, 228 insertions(+), 9 deletions(-) diff --git a/native/shared/src/ffi_core/input.rs b/native/shared/src/ffi_core/input.rs index 27ce417..dcf6fff 100644 --- a/native/shared/src/ffi_core/input.rs +++ b/native/shared/src/ffi_core/input.rs @@ -34,6 +34,16 @@ macro_rules! __bloom_ffi_input { }) } + // bloom_is_key_repeated — OS auto-repeat as its own edge. isKeyPressed + // stays initial-press-only (a held jump key must not machine-gun); + // caret navigation in text fields is the consumer. + #[no_mangle] + pub extern "C" fn bloom_is_key_repeated(key: f64) -> f64 { + $crate::ffi::guard("bloom_is_key_repeated", move || { + if engine().input.is_key_repeated(key as usize) { 1.0 } else { 0.0 } + }) + } + // bloom_get_mouse_x [source: macos] #[no_mangle] pub extern "C" fn bloom_get_mouse_x() -> f64 { diff --git a/native/shared/src/input.rs b/native/shared/src/input.rs index fb091de..41caef3 100644 --- a/native/shared/src/input.rs +++ b/native/shared/src/input.rs @@ -88,6 +88,14 @@ pub struct InputState { // chance to poll. Mark the slot here instead and clear at end_frame, so a // fast tap is still visible for one full frame. touch_pending_release: [bool; MAX_TOUCH_POINTS], + + // OS key auto-repeat, surfaced as its OWN edge (isKeyRepeated) so that + // isKeyPressed semantics never change for games — a held jump key must + // not machine-gun. Text/caret navigation (arrows, Home/End, Delete) is + // what consumes these. The platform layer queues repeats as they arrive; + // begin_frame publishes them for exactly one frame. + keys_repeated: [bool; MAX_KEYS], + repeat_pending: [bool; MAX_KEYS], } impl InputState { @@ -130,9 +138,23 @@ impl InputState { touch_points: [EMPTY_TOUCH; MAX_TOUCH_POINTS], touch_count: 0, touch_pending_release: [false; MAX_TOUCH_POINTS], + keys_repeated: [false; MAX_KEYS], + repeat_pending: [false; MAX_KEYS], + } + } + + /// Platform layer: an OS auto-repeat arrived for `key`. Published as a + /// one-frame isKeyRepeated edge by the next begin_frame. + pub fn queue_key_repeat(&mut self, key: usize) { + if key < MAX_KEYS { + self.repeat_pending[key] = true; } } + pub fn is_key_repeated(&self, key: usize) -> bool { + key < MAX_KEYS && self.keys_repeated[key] + } + pub fn begin_frame(&mut self) { // Apply injected keys BEFORE the edge computation below, so an injection // made during the previous frame reads exactly like a real key press: @@ -160,6 +182,8 @@ impl InputState { for i in 0..MAX_KEYS { self.keys_pressed[i] = self.keys_down[i] && !self.prev_keys_down[i]; self.keys_released[i] = !self.keys_down[i] && self.prev_keys_down[i]; + self.keys_repeated[i] = self.repeat_pending[i]; + self.repeat_pending[i] = false; } for i in 0..MAX_MOUSE_BUTTONS { self.mouse_pressed[i] = self.mouse_down[i] && !self.prev_mouse_down[i]; diff --git a/native/windows/Cargo.toml b/native/windows/Cargo.toml index fe83f95..795a8e8 100644 --- a/native/windows/Cargo.toml +++ b/native/windows/Cargo.toml @@ -51,7 +51,11 @@ windows = { version = "0.58", features = [ "Win32_UI_WindowsAndMessaging", "Win32_UI_HiDpi", "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Controls_Dialogs", "Win32_Graphics_Gdi", + "Win32_System_DataExchange", + "Win32_System_Memory", + "Win32_System_Ole", "Win32_System_LibraryLoader", "Win32_System_Diagnostics_Debug", "Win32_System_Kernel", diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index b8c77f0..d47263f 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -202,6 +202,12 @@ mod win32 { use raw_window_handle::{RawWindowHandle, Win32WindowHandle, RawDisplayHandle, WindowsDisplayHandle}; static mut HWND_GLOBAL: Option = None; + + /// The Bloom-owned top-level window, for platform features that need an + /// owner (dialogs, SetWindowTextW, clipboard). None in embedded mode. + pub fn main_hwnd() -> Option { + unsafe { HWND_GLOBAL } + } static mut IS_FULLSCREEN: bool = false; static mut WINDOWED_STYLE: u32 = 0; static mut WINDOWED_RECT: RECT = RECT { left: 0, top: 0, right: 0, bottom: 0 }; @@ -270,6 +276,12 @@ mod win32 { if bloom_key > 0 { if let Some(eng) = ENGINE.get_mut() { eng.input.set_key_down(bloom_key); + // lParam bit 30 = previous key state: 1 means this is + // an OS auto-repeat, surfaced as its own isKeyRepeated + // edge (isKeyPressed stays initial-press-only). + if (lparam.0 >> 30) & 1 == 1 { + eng.input.queue_key_repeat(bloom_key); + } } } // F12 — native screenshot hotkey. Perry currently drops @@ -527,7 +539,12 @@ mod win32 { } WM_KEYDOWN => { let k = map_keycode(resolve_modifier_vk(wparam.0 as u32, lparam.0)); - if k > 0 { if let Some(eng) = ENGINE.get_mut() { eng.input.set_key_down(k); } } + if k > 0 { + if let Some(eng) = ENGINE.get_mut() { + eng.input.set_key_down(k); + if (lparam.0 >> 30) & 1 == 1 { eng.input.queue_key_repeat(k); } + } + } } WM_KEYUP => { let k = map_keycode(resolve_modifier_vk(wparam.0 as u32, lparam.0)); @@ -1220,7 +1237,16 @@ pub extern "C" fn bloom_toggle_fullscreen() { win32::toggle_fullscreen(); } #[no_mangle] -pub extern "C" fn bloom_set_window_title(title_ptr: *const u8) { let _ = str_from_header(title_ptr); } +pub extern "C" fn bloom_set_window_title(title_ptr: *const u8) { + // Real since 2026-07-17 — the stub read the string and discarded it. + use windows::Win32::UI::WindowsAndMessaging::SetWindowTextW; + use windows::core::PCWSTR; + let title = str_from_header(title_ptr); + if let Some(hwnd) = win32::main_hwnd() { + let wide: Vec = title.encode_utf16().chain(std::iter::once(0)).collect(); + unsafe { let _ = SetWindowTextW(hwnd, PCWSTR(wide.as_ptr())); } + } +} #[no_mangle] pub extern "C" fn bloom_set_window_icon(path_ptr: *const u8) { let _ = str_from_header(path_ptr); } @@ -1237,17 +1263,144 @@ pub extern "C" fn bloom_enable_cursor() { unsafe { while windows::Win32::UI::WindowsAndMessaging::ShowCursor(true) < 0 {} } } -// E4: Clipboard (stub on this platform) +// E4: Clipboard — real Win32 implementation (was a stub until 2026-07-17, +// so paste in the editor's text fields silently never worked on Windows). #[no_mangle] -pub extern "C" fn bloom_set_clipboard_text(_text_ptr: *const u8) {} +pub extern "C" fn bloom_set_clipboard_text(text_ptr: *const u8) { + use windows::Win32::System::DataExchange::{ + OpenClipboard, CloseClipboard, EmptyClipboard, SetClipboardData, + }; + use windows::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE}; + use windows::Win32::System::Ole::CF_UNICODETEXT; + use windows::Win32::Foundation::{HANDLE, HWND}; + + let text = str_from_header(text_ptr); + unsafe { + let owner = win32::main_hwnd().unwrap_or(HWND(std::ptr::null_mut())); + if OpenClipboard(owner).is_err() { + return; + } + let _ = EmptyClipboard(); + let wide: Vec = text.encode_utf16().chain(std::iter::once(0)).collect(); + if let Ok(hmem) = GlobalAlloc(GMEM_MOVEABLE, wide.len() * 2) { + let dst = GlobalLock(hmem) as *mut u16; + if !dst.is_null() { + std::ptr::copy_nonoverlapping(wide.as_ptr(), dst, wide.len()); + let _ = GlobalUnlock(hmem); + // The system owns the memory on success; on failure we leak a + // small block rather than double-free. + let _ = SetClipboardData(CF_UNICODETEXT.0 as u32, HANDLE(hmem.0)); + } + } + let _ = CloseClipboard(); + } +} + #[no_mangle] -pub extern "C" fn bloom_get_clipboard_text() -> *const u8 { std::ptr::null() } +pub extern "C" fn bloom_get_clipboard_text() -> *const u8 { + use windows::Win32::System::DataExchange::{OpenClipboard, CloseClipboard, GetClipboardData}; + use windows::Win32::System::Memory::{GlobalLock, GlobalUnlock}; + use windows::Win32::System::Ole::CF_UNICODETEXT; + use windows::Win32::Foundation::{HGLOBAL, HWND}; + + unsafe { + let owner = win32::main_hwnd().unwrap_or(HWND(std::ptr::null_mut())); + if OpenClipboard(owner).is_err() { + return alloc_perry_string(""); + } + let mut out = String::new(); + if let Ok(handle) = GetClipboardData(CF_UNICODETEXT.0 as u32) { + let hglobal = HGLOBAL(handle.0); + let ptr = GlobalLock(hglobal) as *const u16; + if !ptr.is_null() { + let mut len = 0usize; + while *ptr.add(len) != 0 { + len += 1; + } + out = String::from_utf16_lossy(std::slice::from_raw_parts(ptr, len)); + let _ = GlobalUnlock(hglobal); + } + } + let _ = CloseClipboard(); + alloc_perry_string(&out) + } +} + +// E5b: File dialogs — real Win32 implementation (stubs until 2026-07-17: the +// editor's Open/Save buttons silently did nothing on Windows). The filter +// argument is a simple pattern like "*.world.json"; empty means all files. +// OFN_NOCHANGEDIR is load-bearing: the common dialogs change the process CWD +// by default, which would break every relative asset path afterward. +#[cfg(windows)] +fn run_file_dialog(filter: &str, title: &str, save: bool, default_name: &str) -> String { + use windows::Win32::UI::Controls::Dialogs::{ + GetOpenFileNameW, GetSaveFileNameW, OPENFILENAMEW, + OFN_FILEMUSTEXIST, OFN_PATHMUSTEXIST, OFN_NOCHANGEDIR, OFN_OVERWRITEPROMPT, + }; + use windows::core::{PCWSTR, PWSTR}; + + // Filter block: "Matching files\0\0All files\0*.*\0\0". + let pattern = if filter.is_empty() { "*.*" } else { filter }; + let mut filter_w: Vec = Vec::new(); + filter_w.extend("Matching files".encode_utf16()); + filter_w.push(0); + filter_w.extend(pattern.encode_utf16()); + filter_w.push(0); + filter_w.extend("All files".encode_utf16()); + filter_w.push(0); + filter_w.extend("*.*".encode_utf16()); + filter_w.push(0); + filter_w.push(0); + + let title_w: Vec = title.encode_utf16().chain(std::iter::once(0)).collect(); + + let mut file_buf = [0u16; 4096]; + if save && !default_name.is_empty() { + for (i, c) in default_name.encode_utf16().take(4000).enumerate() { + file_buf[i] = c; + } + } + + let mut ofn = OPENFILENAMEW::default(); + ofn.lStructSize = std::mem::size_of::() as u32; + ofn.hwndOwner = win32::main_hwnd().unwrap_or_default(); + ofn.lpstrFilter = PCWSTR(filter_w.as_ptr()); + ofn.lpstrFile = PWSTR(file_buf.as_mut_ptr()); + ofn.nMaxFile = file_buf.len() as u32; + if !title.is_empty() { + ofn.lpstrTitle = PCWSTR(title_w.as_ptr()); + } + ofn.Flags = if save { + OFN_OVERWRITEPROMPT | OFN_NOCHANGEDIR + } else { + OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR + }; + + let ok = unsafe { + if save { GetSaveFileNameW(&mut ofn) } else { GetOpenFileNameW(&mut ofn) } + }; + if !ok.as_bool() { + return String::new(); + } + let len = file_buf.iter().position(|&c| c == 0).unwrap_or(0); + String::from_utf16_lossy(&file_buf[..len]) +} -// E5b: File dialogs (stub on this platform) #[no_mangle] -pub extern "C" fn bloom_open_file_dialog(_filter_ptr: *const u8, _title_ptr: *const u8) -> *const u8 { std::ptr::null() } +pub extern "C" fn bloom_open_file_dialog(filter_ptr: *const u8, title_ptr: *const u8) -> *const u8 { + let filter = str_from_header(filter_ptr); + let title = str_from_header(title_ptr); + let path = run_file_dialog(&filter, &title, false, ""); + alloc_perry_string(&path) +} + #[no_mangle] -pub extern "C" fn bloom_save_file_dialog(_default_name_ptr: *const u8, _title_ptr: *const u8) -> *const u8 { std::ptr::null() } +pub extern "C" fn bloom_save_file_dialog(default_name_ptr: *const u8, title_ptr: *const u8) -> *const u8 { + let default_name = str_from_header(default_name_ptr); + let title = str_from_header(title_ptr); + let path = run_file_dialog("", &title, true, &default_name); + alloc_perry_string(&path) +} #[no_mangle] pub extern "C" fn bloom_get_platform() -> f64 { 3.0 } diff --git a/package.json b/package.json index 386509b..45831e5 100644 --- a/package.json +++ b/package.json @@ -222,6 +222,13 @@ ], "returns": "f64" }, + { + "name": "bloom_is_key_repeated", + "params": [ + "f64" + ], + "returns": "f64" + }, { "name": "bloom_is_key_down", "params": [ diff --git a/src/core/index.ts b/src/core/index.ts index 0664cf5..1fa8862 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -106,6 +106,7 @@ declare function bloom_get_fps(): number; declare function bloom_get_screen_width(): number; declare function bloom_get_screen_height(): number; declare function bloom_is_key_pressed(key: number): number; +declare function bloom_is_key_repeated(key: number): number; declare function bloom_is_key_down(key: number): number; declare function bloom_is_key_released(key: number): number; declare function bloom_get_mouse_x(): number; @@ -809,6 +810,16 @@ export function isKeyPressed(key: number): boolean { return bloom_is_key_pressed(key) !== 0; } +/** + * True for one frame each time the OS auto-repeats a held key. A SEPARATE + * edge from isKeyPressed (which stays initial-press-only, so a held jump key + * never machine-guns). Use `isKeyPressed(k) || isKeyRepeated(k)` for caret + * navigation and other hold-to-repeat UI. + */ +export function isKeyRepeated(key: number): boolean { + return bloom_is_key_repeated(key) !== 0; +} + export function isKeyDown(key: number): boolean { return bloom_is_key_down(key) !== 0; } diff --git a/src/index.ts b/src/index.ts index 3f2066e..feefb05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ export { beginDrawing, endDrawing, takeScreenshot, clearBackground, setEnvClearFromHdr, setTargetFPS, getDeltaTime, getFPS, getTime, getScreenWidth, getScreenHeight, - isKeyPressed, isKeyDown, isKeyReleased, + isKeyPressed, isKeyDown, isKeyReleased, isKeyRepeated, getMouseX, getMouseY, isMouseButtonPressed, isMouseButtonDown, isMouseButtonReleased, getMousePosition, getTouchPosition, beginMode2D, endMode2D, beginMode3D, endMode3D, diff --git a/src/models/index.ts b/src/models/index.ts index e5b6b66..66e4b78 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -3,6 +3,7 @@ import { Color, Model, Vec3, Mat4, BoundingBox } from '../core/types'; // FFI declarations declare function bloom_load_model(path: number): number; +declare function bloom_unload_model(handle: number): void; declare function bloom_draw_model(handle: number, x: number, y: number, z: number, scale: number, r: number, g: number, b: number, a: number): void; declare function bloom_draw_model_rotated(handle: number, x: number, y: number, z: number, scale: number, rotY: number, colorPackedArgb: number): void; declare function bloom_draw_model_transform16( @@ -145,6 +146,15 @@ function parseOBJ(text: string): { vertices: number[]; indices: number[] } | nul declare function bloom_read_file(path: number): number; +/** + * Free a loaded model's CPU + GPU resources. The FFI existed for years with + * no TS wrapper, so nothing could ever call it — long-lived tools (the world + * editor's project switching) leaked every model. + */ +export function unloadModel(model: Model): void { + bloom_unload_model(model.handle); +} + export function loadModel(path: string): Model { // Check for OBJ format const pathLower = (path as string).toLowerCase();