diff --git a/asset/gameplay/ui.dl b/asset/gameplay/ui.dl index 332a2ab..2695eda 100644 --- a/asset/gameplay/ui.dl +++ b/asset/gameplay/ui.dl @@ -51,4 +51,6 @@ cardlist : editbox : cursor_speed : 20 cursor_color : 0x0000ff + line_height : 22 + selection_color : 0x664f7dff preedit_color : 0x000000 diff --git a/core/editbox.lua b/core/editbox.lua new file mode 100644 index 0000000..1ff5386 --- /dev/null +++ b/core/editbox.lua @@ -0,0 +1,384 @@ +local utf8 = utf8 +local table = table + +global setmetatable + +local editbox = {} + +local KEY_BACKSPACE = 259 +local KEY_DELETE = 261 +local KEY_RIGHT = 262 +local KEY_LEFT = 263 +local KEY_HOME = 268 +local KEY_END = 269 +local KEYSTATE_PRESS = 1 +local KEYSTATE_REPEAT = 2 +local MODIFIER_SHIFT = 0x1 +local HISTORY_LIMIT = 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 diff --git a/core/keyboard.lua b/core/keyboard.lua index da26332..010052b 100644 --- a/core/keyboard.lua +++ b/core/keyboard.lua @@ -3,6 +3,7 @@ local mattext = require "soluna.material.text" local matquad = require "soluna.material.quad" local font = require "soluna.font" local ui = require "core.rules".ui.editbox +local editbox = require "core.editbox" local utf8 = utf8 global assert, print @@ -12,74 +13,254 @@ local CURRENT_EDITBOX local CURSOR_SPEED = ui.cursor_speed local CURSOR_COLOR = ui.cursor_color local PREEDIT_COLOR = ui.preedit_color -local KEY_LEFT = 263 -local KEY_RIGHT = 262 +local SELECTION_COLOR = ui.selection_color or 0x664f7dff +local LINE_HEIGHT = ui.line_height + +local KEY_A = 65 +local KEY_C = 67 +local KEY_R = 82 +local KEY_V = 86 +local KEY_X = 88 +local KEY_Y = 89 +local KEY_Z = 90 local KEY_ESC = 256 local KEY_ENTER = 257 -local KEY_DEL = 261 -local KEY_BACKSPACE = 259 -local KEY_HOME = 268 -local KEY_END = 269 +local KEY_LEFT_SHIFT = 340 +local KEY_LEFT_CONTROL = 341 +local KEY_LEFT_SUPER = 343 +local KEY_RIGHT_SHIFT = 344 +local KEY_RIGHT_CONTROL = 345 +local KEY_RIGHT_SUPER = 347 +local MODIFIER_SHIFT = 0x1 +local MODIFIER_CTRL = 0x2 +local MODIFIER_SUPER = 0x8 +local KEYSTATE_RELEASE = 0 local KEYSTATE_PRESS = 1 +local KEYSTATE_REPEAT = 2 local keyboard = {} +local modifier_state = { + shift = false, + ctrl = false, + super = false, +} + +local modifier_key = { + [KEY_LEFT_SHIFT] = "shift", + [KEY_RIGHT_SHIFT] = "shift", + [KEY_LEFT_CONTROL] = "ctrl", + [KEY_RIGHT_CONTROL] = "ctrl", + [KEY_LEFT_SUPER] = "super", + [KEY_RIGHT_SUPER] = "super", +} + +local function current_modifiers() + local modifiers = 0 + if modifier_state.shift then + modifiers = modifiers | MODIFIER_SHIFT + end + if modifier_state.ctrl then + modifiers = modifiers | MODIFIER_CTRL + end + if modifier_state.super then + modifiers = modifiers | MODIFIER_SUPER + end + return modifiers +end + +local function update_modifiers(modifiers) + modifier_state.shift = modifiers & MODIFIER_SHIFT ~= 0 + modifier_state.ctrl = modifiers & MODIFIER_CTRL ~= 0 + modifier_state.super = modifiers & MODIFIER_SUPER ~= 0 +end + +local function update_modifier_key(keycode, state) + local field = modifier_key[keycode] + if not field then + return + end + modifier_state[field] = state ~= KEYSTATE_RELEASE + return true +end + +local function command_modifier(modifiers) + return ((modifiers or 0) & (MODIFIER_CTRL | MODIFIER_SUPER)) ~= 0 +end + +local function normalize_command_key(keycode) + if keycode and keycode >= 97 and keycode <= 122 then + return keycode - 32 + end + return keycode +end + +local function reset_cursor(desc) + desc.cursor_ticker = -CURSOR_SPEED +end + +local function changed(desc) + desc.label = nil + reset_cursor(desc) +end + +local function handle_command(desc, keycode, state) + if state ~= KEYSTATE_PRESS then + return true + end + keycode = normalize_command_key(keycode) + local editor = desc.editor + if keycode == KEY_A then + editor:select_all() + reset_cursor(desc) + elseif keycode == KEY_C then + local text = editor:copy() + if text then + app.set_clipboard_text(text) + end + elseif keycode == KEY_X then + local text = editor:cut() + if text then + app.set_clipboard_text(text) + changed(desc) + end + elseif keycode == KEY_V then + return true + elseif keycode == KEY_Z then + if editor:undo() then + changed(desc) + end + elseif keycode == KEY_Y or keycode == KEY_R then + if editor:redo() then + changed(desc) + end + else + return false + end + return true +end + +local function layout_align(align) + return align or "LV" +end + +local function ensure_block(desc) + local align = layout_align(desc.align) + local line_height = desc.line_height or LINE_HEIGHT + if desc.block + and desc.block_fontid == desc.fontid + and desc.block_fontsize == desc.fontsize + and desc.block_color == desc.color + and desc.block_align == align + and desc.block_line_height == line_height + then + return + end + if line_height then + local styles = mattext.styles(fontcobj, { + { + font = assert(desc.fontid), + size = assert(desc.fontsize), + color = desc.color or 0, + line_height = line_height, + }, + }) + desc.block, desc.cursor_get = mattext.block(styles, align) + else + desc.block, desc.cursor_get = mattext.block( + fontcobj, + assert(desc.fontid), + assert(desc.fontsize), + desc.color or 0, + align) + end + desc.block_fontid = desc.fontid + desc.block_fontsize = desc.fontsize + desc.block_color = desc.color + desc.block_align = align + desc.block_line_height = line_height + desc.label = nil +end + +local function selection_range(editor) + local selection = editor:selection() + if selection.start == selection.finish then + return + end + return selection.start, selection.finish +end + +local function update_selection(desc, layout) + local start, finish = selection_range(desc.editor) + if not start then + desc.selection_quad = nil + return + end + local line = layout:line(1) + if not line then + desc.selection_quad = nil + return + end + local x1 = start <= line.start and line.x or layout:cursor(start) + local x2 = finish >= line.finish and line.x + line.width or layout:cursor(finish) + if x2 < x1 then + x1, x2 = x2, x1 + end + local width = x2 - x1 + if width <= 0 then + desc.selection_quad = nil + return + end + desc.selection_x = x1 + desc.selection_y = line.y + desc.selection_quad = matquad.quad(width, line.height, SELECTION_COLOR) +end + function keyboard.setup(callback) - function callback.key(keycode, state) - if state == KEYSTATE_PRESS then - if CURRENT_EDITBOX then - local cursor = CURRENT_EDITBOX.cursor - if keycode == KEY_RIGHT then - CURRENT_EDITBOX.cursor = cursor + 1 - elseif keycode == KEY_LEFT then - CURRENT_EDITBOX.cursor = cursor - 1 - elseif keycode == KEY_HOME then - CURRENT_EDITBOX.cursor = 0 - elseif keycode == KEY_END then - local text = CURRENT_EDITBOX.text - if text then - CURRENT_EDITBOX.cursor = utf8.len(text) - end - elseif keycode == KEY_BACKSPACE then - local input = CURRENT_EDITBOX.input - if input then - local offset = utf8.offset(input, -1) - 1 - if offset == 0 then - CURRENT_EDITBOX.input = nil - else - input:sub(1, offset) - end - elseif cursor > 0 then - local text = CURRENT_EDITBOX.text - local offset_start, offset_end = utf8.offset(text, cursor) - CURRENT_EDITBOX.cursor = cursor - 1 - CURRENT_EDITBOX.text = text:sub(1, offset_start - 1) .. text:sub(offset_end + 1) - CURRENT_EDITBOX.label = nil - end - elseif keycode == KEY_DEL then - local text = CURRENT_EDITBOX.text - local offset_start, offset_end = utf8.offset(text, cursor+1) - if offset_start then - CURRENT_EDITBOX.text = text:sub(1, offset_start - 1) .. text:sub(offset_end + 1) - CURRENT_EDITBOX.label = nil - end - elseif keycode == KEY_ESC then - CURRENT_EDITBOX.exit = false - elseif keycode == KEY_ENTER then - CURRENT_EDITBOX.exit = true - end - CURRENT_EDITBOX.cursor_ticker = -CURSOR_SPEED + function callback.key(keycode, state, modifiers) + if modifiers then + update_modifiers(modifiers) + else + if update_modifier_key(keycode, state) then + return end + modifiers = current_modifiers() + end + local desc = CURRENT_EDITBOX + if not desc then + return + end + if state ~= KEYSTATE_PRESS and state ~= KEYSTATE_REPEAT then + return + end + if state == KEYSTATE_PRESS and keycode == KEY_ESC then + desc.exit = false + reset_cursor(desc) + elseif state == KEYSTATE_PRESS and keycode == KEY_ENTER then + desc.exit = true + reset_cursor(desc) + elseif command_modifier(modifiers) and handle_command(desc, keycode, state) then + return + elseif desc.editor:key(keycode, state, modifiers) then + changed(desc) end end local function is_control_char(codepoint) return codepoint < 32 or (codepoint >= 127 and codepoint <= 159) end function callback.char(codepoint) - if CURRENT_EDITBOX and not is_control_char(codepoint) then - local c = utf8.char(codepoint) - CURRENT_EDITBOX.input = (CURRENT_EDITBOX.input or "") .. c + if not CURRENT_EDITBOX or is_control_char(codepoint) or command_modifier(current_modifiers()) then + return + end + if CURRENT_EDITBOX.editor:insert_text(utf8.char(codepoint)) then + changed(CURRENT_EDITBOX) + end + end + function callback.clipboard_pasted(text) + local desc = CURRENT_EDITBOX + if desc and desc.editor:paste(text) then + changed(desc) end end end @@ -87,31 +268,26 @@ end function keyboard.editbox(desc) CURRENT_EDITBOX = desc if desc then - if not desc.block then - desc.block, desc.cursor_get = mattext.block( - fontcobj, - assert(desc.fontid), - assert(desc.fontsize), - desc.color or 0, - desc.align or "LV") + if not desc.editor then + desc.editor = editbox.new(desc.text or "") + end + ensure_block(desc) + if not desc.ime_font_set then app.set_ime_font(desc.fontname, desc.fontsize) + desc.ime_font_set = true + end + local text = desc.editor:text() + local layout = desc.cursor_get(text, desc.width, desc.height) + local cx, cy, cw, ch, cursor, descent = layout:cursor(desc.editor:cursor_position()) + desc.cursor = cursor + desc.text = text + desc.layout = layout + if not desc.label then + desc.label = desc.block(text, desc.width, desc.height) end - local text = desc.text or "" - local width = desc.width - local height = desc.height - local layout = desc.cursor_get(text, width, height) - local cx, cy, cw, ch, cursor, decent = layout:cursor(desc.cursor or utf8.len(text)) - if desc.input then - local input_len = utf8.len(desc.input) - local offset = utf8.offset(text, cursor + 1) - text = text:sub(1, offset-1) .. desc.input .. text:sub(offset) - desc.input = nil - cursor = cursor + input_len - cx, cy, cw, ch, cursor, decent = layout:cursor(cursor) - desc.label = nil - end - local x = cx+desc.ime_x - local y = cy+desc.ime_y-decent + update_selection(desc, layout) + local x = cx + desc.ime_x + local y = cy + desc.ime_y - descent if x ~= desc.ime_rect_x or y ~= desc.ime_rect_y then desc.ime_rect_x = x desc.ime_rect_y = y @@ -123,20 +299,13 @@ function keyboard.editbox(desc) text_color=PREEDIT_COLOR, } end - desc.cursor = cursor - desc.text = text - if not desc.label then - desc.label = desc.block(text, width, height) - end local cursor_ticker = desc.cursor_ticker or -CURSOR_SPEED cursor_ticker = cursor_ticker + 1 - if cursor_ticker <= 0 then - -- show + if cursor_ticker <= 0 and not desc.selection_quad then desc.cursor_quad = matquad.quad(cw, ch, CURSOR_COLOR) desc.cursor_x = cx desc.cursor_y = cy else - -- hide desc.cursor_quad = nil if cursor_ticker > CURSOR_SPEED then cursor_ticker = -CURSOR_SPEED @@ -153,4 +322,11 @@ function keyboard.editbox(desc) end end +function keyboard.position(desc, x, y) + if not desc.layout then + return 0 + end + return desc.layout:hit_test(x - desc.ime_x, y - desc.ime_y) +end + return keyboard diff --git a/core/mouse.lua b/core/mouse.lua index 65b34d4..3855e4f 100644 --- a/core/mouse.lua +++ b/core/mouse.lua @@ -139,6 +139,11 @@ function mouse.press(btn, object) end end +function mouse.down(btn) + btn = BUTTON_ID[btn] + return mouse_state[btn] +end + function mouse.focus_region() return focus.region end diff --git a/gameplay/cardlist.lua b/gameplay/cardlist.lua index 3a985f9..6f24b60 100644 --- a/gameplay/cardlist.lua +++ b/gameplay/cardlist.lua @@ -119,18 +119,70 @@ local function edit(text, x, y, w, h, list, editbox, item) ime_y = y + list.y, } local list_n = #list + 1 - local text_label = { - x = x, - y = y, - } + local last_n = list_n + local text_label = {} + local selection = {} local cursor = {} - list[list_n] = text_label local result local focus_state = {} + local selecting + local last_down + local function point_in_editbox() + local mx = mouse.x - list.x + local my = mouse.y - list.y + return mx >= x and mx < x + w and my >= y and my < y + h + end + local function update_selection() + return keyboard.position(desc, mouse.x, mouse.y) + end + local function update_overlay() + local n = list_n + if desc.selection_quad then + selection.x = x + desc.selection_x + selection.y = y + desc.selection_y + selection.obj = desc.selection_quad + list[n] = selection + n = n + 1 + end + text_label.x = x + text_label.y = y + text_label.obj = desc.label + list[n] = text_label + n = n + 1 + if desc.cursor_quad then + cursor.x = x + desc.cursor_x + cursor.y = y + desc.cursor_y + cursor.obj = desc.cursor_quad + list[n] = cursor + n = n + 1 + end + for i = n, last_n do + list[i] = nil + end + last_n = n + end + keyboard.editbox(desc) + update_overlay() while true do mouse.get(focus_state) + local skip_click + local down = mouse.down "left" + if down and not last_down then + if point_in_editbox() then + desc.editor:begin_selection(update_selection()) + selecting = true + skip_click = true + end + elseif down and selecting then + desc.editor:update_selection(update_selection()) + elseif not down and last_down and selecting then + desc.editor:finish_selection() + selecting = nil + skip_click = true + end + last_down = down local c = mouse.click(focus_state, "left") - if c and c ~= item then + if c and c ~= item and not skip_click then result = true break end @@ -142,19 +194,13 @@ local function edit(text, x, y, w, h, list, editbox, item) if result ~= nil then break end - text_label.obj = desc.label - if desc.cursor_quad then - cursor.x = x + desc.cursor_x - cursor.y = y + desc.cursor_y - cursor.obj = desc.cursor_quad - list[list_n + 1] = cursor - else - list[list_n + 1] = nil - end + update_overlay() flow.sleep(0) end - list[list_n] = nil - list[list_n+1] = nil + for i = list_n, last_n do + list[i] = nil + end + keyboard.editbox(nil) if result then -- enter return desc.text diff --git a/soluna b/soluna index aa239fe..2215d26 160000 --- a/soluna +++ b/soluna @@ -1 +1 @@ -Subproject commit aa239febb14a8838d42aee0f9a049d7453cca106 +Subproject commit 2215d26aa78ed370adabd802b1f4318b8505fc64