-- 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 doc_monitor = require("core.doc.monitor")
local keymap = require("core.keymap")
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

--- 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
-- @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)
	local xo = dv.last_x_offset
	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)
	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
-- @return number line target line candidate
-- @return number col mapped target column candidate
local function move_to_line_offset(dv, line, col, offset)
	local target_line = line + offset
	return move_to_target_line_offset(dv, line, col, target_line)
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
-- @return number line stored line
-- @return number col stored column
local function sync_last_x_offset(dv, line, col)
	local xo = dv.last_x_offset
	xo.line = line
	xo.col = col
	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

TextSurface.translate = {
	--- Move cursor one visible page up
	["previous_page"] = function(doc, line, col, dv)
		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 current_row = dv:get_visual_row_for_position(line, col)
			local target_row = common.clamp(current_row - visible_rows, 1, dv:get_visual_row_count())
			local raw_x = dv.last_x_offset.offset

			if type(raw_x) ~= "number" then
				raw_x = dv:get_col_x_offset(line, col)
			end

			local target_line, target_col = dv:get_position_for_visual_row_x(target_row, raw_x)
			sync_last_x_offset(dv, target_line, target_col)
			dv.last_x_offset.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)
		target_line, target_col = doc:sanitize_position(target_line, target_col)
		sync_last_x_offset(dv, target_line, target_col)
		return target_line, target_col
	end,

	--- Move cursor one visible page down
	["next_page"] = function(doc, line, col, dv)
		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 current_row = dv:get_visual_row_for_position(line, col)
			local target_row = common.clamp(current_row + visible_rows, 1, dv:get_visual_row_count())
			local raw_x = dv.last_x_offset.offset

			if type(raw_x) ~= "number" then
				raw_x = dv:get_col_x_offset(line, col)
			end

			local target_line, target_col = dv:get_position_for_visual_row_x(target_row, raw_x)
			sync_last_x_offset(dv, target_line, target_col)
			dv.last_x_offset.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)
		target_line, target_col = doc:sanitize_position(target_line, target_col)
		sync_last_x_offset(dv, target_line, target_col)
		return target_line, target_col
	end,

	--- Move cursor up by one line
	["previous_line"] = function(doc, line, col, dv)
		if dv:is_wrap_enabled() then
			local current_row = dv:get_visual_row_for_position(line, col)
			local target_row = math.max(1, current_row - 1)
			local raw_x = dv.last_x_offset.offset

			if type(raw_x) ~= "number" then
				raw_x = dv:get_col_x_offset(line, col)
			end

			local target_line, target_col = dv:get_position_for_visual_row_x(target_row, raw_x)
			sync_last_x_offset(dv, target_line, target_col)
			dv.last_x_offset.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)
			return 1, 1
		end
		local target_line, target_col = move_to_line_offset(dv, line, col, -1)
		target_line, target_col = doc:sanitize_position(target_line, target_col)
		sync_last_x_offset(dv, target_line, target_col)
		return target_line, target_col
	end,

	--- Move cursor down by one line
	["next_line"] = function(doc, line, col, dv)
		if dv:is_wrap_enabled() then
			local current_row = dv:get_visual_row_for_position(line, col)
			local target_row = math.min(dv:get_visual_row_count(), current_row + 1)
			local raw_x = dv.last_x_offset.offset

			if type(raw_x) ~= "number" then
				raw_x = dv:get_col_x_offset(line, col)
			end

			local target_line, target_col = dv:get_position_for_visual_row_x(target_row, raw_x)
			sync_last_x_offset(dv, target_line, target_col)
			dv.last_x_offset.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)
			return #doc.lines, math.huge
		end
		local target_line, target_col = move_to_line_offset(dv, line, col, 1)
		target_line, target_col = doc:sanitize_position(target_line, target_col)
		sync_last_x_offset(dv, target_line, target_col)
		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

-- 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.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

