diff --git a/CMakeLists.txt b/CMakeLists.txt index b992d62e..fa644234 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,8 @@ 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/system/win/sys_ime.h" "engine/render/r_font.cpp" "engine/render/r_font.h" "engine/render/r_main.cpp" @@ -80,6 +82,8 @@ 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_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_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/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); } 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..d3e5eda1 --- /dev/null +++ b/engine/system/win/sys_ime_macos.mm @@ -0,0 +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; + +// 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; +} 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);