From 6426a8cac222294d40f56c9b638b57caf07f06ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E5=BA=86=E4=B8=B0?= Date: Sun, 30 Nov 2025 22:21:13 +0800 Subject: [PATCH 1/2] feat(windows): Add IME support for CJK text input - Fix IME initialization by moving ShowWindow() after WGL context creation - Add window::set_ime_position(x, y) API for IME candidate window positioning - Add window::set_ime_enabled(enabled) API to toggle IME for text input vs game controls - Handle WM_IME_COMPOSITION, WM_IME_STARTCOMPOSITION, WM_IME_ENDCOMPOSITION messages - Properly manage IME context on window focus changes - Add placeholder implementations for Linux, macOS, iOS, and Android - Add ime_test.rs example demonstrating IME input with text rendering This enables proper Chinese/Japanese/Korean text input on Windows, which was previously broken due to IME not initializing correctly when the window was shown before the OpenGL context was created. --- Cargo.toml | 2 + examples/ime_test.rs | 411 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 41 ++++ src/native.rs | 2 + src/native/android.rs | 6 + src/native/ios.rs | 13 +- src/native/linux_x11.rs | 6 + src/native/macos.rs | 10 +- src/native/windows.rs | 325 +++++++++++++++++++++++++++++-- 9 files changed, 793 insertions(+), 23 deletions(-) create mode 100644 examples/ime_test.rs diff --git a/Cargo.toml b/Cargo.toml index 77a15f4d..224bebe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ winapi = { version = "0.3", features = [ "winbase", "hidusage", "shellapi", + "imm", ] } [target.'cfg(target_os = "android")'.dependencies] @@ -48,6 +49,7 @@ objc = { package = "objc-rs", version = "0.2" } [dev-dependencies] glam = { version = "0.24", features = ["scalar-math"] } quad-rand = "0.1" +fontdue = "0.8" [profile.release] lto = true diff --git a/examples/ime_test.rs b/examples/ime_test.rs new file mode 100644 index 00000000..467010ab --- /dev/null +++ b/examples/ime_test.rs @@ -0,0 +1,411 @@ +//! IME Input Test Example with Text Rendering +//! +//! Demonstrates: +//! - Text input box with visible text using fontdue +//! - IME candidate window positioning using window::set_ime_position() +//! - Chinese/Japanese/Korean input support +//! +//! Click on an input box to focus it, then type with your IME. + +use fontdue::{Font, FontSettings}; +use miniquad::*; +use std::collections::HashMap; +use std::io::Write; + +// Use Windows system font for Chinese support +#[cfg(target_os = "windows")] +const FONT_PATH: &str = "C:\\Windows\\Fonts\\msyh.ttc"; // Microsoft YaHei + +#[cfg(not(target_os = "windows"))] +const FONT_PATH: &str = "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"; + +// ============================================================================ +// Text Renderer using fontdue +// ============================================================================ + +struct GlyphInfo { + uv: [f32; 4], // x, y, w, h in UV coords + size: [f32; 2], // width, height in pixels + offset: [f32; 2], // xmin, ymin + advance: f32, +} + +struct TextRenderer { + font: Font, + font_size: f32, + cache: HashMap, + atlas: TextureId, + atlas_data: Vec, + atlas_size: u32, + cursor_x: u32, + cursor_y: u32, + row_h: u32, + dirty: bool, +} + +impl TextRenderer { + fn new(ctx: &mut Box, font_size: f32) -> Self { + let font_data = std::fs::read(FONT_PATH).expect("Failed to load font file"); + let font = Font::from_bytes(font_data.as_slice(), FontSettings::default()) + .expect("Failed to parse font"); + + let atlas_size = 1024u32; + let atlas_data = vec![0u8; (atlas_size * atlas_size * 4) as usize]; + let atlas = ctx.new_texture_from_rgba8(atlas_size as u16, atlas_size as u16, &atlas_data); + + Self { + font, font_size, cache: HashMap::new(), atlas, atlas_data, + atlas_size, cursor_x: 0, cursor_y: 0, row_h: 0, dirty: false, + } + } + + fn cache_char(&mut self, ch: char) { + if self.cache.contains_key(&ch) { return; } + + let (m, bmp) = self.font.rasterize(ch, self.font_size); + if m.width == 0 || m.height == 0 { + self.cache.insert(ch, GlyphInfo { + uv: [0.0; 4], size: [0.0; 2], offset: [0.0; 2], advance: m.advance_width, + }); + return; + } + + if self.cursor_x + m.width as u32 > self.atlas_size { + self.cursor_x = 0; + self.cursor_y += self.row_h + 1; + self.row_h = 0; + } + if self.cursor_y + m.height as u32 > self.atlas_size { + eprintln!("Atlas full!"); + return; + } + + for y in 0..m.height { + for x in 0..m.width { + let src = y * m.width + x; + let dx = self.cursor_x + x as u32; + let dy = self.cursor_y + y as u32; + let dst = ((dy * self.atlas_size + dx) * 4) as usize; + self.atlas_data[dst..dst+3].copy_from_slice(&[255, 255, 255]); + self.atlas_data[dst + 3] = bmp[src]; + } + } + + let s = self.atlas_size as f32; + self.cache.insert(ch, GlyphInfo { + uv: [self.cursor_x as f32 / s, self.cursor_y as f32 / s, + m.width as f32 / s, m.height as f32 / s], + size: [m.width as f32, m.height as f32], + offset: [m.xmin as f32, m.ymin as f32], + advance: m.advance_width, + }); + + self.cursor_x += m.width as u32 + 1; + self.row_h = self.row_h.max(m.height as u32); + self.dirty = true; + } + + fn flush(&mut self, ctx: &mut Box) { + if self.dirty { + ctx.texture_update(self.atlas, &self.atlas_data); + self.dirty = false; + } + } + + fn measure(&mut self, text: &str) -> f32 { + text.chars().map(|c| { + self.cache_char(c); + self.cache.get(&c).map(|g| g.advance).unwrap_or(0.0) + }).sum() + } +} + +// ============================================================================ +// Input Box +// ============================================================================ + +struct InputBox { + x: f32, y: f32, w: f32, h: f32, + text: String, + cursor: usize, + focused: bool, +} + +impl InputBox { + fn new(x: f32, y: f32, w: f32, h: f32) -> Self { + Self { x, y, w, h, text: String::new(), cursor: 0, focused: false } + } + fn hit(&self, px: f32, py: f32) -> bool { + px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h + } + fn cursor_x(&self, tr: &mut TextRenderer) -> f32 { + let before: String = self.text.chars().take(self.cursor).collect(); + self.x + 6.0 + tr.measure(&before) + } + fn insert(&mut self, ch: char) { + let pos = self.text.char_indices().nth(self.cursor).map(|(i,_)|i).unwrap_or(self.text.len()); + self.text.insert(pos, ch); + self.cursor += 1; + } + fn backspace(&mut self) { + if self.cursor > 0 { + let start = self.text.char_indices().nth(self.cursor - 1).map(|(i,_)|i).unwrap_or(0); + let end = self.text.char_indices().nth(self.cursor).map(|(i,_)|i).unwrap_or(self.text.len()); + self.text.replace_range(start..end, ""); + self.cursor -= 1; + } + } + fn left(&mut self) { if self.cursor > 0 { self.cursor -= 1; } } + fn right(&mut self) { if self.cursor < self.text.chars().count() { self.cursor += 1; } } +} + +// ============================================================================ +// Vertices +// ============================================================================ + +#[repr(C)] +#[derive(Clone, Copy)] +struct ColorVert { pos: [f32; 2], color: [f32; 4] } + +#[repr(C)] +#[derive(Clone, Copy)] +struct TextVert { pos: [f32; 2], uv: [f32; 2], color: [f32; 4] } + +// ============================================================================ +// Stage +// ============================================================================ + +struct Stage { + boxes: Vec, + focus: Option, + color_pl: Pipeline, + color_bind: Bindings, + text_pl: Pipeline, + text_bind: Bindings, + tr: TextRenderer, + ctx: Box, + dpi: f32, +} + +impl Stage { + fn new() -> Self { + let mut ctx = window::new_rendering_backend(); + let dpi = window::dpi_scale(); + + // Color pipeline + let cs = ctx.new_shader(ShaderSource::Glsl { vertex: COLOR_VS, fragment: COLOR_FS }, + ShaderMeta { images: vec![], uniforms: UniformBlockLayout { uniforms: vec![] } }).unwrap(); + let color_pl = ctx.new_pipeline(&[BufferLayout::default()], + &[VertexAttribute::new("in_pos", VertexFormat::Float2), + VertexAttribute::new("in_color", VertexFormat::Float4)], cs, + PipelineParams { color_blend: Some(BlendState::new(Equation::Add, + BlendFactor::Value(BlendValue::SourceAlpha), + BlendFactor::OneMinusValue(BlendValue::SourceAlpha))), ..Default::default() }); + let cvb = ctx.new_buffer(BufferType::VertexBuffer, BufferUsage::Stream, BufferSource::empty::(1024)); + let cib = ctx.new_buffer(BufferType::IndexBuffer, BufferUsage::Stream, BufferSource::empty::(2048)); + let color_bind = Bindings { vertex_buffers: vec![cvb], index_buffer: cib, images: vec![] }; + + // Text renderer & pipeline + let tr = TextRenderer::new(&mut ctx, 22.0); + let ts = ctx.new_shader(ShaderSource::Glsl { vertex: TEXT_VS, fragment: TEXT_FS }, + ShaderMeta { images: vec!["tex".into()], uniforms: UniformBlockLayout { uniforms: vec![] } }).unwrap(); + let text_pl = ctx.new_pipeline(&[BufferLayout::default()], + &[VertexAttribute::new("in_pos", VertexFormat::Float2), + VertexAttribute::new("in_uv", VertexFormat::Float2), + VertexAttribute::new("in_color", VertexFormat::Float4)], ts, + PipelineParams { color_blend: Some(BlendState::new(Equation::Add, + BlendFactor::Value(BlendValue::SourceAlpha), + BlendFactor::OneMinusValue(BlendValue::SourceAlpha))), ..Default::default() }); + let tvb = ctx.new_buffer(BufferType::VertexBuffer, BufferUsage::Stream, BufferSource::empty::(4096)); + let tib = ctx.new_buffer(BufferType::IndexBuffer, BufferUsage::Stream, BufferSource::empty::(8192)); + let text_bind = Bindings { vertex_buffers: vec![tvb], index_buffer: tib, images: vec![tr.atlas] }; + + let boxes = vec![ + InputBox::new(50.0, 80.0, 500.0, 36.0), + InputBox::new(50.0, 160.0, 500.0, 36.0), + ]; + + Self { boxes, focus: None, color_pl, color_bind, text_pl, text_bind, tr, ctx, dpi } + } + + fn update_ime(&mut self) { + if let Some(i) = self.focus { + let b = &self.boxes[i]; + let x = (b.cursor_x(&mut self.tr) * self.dpi) as i32; + let y = ((b.y + b.h) * self.dpi) as i32; + // Use miniquad's built-in IME position API + window::set_ime_position(x, y); + } + } +} + +impl EventHandler for Stage { + fn update(&mut self) {} + + fn draw(&mut self) { + let (sw, sh) = window::screen_size(); + self.ctx.clear(Some((0.11, 0.11, 0.14, 1.0)), None, None); + + let mut cv: Vec = vec![]; + let mut ci: Vec = vec![]; + let mut tv: Vec = vec![]; + let mut ti: Vec = vec![]; + + // Draw boxes + for (i, b) in self.boxes.iter().enumerate() { + let f = self.focus == Some(i); + let bg = if f { [0.2, 0.2, 0.26, 1.0] } else { [0.16, 0.16, 0.2, 1.0] }; + let br = if f { [0.3, 0.5, 1.0, 1.0] } else { [0.3, 0.3, 0.35, 1.0] }; + rect(&mut cv, &mut ci, b.x, b.y, b.w, b.h, bg, sw, sh); + outline(&mut cv, &mut ci, b.x, b.y, b.w, b.h, br, 2.0, sw, sh); + if f { + let cx = b.cursor_x(&mut self.tr); + rect(&mut cv, &mut ci, cx, b.y + 6.0, 2.0, b.h - 12.0, [1.0,1.0,1.0,0.9], sw, sh); + } + + // Draw text + let mut x = b.x + 6.0; + let baseline = b.y + b.h * 0.72; + for ch in b.text.chars() { + self.tr.cache_char(ch); + if let Some(g) = self.tr.cache.get(&ch) { + if g.size[0] > 0.0 { + let gx = x + g.offset[0]; + let gy = baseline - g.offset[1] - g.size[1]; + glyph_quad(&mut tv, &mut ti, gx, gy, g, [1.0,1.0,1.0,1.0], sw, sh); + } + x += g.advance; + } + } + } + + // Draw labels + let labels = ["Input 1 (type Chinese here):", "Input 2:"]; + for (i, b) in self.boxes.iter().enumerate() { + let mut x = b.x; + for ch in labels[i].chars() { + self.tr.cache_char(ch); + if let Some(g) = self.tr.cache.get(&ch) { + if g.size[0] > 0.0 { + glyph_quad(&mut tv, &mut ti, x + g.offset[0], b.y - 24.0 + 16.0 - g.offset[1] - g.size[1], + g, [0.6,0.6,0.65,1.0], sw, sh); + } + x += g.advance; + } + } + } + + self.tr.flush(&mut self.ctx); + self.ctx.buffer_update(self.color_bind.vertex_buffers[0], BufferSource::slice(&cv)); + self.ctx.buffer_update(self.color_bind.index_buffer, BufferSource::slice(&ci)); + self.ctx.buffer_update(self.text_bind.vertex_buffers[0], BufferSource::slice(&tv)); + self.ctx.buffer_update(self.text_bind.index_buffer, BufferSource::slice(&ti)); + + self.ctx.begin_default_pass(Default::default()); + self.ctx.apply_pipeline(&self.color_pl); + self.ctx.apply_bindings(&self.color_bind); + if !ci.is_empty() { self.ctx.draw(0, ci.len() as i32, 1); } + self.ctx.apply_pipeline(&self.text_pl); + self.ctx.apply_bindings(&self.text_bind); + if !ti.is_empty() { self.ctx.draw(0, ti.len() as i32, 1); } + self.ctx.end_render_pass(); + self.ctx.commit_frame(); + } + + fn mouse_button_down_event(&mut self, _: MouseButton, x: f32, y: f32) { + self.focus = None; + for (i, b) in self.boxes.iter_mut().enumerate() { + b.focused = b.hit(x, y); + if b.focused { + self.focus = Some(i); + // Enable IME when an input box is focused + window::set_ime_enabled(true); + } + } + self.update_ime(); + if self.focus.is_none() { + // Disable IME when no input box is focused (for game controls) + window::set_ime_enabled(false); + } + } + + fn key_down_event(&mut self, k: KeyCode, _: KeyMods, _: bool) { + if let Some(i) = self.focus { + match k { + KeyCode::Backspace => { self.boxes[i].backspace(); self.update_ime(); } + KeyCode::Left => { self.boxes[i].left(); self.update_ime(); } + KeyCode::Right => { self.boxes[i].right(); self.update_ime(); } + KeyCode::Enter => { + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open("ime_output.txt") { + let _ = writeln!(f, "Input {}: \"{}\"", i + 1, self.boxes[i].text); + } + } + KeyCode::Escape => { + self.boxes[i].focused = false; + self.focus = None; + // Disable IME when focus is lost + window::set_ime_enabled(false); + } + _ => {} + } + } + } + + fn char_event(&mut self, ch: char, _: KeyMods, _: bool) { + if ch.is_control() { return; } + if let Some(i) = self.focus { + self.boxes[i].insert(ch); + self.update_ime(); + } + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn rect(v: &mut Vec, i: &mut Vec, x: f32, y: f32, w: f32, h: f32, c: [f32;4], sw: f32, sh: f32) { + let b = v.len() as u16; + let (x0, y0) = ((x/sw)*2.0-1.0, 1.0-(y/sh)*2.0); + let (x1, y1) = (((x+w)/sw)*2.0-1.0, 1.0-((y+h)/sh)*2.0); + v.extend([ColorVert{pos:[x0,y0],color:c}, ColorVert{pos:[x1,y0],color:c}, + ColorVert{pos:[x1,y1],color:c}, ColorVert{pos:[x0,y1],color:c}]); + i.extend([b, b+1, b+2, b, b+2, b+3]); +} + +fn outline(v: &mut Vec, i: &mut Vec, x: f32, y: f32, w: f32, h: f32, c: [f32;4], t: f32, sw: f32, sh: f32) { + rect(v,i,x,y,w,t,c,sw,sh); rect(v,i,x,y+h-t,w,t,c,sw,sh); + rect(v,i,x,y,t,h,c,sw,sh); rect(v,i,x+w-t,y,t,h,c,sw,sh); +} + +fn glyph_quad(v: &mut Vec, i: &mut Vec, x: f32, y: f32, g: &GlyphInfo, c: [f32;4], sw: f32, sh: f32) { + let b = v.len() as u16; + let (x0, y0) = ((x/sw)*2.0-1.0, 1.0-(y/sh)*2.0); + let (x1, y1) = (((x+g.size[0])/sw)*2.0-1.0, 1.0-((y+g.size[1])/sh)*2.0); + let (u0, v0, u1, v1) = (g.uv[0], g.uv[1], g.uv[0]+g.uv[2], g.uv[1]+g.uv[3]); + v.extend([TextVert{pos:[x0,y0],uv:[u0,v0],color:c}, TextVert{pos:[x1,y0],uv:[u1,v0],color:c}, + TextVert{pos:[x1,y1],uv:[u1,v1],color:c}, TextVert{pos:[x0,y1],uv:[u0,v1],color:c}]); + i.extend([b, b+1, b+2, b, b+2, b+3]); +} + +// ============================================================================ +// Shaders +// ============================================================================ + +const COLOR_VS: &str = "#version 100\nattribute vec2 in_pos; attribute vec4 in_color; varying lowp vec4 color;\nvoid main() { gl_Position = vec4(in_pos, 0.0, 1.0); color = in_color; }"; +const COLOR_FS: &str = "#version 100\nvarying lowp vec4 color; void main() { gl_FragColor = color; }"; +const TEXT_VS: &str = "#version 100\nattribute vec2 in_pos; attribute vec2 in_uv; attribute vec4 in_color;\nvarying lowp vec2 uv; varying lowp vec4 color;\nvoid main() { gl_Position = vec4(in_pos, 0.0, 1.0); uv = in_uv; color = in_color; }"; +const TEXT_FS: &str = "#version 100\nprecision mediump float; varying lowp vec2 uv; varying lowp vec4 color; uniform sampler2D tex;\nvoid main() { gl_FragColor = vec4(color.rgb, color.a * texture2D(tex, uv).a); }"; + +// ============================================================================ +// Main +// ============================================================================ + +fn main() { + miniquad::start(conf::Conf { + window_title: "IME Test - Chinese Input".into(), + window_width: 640, + window_height: 300, + ..Default::default() + }, || Box::new(Stage::new())); +} diff --git a/src/lib.rs b/src/lib.rs index bf9661a5..e7f9d500 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -383,6 +383,47 @@ pub mod window { } } + /// Set the position of the IME candidate window. + /// The position is in window client coordinates (pixels). + /// This should be called when the text cursor moves to keep the IME + /// candidate window near the insertion point. + pub fn set_ime_position(x: i32, y: i32) { + let d = native_display().lock().unwrap(); + #[cfg(target_os = "android")] + { + let _ = (x, y); // IME position not applicable on Android + } + + #[cfg(not(target_os = "android"))] + { + d.native_requests + .send(native::Request::SetImePosition { x, y }) + .unwrap(); + } + } + + /// Enable or disable IME (Input Method Editor) for the window. + /// When enabled, the IME will process keyboard input for CJK text input. + /// When disabled, keyboard events are sent directly to the application, + /// which is useful for game controls (e.g., WASD movement). + /// + /// # Arguments + /// * `enabled` - `true` to enable IME (for text input), `false` to disable (for game controls) + pub fn set_ime_enabled(enabled: bool) { + let d = native_display().lock().unwrap(); + #[cfg(target_os = "android")] + { + let _ = enabled; // IME control not applicable on Android + } + + #[cfg(not(target_os = "android"))] + { + d.native_requests + .send(native::Request::SetImeEnabled(enabled)) + .unwrap(); + } + } + #[cfg(target_vendor = "apple")] pub fn apple_gfx_api() -> crate::conf::AppleGfxApi { let d = native_display().lock().unwrap(); diff --git a/src/native.rs b/src/native.rs index e81f5bdf..f3820036 100644 --- a/src/native.rs +++ b/src/native.rs @@ -76,6 +76,8 @@ pub(crate) enum Request { SetWindowPosition { new_x: u32, new_y: u32 }, SetFullscreen(bool), ShowKeyboard(bool), + SetImePosition { x: i32, y: i32 }, + SetImeEnabled(bool), } pub trait Clipboard: Send + Sync { diff --git a/src/native/android.rs b/src/native/android.rs index 9f051c18..b417b522 100644 --- a/src/native/android.rs +++ b/src/native/android.rs @@ -281,6 +281,12 @@ impl MainThreadState { let env = attach_jni_env(); ndk_utils::call_void_method!(env, ACTIVITY, "showKeyboard", "(Z)V", show as i32); }, + SetImePosition { .. } => { + // IME position control not applicable on Android + } + SetImeEnabled(..) => { + // IME enable/disable not applicable on Android + } _ => {} } } diff --git a/src/native/ios.rs b/src/native/ios.rs index 469b1fe4..42892fbf 100644 --- a/src/native/ios.rs +++ b/src/native/ios.rs @@ -114,8 +114,17 @@ impl MainThreadState { fn process_request(&mut self, request: crate::native::Request) { use crate::native::Request::*; - if let ScheduleUpdate = request { - self.update_requested = true; + match request { + ScheduleUpdate => { + self.update_requested = true; + } + SetImePosition { .. } => { + // IME position control not applicable on iOS + } + SetImeEnabled(..) => { + // IME enable/disable not applicable on iOS + } + _ => {} } } } diff --git a/src/native/linux_x11.rs b/src/native/linux_x11.rs index 31c9605b..7f756c16 100644 --- a/src/native/linux_x11.rs +++ b/src/native/linux_x11.rs @@ -420,6 +420,12 @@ impl X11Display { ShowKeyboard(..) => { eprintln!("Not implemented for X11") } + SetImePosition { .. } => { + // IME position control not implemented for X11 yet + } + SetImeEnabled(..) => { + // IME enable/disable not implemented for X11 yet + } } } } diff --git a/src/native/macos.rs b/src/native/macos.rs index 22e5abc6..deb5e548 100644 --- a/src/native/macos.rs +++ b/src/native/macos.rs @@ -204,7 +204,15 @@ impl MacosDisplay { SetWindowPosition { .. } => { eprintln!("Not implemented for macos"); } - _ => {} + ShowKeyboard(..) => { + // Not applicable on macOS desktop + } + SetImePosition { .. } => { + // IME position control not implemented for macOS yet + } + SetImeEnabled(..) => { + // IME enable/disable not implemented for macOS yet + } } } } diff --git a/src/native/windows.rs b/src/native/windows.rs index 28bf447c..cca45234 100644 --- a/src/native/windows.rs +++ b/src/native/windows.rs @@ -16,6 +16,7 @@ use winapi::{ windowsx::{GET_X_LPARAM, GET_Y_LPARAM}, }, um::{ + imm::{HIMC, ImmGetContext, ImmReleaseContext}, libloaderapi::{GetModuleHandleW, GetProcAddress}, shellapi::{DragAcceptFiles, DragQueryFileW, HDROP}, shellscalingapi::*, @@ -24,6 +25,70 @@ use winapi::{ }, }; +// IME constants +const GCS_RESULTSTR: DWORD = 0x0800; + +// IME message constants +const WM_IME_SETCONTEXT: UINT = 0x0281; +const WM_IME_NOTIFY: UINT = 0x0282; +const WM_IME_CHAR: UINT = 0x0286; +const WM_IME_STARTCOMPOSITION: UINT = 0x010D; +const WM_IME_ENDCOMPOSITION: UINT = 0x010E; +const WM_IME_COMPOSITION: UINT = 0x010F; +const WM_INPUTLANGCHANGE: UINT = 0x0051; +const WM_INPUTLANGCHANGEREQUEST: UINT = 0x0050; + +// Window focus messages +const WM_SETFOCUS: UINT = 0x0007; +const WM_KILLFOCUS: UINT = 0x0008; + +/// Global flag tracking whether IME has been explicitly disabled by the user. +/// This is controlled via `show_keyboard(bool)` API calls. +use std::sync::atomic::AtomicBool; +static IME_USER_DISABLED: AtomicBool = AtomicBool::new(false); + +// IME composition form constants +const CFS_POINT: DWORD = 0x0002; +const CFS_CANDIDATEPOS: DWORD = 0x0040; + +// ImmAssociateContextEx flags +const IACE_DEFAULT: DWORD = 0x0010; + +// COMPOSITIONFORM structure +#[repr(C)] +#[allow(non_snake_case)] +struct COMPOSITIONFORM { + dwStyle: DWORD, + ptCurrentPos: POINT, + rcArea: RECT, +} + +// CANDIDATEFORM structure +#[repr(C)] +#[allow(non_snake_case)] +struct CANDIDATEFORM { + dwIndex: DWORD, + dwStyle: DWORD, + ptCurrentPos: POINT, + rcArea: RECT, +} + +// Link to imm32.dll for IME support +#[link(name = "imm32")] +extern "system" { + fn ImmGetCompositionStringW(himc: HIMC, index: DWORD, buf: *mut std::ffi::c_void, len: DWORD) -> i32; + fn ImmAssociateContextEx(hwnd: HWND, himc: HIMC, flags: DWORD) -> i32; + fn ImmAssociateContext(hwnd: HWND, himc: HIMC) -> HIMC; + fn ImmCreateContext() -> HIMC; + fn ImmSetCompositionWindow(himc: HIMC, lpCompForm: *const COMPOSITIONFORM) -> i32; + fn ImmSetCandidateWindow(himc: HIMC, lpCandidate: *const CANDIDATEFORM) -> i32; + fn ImmGetOpenStatus(himc: HIMC) -> i32; + fn ImmSetOpenStatus(himc: HIMC, fOpen: i32) -> i32; +} + +// IME Association flags +const IACE_CHILDREN: DWORD = 0x0001; + mod clipboard; mod keycodes; mod libopengl32; @@ -72,6 +137,51 @@ impl WindowsDisplay { unsafe { ShowCursor(shown.into()) }; } } + /// Set IME candidate window position in client coordinates + fn set_ime_position(&mut self, x: i32, y: i32) { + unsafe { + let himc = ImmGetContext(self.wnd); + if himc.is_null() { + return; + } + + // Set composition window position (where the composing text appears) + let comp_form = COMPOSITIONFORM { + dwStyle: CFS_POINT, + ptCurrentPos: POINT { x, y }, + rcArea: std::mem::zeroed(), + }; + ImmSetCompositionWindow(himc, &comp_form); + + // Set candidate window position (the popup with character choices) + let cand_form = CANDIDATEFORM { + dwIndex: 0, + dwStyle: CFS_CANDIDATEPOS, + ptCurrentPos: POINT { x, y }, + rcArea: std::mem::zeroed(), + }; + ImmSetCandidateWindow(himc, &cand_form); + + ImmReleaseContext(self.wnd, himc); + } + } + + /// Enable or disable IME for the window. + /// When disabled, the IME will not process keyboard input, useful for game controls. + fn set_ime_enabled(&mut self, enabled: bool) { + unsafe { + if enabled { + IME_USER_DISABLED.store(false, std::sync::atomic::Ordering::Relaxed); + // Re-associate IME context with the window + ImmAssociateContextEx(self.wnd, std::ptr::null_mut(), IACE_DEFAULT); + } else { + IME_USER_DISABLED.store(true, std::sync::atomic::Ordering::Relaxed); + // Disassociate IME context from the window + ImmAssociateContextEx(self.wnd, std::ptr::null_mut(), 0); + } + } + } + fn set_mouse_cursor(&mut self, cursor_icon: CursorIcon) { let cursor_name = match cursor_icon { CursorIcon::Default => IDC_ARROW, @@ -469,19 +579,133 @@ unsafe extern "system" fn win32_wndproc( event_handler.char_event(chr, mods, repeat); } } + return 0; + } + // IME message handling: + // We manually extract the result string from WM_IME_COMPOSITION + // instead of relying on WM_IME_CHAR to avoid duplicate characters. + WM_IME_CHAR => { + // Already handled in WM_IME_COMPOSITION; ignore to prevent duplicates + return 0; + } + WM_IME_COMPOSITION => { + let flags = lparam as u32; + const GCS_RESULTSTR: u32 = 0x800; + + // Extract and dispatch the result string manually to avoid duplicates + if (flags & GCS_RESULTSTR) != 0 { + let himc = ImmGetContext(hwnd); + if !himc.is_null() { + let len = ImmGetCompositionStringW(himc, GCS_RESULTSTR, std::ptr::null_mut(), 0); + if len > 0 { + let mut buffer: Vec = vec![0; (len as usize / 2) + 1]; + let actual_len = ImmGetCompositionStringW( + himc, + GCS_RESULTSTR, + buffer.as_mut_ptr() as *mut _, + len as u32 + ); + if actual_len > 0 { + let char_count = actual_len as usize / 2; + let mods = key_mods(); + // Send chars in order + for i in 0..char_count { + let chr = buffer[i]; + if let Some(c) = char::from_u32(chr as u32) { + event_handler.char_event(c, mods, false); + } + } + } + } + ImmReleaseContext(hwnd, himc); + } + return 0; + } + + // For non-result messages (composition state updates), pass to DefWindowProc + return DefWindowProcW(hwnd, umsg, wparam, lparam); + } + WM_IME_SETCONTEXT => { + let user_disabled = IME_USER_DISABLED.load(std::sync::atomic::Ordering::Relaxed); + + // If user explicitly disabled IME, don't auto-restore + if user_disabled { + return 0; + } + + // Must pass to DefWindowProc to enable IME properly + return DefWindowProcW(hwnd, umsg, wparam, lparam); + } + WM_IME_STARTCOMPOSITION => { + // Set candidate window position when IME starts composition + let himc = ImmGetContext(hwnd); + if !himc.is_null() { + let mut pt = POINT { x: 100, y: 100 }; + GetCaretPos(&mut pt); + + let comp_form = COMPOSITIONFORM { + dwStyle: CFS_POINT, + ptCurrentPos: pt, + rcArea: RECT { left: 0, top: 0, right: 0, bottom: 0 }, + }; + ImmSetCompositionWindow(himc, &comp_form); + + // Set candidate window position for all 4 possible candidate windows + for i in 0..4 { + let cand_form = CANDIDATEFORM { + dwIndex: i, + dwStyle: CFS_CANDIDATEPOS, + ptCurrentPos: POINT { x: pt.x, y: pt.y + 20 }, + rcArea: RECT { left: 0, top: 0, right: 0, bottom: 0 }, + }; + ImmSetCandidateWindow(himc, &cand_form); + } + + ImmReleaseContext(hwnd, himc); + } + return DefWindowProcW(hwnd, umsg, wparam, lparam); + } + WM_IME_ENDCOMPOSITION => { + return DefWindowProcW(hwnd, umsg, wparam, lparam); + } + WM_IME_NOTIFY => { + const IMN_SETOPENSTATUS: WPARAM = 0x0008; + + // Re-enable IME if it was unexpectedly closed (unless user disabled it) + if wparam == IMN_SETOPENSTATUS { + let himc = ImmGetContext(hwnd); + if !himc.is_null() { + let open_status = ImmGetOpenStatus(himc); + let user_disabled = IME_USER_DISABLED.load(std::sync::atomic::Ordering::Relaxed); + if open_status == 0 && !user_disabled { + ImmSetOpenStatus(himc, 1); + } + ImmReleaseContext(hwnd, himc); + } + } + + return DefWindowProcW(hwnd, umsg, wparam, lparam); + } + WM_INPUTLANGCHANGEREQUEST | WM_INPUTLANGCHANGE => { + // Pass input language change messages to default handler + return DefWindowProcW(hwnd, umsg, wparam, lparam); } WM_KEYDOWN | WM_SYSKEYDOWN => { - let keycode = HIWORD(lparam as _) as u32 & 0x1FF; - let keycode = keycodes::translate_keycode(keycode); + let keycode_raw = HIWORD(lparam as _) as u32 & 0x1FF; + let keycode = keycodes::translate_keycode(keycode_raw); let mods = key_mods(); let repeat = !!(lparam & 0x40000000) != 0; event_handler.key_down_event(keycode, mods, repeat); + // Pass to DefWindowProc for IME to work properly + return DefWindowProcW(hwnd, umsg, wparam, lparam); } WM_KEYUP | WM_SYSKEYUP => { let keycode = HIWORD(lparam as _) as u32 & 0x1FF; let keycode = keycodes::translate_keycode(keycode); let mods = key_mods(); event_handler.key_up_event(keycode, mods); + // IMPORTANT: Pass to DefWindowProc for IME to work properly + return DefWindowProcW(hwnd, umsg, wparam, lparam); } WM_ENTERSIZEMOVE | WM_ENTERMENULOOP => { SetTimer( @@ -543,6 +767,35 @@ unsafe extern "system" fn win32_wndproc( event_handler.window_minimized_event(); } } + WM_SETFOCUS => { + let user_disabled = IME_USER_DISABLED.load(std::sync::atomic::Ordering::Relaxed); + + // Ensure IME context is available when window gains focus + if !user_disabled { + let himc = ImmGetContext(hwnd); + + if himc.is_null() { + // Create new IME context if none exists + let new_himc = ImmCreateContext(); + if !new_himc.is_null() { + ImmAssociateContext(hwnd, new_himc); + ImmSetOpenStatus(new_himc, 1); + } + } else { + // Ensure IME is open + let open_status = ImmGetOpenStatus(himc); + if open_status == 0 { + ImmSetOpenStatus(himc, 1); + } + ImmReleaseContext(hwnd, himc); + } + } + + return DefWindowProcW(hwnd, umsg, wparam, lparam); + } + WM_KILLFOCUS => { + return DefWindowProcW(hwnd, umsg, wparam, lparam); + } _ => {} } @@ -644,6 +897,7 @@ unsafe fn create_window( ) -> (HWND, HDC) { let mut wndclassw: WNDCLASSW = std::mem::zeroed(); + // CS_OWNDC is required for OpenGL wndclassw.style = CS_HREDRAW | CS_VREDRAW | CS_OWNDC; wndclassw.lpfnWndProc = Some(win32_wndproc); wndclassw.hInstance = GetModuleHandleW(NULL as _); @@ -707,21 +961,9 @@ unsafe fn create_window( ); assert!(!hwnd.is_null()); - let mut rawinputdevice: RAWINPUTDEVICE = std::mem::zeroed(); - rawinputdevice.usUsagePage = HID_USAGE_PAGE_GENERIC; - rawinputdevice.usUsage = HID_USAGE_GENERIC_MOUSE; - rawinputdevice.hwndTarget = NULL as _; - let register_succeed = RegisterRawInputDevices( - &rawinputdevice as *const _, - 1, - std::mem::size_of::() as _, - ); - assert!( - register_succeed == 1, - "Win32: failed to register for raw mouse input!" - ); - - ShowWindow(hwnd, SW_SHOW); + // NOTE: Do not call ShowWindow here! + // Must show window AFTER SetPixelFormat and wglCreateContext + // Otherwise IME will not work correctly. let dc = GetDC(hwnd); assert!(!dc.is_null()); @@ -731,7 +973,16 @@ unsafe fn create_window( } unsafe fn create_msg_window() -> (HWND, HDC) { - let class_name = "MINIQUADAPP\0".encode_utf16().collect::>(); + // Use a separate window class to avoid interfering with main window's IME + let class_name = "MINIQUADMSGWND\0".encode_utf16().collect::>(); + + let mut wndclassw: WNDCLASSW = std::mem::zeroed(); + wndclassw.style = 0; + wndclassw.lpfnWndProc = Some(DefWindowProcW); + wndclassw.hInstance = GetModuleHandleW(NULL as _); + wndclassw.lpszClassName = class_name.as_ptr() as _; + RegisterClassW(&wndclassw); + let window_name = "miniquad message window\0" .encode_utf16() .collect::>(); @@ -753,6 +1004,10 @@ unsafe fn create_msg_window() -> (HWND, HDC) { !msg_hwnd.is_null(), "Win32: failed to create helper window!" ); + + // Disable IME for message window to avoid interfering with main window + ImmAssociateContextEx(msg_hwnd, std::ptr::null_mut(), IACE_CHILDREN); + ShowWindow(msg_hwnd, SW_HIDE); let mut msg = std::mem::zeroed(); while PeekMessageW(&mut msg as _, msg_hwnd, 0, 0, PM_REMOVE) != 0 { @@ -874,8 +1129,19 @@ impl WindowsDisplay { } => self.set_window_size(new_width as _, new_height as _), SetWindowPosition { new_x, new_y } => self.set_window_position(new_x, new_y), SetFullscreen(fullscreen) => self.set_fullscreen(fullscreen), - ShowKeyboard(_show) => { - eprintln!("Not implemented for windows") + ShowKeyboard(show) => { + // On Windows, ShowKeyboard controls IME state + if show { + IME_USER_DISABLED.store(false, std::sync::atomic::Ordering::Relaxed); + } else { + IME_USER_DISABLED.store(true, std::sync::atomic::Ordering::Relaxed); + } + } + SetImePosition { x, y } => { + self.set_ime_position(x, y); + } + SetImeEnabled(enabled) => { + self.set_ime_enabled(enabled); } } } @@ -948,6 +1214,25 @@ where super::gl::load_gl_funcs(|proc| display.get_proc_address(proc)); + // IMPORTANT: Show window AFTER WGL context is created + // This ensures IME initializes correctly when window gains focus + ShowWindow(wnd, SW_SHOW); + + // Register for raw mouse input (needed for raw_mouse_motion event) + let mut rawinputdevice: RAWINPUTDEVICE = std::mem::zeroed(); + rawinputdevice.usUsagePage = HID_USAGE_PAGE_GENERIC; + rawinputdevice.usUsage = HID_USAGE_GENERIC_MOUSE; + rawinputdevice.hwndTarget = NULL as _; + let register_succeed = RegisterRawInputDevices( + &rawinputdevice as *const _, + 1, + std::mem::size_of::() as _, + ); + assert!( + register_succeed == 1, + "Win32: failed to register for raw mouse input!" + ); + display.event_handler = Some(f()); #[cfg(target_arch = "x86_64")] From c7346f6fee016294a2551f661ac1c06a20d3268b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E5=BA=86=E4=B8=B0?= Date: Mon, 1 Dec 2025 00:40:55 +0800 Subject: [PATCH 2/2] fix: address code review feedback for IME support - Remove duplicate GCS_RESULTSTR constant definition - Remove trailing whitespace in comments - Use std::mem::zeroed() instead of arbitrary default POINT values - Define CANDIDATE_WINDOW_Y_OFFSET constant instead of magic number - Only set candidate window position for index 0 (most IMEs only use this) - Add error handling for ImmSetOpenStatus failure in WM_SETFOCUS - Add clarifying comment for dirty flag in ime_test.rs --- examples/ime_test.rs | 2 ++ ime_output.txt | 1 + src/native/windows.rs | 33 ++++++++++++++++++--------------- 3 files changed, 21 insertions(+), 15 deletions(-) create mode 100644 ime_output.txt diff --git a/examples/ime_test.rs b/examples/ime_test.rs index 467010ab..de760021 100644 --- a/examples/ime_test.rs +++ b/examples/ime_test.rs @@ -80,6 +80,7 @@ impl TextRenderer { return; } + // Copy glyph bitmap to atlas for y in 0..m.height { for x in 0..m.width { let src = y * m.width + x; @@ -102,6 +103,7 @@ impl TextRenderer { self.cursor_x += m.width as u32 + 1; self.row_h = self.row_h.max(m.height as u32); + // Mark atlas dirty only after successful insertion self.dirty = true; } diff --git a/ime_output.txt b/ime_output.txt new file mode 100644 index 00000000..d321ab4e --- /dev/null +++ b/ime_output.txt @@ -0,0 +1 @@ +Input 2: "" diff --git a/src/native/windows.rs b/src/native/windows.rs index cca45234..01ffe5fa 100644 --- a/src/native/windows.rs +++ b/src/native/windows.rs @@ -63,7 +63,7 @@ struct COMPOSITIONFORM { rcArea: RECT, } -// CANDIDATEFORM structure +// CANDIDATEFORM structure #[repr(C)] #[allow(non_snake_case)] struct CANDIDATEFORM { @@ -590,7 +590,6 @@ unsafe extern "system" fn win32_wndproc( } WM_IME_COMPOSITION => { let flags = lparam as u32; - const GCS_RESULTSTR: u32 = 0x800; // Extract and dispatch the result string manually to avoid duplicates if (flags & GCS_RESULTSTR) != 0 { @@ -637,10 +636,13 @@ unsafe extern "system" fn win32_wndproc( return DefWindowProcW(hwnd, umsg, wparam, lparam); } WM_IME_STARTCOMPOSITION => { + // Offset for candidate window below composition position + const CANDIDATE_WINDOW_Y_OFFSET: i32 = 20; + // Set candidate window position when IME starts composition let himc = ImmGetContext(hwnd); if !himc.is_null() { - let mut pt = POINT { x: 100, y: 100 }; + let mut pt: POINT = std::mem::zeroed(); GetCaretPos(&mut pt); let comp_form = COMPOSITIONFORM { @@ -650,16 +652,14 @@ unsafe extern "system" fn win32_wndproc( }; ImmSetCompositionWindow(himc, &comp_form); - // Set candidate window position for all 4 possible candidate windows - for i in 0..4 { - let cand_form = CANDIDATEFORM { - dwIndex: i, - dwStyle: CFS_CANDIDATEPOS, - ptCurrentPos: POINT { x: pt.x, y: pt.y + 20 }, - rcArea: RECT { left: 0, top: 0, right: 0, bottom: 0 }, - }; - ImmSetCandidateWindow(himc, &cand_form); - } + // Set candidate window position (most IMEs only use index 0) + let cand_form = CANDIDATEFORM { + dwIndex: 0, + dwStyle: CFS_CANDIDATEPOS, + ptCurrentPos: POINT { x: pt.x, y: pt.y + CANDIDATE_WINDOW_Y_OFFSET }, + rcArea: RECT { left: 0, top: 0, right: 0, bottom: 0 }, + }; + ImmSetCandidateWindow(himc, &cand_form); ImmReleaseContext(hwnd, himc); } @@ -778,8 +778,11 @@ unsafe extern "system" fn win32_wndproc( // Create new IME context if none exists let new_himc = ImmCreateContext(); if !new_himc.is_null() { - ImmAssociateContext(hwnd, new_himc); - ImmSetOpenStatus(new_himc, 1); + let prev_himc = ImmAssociateContext(hwnd, new_himc); + if ImmSetOpenStatus(new_himc, 1) == 0 && prev_himc.is_null() { + // If SetOpenStatus fails and no previous context, release the new one + ImmReleaseContext(hwnd, new_himc); + } } } else { // Ensure IME is open