From 5c2c95765e8aef14418f9a8ccb8dafe6e30e974a Mon Sep 17 00:00:00 2001 From: Guldoman Date: Sun, 16 Oct 2022 01:58:51 +0200 Subject: [PATCH] Add IME support (#991) --- data/core/doc/init.lua | 12 ++++++ data/core/docview.lua | 71 +++++++++++++++++++++++++++++++++--- data/core/ime.lua | 83 ++++++++++++++++++++++++++++++++++++++++++ data/core/init.lua | 6 +++ data/core/keymap.lua | 5 +++ data/core/rootview.lua | 3 ++ data/core/view.lua | 4 ++ src/api/system.c | 36 ++++++++++++++++++ src/main.c | 6 +++ 9 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 data/core/ime.lua diff --git a/data/core/doc/init.lua b/data/core/doc/init.lua index 4136575d..12fb2dde 100644 --- a/data/core/doc/init.lua +++ b/data/core/doc/init.lua @@ -456,6 +456,18 @@ function Doc:text_input(text, idx) end end + +function Doc:ime_text_editing(text, start, length, idx) + for sidx, line1, col1, line2, col2 in self:get_selections(true, idx or true) do + if line1 ~= line2 or col1 ~= col2 then + self:delete_to_cursor(sidx) + end + self:insert(line1, col1, text) + self:set_selections(sidx, line1, col1 + #text, line1, col1) + end +end + + function Doc:replace_cursor(idx, line1, col1, line2, col2, fn) local old_text = self:get_text(line1, col1, line2, col2) local new_text, res = fn(old_text) diff --git a/data/core/docview.lua b/data/core/docview.lua index f4270e9f..cc8ad7ee 100644 --- a/data/core/docview.lua +++ b/data/core/docview.lua @@ -4,6 +4,7 @@ local config = require "core.config" local style = require "core.style" local keymap = require "core.keymap" local translate = require "core.doc.translate" +local ime = require "core.ime" local View = require "core.view" ---@class core.docview : core.view @@ -60,6 +61,7 @@ function DocView:new(doc) self.doc = assert(doc) self.font = "code_font" self.last_x_offset = {} + self.ime_selection = { from = 0, size = 0 } end @@ -292,13 +294,42 @@ function DocView:on_text_input(text) self.doc:text_input(text) end +function DocView:on_ime_text_editing(text, start, length) + self.doc:ime_text_editing(text, start, length) + self.ime_selection.from = start + self.ime_selection.size = length + + -- Set the composition bounding box that the system IME + -- will consider when drawing its interface + local line1, col1, line2, col2 = self.doc:get_selection(true) + local x, y = self:get_line_screen_position(line1) + local h = self:get_line_height() + local col = math.min(col1, col2) + + local x1, x2 = 0, 0 + + if length > 0 then + -- focus on a part of the text + local from = col + start + local to = from + length + x1 = self:get_col_x_offset(line1, from) + x2 = self:get_col_x_offset(line1, to) + else + -- focus the whole text + x1 = self:get_col_x_offset(line1, col1) + x2 = self:get_col_x_offset(line2, col2) + end + + ime.set_location(x + x1, y, x2 - x1, h) + self:scroll_to_make_visible(line1, col + start) +end function DocView:update() -- scroll to make caret visible and reset blink timer if it moved local line1, col1, line2, col2 = self.doc:get_selection() if (line1 ~= self.last_line1 or col1 ~= self.last_col1 or line2 ~= self.last_line2 or col2 ~= self.last_col2) and self.size.x > 0 then - if core.active_view == self then + if core.active_view == self and not ime.editing then self:scroll_to_make_visible(line1, col1) end core.blink_reset() @@ -399,17 +430,45 @@ function DocView:draw_line_gutter(line, x, y, width) end +function DocView:draw_ime_decoration(line1, col1, line2, col2) + local x, y = self:get_line_screen_position(line1) + local line_size = math.max(1, SCALE) + local lh = self:get_line_height() + + -- Draw IME underline + local x1 = self:get_col_x_offset(line1, col1) + local x2 = self:get_col_x_offset(line2, col2) + renderer.draw_rect(x + math.min(x1, x2), y + lh - line_size, math.abs(x1 - x2), line_size, style.text) + + -- Draw IME selection + local col = math.min(col1, col2) + local from = col + self.ime_selection.from + local to = from + self.ime_selection.size + x1 = self:get_col_x_offset(line1, from) + if from ~= to then + x2 = self:get_col_x_offset(line1, to) + line_size = style.caret_width + renderer.draw_rect(x + math.min(x1, x2), y + lh - line_size, math.abs(x1 - x2), line_size, style.caret) + end + self:draw_caret(x + x1, y) +end + + function DocView:draw_overlay() if core.active_view == self then local minline, maxline = self:get_visible_line_range() -- draw caret if it overlaps this line local T = config.blink_period - for _, line, col in self.doc:get_selections() do - if line >= minline and line <= maxline + for _, line1, col1, line2, col2 in self.doc:get_selections() do + if line1 >= minline and line1 <= maxline and system.window_has_focus() then - if config.disable_blink - or (core.blink_timer - core.blink_start) % T < T / 2 then - self:draw_caret(self:get_line_screen_position(line, col)) + if ime.editing then + self:draw_ime_decoration(line1, col1, line2, col2) + else + if config.disable_blink + or (core.blink_timer - core.blink_start) % T < T / 2 then + self:draw_caret(self:get_line_screen_position(line1, col1)) + end end end end diff --git a/data/core/ime.lua b/data/core/ime.lua new file mode 100644 index 00000000..f5aea992 --- /dev/null +++ b/data/core/ime.lua @@ -0,0 +1,83 @@ +local core = require "core" + +local ime = { } + +function ime.reset() + ime.editing = false +end + +---Convert from utf-8 offset and length (from SDL) to byte offsets +---@param text string @Textediting string +---@param start integer @0-based utf-8 offset of the starting position of the selection +---@param length integer @Size of the utf-8 length of the selection +function ime.ingest(text, start, length) + if #text == 0 then + -- finished textediting + ime.reset() + return "", 0, 0 + end + + ime.editing = true + + if start < 0 then + -- we assume no selection and caret at the end + return text, #text, 0 + end + + -- start is 0-based, so we use start + 1 + local start_byte = utf8.offset(text, start + 1) + if not start_byte then + -- bad start offset + -- we assume it meant the last byte of the text + start_byte = #text + else + start_byte = math.min(start_byte - 1, #text) + end + + if length < 0 then + -- caret only + return text, start_byte, 0 + end + + local end_byte = utf8.offset(text, start + length + 1) + if not end_byte or end_byte - 1 < start_byte then + -- bad length, assume caret only + return text, start_byte, 0 + end + + end_byte = math.min(end_byte - 1, #text) + return text, start_byte, end_byte - start_byte +end + +---Forward the given textediting SDL event data to Views. +---@param text string @Textediting string +---@param start integer @0-based utf-8 offset of the starting position of the selection +---@param length integer @Size of the utf-8 length of the selection +function ime.on_text_editing(text, start, length, ...) + if ime.editing or #text > 0 then + core.root_view:on_ime_text_editing(ime.ingest(text, start, length, ...)) + end +end + +---Stop IME composition. +---Might not completely work on every platform. +function ime.stop() + if ime.editing then + -- SDL_ClearComposition for now doesn't work everywhere + system.clear_ime() + ime.on_text_editing("", 0, 0) + end +end + +---Set the bounding box of the text pertaining the IME. +---The IME will draw its interface based on this info. +---@param x number +---@param y number +---@param w number +---@param h number +function ime.set_location(x, y, w, h) + system.set_text_input_rect(x, y, w, h) +end + +ime.reset() +return ime diff --git a/data/core/init.lua b/data/core/init.lua index 5af53ca9..a9ce4459 100644 --- a/data/core/init.lua +++ b/data/core/init.lua @@ -6,6 +6,7 @@ local style = require "colors.default" local command local keymap local dirwatch +local ime local RootView local StatusView local TitleView @@ -657,6 +658,7 @@ function core.init() command = require "core.command" keymap = require "core.keymap" dirwatch = require "core.dirwatch" + ime = require "core.ime" RootView = require "core.rootview" StatusView = require "core.statusview" TitleView = require "core.titleview" @@ -1051,6 +1053,8 @@ end function core.set_active_view(view) assert(view, "Tried to set active view to nil") + -- Reset the IME even if the focus didn't change + ime.stop() if view ~= core.active_view then if core.active_view and core.active_view.force_focus then core.next_active_view = view @@ -1215,6 +1219,8 @@ function core.on_event(type, ...) local did_keymap = false if type == "textinput" then core.root_view:on_text_input(...) + elseif type == "textediting" then + ime.on_text_editing(...) elseif type == "keypressed" then did_keymap = keymap.on_key_pressed(...) elseif type == "keyreleased" then diff --git a/data/core/keymap.lua b/data/core/keymap.lua index ea07aa19..c392c546 100644 --- a/data/core/keymap.lua +++ b/data/core/keymap.lua @@ -1,6 +1,7 @@ local core = require "core" local command = require "core.command" local config = require "core.config" +local ime = require "core.ime" local keymap = {} ---@alias keymap.shortcut string @@ -177,6 +178,10 @@ end -- Events listening -------------------------------------------------------------------------------- function keymap.on_key_pressed(k, ...) + -- In Windows during IME composition, input is still sent to us + -- so we just ignore it + if ime.editing then return false end + local mk = modkey_map[k] if mk then keymap.modkeys[mk] = true diff --git a/data/core/rootview.lua b/data/core/rootview.lua index c4eb656f..7230e8e1 100644 --- a/data/core/rootview.lua +++ b/data/core/rootview.lua @@ -334,6 +334,9 @@ function RootView:on_text_input(...) core.active_view:on_text_input(...) end +function RootView:on_ime_text_editing(...) + core.active_view:on_ime_text_editing(...) +end function RootView:on_focus_lost(...) -- We force a redraw so documents can redraw without the cursor. diff --git a/data/core/view.lua b/data/core/view.lua index e4c6b409..8a12d025 100644 --- a/data/core/view.lua +++ b/data/core/view.lua @@ -238,6 +238,10 @@ function View:on_text_input(text) -- no-op end +function View:on_ime_text_editing(text, start, length) + -- no-op +end + ---@param y number ---@return boolean function View:on_mouse_wheel(y) diff --git a/src/api/system.c b/src/api/system.c index 40018cd5..74ecc4c8 100644 --- a/src/api/system.c +++ b/src/api/system.c @@ -249,6 +249,23 @@ top: lua_pushstring(L, e.text.text); return 2; + case SDL_TEXTEDITING: + lua_pushstring(L, "textediting"); + lua_pushstring(L, e.edit.text); + lua_pushinteger(L, e.edit.start); + lua_pushinteger(L, e.edit.length); + return 4; + +#if SDL_VERSION_ATLEAST(2, 0, 22) + case SDL_TEXTEDITING_EXT: + lua_pushstring(L, "textediting"); + lua_pushstring(L, e.editExt.text); + lua_pushinteger(L, e.editExt.start); + lua_pushinteger(L, e.editExt.length); + SDL_free(e.editExt.text); + return 4; +#endif + case SDL_MOUSEBUTTONDOWN: if (e.button.button == 1) { SDL_CaptureMouse(1); } lua_pushstring(L, "mousepressed"); @@ -425,6 +442,23 @@ static int f_get_window_mode(lua_State *L) { return 1; } +static int f_set_text_input_rect(lua_State *L) { + SDL_Rect rect; + rect.x = luaL_checknumber(L, 1); + rect.y = luaL_checknumber(L, 2); + rect.w = luaL_checknumber(L, 3); + rect.h = luaL_checknumber(L, 4); + SDL_SetTextInputRect(&rect); + return 0; +} + +static int f_clear_ime(lua_State *L) { +#if SDL_VERSION_ATLEAST(2, 0, 22) + SDL_ClearComposition(); +#endif + return 0; +} + static int f_raise_window(lua_State *L) { /* @@ -986,6 +1020,8 @@ static const luaL_Reg lib[] = { { "set_window_hit_test", f_set_window_hit_test }, { "get_window_size", f_get_window_size }, { "set_window_size", f_set_window_size }, + { "set_text_input_rect", f_set_text_input_rect }, + { "clear_ime", f_clear_ime }, { "window_has_focus", f_window_has_focus }, { "raise_window", f_raise_window }, { "show_fatal_error", f_show_fatal_error }, diff --git a/src/main.c b/src/main.c index 682b7c16..9784c0ac 100644 --- a/src/main.c +++ b/src/main.c @@ -133,6 +133,12 @@ int main(int argc, char **argv) { #if SDL_VERSION_ATLEAST(2, 0, 5) SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1"); #endif +#if SDL_VERSION_ATLEAST(2, 0, 18) + SDL_SetHint(SDL_HINT_IME_SHOW_UI, "1"); +#endif +#if SDL_VERSION_ATLEAST(2, 0, 22) + SDL_SetHint(SDL_HINT_IME_SUPPORT_EXTENDED_TEXT, "1"); +#endif #if SDL_VERSION_ATLEAST(2, 0, 8) /* This hint tells SDL to respect borderless window as a normal window.