Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions native/shared/src/ffi_core/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions native/shared/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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];
Expand Down
4 changes: 4 additions & 0 deletions native/windows/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
169 changes: 161 additions & 8 deletions native/windows/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ mod win32 {
use raw_window_handle::{RawWindowHandle, Win32WindowHandle, RawDisplayHandle, WindowsDisplayHandle};

static mut HWND_GLOBAL: Option<HWND> = 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<HWND> {
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 };
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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<u16> = 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); }

Expand All @@ -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<u16> = 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<pattern>\0All files\0*.*\0\0".
let pattern = if filter.is_empty() { "*.*" } else { filter };
let mut filter_w: Vec<u16> = 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<u16> = 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::<OPENFILENAMEW>() 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 }

Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,13 @@
],
"returns": "f64"
},
{
"name": "bloom_is_key_repeated",
"params": [
"f64"
],
"returns": "f64"
},
{
"name": "bloom_is_key_down",
"params": [
Expand Down
11 changes: 11 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down
Loading