diff --git a/druid/system/druid_instance.lua b/druid/system/druid_instance.lua index f38b4d5..0ad5334 100755 --- a/druid/system/druid_instance.lua +++ b/druid/system/druid_instance.lua @@ -701,7 +701,7 @@ function M:new_hotkey(keys_array, callback, callback_argument) end -local rich_text = require("druid.custom.rich_text.rich_text") +local rich_text = require("druid.extended.rich_text.rich_text") ---Create RichText component. ---@param text_node string|node The text node to make Rich Text ---@param value string|nil The initial text value. Default will be gui.get_text(text_node) @@ -711,15 +711,4 @@ function M:new_rich_text(text_node, value) end -local rich_input = require("druid.custom.rich_input.rich_input") ----Create RichInput component. ----As a template please check rich_input.gui layout. ----@param template string The template string name ----@param nodes table|nil Nodes table from gui.clone_tree ----@return druid.rich_input rich_input The new rich input component -function M:new_rich_input(template, nodes) - return self:new(rich_input, template, nodes) -end - - return M diff --git a/example/examples/basic/input/rich_input.lua b/example/examples/basic/input/rich_input.lua index 606ffbc..c9ece8f 100644 --- a/example/examples/basic/input/rich_input.lua +++ b/example/examples/basic/input/rich_input.lua @@ -1,14 +1,16 @@ +local rich_input = require("widget.rich_input.rich_input") + ---@class examples.rich_input: druid.widget ----@field rich_input druid.rich_input ----@field rich_input_2 druid.rich_input +---@field rich_input widget.rich_input +---@field rich_input_2 widget.rich_input local M = {} function M:init() - self.rich_input = self.druid:new_rich_input("rich_input") --[[@as druid.rich_input]] + self.rich_input = self.druid:new_widget(rich_input, "rich_input") self.rich_input:set_placeholder("Enter text") - self.rich_input_2 = self.druid:new_rich_input("rich_input_2") --[[@as druid.rich_input]] + self.rich_input_2 = self.druid:new_widget(rich_input, "rich_input_2") self.rich_input_2:set_placeholder("Enter text") end diff --git a/widget/rich_input/rich_input.gui b/widget/rich_input/rich_input.gui new file mode 100644 index 0000000..c179f20 --- /dev/null +++ b/widget/rich_input/rich_input.gui @@ -0,0 +1,160 @@ +fonts { + name: "druid_text_bold" + font: "/druid/fonts/druid_text_bold.font" +} +textures { + name: "druid" + texture: "/druid/druid.atlas" +} +nodes { + size { + x: 200.0 + y: 40.0 + } + type: TYPE_BOX + id: "root" + inherit_alpha: true + visible: false +} +nodes { + size { + x: 200.0 + y: 40.0 + } + color { + x: 0.31 + y: 0.318 + z: 0.322 + } + type: TYPE_BOX + texture: "druid/rect_round2_width2" + id: "button" + parent: "root" + inherit_alpha: true + slice9 { + x: 4.0 + y: 4.0 + z: 4.0 + w: 4.0 + } +} +nodes { + scale { + x: 0.5 + y: 0.5 + } + size { + x: 380.0 + y: 50.0 + } + color { + x: 0.31 + y: 0.318 + z: 0.322 + } + type: TYPE_TEXT + text: "Placeholder" + font: "druid_text_bold" + id: "placeholder_text" + outline { + x: 0.4 + y: 0.4 + z: 0.4 + } + shadow { + x: 1.0 + y: 1.0 + z: 1.0 + } + parent: "root" + inherit_alpha: true + outline_alpha: 0.0 + shadow_alpha: 0.0 +} +nodes { + scale { + x: 0.5 + y: 0.5 + } + size { + x: 380.0 + y: 50.0 + } + color { + x: 0.722 + y: 0.741 + z: 0.761 + } + type: TYPE_TEXT + text: "User input" + font: "druid_text_bold" + id: "input_text" + shadow { + x: 1.0 + y: 1.0 + z: 1.0 + } + parent: "root" + inherit_alpha: true + outline_alpha: 0.0 + shadow_alpha: 0.0 +} +nodes { + position { + x: 61.0 + } + scale { + x: 0.5 + y: 0.5 + } + size { + x: 16.0 + y: 50.0 + } + color { + x: 0.631 + y: 0.843 + z: 0.961 + } + type: TYPE_BOX + texture: "druid/ui_circle_16" + id: "cursor_node" + parent: "root" + inherit_alpha: true + slice9 { + x: 8.0 + y: 8.0 + z: 8.0 + w: 8.0 + } + alpha: 0.5 +} +nodes { + position { + x: -1.4 + y: 4.0 + } + size { + x: 20.0 + y: 40.0 + } + color { + x: 0.722 + y: 0.741 + z: 0.761 + } + type: TYPE_TEXT + text: "|" + font: "druid_text_bold" + id: "cursor_text" + shadow { + x: 1.0 + y: 1.0 + z: 1.0 + } + parent: "cursor_node" + outline_alpha: 0.0 + shadow_alpha: 0.0 +} +material: "/builtins/materials/gui.material" +adjust_reference: ADJUST_REFERENCE_PARENT diff --git a/widget/rich_input/rich_input.lua b/widget/rich_input/rich_input.lua new file mode 100644 index 0000000..0abcee5 --- /dev/null +++ b/widget/rich_input/rich_input.lua @@ -0,0 +1,296 @@ +local helper = require("druid.helper") +local const = require("druid.const") +local utf8_lua = require("druid.system.utf8") +local utf8 = utf8 or utf8_lua + +---The widget that handles a rich text input field, it's a wrapper around the druid.input component +---@class widget.rich_input: druid.widget +---@field root node The root node of the rich input +---@field input druid.input The input component +---@field cursor node The cursor node +---@field cursor_text node The cursor text node +---@field cursor_position vector3 The position of the cursor +local M = {} + +local DOUBLE_CLICK_TIME = 0.35 +local TEMP_VECTOR = vmath.vector3(0) + + +function M:init() + self.root = self:get_node("root") + + self._last_touch_info = { + cursor_index = nil, + time = 0, + } + self.is_lshift = false + self.is_lctrl = false + + self.input = self.druid:new_input("button", "input_text") + self.is_button_input_enabled = gui.is_enabled(self.input.button.node) + + self.cursor = self:get_node("cursor_node") + self.cursor_position = gui.get_position(self.cursor) + self.cursor_text = self:get_node("cursor_text") + + self.drag = self.druid:new_drag("button", function(...) return self:_on_drag_callback(...) end) + self.drag.on_touch_start:subscribe(function(...) return self:_on_touch_start_callback(...) end) + self.drag:set_input_priority(const.PRIORITY_INPUT_MAX + 1) + self.drag:set_enabled(false) + + self.input:set_text("") + self.placeholder = self.druid:new_text("placeholder_text") + self.text_position = gui.get_position(self.input.text.node) + + self.input.on_input_text:subscribe(function() return self:_update_text() end) + self.input.on_input_select:subscribe(function() return self:_on_select() end) + self.input.on_input_unselect:subscribe(function() return self:_on_unselect() end) + self.input.on_select_cursor_change:subscribe(function() return self:_update_selection() end) + + self:_on_unselect() + self:_update_text() +end + + +---@private +---@param action_id hash Action id from on_input +---@param action table Action table from on_input +---@return boolean is_consumed True if input was consumed +function M:on_input(action_id, action) + if action_id == const.ACTION_LSHIFT then + if action.pressed then + self.is_lshift = true + elseif action.released then + self.is_lshift = false + end + end + + if action_id == const.ACTION_LCTRL or action_id == const.ACTION_LCMD then + if action.pressed then + self.is_lctrl = true + elseif action.released then + self.is_lctrl = false + end + end + + if self.input.is_selected then + if action_id == const.ACTION_LEFT and (action.pressed or action.repeated) then + self.input:move_selection(-1, self.is_lshift, self.is_lctrl) + return true + end + + if action_id == const.ACTION_RIGHT and (action.pressed or action.repeated) then + self.input:move_selection(1, self.is_lshift, self.is_lctrl) + return true + end + end + + return false +end + + +---Set placeholder text +---@param placeholder_text string The placeholder text +---@return widget.rich_input self Current instance +function M:set_placeholder(placeholder_text) + self.placeholder:set_text(placeholder_text) + return self +end + + +---Select input field +---@return widget.rich_input self Current instance +function M:select() + self.input:select() + return self +end + + +---Set input field text +---@param text string The input text +---@return widget.rich_input self Current instance +function M:set_text(text) + self.input:set_text(text) + gui.set_enabled(self.placeholder.node, true and #self.input:get_text() == 0) + + return self +end + + +---Set input field font +---@param font hash The font hash +---@return widget.rich_input self Current instance +function M:set_font(font) + gui.set_font(self.input.text.node, font) + gui.set_font(self.placeholder.node, font) + + return self +end + + +---Set input field text +function M:get_text() + return self.input:get_text() +end + + +---Set allowed charaters for input field. +-- See: https://defold.com/ref/stable/string/ +-- ex: [%a%d] for alpha and numeric +---@param characters string Regular expression for validate user input +---@return widget.rich_input self Current instance +function M:set_allowed_characters(characters) + self.input:set_allowed_characters(characters) + + return self +end + + +function M:_animate_cursor() + gui.cancel_animation(self.cursor_text, "color.w") + gui.set_alpha(self.cursor_text, 1) + gui.animate(self.cursor_text, "color.w", 0, gui.EASING_INSINE, 0.8, 0, nil, gui.PLAYBACK_LOOP_PINGPONG) +end + + +function M:_set_selection_width(selection_width) + gui.set_visible(self.cursor, selection_width > 0) + + local width = selection_width / self.input.text.scale.x + local height = gui.get_size(self.cursor).y + gui.set_size(self.cursor, vmath.vector3(width, height, 0)) + + local is_selection_to_right = self.input.cursor_index == self.input.end_index + gui.set_pivot(self.cursor, is_selection_to_right and gui.PIVOT_E or gui.PIVOT_W) +end + + +function M:_update_text() + local full_text = self.input:get_text() + local visible_text = self.input.text:get_text() + + local is_truncated = visible_text ~= full_text + local cursor_index = self.input.cursor_index + if is_truncated then + -- If text is truncated, we need to adjust the cursor index + -- to the last visible character + cursor_index = utf8.len(visible_text) + + end + + local left_text_part = utf8.sub(self.input:get_text(), 0, cursor_index) + local selected_text_part = utf8.sub(self.input:get_text(), self.input.start_index + 1, self.input.end_index) + + local left_part_width = self.input.text:get_text_size(left_text_part) + local selected_part_width = self.input.text:get_text_size(selected_text_part) + + local pivot_text = gui.get_pivot(self.input.text.node) + local pivot_offset = helper.get_pivot_offset(pivot_text) + + self.cursor_position.x = self.text_position.x - self.input.text_width * (0.5 + pivot_offset.x) + left_part_width + + gui.set_position(self.cursor, self.cursor_position) + gui.set_scale(self.cursor, self.input.text.scale) + + self:_set_selection_width(selected_part_width) +end + + +function M:_on_select() + gui.set_enabled(self.cursor, true) + gui.set_enabled(self.placeholder.node, false) + gui.set_enabled(self.input.button.node, true) + + self:_animate_cursor() + self.drag:set_enabled(true) +end + + +function M:_on_unselect() + gui.cancel_animation(self.cursor, gui.PROP_COLOR) + gui.set_enabled(self.cursor, false) + gui.set_enabled(self.input.button.node, self.is_button_input_enabled) + gui.set_enabled(self.placeholder.node, true and #self.input:get_text() == 0) + + self.drag:set_enabled(false) +end + + +---Update selection +function M:_update_selection() + self:_update_text() +end + + +function M:_get_index_by_touch(touch) + local text_node = self.input.text.node + TEMP_VECTOR.x = touch.screen_x + TEMP_VECTOR.y = touch.screen_y + + -- Distance to the text node position + local scene_scale = helper.get_scene_scale(text_node) + local local_pos = gui.screen_to_local(text_node, TEMP_VECTOR) + local_pos.x = local_pos.x / scene_scale.x + + -- Offset to the left side of the text node + local pivot_offset = helper.get_pivot_offset(gui.get_pivot(text_node)) + local_pos.x = local_pos.x + self.input.total_width * (0.5 + pivot_offset.x) + local_pos.x = local_pos.x - self.text_position.x + + local cursor_index = self.input.text:get_text_index_by_width(local_pos.x) + return cursor_index +end + + +function M:_on_touch_start_callback(touch) + local cursor_index = self:_get_index_by_touch(touch) + + if self._last_touch_info.cursor_index == cursor_index then + local time = socket.gettime() + if time - self._last_touch_info.time < DOUBLE_CLICK_TIME then + local len = utf8.len(self.input:get_text()) + self.input:select_cursor(len, 0, len) + self._last_touch_info.cursor_index = nil + + return + end + end + + self._last_touch_info.cursor_index = cursor_index + self._last_touch_info.time = socket.gettime() + + if self.input.is_lshift then + local start_index = self.input.start_index + local end_index = self.input.end_index + + if cursor_index < start_index then + self.input:select_cursor(cursor_index, cursor_index, end_index) + elseif cursor_index > end_index then + self.input:select_cursor(cursor_index, start_index, cursor_index) + end + else + self.input:select_cursor(cursor_index) + end +end + + +---@param dx number The delta x position +---@param dy number The delta y position +---@param x number The x position +---@param y number The y position +---@param touch table The touch table +function M:_on_drag_callback(dx, dy, x, y, touch) + if not self._last_touch_info.cursor_index then + return + end + + local index = self:_get_index_by_touch(touch) + if self._last_touch_info.cursor_index <= index then + self.input:select_cursor(index, self._last_touch_info.cursor_index, index) + else + self.input:select_cursor(index, index, self._last_touch_info.cursor_index) + end +end + + +return M