-- lx-bundle:true

--- Shared text surface for text editor and viewer panes
-- @module core.text.surface

local core = require("core")
local common = require("core.common")
local config = require("core.config")
local style = require("core.style")
local keymap = require("core.keymap")
local horizontal_scrollbar = require("core.text.horizontal_scrollbar")
local utf8help = require("core.text.utf8help")
local wrap = require("core.text.wrap")
-- Text cursor translation helpers are scoped under core.text
local translate = require("core.text.translate")
local DocView = require("core.doc.view")

local TextSurface = DocView:extend()

TextSurface.LaneIDs = DocView.LaneIDs
TextSurface.LaneOrders = DocView.LaneOrders

config.debug_lanes = config.debug_lanes == true

local multi_cursor_modifiers = {
	alt = true,
	ctrl = true,
	cmd = true,
}

--- Return whether multi-cursor editing is enabled
-- @return boolean enabled
local function is_multi_cursor_enabled()
	return config.enable_multi_cursor ~= false
end

--- Return the configured multi-cursor modifier or the default
-- @return string modifier canonical modifier name
local function get_multi_cursor_modifier()
	local modifier = config.multi_cursor_modifier

	if not multi_cursor_modifiers[modifier] then
		return "alt"
	end

	return modifier
end

--- Return whether exactly the configured modifier and requested Shift state are held
-- @param with_shift boolean true when Shift is required
-- @return boolean matches true when the modifier state matches
local function has_multi_cursor_modifiers(with_shift)
	if not is_multi_cursor_enabled() then
		return false
	end

	if keymap.modkeys["altgr"] or (keymap.modkeys["shift"] == true) ~= with_shift then
		return false
	end

	local modifier = get_multi_cursor_modifier()

	if not keymap.modkeys[modifier] then
		return false
	end

	for name in pairs(multi_cursor_modifiers) do
		if name ~= modifier and keymap.modkeys[name] then
			return false
		end
	end

	return true
end

--- Return whether any keyboard modifier is held
-- @return boolean modified true when a tracked modifier is active
local function has_any_modifier()
	return keymap.modkeys["ctrl"] == true
		or keymap.modkeys["cmd"] == true
		or keymap.modkeys["alt"] == true
		or keymap.modkeys["altgr"] == true
		or keymap.modkeys["shift"] == true
end

--- Return whether the current left-click matches the configured multi-cursor modifier
-- Invalid values fall back to 'alt'
-- @param button string mouse button id
-- @return boolean matches true when exactly the configured modifier is held
local function is_multi_cursor_click(button)
	return button == "left" and has_multi_cursor_modifiers(false)
end

--- Return whether middle-click should toggle a word selection
-- @param button string mouse button id
-- @return boolean matches true when exactly the configured modifier is held
local function is_multi_cursor_word_click(button)
	return button == "middle" and has_multi_cursor_modifiers(false)
end

--- Return whether the current left-click starts column selection
-- @param button string mouse button id
-- @return boolean matches true when Shift and the configured modifier are held
local function is_box_selection_click(button)
	return button == "left" and has_multi_cursor_modifiers(true)
end

--- Return whether the current left-click forces virtual column selection
-- @param button string mouse button id
-- @return boolean matches true when Ctrl, Alt, and Shift alone are held
local function is_forced_virtual_box_selection_click(button)
	return is_multi_cursor_enabled()
		and button == "left"
		and keymap.modkeys["ctrl"] == true
		and keymap.modkeys["alt"] == true
		and keymap.modkeys["shift"] == true
		and not keymap.modkeys["altgr"]
		and not keymap.modkeys["cmd"]
end

--- Return preferred-x state for one primary or secondary selection
-- @param view table text surface
-- @param selection_id number|nil stable selection identifier
-- @return table state
local function get_caret_x_offset_state(view, selection_id)
	local primary = view.doc and view.doc.selection

	if selection_id == nil or primary and primary.id == selection_id then
		return view.last_x_offset
	end

	local state = view._selection_x_offsets[selection_id]

	if not state then
		state = { visual_row = view._selection_visual_rows[selection_id] }
		view._selection_x_offsets[selection_id] = state
	end

	return state
end

--- Move cursor vertically while preserving preferred x offset
-- @param dv table doc view instance
-- @param line number source line
-- @param col number source column
-- @param target_line number destination line
-- @param selection_id number|nil stable selection identifier
-- @return number line sanitized target line candidate
-- @return number col mapped target column candidate
local function move_to_target_line_offset(dv, line, col, target_line, selection_id)
	local xo = get_caret_x_offset_state(dv, selection_id)
	local keep_preferred_column = config.persistent_cursor_col == true
	-- Keep the preferred x-offset across clamped short lines until cursor x changes
	local continue_vertical_chain =
		keep_preferred_column
		and xo.line == line
		and xo.col == col
		and type(xo.offset) == "number"
	if not continue_vertical_chain then
		xo.offset = dv:get_col_x_offset(line, col)
	end
	xo.line = target_line
	xo.col = dv:get_x_offset_col(
		target_line, xo.offset, dv.doc:is_virtual_space_enabled("enabled")
	)
	return xo.line, xo.col
end

--- Move cursor by a signed line offset while preserving preferred x offset
-- @param dv table doc view instance
-- @param line number source line
-- @param col number source column
-- @param offset number signed line delta
-- @param selection_id number|nil stable selection identifier
-- @return number line target line candidate
-- @return number col mapped target column candidate
local function move_to_line_offset(dv, line, col, offset, selection_id)
	local target_line = line + offset
	return move_to_target_line_offset(dv, line, col, target_line, selection_id)
end

--- Return the preferred x offset for a wrapped vertical movement chain
-- @param dv table doc view instance
-- @param line number source line
-- @param col number source column
-- @param selection_id number|nil stable selection identifier
-- @return number offset wrapped-row-local x offset
local function get_wrapped_vertical_x_offset(dv, line, col, selection_id)
	local xo = get_caret_x_offset_state(dv, selection_id)
	local wrap_width = dv:get_wrap_width()
	local font = dv:get_font()
	local continue_vertical_chain =
		config.persistent_cursor_col == true
		and xo.line == line
		and xo.col == col
		and xo.wrapped == true
		and xo.wrap_width == wrap_width
		and xo.font == font
		and type(xo.offset) == "number"

	if not continue_vertical_chain then
		local preferred_row = xo.visual_row or dv._selection_visual_rows[selection_id]
		local row, row_info = dv:get_cursor_visual_row(line, col, preferred_row)
		xo.offset = row_info.indent_x + (dv:get_col_x_offset(line, col) - row_info.start_x)
		xo.visual_row = row
	end

	xo.wrapped = true
	xo.wrap_width = wrap_width
	xo.font = font

	return xo.offset
end

--- Sync stored preferred x metadata with a final cursor position
-- @param dv table doc view instance
-- @param line number final line
-- @param col number final column
-- @param visual_row number|nil wrapped row affinity for a shared boundary column
-- @param selection_id number|nil stable selection identifier
-- @return number line stored line
-- @return number col stored column
local function sync_last_x_offset(dv, line, col, visual_row, selection_id)
	local xo = get_caret_x_offset_state(dv, selection_id)
	xo.line = line
	xo.col = col
	xo.wrapped = dv:is_wrap_enabled()
	xo.wrap_width = xo.wrapped and dv:get_wrap_width() or nil
	xo.font = dv:get_font()
	xo.visual_row = xo.wrapped and visual_row or nil

	if selection_id ~= nil and dv.doc.selection.id ~= selection_id then
		dv._selection_visual_rows[selection_id] = xo.visual_row
	end

	return xo.line, xo.col
end

--- Decide whether one page move should jump to the document edge
-- Shared by wrapped and unwrapped paging so both paths keep the same
-- boundary behavior when the viewport is already at the first or last page
-- @param boundary_jump_enabled boolean true when boundary jumps are allowed
-- @param current_page_row_or_line number current top row or line
-- @param requested_page_row_or_line number unclamped requested top row or line
-- @param clamped_page_row_or_line number clamped target top row or line
-- @param direction number page direction, positive down or negative up
-- @param previous_boundary_direction number|nil stored boundary direction from the last page move
-- @return boolean should_jump_to_document_edge true when caret should jump to BOF or EOF
local function should_jump_to_page_boundary(
	boundary_jump_enabled,
	current_page_row_or_line,
	requested_page_row_or_line,
	clamped_page_row_or_line,
	direction,
	previous_boundary_direction
)
	local is_clamped_to_boundary = clamped_page_row_or_line ~= requested_page_row_or_line
	local is_same_boundary_press =
		boundary_jump_enabled
		and clamped_page_row_or_line == current_page_row_or_line
		and previous_boundary_direction == direction

	return is_same_boundary_press or (boundary_jump_enabled and is_clamped_to_boundary)
end

--- Track which document edge page navigation is resting against
-- The stored direction lets the next page press convert a boundary stop
-- into a final document-edge jump without affecting ordinary paging
-- @param direction number page direction, positive down or negative up
-- @param current_top_row_or_line number clamped target top row or line
-- @param last_top_row_or_line number maximum valid top row or line
-- @return number boundary_direction stored direction value for the next page move
local function get_page_boundary_direction(direction, current_top_row_or_line, last_top_row_or_line)
	if direction > 0 and current_top_row_or_line == last_top_row_or_line then
		return direction
	end

	if direction < 0 and current_top_row_or_line == 1 then
		return direction
	end

	return 0
end

--- Return padded wrapped-row visibility bounds for vertical autoscroll
-- Keep wrapped row visibility aligned with the padded draw origin so
-- the final visible row and caret do not clip against the viewport edge
-- @param dv table doc view instance
-- @param row number wrapped row index
-- @param viewport_h number content viewport height
-- @return number max_scroll_y_for_row_top maximum scroll that still keeps the row top visible
-- @return number min_scroll_y_for_row_bottom minimum scroll that still keeps the row bottom visible
local function get_visible_text_row_scroll_bounds(dv, row, viewport_h)
	local lh = dv:get_line_height()
	local padding_y = math.max(0, tonumber(style.padding and style.padding.y) or 0)
	local row_top = padding_y + (row - 1) * lh
	local row_bottom = padding_y + row * lh

	return row_top, row_bottom - viewport_h
end

--- Clamp one vertical scroll target so the caret row stays fully visible
-- Page navigation sets explicit scroll targets, so this keeps the current
-- caret line or wrapped row inside the viewport instead of leaving it clipped
-- @param dv table doc view instance
-- @param preferred_scroll_y number desired vertical scroll target
-- @param row number visible text row that contains the caret
-- @param viewport_h number content viewport height
-- @return number scroll_y adjusted vertical scroll target
local function clamp_scroll_y_to_visible_text_row(dv, preferred_scroll_y, row, viewport_h)
	local max_scroll_y_for_row_top, min_scroll_y_for_row_bottom =
		get_visible_text_row_scroll_bounds(dv, row, viewport_h)

	return common.clamp(preferred_scroll_y, min_scroll_y_for_row_bottom, max_scroll_y_for_row_top)
end

--- Keep the primary cursor row inside the vertical scroll target
-- @param dv table doc view instance
-- @return boolean changed whether recovery changed the requested viewport
local function clamp_scroll_target_to_cursor(dv)
	local line, col = dv.doc:get_selection()
	local row = dv:get_cursor_visual_row(line, col)
	local viewport_height = select(4, dv:get_content_viewport_rect())
	local previous_target = dv.scroll.to.y

	dv.scroll.to.y = clamp_scroll_y_to_visible_text_row(dv, previous_target, row, viewport_height)
	dv:clamp_scroll_position()

	return dv.scroll.to.y ~= previous_target
end

--- Return the wrapped page scroll target for one top row and caret row
-- @param dv table doc view instance
-- @param top_row number first visible wrapped row
-- @param caret_row number wrapped row containing the caret
-- @param viewport_h number content viewport height
-- @return number scroll_y page-aligned scroll target
local function get_wrapped_page_scroll_target(dv, top_row, caret_row, viewport_h)
	local preferred_scroll_y = (top_row - 1) * dv:get_line_height()

	return clamp_scroll_y_to_visible_text_row(dv, preferred_scroll_y, caret_row, viewport_h)
end