--- Refresh preferred x offset from the current selection head
function TextSurface:sync_cursor_column_from_selection()
	local line, col = self.doc:get_selection()
	local xo = self.last_x_offset
	xo.offset = self:get_col_x_offset(line, col)
	xo.line = line
	xo.col = col
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 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)
	return wrap.get_position_for_visual_row_x(self, row, x)
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)
	local text = self.doc.lines[line]

	if type(text) ~= "string" then
		return 1
	end

	return #text + ((text:sub(-1) == "\n") and 0 or 1)
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 s = doc.lines[line] or ""
	if s:sub(-1) == "\n" then
		s = s:sub(1, -2)
	end
	local len = #s
	if len == 0 then
		return ""
	end

	--- Check whether one character belongs to a word token
	-- @param ch string one-character string
	-- @return boolean is_word_char true when char matches '%w' or '_'
	local function is_word(ch)
		-- Pattern breakdown:
		-- '%w' matches alnum
		-- '_' is added explicitly to include snake_case
		return ch:match("[%w_]") ~= nil
	end

	local idx = math.max(1, math.min(col, len))
	if not is_word(s:sub(idx, idx)) and idx > 1 and is_word(s:sub(idx - 1, idx - 1)) then
		idx = idx - 1
	end
	if not is_word(s:sub(idx, idx)) then
		return ""
	end

	local i = idx
	local j = idx
	while i > 1 and is_word(s:sub(i - 1, i - 1)) do
		i = i - 1
	end
	while j < len and is_word(s:sub(j + 1, j + 1)) do
		j = j + 1
	end
	return s:sub(i, j)
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

	return self:get_font():get_width(#self.doc.lines) + 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

--- 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
function TextSurface:move_viewport_page(direction, overlap, boundary_jump)
	if self:is_wrap_enabled() then
		local lh = self:get_line_height()
		local viewport_h = select(4, self:get_content_viewport_rect())
		local total_rows = self:get_visual_row_count()
		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_row = math.max(1, total_rows - visible_rows + 1)
		local animate_page_scroll = config.animated_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 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)
		local line, col = self.doc:get_selection()
		local caret_row = self:get_visual_row_for_position(line, col)
		local target_row = common.clamp(caret_row + (target_top_row - top_row), 1, total_rows)
		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
		)
		local raw_x = self.last_x_offset.offset

		if type(raw_x) ~= "number" then
			raw_x = self:get_col_x_offset(line, col)
		end

		-- 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 = self:get_position_for_visual_row_x(target_row, raw_x)

		self.doc:set_selection(target_line, target_col)
		self.last_x_offset.offset = raw_x
		sync_last_x_offset(self, target_line, target_col)

		local target_y = (target_top_row - 1) * lh
		target_y = clamp_scroll_y_to_visible_text_row(self, target_y, target_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.animated_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 = 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_position(target_line, col)
	sync_last_x_offset(self, target_line, col)

	self.doc:set_selection(target_line, col)

	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

--- 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
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

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()

	-- Fast-path for very long lines using simple monospace column math
	if #text > (config.long_line_threshold or 5000) then
		local w = font:get_width(" ")
		return (col - 1) * 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 < col 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_col = math.min(col, run_end + 1) - i + 1

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

			if col <= run_end + 1 then
				return xoffset
			end

			i = run_end + 1
		end

		if i < 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
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
-- @return number col nearest one-based column index
function TextSurface:get_x_offset_col(line, x)
	local text = self.doc.lines[line]
	local font = self:get_font()
	x = tonumber(x) or 0
	-- Fast-path for very long lines using simple monospace column math
	if #text > (config.long_line_threshold or 5000) then
		local w = font:get_width(" ")
		if w <= 0 then
			return 1
		end
		local col = math.floor(x / w) + 1
		local line_len = #text
		if text:sub(-1) == "\n" then
			line_len = line_len - 1
		end
		return common.clamp(col, 1, line_len + 1)
	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

	-- Allow cursor past last char when no trailing LF on this line
	return #text + ((text:sub(-1) == "\n") and 0 or 1)
end

--- Convert screen coordinates into document position
-- @param x number absolute x coordinate
-- @param y number absolute y coordinate
-- @return number line resolved line
-- @return number col resolved column
function TextSurface:resolve_screen_position(x, y)
	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 self:get_position_for_visual_row_x(row, x - ox)
	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)
	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.scroll.to.y = 0
		self.scroll.to.x = 0
		self.scroll.y = 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 max_scroll = math.max(0, self:get_scrollable_size() - viewport_h)
	local target = max_scroll

	self.scroll.to.y = target
	self.scroll.to.x = 0
	self.scroll.y = target
	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_visual_row_for_position(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

--- 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

	if clicks == 2 then
		line1, col1 = translate.start_of_word(doc, line1, col1)
		line2, col2 = translate.end_of_word(doc, line2, col2)
	elseif clicks == 3 then
		if line2 == #doc.lines and doc.lines[#doc.lines] ~= "\n" then
			doc:insert(math.huge, math.huge, "\n")
		end
		line1, col1, line2, col2 = line1, 1, line2 + 1, 1
	end

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

	return line1, col1, line2, col2
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

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

	if keymap.modkeys["shift"] then
		if clicks == 1 then
			local line1, col1 = select(3, self.doc:get_selection())
			local line2, col2 = self:resolve_screen_position(x, y)
			self.doc:set_selection(line2, col2, line1, col1)
		end
	else
		local line, col = self:resolve_screen_position(x, y)
		self.doc:set_selection(mouse_selection(self.doc, clicks, line, col, line, col))
		self.mouse_selecting = { line, col, clicks = clicks }
	end
	self.blink_timer = 0
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

	TextSurface.super.on_mouse_move(self, x, y, ...)

	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 = self:resolve_screen_position(x, y)
		local l2, c2 = table.unpack(self.mouse_selecting)
		local clicks = self.mouse_selecting.clicks
		self.doc:set_selection(mouse_selection(self.doc, clicks, l1, c1, l2, c2))
	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
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

	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)
	self.doc:text_input(text)
