From 98a691d26f192c1e1cc921a1ab4c68bc36fed1cd Mon Sep 17 00:00:00 2001 From: Jimmy Chang Date: Mon, 10 Aug 2026 19:41:44 +0800 Subject: [PATCH 1/3] Render non-ASCII text with platform font fallback on macOS The bundled bitmap fonts only carry the first 128 codepoints, so every other character was drawn as a "[U+XXXX]" placeholder. Build names, notes, account names and item text pasted from a localised client are effectively restricted to ASCII, whatever language the user works in. Add a parallel glyph path instead of replacing the existing one: - r_dynfont (macOS/CoreText) rasterises a single codepoint on demand. Going through CTFontCreateForString gives us the system fallback chain, so Han, kana, Cyrillic and emoji all resolve without shipping or hand-picking a font. - r_font caches those glyphs per (codepoint, size) as small textures and consults them in the three places that previously produced tofu: width measurement, cursor hit testing, and drawing. Latin text still goes through the bitmap fonts untouched, so existing layout is unchanged, and tofu remains the fallback when no glyph exists. Glyphs are rasterised at the display's pixel density and scaled back to layout units so they stay sharp on HiDPI, and are anchored to a fixed fraction of the line height: the font's own ascent includes leading and would sit noticeably below where the bitmap fonts place their baseline. Also fix r_tex_c's image constructor, which assigned the mip set to its parameter rather than the member that PerformUpload reads, so building a texture from memory always dereferenced a null image. --- CMakeLists.txt | 2 + engine/render/r_dynfont.h | 35 +++++++++ engine/render/r_dynfont.mm | 153 ++++++++++++++++++++++++++++++++++++ engine/render/r_font.cpp | 132 ++++++++++++++++++++++++++++--- engine/render/r_font.h | 23 ++++++ engine/render/r_texture.cpp | 5 +- 6 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 engine/render/r_dynfont.h create mode 100644 engine/render/r_dynfont.mm diff --git a/CMakeLists.txt b/CMakeLists.txt index b992d62e..bf4d521b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,7 @@ set(SIMPLEGRAPHIC_SOURCES "engine/core/core_tex_manipulation.h" "engine/core/core_video.cpp" "engine/core/core_video.h" + "engine/render/r_dynfont.h" "engine/render/r_font.cpp" "engine/render/r_font.h" "engine/render/r_main.cpp" @@ -80,6 +81,7 @@ set (SIMPLEGRAPHIC_PLATFORM_SOURCES) if (APPLE) set (SIMPLEGRAPHIC_PLATFORM_SOURCES "engine/system/win/sys_macos.mm" + "engine/render/r_dynfont.mm" ) endif() diff --git a/engine/render/r_dynfont.h b/engine/render/r_dynfont.h new file mode 100644 index 00000000..b6988f37 --- /dev/null +++ b/engine/render/r_dynfont.h @@ -0,0 +1,35 @@ +// SimpleGraphic Engine +// +// Dynamic Glyph Rasterisation +// +// The bundled bitmap fonts only cover the first 128 codepoints. Anything +// outside that range used to be drawn as a "[U+XXXX]" tofu placeholder. +// This module rasterises arbitrary codepoints on demand using the platform +// font engine, which also gives us automatic font fallback (CJK, Cyrillic, +// emoji, ...) without having to ship or pick a font ourselves. +// +// Only implemented on macOS for now; other platforms report "no glyph" and +// keep the existing tofu behaviour. + +#pragma once + +#include +#include + +struct dynGlyph_s { + int width = 0; // bitmap width in pixels + int height = 0; // bitmap height in pixels + int bearingX = 0; // x offset from the pen position to the bitmap's left edge + int bearingY = 0; // y offset from the baseline up to the bitmap's top edge + int advance = 0; // how far to move the pen after drawing + int ascent = 0; // distance from the top of the line box down to the baseline + std::vector coverage; // width * height, 8-bit alpha coverage +}; + +// Rasterise one codepoint at the requested pixel size. +// Returns false when the platform has no glyph for it, or on any failure — +// callers should fall back to their existing placeholder rendering. +bool DynFontRasterize(char32_t cp, int pixelSize, dynGlyph_s& out); + +// Whether dynamic rasterisation is available at all on this build. +bool DynFontAvailable(); diff --git a/engine/render/r_dynfont.mm b/engine/render/r_dynfont.mm new file mode 100644 index 00000000..4a0c57a3 --- /dev/null +++ b/engine/render/r_dynfont.mm @@ -0,0 +1,153 @@ +// SimpleGraphic Engine +// +// Dynamic Glyph Rasterisation — macOS (CoreText) backend +// +// We deliberately go through CoreText rather than bundling a rasteriser and a +// font file: CTFontCreateForString gives us the system's font fallback chain, +// so a codepoint gets rendered with whatever installed font actually covers it +// (Songti/PingFang for Han, Hiragino for kana, Apple Color Emoji, ...) with no +// font list to curate. + +#include "r_dynfont.h" + +#include +#include + +#include +#include + +namespace { + +// Convert a codepoint to the UTF-16 units CoreText expects. +// Returns the number of units written (1 for the BMP, 2 for a surrogate pair). +int CodepointToUTF16(char32_t cp, UniChar out[2]) +{ + if (cp < 0x10000) { + out[0] = (UniChar)cp; + return 1; + } + char32_t v = cp - 0x10000; + out[0] = (UniChar)(0xD800 + (v >> 10)); + out[1] = (UniChar)(0xDC00 + (v & 0x3FF)); + return 2; +} + +// Pick the font that actually has a glyph for this codepoint, starting from +// the standard UI font and letting CoreText walk the fallback chain. +// Returns a +1 reference the caller must release, or nullptr. +CTFontRef FontForCodepoint(char32_t cp, int pixelSize, CGGlyph& glyphOut) +{ + UniChar units[2]; + int unitCount = CodepointToUTF16(cp, units); + + CTFontRef base = CTFontCreateUIFontForLanguage(kCTFontUIFontUser, (CGFloat)pixelSize, nullptr); + if (!base) { + return nullptr; + } + + CGGlyph glyphs[2] = {0, 0}; + if (CTFontGetGlyphsForCharacters(base, units, glyphs, unitCount) && glyphs[0]) { + glyphOut = glyphs[0]; + return base; + } + + // The base font can't render it — ask CoreText for a substitute. + CFStringRef str = CFStringCreateWithCharacters(nullptr, units, unitCount); + if (!str) { + CFRelease(base); + return nullptr; + } + CTFontRef fallback = CTFontCreateForString(base, str, CFRangeMake(0, unitCount)); + CFRelease(str); + CFRelease(base); + if (!fallback) { + return nullptr; + } + + glyphs[0] = glyphs[1] = 0; + if (!CTFontGetGlyphsForCharacters(fallback, units, glyphs, unitCount) || !glyphs[0]) { + CFRelease(fallback); + return nullptr; + } + glyphOut = glyphs[0]; + return fallback; +} + +} // namespace + +bool DynFontAvailable() +{ + return true; +} + +bool DynFontRasterize(char32_t cp, int pixelSize, dynGlyph_s& out) +{ + if (pixelSize <= 0 || pixelSize > 512) { + return false; + } + + CGGlyph glyph = 0; + CTFontRef font = FontForCodepoint(cp, pixelSize, glyph); + if (!font) { + return false; + } + + CGRect bounds = CTFontGetBoundingRectsForGlyphs(font, kCTFontOrientationHorizontal, &glyph, nullptr, 1); + CGSize advanceSize{}; + CTFontGetAdvancesForGlyphs(font, kCTFontOrientationHorizontal, &glyph, &advanceSize, 1); + + out = dynGlyph_s{}; + out.advance = (int)std::ceil(advanceSize.width); + // The caller positions text by the top of the line box, so it needs to know + // where the baseline sits within it. + out.ascent = (int)std::ceil(CTFontGetAscent(font)); + + // Whitespace and other zero-area glyphs carry an advance but no pixels. + if (CGRectIsEmpty(bounds) || CGRectIsNull(bounds)) { + CFRelease(font); + return true; + } + + // Pad by one pixel on each side so antialiased edges aren't clipped. + int x0 = (int)std::floor(CGRectGetMinX(bounds)) - 1; + int y0 = (int)std::floor(CGRectGetMinY(bounds)) - 1; + int x1 = (int)std::ceil(CGRectGetMaxX(bounds)) + 1; + int y1 = (int)std::ceil(CGRectGetMaxY(bounds)) + 1; + + int w = x1 - x0; + int h = y1 - y0; + if (w <= 0 || h <= 0 || w > 1024 || h > 1024) { + CFRelease(font); + return false; + } + + std::vector pixels((size_t)w * h, 0); + CGContextRef ctx = CGBitmapContextCreate(pixels.data(), w, h, 8, w, nullptr, kCGImageAlphaOnly); + if (!ctx) { + CFRelease(font); + return false; + } + + CGContextSetShouldAntialias(ctx, true); + CGContextSetShouldSmoothFonts(ctx, false); // grayscale AA; subpixel makes no sense in an alpha mask + + // Place the glyph so its bounding box lands at the bitmap origin. + CGPoint pos = CGPointMake((CGFloat)-x0, (CGFloat)-y0); + CTFontDrawGlyphs(font, &glyph, &pos, 1, ctx); + CGContextFlush(ctx); + CGContextRelease(ctx); + CFRelease(font); + + out.width = w; + out.height = h; + out.bearingX = x0; + // CoreGraphics y grows upwards; our renderer addresses rows downwards from + // the top, so the top edge sits y1 above the baseline. + out.bearingY = y1; + // A CGBitmapContext stores its first row at the top of the image even + // though its drawing origin is bottom-left, which already matches the + // renderer's top-down texture rows — no flip needed here. + out.coverage = std::move(pixels); + + return true; +} diff --git a/engine/render/r_font.cpp b/engine/render/r_font.cpp index 90375b3d..cd9a9f0e 100644 --- a/engine/render/r_font.cpp +++ b/engine/render/r_font.cpp @@ -6,6 +6,9 @@ #include "r_local.h" +#include "r_dynfont.h" + +#include #include #include #include @@ -122,6 +125,72 @@ r_font_c::~r_font_c() delete fontHeights[i]; } delete fontHeightMap; + + for (auto& [key, glyph] : dynGlyphs) { + delete glyph.tex; + } +} + +// Rasterise (and cache) a glyph for a codepoint the bitmap fonts don't cover. +// A cached entry with a negative advance means "the platform has no glyph", +// so we only ask once per codepoint/size. +r_font_c::f_dynGlyph_s const* r_font_c::FindDynGlyph(char32_t cp, int pixelHeight) +{ + if (pixelHeight <= 0) { + return nullptr; + } + uint64_t key = ((uint64_t)cp << 16) | (uint64_t)(pixelHeight & 0xFFFF); + if (auto it = dynGlyphs.find(key); it != dynGlyphs.end()) { + return it->second.valid ? &it->second : nullptr; + } + + // The bitmap fonts size their glyphs to cap height, but a CJK glyph fills + // its whole em box, so rasterising at the full line height makes it look + // oversized next to Latin text. Render slightly smaller and centre it. + const float emScale = 0.82f; + // Rasterise at physical pixel density so glyphs stay sharp on HiDPI. + const float dpi = (std::max)(1.0f, renderer->sys->video->vid.dpiScale); + const int pixelSize = (std::max)(1, (int)std::lround(pixelHeight * emScale * dpi)); + + f_dynGlyph_s entry{}; + dynGlyph_s raster; + if (!DynFontRasterize(cp, pixelSize, raster)) { + dynGlyphs[key] = entry; // valid stays false: don't ask again + return nullptr; + } + + entry.valid = true; + entry.width = raster.width / dpi; + entry.height = raster.height / dpi; + entry.bearingX = raster.bearingX / dpi; + entry.advance = raster.advance / dpi; + + if (raster.width > 0 && raster.height > 0) { + // The renderer treats a single-channel image as luminance, which would + // draw an opaque box, so expand coverage into the alpha of a white RGBA + // image and let the layer colour tint it. + std::vector rgba((size_t)raster.width * raster.height * 4); + for (size_t i = 0; i < (size_t)raster.width * raster.height; ++i) { + rgba[i * 4 + 0] = 255; + rgba[i * 4 + 1] = 255; + rgba[i * 4 + 2] = 255; + rgba[i * 4 + 3] = raster.coverage[i]; + } + auto img = std::make_unique(); + if (img->CopyRaw(IMGTYPE_RGBA, raster.width, raster.height, rgba.data())) { + entry.tex = new r_tex_c(renderer->texMan, std::move(img), TF_NOMIPMAP | TF_CLAMP); + } + } + + // Offset from the top of the line box down to the glyph's top edge. + // The font's own ascent includes leading and sits lower than where the + // bitmap fonts put their baseline, so anchor to a fixed fraction of the + // line height instead to keep Latin and CJK sitting on the same line. + const float baseline = pixelHeight * 0.80f; + entry.bearingY = baseline - raster.bearingY / dpi; + + auto [it, ok] = dynGlyphs.emplace(key, std::move(entry)); + return &it->second; } // ============= @@ -158,11 +227,17 @@ int r_font_c::StringWidthInternal(f_fontHeight_s* fh, std::u32string_view str, i idx += escLen; } else if (ch >= (unsigned)fh->numGlyph) { - auto tofu = BuildTofuString(ch); - for (auto cp : tofu) { - width += measureCodepoint(tofuFont.fh, cp); + if (auto* dyn = FindDynGlyph(ch, height)) { + width += dyn->advance; width = std::ceil(width); } + else { + auto tofu = BuildTofuString(ch); + for (auto cp : tofu) { + width += measureCodepoint(tofuFont.fh, cp); + width = std::ceil(width); + } + } ++idx; } else if (ch == U'\t') { @@ -222,14 +297,23 @@ size_t r_font_c::StringCursorInternal(f_fontHeight_s* fh, std::u32string_view st I += escLen; } else if (*I >= (unsigned)fh->numGlyph) { - auto tofu = BuildTofuString(*I); - for (auto cp : tofu) { - x += measureCodepoint(tofuFont.fh, cp); + if (auto* dyn = FindDynGlyph(*I, height)) { + x += dyn->advance; x = std::ceil(x); if (curX <= x) { return std::distance(str.begin(), I); } } + else { + auto tofu = BuildTofuString(*I); + for (auto cp : tofu) { + x += measureCodepoint(tofuFont.fh, cp); + x = std::ceil(x); + if (curX <= x) { + return std::distance(str.begin(), I); + } + } + } ++I; } else if (*I == U'\t') { @@ -394,14 +478,44 @@ void r_font_c::DrawTextLine(scp_t pos, int align, int height, col4_t col, std::u x = std::ceil(x); }; + // Draw a glyph that came from the platform font engine. Unlike the bitmap + // glyphs these carry their own metrics and sit relative to the baseline. + auto drawDynGlyph = [this, &curTex, &x, y, height](f_dynGlyph_s const* g) { + if (g->tex && g->width > 0 && g->height > 0) { + if (curTex != g->tex) { + curTex = g->tex; + renderer->curLayer->Bind(g->tex); + } + float gx = x + g->bearingX; + float gy = y + g->bearingY; + float gw = g->width; + float gh = g->height; + if (gx + gw >= 0 && gx < renderer->VirtualScreenWidth()) { + renderer->curLayer->Quad( + 0.0f, 0.0f, gx, gy, + 1.0f, 0.0f, gx + gw, gy, + 1.0f, 1.0f, gx + gw, gy + gh, + 0.0f, 1.0f, gx, gy + gh + ); + } + } + x += g->advance; + x = std::ceil(x); + }; + // Render the string for (auto tail = str; !tail.empty();) { // Draw unprintable characters as tofu placeholders auto ch = tail[0]; if (ch >= (unsigned)fh->numGlyph) { - auto tofu = BuildTofuString(ch); - for (auto ch : tofu) { - drawCodepoint(tofuFont.fh, tofuFont.fh->height, 1.0f, tofuFont.yPad, ch); + if (auto* dyn = FindDynGlyph(ch, height)) { + drawDynGlyph(dyn); + } + else { + auto tofu = BuildTofuString(ch); + for (auto ch : tofu) { + drawCodepoint(tofuFont.fh, tofuFont.fh->height, 1.0f, tofuFont.yPad, ch); + } } tail = tail.substr(1); continue; diff --git a/engine/render/r_font.h b/engine/render/r_font.h index cbae8307..10fa7cee 100644 --- a/engine/render/r_font.h +++ b/engine/render/r_font.h @@ -8,7 +8,9 @@ // Classes // ======= +#include #include +#include // Font class r_font_c { @@ -39,6 +41,27 @@ class r_font_c { }; FontHeightEntry FindFontHeight(int height); + // A glyph rasterised on demand for a codepoint the bitmap fonts don't + // cover. Each one owns a small texture; they are cached for the lifetime + // of the font since the set of characters a build uses is small and stable. + // Metrics are in layout (logical) units; the texture itself is rasterised at + // the display's pixel density so it stays sharp on HiDPI screens. + struct f_dynGlyph_s { + class r_tex_c* tex = nullptr; // null for blank glyphs (e.g. ideographic space) + float width = 0.0f; + float height = 0.0f; + float bearingX = 0.0f; + float bearingY = 0.0f; + float advance = 0.0f; + bool valid = false; + }; + + // Returns null when the platform can't produce a glyph, in which case + // callers fall back to the "[U+XXXX]" placeholder. + f_dynGlyph_s const* FindDynGlyph(char32_t cp, int pixelHeight); + + std::unordered_map dynGlyphs; + class r_renderer_c* renderer = nullptr; int numFontHeight = 0; struct f_fontHeight_s *fontHeights[32] = {}; diff --git a/engine/render/r_texture.cpp b/engine/render/r_texture.cpp index 07fdda80..63ff36ac 100644 --- a/engine/render/r_texture.cpp +++ b/engine/render/r_texture.cpp @@ -310,8 +310,9 @@ r_tex_c::r_tex_c(r_ITexManager* manager, std::unique_ptr img, int flags { Init(manager, {}, flags); - // Direct upload - img = BuildMipSet(std::move(img)); + // Direct upload. Note the result has to land in the member `img`, which is + // what PerformUpload reads — assigning to the parameter left it null. + this->img = BuildMipSet(std::move(img)); PerformUpload(this); } From 49f92cd79b997caae29204d300247d2791e3854c Mon Sep 17 00:00:00 2001 From: Jimmy Chang Date: Mon, 10 Aug 2026 23:51:06 +0800 Subject: [PATCH 2/3] Support input method composition on macOS Typing with an IME did nothing visible: the composition never reached the application, keys the IME was about to consume also hit the UI, and the candidate window opened in a fixed screen corner. Committed text was turned into "?" before it arrived. GLFW's content view conforms to NSTextInputClient but leaves several parts inert, so replace those at runtime and chain to the originals rather than forking the library: - setMarkedText: forwards the composition up to the UI so it can be drawn at the caret; unmarkText clears it. - firstRectForCharacterRange: answers with the caret rectangle the UI reports, converted to screen space, so the candidate window follows the text instead of sitting in a corner. - markedRange was one unit short of the text it holds and selectedRange was missing entirely; both are consulted while positioning the candidate window and a bad answer makes the system give up on it. - keyDown: withholds keys from the application while a composition is in progress. GLFW reports the raw key first, which let Return confirm a dialog before the composed text ever arrived. - insertText: clears the marked text GLFW leaves behind on commit, which otherwise kept every later keystroke away from the application. On the UI side, character events now carry the codepoint encoded as UTF-8 instead of being looked up in the key name table, which only knows named keys and answered "?" for anything above ASCII. Composition state is delivered through a new OnPreedit callback, and SetIMECaretRect lets the focused control report where its caret is. --- CMakeLists.txt | 2 + engine/core/core_main.cpp | 8 ++ engine/core/core_main.h | 2 + engine/render.h | 2 + engine/render/r_main.cpp | 6 + engine/render/r_main.h | 1 + engine/system/win/sys_ime.h | 35 +++++ engine/system/win/sys_ime_macos.mm | 213 +++++++++++++++++++++++++++++ engine/system/win/sys_video.cpp | 8 ++ ui.h | 1 + ui_api.cpp | 20 +++ ui_main.cpp | 50 ++++++- ui_main.h | 2 + 13 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 engine/system/win/sys_ime.h create mode 100644 engine/system/win/sys_ime_macos.mm diff --git a/CMakeLists.txt b/CMakeLists.txt index bf4d521b..fa644234 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,7 @@ set(SIMPLEGRAPHIC_SOURCES "engine/core/core_video.cpp" "engine/core/core_video.h" "engine/render/r_dynfont.h" + "engine/system/win/sys_ime.h" "engine/render/r_font.cpp" "engine/render/r_font.h" "engine/render/r_main.cpp" @@ -81,6 +82,7 @@ set (SIMPLEGRAPHIC_PLATFORM_SOURCES) if (APPLE) set (SIMPLEGRAPHIC_PLATFORM_SOURCES "engine/system/win/sys_macos.mm" + "engine/system/win/sys_ime_macos.mm" "engine/render/r_dynfont.mm" ) endif() diff --git a/engine/core/core_main.cpp b/engine/core/core_main.cpp index c75499ec..ef8ec034 100644 --- a/engine/core/core_main.cpp +++ b/engine/core/core_main.cpp @@ -21,6 +21,7 @@ class core_main_c: public core_IMain { void Frame(); void Shutdown(); void KeyEvent(int key, int type); + void PreeditEvent(const char* utf8Text, int caret); bool CanExit(); // Encapsulated @@ -102,6 +103,13 @@ void core_main_c::KeyEvent(int key, int type) ui->KeyEvent(key, type); } +void core_main_c::PreeditEvent(const char* utf8Text, int caret) +{ + if ( !initialised ) return; + + ui->PreeditEvent(utf8Text, caret); +} + bool core_main_c::CanExit() { if ( !initialised ) return true; diff --git a/engine/core/core_main.h b/engine/core/core_main.h index 9a721860..d51002ff 100644 --- a/engine/core/core_main.h +++ b/engine/core/core_main.h @@ -21,6 +21,8 @@ class core_IMain { virtual void Frame() = 0; virtual void Shutdown() = 0; virtual void KeyEvent(int key, int type) = 0; + // Input method composition state; text is UTF-8, caret is a byte offset. + virtual void PreeditEvent(const char* utf8Text, int caret) = 0; virtual bool CanExit() = 0; }; diff --git a/engine/render.h b/engine/render.h index 0bb36813..73f028bf 100644 --- a/engine/render.h +++ b/engine/render.h @@ -93,6 +93,8 @@ class r_IRenderer { virtual void SetDrawSubLayer(int subLayer) = 0; virtual int GetDrawLayer() = 0; virtual void SetViewport(int x = 0, int y = 0, int width = 0, int height = 0) = 0; + // Origin of the viewport currently being drawn into, in window space. + virtual void GetViewportOrigin(int& x, int& y) = 0; virtual void SetBlendMode(int mode) = 0; virtual void DrawColor(const col4_t col = NULL) = 0; virtual void DrawColor(dword col) = 0; diff --git a/engine/render/r_main.cpp b/engine/render/r_main.cpp index 1d46715f..65b901df 100644 --- a/engine/render/r_main.cpp +++ b/engine/render/r_main.cpp @@ -1708,6 +1708,12 @@ int r_renderer_c::GetDrawLayer() return curLayer->subLayer; } +void r_renderer_c::GetViewportOrigin(int& x, int& y) +{ + x = curViewport.x; + y = curViewport.y; +} + void r_renderer_c::SetViewport(int x, int y, int width, int height) { if (height == 0) { diff --git a/engine/render/r_main.h b/engine/render/r_main.h index f7072fb4..9ceb0f0a 100644 --- a/engine/render/r_main.h +++ b/engine/render/r_main.h @@ -86,6 +86,7 @@ class r_renderer_c: public r_IRenderer, public conCmdHandler_c { void SetDrawSubLayer(int subLayer); int GetDrawLayer(); void SetViewport(int x = 0, int y = 0, int width = 0, int height = 0); + void GetViewportOrigin(int& x, int& y); void SetBlendMode(int mode); void DrawColor(const col4_t col = NULL); void DrawColor(dword col); diff --git a/engine/system/win/sys_ime.h b/engine/system/win/sys_ime.h new file mode 100644 index 00000000..4e566702 --- /dev/null +++ b/engine/system/win/sys_ime.h @@ -0,0 +1,35 @@ +// SimpleGraphic Engine +// +// Input Method Editor bridge +// +// GLFW receives the composition text an input method produces but discards +// it, and reports a fixed rectangle when asked where the caret is, so the +// candidate window has nowhere sensible to appear. Without both of those a +// user typing Chinese, Japanese or Korean sees nothing happen at all. +// +// This module fills those two gaps on the platform side. It does not touch +// committed text, which already reaches the app through the normal character +// callback. +// +// Only implemented on macOS; elsewhere the calls are inert and behaviour is +// unchanged. + +#pragma once + +struct GLFWwindow; + +// Called whenever the composition text changes. `utf8Text` is the text being +// composed (empty when composition ends). `caret` is a byte offset into it. +using ime_preeditFn_t = void (*)(const char* utf8Text, int caret, void* userData); + +// Hook into the window's text input so composition updates are reported. +// Safe to call more than once; the last callback wins. +void IME_Install(GLFWwindow* window, ime_preeditFn_t fn, void* userData); + +// Tell the input method where the caret currently is, in window coordinates +// with the origin at the top left, so the candidate window can be placed +// next to it. Height should cover the text line. +void IME_SetCaretRect(int x, int y, int width, int height); + +// Whether an IME bridge is active on this build. +bool IME_Available(); diff --git a/engine/system/win/sys_ime_macos.mm b/engine/system/win/sys_ime_macos.mm new file mode 100644 index 00000000..274c62dd --- /dev/null +++ b/engine/system/win/sys_ime_macos.mm @@ -0,0 +1,213 @@ +// SimpleGraphic Engine +// +// Input Method Editor bridge — macOS +// +// GLFW's content view already conforms to NSTextInputClient, which is how the +// system hands it composition text and asks where the caret is. Two of those +// methods are stubs: setMarkedText: stores the text and does nothing with it, +// and firstRectForCharacterRange: always answers with the window's corner. +// +// Rather than fork GLFW we replace those two implementations at runtime and +// chain to the originals, so an upgrade of the library does not need the +// patch to be reapplied. Committed text is untouched — it already flows out +// through insertText: into the normal character callback. + +#include "sys_ime.h" + +#include +#include + +#define GLFW_EXPOSE_NATIVE_COCOA +#include +#include + +#include + +namespace { + +ime_preeditFn_t g_preeditFn = nullptr; +void* g_preeditUser = nullptr; +bool g_installed = false; + +// Caret rectangle in window coordinates, top-left origin, as the UI sees it. +NSRect g_caretRect = NSMakeRect(0.0, 0.0, 1.0, 16.0); +NSWindow* g_window = nil; + +// Length of the current composition in UTF-16 units, as AppKit counts them. +NSUInteger g_markedLength = 0; + +IMP g_origSetMarkedText = nullptr; +IMP g_origUnmarkText = nullptr; +IMP g_origFirstRect = nullptr; +IMP g_origKeyDown = nullptr; +IMP g_origInsertText = nullptr; + +void ReportPreedit(NSString* text, NSRange selected) +{ + if (!g_preeditFn) { + return; + } + const char* utf8 = text ? [text UTF8String] : ""; + if (!utf8) { + utf8 = ""; + } + // Convert the selection start from UTF-16 units to a byte offset. + int caret = 0; + if (text && selected.location != NSNotFound && selected.location <= [text length]) { + NSString* head = [text substringToIndex:selected.location]; + caret = (int)[head lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + } + g_preeditFn(utf8, caret, g_preeditUser); +} + +void SwizzledSetMarkedText(id self, SEL _cmd, id string, NSRange selectedRange, NSRange replacementRange) +{ + if (g_origSetMarkedText) { + ((void (*)(id, SEL, id, NSRange, NSRange))g_origSetMarkedText)(self, _cmd, string, selectedRange, replacementRange); + } + NSString* text = [string isKindOfClass:[NSAttributedString class]] ? [(NSAttributedString*)string string] + : (NSString*)string; + g_markedLength = text ? [text length] : 0; + ReportPreedit(text, selectedRange); +} + +void SwizzledUnmarkText(id self, SEL _cmd) +{ + if (g_origUnmarkText) { + ((void (*)(id, SEL))g_origUnmarkText)(self, _cmd); + } + // Composition finished or was cancelled; nothing is pending any more. + g_markedLength = 0; + ReportPreedit(@"", NSMakeRange(0, 0)); +} + +void SwizzledInsertText(id self, SEL _cmd, id string, NSRange replacementRange) +{ + if (g_origInsertText) { + ((void (*)(id, SEL, id, NSRange))g_origInsertText)(self, _cmd, string, replacementRange); + } + // Committing ends the composition, but GLFW keeps its marked text around. + // Left set, hasMarkedText stays true and every later keystroke would be + // treated as part of a composition and withheld from the application. + NSView* view = (NSView*)self; + if ([view respondsToSelector:@selector(unmarkText)]) { + [(id)view unmarkText]; + } +} + +// GLFW reports the marked range one unit short of the text it holds, and does +// not implement selectedRange at all. Both are consulted when the system works +// out where to put the candidate window, and a bad answer makes it give up and +// fall back to a screen corner. +NSRange SwizzledMarkedRange(id self, SEL _cmd) +{ + NSView* view = (NSView*)self; + if (![view respondsToSelector:@selector(hasMarkedText)] || + ![(id)view hasMarkedText]) { + return NSMakeRange(NSNotFound, 0); + } + return NSMakeRange(0, g_markedLength); +} + +NSRange SwizzledSelectedRange(id self, SEL _cmd) +{ + // Report the caret sitting at the end of the composition. + return NSMakeRange(g_markedLength, 0); +} + +void SwizzledKeyDown(id self, SEL _cmd, NSEvent* event) +{ + // GLFW reports the raw key to the application before handing the event to + // the input method. While a composition is in progress that lets keys the + // IME is about to consume — Return to commit, Space and digits to pick a + // candidate, arrows to move through them — also reach the UI, which then + // acts on them (confirming a dialog, for instance) before the composed + // text ever arrives. Keep those keys to the input method alone. + NSView* view = (NSView*)self; + if ([view respondsToSelector:@selector(hasMarkedText)] && + [(id)view hasMarkedText]) { + [view interpretKeyEvents:@[event]]; + return; + } + if (g_origKeyDown) { + ((void (*)(id, SEL, NSEvent*))g_origKeyDown)(self, _cmd, event); + } +} + +NSRect SwizzledFirstRect(id self, SEL _cmd, NSRange range, NSRangePointer actualRange) +{ + if (actualRange) { + *actualRange = range; + } + NSWindow* window = g_window ? g_window : [(NSView*)self window]; + if (!window) { + return NSMakeRect(0.0, 0.0, 0.0, 0.0); + } + NSView* view = [window contentView]; + const CGFloat viewHeight = [view bounds].size.height; + + // The UI works top-down, AppKit bottom-up. + NSRect inView = NSMakeRect(g_caretRect.origin.x, + viewHeight - g_caretRect.origin.y - g_caretRect.size.height, + g_caretRect.size.width, + g_caretRect.size.height); + NSRect inWindow = [view convertRect:inView toView:nil]; + return [window convertRectToScreen:inWindow]; +} + +} // namespace + +bool IME_Available() +{ + return true; +} + +void IME_SetCaretRect(int x, int y, int width, int height) +{ + g_caretRect = NSMakeRect((CGFloat)x, (CGFloat)y, (CGFloat)(width > 0 ? width : 1), (CGFloat)(height > 0 ? height : 16)); +} + +void IME_Install(GLFWwindow* window, ime_preeditFn_t fn, void* userData) +{ + g_preeditFn = fn; + g_preeditUser = userData; + + if (g_installed || !window) { + return; + } + + NSWindow* nsWindow = glfwGetCocoaWindow(window); + if (!nsWindow) { + return; + } + g_window = nsWindow; + + NSView* view = [nsWindow contentView]; + Class cls = [view class]; + if (!cls) { + return; + } + + auto replace = [cls](SEL sel, IMP replacement, const char* types) -> IMP { + Method m = class_getInstanceMethod(cls, sel); + if (!m) { + // The view doesn't implement it; add ours so the system still asks us. + class_addMethod(cls, sel, replacement, types); + return nullptr; + } + return method_setImplementation(m, replacement); + }; + + g_origSetMarkedText = replace(@selector(setMarkedText:selectedRange:replacementRange:), + (IMP)SwizzledSetMarkedText, "v@:@{_NSRange=QQ}{_NSRange=QQ}"); + g_origUnmarkText = replace(@selector(unmarkText), (IMP)SwizzledUnmarkText, "v@:"); + g_origFirstRect = replace(@selector(firstRectForCharacterRange:actualRange:), + (IMP)SwizzledFirstRect, "{_NSRect={_NSPoint=dd}{_NSSize=dd}}@:{_NSRange=QQ}^{_NSRange}"); + g_origKeyDown = replace(@selector(keyDown:), (IMP)SwizzledKeyDown, "v@:@"); + replace(@selector(markedRange), (IMP)SwizzledMarkedRange, "{_NSRange=QQ}@:"); + replace(@selector(selectedRange), (IMP)SwizzledSelectedRange, "{_NSRange=QQ}@:"); + g_origInsertText = replace(@selector(insertText:replacementRange:), + (IMP)SwizzledInsertText, "v@:@{_NSRange=QQ}"); + + g_installed = true; +} diff --git a/engine/system/win/sys_video.cpp b/engine/system/win/sys_video.cpp index 6f61bffb..e2bbcdc2 100644 --- a/engine/system/win/sys_video.cpp +++ b/engine/system/win/sys_video.cpp @@ -8,6 +8,7 @@ #include #include "sys_local.h" +#include "sys_ime.h" #include "core.h" #include @@ -518,6 +519,13 @@ int sys_video_c::Apply(sys_vidSet_s* set) auto sys = (sys_main_c*)glfwGetWindowUserPointer(wnd); sys->video->PosChanged(x, y); }); + // Route input-method composition updates into the UI. Committed text + // still arrives through the character callback below. + IME_Install(wnd, [](const char* text, int caret, void* user) { + auto sys = (sys_main_c*)user; + sys->core->PreeditEvent(text, caret); + }, sys); + glfwSetCharCallback(wnd, [](GLFWwindow* wnd, uint32_t codepoint) { auto sys = (sys_main_c*)glfwGetWindowUserPointer(wnd); if (ImGui::GetIO().WantCaptureKeyboard) { diff --git a/ui.h b/ui.h index ca68addd..d3fc8919 100644 --- a/ui.h +++ b/ui.h @@ -18,5 +18,6 @@ class ui_IMain { virtual void Frame() = 0; virtual void Shutdown() = 0; virtual void KeyEvent(int key, int type) = 0; + virtual void PreeditEvent(const char* utf8Text, int caret) = 0; virtual bool CanExit() = 0; }; diff --git a/ui_api.cpp b/ui_api.cpp index 7feebc66..9ae3f2ef 100644 --- a/ui_api.cpp +++ b/ui_api.cpp @@ -5,6 +5,7 @@ // #include "ui_local.h" +#include "system/win/sys_ime.h" #include #include @@ -756,6 +757,24 @@ static int l_SetClearColor(lua_State* L) return 0; } +static int l_SetIMECaretRect(lua_State* L) +{ + ui_main_c* ui = GetUIPtr(L); + int n = lua_gettop(L); + ui->LAssert(L, n >= 4, "Usage: SetIMECaretRect(x, y, width, height)"); + for (int i = 1; i <= 4; i++) { + ui->LAssert(L, lua_isnumber(L, i), "SetIMECaretRect() argument %d: expected number, got %s", i, luaL_typename(L, i)); + } + // Coordinates arrive relative to the active viewport, which is what a + // control draws in. Shift them into window space for the input method. + // UI coordinates already match the window's logical points, so they are + // passed through unscaled. Read as numbers, not integers: text widths are + // fractional, and an integer read of a non-integral value yields zero. + IME_SetCaretRect((int)lua_tonumber(L, 1), (int)lua_tonumber(L, 2), + (int)lua_tonumber(L, 3), (int)lua_tonumber(L, 4)); + return 0; +} + static int l_SetDrawLayer(lua_State* L) { ui_main_c* ui = GetUIPtr(L); @@ -2210,6 +2229,7 @@ int ui_main_c::InitAPI(lua_State* L) // Rendering ADDFUNC(RenderInit); ADDFUNC(GetScreenSize); + ADDFUNC(SetIMECaretRect); ADDFUNC(GetScreenScale); ADDFUNC(SetClearColor); ADDFUNC(SetDrawLayer); diff --git a/ui_main.cpp b/ui_main.cpp index 09e59be6..ca1b661c 100644 --- a/ui_main.cpp +++ b/ui_main.cpp @@ -521,7 +521,7 @@ void ui_main_c::KeyEvent(int key, int type) switch (type) { case KE_CHAR: - CallKeyHandler("OnChar", key, false); + CallCharHandler("OnChar", (char32_t)key); break; case KE_KEYDOWN: case KE_DBLCLK: @@ -545,6 +545,54 @@ void ui_main_c::KeyEvent(int key, int type) } } +// Text input arrives as a Unicode codepoint. The UI layer works in UTF-8, +// so hand it the encoded character rather than routing it through the key +// name table, which only knows named keys and would yield "?". +void ui_main_c::CallCharHandler(const char* hname, char32_t codepoint) +{ + if ( !L ) return; + int extraArgs = PushCallback(hname); + if (extraArgs < 0) { + return; + } + char utf8[4]; + int len = 0; + uint32_t cp = (uint32_t)codepoint; + if (cp < 0x80) { + utf8[len++] = (char)cp; + } + else if (cp < 0x800) { + utf8[len++] = (char)(0xC0 | (cp >> 6)); + utf8[len++] = (char)(0x80 | (cp & 0x3F)); + } + else if (cp < 0x10000) { + utf8[len++] = (char)(0xE0 | (cp >> 12)); + utf8[len++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + utf8[len++] = (char)(0x80 | (cp & 0x3F)); + } + else { + utf8[len++] = (char)(0xF0 | (cp >> 18)); + utf8[len++] = (char)(0x80 | ((cp >> 12) & 0x3F)); + utf8[len++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + utf8[len++] = (char)(0x80 | (cp & 0x3F)); + } + lua_pushlstring(L, utf8, len); + lua_pushboolean(L, false); + PCall(2 + extraArgs, 0); +} + +void ui_main_c::PreeditEvent(const char* utf8Text, int caret) +{ + if ( !L ) return; + int extraArgs = PushCallback("OnPreedit"); + if (extraArgs < 0) { + return; + } + lua_pushstring(L, utf8Text ? utf8Text : ""); + lua_pushinteger(L, caret); + PCall(2 + extraArgs, 0); +} + void ui_main_c::CallKeyHandler(const char* hname, int key, bool dblclk) { if ( !L ) return; diff --git a/ui_main.h b/ui_main.h index 02ec9072..a042118b 100644 --- a/ui_main.h +++ b/ui_main.h @@ -68,6 +68,8 @@ class ui_main_c: public ui_IMain { void DoError(const char* msg, const char* error); void CallKeyHandler(const char* hname, int key, bool dblclk); + void CallCharHandler(const char* hname, char32_t codepoint); + void PreeditEvent(const char* utf8Text, int caret); const char* NameForKey(int key); int KeyForName(const char* name); From a43b601eb3974728c5b068f99092e4f64b7f519a Mon Sep 17 00:00:00 2001 From: Jimmy Chang Date: Tue, 11 Aug 2026 00:00:52 +0800 Subject: [PATCH 3/3] Keep the input method's caret position current The system caches where it believes the caret is and only re-reads it when told the coordinates are stale, so a candidate window could open at a position the caret had already left. Invalidate those coordinates whenever the reported rectangle changes. Also answer attributedSubstringForProposedRange with the text being composed rather than nil, which is what an input method consults for context around the composition. --- engine/system/win/sys_ime_macos.mm | 457 +++++++++++++++-------------- 1 file changed, 244 insertions(+), 213 deletions(-) diff --git a/engine/system/win/sys_ime_macos.mm b/engine/system/win/sys_ime_macos.mm index 274c62dd..d3e5eda1 100644 --- a/engine/system/win/sys_ime_macos.mm +++ b/engine/system/win/sys_ime_macos.mm @@ -1,213 +1,244 @@ -// SimpleGraphic Engine -// -// Input Method Editor bridge — macOS -// -// GLFW's content view already conforms to NSTextInputClient, which is how the -// system hands it composition text and asks where the caret is. Two of those -// methods are stubs: setMarkedText: stores the text and does nothing with it, -// and firstRectForCharacterRange: always answers with the window's corner. -// -// Rather than fork GLFW we replace those two implementations at runtime and -// chain to the originals, so an upgrade of the library does not need the -// patch to be reapplied. Committed text is untouched — it already flows out -// through insertText: into the normal character callback. - -#include "sys_ime.h" - -#include -#include - -#define GLFW_EXPOSE_NATIVE_COCOA -#include -#include - -#include - -namespace { - -ime_preeditFn_t g_preeditFn = nullptr; -void* g_preeditUser = nullptr; -bool g_installed = false; - -// Caret rectangle in window coordinates, top-left origin, as the UI sees it. -NSRect g_caretRect = NSMakeRect(0.0, 0.0, 1.0, 16.0); -NSWindow* g_window = nil; - -// Length of the current composition in UTF-16 units, as AppKit counts them. -NSUInteger g_markedLength = 0; - -IMP g_origSetMarkedText = nullptr; -IMP g_origUnmarkText = nullptr; -IMP g_origFirstRect = nullptr; -IMP g_origKeyDown = nullptr; -IMP g_origInsertText = nullptr; - -void ReportPreedit(NSString* text, NSRange selected) -{ - if (!g_preeditFn) { - return; - } - const char* utf8 = text ? [text UTF8String] : ""; - if (!utf8) { - utf8 = ""; - } - // Convert the selection start from UTF-16 units to a byte offset. - int caret = 0; - if (text && selected.location != NSNotFound && selected.location <= [text length]) { - NSString* head = [text substringToIndex:selected.location]; - caret = (int)[head lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; - } - g_preeditFn(utf8, caret, g_preeditUser); -} - -void SwizzledSetMarkedText(id self, SEL _cmd, id string, NSRange selectedRange, NSRange replacementRange) -{ - if (g_origSetMarkedText) { - ((void (*)(id, SEL, id, NSRange, NSRange))g_origSetMarkedText)(self, _cmd, string, selectedRange, replacementRange); - } - NSString* text = [string isKindOfClass:[NSAttributedString class]] ? [(NSAttributedString*)string string] - : (NSString*)string; - g_markedLength = text ? [text length] : 0; - ReportPreedit(text, selectedRange); -} - -void SwizzledUnmarkText(id self, SEL _cmd) -{ - if (g_origUnmarkText) { - ((void (*)(id, SEL))g_origUnmarkText)(self, _cmd); - } - // Composition finished or was cancelled; nothing is pending any more. - g_markedLength = 0; - ReportPreedit(@"", NSMakeRange(0, 0)); -} - -void SwizzledInsertText(id self, SEL _cmd, id string, NSRange replacementRange) -{ - if (g_origInsertText) { - ((void (*)(id, SEL, id, NSRange))g_origInsertText)(self, _cmd, string, replacementRange); - } - // Committing ends the composition, but GLFW keeps its marked text around. - // Left set, hasMarkedText stays true and every later keystroke would be - // treated as part of a composition and withheld from the application. - NSView* view = (NSView*)self; - if ([view respondsToSelector:@selector(unmarkText)]) { - [(id)view unmarkText]; - } -} - -// GLFW reports the marked range one unit short of the text it holds, and does -// not implement selectedRange at all. Both are consulted when the system works -// out where to put the candidate window, and a bad answer makes it give up and -// fall back to a screen corner. -NSRange SwizzledMarkedRange(id self, SEL _cmd) -{ - NSView* view = (NSView*)self; - if (![view respondsToSelector:@selector(hasMarkedText)] || - ![(id)view hasMarkedText]) { - return NSMakeRange(NSNotFound, 0); - } - return NSMakeRange(0, g_markedLength); -} - -NSRange SwizzledSelectedRange(id self, SEL _cmd) -{ - // Report the caret sitting at the end of the composition. - return NSMakeRange(g_markedLength, 0); -} - -void SwizzledKeyDown(id self, SEL _cmd, NSEvent* event) -{ - // GLFW reports the raw key to the application before handing the event to - // the input method. While a composition is in progress that lets keys the - // IME is about to consume — Return to commit, Space and digits to pick a - // candidate, arrows to move through them — also reach the UI, which then - // acts on them (confirming a dialog, for instance) before the composed - // text ever arrives. Keep those keys to the input method alone. - NSView* view = (NSView*)self; - if ([view respondsToSelector:@selector(hasMarkedText)] && - [(id)view hasMarkedText]) { - [view interpretKeyEvents:@[event]]; - return; - } - if (g_origKeyDown) { - ((void (*)(id, SEL, NSEvent*))g_origKeyDown)(self, _cmd, event); - } -} - -NSRect SwizzledFirstRect(id self, SEL _cmd, NSRange range, NSRangePointer actualRange) -{ - if (actualRange) { - *actualRange = range; - } - NSWindow* window = g_window ? g_window : [(NSView*)self window]; - if (!window) { - return NSMakeRect(0.0, 0.0, 0.0, 0.0); - } - NSView* view = [window contentView]; - const CGFloat viewHeight = [view bounds].size.height; - - // The UI works top-down, AppKit bottom-up. - NSRect inView = NSMakeRect(g_caretRect.origin.x, - viewHeight - g_caretRect.origin.y - g_caretRect.size.height, - g_caretRect.size.width, - g_caretRect.size.height); - NSRect inWindow = [view convertRect:inView toView:nil]; - return [window convertRectToScreen:inWindow]; -} - -} // namespace - -bool IME_Available() -{ - return true; -} - -void IME_SetCaretRect(int x, int y, int width, int height) -{ - g_caretRect = NSMakeRect((CGFloat)x, (CGFloat)y, (CGFloat)(width > 0 ? width : 1), (CGFloat)(height > 0 ? height : 16)); -} - -void IME_Install(GLFWwindow* window, ime_preeditFn_t fn, void* userData) -{ - g_preeditFn = fn; - g_preeditUser = userData; - - if (g_installed || !window) { - return; - } - - NSWindow* nsWindow = glfwGetCocoaWindow(window); - if (!nsWindow) { - return; - } - g_window = nsWindow; - - NSView* view = [nsWindow contentView]; - Class cls = [view class]; - if (!cls) { - return; - } - - auto replace = [cls](SEL sel, IMP replacement, const char* types) -> IMP { - Method m = class_getInstanceMethod(cls, sel); - if (!m) { - // The view doesn't implement it; add ours so the system still asks us. - class_addMethod(cls, sel, replacement, types); - return nullptr; - } - return method_setImplementation(m, replacement); - }; - - g_origSetMarkedText = replace(@selector(setMarkedText:selectedRange:replacementRange:), - (IMP)SwizzledSetMarkedText, "v@:@{_NSRange=QQ}{_NSRange=QQ}"); - g_origUnmarkText = replace(@selector(unmarkText), (IMP)SwizzledUnmarkText, "v@:"); - g_origFirstRect = replace(@selector(firstRectForCharacterRange:actualRange:), - (IMP)SwizzledFirstRect, "{_NSRect={_NSPoint=dd}{_NSSize=dd}}@:{_NSRange=QQ}^{_NSRange}"); - g_origKeyDown = replace(@selector(keyDown:), (IMP)SwizzledKeyDown, "v@:@"); - replace(@selector(markedRange), (IMP)SwizzledMarkedRange, "{_NSRange=QQ}@:"); - replace(@selector(selectedRange), (IMP)SwizzledSelectedRange, "{_NSRange=QQ}@:"); - g_origInsertText = replace(@selector(insertText:replacementRange:), - (IMP)SwizzledInsertText, "v@:@{_NSRange=QQ}"); - - g_installed = true; -} +// SimpleGraphic Engine +// +// Input Method Editor bridge — macOS +// +// GLFW's content view already conforms to NSTextInputClient, which is how the +// system hands it composition text and asks where the caret is. Two of those +// methods are stubs: setMarkedText: stores the text and does nothing with it, +// and firstRectForCharacterRange: always answers with the window's corner. +// +// Rather than fork GLFW we replace those two implementations at runtime and +// chain to the originals, so an upgrade of the library does not need the +// patch to be reapplied. Committed text is untouched — it already flows out +// through insertText: into the normal character callback. + +#include "sys_ime.h" + +#include +#include + +#define GLFW_EXPOSE_NATIVE_COCOA +#include +#include + +#include + +namespace { + +ime_preeditFn_t g_preeditFn = nullptr; +void* g_preeditUser = nullptr; +bool g_installed = false; + +// Caret rectangle in window coordinates, top-left origin, as the UI sees it. +NSRect g_caretRect = NSMakeRect(0.0, 0.0, 1.0, 16.0); +NSWindow* g_window = nil; + +// The composition currently being edited, and its length in UTF-16 units. +NSString* g_markedString = nil; +NSUInteger g_markedLength = 0; + +IMP g_origSetMarkedText = nullptr; +IMP g_origUnmarkText = nullptr; +IMP g_origFirstRect = nullptr; +IMP g_origKeyDown = nullptr; +IMP g_origInsertText = nullptr; + +void ReportPreedit(NSString* text, NSRange selected) +{ + if (!g_preeditFn) { + return; + } + const char* utf8 = text ? [text UTF8String] : ""; + if (!utf8) { + utf8 = ""; + } + // Convert the selection start from UTF-16 units to a byte offset. + int caret = 0; + if (text && selected.location != NSNotFound && selected.location <= [text length]) { + NSString* head = [text substringToIndex:selected.location]; + caret = (int)[head lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + } + g_preeditFn(utf8, caret, g_preeditUser); +} + +void SwizzledSetMarkedText(id self, SEL _cmd, id string, NSRange selectedRange, NSRange replacementRange) +{ + if (g_origSetMarkedText) { + ((void (*)(id, SEL, id, NSRange, NSRange))g_origSetMarkedText)(self, _cmd, string, selectedRange, replacementRange); + } + NSString* text = [string isKindOfClass:[NSAttributedString class]] ? [(NSAttributedString*)string string] + : (NSString*)string; + [g_markedString release]; + g_markedString = text ? [text copy] : nil; + g_markedLength = text ? [text length] : 0; + ReportPreedit(text, selectedRange); +} + +void SwizzledUnmarkText(id self, SEL _cmd) +{ + if (g_origUnmarkText) { + ((void (*)(id, SEL))g_origUnmarkText)(self, _cmd); + } + // Composition finished or was cancelled; nothing is pending any more. + [g_markedString release]; + g_markedString = nil; + g_markedLength = 0; + ReportPreedit(@"", NSMakeRange(0, 0)); +} + +void SwizzledInsertText(id self, SEL _cmd, id string, NSRange replacementRange) +{ + if (g_origInsertText) { + ((void (*)(id, SEL, id, NSRange))g_origInsertText)(self, _cmd, string, replacementRange); + } + // Committing ends the composition, but GLFW keeps its marked text around. + // Left set, hasMarkedText stays true and every later keystroke would be + // treated as part of a composition and withheld from the application. + NSView* view = (NSView*)self; + if ([view respondsToSelector:@selector(unmarkText)]) { + [(id)view unmarkText]; + } +} + +// GLFW reports the marked range one unit short of the text it holds, and does +// not implement selectedRange at all. Both are consulted when the system works +// out where to put the candidate window, and a bad answer makes it give up and +// fall back to a screen corner. +NSRange SwizzledMarkedRange(id self, SEL _cmd) +{ + NSView* view = (NSView*)self; + if (![view respondsToSelector:@selector(hasMarkedText)] || + ![(id)view hasMarkedText]) { + return NSMakeRange(NSNotFound, 0); + } + return NSMakeRange(0, g_markedLength); +} + +NSAttributedString* SwizzledAttributedSubstring(id self, SEL _cmd, NSRange range, NSRangePointer actualRange) +{ + // GLFW answers nil here, leaving the input method without any context for + // the text it is composing. + if (!g_markedString || [g_markedString length] == 0) { + return nil; + } + NSRange clamped = NSIntersectionRange(range, NSMakeRange(0, [g_markedString length])); + if (clamped.length == 0) { + return nil; + } + if (actualRange) { + *actualRange = clamped; + } + return [[[NSAttributedString alloc] initWithString:[g_markedString substringWithRange:clamped]] autorelease]; +} + +NSRange SwizzledSelectedRange(id self, SEL _cmd) +{ + // Report the caret sitting at the end of the composition. + return NSMakeRange(g_markedLength, 0); +} + +void SwizzledKeyDown(id self, SEL _cmd, NSEvent* event) +{ + // GLFW reports the raw key to the application before handing the event to + // the input method. While a composition is in progress that lets keys the + // IME is about to consume — Return to commit, Space and digits to pick a + // candidate, arrows to move through them — also reach the UI, which then + // acts on them (confirming a dialog, for instance) before the composed + // text ever arrives. Keep those keys to the input method alone. + NSView* view = (NSView*)self; + if ([view respondsToSelector:@selector(hasMarkedText)] && + [(id)view hasMarkedText]) { + [view interpretKeyEvents:@[event]]; + return; + } + if (g_origKeyDown) { + ((void (*)(id, SEL, NSEvent*))g_origKeyDown)(self, _cmd, event); + } +} + +NSRect SwizzledFirstRect(id self, SEL _cmd, NSRange range, NSRangePointer actualRange) +{ + if (actualRange) { + *actualRange = range; + } + NSWindow* window = g_window ? g_window : [(NSView*)self window]; + if (!window) { + return NSMakeRect(0.0, 0.0, 0.0, 0.0); + } + NSView* view = [window contentView]; + const CGFloat viewHeight = [view bounds].size.height; + + // The UI works top-down, AppKit bottom-up. + NSRect inView = NSMakeRect(g_caretRect.origin.x, + viewHeight - g_caretRect.origin.y - g_caretRect.size.height, + g_caretRect.size.width, + g_caretRect.size.height); + NSRect inWindow = [view convertRect:inView toView:nil]; + return [window convertRectToScreen:inWindow]; +} + +} // namespace + +bool IME_Available() +{ + return true; +} + +void IME_SetCaretRect(int x, int y, int width, int height) +{ + NSRect updated = NSMakeRect((CGFloat)x, (CGFloat)y, (CGFloat)(width > 0 ? width : 1), (CGFloat)(height > 0 ? height : 16)); + if (!NSEqualRects(updated, g_caretRect)) { + g_caretRect = updated; + // The system caches where it thinks the caret is and only re-asks when + // told the coordinates went stale. Without this the candidate window + // opens wherever it last believed the caret to be. + [[NSTextInputContext currentInputContext] invalidateCharacterCoordinates]; + } +} + +void IME_Install(GLFWwindow* window, ime_preeditFn_t fn, void* userData) +{ + g_preeditFn = fn; + g_preeditUser = userData; + + if (g_installed || !window) { + return; + } + + NSWindow* nsWindow = glfwGetCocoaWindow(window); + if (!nsWindow) { + return; + } + g_window = nsWindow; + + NSView* view = [nsWindow contentView]; + Class cls = [view class]; + if (!cls) { + return; + } + + auto replace = [cls](SEL sel, IMP replacement, const char* types) -> IMP { + Method m = class_getInstanceMethod(cls, sel); + if (!m) { + // The view doesn't implement it; add ours so the system still asks us. + class_addMethod(cls, sel, replacement, types); + return nullptr; + } + return method_setImplementation(m, replacement); + }; + + g_origSetMarkedText = replace(@selector(setMarkedText:selectedRange:replacementRange:), + (IMP)SwizzledSetMarkedText, "v@:@{_NSRange=QQ}{_NSRange=QQ}"); + g_origUnmarkText = replace(@selector(unmarkText), (IMP)SwizzledUnmarkText, "v@:"); + g_origFirstRect = replace(@selector(firstRectForCharacterRange:actualRange:), + (IMP)SwizzledFirstRect, "{_NSRect={_NSPoint=dd}{_NSSize=dd}}@:{_NSRange=QQ}^{_NSRange}"); + g_origKeyDown = replace(@selector(keyDown:), (IMP)SwizzledKeyDown, "v@:@"); + replace(@selector(markedRange), (IMP)SwizzledMarkedRange, "{_NSRange=QQ}@:"); + replace(@selector(selectedRange), (IMP)SwizzledSelectedRange, "{_NSRange=QQ}@:"); + replace(@selector(attributedSubstringForProposedRange:actualRange:), + (IMP)SwizzledAttributedSubstring, "@@:{_NSRange=QQ}^{_NSRange}"); + g_origInsertText = replace(@selector(insertText:replacementRange:), + (IMP)SwizzledInsertText, "v@:@{_NSRange=QQ}"); + + g_installed = true; +}