-- lx-bundle:true

--- Provide a reusable Lua-owned single-line Textbox Control
-- @module qui.textbox

local core = require("core")
local qui = require("qui")
local colors = require("qui.colors")
local metrics = require("qui.metrics")
local theme = require("qui.theme")
local Font = require("qui.font")
local utf8help = require("core.text.utf8help")
local utf8 = require("utf8")

local Textbox = {}
local color_flags = setmetatable({}, { __mode = "k" })	-- One reusable flag set follows each live Textbox table
local focused_textboxes = setmetatable({}, { __mode = "k" })	-- Weak keys do not keep destroyed Controls alive
local blink_coroutine_running = false
local blink_visible = true

-- Static appearance fields are rejected because they cannot respond to later Theme changes
local static_color_fields = {
	"background",
	"border_color",
	"focus_border_color",
	"text_color",
	"placeholder_color",
	"selection_color",
	"caret_color",
}

--- Return a checked non-negative whole number
-- @param value number|nil requested value
-- @param fallback number default value
-- @param name string readable option name
-- @return integer value
local function non_negative_integer(value, fallback, name)
	value = value == nil and fallback or value
	assert(type(value) == "number" and value >= 0 and value < math.huge, name .. " must be a non-negative number")

	return math.floor(value + 0.5)
end

--- Return a valid UTF-8 character count
-- @param text string text to inspect
-- @return integer length
local function text_length(text)
	local length = utf8.len(text)
	assert(length, "Textbox text must be valid UTF-8")

	return length
end

--- Return inclusive character positions using one-based Lua string slicing
-- @param text string source text
-- @param first integer first one-based character
-- @param last integer last one-based character
-- @return string slice
local function character_slice(text, first, last)
	if first > last then
		return ""
	end

	return utf8.sub(text, first, last)
end

--- Return text before a zero-based caret position
-- @param text string source text
-- @param caret integer zero-based caret
-- @return string prefix
local function text_before(text, caret)
	return character_slice(text, 1, caret)
end

--- Return the character at a zero-based position
-- @param text string source text
-- @param position integer zero-based character position
-- @return string character
local function character_at(text, position)
	return character_slice(text, position + 1, position + 1)
end

--- Classify one character for Ctrl+Left, Ctrl+Right, and double-click selection
-- @param character string one UTF-8 character
-- @return string class
local function character_class(character)
	-- Pattern breakdown: '%s' matches ASCII whitespace and '[%w_]' matches ASCII word characters plus underscore
	if character:match("^%s$") then
		return "space"
	elseif character:match("^[%w_]$") then
		return "word"
	end

	return "punctuation"
end

--- Find the previous word boundary from a zero-based caret
-- @param text string source text
-- @param caret integer current caret
-- @return integer boundary
local function previous_word_boundary(text, caret)
	local position = caret
	while position > 0 and character_class(character_at(text, position - 1)) == "space" do
		position = position - 1
	end
	if position == 0 then
		return 0
	end

	local class = character_class(character_at(text, position - 1))
	while position > 0 and character_class(character_at(text, position - 1)) == class do
		position = position - 1
	end

	return position
end

--- Find the next word boundary from a zero-based caret
-- @param text string source text
-- @param caret integer current caret
-- @return integer boundary
local function next_word_boundary(text, caret)
	local length = text_length(text)
	local position = caret
	while position < length and character_class(character_at(text, position)) == "space" do
		position = position + 1
	end
	if position == length then
		return length
	end

	local class = character_class(character_at(text, position))
	while position < length and character_class(character_at(text, position)) == class do
		position = position + 1
	end

	return position
end

--- Return ordered selection edges
-- @param self table Textbox control
-- @return integer first
-- @return integer last
local function selection_edges(self)
	return math.min(self.anchor, self.caret), math.max(self.anchor, self.caret)
end

--- Report whether the Textbox currently has selected text
-- @param self table Textbox control
-- @return boolean selected
local function has_selection(self)
	return self.anchor ~= self.caret
end