end

--- Update cursor visibility, blink timing, and transient line flash state
function TextSurface:update()
	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 syntax highlighted text content for one line
-- @param idx number line index
-- @param x number line origin x
-- @param y number line origin y
function TextSurface:draw_line_text(idx, x, y)
	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 ""
	local sel_line1, sel_col1, sel_line2, sel_col2 = self.doc:get_selection(true)

	-- Token background should not override active selection highlight
	-- Compute selected x-range for this line once and suppress token bg inside it
	local sel_x1, sel_x2 = nil, nil
	local sel_start_col, sel_end_col = nil, nil
	if idx >= sel_line1 and idx <= sel_line2 then
		local line_text = self.doc.lines[idx] or ""
		if sel_line1 ~= idx then
			sel_col1 = 1
		end
		if sel_line2 ~= idx then
			sel_col2 = #line_text + 1
		end
		sel_start_col, sel_end_col = sel_col1, sel_col2
		sel_x1 = x + self:get_col_x_offset(idx, sel_col1)
		sel_x2 = x + self:get_col_x_offset(idx, sel_col2)
		if sel_x2 < sel_x1 then
			sel_x1, sel_x2 = sel_x2, sel_x1
			sel_start_col, sel_end_col = sel_end_col, sel_start_col
		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 and sel_x1 and sel_x2 and sel_x2 > sel_x1 then
			local cx1 = tx
			local cx2 = tx + w
			-- Skip token bg when chunk intersects selected range on this line
			if cx2 > sel_x1 and cx1 < sel_x2 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

		local chunk_len = utf8help.len(chunk)

		if use_selected_normal_fg and sel_start_col and sel_end_col and sel_end_col > sel_start_col then
			local chunk_start = chunk_col
			local chunk_end = chunk_col + chunk_len
			local sel_start = math.max(chunk_start, sel_start_col)
			local sel_end = math.min(chunk_end, sel_end_col)
			if sel_start < sel_end then
				local pre_len = sel_start - chunk_start
				local sel_len = sel_end - sel_start

				if pre_len > 0 then
					draw_text_run(utf8help.sub(chunk, 1, pre_len), fg, bg)
				end

				draw_text_run(utf8help.sub(chunk, pre_len + 1, pre_len + sel_len), default_fg, bg)

				local rest_start = pre_len + sel_len + 1
				if rest_start <= chunk_len then
					draw_text_run(utf8help.sub(chunk, rest_start), 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 = utf8help.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 = utf8help.sub(text, 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 and sel_x1 and sel_x2 and sel_x2 > sel_x1 then
				local cx1 = x_draw
				local cx2 = x_draw + w
				if cx2 > sel_x1 and cx1 < sel_x2 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 + utf8help.len(chunk)
					break
				end
				local chunk = text:sub(start, tab_idx - 1)
				draw_chunk(chunk, fg, bg, col, use_selected_normal_fg)
				col = col + utf8help.len(chunk)
			local advance = tab_size - (tx - x) % tab_size
			if bg and advance > 0 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 cursor
-- @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, col = self.doc:get_selection()

	-- 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 selection if it overlaps this line
	local line1, col1, line2, col2 = self.doc:get_selection(true)
	if idx >= line1 and idx <= line2 then
		local text = self.doc.lines[idx]
		if line1 ~= idx then
			col1 = 1
		end
		if line2 ~= idx then
			col2 = #text + 1
		end
		local x1 = x + self:get_col_x_offset(idx, col1)
		local x2 = x + self:get_col_x_offset(idx, col2)
		local lh = self:get_line_height()
		renderer.draw_rect(x1, y, x2 - x1, lh, style.selection)
	end

	-- Draw line highlight if cursor is on this line
	if config.highlight_current_line and not self.doc:has_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

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

	-- 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

	-- draw cursor if it overlaps this line
	if
		line == idx
		and self.draw_cursor ~= false
		and core.active_view == self
		and (self:is_scrolling() or get_blink_period() <= 0 or self.blink_timer < get_blink_period() / 2)
		and system.window_has_focus()
	then
		local lh = self:get_line_height()
		local x1 = x + self:get_col_x_offset(line, col)
		renderer.draw_rect(x1, y, style.cursor_width, lh, style.cursor)
	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 line1, _, line2, _ = self.doc:get_selection(true)

	if idx >= line1 and idx <= line2 then
		color = style.line_number2
	end
	local yoffset = self:get_line_text_y_offset()

	x = x + style.padding.x
	renderer.draw_text(self:get_font(), idx, x, y + yoffset, color)
end

-- Return wrapped-row-local x offset for one logical position
local function get_wrapped_row_x(self, row_info, col, line_end)
	return row_info.indent_x + (self:get_col_x_offset(row_info.line, col) - row_info.start_x)
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)
	if row_info.line ~= line then
		return nil, nil
	end

	local start_col = math.max(col1, row_info.start_col)
	local end_col = math.min(col2, row_info.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)
	if #text == 0 then
		return
	end

	local font = self:get_font()
	local tx = 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
			local run_width = font:get_width(run)

			if bg and run_width > 0 then
				renderer.draw_rect(tx, y, run_width, lh, bg)
			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
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, sel_start_col, sel_end_col, default_fg, use_selected_normal_fg)
	if #text == 0 then
		return
	end

	if not (use_selected_normal_fg and sel_start_col and sel_end_col and sel_end_col > sel_start_col) then
		draw_wrapped_piece(self, row_info, text, start_col, x, y, fg, bg)
		return
	end

	local chunk_start = start_col
	local chunk_end = start_col + #text
	local overlap_start = math.max(chunk_start, sel_start_col)
	local overlap_end = math.min(chunk_end, sel_end_col)

	if overlap_start >= overlap_end then
		draw_wrapped_piece(self, row_info, text, start_col, x, y, fg, bg)
		return
	end

	local prefix_len = overlap_start - chunk_start
	local selected_len = overlap_end - overlap_start

	if prefix_len > 0 then
		draw_wrapped_piece(self, row_info, text:sub(1, prefix_len), start_col, x, y, fg, bg)
	end

	draw_wrapped_piece(self, row_info, text:sub(prefix_len + 1, prefix_len + selected_len), overlap_start, x, y, default_fg, nil)

	local rest_start = prefix_len + selected_len + 1
	if rest_start <= #text then
		draw_wrapped_piece(self, row_info, text:sub(rest_start), overlap_end, x, y, fg, bg)
	end
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

