Download CommonLua/X/XTextEditor.lua from sirnii/myspace: direct link, hf CLI and curl.
- Browser
- Download file 55.9 kB
-
https://huggingface.co/spaces/sirnii/myspace/resolve/main/CommonLua/X/XTextEditor.lua
- Command line
-
hf download hf://spaces/sirnii/myspace/CommonLua/X/XTextEditor.lua
-
curl -L -o XTextEditor.lua https://huggingface.co/spaces/sirnii/myspace/resolve/main/CommonLua/X/XTextEditor.lua
55.9 kB
| if FirstLoad then | |
| XTextEditorPluginCache = {} | |
| end | |
| DefineClass.XTextEditor = { | |
| __parents = { "XScrollArea", "XEditableText" }, | |
| properties = { | |
| { category = "General", id = "Multiline", editor = "bool", default = true }, | |
| { category = "General", id = "Password", editor = "bool", default = false, help = "Display the entire text as * characters.", }, | |
| { category = "General", id = "ShowLastPswdLetter", editor = "bool", default = false, help = "When Password is set, show the last entered character.", | |
| no_edit = function(self) return not self.Password end, | |
| }, | |
| { category = "General", id = "ConsoleKeyboardTitle", editor = "text", default = "", translate = true, help = "Title for the virtual keyboard.", }, | |
| { category = "General", id = "ConsoleKeyboardDescription", editor = "text", default = "", translate = true, help = "Description for the virtual keyboard.", }, | |
| { category = "General", id = "WordWrap", editor = "bool", default = true }, | |
| { category = "General", id = "AllowTabs", editor = "bool", default = false, }, | |
| { category = "General", id = "AllowPaste", editor = "bool", default = true, }, | |
| { category = "General", id = "AllowEscape", editor = "bool", default = true, }, | |
| { category = "General", id = "MinVisibleLines", editor = "number", default = 1 }, | |
| { category = "General", id = "MaxVisibleLines", editor = "number", default = 8 }, | |
| { category = "General", id = "MaxLines", editor = "number", default = 10000 }, | |
| { category = "General", id = "MaxLen", editor = "number", default = 65536, }, | |
| { category = "General", id = "AutoSelectAll", editor = "bool", default = false, }, | |
| { category = "General", id = "Filter", editor = "text", default = ".", help = "Lua string pattern for allowed characters."}, | |
| { category = "General", id = "NegFilter", editor = "text", default = "", help = "Lua string pattern for forbidden characters."}, | |
| { category = "General", id = "NewLine", editor = "text", default = Platform.pc and "\r\n" or "\n", }, | |
| { category = "General", id = "Ime", editor = "bool", default = true, help = "Activate IME support for CKJ languages when the control receives focus."}, | |
| { category = "General", id = "Plugins", editor = "string_list", default = empty_table, items = function(self) return TextEditorPluginsCombo(rawget(self, "Multiline")) end, }, | |
| { category = "Layout", id = "TextHAlign", editor = "choice", default = "left", items = {"left", "center", "right"}, }, | |
| { category = "Visual", id = "Hint", editor = "text", translate = function(self) return self.Translate end, default = "" }, | |
| { category = "Visual", id = "HintColor", editor = "color", default = RGBA(0, 0, 0, 128) }, | |
| { category = "Visual", id = "HintVAlign", editor = "choice", default = "center", items = {"top", "center", "bottom"}, }, | |
| { category = "Visual", id = "SelectionBackground", editor = "color", default = RGB(38, 146, 227), }, | |
| { category = "Visual", id = "SelectionColor", editor = "color", default = RGB(255, 255, 255), }, | |
| }, | |
| Clip = "parent & self", | |
| Padding = box(2, 1, 2, 1), | |
| BorderWidth = 1, | |
| Background = RGB(240, 240, 240), | |
| FocusedBackground = RGB(255, 255, 255), | |
| BorderColor = RGB(128, 128, 128), | |
| DisabledBorderColor = RGBA(128, 128, 128, 128), | |
| TextColor = RGB(0, 0, 0), | |
| IdNode = false, | |
| lines = false, -- keeps the lines of text word-wrapped; newlines are internally kept as '\n' to make the code simpler | |
| need_reflow = false, | |
| len = 0, | |
| newline_count = 0, | |
| -- plugins | |
| plugins = false, | |
| plugin_methods = false, | |
| -- cursor | |
| cursor_line = 1, | |
| cursor_char = 0, | |
| cursor_virtual_x = -1, -- used for Up/Down/Pageup/Pagedown cursor navigation | |
| show_cursor = false, | |
| stop_blink = false, | |
| cursor_blink_time = 400, | |
| blink_cursor_thread = false, | |
| touch = false, | |
| -- selection | |
| selection_start_line = false, | |
| selection_start_char = false, | |
| -- undo & redo | |
| undo_data = 0, | |
| max_undo_data = 65536, | |
| undo_stack = false, | |
| redo_stack = false, | |
| ime_korean_composition = false, | |
| vkPass = { | |
| const.vkEnd, | |
| const.vkHome, | |
| const.vkLeft, | |
| const.vkRight, | |
| const.vkInsert, | |
| const.vkDelete, | |
| const.vkBackspace, | |
| const.vkEnter, | |
| }, | |
| } | |
| ----- helpers | |
| local word_chars = "[_%w\127-\255]" -- count any utf8 extented character as a word character | |
| local nonword_chars = "[^_%w\127-\255]" | |
| local word_pattern = word_chars .. "*" .. nonword_chars .. "*" | |
| local strict_word_pattern = word_chars .. "*" | |
| local function IsControlPressed() | |
| return terminal.IsKeyPressed(const.vkControl) or (Platform.osx and terminal.IsKeyPressed(const.vkLwin)) | |
| end | |
| local function IsShiftPressed() | |
| return terminal.IsKeyPressed(const.vkShift) | |
| end | |
| local function IsAltPressed() | |
| return terminal.IsKeyPressed(const.vkAlt) | |
| end | |
| local function TrimTextToWidth(text, font, width) | |
| local a, b = 0, utf8.len(text) - (text:ends_with("\n") and 1 or 0) | |
| local MeasureText = UIL.MeasureText | |
| while a ~= b do | |
| local mid = (a + b + 1) / 2 | |
| local partial_width = MeasureText(utf8.sub(text, 1, mid), font) | |
| if partial_width <= width then -- it fits | |
| a = mid | |
| else | |
| b = mid - 1 | |
| end | |
| end | |
| text = utf8.sub(text, 1, a) | |
| return text, MeasureText(text, font) | |
| end | |
| local function ApplyCharFilters(text, filter, allowed) | |
| if filter and filter ~= "" then | |
| text = text:gsub(filter, "") | |
| end | |
| if not allowed or allowed == "." then | |
| return text | |
| end | |
| local result = {} | |
| for part in text:gmatch(allowed .. "*") do | |
| table.insert(result, part) | |
| end | |
| return table.concat(result) | |
| end | |
| local function NormalizeNewLines(text) | |
| local newlines = 0 | |
| text = text:gsub("([^\r\n]*)(\r?\n?)", function(text, newline) | |
| if #newline ~= 0 then | |
| newlines = newlines + 1 | |
| return text .. "\n" | |
| end | |
| return text | |
| end) | |
| return text, newlines | |
| end | |
| local function CountNewLines(text) | |
| local count = 0 | |
| for _ in text:gmatch("\n") do | |
| count = count + 1 | |
| end | |
| return count | |
| end | |
| ----- general methods | |
| function XTextEditor:Init() | |
| self.lines = { "" } | |
| self:SetTextHAlign(self.TextHAlign) | |
| self:SetPlugins(empty_table) | |
| end | |
| function XTextEditor:AlignHDest(dest, free_space) | |
| if self.TextHAlign == "right" then | |
| return dest + Max(0, free_space) | |
| elseif self.TextHAlign == "center" then | |
| return dest + Max(0, free_space / 2) | |
| else | |
| return dest | |
| end | |
| end | |
| function XTextEditor:SetTranslatedText(text, force_reflow) | |
| if not self.Multiline then | |
| text = text:gsub("\n", " "):gsub("\r", "") | |
| end | |
| if text == self:GetTranslatedText() and not force_reflow then return end | |
| XEditableText.SetTranslatedText(self, text, false) | |
| local text, newlines = NormalizeNewLines(ApplyCharFilters(self.text, self.NegFilter, self.Filter)) | |
| self.len = utf8.len(text) | |
| self.newline_count = newlines | |
| self.lines = { text } | |
| self.cursor_line = 1 | |
| self.cursor_char = 0 | |
| self.cursor_virtual_x = -1 | |
| self.undo_data = 0 | |
| self.undo_stack = false | |
| self.redo_stack = false | |
| self:ReflowTextLine(1, true, text) | |
| self:ClearSelection() | |
| self:ScrollTo(0, 0) | |
| self:InvalidateMeasure() | |
| self:InvalidateLayout() | |
| self:OnTextChanged() | |
| self:InvokePlugins("OnTextChanged") | |
| end | |
| function XTextEditor:GetTranslatedText() | |
| self:GetText() -- puts the text from the internal representation into the .text member | |
| return XEditableText.GetTranslatedText(self) | |
| end | |
| function XTextEditor:GetText() | |
| local text = table.concat(self:GetTextLines()) | |
| if self.NewLine ~= "\n" then | |
| text = text:gsub("\n", self.NewLine) | |
| end | |
| self.text = text | |
| return XEditableText.GetText(self) | |
| end | |
| function XTextEditor:GetTextLines() | |
| return self.lines or {} | |
| end | |
| ----- plugins | |
| function XTextEditor:SetPlugins(plugins) | |
| self.plugins = nil | |
| self.plugin_methods = nil | |
| if config.DefaultTextEditPlugins then | |
| plugins = table.copy(plugins or empty_table) | |
| table.iappend(plugins, config.DefaultTextEditPlugins) | |
| end | |
| for _, id in ipairs(plugins or empty_table) do | |
| local class = _G[id] | |
| if class.SingleInstance then | |
| local instance = XTextEditorPluginCache[id] | |
| if not instance then | |
| instance = class:new() | |
| XTextEditorPluginCache[id] = class | |
| end | |
| self:AddPlugin(instance) | |
| else | |
| self:AddPlugin(class:new({}, self)) | |
| end | |
| end | |
| end | |
| function XTextEditor:FindPluginOfKind(class) | |
| for _, plugin in ipairs(self.plugins) do | |
| if IsKindOf(plugin, class) then | |
| return plugin | |
| end | |
| end | |
| end | |
| function XTextEditor:AddPlugin(plugin) | |
| local plugins = self.plugins or {} | |
| plugins[#plugins + 1] = plugin | |
| self.plugins = plugins | |
| -- cache plugin methods present for this control | |
| local plugin_methods = self.plugin_methods or {} | |
| for key, value in pairs(XTextEditorPlugin) do | |
| if type(value) == "function" and plugin[key] ~= value then | |
| plugin_methods[key] = true | |
| end | |
| end | |
| self.plugin_methods = plugin_methods | |
| end | |
| function XTextEditor:HasPluginMethod(method) | |
| local plugin_methods = self.plugin_methods or empty_table | |
| return plugin_methods[method] | |
| end | |
| function XTextEditor:InvokePlugins(method, ...) | |
| local plugin_methods = self.plugin_methods or empty_table | |
| if not plugin_methods[method] then return end | |
| for _, plugin in ipairs(self.plugins) do | |
| if plugin:HasMember(method) then | |
| local ret = plugin[method](plugin, self, ...) | |
| if ret then | |
| return ret | |
| end | |
| end | |
| end | |
| end | |
| ----- editing, undo & redo | |
| function XTextEditor:DeleteText(line, char, to_line, to_char) | |
| assert(char >= 0 and to_char >= 0 and char <= utf8.len(self.lines[line]) and to_char <= utf8.len(self.lines[to_line])) | |
| if line == to_line then | |
| local old_text = self.lines[line] | |
| local new_text = utf8.sub(old_text, 1, char) .. utf8.sub(old_text, to_char + 1) | |
| self.lines[line] = new_text | |
| self:ReflowTextLine(line, false, utf8.sub(old_text, char + 1, to_char)) | |
| else | |
| -- delete the text | |
| local new_text = utf8.sub(self.lines[line], 1, char) .. utf8.sub(self.lines[to_line], to_char + 1) | |
| for i = to_line, line + 1, -1 do | |
| table.remove(self.lines, i) | |
| end | |
| -- reflow resulting line | |
| self.lines[line] = new_text | |
| self:ReflowTextLine(line, true, new_text) | |
| end | |
| end | |
| function XTextEditor:InsertText(charidx, line, char, text) | |
| local old_text = self.lines[line] | |
| assert(char >= 0 and char <= utf8.len(old_text)) | |
| if old_text:ends_with("\n") and char == utf8.len(old_text) and line < #self.lines then | |
| line, char = line + 1, 0 | |
| old_text = self.lines[line] | |
| end | |
| self.lines[line] = utf8.sub(old_text, 1, char) .. text .. utf8.sub(old_text, char + 1) | |
| self:ReflowTextLine(line, true, text) | |
| return charidx + utf8.len(text) | |
| end | |
| local function undo_data_size(undo_op) | |
| return 4 * 80 + (undo_op.insert_text and #undo_op.insert_text or 0) | |
| end | |
| -- replaces the selection by 'insert_text', handles cursor and pushing undo ops | |
| -- handles each and every edit operation | |
| function XTextEditor:EditOperation(insert_text, op_type, setcursor_charidx, keep_selection) | |
| if not self.enabled then return end | |
| local changes_made = false | |
| local old_lines = #self.lines | |
| -- delete selected text | |
| local charidx | |
| local deleted_text = nil | |
| local line1, char1 = self.cursor_line, self.cursor_char | |
| local line2, char2 | |
| local undo_cursor_charidx = self:GetCursorCharIdx(line1, char1) | |
| if self:HasSelection() then | |
| line1, char1, line2, char2 = self:GetSelectionSortedBounds() | |
| deleted_text = self:GetSelectedTextInternal() | |
| self:DeleteText(line1, char1, line2, char2) | |
| charidx = self:GetCursorCharIdx(line1, char1) | |
| if line1 > #self.lines then -- we've deleted the entire line the cursor was on | |
| assert(line1 > 1) | |
| line1, char1 = line1 - 1, #self.lines[line1 - 1] | |
| end | |
| self.len = self.len - utf8.len(deleted_text) | |
| self.newline_count = self.newline_count - CountNewLines(deleted_text) | |
| self:ClearSelection() | |
| changes_made = true | |
| end | |
| -- insert insert_text (if the control's limits are not exceeded) | |
| local charidx = charidx or self:GetCursorCharIdx() | |
| local charidx_to = charidx | |
| if insert_text then | |
| if not self:GetMultiline() then | |
| insert_text = insert_text:gsub("[\r\n]+", "") | |
| end | |
| local text, newlines = NormalizeNewLines(ApplyCharFilters(insert_text, self.NegFilter, self.Filter)) | |
| local len = self.len + utf8.len(text) | |
| local newline_count = self.newline_count + newlines | |
| if (self.MaxLen < 0 or len <= self.MaxLen) and (self.MaxLines < 0 or newline_count < self.MaxLines) then | |
| charidx_to = self:InsertText(charidx, line1, char1, text) | |
| self.len = len | |
| self.newline_count = newline_count | |
| end | |
| changes_made = true | |
| end | |
| if not changes_made then return end | |
| -- update cursor position; invalidate | |
| self:SetCursor(self:CursorFromCharIdx(setcursor_charidx or charidx_to)) | |
| self:InvalidateMeasure() | |
| self:InvalidateLayout() | |
| self:Invalidate() | |
| self:OnTextChanged() | |
| self:InvokePlugins("OnTextChanged") | |
| if keep_selection then | |
| assert(not setcursor_charidx) -- this case is yet unused and thus not handled | |
| line2, char2 = self.cursor_line, self.cursor_char | |
| self:SetCursor(line1, char1, false) | |
| self:SetCursor(line2, char2, true) | |
| end | |
| -- can we merge this undo operation into the previous one? | |
| local prev_op = self.undo_stack and self.undo_stack[#self.undo_stack] | |
| if prev_op and op_type ~= "undo" and op_type ~= "paste" and op_type ~= "cut" then | |
| if not prev_op.insert_text and not deleted_text and prev_op.charidx_to == charidx then | |
| -- merge insertions | |
| prev_op.charidx_to = charidx_to | |
| return | |
| elseif prev_op.insert_text and deleted_text and prev_op.charidx == prev_op.charidx_to and charidx == charidx_to then | |
| -- merge deletions | |
| if prev_op.charidx == charidx then | |
| prev_op.insert_text = prev_op.insert_text .. deleted_text | |
| self.undo_data = self.undo_data + #deleted_text | |
| return | |
| elseif charidx + utf8.len(deleted_text) == prev_op.charidx then | |
| prev_op.charidx = charidx | |
| prev_op.charidx_to = charidx_to | |
| prev_op.insert_text = deleted_text .. prev_op.insert_text | |
| self.undo_data = self.undo_data + #deleted_text | |
| return | |
| end | |
| end | |
| end | |
| -- insert undo operation; use char indexes so undo/redo can work properly after a resize | |
| local undo_op = { charidx = charidx, charidx_to = charidx_to, insert_text = deleted_text, cursor_charidx = undo_cursor_charidx } | |
| if op_type == "undo" then | |
| return undo_op | |
| end | |
| self.redo_stack = false | |
| self.undo_stack = self.undo_stack or {} | |
| table.insert(self.undo_stack, undo_op) | |
| -- cleanup least recent undo operations if we exceed 'max_undo_data' | |
| self.undo_data = self.undo_data + undo_data_size(undo_op) | |
| while self.undo_data > self.max_undo_data do | |
| undo_op = table.remove(self.undo_stack, 1) | |
| self.undo_data = self.undo_data - undo_data_size(undo_op) | |
| end | |
| end | |
| function XTextEditor:Undo() | |
| if self.undo_stack and #self.undo_stack > 0 then | |
| local undo_op = table.remove(self.undo_stack) | |
| self.undo_data = self.undo_data - undo_data_size(undo_op) | |
| undo_op = self:ExecuteUndoRedoOp(undo_op) | |
| self.redo_stack = self.redo_stack or {} | |
| table.insert(self.redo_stack, undo_op) | |
| end | |
| end | |
| function XTextEditor:Redo() | |
| if self.redo_stack and #self.redo_stack > 0 then | |
| local undo_op = self:ExecuteUndoRedoOp(table.remove(self.redo_stack)) | |
| table.insert(self.undo_stack, undo_op) | |
| end | |
| end | |
| function XTextEditor:ExecuteUndoRedoOp(undo_op) | |
| self.selection_start_line, self.selection_start_char = self:CursorFromCharIdx(undo_op.charidx) | |
| local cursor_line, cursor_char = self:CursorFromCharIdx(undo_op.charidx_to) | |
| self:SetCursor(cursor_line, cursor_char, true) | |
| return self:EditOperation(undo_op.insert_text, "undo", undo_op.undo_cursor_charidx) | |
| end | |
| function XTextEditor:ExchangeLines(line1, line2, line3, cursor_anchor_line) | |
| local text1 = self:GetSelectedTextInternal(line1, 0, line2, 0) | |
| local text2 = self:GetSelectedTextInternal(line2, 0, line3, 0) | |
| if not text2:ends_with("\n") then | |
| text1, text2 = text1:sub(0, -2), text2.."\n" | |
| end | |
| local cursor_offs = self:GetCursorCharIdx() - self:GetCursorCharIdx(cursor_anchor_line, 0) | |
| local cursor_idx = cursor_offs + self:GetCursorCharIdx(line1, 0) + (cursor_anchor_line == line1 and utf8.len(text2) or 0) | |
| self:SetCursor(line1, 0) | |
| self:SetCursor(line3, 0, "select") | |
| self:EditOperation(text2..text1, false, cursor_idx) | |
| end | |
| function XTextEditor:ShouldProcessChar(ch) | |
| return | |
| (ch ~= "" and string.byte(ch) >= 32 or ch == "\r" or ch == "\n") and | |
| (not IsControlPressed() == not IsAltPressed()) and -- CTRL + ALT == AltGR. AltGR is used for sending special chars. | |
| (not self.Filter or string.find(ch, self.Filter)) and | |
| (not string.find(self.NegFilter, ch, 1, true)) and | |
| (not self.AllowTabs or ch ~= "\t") and | |
| (self:GetMultiline() or (ch ~= "\r" and ch ~= "\n")) | |
| end | |
| function XTextEditor:ProcessChar(ch) | |
| if self:ShouldProcessChar(ch) then | |
| -- auto-indent when Enter is pressed | |
| if ch == "\r" or ch == "\n" then | |
| local last_nonempty_line | |
| for i = self.cursor_line, 1, -1 do | |
| if self.lines[i]:find("%S") then | |
| last_nonempty_line = self.lines[i] | |
| break | |
| end | |
| end | |
| ch = last_nonempty_line and "\n" .. last_nonempty_line:match("\t*") or "\n" | |
| end | |
| self:EditOperation(ch) | |
| return true | |
| end | |
| return false | |
| end | |
| function XTextEditor:OnShortcut(shortcut, source, ...) | |
| if self:InvokePlugins("OnShortcut", shortcut, source, ...) then | |
| return "break" | |
| end | |
| -- Edit commands | |
| if shortcut == "Escape" and self.AllowEscape and self:HasSelection() then | |
| self:ClearSelection() | |
| return "break" | |
| elseif shortcut == "Tab" and self.AllowTabs then | |
| self:EditOperation("\t") | |
| return "break" | |
| elseif shortcut == "Ctrl-Insert" and not self.Password then | |
| CopyToClipboard(self:GetSelectedText()) | |
| return "break" | |
| elseif shortcut == "Shift-Insert" then | |
| self:EditOperation(GetFromClipboard(Max(self.MaxLen, 65536)), "paste") | |
| return "break" | |
| elseif shortcut == "Shift-Delete" and not self.Password then | |
| CopyToClipboard(self:GetSelectedText()) | |
| self:EditOperation(nil, "cut") -- deletes the selection | |
| return "break" | |
| elseif shortcut == "Delete" then | |
| if not self:HasSelection() then | |
| self.selection_start_line, self.selection_start_char = self:NextCursorPos("to_next_char") | |
| end | |
| self:EditOperation() -- deletes the selection | |
| return "break" | |
| elseif shortcut == "Backspace" then | |
| if not self:HasSelection() then | |
| self.selection_start_line, self.selection_start_char = self:PrevCursorPos("to_next_char") | |
| end | |
| self:EditOperation() -- deletes the selection | |
| return "break" | |
| elseif shortcut == "Ctrl-Delete" then | |
| self:ClearSelection() | |
| local line, char = self:NextWordForward(self.cursor_line, self.cursor_char) | |
| self:SetCursor(line, char, true) | |
| if self:HasSelection() then | |
| self:ReverseSelectionBounds() -- so that the cursor returns before the word after undo | |
| self:EditOperation() -- deletes the selection | |
| end | |
| return "break" | |
| elseif shortcut == "Ctrl-Backspace" then | |
| self:ClearSelection() | |
| local line, char = self:NextWordBack(self.cursor_line, self.cursor_char) | |
| self:SetCursor(line, char, true) | |
| if self:HasSelection() then | |
| self:ReverseSelectionBounds() -- so that the cursor returns after the word after undo | |
| self:EditOperation() -- deletes the selection | |
| end | |
| return "break" | |
| elseif shortcut == "Ctrl-A" then | |
| self:SelectAll() | |
| return "break" | |
| elseif shortcut == "Ctrl-C" and not self.Password then | |
| if self:HasSelection() then | |
| CopyToClipboard(self:GetSelectedText()) | |
| else | |
| CopyToClipboard(self.lines[self.cursor_line]) | |
| end | |
| return "break" | |
| elseif shortcut == "Ctrl-X" and not self.Password then | |
| if not self:HasSelection() then | |
| self.selection_start_line = self.cursor_line | |
| self.selection_start_char = 0 | |
| self.cursor_char = utf8.len(self.lines[self.cursor_line]) | |
| end | |
| CopyToClipboard(self:GetSelectedText()) | |
| self:EditOperation(nil, "cut") -- deletes the selection | |
| return "break" | |
| elseif shortcut == "Ctrl-V" then | |
| if self.AllowPaste then | |
| self:EditOperation(GetFromClipboard(Max(self.MaxLen, 65536)), "paste") | |
| end | |
| return "break" | |
| elseif shortcut == "Ctrl-Z" then | |
| self:Undo() | |
| return "break" | |
| elseif shortcut == "Ctrl-Y" then | |
| self:Redo() | |
| return "break" | |
| end | |
| -- Cursor navigation | |
| local consume_key = false | |
| local line = self.cursor_line | |
| local char = self.cursor_char | |
| shortcut = string.gsub(shortcut, "Shift%-", "") -- ignore shift | |
| -- left, right, home, end, ctrl + (left, right, home, end) | |
| if shortcut == "Left" then | |
| line, char = self:PrevCursorPos(IsShiftPressed() and "to_next_char") | |
| consume_key = true | |
| elseif shortcut == "Right" then | |
| line, char = self:NextCursorPos(IsShiftPressed() and "to_next_char") | |
| consume_key = true | |
| elseif shortcut == "Home" then | |
| local white_space = self.lines[line]:find("[^%s]") | |
| local first_word = white_space and white_space - 1 or 0 | |
| if char == first_word then | |
| char = 0 | |
| else | |
| char = first_word | |
| end | |
| consume_key = true | |
| elseif shortcut == "End" then | |
| local line_text = self.lines[line] | |
| char = utf8.len(line_text) - (self:ShouldIgnoreLastLineChar(line, line_text) and 1 or 0) | |
| consume_key = true | |
| elseif shortcut == "Ctrl-Left" then | |
| line, char = self:NextWordBack(line, char) | |
| consume_key = true | |
| elseif shortcut == "Ctrl-Right" then | |
| line, char = self:NextWordForward(line, char) | |
| consume_key = true | |
| elseif shortcut == "Ctrl-Home" then | |
| line, char = 1, 0 | |
| consume_key = true | |
| elseif shortcut == "Ctrl-End" then | |
| line = #self.lines | |
| local line_text = self.lines[line] | |
| char = utf8.len(line_text) - (self:ShouldIgnoreLastLineChar(line, line_text) and 1 or 0) | |
| consume_key = true | |
| end | |
| if consume_key then | |
| self:SetCursor(line, char, IsShiftPressed()) | |
| return "break" | |
| end | |
| -- up, down, pageup, pagedown | |
| if self:GetMultiline() then | |
| local v_offset = 0 | |
| if shortcut == "Up" then | |
| v_offset = -self.font_height | |
| v_offset = v_offset - (self:InvokePlugins("VerticalSpaceAfterLine", self.cursor_line - 1) or 0) | |
| elseif shortcut == "Down" then | |
| v_offset = self.font_height | |
| elseif shortcut == "Pageup" then | |
| v_offset = -self.content_box:sizey() | |
| elseif shortcut == "Pagedown" then | |
| v_offset = self.content_box:sizey() | |
| end | |
| if v_offset ~= 0 then | |
| local x, y = self:GetCursorXY() | |
| if self.cursor_virtual_x then | |
| x = self.cursor_virtual_x | |
| else | |
| self.cursor_virtual_x = x | |
| end | |
| local out_of_bounds | |
| line, char, out_of_bounds = self:CursorFromPoint(x, y + v_offset) | |
| self:SetCursor(line, char, IsShiftPressed()) | |
| if not out_of_bounds then | |
| self.cursor_virtual_x = x -- SetCursor resets this member, keep it intact | |
| end | |
| return "break" | |
| end | |
| end | |
| end | |
| ----- word/line-wrapping | |
| function XTextEditor:TrimLineForWordWrap(width, line) | |
| local newline | |
| local line_width = 0 | |
| local line_length = 0 | |
| local line_text = self.lines[line] | |
| local font = self:GetFontId() | |
| for word in line_text:gmatch(word_pattern) do | |
| local orig_length = #word | |
| local length = orig_length | |
| local to_newline = word:match("([^\n]*)\n") | |
| if to_newline then | |
| word = to_newline | |
| length = #to_newline + 1 | |
| newline = true | |
| end | |
| local word_width = self:MeasureTextForDisplay(word) or 0 | |
| if line_width + word_width > width then | |
| if line_width == 0 then -- force-wrap any words longer than our width | |
| word, word_width = TrimTextToWidth(word, font, width - line_width) | |
| length = #word | |
| else | |
| word_width = 0 | |
| length = 0 | |
| end | |
| end | |
| line_width = line_width + word_width | |
| line_length = line_length + length | |
| if newline or length ~= orig_length then | |
| break | |
| end | |
| end | |
| -- push characters that lines shouldn't end with to the next line (quotes, brackets, etc.) | |
| local text = line_text:sub(1, line_length) | |
| if #text ~= 1 and #line_text ~= line_length and not text:ends_with("\n") then | |
| local cant_start, cant_end = utf8.GetLineBreakInfo(text) | |
| if cant_end or text:ends_with('"') or text:ends_with("'") then | |
| text = text:sub(1, -2) | |
| line_width = self:MeasureTextForDisplay(text) or 0 | |
| line_length = line_length - 1 | |
| end | |
| end | |
| self.lines[line] = text | |
| return line_text:sub(line_length + 1) -- returns remaining text | |
| end | |
| -- performs/updates word-wrapping (or splits into lines if WordWrap is false) | |
| function XTextEditor:ReflowTextLine(line, inserting_text, text_diff, width) | |
| width = (width or self.content_box:sizex()) - 1 -- reserve 1 pixel for cursor | |
| if width <= 0 then | |
| assert(#self.lines == 1) -- we expect to be here only when initially setting the text at control creation time | |
| self.need_reflow = true | |
| return | |
| end | |
| self.need_reflow = nil | |
| local lines = self.lines | |
| local newline_inserted = inserting_text and text_diff:find("\n") | |
| if not self.WordWrap then | |
| if newline_inserted then | |
| local text = table.remove(lines, line) | |
| local trailing_newline = false | |
| for line_text, newline in string.gmatch(text, "([^\n]*)(\n?)") do | |
| if line_text ~= "" or newline ~= "" then | |
| table.insert(lines, line, line_text .. (newline ~= "" and "\n" or "")) | |
| line = line + 1 | |
| trailing_newline = #newline ~= 0 | |
| end | |
| end | |
| if trailing_newline then | |
| table.insert(lines, line, "") | |
| end | |
| end | |
| -- editing the text might have removed the trailing newline => merge with next line | |
| if line < #lines and not lines[line]:ends_with("\n") then | |
| local new_text = lines[line] .. lines[line + 1] | |
| lines[line] = new_text | |
| table.remove(lines, line + 1) | |
| end | |
| return | |
| end | |
| -- word-wrapping case; prevent excessive text splitting by capping up the width | |
| width = Max(width, 5 * self.font_height) | |
| -- optimization in case of deleting only a part of a single line | |
| local text = lines[line] | |
| if not inserting_text then | |
| local text_width = self:MeasureTextForDisplay(text) | |
| -- no need to get text from the next line into the current? | |
| if line == #lines or lines[line]:ends_with("\n") then | |
| return | |
| end | |
| -- a forcefully wordwrapped word need to always be reflowed | |
| local next_line_text = lines[line + 1] | |
| local long_word = text:sub(-1, -1):match(word_chars) | |
| if not long_word then | |
| -- if we can't fit the first word of the next line, there's nothing to do | |
| local first_word = next_line_text:match(word_pattern) | |
| if not first_word or #first_word == 0 or self:MeasureTextForDisplay(first_word) > width - text_width then | |
| return | |
| end | |
| end | |
| end | |
| -- form entire text to be reflowed | |
| local prev_line_text = line ~= 1 and lines[line - 1] | |
| if prev_line_text and not prev_line_text:ends_with("\n") then | |
| line = line - 1 | |
| text = prev_line_text | |
| end | |
| local i = line + 1 | |
| while i <= #self.lines and (not text:ends_with("\n") or text:sub(-1, -1):match(word_chars)) do | |
| text = text .. self.lines[i] | |
| table.remove(self.lines, i) | |
| end | |
| self.lines[line] = text | |
| -- rewrap text | |
| local remaining_text = self:TrimLineForWordWrap(width, line) | |
| while #remaining_text > 0 do | |
| line = line + 1 | |
| if line > #lines or remaining_text:ends_with("\n") then | |
| table.insert(lines, line, remaining_text) | |
| else | |
| lines[line] = remaining_text .. lines[line] | |
| end | |
| remaining_text = self:TrimLineForWordWrap(width, line) | |
| end | |
| if line == #lines and lines[line]:ends_with("\n") then | |
| lines[line + 1] = "" | |
| end | |
| end | |
| ----- focus & cursor | |
| function XTextEditor:OnSetFocus(old_focus) | |
| if not hr.ImeCompositionStarted then | |
| self:CreateCursorBlinkThread() | |
| end | |
| -- select all only on focus transfers and not on inactive->active transitions | |
| if self.AutoSelectAll and (old_focus and not self.desktop.inactive) then | |
| self:SelectAll() | |
| end | |
| ShowVirtualKeyboard(true) | |
| if self.Ime then | |
| ShowIme() | |
| end | |
| self:ImeUpdatePos() | |
| self:InvokePlugins("OnSetFocus", self, old_focus) | |
| end | |
| function XTextEditor:OnKillFocus() | |
| ShowVirtualKeyboard(false) | |
| self:DestroyCursorBlinkThread() | |
| self:ClearSelection() | |
| if not self:GetMultiline() then | |
| self:ScrollTo(0, 0) | |
| end | |
| if self.Ime then | |
| HideIme() | |
| end | |
| self:InvokePlugins("OnKillFocus", self) | |
| self:Invalidate() | |
| end | |
| function XTextEditor:ImeUpdatePos() | |
| if IsImeEnabled() and self:IsFocused() then | |
| local x, y = self:GetCursorXY() | |
| SetImePosition(x, y, self:GetFontId()) | |
| end | |
| end | |
| function XTextEditor:CreateCursorBlinkThread() | |
| if not self.blink_cursor_thread then | |
| self.blink_cursor_thread = CreateRealTimeThread(function() | |
| while true do | |
| self.show_cursor = self.stop_blink or not self.show_cursor | |
| self.stop_blink = false | |
| self:Invalidate() | |
| Sleep(self.cursor_blink_time) | |
| end | |
| end) | |
| end | |
| end | |
| function XTextEditor:DestroyCursorBlinkThread() | |
| DeleteThread(self.blink_cursor_thread) | |
| self.blink_cursor_thread = false | |
| self.show_cursor = false | |
| self.stop_blink = false | |
| end | |
| function XTextEditor:LineIdxFromScreenY(y) | |
| y = y - self.content_box:miny() + self.OffsetY | |
| if not self:HasPluginMethod("VerticalSpaceAfterLine") then | |
| local line = y / self.font_height | |
| return line + 1, line * self.font_height | |
| end | |
| local line, cy = 1, 0 | |
| local line_height = self.font_height | |
| repeat | |
| cy = cy + (self:InvokePlugins("VerticalSpaceAfterLine", line - 1) or 0) | |
| if cy + line_height > y then | |
| return line, cy | |
| end | |
| line = line + 1 | |
| cy = cy + line_height | |
| until line > #self.lines | |
| return line, cy | |
| end | |
| function XTextEditor:ShouldIgnoreLastLineChar(line, text) | |
| text = text or self.lines[line] | |
| return text:ends_with("\n") or line ~= #self.lines and text:ends_with(" ") | |
| end | |
| function XTextEditor:CursorFromPoint(x, y) | |
| local font = self:GetFontId() | |
| local line = self:LineIdxFromScreenY(y) | |
| if line < 1 then | |
| return 1, 0, true | |
| elseif line > #self.lines then | |
| line = #self.lines | |
| local line_text = self.lines[line] | |
| return line, utf8.len(line_text), true | |
| end | |
| local text_pos_x = x - self.content_box:minx() + self.OffsetX | |
| text_pos_x = text_pos_x - self:AlignHDest(0, self.content_box:sizex() - self:MeasureTextForDisplay(self.lines[line])) | |
| local text, length = self:GetDisplayText(line) | |
| if self:ShouldIgnoreLastLineChar(line, text) then | |
| text, length = text:sub(1, -2), length - 1 | |
| end | |
| local text_to_cursor = TrimTextToWidth(text, font, text_pos_x) | |
| local char = utf8.len(text_to_cursor) | |
| if char ~= length then | |
| local x1 = UIL.MeasureToCharStart(text, font, char + 1) | |
| local x2 = UIL.MeasureToCharStart(text, font, char + 2) | |
| char = text_pos_x > (x1 + x2) / 2 and char + 1 or char | |
| end | |
| return line, char | |
| end | |
| local pw = string.rep("*", 32) | |
| local function ensure_stars_count(len) | |
| if len > #pw then | |
| pw = string.rep(pw, len / #pw + 1) | |
| end | |
| end | |
| function XTextEditor:MeasureTextForDisplay(text, up_to_start_of) | |
| if self.Password then | |
| up_to_start_of = up_to_start_of or utf8.len(text) + 1 | |
| ensure_stars_count(up_to_start_of) | |
| text = pw | |
| end | |
| if not up_to_start_of then | |
| return UIL.MeasureText(text, self:GetFontId()) | |
| end | |
| return UIL.MeasureToCharStart(text, self:GetFontId(), up_to_start_of) | |
| end | |
| function XTextEditor:GetDisplayText(line) | |
| local text = self.lines[line] | |
| local len = text and utf8.len(text) | |
| if self.Password then | |
| local bShowLastPswdLetter = len >= 1 and self.ShowLastPswdLetter | |
| local stars = bShowLastPswdLetter and len - 1 or len | |
| ensure_stars_count(stars) | |
| return bShowLastPswdLetter and utf8.sub(pw, 1, stars).. utf8.sub(text, len, len) or utf8.sub(pw, 1, stars), len | |
| end | |
| return text, len | |
| end | |
| function XTextEditor:GetCursorXY() | |
| local line = self.cursor_line | |
| local text = self.lines[line] | |
| local cursor_x = self:MeasureTextForDisplay(text, self.cursor_char + 1) | |
| cursor_x = self:AlignHDest(cursor_x, self.content_box:sizex() - self:MeasureTextForDisplay(text)) | |
| local line_height = self.font_height | |
| local cursor_y = (line - 1) * line_height | |
| if self:HasPluginMethod("VerticalSpaceAfterLine") then | |
| for i = 0, line - 1 do | |
| cursor_y = cursor_y + (self:InvokePlugins("VerticalSpaceAfterLine", i) or 0) | |
| end | |
| end | |
| return self.content_box:minx() - self.OffsetX + cursor_x, self.content_box:miny() - self.OffsetY + cursor_y | |
| end | |
| function XTextEditor:SetCursor(line, char, selecting, include_last_endline) | |
| if not selecting then | |
| self:ClearSelection() | |
| end | |
| if line == #self.lines + 1 and char == 0 then | |
| line = line - 1 | |
| char = utf8.len(self.lines[line]) | |
| end | |
| local line_text = self.lines[line] | |
| if not line_text then | |
| return | |
| end | |
| if char == utf8.len(line_text) and self:ShouldIgnoreLastLineChar(line, line_text) and not include_last_endline then | |
| line, char = line + 1, 0 -- go to the following line if we are at the end of a trailing \n or space | |
| end | |
| if self.cursor_line == line and self.cursor_char == char then | |
| return | |
| end | |
| if selecting and not self:HasSelection() then | |
| self:StartSelecting() | |
| end | |
| self.cursor_line = line | |
| self.cursor_char = char | |
| self.cursor_virtual_x = false | |
| self.stop_blink = true | |
| if not self:GetThread("CursorAndIMEUpdate") then | |
| self:CreateThread("CursorAndIMEUpdate", function() | |
| if self.window_state ~= "destroying" then | |
| if self:IsFocused() then | |
| self:ScrollCursorIntoView() | |
| end | |
| self:ImeUpdatePos() | |
| self:Invalidate() | |
| end | |
| end) | |
| end | |
| end | |
| function XTextEditor:GetCursorCharIdx(line, char) | |
| line = line or self.cursor_line | |
| local idx = 0 | |
| local lines = self.lines | |
| for i = 1, line - 1 do | |
| idx = idx + utf8.len(lines[i]) | |
| end | |
| return idx + (char or self.cursor_char) | |
| end | |
| function XTextEditor:CursorFromCharIdx(idx) | |
| local line = 1 | |
| local lines = self.lines | |
| local line_len = utf8.len(lines[line]) | |
| while idx > line_len and line < #lines do | |
| idx = idx - line_len | |
| line = line + 1 | |
| line_len = utf8.len(lines[line]) | |
| end | |
| idx = Min(idx, line_len) | |
| return line, idx | |
| end | |
| function XTextEditor:PrevCursorPos(to_next_char) | |
| local line, char = self.cursor_line, self.cursor_char | |
| if char > 0 then | |
| return line, char - 1 | |
| elseif self.cursor_line > 1 then | |
| local line_text = self.lines[line - 1] | |
| local skip_last = to_next_char or self:ShouldIgnoreLastLineChar(line - 1, line_text) | |
| return line - 1, utf8.len(line_text) - (skip_last and 1 or 0) | |
| else | |
| return line, char | |
| end | |
| end | |
| function XTextEditor:NextCursorPos(to_next_char) | |
| local line, char = self.cursor_line, self.cursor_char | |
| local ignore_last_char = self:ShouldIgnoreLastLineChar(line) | |
| if char < utf8.len(self.lines[line]) - (ignore_last_char and 1 or 0) then | |
| return line, char + 1 | |
| elseif line < #self.lines then | |
| return line + 1, (not ignore_last_char and to_next_char) and 1 or 0 | |
| else | |
| return line, char | |
| end | |
| end | |
| function XTextEditor:NextWordBack(line, char) | |
| if char == 0 and line > 1 then | |
| line = line - 1 | |
| char = utf8.len(self.lines[line]) | |
| end | |
| local pos, prev_pos = 0, 0 | |
| for word in self.lines[line]:gmatch(word_pattern) do | |
| prev_pos = pos | |
| pos = pos + utf8.len(word) | |
| if pos >= char then | |
| char = prev_pos | |
| break | |
| end | |
| end | |
| return line, char | |
| end | |
| function XTextEditor:NextWordForward(line, char) | |
| local pos = 0 | |
| for word in self.lines[line]:gmatch(word_pattern) do | |
| pos = pos + utf8.len(word) | |
| if pos > char then | |
| char = pos | |
| break | |
| end | |
| end | |
| if char == utf8.len(self.lines[line]) and line < #self.lines then | |
| line = line + 1 | |
| char = 0 | |
| end | |
| return line, char | |
| end | |
| function XTextEditor:ScrollCursorIntoView() | |
| local x, y = self:GetCursorXY() | |
| local height = self.font_height | |
| if self.cursor_line == #self.lines then | |
| height = height + (self:InvokePlugins("VerticalSpaceAfterLine", #self.lines) or 0) | |
| end | |
| self:ScrollIntoView(box(x, y, x + 1, y + height)) | |
| end | |
| ----- measure & draw | |
| function XTextEditor:GetMaxLineWidth() | |
| local result = 0 | |
| for _, text in ipairs(self.lines) do | |
| result = Max(result, self:MeasureTextForDisplay(text)) | |
| end | |
| return result | |
| end | |
| function XTextEditor:Measure(preferred_width, preferred_height) | |
| XControl.Measure(self, preferred_width, preferred_height) -- skip XScrollArea.Measure | |
| if self.need_reflow then | |
| self:ReflowTextLine(1, true, self.lines[1], preferred_width) | |
| end | |
| local width = self:GetMaxLineWidth() | |
| local line_count = #(self.lines or "") | |
| self.scroll_range_x = width | |
| self.scroll_range_y = line_count * self:GetFontHeight() | |
| local extra_height = 0 | |
| if self:HasPluginMethod("VerticalSpaceAfterLine") then | |
| for i = 0, line_count do | |
| extra_height = extra_height + (self:InvokePlugins("VerticalSpaceAfterLine", i) or 0) | |
| end | |
| end | |
| self.scroll_range_y = self.scroll_range_y + extra_height | |
| local h = self:GetFontHeight() | |
| return width, Clamp(line_count * h + extra_height, self.MinVisibleLines * h, self.MaxVisibleLines * h) | |
| end | |
| local StretchText = UIL.StretchText | |
| local MeasureText = UIL.MeasureText | |
| local MeasureToCharStart = UIL.MeasureToCharStart | |
| local DrawSolidRect = UIL.DrawSolidRect | |
| function XTextEditor:DrawCursor(color) | |
| if self.show_cursor and terminal.desktop.keyboard_focus == self and not hr.ImeCompositionStarted then | |
| local x, y = self:GetCursorXY() | |
| DrawSolidRect(sizebox(x, y, 1, self.font_height), color or self:CalcTextColor()) | |
| end | |
| end | |
| function XTextEditor:DrawWindow(...) | |
| self:InvokePlugins("OnBeginDraw") | |
| XScrollArea.DrawWindow(self, ...) | |
| self:InvokePlugins("OnEndDraw") | |
| end | |
| function XTextEditor:DrawContent(clip_box) | |
| local destx = self.content_box:minx() - self.OffsetX | |
| local desty = self.content_box:miny() - self.OffsetY | |
| local sizex = self.content_box:sizex() | |
| local font = self:GetFontId() | |
| local text_color = self:CalcTextColor() | |
| local lines = self.lines or {} | |
| local line_height = self.font_height | |
| local hint = self.Hint | |
| if hint ~= "" and (not lines[1] or lines[1] == "") then | |
| if self.Translate then | |
| hint = _InternalTranslate(hint, self.context) | |
| end | |
| local hint_width = MeasureText(hint, font) | |
| local hint_height = self:GetFontHeight() | |
| local align_y = 0 | |
| if self.HintVAlign == "center" then | |
| align_y = (self.content_box:sizey() - hint_height) / 2 | |
| elseif self.HintVAlign == "bottom" then | |
| align_y = self.content_box:sizey() - hint_height | |
| end | |
| local hint_desty = desty + align_y | |
| local target_box = sizebox(self:AlignHDest(destx, sizex - hint_width), hint_desty, hint_width, line_height) | |
| StretchText(hint, target_box, font, self.HintColor) | |
| self:DrawCursor(text_color) | |
| return | |
| end | |
| -- get first line that is fully in the view | |
| local start_idx, start_y = self:LineIdxFromScreenY(self.content_box:miny()) -- start_y is the start of line start_idx in local coords | |
| if start_y > self.OffsetY then -- go back one line if needed | |
| start_idx = start_idx - 1 | |
| start_y = start_y - line_height - (self:InvokePlugins("VerticalSpaceAfterLine", start_idx) or 0) | |
| end | |
| desty = desty + start_y | |
| if start_idx <= #lines then | |
| -- are we starting to draw from within the selection? | |
| local color = text_color | |
| local in_selection = false | |
| local sstart_line, sstart_char, send_line, send_char = self:GetSelectionSortedBounds() | |
| if self.ime_korean_composition then | |
| send_line = sstart_line | |
| send_char = sstart_char | |
| elseif sstart_line and send_line >= start_idx and | |
| (sstart_line < start_idx or sstart_line == start_idx and sstart_char == 0) | |
| then | |
| color = self.SelectionColor | |
| in_selection = true | |
| end | |
| -- let plugins process text before the first draw line | |
| if self:HasPluginMethod("OnDrawLineOutsideView") then | |
| for i = 1, start_idx - 1 do | |
| self:InvokePlugins("OnDrawLineOutsideView", i, self:GetDisplayText(i), "above_view") | |
| end | |
| end | |
| -- start drawing line by line | |
| -- draw entire unselected text first, and selection above it | |
| local end_idx = Min(#lines, start_idx + self.content_box:sizey() / line_height + 1) | |
| for i = start_idx, end_idx do | |
| local text = self:GetDisplayText(i) | |
| local ends_with_new_line = text:ends_with("\n") | |
| if ends_with_new_line then | |
| text = text:sub(1, -2) | |
| end | |
| local width = self:MeasureTextForDisplay(text) | |
| -- draw entire unselected text first | |
| local orig_text, orig_target | |
| local target_box = sizebox(self:AlignHDest(destx, sizex - width), desty, width, line_height) | |
| if not self:InvokePlugins("OnBeforeDrawText", i, text, target_box, font, text_color) then | |
| StretchText(text, target_box, font, text_color) | |
| end | |
| if not (in_selection and send_line ~= i) then | |
| self:InvokePlugins("OnAfterDrawText", i, text, target_box, font, text_color) | |
| orig_text, orig_target = text, target_box | |
| end | |
| -- draw the selection marquee and selected text second (above unselected text) | |
| if in_selection or sstart_line == i or send_line == i then | |
| local start_x = in_selection and 0 or MeasureToCharStart(text, font, sstart_char + 1) | |
| local end_x = send_line == i and MeasureToCharStart(text, font, send_char + 1) or width | |
| if send_line ~= i and ends_with_new_line then | |
| end_x = end_x + self.font_height / 4 -- mark EOL character in selection | |
| end | |
| local target_box = sizebox(destx + self:AlignHDest(start_x, sizex - width), desty, end_x - start_x, line_height) | |
| DrawSolidRect(target_box, self.SelectionBackground) | |
| local start_char = in_selection and 1 or sstart_char + 1 | |
| text = send_line == i and utf8.sub(text, start_char, send_char) or utf8.sub(text, start_char) | |
| width = self:MeasureTextForDisplay(text) | |
| target_box = Resize(target_box, width, target_box:sizey()) | |
| StretchText(text, target_box, font, self.SelectionColor) | |
| if in_selection and send_line ~= i then | |
| self:InvokePlugins("OnDrawText", i, text, target_box, font, text_color) | |
| end | |
| in_selection = send_line ~= i | |
| end | |
| if orig_text then | |
| self:InvokePlugins("OnDrawText", i, orig_text, orig_target, font, text_color) | |
| end | |
| desty = desty + line_height + (self:InvokePlugins("VerticalSpaceAfterLine", i) or 0) | |
| end | |
| -- let plugins process text before the first draw line | |
| if self:HasPluginMethod("OnDrawLineOutsideView") then | |
| for i = end_idx + 1, #lines do | |
| self:InvokePlugins("OnDrawLineOutsideView", i, self:GetDisplayText(i), not "above_view") | |
| end | |
| end | |
| end | |
| self:DrawCursor(text_color) | |
| end | |
| function XTextEditor:SetBox(x, y, width, height) | |
| if not self.lines then | |
| XScrollArea.SetBox(self, x, y, width, height) | |
| return | |
| end | |
| local need_reflow = self.WordWrap and width ~= self.box:sizex() | |
| local size_changed = width ~= self.box:sizex() or height ~= self.box:sizey() | |
| XScrollArea.SetBox(self, x, y, width, height) | |
| if need_reflow then | |
| local cursor_idx = self:GetCursorCharIdx() | |
| self:SetTranslatedText(table.concat(self:GetTextLines()), "force_reflow") | |
| self:SetCursor(self:CursorFromCharIdx(cursor_idx)) | |
| elseif size_changed and self:IsFocused() then | |
| self:ScrollCursorIntoView() | |
| end | |
| end | |
| ----- selection | |
| function XTextEditor:StartSelecting() | |
| self.selection_start_line = self.cursor_line | |
| self.selection_start_char = self.cursor_char | |
| end | |
| function XTextEditor:ClearSelection() | |
| if self.selection_start_line == false and self.selection_start_char == false then return end | |
| self.selection_start_line = false | |
| self.selection_start_char = false | |
| self:Invalidate() | |
| end | |
| function XTextEditor:HasSelection() | |
| return | |
| self.selection_start_line and | |
| (self.selection_start_line ~= self.cursor_line or self.selection_start_char ~= self.cursor_char) | |
| end | |
| function XTextEditor:ReverseSelectionBounds() | |
| self.selection_start_line, self.selection_start_char, self.cursor_line, self.cursor_char = | |
| self.cursor_line, self.cursor_char, self.selection_start_line, self.selection_start_char | |
| end | |
| function XTextEditor:GetSelectionSortedBounds() | |
| if not self:HasSelection() then | |
| return | |
| end | |
| local selection_backwards = | |
| self.selection_start_line < self.cursor_line or | |
| self.selection_start_line == self.cursor_line and self.selection_start_char < self.cursor_char | |
| if selection_backwards then | |
| return self.selection_start_line, self.selection_start_char, self.cursor_line, self.cursor_char | |
| else | |
| return self.cursor_line, self.cursor_char, self.selection_start_line, self.selection_start_char | |
| end | |
| end | |
| function XTextEditor:GetSelectedTextInternal(sstart_line, sstart_char, send_line, send_char) | |
| if not sstart_line then | |
| sstart_line, sstart_char, send_line, send_char = self:GetSelectionSortedBounds() | |
| end | |
| if not sstart_line then | |
| return "" | |
| elseif sstart_line == send_line then | |
| return utf8.sub(self.lines[sstart_line], sstart_char + 1, send_char) | |
| else | |
| return | |
| utf8.sub(self.lines[sstart_line], sstart_char + 1) .. | |
| table.concat(self.lines, "", sstart_line + 1, send_line - 1) .. | |
| (send_line > #self.lines and "" or utf8.sub(self.lines[send_line], 1, send_char)) | |
| end | |
| end | |
| function XTextEditor:GetSelectedText() | |
| local text = self:GetSelectedTextInternal() | |
| if self.NewLine ~= "\n" then | |
| text = text:gsub("\n", self.NewLine) | |
| end | |
| return text | |
| end | |
| function XTextEditor:SelectAll() | |
| self:SetCursor(1, 0, false) | |
| self:SetCursor(#self.lines, utf8.len(self.lines[#self.lines]), true) | |
| end | |
| function XTextEditor:SelectFirstOccurence(text, ignore_case) | |
| if text == "" then return end | |
| if ignore_case then | |
| text = text:lower() | |
| end | |
| for line, line_text in ipairs(self.lines) do | |
| line_text = ignore_case and line_text:lower() or line_text | |
| local char = string.find(line_text, text, 1, true) | |
| if char then | |
| self:ClearSelection() | |
| self:SetCursor(line, char - 1, false) | |
| self:SetCursor(line, char - 1 + utf8.len(text), true) | |
| self:ScrollCursorIntoView() | |
| self:InvokePlugins("OnSelectHighlight", text, ignore_case) | |
| return true | |
| end | |
| end | |
| end | |
| function XTextEditor:SelectWordUnderCursor() | |
| local pos = 0 | |
| local line_text = self.lines[self.cursor_line] | |
| for word in line_text:gmatch(word_pattern) do | |
| local len = utf8.len(word) | |
| if pos + len > self.cursor_char then | |
| word = word:match(strict_word_pattern) | |
| self:ClearSelection() | |
| self:SetCursor(self.cursor_line, pos, false) | |
| self:SetCursor(self.cursor_line, pos + utf8.len(word), true) | |
| self:InvokePlugins("OnWordSelection", word) | |
| return true | |
| end | |
| pos = pos + len | |
| end | |
| end | |
| function XTextEditor:GetWordUnderCursor(pt) | |
| local pos = 0 | |
| local line, char = self:CursorFromPoint(pt:x(), pt:y()) | |
| local line_text = self.lines[line] | |
| for word in line_text:gmatch(word_pattern) do | |
| local len = utf8.len(word) | |
| if pos + len > char then | |
| word = word:match(strict_word_pattern) | |
| return word | |
| end | |
| pos = pos + len | |
| end | |
| end | |
| ----- messages | |
| function XTextEditor:OnMouseButtonDown(pt, button) | |
| if button == "L" then | |
| if self.desktop:GetKeyboardFocus() ~= self and self.AutoSelectAll then | |
| self:SetFocus() | |
| else | |
| local line, char = self:CursorFromPoint(pt:x(), pt:y()) | |
| self:SetCursor(line, char, IsShiftPressed()) | |
| self:SetFocus() | |
| if not self.touch then | |
| self.desktop:SetMouseCapture(self) | |
| end | |
| end | |
| return "break" | |
| end | |
| if button == "R" and self:InvokePlugins("OnRightButtonDown", pt) then | |
| return "break" | |
| end | |
| end | |
| function XTextEditor:OnMousePos(pt) | |
| if self.desktop:GetMouseCapture() == self or self.touch then | |
| local line, char = self:CursorFromPoint(pt:x(), pt:y()) | |
| self:SetCursor(line, char, true) | |
| return "break" | |
| end | |
| end | |
| function XTextEditor:OnMouseButtonUp(pt, button) | |
| if button == "L" then | |
| self.desktop:SetMouseCapture() | |
| return "break" | |
| end | |
| end | |
| function XTextEditor:OnMouseButtonDoubleClick(pt, button) | |
| if button == "L" then | |
| if not self:SelectWordUnderCursor() then | |
| local line_text = self.lines[self.cursor_line] | |
| self:ClearSelection() | |
| self:SetCursor(self.cursor_line, 0, false) | |
| self:SetCursor(self.cursor_line, utf8.len(line_text), true) | |
| end | |
| return "break" | |
| end | |
| end | |
| function XTextEditor:OnTouchBegan(id, pt, touch) | |
| self.touch = true | |
| self:OnMouseButtonDown(pt, "L") | |
| return "capture" | |
| end | |
| function XTextEditor:OnTouchMoved(id, pt, touch) | |
| if touch.capture == self then | |
| self:OnMousePos(pt) | |
| return "break" | |
| end | |
| end | |
| function XTextEditor:OnTouchEnded() | |
| self.touch = false | |
| return "break" | |
| end | |
| function XTextEditor:OnTouchCancelled() | |
| self.touch = false | |
| return "break" | |
| end | |
| function XTextEditor:OnKbdKeyUp(virtual_key) | |
| if self:ShouldConsumeVk(virtual_key) then | |
| return "break" | |
| end | |
| end | |
| function XTextEditor:OnKbdKeyDown(virtual_key) | |
| if self:InvokePlugins("OnKbdKeyDown", virtual_key) then | |
| return "break" | |
| end | |
| if self:ShouldConsumeVk(virtual_key) then | |
| return "break" | |
| end | |
| end | |
| function XTextEditor:ShouldConsumeVk(virtual_key) | |
| -- if we catch Ctrl/Alt/Shift we will break shortcuts. | |
| -- vkPass are single key shortcuts accepted by the control. We need to pass them as well. | |
| return self.vkConsume[virtual_key] | |
| and not IsControlPressed() | |
| and not IsAltPressed() | |
| and not table.find(self.vkPass, virtual_key) | |
| end | |
| function XTextEditor:OnKbdChar(char, virtual_key) | |
| if self:ProcessChar(char) then | |
| return "break" | |
| end | |
| end | |
| function XTextEditor:OnKbdIMEStartComposition(char, virtual_key, repeated, time, lang) --char, vkey, repeat, time, lang | |
| self:DestroyCursorBlinkThread() | |
| self:Invalidate() | |
| self:ImeUpdatePos() | |
| if lang == "ko" then | |
| self.ime_korean_composition = true | |
| end | |
| return "break" | |
| end | |
| function XTextEditor:OnKbdIMEEndComposition(...) --char, vkey, repeat, time, lang | |
| self:CreateCursorBlinkThread() | |
| self.ime_korean_composition = false | |
| return "break" | |
| end | |
| function XTextEditor:OnKbdIMEUpdateComposition(...) | |
| if not self.ime_korean_composition then | |
| return "break" | |
| end | |
| -- replace the IME composition text with the new one, keep it selected | |
| local charidx = self:GetCursorCharIdx() | |
| local comp = terminal.GetWindowsImeCompositionString() | |
| self:EditOperation(comp, "undo") | |
| local line, char = self:CursorFromCharIdx(charidx) | |
| self:SetCursor(line, char, true) | |
| end | |
| -- Build table with virtual keys, whose messages must be consumed | |
| -- the numbers below are various OEM virtual key codes that correspond to characters, see http://cherrytree.at/misc/vk.htm | |
| XTextEditor.vkConsume = { | |
| [186] = true, [187] = true, [188] = true, [189] = true, [190] = true, | |
| [191] = true, [192] = true, [219] = true, [220] = true, [221] = true, [222] = true, | |
| [226] = true | |
| } | |
| local function AddConsumeConst(string) | |
| if rawget(const, string) then | |
| XTextEditor.vkConsume[const[string]] = true | |
| end | |
| end | |
| for i = string.byte("A"), string.byte("Z") do | |
| AddConsumeConst("vk"..string.char(i)) | |
| end | |
| for i = string.byte("0"), string.byte("9") do | |
| AddConsumeConst("vk"..string.char(i)) | |
| AddConsumeConst("vkNumpad"..string.char(i)) | |
| end | |
| AddConsumeConst("vkBackspace") | |
| AddConsumeConst("vkSpace") | |
| AddConsumeConst("vkMinus") | |
| AddConsumeConst("vkPlus") | |
| AddConsumeConst("vkOpensq") | |
| AddConsumeConst("vkClosesq") | |
| AddConsumeConst("vkSemicolon") | |
| AddConsumeConst("vkTilde") | |
| AddConsumeConst("vkQuote") | |
| AddConsumeConst("vkComma") | |
| AddConsumeConst("vkDot") | |
| AddConsumeConst("vkSlash") | |
| AddConsumeConst("vkBackslash") | |
| AddConsumeConst("vkLeft") | |
| AddConsumeConst("vkRight") | |
| AddConsumeConst("vkDelete") | |
| AddConsumeConst("vkHome") | |
| AddConsumeConst("vkEnd") | |
| AddConsumeConst("vkEnter") | |
| AddConsumeConst("vkMultiply") | |
| AddConsumeConst("vkAdd") | |
| AddConsumeConst("vkSubtract") | |
| AddConsumeConst("vkDivide") | |
| AddConsumeConst("vkSeparator") | |
| AddConsumeConst("vkDecimal") | |
| AddConsumeConst("vkProcesskey") -- c++ will send us one such key down event on IME composition done | |
| function HasControllerTextInput() | |
| return Platform.console or (Platform.steam and IsSteamInBigPictureMode()) or Platform.steamdeck | |
| end | |
| function XTextEditor:OpenControllerTextInput() | |
| -- if not HasControllerTextInput() then CreateMessageBox(nil, T("(design)Error opening Virtual Keyboard"), T("(design)A virtual keyboard is not set up for this platform.\nPlease use a mouse and keyboard and try again.")) end | |
| if not self:IsThreadRunning("keyboard") then | |
| self:CreateThread("keyboard", function() | |
| local current_text = self:GetTranslatedText() | |
| local text, err = WaitControllerTextInput(self:GetPassword() and "" or current_text, self.ConsoleKeyboardTitle, | |
| self.ConsoleKeyboardDescription, Clamp(self:GetMaxLen(), 0, 256), self:GetPassword()) | |
| if not err and self.window_state ~= "destroying" then | |
| text = text:trim_spaces() | |
| if text ~= current_text then | |
| self:OnControllerTextInput(text) | |
| end | |
| end | |
| end) | |
| end | |
| end | |
| function XTextEditor:OnControllerTextInput(text) | |
| self:SetText(self.UserText and CreateUserText(text, self.UserTextType) or (self.Translate and T(text)) or text) | |
| end | |
| if FirstLoad then | |
| ActiveVirtualKeyboard = {} | |
| end | |
| -- Accepts T{} notation texts for title and description, returns plaintext | |
| function WaitControllerTextInput(default, title, description, max_length, password) | |
| if not HasControllerTextInput() then return default end -- trivial stub for PC | |
| assert(default == "" or not IsT(default), "Use a plaintext default value") | |
| assert(IsT(title) and IsT(description), "Description and title must be T") | |
| local err, shown, text = AsyncOpWait(nil, ActiveVirtualKeyboard, "AsyncShowVirtualKeyboard", default, _InternalTranslate(title), _InternalTranslate(description), max_length, password or false) | |
| text = err and default or text or "" | |
| return text, err, shown | |
| end | |
| ----- XTextEditorPlugin | |
| DefineClass.XTextEditorPlugin = { | |
| __parents = { "InitDone" }, | |
| OnTextChanged = function(self, edit) end, | |
| OnBeginDraw = function(self, edit) end, | |
| OnEndDraw = function(self, edit) end, | |
| OnSetFocus = function(self, edit, old_focus) end, | |
| OnKillFocus = function(self, edit) end, | |
| OnDrawLineOutsideView = function(self, edit, line_idx, text) end, -- use to parse/process text that is outside the view and is thus not drawn | |
| OnDrawText = function(self, edit, line_idx, text, target_box, font, text_color) end, | |
| OnBeforeDrawText = function(self, edit, line_idx, text, target_box, font, text_color) end, | |
| OnAfterDrawText = function(self, edit, line_idx, text, target_box, font, text_color) end, | |
| OnWordSelection = function(self, edit, word) end, | |
| OnSelectHighlight = function(self, edit, highlighted_text, ignore_case) end, | |
| OnRightButtonDown = function(self, edit, pt) end, | |
| OnShortcut = function(self, edit, shortcut, source, ...) end, | |
| OnKbdKeyDown = function(self, edit, virtual_key) end, | |
| VerticalSpaceAfterLine = function(self, edit, line) end, | |
| MultiLineOnly = false, -- functional only for XMultiLineEdit controls | |
| SingleInstance = true, -- a single instance of the plugin will be used (for all text editors) | |
| } | |
| function TextEditorPluginsCombo(multiline) | |
| local items = { "" } | |
| ClassDescendantsList("XTextEditorPlugin", function(name, class) | |
| if not (class.MultiLineOnly and not multiline) then | |
| items[#items + 1] = name | |
| end | |
| end) | |
| return items | |
| end | |
| ----- XEdit, XNumberEdit & XMultiLineEdit (the actual edit controls) | |
| DefineClass.XEdit = { | |
| __parents = { "XTextEditor" }, | |
| properties = { | |
| { category = "General", id = "Multiline", editor = false, default = false }, | |
| { category = "General", id = "WordWrap", editor = false }, | |
| { category = "General", id = "MinVisibleLines", editor = false }, | |
| { category = "General", id = "MaxVisibleLines", editor = false }, | |
| { category = "General", id = "MaxLines", editor = false }, | |
| { category = "Visual", id = "HintVAlign", editor = false }, | |
| }, | |
| Multiline = false, | |
| WordWrap = false, | |
| MinVisibleLines = 1, | |
| MaxVisibleLines = 1, | |
| MaxLen = 1024, | |
| MaxLines = 1, | |
| HintVAlign = "center" | |
| } | |
| DefineClass.XNumberEdit = { | |
| __parents = { "XEdit" }, | |
| properties = { | |
| { category = "General", id = "Password", editor = false }, | |
| { category = "General", id = "Translate", editor = false , default = false }, | |
| { category = "General", id = "IsInRange", editor = "bool", default = false }, | |
| { category = "General", id = "MinValue", editor = "number", default = 0 }, | |
| { category = "General", id = "MaxValue", editor = "number", default = 100 }, | |
| }, | |
| Filter = "[/%*%+%(%)%%%-%.,0-9 ]", | |
| } | |
| function XNumberEdit:SetRange(min, max) | |
| self.MaxValue = tonumber(max) | |
| self.MinValue = tonumber(min) | |
| end | |
| function XNumberEdit:SetNumber(text) | |
| self:SetTranslatedText(text) | |
| end | |
| function XNumberEdit:SetTranslatedText(text) | |
| XTextEditor.SetTranslatedText(self, tostring(text)) | |
| end | |
| local expr_env = LuaValueEnv() | |
| function XNumberEdit:GetNumber() | |
| local text = self:GetText() | |
| return tonumber(text) or tonumber(dostring("return " .. text, expr_env) or "") | |
| end | |
| function XNumberEdit:OnTextChanged() | |
| local number = self:GetNumber() | |
| if number and self.IsInRange and (number < self.MinValue or number > self.MaxValue) then | |
| number = Clamp(number, self.MinValue, self.MaxValue) | |
| self:SetTranslatedText(number) | |
| end | |
| end | |
| DefineClass.XMultiLineEdit = { | |
| __parents = { "XTextEditor" }, | |
| properties = { | |
| { category = "General", id = "Multiline", editor = false }, | |
| { category = "General", id = "Password", editor = false }, | |
| }, | |
| Multiline = true, | |
| AllowTabs = true, | |
| Password = false, | |
| vkPass = { | |
| const.vkEnd, | |
| const.vkHome, | |
| const.vkLeft, | |
| const.vkRight, | |
| const.vkInsert, | |
| const.vkDelete, | |
| const.vkBackspace, | |
| const.vkUp, | |
| const.vkDown, | |
| const.vkPageup, | |
| const.vkPagedown, | |
| }, | |
| } | |