myspace / CommonLua /X /XTextEditor.lua
sirnii's picture
Upload 1816 files
b6a38d7 verified
raw
history blame
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,
},
}