-- Draw wrapped selections, highlights, text, overlays, and cursor for one logical line
function TextSurface:draw_wrapped_line_body(line, first_row, last_row, x, y)
	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, caret_col = self.doc:get_selection()
	local selected_line1, selected_col1, selected_line2, selected_col2 = self.doc:get_selection(true)
	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

	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 selected_line1 <= line and selected_line2 >= line then
			local row_sel_col1 = selected_col1
			local row_sel_col2 = selected_col2

			if selected_line1 ~= line then
				row_sel_col1 = 1
			end

			if selected_line2 ~= line then
				row_sel_col2 = self:get_line_end_col(line)
			end

			local overlap_start, overlap_end = get_wrapped_row_overlap(row_info, line, row_sel_col1, row_sel_col2)

			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
		end

		if config.highlight_current_line and not self.doc:has_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)

				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

	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_sel_start = nil
	local row_sel_end = nil

	if selected_line1 <= line and selected_line2 >= line then
		local line_sel_col1 = (selected_line1 == line) and selected_col1 or 1
		local line_sel_col2 = (selected_line2 == line) and selected_col2 or self:get_line_end_col(line)
		row_sel_start, row_sel_end = get_wrapped_row_overlap(row_info, line, line_sel_col1, line_sel_col2)
	end

	local current_col = 1
	local token_index = 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_info = (row <= last_row) and get_wrapped_visual_row_info(self, row) or nil

				if row_info and selected_line1 <= line and selected_line2 >= line then
					local line_sel_col1 = (selected_line1 == line) and selected_col1 or 1
					local line_sel_col2 = (selected_line2 == line) and selected_col2 or self:get_line_end_col(line)
					row_sel_start, row_sel_end = get_wrapped_row_overlap(row_info, line, line_sel_col1, line_sel_col2)
				end
			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

				draw_wrapped_piece_with_selection(
					self,
					row_info,
					piece,
					overlap_start,
					x,
					row_y,
					fg,
					bg,
					row_sel_start,
					row_sel_end,
					default_fg,
					use_selected_normal_fg
				)

				if overlap_end >= row_info.end_col then
					row = row + 1
					row_info = (row <= last_row) and get_wrapped_visual_row_info(self, row) or nil

					if row_info and selected_line1 <= line and selected_line2 >= line then
						local line_sel_col1 = (selected_line1 == line) and selected_col1 or 1
						local line_sel_col2 = (selected_line2 == line) and selected_col2 or self:get_line_end_col(line)
						row_sel_start, row_sel_end = get_wrapped_row_overlap(row_info, line, line_sel_col1, line_sel_col2)
					end
				else
					break
				end
			end
		end

		current_col = token_end
	end

	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)

				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

	if
		caret_line == line
		and self.draw_cursor ~= false
		and core.active_view == self
		and (self:is_scrolling() or get_blink_period() <= 0 or self.blink_timer < get_blink_period() / 2)
		and system.window_has_focus()
	then
		local caret_row, caret_row_info = self:get_visual_row_for_position(caret_line, caret_col)

		if 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, caret_row_info, caret_col)

			renderer.draw_rect(x1, row_y, style.cursor_width, lh, style.cursor)
		end
	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()
	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 + (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 = 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)

	local row = 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

		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 - 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

	core.pop_clip_rect()

	local monitor_state = doc_monitor.get_state and doc_monitor.get_state(self.doc)
	if monitor_state and monitor_state.deleted then
		local border_color = style.deleted or style.error or style.text
		local bx, by, bw, bh = self:get_content_viewport_rect()
		local bt = 1

		renderer.draw_rect(bx, by, bw, bt, border_color)
		renderer.draw_rect(bx, by + bh - bt, bw, bt, border_color)
		renderer.draw_rect(bx, by, bt, bh, border_color)
		renderer.draw_rect(bx + bw - bt, by, bt, bh, border_color)
	end

	self:draw_scrollbar()
	self:draw_debug_lanes()

	if draw_start_time then
		local drawn_row_count = math.max(0, maxrow - 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)

	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

	-- In gridlock, highlight the line *after* the last line to avoid text overlap