--- Save one complete state before a user-visible text change
-- @param self table Textbox control
local function save_undo(self)
	self.undo_stack[#self.undo_stack + 1] = { text = self.text, caret = self.caret, anchor = self.anchor }
	if #self.undo_stack > self.undo_limit then
		table.remove(self.undo_stack, 1)
	end
	self.redo_stack = {}
end

--- Notify application code after text changes
-- @param self table Textbox control
local function notify_change(self)
	if self.on_change then
		self:on_change(self.text)
	end
end

--- Replace one zero-based character range and move the caret after inserted text
-- @param self table Textbox control
-- @param first integer first character
-- @param last integer first character after the range
-- @param replacement string replacement text
-- @return boolean changed
local function replace_range(self, first, last, replacement, allow_read_only)
	if self.read_only and not allow_read_only then
		return false
	end
	text_length(replacement)

	local before = character_slice(self.text, 1, first)
	local after = character_slice(self.text, last + 1, text_length(self.text))
	local available = self.max_length and math.max(0, self.max_length - text_length(before) - text_length(after)) or nil
	if available then
		replacement = character_slice(replacement, 1, available)
	end
	local changed_text = before .. replacement .. after
	if changed_text == self.text then
		return false
	end

	save_undo(self)
	self.text = changed_text
	self.caret = first + text_length(replacement)
	self.anchor = self.caret
	self.composition = ""
	notify_change(self)

	return true
end

--- Restore one state between undo and redo stacks
-- @param self table Textbox control
-- @param source table source stack
-- @param destination table destination stack
-- @return boolean changed
local function restore_history(self, source, destination)
	local restored = table.remove(source)
	if not restored then
		return false
	end

	destination[#destination + 1] = { text = self.text, caret = self.caret, anchor = self.anchor }
	self.text = restored.text
	self.caret = restored.caret
	self.anchor = restored.anchor
	self.composition = ""
	notify_change(self)

	return true
end

--- Move the caret while applying ordinary selection rules
-- @param self table Textbox control
-- @param position integer requested zero-based position
-- @param extend boolean preserve the selection anchor
local function move_caret(self, position, extend)
	self.caret = math.max(0, math.min(text_length(self.text), position))
	if not extend then
		self.anchor = self.caret
	end
	self.composition = ""
end

--- Return displayed text, concealing passwords without changing stored content
-- @param self table Textbox control
-- @param text string stored or composition text
-- @return string displayed
local function display_text(self, text)
	if not self.password then
		return text
	end

	return string.rep(self.password_character, text_length(text))
end

--- Convert a zero-based character position to the font API's one-based byte column
-- @param text string source text
-- @param position integer zero-based character position
-- @return integer byte_column
local function byte_column(text, position)
	if position >= text_length(text) then
		return #text + 1
	end

	return utf8.offset(text, position + 1)
end

--- Convert one valid one-based UTF-8 byte column to a zero-based character position
-- @param text string source text
-- @param column integer one-based byte column
-- @return integer position
local function character_position(text, column)
	return utf8.len(text, 1, math.max(0, column - 1)) or 0
end

--- Find the complete visual cluster boundary before a caret position
-- This deliberately shares the editor's emoji, combining-mark, flag, and joined-sequence rules
-- @param text string source text
-- @param position integer zero-based caret position
-- @return integer previous_position
local function previous_visual_boundary(text, position)
	if position <= 0 then
		return 0
	end

	local column = byte_column(text, position)
	if column <= #text then
		local current_start = utf8help.visual_cluster_start_offset(text, column)
		local current_position = current_start and character_position(text, current_start) or position
		if current_position < position then
			return current_position
		end
	end

	local previous_column = utf8.offset(text, -1, column)
	local previous_start = previous_column and utf8help.visual_cluster_start_offset(text, previous_column)

	return previous_start and character_position(text, previous_start) or math.max(0, position - 1)
end

--- Find the complete visual cluster boundary after a caret position
-- @param text string source text
-- @param position integer zero-based caret position
-- @return integer next_position
local function next_visual_boundary(text, position)
	local length = text_length(text)
	if position >= length then
		return length
	end

	local column = byte_column(text, position)
	local cluster_start = utf8help.visual_cluster_start_offset(text, column) or column
	local next_column = utf8help.next_visual_cluster_offset(text, cluster_start)

	return next_column and character_position(text, next_column) or math.min(length, position + 1)
end

--- Snap a proposed caret to the nearer edge of its complete visual cluster
-- Password fields draw one mask per codepoint, so pointer hits there still need this stored-text check
-- @param text string source text
-- @param position integer proposed zero-based caret position
-- @return integer boundary_position
local function nearest_visual_boundary(text, position)
	local length = text_length(text)
	if position <= 0 or position >= length then
		return math.max(0, math.min(length, position))
	end

	local column = byte_column(text, position)
	local start_column = utf8help.visual_cluster_start_offset(text, column)
	if not start_column then
		return position
	end

	local first = character_position(text, start_column)
	if first == position then
		return position
	end

	local end_column = utf8help.next_visual_cluster_offset(text, start_column)
	local last = end_column and character_position(text, end_column) or length

	return position - first <= last - position and first or last
end

--- Return the complete word, whitespace, or punctuation run surrounding one caret position
-- @param text string source text
-- @param position integer zero-based caret position
-- @return integer first
-- @return integer last
local function word_edges_at(text, position)
	local length = text_length(text)
	if length == 0 then
		return 0, 0
	end

	local probe = math.min(position, length - 1)
	local class = character_class(character_at(text, probe))
	local first = probe
	local last = probe + 1
	while first > 0 and character_class(character_at(text, first - 1)) == class do
		first = first - 1
	end
	while last < length and character_class(character_at(text, last)) == class do
		last = last + 1
	end

	return previous_visual_boundary(text, next_visual_boundary(text, first)), next_visual_boundary(text, last - 1)
end

--- Measure a displayed prefix through the already-owned font
-- @param self table Textbox control
-- @param position integer zero-based character position
-- @return number width
local function prefix_width(self, control_canvas, position)
	local shown = display_text(self, self.text)
	return control_canvas:text_x(self.font, shown, byte_column(shown, position))
end

--- Keep the caret inside the available horizontal text area
-- @param self table Textbox control
-- @param inner_width integer visible text width
local function reveal_caret(self, control_canvas, inner_width)
	local caret_x = prefix_width(self, control_canvas, self.caret)
	if caret_x < self.scroll_x then
		self.scroll_x = caret_x
	elseif caret_x > self.scroll_x + inner_width then
		self.scroll_x = caret_x - inner_width
	end
	self.scroll_x = math.max(0, self.scroll_x)
end

--- Resolve a pointer x coordinate to the nearest zero-based caret
-- @param self table Textbox control
-- @param x number local pointer x
-- @return integer caret
local function caret_from_x(self, control_canvas, x)
	local shown = display_text(self, self.text)
	local target_x = math.max(0, self.scroll_x + x - self.padding_x)
	local column = control_canvas:text_column(self.font, shown, math.floor(target_x + 0.5))
	local position = character_position(shown, column)
	if self.password then
		position = nearest_visual_boundary(self.text, position)
	else
		local cluster_start = column <= #shown and utf8help.visual_cluster_start_offset(shown, column) or nil
		if cluster_start then
			position = character_position(shown, cluster_start)
		end
	end

	return math.max(0, math.min(text_length(self.text), position))
end

--- Start the one shared caret timer used by all focused Textbox Controls
local function ensure_blink_coroutine()
	if blink_coroutine_running then
		return
	end

	blink_coroutine_running = true
	core.add_coroutine(function()
		while next(focused_textboxes) do
			coroutine.yield(0.5)
			blink_visible = not blink_visible
			for textbox in pairs(focused_textboxes) do
				textbox:invalidate()
			end
		end
		blink_visible = true
		blink_coroutine_running = false
	end, focused_textboxes)
end

--- Select one central color identifier for every Textbox instance
-- The Theme system owns the automatic 'textbox' to 'input' fallback relationship
-- @param self table Textbox control
-- @param type string requested visual type
-- @param flags table independent current Textbox conditions
-- @return string|nil color_identifier
function Textbox.default_color(self, type, flags)
	if type == "background" then
		return colors.textbox_bg
	elseif type == "frame" then
		if flags.focused then
			return colors.textbox_focused_frame
		elseif flags.hovering then
			return colors.textbox_hover_frame
		end

		return colors.textbox_frame
	elseif type == "text" then
		return flags.grayed and colors.textbox_disabled_text or colors.textbox_text
	elseif type == "placeholder" then
		return colors.textbox_placeholder_text
	elseif type == "selection" then
		return colors.textbox_selection_bg
	elseif type == "caret" then
		return colors.textbox_caret
	end

	return nil
end

--- Create one reusable single-line Textbox for use inside 'qui.pane.controls'
-- @param options table text, behavior, appearance, placement, and callbacks
-- @return table definition unrestricted Lua Control declaration
function Textbox.new(options)
	assert(type(options) == "table", "Textbox expects a table")
	assert(options.text == nil or type(options.text) == "string", "Textbox text must be a string")
	assert(options.placeholder == nil or type(options.placeholder) == "string", "Textbox placeholder must be a string")
	assert(options.on_change == nil or type(options.on_change) == "function", "Textbox on_change must be a function")
	assert(options.on_submit == nil or type(options.on_submit) == "function", "Textbox on_submit must be a function")
	assert(options.on_color == nil or type(options.on_color) == "function", "Textbox on_color must be a function")
	assert(options.default_color == nil, "Textbox default_color is fixed; use on_color for dynamic changes")

	for _, field in ipairs(static_color_fields) do
		assert(rawget(options, field) == nil, "Textbox " .. field .. " is fixed; use on_color for dynamic changes")
	end

	local control = options
	control.text = control.text or ""
	local length = text_length(control.text)
	control.placeholder = control.placeholder or ""
	control.font = control.font or Font.default
	assert(control.font, "Textbox requires a font")
	control.padding_x = non_negative_integer(
		control.padding_x, theme.resolve_metric(metrics.textbox_padding_x), "Textbox padding_x")
	control.padding_y = non_negative_integer(
		control.padding_y, theme.resolve_metric(metrics.textbox_padding_y), "Textbox padding_y")
	control.width = non_negative_integer(control.width, 180, "Textbox width")
	control.height = control.height and
		non_negative_integer(control.height, 0, "Textbox height") or nil
	if control.max_length ~= nil then
		control.max_length = non_negative_integer(control.max_length, 0, "Textbox max_length")
	end
	assert(not control.max_length or length <= control.max_length, "Textbox text exceeds max_length")
	control.undo_limit = non_negative_integer(control.undo_limit, 100, "Textbox undo_limit")
	control.password = control.password == true
	control.password_character = control.password_character or "•"
	assert(text_length(control.password_character) == 1, "Textbox password_character must be one UTF-8 character")
	control.read_only = control.read_only == true
	control.enabled = control.enabled ~= false
	control.focusable = control.focusable ~= false
	control.accepts_pointer = control.accepts_pointer ~= false
	control.cursor = control.cursor or "ibeam"
	if control.read_only then
		control.text_input = nil
	else
		control.text_input = control.text_input or {
			type = control.password and "password_hidden" or "text",
			capitalization = "automatic",
			autocorrect = false,
			multiline = false,
		}
	end
	control.default_color = Textbox.default_color
	control.caret = non_negative_integer(control.caret, length, "Textbox caret")
	control.anchor = non_negative_integer(control.anchor, control.caret, "Textbox anchor")
	assert(control.caret <= length and control.anchor <= length, "Textbox caret and anchor must stay inside text")
	control.scroll_x = 0
	control.focused = false
	control.dragging = false	-- False, 'character', or 'word' keeps one selection rule for the whole gesture
	control.drag_anchor = nil	-- Fixed character-selection origin captured on pointer down
	control.drag_word_first = nil	-- First edge of the word where word dragging began
	control.drag_word_last = nil	-- Last edge of the word where word dragging began
	control.suppress_double_click = false	-- A click used only to clear selection cannot begin a word gesture
	control.composition = ""
	control.composition_start = 0
	control.composition_length = 0
	control.undo_stack = {}
	control.redo_stack = {}
	control.arranged_width = control.width
	control.arranged_height = control.height or 0
	control.hovered = false
	color_flags[control] = {
		grayed = not control.enabled,
		hovering = false,
		focused = false,
	}

	--- Measure the requested field size without creating drawing resources
	function control:measure(control_canvas, minimum_width, minimum_height, maximum_width, maximum_height)
		local _, text_height = control_canvas:measure_text(self.font, self.text)
		local natural_height = self.height or text_height + self.padding_y * 2

		return math.max(minimum_width, math.min(self.width, maximum_width)),
			math.max(minimum_height, math.min(natural_height, maximum_height))
	end

	--- Draw text, selection, composition, and caret from Lua-owned state
	function control:draw(control_canvas)
		local width, height = control_canvas:size()
		self.arranged_width = width
		self.arranged_height = height
		local flags = assert(color_flags[self], "Textbox color flags are unavailable")
		flags.grayed = not self.enabled
		flags.hovering = self.hovered
		flags.focused = self.focused
		local inner_width = math.max(1, width - self.padding_x * 2)
		reveal_caret(self, control_canvas, inner_width)
		local shown_text = display_text(self, self.text)
		local first_column = control_canvas:text_column(self.font, shown_text, math.floor(self.scroll_x))
		local last_column = control_canvas:text_column(self.font, shown_text, math.ceil(self.scroll_x + inner_width))
		local first_visible = utf8.len(shown_text, 1, math.max(0, first_column - 1)) or 0
		local last_visible = utf8.len(shown_text, 1, math.max(0, last_column - 1)) or text_length(shown_text)
		last_visible = math.min(text_length(shown_text), last_visible + 1)
		local _, text_height = control_canvas:measure_text(self.font, shown_text)
		local text_y = math.floor((height - text_height) / 2)

		control_canvas:fill_rect(0, 0, width, height, self:get_color("background", flags))
		if has_selection(self) then
			local first, last = selection_edges(self)
			local selection_x = self.padding_x + prefix_width(self, control_canvas, first) - self.scroll_x
			local selection_right = self.padding_x + prefix_width(self, control_canvas, last) - self.scroll_x
			local clipped_x = math.max(self.padding_x, selection_x)
			local clipped_right = math.min(width - self.padding_x, selection_right)
			if clipped_right > clipped_x then
				control_canvas:fill_rect(clipped_x, self.padding_y, clipped_right - clipped_x,
					math.max(0, height - self.padding_y * 2), self:get_color("selection", flags))
			end
		end

		local shown = character_slice(shown_text, first_visible + 1, last_visible)
		if shown ~= "" then
			local shown_x = self.padding_x + prefix_width(self, control_canvas, first_visible) - self.scroll_x
			control_canvas:draw_text(self.font, shown, shown_x, text_y, self:get_color("text", flags))
		elseif self.placeholder ~= "" and (not self.focused or self.composition == "") then
			-- Temporary input replaces the empty-field hint instead of being drawn across it
			control_canvas:draw_text(self.font, self.placeholder, self.padding_x, text_y, self:get_color("placeholder", flags))
		end

		local caret_x = self.padding_x + prefix_width(self, control_canvas, self.caret) - self.scroll_x
		if self.focused and self.composition ~= "" then
			local composition = display_text(self, self.composition)
			-- Keep unfinished text visually identical to text after it is committed
			control_canvas:draw_text(self.font, composition, caret_x, text_y, self:get_color("text", flags))
		elseif self.focused and blink_visible and width > 0 and height > 0 then
			control_canvas:fill_rect(math.min(width - 1, caret_x), self.padding_y, 1,
				math.max(0, height - self.padding_y * 2), self:get_color("caret", flags))
		end

		control_canvas:draw_rect(0, 0, width, height, 1, self:get_color("frame", flags))
		if self.focused and width > 0 and height > 0 then
			local area_x = math.min(self.padding_x, width - 1)
			local area_width = width - area_x
			self:set_text_input_area(area_x, 0, area_width, height,
				math.max(0, math.min(area_width, caret_x - area_x)))
		end
	end

	--- Handle selection, editing, navigation, clipboard, history, focus, and composition
	function control:event(event, control_canvas)
		local command_modifier = event.control or event.system
		if event.kind == "pointer_enter" then
			self.hovered = true
			return { redraw = true }
		elseif event.kind == "pointer_leave" then
			self.hovered = false
			return { redraw = true }
		elseif event.kind == "focus_gained" then
			self.focused = true
			focused_textboxes[self] = true
			blink_visible = true
			ensure_blink_coroutine()
			return { redraw = true }
		elseif event.kind == "window_deactivated" then
			self.composition = ""
			self.suppress_double_click = false
			return { redraw = true }
		elseif event.kind == "focus_lost" then
			self.focused = false
			self.dragging = false
			self.drag_anchor = nil
			self.drag_word_first = nil
			self.drag_word_last = nil
			self.suppress_double_click = false
			self.composition = ""
			focused_textboxes[self] = nil
			return { redraw = true, capture = "release" }
		elseif event.kind == "pointer_down" and event.button == "left" then
			local position = caret_from_x(self, control_canvas, event.x)
			local had_selection = has_selection(self)
			local select_word = event.clicks == 2 and not self.suppress_double_click and text_length(self.text) > 0
			-- Clearing an existing selection consumes this click sequence before SDL can report its next click as a double-click
			self.suppress_double_click = event.clicks == 1 and had_selection and not event.shift
			if select_word then
				local first, last = word_edges_at(self.text, position)
				self.anchor, self.caret = first, last
				self.dragging = "word"
				self.drag_word_first = first
				self.drag_word_last = last
			else
				self.caret = position
				self.anchor = event.shift and self.anchor or position
				self.dragging = "character"
				self.drag_anchor = self.anchor
			end
			self.composition = ""
			blink_visible = true
			return { handled = true, redraw = true, focus = "acquire", capture = "acquire" }
		elseif event.kind == "pointer_move" and self.dragging then
			local position = caret_from_x(self, control_canvas, event.x)
			if self.dragging == "word" then
				local first, last = word_edges_at(self.text, position)
				if position < self.drag_word_first then
					self.anchor, self.caret = self.drag_word_last, first
				else
					self.anchor, self.caret = self.drag_word_first, last
				end
			else
				self.anchor = self.drag_anchor
				self.caret = position
			end
			blink_visible = true
			return { handled = true, redraw = true }
		elseif event.kind == "pointer_up" and event.button == "left" and self.dragging then
			self.dragging = false
			self.drag_anchor = nil
			self.drag_word_first = nil
			self.drag_word_last = nil
			return { handled = true, redraw = true, capture = "release" }
		elseif event.kind == "text_editing" then
			self.suppress_double_click = false
			if self.read_only then
				return nil
			end
			self.composition = event.text
			self.composition_start = event.text_start
			self.composition_length = event.text_length
			return { handled = true, redraw = true }
		elseif event.kind == "text_input" then
			self.suppress_double_click = false
			local first, last = selection_edges(self)
			return { handled = true, redraw = replace_range(self, first, last, event.text) }
		elseif event.kind ~= "key_down" then
			return nil
		end

		self.suppress_double_click = false
		local first, last = selection_edges(self)
		if command_modifier and event.key == "a" then
			self.anchor, self.caret = 0, text_length(self.text)
			return { handled = true, redraw = true }
		elseif command_modifier and event.key == "c" then
			if has_selection(self) then
				local copied = character_slice(self.text, first + 1, last)
				local ok, clipboard_error = system.set_clipboard(copied)
				if not ok then core.error("Copy failed: %s", tostring(clipboard_error)) end
			end
			return { handled = true }
		elseif command_modifier and event.key == "x" then
			if has_selection(self) and not self.read_only then
				local copied = character_slice(self.text, first + 1, last)
				local ok, clipboard_error = system.set_clipboard(copied)
				if ok then replace_range(self, first, last, "") else core.error("Cut failed: %s", tostring(clipboard_error)) end
			end
			return { handled = true, redraw = true }
		elseif command_modifier and event.key == "v" then
			if not self.read_only then
				local pasted, clipboard_error = system.get_clipboard()
				if pasted then
					-- Pattern breakdown: one or more CR or LF bytes become one space in this single-line Control
					pasted = pasted:gsub("[\r\n]+", " ")
					replace_range(self, first, last, pasted)
				else
					core.error("Paste failed: %s", tostring(clipboard_error))
				end
			end
			return { handled = true, redraw = true }
		elseif command_modifier and event.key == "z" then
			local changed = event.shift and restore_history(self, self.redo_stack, self.undo_stack) or
				restore_history(self, self.undo_stack, self.redo_stack)
			return { handled = true, redraw = changed }
		elseif command_modifier and event.key == "y" then
			return { handled = true, redraw = restore_history(self, self.redo_stack, self.undo_stack) }
		elseif event.key == "left" then
			local position = command_modifier and previous_word_boundary(self.text, self.caret) or
				previous_visual_boundary(self.text, self.caret)
			if command_modifier then position = previous_visual_boundary(self.text, next_visual_boundary(self.text, position)) end
			if has_selection(self) and not event.shift and not command_modifier then position = first end
			move_caret(self, position, event.shift)
		elseif event.key == "right" then
			local position = command_modifier and next_word_boundary(self.text, self.caret) or
				next_visual_boundary(self.text, self.caret)
			if command_modifier then position = next_visual_boundary(self.text, previous_visual_boundary(self.text, position)) end
			if has_selection(self) and not event.shift and not command_modifier then position = last end
			move_caret(self, position, event.shift)
		elseif event.key == "home" then
			move_caret(self, 0, event.shift)
		elseif event.key == "end" then
			move_caret(self, text_length(self.text), event.shift)
		elseif event.key == "backspace" then
			if not self.read_only then
				if not has_selection(self) then
					first = command_modifier and previous_word_boundary(self.text, self.caret) or
						previous_visual_boundary(self.text, self.caret)
					if command_modifier then first = previous_visual_boundary(self.text, next_visual_boundary(self.text, first)) end
				end
				replace_range(self, first, last, "")
			end
		elseif event.key == "delete" then
			if not self.read_only then
				if not has_selection(self) then
					last = command_modifier and next_word_boundary(self.text, self.caret) or
						next_visual_boundary(self.text, self.caret)
					if command_modifier then last = next_visual_boundary(self.text, previous_visual_boundary(self.text, last)) end
				end
				replace_range(self, first, last, "")
			end
		elseif event.key == "return" then
			local handled = self.on_submit and self:on_submit(self.text) == true
			return handled and { handled = true } or nil
		else
			return nil
		end

		blink_visible = true
		return { handled = true, redraw = true }
	end

	--- Replace all text through the same length and notification rules as user input
	-- @param text string new text
	-- @return boolean changed
	function control:set_text(text)
		assert(type(text) == "string", "Textbox text must be a string")
		return replace_range(self, 0, text_length(self.text), text, true)
	end

	--- Select all current text
	function control:select_all()
		self.anchor, self.caret = 0, text_length(self.text)
		blink_visible = true

		return self:invalidate()
	end

	return qui.control(control)
end

-- Callable module syntax keeps declarations readable as 'Textbox { ... }'
setmetatable(Textbox, {
	__call = function(_, options)
		return Textbox.new(options)
	end,
})

return Textbox