TextSurface.translate = {
	--- Move cursor one visible page up
	["previous_page"] = function(doc, line, col, dv, selection_id)
		if dv:is_wrap_enabled() then
			local viewport_h = select(4, dv:get_content_viewport_rect())
			local visible_rows = math.max(1, math.floor(viewport_h / dv:get_line_height()))
			local step = math.max(1, visible_rows - 1)
			local state = get_caret_x_offset_state(dv, selection_id)
			local current_row = dv:get_cursor_visual_row(line, col, state.visual_row)
			local target_row = common.clamp(current_row - step, 1, dv:get_visual_row_count())
			local raw_x = get_wrapped_vertical_x_offset(dv, line, col, selection_id)

			local target_line, target_col, resolved_row = dv:get_position_for_visual_row_x(target_row, raw_x)
			sync_last_x_offset(dv, target_line, target_col, resolved_row, selection_id)
			get_caret_x_offset_state(dv, selection_id).offset = raw_x
			return target_line, target_col
		end

		local min, max = dv:get_visible_line_range()
		local target_line = line - (max - min)
		target_line = common.clamp(target_line, 1, #doc.lines)
		local target_col
		target_line, target_col = move_to_target_line_offset(dv, line, col, target_line, selection_id)
		target_line, target_col = doc:sanitize_cursor_position(target_line, target_col)
		sync_last_x_offset(dv, target_line, target_col, nil, selection_id)
		return target_line, target_col
	end,

	--- Move cursor one visible page down
	["next_page"] = function(doc, line, col, dv, selection_id)
		if dv:is_wrap_enabled() then
			local viewport_h = select(4, dv:get_content_viewport_rect())
			local visible_rows = math.max(1, math.floor(viewport_h / dv:get_line_height()))
			local step = math.max(1, visible_rows - 1)
			local state = get_caret_x_offset_state(dv, selection_id)
			local current_row = dv:get_cursor_visual_row(line, col, state.visual_row)
			local target_row = common.clamp(current_row + step, 1, dv:get_visual_row_count())
			local raw_x = get_wrapped_vertical_x_offset(dv, line, col, selection_id)

			local target_line, target_col, resolved_row = dv:get_position_for_visual_row_x(target_row, raw_x)
			sync_last_x_offset(dv, target_line, target_col, resolved_row, selection_id)
			get_caret_x_offset_state(dv, selection_id).offset = raw_x
			return target_line, target_col
		end

		local min, max = dv:get_visible_line_range()
		local target_line = line + (max - min)
		target_line = common.clamp(target_line, 1, #doc.lines)
		local target_col
		target_line, target_col = move_to_target_line_offset(dv, line, col, target_line, selection_id)
		target_line, target_col = doc:sanitize_cursor_position(target_line, target_col)
		sync_last_x_offset(dv, target_line, target_col, nil, selection_id)
		return target_line, target_col
	end,

	--- Move cursor up by one line
	["previous_line"] = function(doc, line, col, dv, selection_id)
		if dv:is_wrap_enabled() then
			local state = get_caret_x_offset_state(dv, selection_id)
			local current_row = dv:get_cursor_visual_row(line, col, state.visual_row)
			local target_row = math.max(1, current_row - 1)
			local raw_x = get_wrapped_vertical_x_offset(dv, line, col, selection_id)

			local target_line, target_col, resolved_row = dv:get_position_for_visual_row_x(target_row, raw_x)
			sync_last_x_offset(dv, target_line, target_col, resolved_row, selection_id)
			get_caret_x_offset_state(dv, selection_id).offset = raw_x
			return target_line, target_col
		end

		if line == 1 then
			local target_line, target_col = doc:sanitize_position(1, col)
			sync_last_x_offset(dv, target_line, target_col, nil, selection_id)
			return 1, 1
		end
		local target_line, target_col = move_to_line_offset(dv, line, col, -1, selection_id)
		target_line, target_col = doc:sanitize_cursor_position(target_line, target_col)
		sync_last_x_offset(dv, target_line, target_col, nil, selection_id)
		return target_line, target_col
	end,

	--- Move cursor down by one line
	["next_line"] = function(doc, line, col, dv, selection_id)
		if dv:is_wrap_enabled() then
			local state = get_caret_x_offset_state(dv, selection_id)
			local current_row = dv:get_cursor_visual_row(line, col, state.visual_row)
			local target_row = math.min(dv:get_visual_row_count(), current_row + 1)
			local raw_x = get_wrapped_vertical_x_offset(dv, line, col, selection_id)

			local target_line, target_col, resolved_row = dv:get_position_for_visual_row_x(target_row, raw_x)
			sync_last_x_offset(dv, target_line, target_col, resolved_row, selection_id)
			get_caret_x_offset_state(dv, selection_id).offset = raw_x
			return target_line, target_col
		end

		if line == #doc.lines then
			local target_line, target_col = doc:sanitize_position(#doc.lines, math.huge)
			sync_last_x_offset(dv, target_line, target_col, nil, selection_id)
			return #doc.lines, math.huge
		end
		local target_line, target_col = move_to_line_offset(dv, line, col, 1, selection_id)
		target_line, target_col = doc:sanitize_cursor_position(target_line, target_col)
		sync_last_x_offset(dv, target_line, target_col, nil, selection_id)
		return target_line, target_col
	end,
}

--- Resolve configured blink period
-- `0` disables blinking and keeps the cursor visible for perf testing
-- @return number period blink period in seconds
local function get_blink_period()
	local period = tonumber(config.cursor_blink_period) or 1.0
	return math.max(0, period)
end

--- Return true when active text carets should be drawn this frame
-- @param view table text surface
-- @return boolean visible true when caret rendering is active
local function should_draw_carets(view)
	return view.draw_cursor ~= false
		and (not view.composition_text or view.composition_text == "")
		and core.active_view == view
		and (view:is_scrolling() or get_blink_period() <= 0 or view.blink_timer < get_blink_period() / 2)
		and system.window_has_focus()
end

--- Return the configured color for one primary or secondary caret
-- @param view table text surface
-- @param selection table stored selection
-- @return table color caret color
local function get_caret_color(view, selection)
	if selection == view.doc.selection then
		return style.cursor
	end

	return style.cursor2 or style.cursor
end

--- Return a temporary visual caret while its owning selection remains unchanged
-- @param selection table stored selection
-- @return table caret
local function get_visible_caret(selection)
	local caret = selection.visual_caret
	if caret
		and selection.start.line == caret.start_line
		and selection.start.col == caret.start_col
		and selection.stop.line == caret.stop_line
		and selection.stop.col == caret.stop_col
	then
		return caret
	end

	return selection.start
end

-- Keep a slim marker lane available when the gutter stays visible without line numbers
local function get_minimal_gutter_width()
	return math.max(style.padding.x * 2, math.floor(8 * SCALE + 0.5))
end

--- Build a doc view bound to one document
-- @param doc table document model
function TextSurface:new(doc)
	TextSurface.super.new(self, doc)
	self.mouse_cursor = "ibeam"
	self.font = "code_font"
	self.last_x_offset = {}
	self._selection_visual_rows = {}
	self._selection_x_offsets = {}
	self._selection_state_count = 1
	self.composition_text = ""	-- Uncommitted native text drawn at the primary caret
	self.composition_start = 0	-- Selected character offset inside the composition
	self.composition_length = 0	-- Selected character count inside the composition
	self.blink_timer = 0
	self._page_boundary_dir = 0
	self.use_syntax_highlighting = true
	self.gutter_visible = true
	self.line_numbers_visible = true
	self.wrap_enabled = false
	self._wrap_layout = {
		dirty_from_line = 1,
		change_id = false,
		rebuild_count = 0,
	}
end

--- Resolve initial word wrap from explicit, syntax, and global defaults
-- @param doc table document model
-- @param opts table|nil explicit view construction options
-- @param fallback boolean global view default
-- @return boolean word_wrap
function TextSurface.get_initial_word_wrap(doc, opts, fallback)
	if type(opts) == "table" and opts.word_wrap ~= nil then
		return opts.word_wrap == true
	end

	if doc and type(doc.initial_word_wrap) == "boolean" then
		return doc.initial_word_wrap
	end

	local syntax_defaults = doc and doc.format and doc.format.defaults

	if type(syntax_defaults) == "table" and type(syntax_defaults.word_wrap) == "boolean" then
		return syntax_defaults.word_wrap
	end

	return fallback == true
end

--- Return the wrapped row currently associated with the caret position
-- @param line number caret line
-- @param col number caret column
-- @param preferred_visual_row number|nil explicit row affinity for a secondary caret
-- @return number row wrapped visual row
-- @return table|nil row_info wrapped row metadata
function TextSurface:get_cursor_visual_row(line, col, preferred_visual_row)
	if not self:is_wrap_enabled() then
		return line, nil
	end

	preferred_visual_row = tonumber(preferred_visual_row)

	if preferred_visual_row then
		local row_info = self:get_visual_row_info(preferred_visual_row)

		if row_info and row_info.line == line and col >= row_info.start_col and col <= row_info.end_col then
			return preferred_visual_row, row_info
		end
	end

	local xo = self.last_x_offset

	if xo.line == line and xo.col == col and type(xo.visual_row) == "number" then
		local row_info = self:get_visual_row_info(xo.visual_row)

		if row_info and row_info.line == line and col >= row_info.start_col and col <= row_info.end_col then
			return xo.visual_row, row_info
		end
	end

	return self:get_visual_row_for_position(line, col)
end

--- Return stored wrapped-row affinity for one selection
-- @param selection_id number|nil stable selection identifier
-- @return number|nil visual_row
function TextSurface:get_selection_visual_row(selection_id)
	local state = get_caret_x_offset_state(self, selection_id)

	return state.visual_row or self._selection_visual_rows[selection_id]
end

--- Return the next Home or End stop for one text surface
-- Wrapped Home walks row starts, first-row content, then column one
-- Wrapped End walks row ends until the final visual row
-- @param line number logical line
-- @param col number logical byte column
-- @param to_end boolean true for End, false for Home
-- @param preferred_visual_row number|nil wrapped-row affinity
-- @return number line target logical line
-- @return number col target logical byte column
-- @return number visual_row target wrapped row
function TextSurface:get_line_edge_position(line, col, to_end, preferred_visual_row)
	line, col = self.doc:sanitize_position(line, col)

	if not self:is_wrap_enabled() then
		return line, to_end and self:get_line_end_col(line) or 1, line
	end

	local visual_row, row_info = self:get_cursor_visual_row(line, col, preferred_visual_row)
	local first_row, last_row = self:get_visual_rows_for_line(line)

	if to_end then
		if col == row_info.end_col and visual_row < last_row then
			visual_row = visual_row + 1
			row_info = self:get_visual_row_info(visual_row)
		end

		return line, row_info.end_col, visual_row
	end

	local logical_text = self.doc:get_logical_line(line)
	local first_content_col = self.doc:get_indent_end_col(line)

	if first_content_col > #logical_text then
		first_content_col = 1
	end

	if visual_row == first_row then
		if col <= first_content_col then
			return line, 1, visual_row
		end

		return line, first_content_col, visual_row
	end

	if col == row_info.start_col and visual_row > first_row then
		visual_row = visual_row - 1
		row_info = self:get_visual_row_info(visual_row)

		if visual_row == first_row then
			return line, first_content_col, visual_row
		end
	end

	return line, row_info.start_col, visual_row
end

--- Refresh preferred x state from an explicit caret position
-- @param line number caret line
-- @param col number caret column
-- @param visual_row number|nil wrapped row affinity
-- @param selection_id number|nil stable selection identifier
function TextSurface:sync_caret_column(line, col, visual_row, selection_id)
	local xo = get_caret_x_offset_state(self, selection_id)

	if self:is_wrap_enabled() then
		local row_info
		visual_row = tonumber(visual_row)

		if visual_row then
			row_info = self:get_visual_row_info(visual_row)
		end

		if not row_info or row_info.line ~= line or col < row_info.start_col or col > row_info.end_col then
			visual_row, row_info = self:get_visual_row_for_position(line, col)
		end

		xo.offset = row_info.indent_x + (self:get_col_x_offset(line, col) - row_info.start_x)
	else
		xo.offset = self:get_col_x_offset(line, col)
	end

	sync_last_x_offset(self, line, col, visual_row, selection_id)
end

--- Refresh preferred x offset from the current selection head
-- @param visual_row number|nil wrapped row affinity for a shared boundary column
function TextSurface:sync_cursor_column_from_selection(visual_row)
	local line, col = self.doc:get_selection()
	self:sync_caret_column(line, col, visual_row)
end

--- Insert one secondary caret at an explicit document position
-- @param line number target line
-- @param col number target column
-- @return boolean added true when a distinct caret was added
function TextSurface:insert_cursor_at(line, col)
	if not is_multi_cursor_enabled() then
		return false
	end

	local allow_virtual = self.doc:is_virtual_space_enabled("enabled")
	local sanitize = allow_virtual and self.doc.sanitize_cursor_position or self.doc.sanitize_position
	line, col = sanitize(self.doc, line, col)
	local id = self.doc:add_selection(line, col, nil, nil, allow_virtual)

	if not id then
		return false
	end

	local visual_row

	if self:is_wrap_enabled() then
		visual_row = self:get_visual_row_for_position(line, col)
	end

	self._selection_visual_rows[id] = visual_row
	self:sync_caret_column(line, col, visual_row, id)
	self.blink_timer = 0
	self:scroll_to_make_visible(line, col)
	core.redraw = true

	return true
end

--- Add carets one displayed row above or below every current selection head
-- Preserves each caret's preferred horizontal position and merges duplicates
-- @param direction number negative for above, positive for below
-- @return boolean added true when at least one caret was added
function TextSurface:insert_cursors_vertical(direction)
	if not is_multi_cursor_enabled() then
		return false
	end

	direction = direction < 0 and -1 or 1

	local selections = self.doc:get_selections()
	local targets = {}
	local wrapped = self:is_wrap_enabled()
	local total_rows = wrapped and self:get_visual_row_count() or #self.doc.lines

	for index = 1, #selections do
		local selection = selections[index]
		local line, col = selection[1], selection[2]
		local state = get_caret_x_offset_state(self, selection.id)
		local source_row = line
		local target_line, target_col, target_row
		local preferred_x

		if wrapped then
			source_row = self:get_cursor_visual_row(line, col, state.visual_row)
			target_row = source_row + direction

			if target_row >= 1 and target_row <= total_rows then
				preferred_x = get_wrapped_vertical_x_offset(self, line, col, selection.id)
				target_line, target_col, target_row = self:get_position_for_visual_row_x(target_row, preferred_x)
			end
		else
			target_row = line + direction

			if target_row >= 1 and target_row <= total_rows then
				local continue_vertical_chain = config.persistent_cursor_col == true
					and state.line == line
					and state.col == col
					and type(state.offset) == "number"

				preferred_x = continue_vertical_chain and state.offset or self:get_col_x_offset(line, col)
				target_line = target_row
				target_col = self:get_x_offset_col(
					target_line, preferred_x, self.doc:is_virtual_space_enabled("enabled")
				)
				target_line, target_col = self.doc:sanitize_cursor_position(target_line, target_col)
			end
		end

		if target_line and (target_line ~= line or target_col ~= col or target_row ~= source_row) then
			targets[#targets + 1] = {
				line = target_line,
				col = target_col,
				visual_row = wrapped and target_row or nil,
				preferred_x = preferred_x,
			}
		end
	end

	local added = false
	local reveal

	for index = 1, #targets do
		local target = targets[index]
		local id = self.doc:add_selection(
			target.line, target.col, nil, nil, self.doc:is_virtual_space_enabled("enabled")
		)

		if id then
			local state = get_caret_x_offset_state(self, id)
			state.line = target.line
			state.col = target.col
			state.offset = target.preferred_x
			state.wrapped = wrapped
			state.wrap_width = wrapped and self:get_wrap_width() or nil
			state.font = self:get_font()
			state.visual_row = target.visual_row
			self._selection_visual_rows[id] = target.visual_row
			added = true

			if not reveal
				or direction < 0 and target.visual_row and target.visual_row < reveal.visual_row
				or direction < 0 and not wrapped and target.line < reveal.line
				or direction > 0 and target.visual_row and target.visual_row > reveal.visual_row
				or direction > 0 and not wrapped and target.line > reveal.line
			then
				reveal = target
			end
		end
	end

	if not added then
		return false
	end

	self.blink_timer = 0
	self:scroll_to_make_visible(reveal.line, reveal.col)
	core.redraw = true

	return true
end

--- Replace one multiline selection with carets at selected logical line ends
-- @return boolean inserted true when the selection became multiple carets
function TextSurface:insert_cursors_at_line_ends()
	if not is_multi_cursor_enabled() then
		return false
	end

	if self.doc:get_selection_count() ~= 1 or not self.doc:has_selection() then
		return false
	end

	local head_line = self.doc:get_selection()
	local start_line, end_line = self.doc:get_selected_line_range()

	if start_line == end_line then
		return false
	end

	local selections = {}

	for line = start_line, end_line do
		local col = #self.doc:get_logical_line(line) + 1
		selections[#selections + 1] = { line, col, line, col }
	end

	local primary_index = common.clamp(head_line, start_line, end_line) - start_line + 1
	self.doc:set_selections(selections, primary_index)
	self._selection_visual_rows = {}
	self._selection_x_offsets = {}
	self:sync_cursor_column_from_selection()
	self.blink_timer = 0
	self:scroll_to_make_visible(self.doc:get_selection())
	core.redraw = true

	return true
end

-- Return true when wrapped visual rows are active for this surface
function TextSurface:is_wrap_enabled()
	return wrap.is_wrap_enabled(self)
end

--- Return the horizontal range owned by the core text scrollbar
-- @return number max_scroll_x horizontal scroll limit
function TextSurface:get_scroll_max_x()
	return horizontal_scrollbar.get_scroll_max_x(self)
end

-- Return current wrapped text width after gutter and lane reservations
function TextSurface:get_wrap_width()
	return wrap.get_wrap_width(self)
end

-- Mark one logical-line range dirty for incremental wrap repair
function TextSurface:invalidate_wrap_layout(line1, line2, reason)
	return wrap.invalidate_wrap_layout(self, line1, line2, reason)
end

-- Rebuild wrapped visual rows when metrics or doc content changed
function TextSurface:ensure_wrap_layout()
	return wrap.ensure_wrap_layout(self)
end

-- Return total visible row count including wrapped continuation rows
function TextSurface:get_visual_row_count()
	return wrap.get_visual_row_count(self)
end

-- Return wrapped-row span for one logical line
function TextSurface:get_visual_rows_for_line(line)
	return wrap.get_visual_rows_for_line(self, line)
end

-- Return wrapped-row index and row metadata for one logical position
function TextSurface:get_visual_row_for_position(line, col, line_end)
	return wrap.get_visual_row_for_position(self, line, col, line_end)
end

-- Convert one wrapped-row x offset back to a logical line and column
function TextSurface:get_position_for_visual_row_x(row, x, allow_virtual)
	if allow_virtual == nil then
		allow_virtual = self.doc:is_virtual_space_enabled("enabled")
	end

	return wrap.get_position_for_visual_row_x(self, row, x, allow_virtual)
end

-- Return wrapped-row metadata for one visual row index
function TextSurface:get_visual_row_info(row)
	return wrap.get_visual_row_info(self, row)
end

-- Resolve one estimated wrapped row to an exact row and metadata
function TextSurface:resolve_visual_row(row)
	return wrap.resolve_visual_row(self, row)
end

-- Return row-local x offset for a logical position inside its wrapped row
function TextSurface:get_visual_position_x(line, col, line_end)
	return wrap.get_visual_position_x(self, line, col, line_end)
end

--- Return one snapshot of the current wrap profiling counters
-- @return table|nil snapshot current profiling summary when enabled
function TextSurface:get_wrap_profile_snapshot()
	return wrap.get_profile_snapshot(self)
end

--- Reset wrap profiling counters for this surface
function TextSurface:reset_wrap_profile()
	return wrap.reset_profile(self)
end

-- Return one caret-valid end column for the given logical line
function TextSurface:get_line_end_col(line)
	return self.doc:get_line_end_col(line)
end

--- Return total vertical scrollable extent in pixels
-- @return number size scrollable height
function TextSurface:get_scrollable_size()
	if self:is_wrap_enabled() then
		local visual_row_count = self:get_visual_row_count()
		local scrollable_size = self:get_line_height() * visual_row_count + style.padding.y * 2

		return scrollable_size
	end

	-- Include vertical text padding so the last line is never clipped
	return self:get_line_height() * #self.doc.lines + style.padding.y * 2
end

--- Return resolved font object for this view
-- @return table font font object
function TextSurface:get_font()
	return style[self.font]
end

--- Return line height in pixels after config scaling
-- @return number line_height rendered line height
function TextSurface:get_line_height()
	return math.floor(self:get_font():get_height() * config.line_height)
end

--- Capture one text zoom anchor from one screen-space point
-- Wrapped text zoom keeps the visible top wrapped row stable instead of chasing
-- a reflow-sensitive caret point inside the paragraph
-- @param anchor_x number screen x anchor
-- @param anchor_y number screen y anchor
-- @return table anchor captured zoom anchor state
function TextSurface:capture_content_zoom_anchor(_anchor_x, _anchor_y)
	if self:is_wrap_enabled() ~= true then
		return nil
	end

	local viewport_anchor = wrap.capture_viewport_anchor(self)

	if not viewport_anchor then
		return nil
	end

	return {
		wrapped_viewport_anchor = viewport_anchor,
	}
end

--- Restore one captured text zoom anchor after font metrics changed
-- Wrapped text zoom reuses the shared top-of-viewport anchor so reflow above
-- or inside the paragraph does not move the viewport unpredictably
-- @param anchor table captured zoom anchor state
-- @return boolean restored true when the anchor was applied
function TextSurface:restore_content_zoom_anchor(anchor)
	if type(anchor) ~= "table" or anchor.view ~= self then
		return false
	end

	if wrap.restore_viewport_anchor(self, anchor.wrapped_viewport_anchor) then
		self.scroll.to.x = 0
		self.scroll.x = 0
		return true
	end

	return false
end

--- Return identifier-like token under cursor or empty string
-- @return string word token under cursor
function TextSurface:get_word_under_cursor()
	local doc = self.doc
	local line, col = doc:get_selection()
	local start_line, start_col = translate.start_of_word(doc, line, col)
	local end_line, end_col = translate.end_of_word(doc, start_line, start_col)

	return doc:get_text(start_line, start_col, end_line, end_col)
end

--- Return selected text or fallback token under cursor
-- @return string text selected text or token
function TextSurface:get_word_under_cursor_or_selection()
	local doc = self.doc
	if doc:has_selection() then
		return doc:get_text(doc:get_selection())
	end
	return self:get_word_under_cursor()
end

--- Return true when the gutter lane should be visible
-- @return boolean visible true when the gutter lane is drawn
function TextSurface:is_gutter_visible()
	return self.gutter_visible ~= false
end

--- Return true when line numbers should be drawn inside the visible gutter
-- @return boolean visible true when line numbers should be drawn
function TextSurface:are_line_numbers_visible()
	return self:is_gutter_visible() and self.line_numbers_visible ~= false
end

--- Return the left inset applied before the first text column
-- Hidden gutters still keep a natural text margin on the left edge
-- @return number width left text inset in pixels
function TextSurface:get_text_x_offset()
	if self:is_gutter_visible() then
		return self:get_gutter_width()
	end

	local padding = self:get_content_padding()
	return math.max(0, tonumber(padding and padding.x) or 0)
end

--- Return visible text width after gutter and left inset reservations
-- @return number width visible text viewport width
function TextSurface:get_text_viewport_width()
	local viewport_w = select(3, self:get_content_viewport_rect())
	return math.max(1, viewport_w - self:get_text_x_offset())
end

--- Return gutter width in pixels
-- @return number width gutter width
function TextSurface:get_gutter_width()
	if not self:is_gutter_visible() then
		return 0
	end

	if not self:are_line_numbers_visible() then
		return get_minimal_gutter_width()
	end

	-- Reserve the configured minimum so short documents keep the same text position
	local min_digits = math.max(1, math.floor(tonumber(config.line_number_min_digits) or 3))
	local line_number = tostring(#self.doc.lines)
	local width_text = #line_number < min_digits and string.rep("0", min_digits) or line_number

	return self:get_font():get_width(width_text) + style.padding.x * 2
end

--- Return right edge limit available for plugin overlays
-- Plugins can override this to reserve UI zones (for example minimap overlays)
-- @return number x_right right lane drawing limit
function TextSurface:get_overlay_right_lane_limit()
	local x, _, w = self:get_content_viewport_rect()
	return x + w
end

--- Convert line index to screen origin for text area
-- @param idx number one-based line index
-- @return number x line origin x
-- @return number y line origin y
function TextSurface:get_line_screen_position(idx)
	if self:is_wrap_enabled() then
		local row = self:get_visual_row_for_position(idx, 1)
		local x, y = self:get_content_offset()
		local lh = self:get_line_height()
		local line_x = x + self:get_text_x_offset()
		local line_y = y + (row - 1) * lh + style.padding.y

		return line_x, line_y
	end

	local x, y = self:get_content_offset()
	local lh = self:get_line_height()
	return x + self:get_text_x_offset(), y + (idx - 1) * lh + style.padding.y
end

--- Return y offset used to center glyphs in each line row
-- @return number offset y pixel offset
function TextSurface:get_line_text_y_offset()
	local lh = self:get_line_height()
	local th = self:get_font():get_height()
	return (lh - th) / 2
end

--- Return current scroll bounds for visible content area
-- @return number x1 left bound
-- @return number y1 top bound
-- @return number x2 right bound
-- @return number y2 bottom bound
function TextSurface:get_content_bounds()
	local x = self.scroll.x
	local y = self.scroll.y
	local _, _, w, h = self:get_content_viewport_rect()
	return x, y, x + w, y + h
end

--- Return min and max currently visible line indices
-- @return number minline first visible line
-- @return number maxline last visible line
function TextSurface:get_visible_line_range()
	if self:is_wrap_enabled() then
		local minrow, maxrow = self:get_visible_visual_row_range()
		local mininfo = self:get_visual_row_info(minrow)
		local maxinfo = self:get_visual_row_info(maxrow)

		return mininfo.line, maxinfo.line
	end

	local x, y, x2, y2 = self:get_content_bounds()
	local lh = self:get_line_height()
	local minline = math.max(1, math.floor(y / lh))
	local maxline = math.min(#self.doc.lines, math.floor(y2 / lh) + 1)
	return minline, maxline
end

-- Return min and max currently visible wrapped-row indices
-- Falls back to logical rows when wrapping is disabled
-- @return number minrow first visible row
-- @return number maxrow last visible row
function TextSurface:get_visible_visual_row_range()
	local _, y, _, y2 = self:get_content_bounds()
	local lh = self:get_line_height()
	local total_rows = self:get_visual_row_count()
	local minrow = common.clamp(math.floor(y / lh) + 1, 1, total_rows)
	local maxrow = common.clamp(math.floor(y2 / lh) + 1, minrow, total_rows)

	return minrow, maxrow
end

--- Scroll the viewport by one rendered row with explicit cursor behavior
-- @param direction number negative for up or positive for down
-- @param keep_cursor_visible boolean|nil prevent the cursor from remaining outside the viewport
-- @param keep_cursor_screen_position boolean|nil move the cursor with every viewport step
function TextSurface:move_viewport_line(direction, keep_cursor_visible, keep_cursor_screen_position)
	local line_height = self:get_line_height()
	local previous_scroll_y = self.scroll.to.y or self.scroll.y or 0

	self.scroll.to.y = previous_scroll_y + (direction < 0 and -line_height or line_height)
	self:clamp_scroll_position()

	if self.scroll.to.y == previous_scroll_y then
		if keep_cursor_visible and clamp_scroll_target_to_cursor(self) then
			self.scroll.y = self.scroll.to.y
		end

		return
	end

	local line, col = self.doc:get_selection()
	local cursor_row = self:get_cursor_visual_row(line, col)
	local total_rows = self:get_visual_row_count()
	local scroll_distance = math.abs(self.scroll.to.y - previous_scroll_y)
	local full_scroll_step = math.abs(scroll_distance - line_height) < 0.001

	if keep_cursor_screen_position then
		-- A fixed-screen cursor needs one complete document row to replace the scrolled row
		local cursor_can_move = direction > 0 and cursor_row < total_rows
			or direction < 0 and cursor_row > 1

		if not full_scroll_step or not cursor_can_move then
			self.scroll.to.y = previous_scroll_y

			if keep_cursor_visible and clamp_scroll_target_to_cursor(self) then
				self.scroll.y = self.scroll.to.y
			end

			return
		end

		local translator = direction > 0
			and TextSurface.translate.next_line
			or TextSurface.translate.previous_line
		self.doc:move_to(translator, self)
		self.last_line, self.last_col = self.doc:get_selection()

		if keep_cursor_visible then
			clamp_scroll_target_to_cursor(self)
		end

		-- Moving both sides in one frame keeps the cursor at exactly the same screen height
		self.scroll.y = self.scroll.to.y
		return
	end

	if not keep_cursor_visible then
		return
	end

	local viewport_height = select(4, self:get_content_viewport_rect())
	local max_scroll_for_top, min_scroll_for_bottom =
		get_visible_text_row_scroll_bounds(self, cursor_row, viewport_height)
	local cursor_would_be_hidden = direction > 0
		and self.scroll.to.y > max_scroll_for_top
		or direction < 0 and self.scroll.to.y < min_scroll_for_bottom

	if cursor_would_be_hidden then
		-- Follow the viewport only after an independently scrolling cursor reaches its edge
		local translator = direction > 0
			and TextSurface.translate.next_line
			or TextSurface.translate.previous_line
		self.doc:move_to(translator, self)
	end

	if clamp_scroll_target_to_cursor(self) then
		self.scroll.y = self.scroll.to.y
	end

	self.last_line, self.last_col = self.doc:get_selection()
end

--- Page viewport and selection while preserving visual cursor column
-- @param direction number page direction, positive down or negative up
-- @param overlap number|nil kept rows between pages
-- @param boundary_jump boolean|nil allow terminal jump on repeated boundary press
-- @param extend_selection boolean|nil keep the current selection anchor
function TextSurface:move_viewport_page(direction, overlap, boundary_jump, extend_selection)
	if self:is_wrap_enabled() then
		local lh = self:get_line_height()
		local viewport_h = select(4, self:get_content_viewport_rect())
		local visible_rows = math.max(1, math.floor(viewport_h / lh))
		local keep_rows = math.max(0, math.floor(overlap or 1))
		local step = math.max(1, visible_rows - keep_rows)
		local animate_page_scroll = config.animate_page_scroll == true
		local source_scroll_y = animate_page_scroll and (self.scroll.to.y or self.scroll.y or 0) or (self.scroll.y or 0)
		local line, col, anchor_line, anchor_col = self.doc:get_selection()
		local caret_row = self:get_cursor_visual_row(line, col)
		local total_rows = self:get_visual_row_count()
		local max_top_row = math.max(1, total_rows - visible_rows + 1)
		local top_row = common.clamp(math.floor(source_scroll_y / lh) + 1, 1, max_top_row)
		local requested_top_row = top_row + direction * step
		local target_top_row = common.clamp(requested_top_row, 1, max_top_row)

		-- Resolve the destination page before using its row totals because an
		-- estimated wrap block can change all following row positions
		target_top_row = self:resolve_visual_row(target_top_row)
		total_rows = self:get_visual_row_count()
		max_top_row = math.max(1, total_rows - visible_rows + 1)
		top_row = common.clamp(math.floor(source_scroll_y / lh) + 1, 1, max_top_row)
		requested_top_row = top_row + direction * step
		target_top_row = common.clamp(requested_top_row, 1, max_top_row)

		local should_jump_to_document_edge = should_jump_to_page_boundary(
			boundary_jump == true,
			top_row,
			requested_top_row,
			target_top_row,
			direction,
			self._page_boundary_dir
		)

		if target_top_row == top_row and not should_jump_to_document_edge then
			self._page_boundary_dir = get_page_boundary_direction(direction, target_top_row, max_top_row)
			return
		end

		local target_row = common.clamp(caret_row + (target_top_row - top_row), 1, total_rows)
		local raw_x = get_wrapped_vertical_x_offset(self, line, col)

		-- Match the non-wrapped pager: a clamped boundary move may do one final
		-- jump to the document edge, while ordinary paging stops once the
		-- viewport can no longer advance
		if should_jump_to_document_edge then
			target_row = (direction > 0) and total_rows or 1
		end

		local target_line, target_col, resolved_row = self:get_position_for_visual_row_x(target_row, raw_x)

		if extend_selection then
			self.doc:set_selection(
				target_line, target_col, anchor_line, anchor_col, nil,
				self.doc:is_virtual_space_enabled("enabled")
			)
		else
			self.doc:set_selection(
				target_line, target_col, nil, nil, nil,
				self.doc:is_virtual_space_enabled("enabled")
			)
		end
		self.last_x_offset.offset = raw_x
		sync_last_x_offset(self, target_line, target_col, resolved_row)

		local target_y = get_wrapped_page_scroll_target(self, target_top_row, resolved_row, viewport_h)
		self.scroll.to.y = target_y
		if not animate_page_scroll then
			self.scroll.y = target_y
		end
		self.scroll.to.x = 0
		self.scroll.x = 0
		self.last_line, self.last_col = target_line, target_col
		self:clamp_scroll_position()
		self._page_boundary_dir = get_page_boundary_direction(direction, target_top_row, max_top_row)
		return
	end

	local lh = self:get_line_height()
	local viewport_h = select(4, self:get_content_viewport_rect())
	local visible_rows = math.max(1, math.floor(viewport_h / lh))
	local keep_rows = math.max(0, math.floor(overlap or 1))
	local step = math.max(1, visible_rows - keep_rows)
	local max_top_line = math.max(1, #self.doc.lines - visible_rows + 1)
	local animate_page_scroll = config.animate_page_scroll == true

	-- When animated paging is enabled, page math must use target scroll
	local source_scroll_y = animate_page_scroll and (self.scroll.to.y or self.scroll.y or 0) or (self.scroll.y or 0)
	local top_line_raw = math.floor(source_scroll_y / lh) + 1
	local top_line = common.clamp(top_line_raw, 1, max_top_line)
	local requested_top_line = top_line + direction * step
	local target_top_line = common.clamp(requested_top_line, 1, max_top_line)

	local line, col, anchor_line, anchor_col = self.doc:get_selection()
	local line_delta = target_top_line - top_line
	local target_line = common.clamp(line + line_delta, 1, #self.doc.lines)
	local should_jump_to_document_edge = should_jump_to_page_boundary(
		boundary_jump == true,
		top_line,
		requested_top_line,
		target_top_line,
		direction,
		self._page_boundary_dir
	)
	if should_jump_to_document_edge then
		if direction > 0 then
			target_line = #self.doc.lines
		else
			target_line = 1
		end
	end
	target_line, col = move_to_target_line_offset(self, line, col, target_line)
	target_line, col = self.doc:sanitize_cursor_position(target_line, col)
	sync_last_x_offset(self, target_line, col)

	if extend_selection then
		self.doc:set_selection(
			target_line, col, anchor_line, anchor_col, nil,
			self.doc:is_virtual_space_enabled("enabled")
		)
	else
		self.doc:set_selection(
			target_line, col, nil, nil, nil, self.doc:is_virtual_space_enabled("enabled")
		)
	end

	local target_y = (target_top_line - 1) * lh
	target_y = clamp_scroll_y_to_visible_text_row(self, target_y, target_line, viewport_h)
	self.scroll.to.y = target_y
	if not animate_page_scroll then
		self.scroll.y = target_y
	end
	-- Keep update-loop cursor visibility sync from re-adjusting this explicit page jump
	self.last_line, self.last_col = target_line, col
	self:clamp_scroll_position()
	self._page_boundary_dir = get_page_boundary_direction(direction, target_top_line, max_top_line)
end

--- Return the tab width shared by drawing and caret placement
-- @param doc table text document
-- @param font userdata active text font
-- @return number width tab width in pixels
local function get_indent_grid_tab_width(doc, font)
	-- Keep rendered tab stops on the same grid used by indentation editing
	return font:get_width(" ") * doc:get_indent_size()
end

--- Return whether one long line can use byte-based monospace math
-- @param text string complete document line
-- @return boolean safe simple byte math can be used
local function can_use_simple_long_line_math(text)
	-- Bytes 128 through 255 mark Unicode text that needs real glyph measurements
	return #text > (config.long_line_threshold or 5000)
		and not text:find("[\128-\255]")
		and not text:find("\t", 1, true)
end

--- Convert line and column to horizontal pixel offset
-- @param line number one-based line index
-- @param col number one-based column index
-- @return number xoffset horizontal offset in pixels
function TextSurface:get_col_x_offset(line, col)
	local text = self.doc.lines[line]

	if not text then
		return 0
	end
	local font = self:get_font()
	local line_end_col = self:get_line_end_col(line)
	local physical_col = math.min(math.max(1, col), line_end_col)

	-- Fast-path only when every byte is one fixed-width visible character
	if can_use_simple_long_line_math(text) then
		local w = font:get_width(" ")
		return (physical_col - 1 + math.max(0, col - line_end_col)) * w
	end

	-- Use the shared indentation grid for tab measurement and caret mapping
	local tab_size = get_indent_grid_tab_width(self.doc, font)
	local xoffset = 0
	local i = 1
	while i < physical_col do
		local tab_i = text:find("\t", i, true)
		local run_end = math.min(tab_i and (tab_i - 1) or line_end_col - 1, line_end_col - 1)

		if i <= run_end then
			local run = text:sub(i, run_end)
			local run_col = math.min(physical_col, run_end + 1) - i + 1

			xoffset = xoffset + font:get_text_x_offset(run, run_col)

			if physical_col <= run_end + 1 then
				break
			end

			i = run_end + 1
		end

		if i < physical_col and text:sub(i, i) == "\t" then
			local advance = tab_size - (xoffset % tab_size)
			xoffset = xoffset + advance
			i = i + 1
		end
	end

	return xoffset + math.max(0, col - line_end_col) * font:get_width(" ")
end

--- Convert horizontal pixel offset to nearest column on a line
-- @param line number one-based line index
-- @param x number horizontal offset in pixels
-- @param allow_virtual boolean|nil allow positions beyond physical line end
-- @return number col nearest one-based column index
function TextSurface:get_x_offset_col(line, x, allow_virtual)
	local text = self.doc.lines[line]
	local font = self:get_font()
	x = tonumber(x) or 0
	local line_end_col = self:get_line_end_col(line)
	-- Fast-path only when every byte is one fixed-width visible character
	if can_use_simple_long_line_math(text) then
		local w = font:get_width(" ")
		if w <= 0 then
			return 1
		end
		local col = math.floor(x / w) + 1

		if allow_virtual then
			return math.max(1, col)
		end

		return common.clamp(col, 1, line_end_col)
	end
	-- Map x offsets to columns with the shared indentation grid tab stops
	local tab_size = get_indent_grid_tab_width(self.doc, font)
	local xoffset, i = 0, 1
	while i <= #text do
		local tab_i = text:find("\t", i, true)
		local run_end = tab_i and (tab_i - 1) or #text

		if i <= run_end then
			local run = text:sub(i, run_end)
			local run_width = font:get_text_x_offset(run, #run + 1)

			if x <= xoffset + run_width then
				return i + font:get_x_offset_col(run, x - xoffset) - 1
			end

			xoffset = xoffset + run_width
			i = run_end + 1
		end

		if i <= #text and text:sub(i, i) == "\t" then
			local w = tab_size - (xoffset % tab_size)
			local next_i = i + 1

			if x <= xoffset + w then
				return (x - xoffset <= w / 2) and i or next_i
			end

			xoffset = xoffset + w
			i = i + 1
		end
	end

	if allow_virtual then
		local space_width = font:get_width(" ")

		if space_width > 0 then
			return line_end_col + math.max(0, math.floor((x - xoffset) / space_width + 0.5))
		end
	end

	return line_end_col
end

--- Convert screen coordinates into document position
-- @param x number absolute x coordinate
-- @param y number absolute y coordinate
-- @return number line resolved line
-- @param allow_virtual boolean|nil override ordinary cursor setting
-- @return number col resolved column
function TextSurface:resolve_screen_position(x, y, allow_virtual)
	if allow_virtual == nil then
		allow_virtual = self.doc:is_virtual_space_enabled("enabled")
	end

	if self:is_wrap_enabled() then
		local ox, oy = self:get_line_screen_position(1)
		local row = common.clamp(math.floor((y - oy) / self:get_line_height()) + 1, 1, self:get_visual_row_count())

		return wrap.get_position_for_visual_row_x(self, row, x - ox, allow_virtual)
	end

	local ox, oy = self:get_line_screen_position(1)
	local line = math.floor((y - oy) / self:get_line_height()) + 1
	line = common.clamp(line, 1, #self.doc.lines)
	local col = self:get_x_offset_col(line, x - ox, allow_virtual)
	return line, col
end

--- Snap wrapped doc-edge jumps without using global estimated row space
-- Wrapped 'Ctrl+Home' and 'Ctrl+End' should land on direct logical anchors
-- so large files do not loop through unstable wrapped-row estimates
-- @param line number target line
-- @param col number|nil target column when known
-- @return boolean handled true when the jump used a direct wrapped edge anchor
function TextSurface:scroll_to_wrapped_doc_edge(line, col)
	if not self:is_wrap_enabled() then
		return false
	end

	local last_line = #self.doc.lines
	local last_col = #self.doc.lines[last_line]

	if line <= 1 and (col == nil or col <= 1) then
		self:set_vertical_edge_scroll_target(0)
		self.scroll.to.x = 0
		self.scroll.x = 0
		self:clamp_scroll_position()
		return true
	end

	if line < last_line or (col ~= nil and col < last_col) then
		return false
	end

	local viewport_h = select(4, self:get_content_viewport_rect())
	local line_height = self:get_line_height()
	local visible_rows = math.max(1, math.floor(viewport_h / line_height))
	local final_row = self:get_visual_row_for_position(last_line, last_col)
	local total_rows = self:get_visual_row_count()
	local last_top_row = math.max(1, total_rows - visible_rows + 1)
	local target = get_wrapped_page_scroll_target(self, last_top_row, final_row, viewport_h)

	self:set_vertical_edge_scroll_target(target)
	self.scroll.to.x = 0
	self.scroll.x = 0
	self:clamp_scroll_position()

	return true
end

--- Scroll so a target line lands at configured viewport anchor
-- @param line number target line
-- @param ignore_if_visible boolean|nil skip when already visible
-- @param instant boolean|nil true to skip animation
function TextSurface:scroll_to_line(line, ignore_if_visible, instant)
	if self:is_wrap_enabled() then
		if self:scroll_to_wrapped_doc_edge(line, 1) then
			return
		end

		local row = self:get_visual_row_for_position(line, 1)
		local minrow, maxrow = self:get_visible_visual_row_range()

		if not (ignore_if_visible and row > minrow and row < maxrow) then
			local lh = self:get_line_height()
			local viewport_h = select(4, self:get_content_viewport_rect())
			local anchor = common.clamp(config.scroll_to_line_vertical_position or 0.5, 0, 1)
			local target = math.max(0, lh * (row - 1) - viewport_h * anchor)
			local max_scroll = math.max(0, self:get_scrollable_size() - viewport_h)

			target = common.clamp(target, 0, max_scroll)
			self.scroll.to.y = target
			self.scroll.to.x = 0

			-- Wrapped jumps cannot safely animate through stale estimated row space
			self.scroll.y = target
			self.scroll.x = 0

			self:clamp_scroll_position()
		end
		return
	end

	local min, max = self:get_visible_line_range()

	if not (ignore_if_visible and line > min and line < max) then
		local lh = self:get_line_height()
		local viewport_h = select(4, self:get_content_viewport_rect())

		-- Use configurable vertical anchor for jump positioning
		local anchor = common.clamp(config.scroll_to_line_vertical_position or 0.5, 0, 1)

		-- Clamp target immediately to avoid EOF overshoot/back-lerp jerk
		local target = math.max(0, lh * (line - 1) - viewport_h * anchor)
		local max_scroll = math.max(0, self:get_scrollable_size() - viewport_h)

		target = common.clamp(target, 0, max_scroll)
		self.scroll.to.y = target

		if instant then
			self.scroll.y = target
		end

		self:clamp_scroll_position()
	end
end

--- Scroll viewport enough to keep a document position visible
-- @param line number target line
-- @param col number target column
function TextSurface:scroll_to_make_visible(line, col)
	if self:is_wrap_enabled() then
		if self:scroll_to_wrapped_doc_edge(line, col) then
			return
		end

		local row = self:get_cursor_visual_row(line, col)
		local viewport_h = select(4, self:get_content_viewport_rect())
		local min, max = get_visible_text_row_scroll_bounds(self, row, viewport_h)

		self.scroll.to.y = math.min(self.scroll.to.y, min)
		self.scroll.to.y = math.max(self.scroll.to.y, max)
		self.scroll.to.x = 0

		-- Wrapped visibility jumps should snap to the new anchor immediately
		self.scroll.x = 0
		self.scroll.y = self.scroll.to.y
		self:clamp_scroll_position()

		return
	end

	local viewport_h = select(4, self:get_content_viewport_rect())
	local min, max = get_visible_text_row_scroll_bounds(self, line, viewport_h)

	self.scroll.to.y = math.min(self.scroll.to.y, min)
	self.scroll.to.y = math.max(self.scroll.to.y, max)

	local text_offset_x = self:get_text_x_offset()
	local xoffset = self:get_col_x_offset(line, col)
	local viewport_w = select(3, self:get_content_viewport_rect())

	-- Horizontal autoscroll hysteresis:
	-- move right when cursor crosses ~80%, move left only after ~20%
	local view_w = math.max(1, viewport_w - text_offset_x)
	local left_ratio = common.clamp(config.hscroll_left_margin_ratio or 0.2, 0.0, 1.0)
	local right_ratio = common.clamp(config.hscroll_right_margin_ratio or 0.8, 0.0, 1.0)

	if right_ratio < left_ratio then
		right_ratio = left_ratio
	end

	local left_margin = view_w * left_ratio
	local right_margin = view_w * right_ratio
	local left_bound = self.scroll.to.x + left_margin
	local right_bound = self.scroll.to.x + right_margin

	if xoffset < left_bound then
		self.scroll.to.x = math.max(0, xoffset - left_margin)
	elseif xoffset > right_bound then
		self.scroll.to.x = math.max(0, xoffset - right_margin)
	end

	self:clamp_scroll_position()
end

--- Return true when one character belongs to a configured mouse-selection divider run
-- @param char string character to classify
-- @return boolean divider true for configured punctuation dividers
local function is_mouse_word_divider(char)
	return char ~= "" and config.word_dividers:find(char, nil, true) ~= nil
end

--- Move to the start of the word or consecutive divider token under the pointer
-- @param doc table document model
-- @param line number pointer line
-- @param col number pointer column
-- @return number line selected start line
-- @return number col selected start column
local function start_of_mouse_token(doc, line, col)
	if not is_mouse_word_divider(doc:get_char(line, col)) then
		return translate.start_of_word(doc, line, col)
	end

	while true do
		local previous_line, previous_col = doc:position_offset(line, col, -1)
		if previous_line == line and previous_col == col
			or not is_mouse_word_divider(doc:get_char(previous_line, previous_col))
		then
			break
		end
		line, col = previous_line, previous_col
	end

	return line, col
end

--- Move past the word or consecutive divider token under the pointer
-- @param doc table document model
-- @param line number pointer line
-- @param col number pointer column
-- @return number line selected end line
-- @return number col selected end column
local function end_of_mouse_token(doc, line, col)
	if not is_mouse_word_divider(doc:get_char(line, col)) then
		return translate.end_of_word(doc, line, col)
	end

	while is_mouse_word_divider(doc:get_char(line, col)) do
		local next_line, next_col = doc:position_offset(line, col, 1)
		if next_line == line and next_col == col then
			break
		end
		line, col = next_line, next_col
	end

	return line, col
end

--- Build selection range for click mode and drag direction
-- @param doc table document model
-- @param clicks number click count
-- @param line1 number anchor line
-- @param col1 number anchor column
-- @param line2 number focus line
-- @param col2 number focus column
-- @return number line1 normalized anchor line
-- @return number col1 normalized anchor col
-- @return number line2 normalized focus line
-- @return number col2 normalized focus col
local function mouse_selection(doc, clicks, line1, col1, line2, col2)
	local swap = line2 < line1 or line2 == line1 and col2 <= col1
	if swap then
		line1, col1, line2, col2 = line2, col2, line1, col1
	end

	-- Keep fast repeated clicks cycling between whole words and whole lines
	if clicks >= 2 and clicks % 2 == 0 then
		line1, col1 = start_of_mouse_token(doc, line1, col1)
		line2, col2 = end_of_mouse_token(doc, line2, col2)
	elseif clicks >= 3 then
		-- Keep full-line mouse selection on the visible line without consuming its newline
		local text = doc:get_logical_line(line2)
		line1, col1, line2, col2 = line1, 1, line2, #text + 1
	end

	if swap then
		return line2, col2, line1, col1
	end

	return line1, col1, line2, col2
end

--- Extend an existing selection without moving its fixed anchor
-- @param doc table document model
-- @param clicks number click count
-- @param line number pointer line
-- @param col number pointer column
-- @param anchor_line number fixed selection anchor line
-- @param anchor_col number fixed selection anchor column
-- @return number line caret line
-- @return number col caret column
-- @return number anchor_line unchanged anchor line
-- @return number anchor_col unchanged anchor column
local function shift_mouse_selection(doc, clicks, line, col, anchor_line, anchor_col)
	local before_anchor = line < anchor_line or line == anchor_line and col < anchor_col

	if clicks >= 2 and clicks % 2 == 0 then
		if before_anchor then
			line, col = start_of_mouse_token(doc, line, col)
		else
			line, col = end_of_mouse_token(doc, line, col)
		end
	elseif clicks >= 3 then
		col = before_anchor and 1 or #doc:get_logical_line(line) + 1
	end

	return line, col, anchor_line, anchor_col
end

--- Toggle the word at one document position as a secondary selection
-- The first endpoint is the caret, so the caret remains at the word end
-- @param line number clicked line
-- @param col number clicked column
-- @return boolean changed true when the word selection was toggled
function TextSurface:toggle_word_selection(line, col)
	if not is_multi_cursor_enabled() then
		return false
	end

	local caret_line, caret_col, anchor_line, anchor_col = mouse_selection(
		self.doc, 2, line, col, line, col
	)
	local selections = self.doc:get_selections(true)

	for index = 1, #selections do
		local selection = selections[index]

		if selection[1] == anchor_line
			and selection[2] == anchor_col
			and selection[3] == caret_line
			and selection[4] == caret_col
		then
			self._selection_visual_rows[selection.id] = nil
			self._selection_x_offsets[selection.id] = nil

			if #selections > 1 then
				self.doc:remove_selection(selection.id)
			else
				self.doc:set_selection(caret_line, caret_col)
			end

			self:sync_cursor_column_from_selection()
			self.blink_timer = 0
			core.redraw = true

			return true
		end
	end

	local directed_selections, primary_index = self.doc:get_selections()

	if #directed_selections == 1 then
		local selection = directed_selections[1]
		local empty = selection[1] == selection[3] and selection[2] == selection[4]

		if empty then
			self.doc:set_selection(caret_line, caret_col, anchor_line, anchor_col)
			self._selection_visual_rows = {}
			self._selection_x_offsets = {}
			self:sync_cursor_column_from_selection()
			self.blink_timer = 0
			core.redraw = true

			return true
		end
	end

	for index = 1, #directed_selections do
		local selection = directed_selections[index]
		local selection_line, selection_col = selection[1], selection[2]
		local empty = selection_line == selection[3] and selection_col == selection[4]

		if empty
			and selection_line == anchor_line
			and selection_col >= anchor_col
			and selection_col <= caret_col
		then
			directed_selections[index] = {
				caret_line, caret_col, anchor_line, anchor_col,
				id = selection.id,
			}
			self.doc:set_selections(directed_selections, primary_index)
			self._selection_visual_rows[selection.id] = nil
			self._selection_x_offsets[selection.id] = nil
			self:sync_caret_column(caret_line, caret_col, nil, selection.id)
			self.blink_timer = 0
			core.redraw = true

			return true
		end
	end

	local id = self.doc:add_selection(caret_line, caret_col, anchor_line, anchor_col)

	if not id then
		self:sync_cursor_column_from_selection()
		self.blink_timer = 0
		core.redraw = true

		return true
	end

	local visual_row

	if self:is_wrap_enabled() then
		visual_row = self:get_visual_row_for_position(caret_line, caret_col)
	end

	self._selection_visual_rows[id] = visual_row
	self:sync_caret_column(caret_line, caret_col, visual_row, id)
	self.blink_timer = 0
	core.redraw = true

	return true
end

--- Build one stable key for matching a requested selection after normalization
-- @param selection table detached selection endpoints
-- @return string key endpoint key
local function selection_endpoint_key(selection)
	return table.concat({ selection[1], selection[2], selection[3], selection[4] }, ":")
end

--- Resolve one screen point to a displayed row and text-local x offset
-- @param view table text surface
-- @param x number absolute x coordinate
-- @param y number absolute y coordinate
-- @param allow_virtual boolean true to keep columns beyond line endings
-- @return number row displayed row
-- @return number local_x text-local x offset
-- @return number line logical line
-- @return number col logical column
local function resolve_box_selection_point(view, x, y, allow_virtual)
	local origin_x = view:get_line_screen_position(1)
	local line, col, visual_row = view:resolve_screen_position(x, y, allow_virtual)

	return visual_row or line, math.max(0, x - origin_x), line, col
end

--- Update column selections between one fixed corner and the current pointer
-- @param x number absolute x coordinate
-- @param y number absolute y coordinate
-- @return boolean updated true when selection state changed
function TextSurface:update_box_selection(x, y)
	if not is_multi_cursor_enabled() then
		self.box_selecting = nil

		return false
	end

	local box = self.box_selecting

	if not box then
		return false
	end

	local allow_virtual = box.allow_virtual
	local focus_row, focus_x, focus_line, focus_col = resolve_box_selection_point(
		self, x, y, allow_virtual
	)

	if focus_row == box.focus_row and focus_x == box.focus_x then
		return false
	end

	box.focus_row = focus_row
	box.focus_x = focus_x

	local first_row = math.min(box.anchor_row, focus_row)
	local last_row = math.max(box.anchor_row, focus_row)
	local wrapped = self:is_wrap_enabled()
	local selections = {}
	local visual_rows = {}

	for row = first_row, last_row do
		local head_line, head_col
		local anchor_line, anchor_col

		if wrapped then
			head_line, head_col = self:get_position_for_visual_row_x(row, focus_x, allow_virtual)
			anchor_line, anchor_col = self:get_position_for_visual_row_x(row, box.anchor_x, allow_virtual)
		else
			head_line = row
			head_col = self:get_x_offset_col(row, focus_x, allow_virtual)
			anchor_line = row
			anchor_col = self:get_x_offset_col(row, box.anchor_x, allow_virtual)
		end

		if allow_virtual or self.doc:get_logical_line(head_line) ~= "" then
			local selection = {
				head_line,
				head_col,
				anchor_line,
				anchor_col,
				virtual = allow_virtual,
			}
			selections[#selections + 1] = selection
			visual_rows[selection_endpoint_key(selection)] = row
		end
	end

	if #selections == 0 then
		self.doc:set_selection(focus_line, focus_col, nil, nil, nil, allow_virtual)
		self._selection_visual_rows = {}
		self._selection_x_offsets = {}
		self:sync_cursor_column_from_selection(wrapped and focus_row or nil)
		self:scroll_to_make_visible(focus_line, focus_col)
		self.blink_timer = 0
		core.redraw = true

		return true
	end

	local primary_index = focus_row < box.anchor_row and 1 or #selections
	self.doc:set_selections(selections, primary_index)
	self._selection_visual_rows = {}
	self._selection_x_offsets = {}

	local normalized = self.doc:get_selections()

	for index = 1, #normalized do
		local selection = normalized[index]
		local visual_row = visual_rows[selection_endpoint_key(selection)]

		if wrapped and not visual_row then
			visual_row = self:get_visual_row_for_position(selection[1], selection[2])
		end

		self:sync_caret_column(selection[1], selection[2], visual_row, selection.id)
	end

	local line, col = self.doc:get_selection()
	self:scroll_to_make_visible(line, col)
	self.blink_timer = 0
	core.redraw = true

	return true
end

--- Add or remove one secondary caret at a document position
-- @param line number caret line
-- @param col number caret column
-- @param visual_row number|nil wrapped visual row affinity
-- @return boolean changed true when the selection collection changed
function TextSurface:toggle_secondary_caret(line, col, visual_row)
	if not is_multi_cursor_enabled() then
		return false
	end

	local selections = self.doc:get_selections()

	for index = 1, #selections do
		local selection = selections[index]

		if
			selection[1] == line
			and selection[2] == col
			and selection[3] == line
			and selection[4] == col
		then
			if #selections <= 1 then
				return false
			end

			self._selection_visual_rows[selection.id] = nil
			self._selection_x_offsets[selection.id] = nil
			self.doc:remove_selection(selection.id)
			self.blink_timer = 0
			core.redraw = true

			return true
		end
	end

	local id = self.doc:add_selection(
		line, col, nil, nil, self.doc:is_virtual_space_enabled("enabled")
	)

	if not id then
		return false
	end

	self._selection_visual_rows[id] = visual_row
	self.blink_timer = 0
	core.redraw = true

	return true
end

--- Handle mouse press for lane handlers and text selection
-- @param button string mouse button id
-- @param x number absolute x coordinate
-- @param y number absolute y coordinate
-- @param clicks number click count
-- @return boolean|nil handled true when lane handler consumed input
function TextSurface:on_mouse_down(button, x, y, clicks)
	local side, id, handler = self:resolve_lane_input_handler_at(x, y)

	if handler and handler.on_mouse_down and handler.on_mouse_down(self, button, x, y, clicks) then
		self._active_lane_input = { side = side, id = id, handler = handler }
		self.mouse_selecting = nil
		return true
	end

	if is_multi_cursor_word_click(button) then
		local line, col = self:resolve_screen_position(x, y)
		self:toggle_word_selection(line, col)
		self.mouse_selecting = nil

		return true
	end

	local caught = TextSurface.super.on_mouse_down(self, button, x, y, clicks)
	if caught then
		return caught
	end

	if button == "middle" then
		return has_any_modifier()
	end

	local force_virtual_box = is_forced_virtual_box_selection_click(button)

	if (force_virtual_box or is_box_selection_click(button)) and clicks == 1 then
		local allow_virtual = self.doc.disable_virtual_space ~= true
			and (force_virtual_box or self.doc:is_virtual_space_enabled("box_selection"))
		local row, local_x, line, col = resolve_box_selection_point(
			self, x, y, allow_virtual
		)
		self.doc:set_selection(line, col, nil, nil, nil, allow_virtual)
		self._selection_visual_rows = {}
		self._selection_x_offsets = {}
		self:sync_cursor_column_from_selection(self:is_wrap_enabled() and row or nil)
		-- Keep one mode for whole drag even if settings reload mid-gesture
		self.box_selecting = {
			anchor_row = row,
			anchor_x = local_x,
			focus_row = row,
			focus_x = local_x,
			allow_virtual = allow_virtual,
		}
		self.mouse_selecting = nil
		self.blink_timer = 0

		return true
	end

	if is_multi_cursor_click(button) then
		if self.doc:has_any_selection() then
			local caret_line, caret_col = self.doc:get_selection()
			self.doc:set_selection(
				caret_line, caret_col, nil, nil, nil,
				self.doc:is_virtual_space_enabled("enabled")
			)
			self._selection_visual_rows = {}
			self._selection_x_offsets = {}
		end

		local line, col, visual_row = self:resolve_screen_position(x, y)
		self:toggle_secondary_caret(line, col, visual_row)
		self.mouse_selecting = nil

		return true
	end

	self._selection_visual_rows = {}
	self._selection_x_offsets = {}

	if keymap.modkeys["shift"] then
		-- Keep the existing anchor fixed while click count controls character, word, or line selection
		local anchor_line, anchor_col = select(3, self.doc:get_selection())
		local line, col, visual_row = self:resolve_screen_position(x, y)
		local caret_line, caret_col
		caret_line, caret_col, anchor_line, anchor_col = shift_mouse_selection(
			self.doc, clicks, line, col, anchor_line, anchor_col
		)
		self.doc:set_selection(
			caret_line, caret_col, anchor_line, anchor_col, nil,
			self.doc:is_virtual_space_enabled("enabled")
		)
		self:sync_cursor_column_from_selection(visual_row)
		self.mouse_selecting = { anchor_line, anchor_col, clicks = clicks, fixed_anchor = true }
	else
		local line, col, visual_row = self:resolve_screen_position(x, y)
		local caret_line, caret_col, anchor_line, anchor_col = mouse_selection(
			self.doc, clicks, line, col, line, col
		)
		self.doc:set_selection(
			caret_line, caret_col, anchor_line, anchor_col, nil,
			self.doc:is_virtual_space_enabled("enabled")
		)
		self:sync_cursor_column_from_selection(visual_row)
		self.mouse_selecting = { line, col, clicks = clicks }
	end
	self.blink_timer = 0
end

--- Apply wheel movement and keep both text axes inside their known ranges
-- @param ... any wheel event payload
function TextSurface:on_mouse_wheel(...)
	TextSurface.super.on_mouse_wheel(self, ...)
	self:clamp_scroll_position()
end

--- Handle mouse move for lane hover state and drag selection
-- @param x number absolute x coordinate
-- @param y number absolute y coordinate
-- @param ... any extra event payload
function TextSurface:on_mouse_move(x, y, ...)
	local active = self._active_lane_input
	if active and active.handler then
		if active.handler.on_mouse_move then
			active.handler.on_mouse_move(self, x, y, ...)
		end
		local mouse_cursor = active.handler.get_mouse_cursor and active.handler.get_mouse_cursor(self, x, y, ...)
		self.mouse_cursor = mouse_cursor or "arrow"
		return
	end

	local handled = TextSurface.super.on_mouse_move(self, x, y, ...)

	-- Core owns the drag cursor until shared document panning ends
	if self._mouse_pan then
		return handled
	end

	if self:scrollbar_overlaps_point(x, y) or self.dragging_scrollbar then
		self.mouse_cursor = "arrow"
	else
		self.mouse_cursor = "ibeam"
	end

	local side, id, hover_handler = self:resolve_lane_input_handler_at(x, y)
	local prev_hover = self._hover_lane_input
	local same_hover = prev_hover and hover_handler and prev_hover.side == side and prev_hover.id == id

	if prev_hover and not same_hover then
		if prev_hover.handler and prev_hover.handler.on_mouse_left then
			prev_hover.handler.on_mouse_left(self, x, y, ...)
		end
		self._hover_lane_input = nil
	end

	if hover_handler then
		if hover_handler.on_mouse_move then
			hover_handler.on_mouse_move(self, x, y, ...)
		end
		local mouse_cursor = hover_handler.get_mouse_cursor and hover_handler.get_mouse_cursor(self, x, y, ...)
		self.mouse_cursor = mouse_cursor or "arrow"
		self._hover_lane_input = { side = side, id = id, handler = hover_handler }
	end

	if self.mouse_selecting then
		local l1, c1, visual_row = self:resolve_screen_position(x, y)
		local l2, c2 = table.unpack(self.mouse_selecting)
		local clicks = self.mouse_selecting.clicks
		local select_range = self.mouse_selecting.fixed_anchor and shift_mouse_selection or mouse_selection
		local caret_line, caret_col, anchor_line, anchor_col = select_range(
			self.doc, clicks, l1, c1, l2, c2
		)
		self.doc:set_selection(
			caret_line, caret_col, anchor_line, anchor_col, nil,
			self.doc:is_virtual_space_enabled("enabled")
		)
		self:sync_cursor_column_from_selection(visual_row)
	elseif self.box_selecting then
		self:update_box_selection(x, y)
	end
end

--- Finish lane or text mouse interaction on mouse release
-- @param button string mouse button id
-- @param ... any extra event payload
function TextSurface:on_mouse_up(button, ...)
	local active = self._active_lane_input

	if active and active.handler then
		if active.handler.on_mouse_up then
			active.handler.on_mouse_up(self, button, ...)
		end
		self._active_lane_input = nil
	end

	TextSurface.super.on_mouse_up(self, button, ...)
	self.mouse_selecting = nil
	self.box_selecting = nil
end

--- Handle pointer leaving the view and clear hover/drag state
-- @param ... any extra event payload
function TextSurface:on_mouse_left(...)
	local active = self._active_lane_input

	if active and active.handler and active.handler.on_mouse_left then
		active.handler.on_mouse_left(self, ...)
	end
	self._active_lane_input = nil

	local hover = self._hover_lane_input

	if hover and hover.handler and hover.handler.on_mouse_left then
		hover.handler.on_mouse_left(self, ...)
	end
	self._hover_lane_input = nil
	self.mouse_selecting = nil
	self.box_selecting = nil

	TextSurface.super.on_mouse_left(self, ...)
end

--- Forward text input to the active document
-- @param text string typed text
function TextSurface:on_text_input(text)
	-- Committed text replaces the temporary composition shown at the caret
	self.composition_text = ""
	self.composition_start = 0
	self.composition_length = 0
	self.doc:text_input(text)
end

--- Update cursor visibility, blink timing, and transient line flash state
function TextSurface:update()
	-- Reserve or release the core horizontal lane before viewport-dependent work
	horizontal_scrollbar.update(self)

	local selection_count = self.doc:get_selection_count()

	if selection_count ~= self._selection_state_count then
		local live_ids = {}

		for index = 1, #self.doc.selections do
			live_ids[self.doc.selections[index].id] = true
		end

		for id in pairs(self._selection_visual_rows) do
			if not live_ids[id] then
				self._selection_visual_rows[id] = nil
			end
		end

		for id in pairs(self._selection_x_offsets) do
			if not live_ids[id] then
				self._selection_x_offsets[id] = nil
			end
		end

		self._selection_state_count = selection_count
	end

	if selection_count <= 1 and (next(self._selection_visual_rows) or next(self._selection_x_offsets)) then
		self._selection_visual_rows = {}
		self._selection_x_offsets = {}
	end

	if self:is_wrap_enabled() and self.size.x > 0 then
		self.scroll.to.x = 0
		self.scroll.x = 0
	end

	-- scroll to make cursor visible and reset blink timer if it moved
	local line, col = self.doc:get_selection()
	if (line ~= self.last_line or col ~= self.last_col) and self.size.x > 0 then
		if core.active_view == self then
			self:scroll_to_make_visible(line, col)
		end
		self.blink_timer = 0
		self.last_line, self.last_col = line, col
	end

	-- update blink timer
	if self == core.active_view and not self.mouse_selecting then
		if self:is_scrolling() then
			self.blink_timer = 0
		else
			local blink_period = get_blink_period()
			if blink_period <= 0 then
				self.blink_timer = 0
			else
				local n = blink_period / 2
				local prev = self.blink_timer
				self.blink_timer = (self.blink_timer + 1 / config.fps) % blink_period
				if (self.blink_timer > n) ~= (prev > n) then
					core.redraw = true
				end
			end
		end
	end

	-- Keep redrawing while a transient line flash is active
	if config.line_flash and self.doc and self.doc._line_flash then
		local flash = self.doc._line_flash
		local duration = math.max(0.01, config.line_flash_duration or config.outline_jump_flash_duration or 0.16)
		local age = system.get_time() - (flash.time or 0)
		if age < duration then
			core.redraw = true
		else
			self.doc._line_flash = nil
		end
	end

	TextSurface.super.update(self)
end

--- Draw background highlight for the current line
-- @param x number line origin x
-- @param y number line origin y
function TextSurface:draw_line_highlight(x, y)
	local lh = self:get_line_height()
	renderer.draw_rect(x, y, self.size.x, lh, style.line_highlight)
end

--- Draw plugin decorations beneath text for one visible logical line
-- @param _line number logical line index
-- @param _first_row number first visible visual row for the line
-- @param _last_row number last visible visual row for the line
-- @param _x number text origin x
-- @param _y number first visible row y
-- @param _line_first_row number|nil first visual row owned by the complete logical line
function TextSurface:draw_line_underlay(_line, _first_row, _last_row, _x, _y, _line_first_row)
end

--- Draw plugin decorations above text for one visible logical line
-- @param _line number logical line index
-- @param _first_row number first visible visual row for the line
-- @param _last_row number last visible visual row for the line
-- @param _x number text origin x
-- @param _y number first visible row y
function TextSurface:draw_line_overlay(_line, _first_row, _last_row, _x, _y)
end

--- Return ordered endpoints for one stored selection
-- @param selection table stored selection
-- @return number line1 ordered start line
-- @return number col1 ordered start column
-- @return number line2 ordered end line
-- @return number col2 ordered end column
local function get_stored_selection_bounds(selection)
	local line1 = selection.start.line
	local col1 = selection.start.col
	local line2 = selection.stop.line
	local col2 = selection.stop.col

	if line1 > line2 or line1 == line2 and col1 > col2 then
		return line2, col2, line1, col1
	end

	return line1, col1, line2, col2
end

--- Collect selection ranges that overlap one logical line
-- @param doc table text document
-- @param line number logical line
-- @param line_end_col number end column used for continuing selections
-- @return table|nil ranges ordered columns with selected line-break state
local function get_line_selection_ranges(doc, line, line_end_col)
	local ranges

	for index = 1, #doc.selections do
		local line1, col1, line2, col2 = get_stored_selection_bounds(doc.selections[index])

		if (line1 ~= line2 or col1 ~= col2) and line >= line1 and line <= line2 then
			local start_col = line == line1 and col1 or 1
			local end_col = line == line2 and col2 or line_end_col
			local text = doc.lines[line] or ""
			local includes_line_break = line < line2 and text:sub(-1) == "\n"

			if end_col > start_col or includes_line_break then
				ranges = ranges or {}
				ranges[#ranges + 1] = {
					start_col,
					end_col,
					includes_line_break = includes_line_break,
				}
			end
		end
	end

	return ranges
end

--- Return true when one horizontal run intersects any selection range
-- @param ranges table|nil line selection ranges with cached x positions
-- @param x1 number run start x
-- @param x2 number run end x
-- @return boolean intersects
local function selection_intersects_x(ranges, x1, x2)
	if not ranges then
		return false
	end

	for index = 1, #ranges do
		local range = ranges[index]

		if x2 > range.x1 and x1 < range.x2 then
			return true
		end
	end

	return false
end

--- Draw syntax highlighted text content for one line
-- @param idx number line index
-- @param x number line origin x
-- @param y number line origin y
-- @param selection_ranges table|nil precomputed selected column ranges
function TextSurface:draw_line_text(idx, x, y, selection_ranges)
	local tx, ty = x, y + self:get_line_text_y_offset()
	local font = self:get_font()
	local lh = self:get_line_height()
	local text = self.doc.lines[idx] or ""
	selection_ranges = selection_ranges or get_line_selection_ranges(self.doc, idx, #text + 1)

	if selection_ranges then
		for index = 1, #selection_ranges do
			local range = selection_ranges[index]
			range.x1 = x + self:get_col_x_offset(idx, range[1])
			range.x2 = x + self:get_col_x_offset(idx, range[2])
		end
	end

	-- Support syntax styles with optional background: { fg = ..., bg = ... }
	local normal_style = style.syntax.normal
	local default_fg = (type(normal_style) == "table" and (normal_style.fg or normal_style.bg)) and normal_style.fg
		or normal_style

	if self.line_text_color_override then
		default_fg = self.line_text_color_override
	end
	default_fg = default_fg or style.text

	--- Resolve foreground and background from a syntax style entry
	-- @param token_style table|string|nil token style descriptor
	-- @return table|string fg foreground color
	-- @return table|string|nil bg optional background color
	local function resolve_syntax_colors(token_style)
		if type(token_style) == "table" and (token_style.fg or token_style.bg) then
			return token_style.fg or default_fg, token_style.bg
		end
		return token_style or default_fg, nil
	end

	local function record_syntax_render_perf(key, delta)
		if config.debug_syntax_render_perf ~= true then
			return
		end

		if core.debug and type(core.debug.record_syntax_render_perf) == "function" then
			core.debug.record_syntax_render_perf(key, delta)
		end
	end

	--- Draw one contiguous text run with optional background
	-- @param run string text run
	-- @param fg table|string foreground color
	-- @param bg table|string|nil background color
	local function draw_text_run(run, fg, bg)
		if #run == 0 then
			return
		end
		local w = font:get_width(run)
		local draw_bg = bg and w > 0
		if draw_bg then
			local cx1 = tx
			local cx2 = tx + w

			if selection_intersects_x(selection_ranges, cx1, cx2) then
				draw_bg = false
			end
		end
		if draw_bg then
			renderer.draw_rect(tx, y, w, lh, bg)
		end
		record_syntax_render_perf("editor_text_draw_calls")
		tx = renderer.draw_text(font, run, tx, ty, fg)
	end

	-- For styles with explicit { fg, bg }, selected text should use normal fg
	--- Draw a token chunk and split around selected columns when needed
	-- @param chunk string token text chunk
	-- @param fg table|string foreground color
	-- @param bg table|string|nil background color
	-- @param chunk_col number one-based chunk start column
	-- @param use_selected_normal_fg boolean force default fg on selected text
	local function draw_chunk(chunk, fg, bg, chunk_col, use_selected_normal_fg)
		if #chunk == 0 then
			return
		end

		-- Document columns use byte positions, so selection splits use byte lengths
		local chunk_len = #chunk

		if use_selected_normal_fg and selection_ranges then
			local chunk_start = chunk_col
			local chunk_end = chunk_col + chunk_len
			local consumed = 0

			for index = 1, #selection_ranges do
				local range = selection_ranges[index]
				local selected_start = math.max(chunk_start, range[1])
				local selected_end = math.min(chunk_end, range[2])

				if selected_start < selected_end then
					local prefix_length = selected_start - chunk_start - consumed

					if prefix_length > 0 then
						draw_text_run(chunk:sub(consumed + 1, consumed + prefix_length), fg, bg)
					end

					local selected_length = selected_end - selected_start
					consumed = selected_start - chunk_start
					draw_text_run(
						chunk:sub(consumed + 1, consumed + selected_length),
						default_fg,
						bg
					)
					consumed = consumed + selected_length
				end
			end

			if consumed > 0 then
				if consumed < chunk_len then
					draw_text_run(chunk:sub(consumed + 1), fg, bg)
				end

				return
			end
		end

		draw_text_run(chunk, fg, bg)
	end

	-- Render only visible slice on very long lines to avoid huge per-frame draw cost
	local text_len = #text

	if text_len > (config.long_line_threshold or 5000) then
		local line_len = text_len
		if text:sub(-1) == "\n" then
			line_len = line_len - 1
		end
		local viewport_w = select(3, self:get_content_viewport_rect())
		local left_col = self:get_x_offset_col(idx, self.scroll.x)
		local right_col = self:get_x_offset_col(idx, self.scroll.x + viewport_w + font:get_width(" ") * 16)
		left_col = common.clamp(left_col, 1, line_len + 1)
		right_col = common.clamp(right_col, left_col, line_len + 1)
		local chunk = text:sub(left_col, right_col - 1)
		local x_draw = x + self:get_col_x_offset(idx, left_col)
		local fg, bg = resolve_syntax_colors(style.syntax.normal)
		if #chunk > 0 then
			local w = font:get_width(chunk)
			local draw_bg = bg and w > 0
			if draw_bg then
				local cx1 = x_draw
				local cx2 = x_draw + w
				if selection_intersects_x(selection_ranges, cx1, cx2) then
					draw_bg = false
				end
			end
			if draw_bg then
				renderer.draw_rect(x_draw, y, w, lh, bg)
			end
			renderer.draw_text(font, chunk, x_draw, ty, fg)
		end
		return
	end

	-- Render tabs on the same grid used by indentation editing and hit-testing
	local tab_size = get_indent_grid_tab_width(self.doc, font)
	local col = 1

	local token_iter
	local token_state
	local token_var

	if self.use_syntax_highlighting == false or not self.doc.highlighter then
		local emitted = false

		token_iter = function()
			if emitted then
				return nil
			end

			emitted = true
			return 1, "normal", text
		end
	else
		token_iter, token_state, token_var = self.doc.highlighter:each_token(idx)
	end

	for _, token_type, text in token_iter, token_state, token_var do
		record_syntax_render_perf("editor_tokens_emitted")
		local token_style = style.syntax[token_type]
		if not token_style and type(token_type) == "string" then
			-- Pattern breakdown:
			-- '^heading' requires heading prefix at token start
			-- '(%d+)' captures one or more digits as heading level
			-- '$' requires token to end after the digits
			local heading_level = token_type:match("^heading(%d+)$")
			if heading_level and style.resolve_heading then
				token_style = style.resolve_heading(tonumber(heading_level))
			end
		end
		local fg, bg = resolve_syntax_colors(token_style)
		local use_selected_normal_fg = type(token_style) == "table" and (token_style.fg or token_style.bg)
		local start = 1
		while true do
			local tab_idx = text:find("\t", start, true)
			if not tab_idx then
					local chunk = text:sub(start)
					draw_chunk(chunk, fg, bg, col, use_selected_normal_fg)
					col = col + #chunk
					break
				end
				local chunk = text:sub(start, tab_idx - 1)
				draw_chunk(chunk, fg, bg, col, use_selected_normal_fg)
				col = col + #chunk
			local advance = tab_size - (tx - x) % tab_size
			if bg and advance > 0 and not selection_intersects_x(selection_ranges, tx, tx + advance) then
				renderer.draw_rect(tx, y, advance, lh, bg)
			end
			tx = tx + advance
			col = col + 1
			start = tab_idx + 1
		end
	end
end

--- Draw line background, selections, text, overlays, and carets
-- @param idx number line index
-- @param x number line origin x
-- @param y number line origin y
function TextSurface:draw_line_body(idx, x, y)
	local line = self.doc:get_selection()
	local text = self.doc.lines[idx] or ""
	local selection_ranges = get_line_selection_ranges(self.doc, idx, #text + 1)

	-- Use only 'style.match = { fg = ..., bg = ... }'
	local match_style = style.match
	local match_fg = (type(match_style) == "table") and (match_style.fg or style.text) or style.text
	local match_bg = (type(match_style) == "table") and match_style.bg or nil

	-- Draw every selection that overlaps this line
	if selection_ranges then
		local lh = self:get_line_height()
		local line_break_width = self:get_font():get_width(" ")

		for index = 1, #selection_ranges do
			local range = selection_ranges[index]
			local x1 = x + self:get_col_x_offset(idx, range[1])
			local x2 = x + self:get_col_x_offset(idx, range[2])

			if x2 > x1 then
				renderer.draw_rect(x1, y, x2 - x1, lh, style.selection)
			end
			if range.includes_line_break then
				-- Give the selected line break a visible cell, including on empty lines
				renderer.draw_rect(x2, y, line_break_width, lh, style.selection)
			end
		end
	end

	-- Draw line highlight if cursor is on this line
	if config.highlight_current_line and not self.doc:has_any_selection() and line == idx and core.active_view == self then
		self:draw_line_highlight(x + self.scroll.x, y)
	end

	-- Draw fading transient line flash on the target line
	if config.line_flash and self.doc and self.doc._line_flash then
		local flash = self.doc._line_flash
		if flash.line == idx then
			local duration = math.max(0.01, config.line_flash_duration or config.outline_jump_flash_duration or 0.16)
			local age = system.get_time() - (flash.time or 0)
			if age < duration then
				local t = age / duration
				local peak = common.clamp(config.line_flash_opacity or config.outline_jump_flash_opacity or 0.35, 0, 1)
				local alpha = peak * (1 - t)
				local flash_base = flash.color
					or ((flash.kind == "error") and (style.error or style.notice))
					or style.notice
				local color = style.transparent(flash_base or style.line_highlight or style.selection, alpha)
				renderer.draw_rect(x + self.scroll.x, y, self.size.x, self:get_line_height(), color)
			end
		end
	end

	-- Gridlock EOF marker drawn after last line in draw(), not here

	-- Draw active match background under text
	local ms = self.doc.match_selection
	if ms and match_bg then
		local mline1, mcol1, mline2, mcol2 = ms[1], ms[2], ms[3], ms[4]
		if idx >= mline1 and idx <= mline2 then
			local text = self.doc.lines[idx]
			local line_len = #text
			if text:sub(-1) == "\n" then
				line_len = line_len - 1
			end
			local start_col = (idx == mline1) and mcol1 or 1
			local end_col = (idx == mline2) and mcol2 or (line_len + 1)
			start_col = common.clamp(start_col, 1, line_len + 1)
			end_col = common.clamp(end_col, 1, line_len + 1)
			if end_col > start_col then
				local x1 = x + self:get_col_x_offset(idx, start_col)
				local x2 = x + self:get_col_x_offset(idx, end_col)
				local lh = self:get_line_height()
				local pad = math.max(1, math.ceil(SCALE))
				renderer.draw_rect(x1 - pad, y, (x2 - x1) + pad * 2, lh, match_bg)
			end
		end
	end

	self:draw_line_underlay(idx, idx, idx, x, y)

	-- Draw line's text
	self:draw_line_text(idx, x, y, selection_ranges)

	-- Draw find match text overlay when the active selection is a match
	if ms and match_fg then
		local line1, col1, line2, col2 = ms[1], ms[2], ms[3], ms[4]

		if line1 == line2 and line2 == idx then
			local x1 = x + self:get_col_x_offset(idx, col1)
			local y1 = y + self:get_line_text_y_offset()
			local text = self.doc.lines[idx]
			local line_len = #text

			if text:sub(-1) == "\n" then
				line_len = line_len - 1
			end
			local end_col = common.clamp(col2, 1, line_len + 1)
			local chunk = text:sub(col1, end_col - 1)

			if #chunk > 0 then
				renderer.draw_text(self:get_font(), chunk, x1, y1, match_fg)
			end
		elseif idx >= line1 and idx <= line2 then
			local text = self.doc.lines[idx]
			local line_len = #text

			if text:sub(-1) == "\n" then
				line_len = line_len - 1
			end
			local start_col = (idx == line1) and col1 or 1
			local end_col = (idx == line2) and col2 or (line_len + 1)

			start_col = common.clamp(start_col, 1, line_len + 1)
			end_col = common.clamp(end_col, 1, line_len + 1)

			if end_col > start_col then
				local x1 = x + self:get_col_x_offset(idx, start_col)
				local y1 = y + self:get_line_text_y_offset()
				local chunk = text:sub(start_col, end_col - 1)
				if #chunk > 0 then
					renderer.draw_text(self:get_font(), chunk, x1, y1, match_fg)
				end
			end
		end
	end

	self:draw_line_overlay(idx, idx, idx, x, y)

	-- Draw every caret that overlaps this line
	if should_draw_carets(self) then
		local lh = self:get_line_height()

		for selection_index = 1, #self.doc.selections do
			local selection = self.doc.selections[selection_index]
			local caret = get_visible_caret(selection)

			if caret.line == idx then
				local x1 = x + self:get_col_x_offset(caret.line, caret.col)
				local color = get_caret_color(self, selection)
				renderer.draw_rect(x1, y, style.cursor_width, lh, color)
			end
		end
	end
end

--- Draw line number gutter entry for one line
-- @param idx number line index
-- @param x number gutter origin x
-- @param y number gutter origin y
function TextSurface:draw_line_gutter(idx, x, y)
	if not self:are_line_numbers_visible() then
		return
	end

	local color = style.line_number
	local selection_ranges = get_line_selection_ranges(self.doc, idx, self:get_line_end_col(idx))
	local emphasized = selection_ranges ~= nil

	-- Keep every rendered caret line visible even when its selection is empty
	if not emphasized then
		for selection_index = 1, #self.doc.selections do
			if get_visible_caret(self.doc.selections[selection_index]).line == idx then
				emphasized = true
				break
			end
		end
	end

	if emphasized then
		color = style.line_number2
	end
	local yoffset = self:get_line_text_y_offset()
	local font = self:get_font()
	local line_number = tostring(idx)

	-- Keep every line number against the same padded right edge
	x = x + self:get_gutter_width() - style.padding.x - font:get_width(line_number)
	renderer.draw_text(font, line_number, x, y + yoffset, color)
end

--- Measure one position from its wrapped-row start so long-line shortcuts cannot shift later rows
-- @param self table text surface
-- @param row_info table wrapped-row measurements
-- @param col number logical byte column
-- @return number x_offset measured width from the wrapped-row start
local function get_wrapped_row_content_x(self, row_info, col)
	local text = self.doc.lines[row_info.line] or ""
	local font = self:get_font()
	local tab_width = get_indent_grid_tab_width(self.doc, font)
	local line_end_col = self:get_line_end_col(row_info.line)
	local physical_col = math.min(col, line_end_col)
	local absolute_x = row_info.start_x
	local index = row_info.start_col

	while index < physical_col do
		local tab_col = text:find("\t", index, true)
		local run_end = math.min(physical_col - 1, (tab_col or physical_col) - 1)

		if run_end >= index then
			absolute_x = absolute_x + font:get_width(text:sub(index, run_end))
			index = run_end + 1
		end

		if tab_col and tab_col < physical_col then
			absolute_x = absolute_x + (tab_width - (absolute_x % tab_width))
			index = tab_col + 1
		end
	end

	absolute_x = absolute_x + math.max(0, col - line_end_col) * font:get_width(" ")

	return absolute_x - row_info.start_x
end

--- Return wrapped-row-local x offset for one logical position
-- @param self table text surface
-- @param row_info table wrapped-row measurements
-- @param col number logical byte column
-- @param line_end boolean|nil preserve the shared wrapped-row boundary
-- @return number x_offset horizontal position inside the wrapped row
local function get_wrapped_row_x(self, row_info, col, line_end)
	-- Wrapped positions only need the short visible row prefix, never the whole logical-line prefix
	return row_info.indent_x + get_wrapped_row_content_x(self, row_info, col)
end

--- Return the screen position shared by caret drawing and native IME windows
-- @param line number document line
-- @param col number byte column
-- @param preferred_visual_row number|nil wrapped row retained at a shared boundary
-- @return number x caret left edge
-- @return number y visual row top edge
function TextSurface:get_caret_screen_position(line, col, preferred_visual_row)
	if self:is_wrap_enabled() then
		local row, row_info = self:get_cursor_visual_row(line, col, preferred_visual_row)
		local content_x, content_y = self:get_content_offset()
		local x = content_x + self:get_text_x_offset() + get_wrapped_row_x(self, row_info, col)
		local y = content_y + (row - 1) * self:get_line_height() + style.padding.y

		return x, y
	end

	local x, y = self:get_line_screen_position(line)

	return x + self:get_col_x_offset(line, col), y
end

--- Draw unfinished native text at the primary caret using the normal fallback font group
function TextSurface:draw_composition()
	local text = self.composition_text

	if not text or text == "" or core.active_view ~= self then
		return
	end

	local line, col = self.doc:get_selection()
	local selection = self.doc.selection
	local preferred_row = selection and self._selection_visual_rows[selection.id] or nil
	local x, y = self:get_caret_screen_position(line, col, preferred_row)

	local font = self:get_font()
	local text_y = y + self:get_line_text_y_offset()
	-- Keep unfinished text visually identical to text after it is committed
	renderer.draw_text(font, text, x, text_y, style.text)
end

-- Return one row-local overlap range for a logical selection on the same line
local function get_wrapped_row_overlap(row_info, line, col1, col2, line_end_col)
	if row_info.line ~= line then
		return nil, nil
	end

	local start_col = math.max(col1, row_info.start_col)
	local row_end_col = row_info.end_col == line_end_col
		and math.max(row_info.end_col, col2)
		or row_info.end_col
	local end_col = math.min(col2, row_end_col)

	if end_col <= start_col then
		return nil, nil
	end

	return start_col, end_col
end

-- Draw one plain text piece on a wrapped row while keeping tab stops stable
local function draw_wrapped_piece(self, row_info, text, start_col, x, y, fg, bg, initial_x)
	if #text == 0 then
		return initial_x
	end

	local font = self:get_font()
	local tx = initial_x or x + get_wrapped_row_x(self, row_info, start_col)
	local lh = self:get_line_height()
	local ty = y + self:get_line_text_y_offset()
	local logical_col = start_col
	local start_index = 1

	while true do
		local tab_index = text:find("\t", start_index, true)
		local run = tab_index and text:sub(start_index, tab_index - 1) or text:sub(start_index)

		if #run > 0 then
			if bg then
				-- Plain text needs no separate measurement because draw_text returns its end position
				local run_width = font:get_width(run)

				if run_width > 0 then
					renderer.draw_rect(tx, y, run_width, lh, bg)
				end
			end

			tx = renderer.draw_text(font, run, tx, ty, fg)
			logical_col = logical_col + #run
		end

		if not tab_index then
			break
		end

		local tab_x1 = x + get_wrapped_row_x(self, row_info, logical_col)
		local tab_x2 = x + get_wrapped_row_x(self, row_info, logical_col + 1, true)

		if bg and tab_x2 > tab_x1 then
			renderer.draw_rect(tab_x1, y, tab_x2 - tab_x1, lh, bg)
		end

		tx = tab_x2
		logical_col = logical_col + 1
		start_index = tab_index + 1
	end

	return tx
end

--- Draw one wrapped token piece with selection-aware foreground fallback
local function draw_wrapped_piece_with_selection(
	self,
	row_info,
	text,
	start_col,
	x,
	y,
	fg,
	bg,
	selection_ranges,
	default_fg,
	use_selected_normal_fg,
	initial_x
)
	if #text == 0 then
		return initial_x
	end

	if not (use_selected_normal_fg and selection_ranges) then
		return draw_wrapped_piece(self, row_info, text, start_col, x, y, fg, bg, initial_x)
	end

	local chunk_start = start_col
	local chunk_end = start_col + #text
	local consumed = 0
	local next_x = initial_x

	for index = 1, #selection_ranges do
		local range = selection_ranges[index]
		local selected_start = math.max(chunk_start, range[1])
		local selected_end = math.min(chunk_end, range[2])

		if selected_start < selected_end then
			local prefix_length = selected_start - chunk_start - consumed

			if prefix_length > 0 then
				next_x = draw_wrapped_piece(
					self,
					row_info,
					text:sub(consumed + 1, consumed + prefix_length),
					chunk_start + consumed,
					x,
					y,
					fg,
					bg,
					next_x
				)
			end

			local selected_length = selected_end - selected_start
			consumed = selected_start - chunk_start
			next_x = draw_wrapped_piece(
				self,
				row_info,
				text:sub(consumed + 1, consumed + selected_length),
				selected_start,
				x,
				y,
				default_fg,
				nil,
				next_x
			)
			consumed = consumed + selected_length
		end
	end

	if consumed == 0 then
		next_x = draw_wrapped_piece(self, row_info, text, start_col, x, y, fg, bg, next_x)
	elseif consumed < #text then
		next_x = draw_wrapped_piece(
			self,
			row_info,
			text:sub(consumed + 1),
			chunk_start + consumed,
			x,
			y,
			fg,
			bg,
			next_x
		)
	end

	return next_x
end

-- Draw the wrapped gutter rows for one logical line
function TextSurface:draw_wrapped_line_gutter(line, first_row, last_row, x, y)
	local line_first_row = self:get_visual_rows_for_line(line)

	for row = first_row, last_row do
		if row == line_first_row then
			self:draw_line_gutter(line, x, y)
		end

		y = y + self:get_line_height()
	end
end

-- Return one wrapped-row info table through the shared lazy wrap API
local function get_wrapped_visual_row_info(view, row)
	return view:get_visual_row_info(row)
end

--- Resolve one cached syntax-owned full-row background color
-- @param line number logical document line
-- @return table|nil color resolved background color
function TextSurface:get_syntax_line_background(line)
	if self.use_syntax_highlighting == false or not self.doc.highlighter then
		return nil
	end

	local role = self.doc.highlighter:get_line_bg(line)
	local role_style = type(role) == "string" and style.syntax and style.syntax[role] or nil

	if type(role_style) == "table" and role_style.bg then
		return role_style.bg
	end
	if type(role_style) == "table" and type(role_style[1]) == "number" then
		return role_style
	end

	return nil
end

--- Draw joined visible row backgrounds from cached syntax metadata
-- Adjacent rows sharing one color become one renderer rectangle
-- @param first_row number first logical line or visual row
-- @param last_row number last logical line or visual row
-- @param x number visible text area left edge
-- @param y number first row top edge
-- @param width number visible text area width
-- @param line_height number visual row height
-- @param wrapped boolean true when row numbers belong to wrapped layout
function TextSurface:draw_syntax_row_backgrounds(first_row, last_row, x, y, width, line_height, wrapped)
	-- The global switch avoids every row lookup and rectangle when disabled
	if config.syntax_line_backgrounds == false then
		return
	end

	local run_color
	local run_first_row = first_row

	for row = first_row, last_row + 1 do
		local color
		if row <= last_row then
			local line
			if wrapped then
				local row_info = get_wrapped_visual_row_info(self, row)
				line = row_info and row_info.line or nil
			else
				line = row
			end
			if line then
				color = self:get_syntax_line_background(line)
			end
		end

		if color ~= run_color then
			if run_color then
				local run_y = y + (run_first_row - first_row) * line_height
				renderer.draw_rect(x, run_y, width, (row - run_first_row) * line_height, run_color)
			end
			run_color = color
			run_first_row = row
		end
	end
end

--- Draw wrapped selections, highlights, text, overlays, and carets for one logical line
-- @param line number logical line index
-- @param first_row number first visible wrapped row
-- @param last_row number last visible wrapped row
-- @param x number text origin x
-- @param y number first visible row y
function TextSurface:draw_wrapped_line_body(line, first_row, last_row, x, y)
	local body_start_time = config.text_wrap_profile_enabled == true and system.get_time() or nil
	local layout = self:ensure_wrap_layout()
	local font = self:get_font()
	local lh = self:get_line_height()
	local text = self.doc.lines[line] or ""
	local caret_line = self.doc:get_selection()
	local selection_ranges = get_line_selection_ranges(self.doc, line, self:get_line_end_col(line))
	local line_break_width = selection_ranges and font:get_width(" ") or 0
	local line_first_row, line_last_row = self:get_visual_rows_for_line(line)
	local default_style = style.syntax.normal
	local default_fg = (type(default_style) == "table" and (default_style.fg or default_style.bg)) and default_style.fg or default_style

	if self.line_text_color_override then
		default_fg = self.line_text_color_override
	end
	default_fg = default_fg or style.text
	local setup_finish_time = body_start_time and system.get_time() or nil

	for row = first_row, last_row do
		local row_info = get_wrapped_visual_row_info(self, row)
		local row_y = y + (row - first_row) * lh

		if selection_ranges then
			for index = 1, #selection_ranges do
				local range = selection_ranges[index]
				local overlap_start, overlap_end = get_wrapped_row_overlap(
					row_info, line, range[1], range[2], self:get_line_end_col(line)
				)

				if overlap_start and overlap_end then
					local x1 = x + get_wrapped_row_x(self, row_info, overlap_start)
					local x2 = x + get_wrapped_row_x(self, row_info, overlap_end, true)
					renderer.draw_rect(x1, row_y, x2 - x1, lh, style.selection)
				end
				if range.includes_line_break and row == line_last_row then
					-- The line break belongs after the final wrapped row, even when that row is empty
					local line_end_x = x + get_wrapped_row_x(self, row_info, self:get_line_end_col(line), true)
					renderer.draw_rect(line_end_x, row_y, line_break_width, lh, style.selection)
				end
			end
		end

		if config.highlight_current_line and not self.doc:has_any_selection() and caret_line == line and core.active_view == self then
			self:draw_line_highlight(x + self.scroll.x, row_y)
		end

		if config.line_flash and self.doc and self.doc._line_flash and self.doc._line_flash.line == line then
			local flash = self.doc._line_flash
			local duration = math.max(0.01, config.line_flash_duration or config.outline_jump_flash_duration or 0.16)
			local age = system.get_time() - (flash.time or 0)

			if age < duration then
				local peak = common.clamp(config.line_flash_opacity or config.outline_jump_flash_opacity or 0.35, 0, 1)
				local alpha = peak * (1 - (age / duration))
				local flash_base = flash.color or ((flash.kind == "error") and (style.error or style.notice)) or style.notice
				local color = style.transparent(flash_base or style.line_highlight or style.selection, alpha)

				renderer.draw_rect(x + self.scroll.x, row_y, self.size.x, lh, color)
			end
		end
	end

	local match_style = style.match
	local match_fg = (type(match_style) == "table") and (match_style.fg or style.text) or style.text
	local match_bg = (type(match_style) == "table") and match_style.bg or nil
	local match_selection = self.doc.match_selection

	if match_selection and match_bg then
		local match_line1, match_col1, match_line2, match_col2 = match_selection[1], match_selection[2], match_selection[3], match_selection[4]

		if line >= match_line1 and line <= match_line2 then
			local line_match_col1 = (line == match_line1) and match_col1 or 1
			local line_match_col2 = (line == match_line2) and match_col2 or self:get_line_end_col(line)

			for row_index = first_row, last_row do
				local row_info_local = get_wrapped_visual_row_info(self, row_index)
				local overlap_start, overlap_end = get_wrapped_row_overlap(
					row_info_local, line, line_match_col1, line_match_col2, self:get_line_end_col(line)
				)

				if overlap_start and overlap_end then
					local row_y = y + (row_index - first_row) * lh
					local x1 = x + get_wrapped_row_x(self, row_info_local, overlap_start)
					local x2 = x + get_wrapped_row_x(self, row_info_local, overlap_end, true)
					local pad = math.max(1, math.ceil(SCALE))

					renderer.draw_rect(x1 - pad, row_y, (x2 - x1) + pad * 2, lh, match_bg)
				end
			end
		end
	end

	self:draw_line_underlay(line, first_row, last_row, x, y, line_first_row)
	local decorations_finish_time = body_start_time and system.get_time() or nil

	local token_iter
	local token_state
	local token_var

	if self.use_syntax_highlighting == false or not self.doc.highlighter then
		local emitted = false

		token_iter = function()
			if emitted then
				return nil
			end

			emitted = true
			return 1, "normal", text
		end
	else
		token_iter, token_state, token_var = self.doc.highlighter:each_token(line)
	end

	local row = first_row
	local row_info = get_wrapped_visual_row_info(self, row)
	local row_text_x = row_info and x + row_info.indent_x or nil

	local current_col = 1
	local token_index = 0
	local visible_piece_count = 0
	local visible_piece_bytes = 0
	local row_advance_count = 0

	for _, token_type, token_text in token_iter, token_state, token_var do
		token_index = token_index + 1
		local token_style = style.syntax[token_type]
		local fg = token_style
		local bg = nil

		if type(token_style) == "table" and (token_style.fg or token_style.bg) then
			fg = token_style.fg or default_fg
			bg = token_style.bg
		end

		fg = fg or default_fg

		local use_selected_normal_fg = type(token_style) == "table" and (token_style.fg or token_style.bg)
		local token_start = current_col
		local token_end = current_col + #token_text

		while row_info and token_start < token_end do
			if token_end <= row_info.start_col then
				break
			end

			if token_start >= row_info.end_col then
				row = row + 1
				row_advance_count = row_advance_count + 1
				row_info = (row <= last_row) and get_wrapped_visual_row_info(self, row) or nil
				row_text_x = row_info and x + row_info.indent_x or nil
			else
				local overlap_start = math.max(token_start, row_info.start_col)
				local overlap_end = math.min(token_end, row_info.end_col)
				local piece = token_text:sub(overlap_start - token_start + 1, overlap_end - token_start)
				local row_y = y + (row - first_row) * lh
				visible_piece_count = visible_piece_count + 1
				visible_piece_bytes = visible_piece_bytes + #piece

				row_text_x = draw_wrapped_piece_with_selection(
					self,
					row_info,
					piece,
					overlap_start,
					x,
					row_y,
					fg,
					bg,
					selection_ranges,
					default_fg,
					use_selected_normal_fg,
					row_text_x
				)

				if overlap_end >= row_info.end_col then
					row = row + 1
					row_advance_count = row_advance_count + 1
					row_info = (row <= last_row) and get_wrapped_visual_row_info(self, row) or nil
					row_text_x = row_info and x + row_info.indent_x or nil
				else
					break
				end
			end
		end

		current_col = token_end
	end
	local text_finish_time = body_start_time and system.get_time() or nil

	if match_selection and match_fg then
		local match_line1, match_col1, match_line2, match_col2 = match_selection[1], match_selection[2], match_selection[3], match_selection[4]

		if line >= match_line1 and line <= match_line2 then
			local line_match_col1 = (line == match_line1) and match_col1 or 1
			local line_match_col2 = (line == match_line2) and match_col2 or self:get_line_end_col(line)

			for row_index = first_row, last_row do
				local row_info_local = get_wrapped_visual_row_info(self, row_index)
				local overlap_start, overlap_end = get_wrapped_row_overlap(
					row_info_local, line, line_match_col1, line_match_col2, self:get_line_end_col(line)
				)

				if overlap_start and overlap_end then
					local row_y = y + (row_index - first_row) * lh
					local chunk = text:sub(overlap_start, overlap_end - 1)

					draw_wrapped_piece(self, row_info_local, chunk, overlap_start, x, row_y, match_fg, nil)
				end
			end
		end
	end

	self:draw_line_overlay(line, first_row, last_row, x, y)

	if should_draw_carets(self) then
		for selection_index = 1, #self.doc.selections do
			local selection = self.doc.selections[selection_index]
			local caret = get_visible_caret(selection)

			if caret.line == line then
				local preferred_row = self._selection_visual_rows[selection.id]
				local caret_row, row_info = self:get_cursor_visual_row(caret.line, caret.col, preferred_row)

				if row_info and caret_row >= first_row and caret_row <= last_row then
					local row_y = y + (caret_row - first_row) * lh
					local x1 = x + get_wrapped_row_x(self, row_info, caret.col)
					local color = get_caret_color(self, selection)

					renderer.draw_rect(x1, row_y, style.cursor_width, lh, color)
				end
			end
		end
	end

	if body_start_time then
		local body_finish_time = system.get_time()

		wrap.record_body_profile(
			self,
			(setup_finish_time - body_start_time) * 1000,
			(decorations_finish_time - setup_finish_time) * 1000,
			(text_finish_time - decorations_finish_time) * 1000,
			(body_finish_time - text_finish_time) * 1000,
			token_index,
			visible_piece_count,
			visible_piece_bytes,
			row_advance_count
		)
	end
end

--- Draw the wrapped text surface using cached visual rows
function TextSurface:draw_wrapped()
	local draw_start_time = (config.text_wrap_profile_enabled == true) and system.get_time() or nil

	self:draw_background(style.background)

	local font = self:get_font()
	local layout = self:ensure_wrap_layout()

	-- Keep renderer tab width aligned with the shared indentation grid
	font:set_tab_size(get_indent_grid_tab_width(self.doc, font))

	local lh = self:get_line_height()
	local _, _, viewport_w, viewport_h = self:get_content_viewport_rect()
	local minrow, maxrow = self:get_visible_visual_row_range()
	-- Keep the padded preceding row alive until renderer clipping fully hides it
	local paint_minrow = math.max(1, minrow - 1)
	local content_x, content_y = self:get_content_offset()
	local gutter_width = self:get_gutter_width()
	local text_offset_x = self:get_text_x_offset()
	local gutter_y = content_y + (paint_minrow - 1) * lh + style.padding.y
	local text_x = content_x + text_offset_x
	local text_y = gutter_y
	local position = self.position
	local text_width = math.max(1, viewport_w - text_offset_x)

	if gutter_width > 0 then
		core.push_clip_rect(position.x, position.y, gutter_width, viewport_h)

		local gutter_cursor_y = gutter_y
		for row = paint_minrow, maxrow do
			local row_info = get_wrapped_visual_row_info(self, row)
			self:draw_wrapped_line_gutter(row_info.line, row, row, position.x, gutter_cursor_y)
			gutter_cursor_y = gutter_cursor_y + lh
		end

		core.pop_clip_rect()
	end

	core.push_clip_rect(position.x + text_offset_x, position.y, text_width, viewport_h)
	self:draw_syntax_row_backgrounds(
		paint_minrow,
		maxrow,
		position.x + text_offset_x,
		text_y,
		text_width,
		lh,
		true
	)

	local row = paint_minrow
	local drawn_line_count = 0
	while row <= maxrow do
		local resolved_row, row_info = self:resolve_visual_row(row)

		if not row_info then
			break
		end
		if resolved_row < row then
			-- Lazy exactification moved this row behind the drawing cursor, so retry with fresh bounds next frame
			core.redraw = true
			break
		end

		local line = row_info.line
		local line_first_row, line_last_row = self:get_visual_rows_for_line(line)
		local visible_first_row = math.max(line_first_row, resolved_row)
		local visible_last_row = math.min(line_last_row, maxrow)
		local line_y = text_y + (visible_first_row - paint_minrow) * lh

		self:draw_wrapped_line_body(line, visible_first_row, visible_last_row, text_x, line_y)
		drawn_line_count = drawn_line_count + 1

		row = visible_last_row + 1
	end

	local char_width = font:get_width("n")
	if char_width > 0 then
		local ruler_width = math.max(1, math.ceil(SCALE * 1))
		local ruler_color = style.guide or style.selection
		local rulers = config.rulers

		if type(rulers) == "table" and #rulers > 0 then
			for index = 1, #rulers do
				local column = tonumber(rulers[index])

				if column and column > 0 then
					local ruler_x = text_x + char_width * column
					renderer.draw_rect(ruler_x, position.y, ruler_width, viewport_h, ruler_color)
				end
			end
		end
	end

	self:draw_composition()

	core.pop_clip_rect()

	self:draw_scrollbar()
	self:draw_debug_lanes()
	horizontal_scrollbar.draw(self)

	if draw_start_time then
		local drawn_row_count = math.max(0, maxrow - paint_minrow + 1)
		local elapsed_ms = (system.get_time() - draw_start_time) * 1000

		wrap.record_draw_profile(self, elapsed_ms, drawn_row_count, drawn_line_count)
	end
end

--- Draw the full document view, including overlays and scrollbars
function TextSurface:draw()
	if self:is_wrap_enabled() then
		return self:draw_wrapped()
	end

	self:draw_background(style.background)

	local font = self:get_font()
	-- Keep renderer tab width aligned with the shared indentation grid
	font:set_tab_size(get_indent_grid_tab_width(self.doc, font))

	local minline, maxline = self:get_visible_line_range()
	local lh = self:get_line_height()
	local _, _, viewport_w, viewport_h = self:get_content_viewport_rect()

	local _, y = self:get_line_screen_position(minline)
	local x = self.position.x

	local gutter_width = self:get_gutter_width()
	if gutter_width > 0 then
		core.push_clip_rect(x, self.position.y, gutter_width, viewport_h)

		for i = minline, maxline do
			self:draw_line_gutter(i, x, y)
			y = y + lh
		end
		core.pop_clip_rect()
	end

	local x, y = self:get_line_screen_position(minline)
	local text_offset_x = self:get_text_x_offset()
	local pos = self.position
	local text_w = math.max(1, viewport_w - text_offset_x)

	core.push_clip_rect(pos.x + text_offset_x, pos.y, text_w, viewport_h)
	self:draw_syntax_row_backgrounds(
		minline,
		maxline,
		pos.x + text_offset_x,
		y,
		text_w,
		lh,
		false
	)

	for i = minline, maxline do
		self:draw_line_body(i, x, y)
		y = y + lh
	end
	-- Draw configured vertical editor rulers in the text area
	local ch_w = font:get_width("n")
	if ch_w > 0 then
		local base_x = self:get_line_screen_position(1)
		local rw = math.max(1, math.ceil(SCALE * 1))
		local ry = self.position.y
		local rh = viewport_h
		local color = style.guide or style.selection
		local rulers = config.rulers

		-- Nil/empty rulers list disables ruler drawing
			if type(rulers) == "table" and #rulers > 0 then
				for i = 1, #rulers do
					local col = tonumber(rulers[i])

					if col and col > 0 then
						local rx = base_x + ch_w * col
						renderer.draw_rect(rx, ry, rw, rh, color)
					end
				end
		end
	end

	self:draw_composition()
	core.pop_clip_rect()

	self:draw_scrollbar()
	self:draw_debug_lanes()
	horizontal_scrollbar.draw(self)
end

return TextSurface