--	if core.gridlock then
--		local y_line = select(2, self:get_line_screen_position(#self.doc.lines)) + lh
--		if y_line >= pos.y and y_line <= pos.y + viewport_h then
--
--			-- Draw a thin mid-line marker after the last line
--			local line_h = 3 * SCALE
--			local y_mid = y_line + (lh - line_h) / 2
--			renderer.draw_rect(pos.x + gw, y_mid, text_w, line_h, style.gridlock_eol)
--
--			-- Gridlock hint text below the marker
--			local msg = "The number of lines in this file cannot be changed"
--			local y_text = y_mid + line_h + 2 * SCALE
--
--			if y_text < pos.y + viewport_h then
--				renderer.draw_text(style.gridlock_font, msg, pos.x + gw, y_text, style.gridlock_text)
--			end
--		end
--	end
	core.pop_clip_rect()

	local monitor_state = doc_monitor.get_state and doc_monitor.get_state(self.doc)
	if monitor_state and monitor_state.deleted then
		local border_color = style.deleted or style.error or style.text
		local bx, by, bw, bh = self:get_content_viewport_rect()
		local bt = 1 -- one pixel unscaled

		-- Draw thin inner border around the editor viewport for deleted on-disk files
		renderer.draw_rect(bx, by, bw, bt, border_color)
		renderer.draw_rect(bx, by + bh - bt, bw, bt, border_color)
		renderer.draw_rect(bx, by, bt, bh, border_color)
		renderer.draw_rect(bx + bw - bt, by, bt, bh, border_color)
	end

	self:draw_scrollbar()
	self:draw_debug_lanes()
end

return TextSurface
