Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions asset/gameplay/ui.dl
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,6 @@ cardlist :
editbox :
cursor_speed : 20
cursor_color : 0x0000ff
line_height : 22
selection_color : 0x664f7dff
preedit_color : 0x000000
384 changes: 384 additions & 0 deletions core/editbox.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,384 @@
local utf8 = utf8
local table = table

global setmetatable

local editbox = {}

local KEY_BACKSPACE <const> = 259
local KEY_DELETE <const> = 261
local KEY_RIGHT <const> = 262
local KEY_LEFT <const> = 263
local KEY_HOME <const> = 268
local KEY_END <const> = 269
local KEYSTATE_PRESS <const> = 1
local KEYSTATE_REPEAT <const> = 2
local MODIFIER_SHIFT <const> = 0x1
local HISTORY_LIMIT <const> = 100

local remove = table.remove

local Editor = {}
Editor.__index = Editor

local function text_len(text)
return utf8.len(text or "") or 0
end

local function clamp_position(text, position)
local len = text_len(text)
position = position or len
if position < 0 then
return 0
elseif position > len then
return len
end
return position
end

local function byte_offset(text, position)
if position <= 0 then
return 1
end
local len = text_len(text)
if position >= len then
return #text + 1
end
return utf8.offset(text, position + 1) or (#text + 1)
end

local function is_control_codepoint(codepoint)
return codepoint < 32 or (codepoint >= 127 and codepoint <= 159)
end

local function clean_text(text)
if not text or text == "" then
return ""
end
local out
local last = 1
for offset, codepoint in utf8.codes(text) do
if is_control_codepoint(codepoint) then
out = out or {}
if offset > last then
out[#out + 1] = text:sub(last, offset - 1)
end
last = offset + #utf8.char(codepoint)
end
end
if not out then
return text
end
if last <= #text then
out[#out + 1] = text:sub(last)
end
return table.concat(out)
end

local function snapshot(self)
return {
text = self._text,
cursor = self._cursor,
selection_anchor = self._selection_anchor,
selection_focus = self._selection_focus,
}
end

local function same_snapshot(a, b)
return a
and b
and a.text == b.text
and a.cursor == b.cursor
and a.selection_anchor == b.selection_anchor
and a.selection_focus == b.selection_focus
end

local function push_snapshot(stack, value)
if same_snapshot(stack[#stack], value) then
return
end
stack[#stack + 1] = value
if #stack > HISTORY_LIMIT then
remove(stack, 1)
end
end

local function clear_selection(self)
self._selection_anchor = nil
self._selection_focus = nil
end

local function restore_snapshot(self, value)
self._text = value.text or ""
self._cursor = clamp_position(self._text, value.cursor)
self._selection_anchor = value.selection_anchor
self._selection_focus = value.selection_focus
end

local function record_undo(self)
push_snapshot(self._undo, snapshot(self))
self._redo = {}
end

local function selection_range(self)
local anchor = self._selection_anchor
local focus = self._selection_focus
if not anchor or not focus then
return
end
local text = self._text
local start = clamp_position(text, anchor)
local finish = clamp_position(text, focus)
if finish < start then
start, finish = finish, start
end
if start == finish then
return
end
return start, finish
end

local function delete_selection(self)
local start, finish = selection_range(self)
if not start then
return false
end
local text = self._text
record_undo(self)
self._text = text:sub(1, byte_offset(text, start) - 1) .. text:sub(byte_offset(text, finish))
self._cursor = start
clear_selection(self)
return true
end

local function set_cursor(self, position, select)
local cursor = clamp_position(self._text, position)
local changed = self._cursor ~= cursor
if self._selection_anchor or self._selection_focus then
changed = true
end
if select then
if not self._selection_anchor then
self._selection_anchor = clamp_position(self._text, self._cursor)
end
self._selection_focus = cursor
else
clear_selection(self)
end
self._cursor = cursor
return changed
end

local function move_cursor_by(self, delta, select)
if not select then
local start, finish = selection_range(self)
if start then
if delta < 0 then
return set_cursor(self, start, false)
end
return set_cursor(self, finish, false)
end
end
return set_cursor(self, self._cursor + delta, select)
end

local function shift_modifier(modifiers)
return (modifiers or 0) & MODIFIER_SHIFT ~= 0
end

function editbox.new(text)
text = clean_text(text or "")
return setmetatable({
_text = text,
_cursor = text_len(text),
_undo = {},
_redo = {},
}, Editor)
end

function Editor:text()
return self._text
end

function Editor:set_text(text)
self._text = clean_text(text or "")
self._cursor = text_len(self._text)
clear_selection(self)
self._undo = {}
self._redo = {}
end

function Editor:cursor_position()
return clamp_position(self._text, self._cursor)
end

function Editor:selection()
local cursor = self:cursor_position()
local start, finish = selection_range(self)
return {
start = start or cursor,
finish = finish or cursor,
anchor = self._selection_anchor or cursor,
focus = self._selection_focus or cursor,
}
end

function Editor:selected_text()
local start, finish = selection_range(self)
if not start then
return
end
local text = self._text
return text:sub(byte_offset(text, start), byte_offset(text, finish) - 1)
end

function Editor:select_all()
local len = text_len(self._text)
if len == 0 then
clear_selection(self)
return false
end
self._selection_anchor = 0
self._selection_focus = len
self._cursor = len
return true
end

function Editor:insert_text(text)
text = clean_text(text)
if text == "" then
return false
end
record_undo(self)
local start, finish = selection_range(self)
local position = clamp_position(self._text, self._cursor)
local current = self._text
if start then
self._text = current:sub(1, byte_offset(current, start) - 1) .. text .. current:sub(byte_offset(current, finish))
position = start
else
self._text = current:sub(1, byte_offset(current, position) - 1) ..
text .. current:sub(byte_offset(current, position))
end
self._cursor = position + text_len(text)
clear_selection(self)
return true
end

function Editor:backspace()
if delete_selection(self) then
return true
end
local text = self._text
local cursor = clamp_position(text, self._cursor)
if text == "" or cursor == 0 then
return false
end
record_undo(self)
self._text = text:sub(1, byte_offset(text, cursor - 1) - 1) .. text:sub(byte_offset(text, cursor))
self._cursor = cursor - 1
return true
end

function Editor:delete_forward()
if delete_selection(self) then
return true
end
local text = self._text
local cursor = clamp_position(text, self._cursor)
if cursor >= text_len(text) then
return false
end
record_undo(self)
self._text = text:sub(1, byte_offset(text, cursor) - 1) .. text:sub(byte_offset(text, cursor + 1))
self._cursor = cursor
return true
end

function Editor:copy()
return self:selected_text()
end

function Editor:cut()
local value = self:selected_text()
if not value then
return
end
delete_selection(self)
return value
end

function Editor:paste(text)
return self:insert_text(text)
end

function Editor:begin_selection(position)
local cursor = clamp_position(self._text, position)
local changed = self._cursor ~= cursor or self._selection_anchor ~= cursor or self._selection_focus ~= cursor
self._cursor = cursor
self._selection_anchor = cursor
self._selection_focus = cursor
return changed
end

function Editor:update_selection(position)
local cursor = clamp_position(self._text, position)
if not self._selection_anchor then
self._selection_anchor = clamp_position(self._text, self._cursor)
end
local changed = self._cursor ~= cursor or self._selection_focus ~= cursor
self._cursor = cursor
self._selection_focus = cursor
return changed
end

function Editor:finish_selection()
if self._selection_anchor == self._selection_focus then
clear_selection(self)
return true
end
return false
end

function Editor:undo()
local value = remove(self._undo)
if not value then
return false
end
push_snapshot(self._redo, snapshot(self))
restore_snapshot(self, value)
return true
end

function Editor:redo()
local value = remove(self._redo)
if not value then
return false
end
push_snapshot(self._undo, snapshot(self))
restore_snapshot(self, value)
return true
end

function Editor:key(keycode, key_state, modifiers)
if key_state ~= KEYSTATE_PRESS and key_state ~= KEYSTATE_REPEAT then
return false
end
local select = shift_modifier(modifiers)
if keycode == KEY_BACKSPACE then
return self:backspace()
elseif keycode == KEY_DELETE then
return self:delete_forward()
elseif keycode == KEY_LEFT then
return move_cursor_by(self, -1, select)
elseif keycode == KEY_RIGHT then
return move_cursor_by(self, 1, select)
elseif keycode == KEY_HOME then
return set_cursor(self, 0, select)
elseif keycode == KEY_END then
return set_cursor(self, text_len(self._text), select)
end
return false
end

return editbox
Loading