-- lx-bundle:true

---@diagnostic disable: assign-type-mismatch
local core = require("core")
local command = require("core.command")
local common = require("core.common")
local config = require("core.config")
local keymap = require("core.keymap")
local monitor = require("core.doc.monitor")
local format_registry = require("core.format")
local translate = require("core.text.translate")
local TextDocument = require("core.text.document")
local TextEditor = require("core.text.editor")
local TextViewer = require("core.text.viewer")
local edit = require("core.text.edit")
local line = require("core.text.line")
local file = require("core.text.file")
local text_format = require("core.text.format")
local surround = require("core.text.surround")
local navigate = require("core.text.navigate")
local Outliner = require("core.text.outliner")
local text_syntax = require("core.text.syntax")
local utf8 = require("utf8")

-- Returns the active document view
local function dv()
	return core.active_view
end

-- Returns the active document model
local function doc()
	return core.active_view.doc
end

--- Convert leading indentation and activate its matching insertion type
-- @param indent_type string target 'spaces' or 'tabs'
local function convert_document_indentation(indent_type)
	local active_doc = doc()
	active_doc:convert_indentation(indent_type)
	active_doc.indent_type = indent_type
	core.redraw = true
end

--- Return the next explicit indentation size or nil to restore Auto
-- The detected size anchors one complete pass through the configured cycle
-- @param active_doc table document whose indentation size is changing
-- @return number|nil indent_size next override or nil for Auto
local function get_next_indent_size(active_doc)
	local sizes = config.indent_size_cycle
	local automatic_size = active_doc:get_automatic_indent_size()
	local current_size = active_doc.indent_size
	local automatic_index = nil
	local current_index = nil

	for index, size in ipairs(sizes) do
		if size == automatic_size then
			automatic_index = index
		end
		if size == current_size then
			current_index = index
		end
	end

	if #sizes == 0 or current_size == automatic_size then
		return nil
	end

	if current_size == nil then
		local next_index = automatic_index and automatic_index % #sizes + 1 or 1

		return sizes[next_index]
	end

	if current_index == nil or automatic_index == nil and current_index == #sizes then
		return nil
	end

	return sizes[current_index % #sizes + 1]
end

--- Build reload options that preserve the active view's source position
-- @param active_view table document view being reloaded
-- @param force boolean allow a clean document to reload immediately
-- @return table options monitor reload options
local function get_reload_options(active_view, force)
	local options = { force = force }

	if type(active_view.capture_reload_view_state) == "function"
		and type(active_view.restore_reload_view_state) == "function"
	then
		local view_state = active_view:capture_reload_view_state()

		-- Dirty reload confirmation may finish later, so retain this exact view and state
		options.after_reload = function()
			active_view:restore_reload_view_state(view_state)
		end
	end

	return options
end

--- Return whether the active document format handles one generic text operation
-- @param format_name string generic format name
-- @return boolean supported
local function supports_format(format_name)
	local active_view = core.active_view

	return active_view ~= nil
		and active_view.doc ~= nil
		and text_format.supports(active_view.doc, format_name)
end

--- Run a generic format while allowing built-in shortcuts to be disabled independently
-- @param format_name string format-owned text operation name
-- @param args table|nil decoded command arguments
-- @return boolean handled
local function perform_format(format_name, args)
	if type(args) == "table" and args.shortcut == true and config.format_shortcuts == false then
		return false
	end

	return text_format.perform(doc(), format_name)
end

-- Returns outliner for the active document
local function outliner()
	return Outliner.for_doc(doc())
end

-- Return true when the active view is one of the plain-text editor surfaces
local function is_toggleable_text_view()
	local active_view = dv()

	return active_view
		and active_view.doc
		and active_view.is
		and (active_view:is(TextEditor) or active_view:is(TextViewer))
		or false
end

-- Return true when the active view is a TextViewer pane
local function is_active_text_viewer()
	local active_view = dv()

	return active_view
		and active_view.is
		and active_view:is(TextViewer)
		or false
end

--- Run one viewer-only command against the active TextViewer
-- Keeps explicit viewer command handlers small while preserving the active-view guard
-- @param perform function callback that receives the active viewer
-- @return boolean handled
local function perform_text_viewer_command(perform)
	if not is_active_text_viewer() or type(perform) ~= "function" then
		return false
	end

	perform(dv())

	return true
end

--- Apply the vertical edge scroll after moving or extending the primary selection
-- Wrapped documents use their exact visual-row target while unwrapped documents
-- use the shared document-view edge target
-- @param line number target line
-- @param col number target column
local function finish_doc_edge_move(line, col)
	local active_view = dv()
	local active_doc = doc()

	if not (
		active_view
		and active_doc
		and active_view.doc == active_doc
	) then
		return
	end

	if active_view.is_wrap_enabled
		and active_view.scroll_to_wrapped_doc_edge
		and active_view:is_wrap_enabled()
	then
		active_view:scroll_to_wrapped_doc_edge(line, col)

		-- Keep the next update from replacing the exact wrapped edge target
		if active_view.last_x_offset then
			active_view.last_x_offset.line = line
			active_view.last_x_offset.col = col
			active_view.last_x_offset.offset = active_view:get_visual_position_x(line, col)
			active_view.last_x_offset.wrapped = true
			active_view.last_x_offset.wrap_width = active_view:get_wrap_width()
			active_view.last_x_offset.font = active_view:get_font()
		end

		active_view.last_line = line
		active_view.last_col = col
	elseif active_view.scroll_to_vertical_edge then
		active_view:scroll_to_vertical_edge(line > 1)
	end

	core.redraw = true
end

--- Return one formatted millisecond value for profiling output
-- @param value number|nil millisecond value
-- @return string text formatted millisecond string
local function format_wrap_profile_ms(value)
	return string.format("%.3fms", tonumber(value) or 0)
end

--- Build one deterministic synthetic text doc for wrap profiling
-- @param line_count number logical line count
-- @param line_width number target line width in bytes before newline
-- @return table doc synthetic text document
local function build_wrap_profile_doc(line_count, line_width)
	local doc = TextDocument()
	local line_parts = {}
	local sample = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu "

	doc:remove(1, 1, 1, math.huge)

	for line = 1, line_count do
		local prefix = string.format("%05d ", line)
		local text = prefix

		while #text < line_width do
			text = text .. sample
		end

		line_parts[#line_parts + 1] = text:sub(1, line_width) .. "\n"
	end

	doc:insert(1, 1, table.concat(line_parts))

	return doc
end

--- Build one detached surface that matches the active view geometry for wrap profiling
-- @param active_view table active text surface
-- @param profile_doc table synthetic document
-- @return table profile_view detached text surface
local function build_wrap_profile_view(active_view, profile_doc)
	local profile_view = active_view:is(TextViewer) and TextViewer(profile_doc) or TextEditor(profile_doc)

	profile_view.position.x = 0
	profile_view.position.y = 0
	profile_view.size.x = math.max(200, tonumber(active_view.size.x) or 0)
	profile_view.size.y = math.max(200, tonumber(active_view.size.y) or 0)
	profile_view.wrap_enabled = true
	profile_view:invalidate_wrap_layout(1, #profile_doc.lines, "wrap_profile_bench")

	return profile_view
end

-- Swap the active text view class in-place and preserve viewport state
local function replace_active_text_view(next_view_class)
	local active_view = dv()
	local active_doc = doc()
	local node = core.workspace_view and core.workspace_view.root_node and core.workspace_view.root_node:get_node_for_view(active_view)

	if not (active_view and active_doc and node and next_view_class) then
		return false
	end

	local view_index = node:get_view_idx(active_view)

	if not view_index then
		return false
	end

	local next_view = next_view_class(active_doc)

	-- Keep the replacement visually seamless by carrying over geometry and scroll targets
	next_view.position.x, next_view.position.y = active_view.position.x, active_view.position.y
	next_view.size.x, next_view.size.y = active_view.size.x, active_view.size.y
	next_view.scroll.x, next_view.scroll.y = active_view.scroll.x, active_view.scroll.y
	next_view.scroll.to.x, next_view.scroll.to.y = active_view.scroll.to.x, active_view.scroll.to.y
	next_view._view_selection = active_view._view_selection
	next_view._view_selections = active_view._view_selections
	next_view._view_primary_selection_index = active_view._view_primary_selection_index
	next_view.wrap_enabled = active_view.wrap_enabled == true
	next_view:invalidate_wrap_layout(1, #active_doc.lines, "replace_active_text_view")

	node.views[view_index] = next_view
	node.active_view = next_view
	core.set_active_view(next_view)
	core.workspace_view.root_node:update_layout()
	core.redraw = true

	return true
end

-- Save a dirty or untitled doc before switching it into the viewer/editor pair
local function save_doc_before_toggle(active_doc, on_saved)
	if not active_doc or type(on_saved) ~= "function" then
		return false
	end

	if active_doc.filename and active_doc:is_dirty() ~= true then
		on_saved()
		return true
	end

	if active_doc.filename then
		core.command_view:enter({
			caption = "Save before toggling viewer",
			submit = function(_, item)
				local choice = item and item.text or ""
				if choice:match("^[sS]") then
					if file.save(active_doc) then
						on_saved()
					end
				end
			end,
			suggest = function(text)
				local items = {}
				if not text:find("^[^sS]") then
					table.insert(items, "Save and Toggle")
				end
				if not text:find("^[^kK]") then
					table.insert(items, "Keep Editing")
				end
				return items
			end,
		})
		return true
	end

	core.command_view:set_text("")
	core.command_view:enter({
		caption = "Save As before toggling viewer",
		submit = function(filename)
			if type(filename) ~= "string" or filename == "" then
				return
			end

			if file.save(active_doc, filename) then
				on_saved()
			end
		end,
		suggest = common.path_suggest,
	})

	return true
end

-- Keep untitled text docs aligned with explicit format picks
local function apply_unsaved_format_extension(active_doc, ext)
	if not active_doc then
		return
	end

	if active_doc.is_actual_file == true then
		return
	end

	if type(ext) ~= "string" then
		return
	end

	ext = ext:match("^%s*(.-)%s*$") or ""
	ext = ext:gsub("^%.*", "")

	if ext == "" then
		return
	end

	local default_title = config.default_doc_title or "untitled"
	local filename = type(active_doc.filename) == "string" and active_doc.filename or ""
	local has_filename = filename ~= ""
	local untitled_prefix = default_title .. "."

	if has_filename then
		-- Allow replacing extension for synthetic untitled names
		if not (filename == default_title or filename:sub(1, #untitled_prefix) == untitled_prefix) then
			return
		end
	end

	active_doc.filename = default_title .. "." .. ext
end

local doc_command_requires = { "doc" }
local editor_command_requires = { "doc", "edit", "scroll" }
local viewer_command_requires = { "doc", "scroll" }
local selection_command_requires = { "doc", "select", "scroll" }
local selection_begin_positions = setmetatable({}, { __mode = "k" })

--- Return whether multi-cursor commands are enabled
-- @return boolean enabled
local function multi_cursor_commands_enabled()
	return config.enable_multi_cursor ~= false
end

--- Write clipboard text without exposing selected content to a failed Cut
-- @param text string clipboard content
-- @param action string user-facing operation name
-- @return boolean written
local function set_clipboard_text(text, action)
	local call_ok, write_ok, write_error = pcall(system.set_clipboard, text)

	if call_ok and write_ok == true then
		return true
	end

	if not call_ok then
		---@diagnostic disable-next-line: cast-local-type
		write_error = write_ok
	end

	core.error("%s failed: %s", action, tostring(write_error or "clipboard unavailable"))

	return false
end

--- Read clipboard text without raising when the platform clipboard is unavailable
-- @return string|nil text
local function get_clipboard_text()
	local call_ok, text, read_error = pcall(system.get_clipboard)

	if call_ok and type(text) == "string" then
		return text
	end

	if not call_ok then
		read_error = text
	end

	core.error("Paste failed: %s", tostring(read_error or "clipboard unavailable"))

	return nil
end

local commands = {

	-- Undo the last document change
	["doc:undo"] = {
		requires = editor_command_requires,
		perform = function()
		doc():undo()
		end,
	},

	-- Redo the last undone document change
	["doc:redo"] = {
		requires = editor_command_requires,
		perform = function()
		doc():redo()
		end,
	},

	-- Cut every current selection to the clipboard
	["doc:cut"] = {
		requires = editor_command_requires,
		perform = function()
		if doc():has_any_selection() then
			local text = table.concat(doc():get_selected_texts(), "\n")

			if set_clipboard_text(text, "Cut") then
				doc():delete_to(0)
			end
		end
		end,
	},

	-- Copy every current selection to the clipboard
	["doc:copy"] = {
		requires = editor_command_requires,
		perform = function()
		if doc():has_any_selection() then
			set_clipboard_text(table.concat(doc():get_selected_texts(), "\n"), "Copy")
		end
		end,
	},

	-- Paste clipboard text at the cursor position
	["doc:paste"] = {
		requires = editor_command_requires,
		perform = function()
		local text = get_clipboard_text()

		if text then
			doc():paste_text(text:gsub("\r", ""))
		end
		end,
	},

	-- Insert a newline and preserve current line indentation
	["doc:newline"] = {
		requires = editor_command_requires,
		perform = function()
		doc():insert_newline()
		end,
	},

	-- Insert a new indented line below the current line
	["doc:newline-below"] = {
		requires = editor_command_requires,
		perform = function()
		line.insert(doc(), true)
		end,
	},

	-- Insert a new indented line above the current line
	["doc:newline-above"] = {
		requires = editor_command_requires,
		perform = function()
		line.insert(doc(), false)
		end,
	},

	-- Delete selection or the next character/chunk at the cursor
	["doc:delete"] = {
		requires = editor_command_requires,
		perform = function()
		-- Keep forward deletion on the same translate path so indentation obeys the shared grid
		doc():delete_to(translate.next_char)
		end,
	},

	-- Backspace selection or the previous character/indent chunk
	["doc:backspace"] = {
		requires = editor_command_requires,
		perform = function()
		if doc():get_selection_count() == 1 and not doc():has_selection() then
			local line, col = doc():get_selection()
			local visual_column = doc():get_indent_visual_column(line, col)
			local indent_size = doc():get_indent_size()
			local prefix = doc():get_text(line, 1, line, col)

			-- Keep space-indent backspace aligned to whole indent steps when the
			-- prefix is spaces only and the caret sits inside leading indentation
			if visual_column and visual_column > 0 and prefix:find("^ +$") then
				local delete_columns = visual_column % indent_size

				if delete_columns == 0 then
					delete_columns = indent_size
				end

				local target_visual_column = visual_column - delete_columns
				local target_col = doc():get_indent_col_for_visual_column(line, target_visual_column, "right")

				if target_col and target_col < col then
					doc():remove(line, target_col, line, col)
					doc():set_selection(line, target_col)
					return
				end
			end
		end

		-- Keep backward deletion on the same translate path so indentation obeys the shared grid
		doc():delete_to(translate.previous_char)
		end,
	},

	-- Select the entire document
	["doc:select-all"] = {
		requires = editor_command_requires,
		perform = function()
		doc():set_selection(1, 1, math.huge, math.huge)
		end,
	},

	-- Clear selection and keep only the cursor position
	["doc:select-none"] = {
		requires = editor_command_requires,
		perform = function()
		local active_doc = doc()

		if active_doc:get_selection_count() > 1 then
			local line1, col1, line2, col2 = active_doc:get_selection()
			active_doc:set_selection(line1, col1, line2, col2)

			return true
		end

		if not active_doc:has_selection() then
			return false
		end

		local line, col = active_doc:get_selection()
		active_doc:set_selection(line, col)

		return true
		end,
	},

	-- Expand selection to whole lines
	["doc:select-lines"] = {
		requires = editor_command_requires,
		perform = function()
		local active_doc = doc()
		local line1, _, line2, _, swap = active_doc:get_selection(true)
		local end_line = line2 + 1
		local end_col = 1

		if line2 == #active_doc.lines then
			-- EOF already has a legal selection edge, so selecting must not edit text
			end_line, end_col = active_doc:sanitize_position(line2, math.huge)
		end

		active_doc:set_selection(line1, 1, end_line, end_col, swap)
		end,
	},

	-- Forward generic bold requests only when the active format provides them
	["format:bold"] = {
		requires = editor_command_requires,
		predicate = function()
			return supports_format("bold")
		end,
		perform = function(_, args)
			return perform_format("bold", args)
		end,
	},

	-- Forward generic code requests only when the active format provides them
	["format:code"] = {
		requires = editor_command_requires,
		predicate = function()
			return supports_format("code")
		end,
		perform = function(_, args)
			return perform_format("code", args)
		end,
	},

	-- Forward generic italic requests only when the active format provides them
	["format:italic"] = {
		requires = editor_command_requires,
		predicate = function()
			return supports_format("italic")
		end,
		perform = function(_, args)
			return perform_format("italic", args)
		end,
	},

	-- Forward generic strikethrough requests only when the active format provides them
	["format:strikethrough"] = {
		requires = editor_command_requires,
		predicate = function()
			return supports_format("strikethrough")
		end,
		perform = function(_, args)
			return perform_format("strikethrough", args)
		end,
	},

	-- Forward generic underline requests only when the active format provides them
	["format:underline"] = {
		requires = editor_command_requires,
		predicate = function()
			return supports_format("underline")
		end,
		perform = function(_, args)
			return perform_format("underline", args)
		end,
	},

	-- Surround selections from a syntax preset or explicit command markers
	["format:surround"] = {
		requires = editor_command_requires,
		perform = function(_, args)
			return surround.perform(doc(), args)
		end,
	},

	-- Select the word at the cursor
	["doc:select-word"] = {
		requires = editor_command_requires,
		perform = function()
		local line1, col1 = doc():get_selection(true)
		local line1, col1 = translate.start_of_word(doc(), line1, col1)
		local line2, col2 = translate.end_of_word(doc(), line1, col1)
		doc():set_selection(line2, col2, line1, col1)
		end,
	},

	-- Join selected lines into one line with normalized spacing
	["doc:join-lines"] = {
		requires = editor_command_requires,
		perform = function()
		line.join(doc())
		end,
	},

	-- Insert one indentation level or indent selected lines
	["doc:indent"] = {
		requires = editor_command_requires,
		perform = function()
		if doc():get_selection_count() > 1 then
			edit.indent_at_selections(doc())
		elseif doc():has_selection() then
			local text = edit.get_indent_string(doc())
			edit.insert_at_start_of_selected_lines(doc(), text)
		else
			local line, col = doc():get_selection()
			local text = edit.get_indent_input(doc(), line, col)
			doc():text_input(text)
		end
		end,
	},

	-- Remove one indentation level from selected lines
	["doc:unindent"] = {
		requires = editor_command_requires,
		perform = function()
		if doc():get_selection_count() > 1 then
			edit.unindent_at_selections(doc())
		else
			local text = edit.get_indent_string(doc())
			edit.remove_from_start_of_selected_lines(doc(), text)
		end
		end,
	},

	-- Duplicate the selected lines below
	["doc:duplicate-lines"] = {
		requires = editor_command_requires,
		perform = function()
		line.duplicate(doc())
		end,
	},

	-- Delete the selected lines
	["doc:delete-lines"] = {
		requires = editor_command_requires,
		perform = function()
		line.delete(doc())
		end,
	},

	-- Move selected lines one line up
	["doc:move-lines-up"] = {
		requires = editor_command_requires,
		perform = function()
		line.move(doc(), -1)
		end,
	},

	-- Move selected lines one line down
	["doc:move-lines-down"] = {
		requires = editor_command_requires,
		perform = function()
		line.move(doc(), 1)
		end,
	},

	-- Toggle line comments on selected lines
	["doc:toggle-line-comments"] = {
		requires = editor_command_requires,
		perform = function()
			local comment = doc().format.comment
		if not comment then
			return
		end
		local comment_text = comment .. " "
		local line1, line2 = edit.get_selected_line_range(doc())
		local uncomment = true
		for line = line1, line2 do
			local text = doc().lines[line]
			if text:find("%S") and text:find(comment_text, 1, true) ~= 1 then
				uncomment = false
			end
		end
		if uncomment then
			edit.remove_from_start_of_selected_lines(doc(), comment_text, true)
		else
			edit.insert_at_start_of_selected_lines(doc(), comment_text, true)
		end
		end,
	},

	-- Convert selected text to uppercase
	["doc:upper-case"] = {
		requires = editor_command_requires,
		perform = function()
			doc():replace(utf8.upper)
		end,
	},

	-- Convert selected text to lowercase
	["doc:lower-case"] = {
		requires = editor_command_requires,
		perform = function()
			doc():replace(utf8.lower)
		end,
	},

	-- Set the document format explicitly from every registered format module
	["doc:set-format"] = {
		requires = doc_command_requires,
		perform = function()
		local active_doc = doc()
		local items = {}
		local result_by_text = {}
		local mt = {
			__tostring = function(item)
				return item.text
			end,
		}

		-- Build the picker from the complete format registry
		local available = format_registry.get_available and format_registry.get_available() or {}

		for index = 1, #available do
			local entry = available[index]
			local name = entry and entry.name

			if type(name) == "string" and name ~= "" then
				local selected_format = format_registry.get_by_name(name)

				items[#items + 1] = setmetatable({
					text = selected_format and (selected_format.name or selected_format.shortname) or name,
					info = tostring(entry.module or "format"),
					result = entry.module or name,
				}, mt)
				result_by_text[string.lower(tostring(items[#items].text))] = items[#items].result
			end
		end

		table.sort(items, function(left, right)
			local left_text = tostring(left.text or ""):lower()
			local right_text = tostring(right.text or ""):lower()

			if left_text ~= right_text then
				return left_text < right_text
			end

			return tostring(left.info or "") < tostring(right.info or "")
		end)

		-- Start empty so the picker shows every format
		core.command_view:set_text("")
		core.command_view:enter({
			caption = "Set Format",
			submit = function(text, item)
				local query = item and (item.result or item.text) or text

				if (not item) and type(query) == "string" then
					local mapped = result_by_text[string.lower(query)]

					if type(mapped) == "string" and mapped ~= "" then
						query = mapped
					end
				end

				local selected = nil
				local selected_module = nil

				if type(query) == "string" and query ~= "" then
					if type(format_registry.get_by_module) == "function" then
						selected = format_registry.get_by_module(query)

						if selected then
							selected_module = query
						end
					end

					if not selected and type(format_registry.get_by_name) == "function" then
						selected = format_registry.get_by_name(query)
					end
				end

				if not selected then
					return
				end

				if not selected_module and type(selected._module_name) == "string" then
					selected_module = selected._module_name
				end

				-- Keep an explicit choice until this document is loaded again
				active_doc.format_override = selected
				active_doc.format_detect_meta = nil
				active_doc.kind = format_registry.get_kind(selected)
				active_doc.format = selected

				if active_doc.highlighter and active_doc.highlighter.reset then
					active_doc.highlighter:reset()
				end

				local ext = nil

				if type(format_registry.get_default_extension_by_module) == "function" and type(selected_module) == "string" then
					ext = format_registry.get_default_extension_by_module(selected_module)
				end

				apply_unsaved_format_extension(active_doc, ext)
				core.log_quiet('Format set to "%s"', tostring(selected.shortname or selected.name or "format"))
			end,
			suggest = function(text)
				if not text or text == "" then
					return items
				end

				return common.fuzzy_match(items, text or "")
			end,
		})
		end,
	},

	-- Set a single-byte codepage on the active doc and reopen from disk when possible
	["doc:set-codepage"] = {
		requires = doc_command_requires,
		perform = function()
		local active_doc = doc()

		if active_doc.filename and active_doc:is_dirty() then
			core.error("Save or discard changes before changing codepage")
			return
		end

		-- Start with an empty query so the picker shows all available codepages
		core.command_view:set_text("")
		local items = file.get_codepage_suggest_items()

		core.command_view:enter({
			caption = "Set Codepage",
			submit = function(text, item)
				local name = item and item.text or (text and text:match("^%s*(.-)%s*$") or "")

				if not name or name == "" then
					return
				end

				local ok, err = file.apply_codepage(active_doc, dv(), name)

				if not ok and err then
					core.error("%s", err)
				end
			end,
			suggest = function(text)
				return common.fuzzy_match(items, text or "")
			end,
		})
		end,
	},

	-- Toggle document line ending mode between LF and CRLF
	["doc:toggle-line-ending"] = {
		requires = editor_command_requires,
		perform = function()
			doc().crlf = not doc().crlf
			doc().cr_only = false
		end,
	},

	-- Use spaces for future indentation without rewriting existing whitespace
	["doc:use-spaces"] = {
		requires = editor_command_requires,
		perform = function()
			doc().indent_type = "spaces"
			core.redraw = true
		end,
	},

	-- Use tabs for future indentation without rewriting existing whitespace
	["doc:use-tabs"] = {
		requires = editor_command_requires,
		perform = function()
			doc().indent_type = "tabs"
			core.redraw = true
		end,
	},

	-- Convert leading indentation to spaces and use spaces for future indentation
	["doc:to-spaces"] = {
		requires = editor_command_requires,
		perform = function()
			convert_document_indentation("spaces")
		end,
	},

	-- Convert leading indentation to tabs and use tabs for future indentation
	["doc:to-tabs"] = {
		requires = editor_command_requires,
		perform = function()
			convert_document_indentation("tabs")
		end,
	},

	-- Re-scan current content and restore automatic indentation type and size
	["doc:detect-indent"] = {
		requires = editor_command_requires,
		perform = function()
			local active_doc = doc()
			active_doc:detect_indentation()
			active_doc.indent_type = nil
			active_doc.indent_size = nil
			core.redraw = true
		end,
	},

	-- Cycle the current document between automatic and common indentation sizes
	["doc:cycle-indent-size"] = {
		requires = editor_command_requires,
		perform = function()
			local active_doc = doc()
			active_doc.indent_size = get_next_indent_size(active_doc)

			core.redraw = true
		end,
	},

	-- Toggle the active plain-text doc between editor and viewer surfaces
	["doc:toggle-viewer"] = {
		requires = viewer_command_requires,
		predicate = is_toggleable_text_view,
		perform = function()
		if not is_toggleable_text_view() then
			return false
		end

		local active_view = dv()
		local active_doc = doc()

		return save_doc_before_toggle(active_doc, function()
			local next_view_class = active_view:is(TextViewer) and TextEditor or TextViewer
			replace_active_text_view(next_view_class)
		end)
		end,
	},

	-- Toggle syntax highlighting on the active TextViewer only
	["doc:toggle-viewer-syntax"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
		if not is_active_text_viewer() then
			return false
		end

		local active_view = dv()
		active_view.use_syntax_highlighting = active_view.use_syntax_highlighting ~= true
		core.status_view:show_notice(
			active_view.use_syntax_highlighting
				and "Viewer syntax highlighting enabled"
				or "Viewer syntax highlighting disabled"
		)
		core.redraw = true

		return true
		end,
	},

	-- Toggle wrapped visual rows on the active plain-text editor or viewer
	["doc:toggle-word-wrap"] = {
		requires = viewer_command_requires,
		predicate = is_toggleable_text_view,
		perform = function()
		if not is_toggleable_text_view() then
			return false
		end

		local active_view = dv()
		local viewport_anchor = active_view.capture_word_wrap_anchor
			and active_view:capture_word_wrap_anchor()

		active_view.wrap_enabled = active_view.wrap_enabled ~= true
		active_view:invalidate_wrap_layout(1, #active_view.doc.lines, "toggle_wrap")

		if active_view.wrap_enabled then
			active_view.scroll.x = 0
			active_view.scroll.to.x = 0
		end

		if viewport_anchor and active_view.restore_word_wrap_anchor then
			active_view:restore_word_wrap_anchor(viewport_anchor)
		else
			active_view:scroll_to_make_visible(active_view.doc:get_selection())
		end

		core.redraw = true

		return true
		end,
	},

	-- Reset the active wrapped text surface profiling counters
	["doc:reset-wrap-profile"] = {
		requires = viewer_command_requires,
		predicate = is_toggleable_text_view,
		perform = function()
		if not is_toggleable_text_view() then
			return false
		end

		local active_view = dv()
		active_view:reset_wrap_profile()
		core.status_view:show_notice("Wrap profile counters reset")
		core.redraw = true

		return true
		end,
	},

	-- Log the current wrapped layout and draw profiling counters for the active text surface
	["doc:report-wrap-profile"] = {
		requires = viewer_command_requires,
		predicate = is_toggleable_text_view,
		perform = function()
		if not is_toggleable_text_view() then
			return false
		end

		local active_view = dv()
		local snapshot = active_view:get_wrap_profile_snapshot()

		if not snapshot then
			core.status_view:show_notice("Wrap profiling is disabled in config")
			return true
		end

		core.log(
			"[wrap-profile] ensure=%d cache_hits=%d rebuilds=%d ensure_total=%s rebuild_total=%s rebuild_max=%s rebuilt_lines=%d rebuilt_rows=%d",
			snapshot.ensure_calls or 0,
			snapshot.ensure_cache_hits or 0,
			snapshot.rebuild_calls or 0,
			format_wrap_profile_ms(snapshot.total_ensure_ms),
			format_wrap_profile_ms(snapshot.total_rebuild_ms),
			format_wrap_profile_ms(snapshot.max_rebuild_ms),
			snapshot.total_rebuilt_lines or 0,
			snapshot.total_rebuilt_rows or 0
		)
		core.log(
			"[wrap-profile] draws=%d draw_total=%s draw_max=%s drawn_rows=%d drawn_lines=%d",
			snapshot.draw_calls or 0,
			format_wrap_profile_ms(snapshot.total_draw_ms),
			format_wrap_profile_ms(snapshot.max_draw_ms),
			snapshot.total_drawn_rows or 0,
			snapshot.total_drawn_lines or 0
		)
		core.status_view:show_notice("Wrap profile logged to the log view")

		return true
		end,
	},

	-- Run one synthetic large-file wrap benchmark that matches the active surface class and geometry
	["doc:benchmark-wrap-layout"] = {
		requires = viewer_command_requires,
		predicate = is_toggleable_text_view,
		perform = function()
		if not is_toggleable_text_view() then
			return false
		end

		local active_view = dv()
		local bench_lines = math.max(100, tonumber(config.text_wrap_profile_bench_lines) or 25000)
		local bench_columns = math.max(40, tonumber(config.text_wrap_profile_bench_columns) or 180)
		local bench_repeats = math.max(1, tonumber(config.text_wrap_profile_bench_repeats) or 5)
		local bench_doc = build_wrap_profile_doc(bench_lines, bench_columns)
		local bench_view = build_wrap_profile_view(active_view, bench_doc)
		local layout
		local start_time = system.get_time()

		layout = bench_view:ensure_wrap_layout()
		local initial_ms = (system.get_time() - start_time) * 1000
		local cached_total_ms = 0

		for _ = 1, bench_repeats do
			start_time = system.get_time()
			bench_view:ensure_wrap_layout()
			cached_total_ms = cached_total_ms + (system.get_time() - start_time) * 1000
		end

		local probe_rows = math.max(1, math.min(2000, bench_view:get_visual_row_count()))
		start_time = system.get_time()
		for row = 1, probe_rows do
			bench_view:get_position_for_visual_row_x(row, 0)
		end
		local map_ms = (system.get_time() - start_time) * 1000

		local edit_line = math.max(1, math.min(math.floor(bench_lines / 2), #bench_doc.lines))
		bench_doc:insert(edit_line, 1, "prefix ")
		start_time = system.get_time()
		layout = bench_view:ensure_wrap_layout()
		local edit_ms = (system.get_time() - start_time) * 1000

		bench_view.size.x = math.max(160, bench_view.size.x - math.max(80, math.floor(bench_view.size.x * 0.25)))
		bench_view:invalidate_wrap_layout(1, #bench_doc.lines, "wrap_profile_resize")
		start_time = system.get_time()
		layout = bench_view:ensure_wrap_layout()
		local resize_ms = (system.get_time() - start_time) * 1000

		core.log(
			"[wrap-bench] lines=%d columns=%d rows=%d initial=%s cached_avg=%s probe_map=%s edit=%s resize=%s",
			bench_lines,
			bench_columns,
			layout and layout.total_rows or 0,
			format_wrap_profile_ms(initial_ms),
			format_wrap_profile_ms(cached_total_ms / bench_repeats),
			format_wrap_profile_ms(map_ms),
			format_wrap_profile_ms(edit_ms),
			format_wrap_profile_ms(resize_ms)
		)
		core.status_view:show_notice("Wrap benchmark logged to the log view")

		return true
		end,
	},

	-- Scroll the active text viewer left by one keyboard step
	["text-viewer:scroll-left"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_horizontal_step(-1)
			end)
		end,
	},

	-- Scroll the active text viewer right by one keyboard step
	["text-viewer:scroll-right"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_horizontal_step(1)
			end)
		end,
	},

	-- Scroll the active text viewer left by one larger keyboard step
	["text-viewer:scroll-left-large"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_horizontal_step(-1, true)
			end)
		end,
	},

	-- Scroll the active text viewer right by one larger keyboard step
	["text-viewer:scroll-right-large"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_horizontal_step(1, true)
			end)
		end,
	},

	-- Jump the active text viewer to the leftmost horizontal edge
	["text-viewer:scroll-leftmost"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_to_horizontal_edge(false)
			end)
		end,
	},

	-- Jump the active text viewer to the rightmost horizontal edge
	["text-viewer:scroll-rightmost"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_to_horizontal_edge(true)
			end)
		end,
	},

	-- Jump the active text viewer to the end of the longest visible line
	["text-viewer:scroll-rightmost-visible"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_to_rightmost_visible()
			end)
		end,
	},

	-- Scroll the active text viewer up by one rendered line
	["text-viewer:scroll-up"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_vertical_step(-1)
			end)
		end,
	},

	-- Scroll the active text viewer down by one rendered line
	["text-viewer:scroll-down"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_vertical_step(1)
			end)
		end,
	},

	-- Scroll the active text viewer up by one visible page
	["text-viewer:scroll-page-up"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_page(-1)
			end)
		end,
	},

	-- Scroll the active text viewer down by one visible page
	["text-viewer:scroll-page-down"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_page(1)
			end)
		end,
	},

	-- Jump the active text viewer to the top edge
	["text-viewer:scroll-top"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_to_vertical_edge(false)
			end)
		end,
	},

	-- Jump the active text viewer to the bottom edge
	["text-viewer:scroll-bottom"] = {
		requires = viewer_command_requires,
		predicate = is_active_text_viewer,
		perform = function()
			return perform_text_viewer_command(function(view)
				view:scroll_to_vertical_edge(true)
			end)
		end,
	},

	-- Prompt for a filename and save the document there
	["doc:save-as"] = {
		requires = editor_command_requires,
		perform = function()
		if doc().filename then
			core.command_view:set_text(doc().filename)
		end

		-- Saves to a user-provided path from the command prompt
		core.command_view:enter({
			caption = "Save As",
			submit = function(filename)
				file.save(doc(), filename)
			end,
			suggest = common.path_suggest,
		})
		end,
	},

	-- Save the current document to its existing filename
	["doc:save"] = {
		requires = editor_command_requires,
		perform = function()
		if doc().filename and not doc().save_as_required then
			file.save(doc())
		else
			command.perform("doc:save-as")
		end
		end,
	},

	-- Reload the current document from disk through doc monitor logic
	["doc:reload"] = {
		requires = doc_command_requires,
		perform = function()
		local active_doc = doc()

		if not active_doc or not active_doc.filename then
			core.error("Cannot reload unsaved doc")
			return false
		end

		local reload_options = get_reload_options(dv(), active_doc:is_dirty() ~= true)

		local state = monitor.get_state and monitor.get_state(active_doc)

		if state and state.stale and not state.deleted then
			local ok, err = monitor.reload_doc(active_doc, reload_options)

			if not ok and err == "dirty" then
				return monitor.confirm_dirty_reload(active_doc, reload_options)
			end

			if not ok and err then
				core.error("Reload failed: %s", tostring(err))
				return false
			end

			return ok
		end

		local ok, err = monitor.reload_doc(active_doc, reload_options)

		if not ok and err == "dirty" then
			core.error("Reload blocked by unsaved changes")
			return false
		end

		if not ok and err then
			core.error("Reload failed: %s", tostring(err))
			return false
		end

		return ok
		end,
	},

	-- Reload all stale file-backed docs that are safe to reload
	["doc:reload-stale"] = {
		perform = function()
			monitor.reload_stale()
			return true
		end,
	},

	-- Toggle the integrated document monitor background service
	["doc:toggle-monitor"] = {
		perform = function()
			monitor.toggle()
			return true
		end,
	},

	-- Rename the current file on disk and update document path
	["doc:rename"] = {
		requires = editor_command_requires,
		perform = function()
		local active_doc = doc()

		if not active_doc.filename then
			core.error("Cannot rename unsaved doc")
			return
		end

		core.command_view:set_text(active_doc.filename)

		-- Saves under the new filename and removes the original file when changed
		core.command_view:enter({
			caption = "Rename",
			submit = function(filename)
				local ok, err = file.rename(active_doc, filename)

				if not ok and err then
					core.error("%s", err)
				end
			end,
			suggest = common.path_suggest,
		})
		end,
	},

	-- Open picker to jump cursor to a specific line
	["doc:go-to-line"] = {
		requires = editor_command_requires,
		perform = function()
		local view = dv()

		local items
		-- Lazily builds fuzzy-search items from current document lines
		local function init_items()
			if items then
				return
			end
			items = {}
			-- Shows the line text in fuzzy matching while keeping metadata
			local mt = {
				__tostring = function(x)
					return x.text
				end,
			}
			for i, line in ipairs(view.doc.lines) do
				local item = { text = line:sub(1, -2), line = i, info = "line: " .. i }
				table.insert(items, setmetatable(item, mt))
			end
		end

		-- Resolves selection or typed coordinates, then jumps to the target line
		core.command_view:enter({
			caption = "Go To Line",
			submit = function(text, item)
				local line, col
				if item then
					line, col = item.line, 1
				else
					local why
					line, col, why = navigate.parse_line_col(text or "")
					if why == "empty" then
						return
					end
				end

				if not line then
					core.error("Invalid line number or unmatched string")
					return
				end
				local ok, err = navigate.jump_to_line_col(line, col, view)
				if not ok then
					core.error(err)
				end
			end,
			-- Suggests line-content matches when input is not numeric coordinates
			suggest = function(text)
				if not text:find("^%s*%d*%s*$") and not text:find("^%s*%d+%s*:%s*%d*%s*$") then
					init_items()
					return common.fuzzy_match(items, text)
				end
			end,
		})
		end,
	},

	-- Jump to the next outline point
	["doc:outline-next"] = {
		requires = editor_command_requires,
		perform = function()
		outliner():jump(1, dv())
		end,
	},

	-- Jump to the previous outline point
	["doc:outline-previous"] = {
		requires = editor_command_requires,
		perform = function()
		outliner():jump(-1, dv())
		end,
	},

	-- Jump to the first outline point
	["doc:outline-first"] = {
		requires = editor_command_requires,
		perform = function()
		outliner():jump_edge("first", dv())
		end,
	},

	-- Jump to the last outline point
	["doc:outline-last"] = {
		requires = editor_command_requires,
		perform = function()
		outliner():jump_edge("last", dv())
		end,
	},

	-- Debug helper to show active syntax selection details
	["doc:show-syntax"] = {
		requires = editor_command_requires,
		perform = function()
		text_syntax.show_syntax(doc())
		end,
	},

	-- Debug helper to show embedded syntax stack at the current line
	["doc:show-embedded-syntax"] = {
		requires = editor_command_requires,
		perform = function()
		text_syntax.show_embedded_syntax(doc())
		end,
	},

	-- Save an exact selection anchor independently from later navigation
	["doc:selection-begin"] = {
		requires = selection_command_requires,
		perform = function()
			local active_doc = doc()
			local line, col = active_doc:get_selection()
			selection_begin_positions[active_doc] = { line = line, col = col }

			return true
		end,
	},

	-- Select from the saved position to the current caret and consume the saved position
	["doc:selection-end"] = {
		requires = selection_command_requires,
		perform = function()
			local active_doc = doc()
			local begin = selection_begin_positions[active_doc]

			if not begin then
				core.error("No selection begin is set")

				return false
			end

			local line, col = active_doc:get_selection()
			selection_begin_positions[active_doc] = nil
			-- The first endpoint is Lx's live caret; the saved position remains the anchor
			active_doc:set_selection(line, col, begin.line, begin.col)
			dv():sync_cursor_column_from_selection()
			dv().blink_timer = 0
			dv():scroll_to_make_visible(line, col)
			core.redraw = true

			return true
		end,
	},

	-- Insert one secondary cursor at an explicit document position
	["doc:insert-cursor-at"] = {
		requires = editor_command_requires,
		predicate = multi_cursor_commands_enabled,
		perform = function(_, args)
			if type(args) ~= "table" or args.row == nil and args.col == nil then
				core.error("Command expects arguments")

				return false
			end

			local current_row, current_col = doc():get_selection()
			local row = args.row ~= nil and tonumber(args.row) or current_row
			local col = args.col ~= nil and tonumber(args.col) or current_col

			if not row or not col
				or row ~= row or col ~= col
				or row == math.huge or row == -math.huge
				or col == math.huge or col == -math.huge
			then
				core.error("Command expects arguments")

				return false
			end

			return dv():insert_cursor_at(math.floor(row), math.floor(col))
		end,
	},

	-- Add cursors one displayed row above every current selection head
	["doc:insert-cursor-above"] = {
		requires = editor_command_requires,
		predicate = multi_cursor_commands_enabled,
		perform = function()
			return dv():insert_cursors_vertical(-1)
		end,
	},

	-- Add cursors one displayed row below every current selection head
	["doc:insert-cursor-below"] = {
		requires = editor_command_requires,
		predicate = multi_cursor_commands_enabled,
		perform = function()
			return dv():insert_cursors_vertical(1)
		end,
	},

	-- Replace one multiline selection with a caret at each selected line end
	["doc:insert-cursor-at-eol"] = {
		requires = editor_command_requires,
		predicate = multi_cursor_commands_enabled,
		perform = function()
			return dv():insert_cursors_at_line_ends()
		end,
	},

		-- Move to the previous character without extending selection
		["doc:move-to-previous-char"] = {
			requires = editor_command_requires,
			perform = function()
				if doc():has_any_selection() then
					doc():collapse_selections(false, translate.previous_char, dv())
				else
					doc():move_to(translate.previous_char)
				end
			end,
		},

		-- Select to the previous character
		["doc:select-to-previous-char"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(translate.previous_char, dv())
			end,
		},

		-- Delete to the previous character
		["doc:delete-to-previous-char"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(translate.previous_char, dv())
			end,
		},

		-- Move to the next character without extending selection
		["doc:move-to-next-char"] = {
			requires = editor_command_requires,
			perform = function()
				if doc():has_any_selection() then
					doc():collapse_selections(true, translate.next_char, dv())
				else
					doc():move_to(translate.next_char)
				end
			end,
		},

		-- Select to the next character
		["doc:select-to-next-char"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(translate.next_char, dv())
			end,
		},

		-- Delete to the next character
		["doc:delete-to-next-char"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(translate.next_char, dv())
			end,
		},

		-- Move to the previous word start
		["doc:move-to-previous-word-start"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(translate.previous_word_start, dv())
			end,
		},

		-- Select to the previous word start
		["doc:select-to-previous-word-start"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(translate.previous_word_start_select, dv())
			end,
		},

		-- Delete to the previous word start
		["doc:delete-to-previous-word-start"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(translate.previous_word_start_delete, dv())
			end,
		},

		-- Move to the next word end
		["doc:move-to-next-word-end"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(translate.next_word_end, dv())
			end,
		},

		-- Select to the next word end
		["doc:select-to-next-word-end"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(translate.next_word_end_select, dv())
			end,
		},

		-- Delete to the next word end
		["doc:delete-to-next-word-end"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(translate.next_word_end_delete, dv())
			end,
		},

		-- Move to the previous block start
		["doc:move-to-previous-block-start"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(translate.previous_block_start, dv())
			end,
		},

		-- Select to the previous block start
		["doc:select-to-previous-block-start"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(translate.previous_block_start, dv())
			end,
		},

		-- Delete to the previous block start
		["doc:delete-to-previous-block-start"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(translate.previous_block_start, dv())
			end,
		},

		-- Move to the next block end
		["doc:move-to-next-block-end"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(translate.next_block_end, dv())
			end,
		},

		-- Select to the next block end
		["doc:select-to-next-block-end"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(translate.next_block_end, dv())
			end,
		},

		-- Delete to the next block end
		["doc:delete-to-next-block-end"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(translate.next_block_end, dv())
			end,
		},

		-- Move to the start of the document
		["doc:move-to-start-of-doc"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(translate.start_of_doc, dv())
				finish_doc_edge_move(1, 1)
			end,
		},

		-- Select to the start of the document
		["doc:select-to-start-of-doc"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(translate.start_of_doc, dv())
				finish_doc_edge_move(1, 1)
			end,
		},

		-- Delete to the start of the document
		["doc:delete-to-start-of-doc"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(translate.start_of_doc, dv())
			end,
		},

		-- Move to the end of the document
		["doc:move-to-end-of-doc"] = {
			requires = editor_command_requires,
			perform = function()
				local active_doc = doc()
				local last_line = #active_doc.lines
				local last_col = #active_doc.lines[last_line]

				doc():move_to(translate.end_of_doc, dv())
				finish_doc_edge_move(last_line, last_col)
			end,
		},

		-- Select to the end of the document
		["doc:select-to-end-of-doc"] = {
			requires = editor_command_requires,
			perform = function()
				local active_doc = doc()
				local last_line = #active_doc.lines
				local last_col = #active_doc.lines[last_line]

				active_doc:select_to(translate.end_of_doc, dv())
				finish_doc_edge_move(last_line, last_col)
			end,
		},

		-- Delete to the end of the document
		["doc:delete-to-end-of-doc"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(translate.end_of_doc, dv())
			end,
		},

		-- Move to the start of the line and refresh preferred x position
		["doc:move-to-start-of-line"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(translate.start_of_line, dv())
			end,
		},

		-- Select to the start of the line
		["doc:select-to-start-of-line"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(translate.start_of_line, dv())
			end,
		},

		-- Delete to the start of the line
		["doc:delete-to-start-of-line"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(translate.start_of_line, dv())
			end,
		},

		-- Move to the end of the line and refresh preferred x position
		["doc:move-to-end-of-line"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(translate.end_of_line, dv())
			end,
		},

		-- Select to the end of the line
		["doc:select-to-end-of-line"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(translate.end_of_line, dv())
			end,
		},

		-- Delete to the end of the line
		["doc:delete-to-end-of-line"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(translate.end_of_line, dv())
			end,
		},

		-- Move to the start of the current word
		["doc:move-to-start-of-word"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(translate.start_of_word, dv())
			end,
		},

		-- Select to the start of the current word
		["doc:select-to-start-of-word"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(translate.start_of_word, dv())
			end,
		},

		-- Delete to the start of the current word
		["doc:delete-to-start-of-word"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(translate.start_of_word, dv())
			end,
		},

		-- Move to the end of the current word
		["doc:move-to-end-of-word"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(translate.end_of_word, dv())
			end,
		},

		-- Select to the end of the current word
		["doc:select-to-end-of-word"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(translate.end_of_word, dv())
			end,
		},

		-- Delete to the end of the current word
		["doc:delete-to-end-of-word"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(translate.end_of_word, dv())
			end,
		},

		-- Move to the previous visual line
		["doc:move-to-previous-line"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(TextEditor.translate.previous_line, dv())
			end,
		},

		-- Select to the previous visual line
		["doc:select-to-previous-line"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(TextEditor.translate.previous_line, dv())
			end,
		},

		-- Delete to the previous visual line
		["doc:delete-to-previous-line"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(TextEditor.translate.previous_line, dv())
			end,
		},

		-- Move to the next visual line
		["doc:move-to-next-line"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(TextEditor.translate.next_line, dv())
			end,
		},

		-- Select to the next visual line
		["doc:select-to-next-line"] = {
			requires = editor_command_requires,
			perform = function()
				doc():select_to(TextEditor.translate.next_line, dv())
			end,
		},

		-- Delete to the next visual line
		["doc:delete-to-next-line"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(TextEditor.translate.next_line, dv())
			end,
		},

		-- Move to the previous page
		["doc:move-to-previous-page"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(TextEditor.translate.previous_page, dv())
			end,
		},

		-- Select to the previous page
		["doc:select-to-previous-page"] = {
			requires = editor_command_requires,
			perform = function()
				if doc():get_selection_count() > 1 then
					doc():select_to(TextEditor.translate.previous_page, dv())

					return
				end

				dv():move_viewport_page(-1, 1, true, true)
			end,
		},

		-- Delete to the previous page
		["doc:delete-to-previous-page"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(TextEditor.translate.previous_page, dv())
			end,
		},

		-- Move to the next page
		["doc:move-to-next-page"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(TextEditor.translate.next_page, dv())
			end,
		},

		-- Select to the next page
		["doc:select-to-next-page"] = {
			requires = editor_command_requires,
			perform = function()
				if doc():get_selection_count() > 1 then
					doc():select_to(TextEditor.translate.next_page, dv())

					return
				end

				dv():move_viewport_page(1, 1, true, true)
			end,
		},

		-- Delete to the next page
		["doc:delete-to-next-page"] = {
			requires = editor_command_requires,
			perform = function()
				doc():delete_to(TextEditor.translate.next_page, dv())
			end,
		},

		-- Move the cursor up by one page
		["doc:move-cursor-page-up"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(TextEditor.translate.previous_page, dv())
			end,
		},

		-- Move the cursor down by one page
		["doc:move-cursor-page-down"] = {
			requires = editor_command_requires,
			perform = function()
				doc():move_to(TextEditor.translate.next_page, dv())
			end,
		},

		-- Scroll the viewport up by one page without terminal jump behavior
		["doc:scroll-page-up"] = {
			requires = editor_command_requires,
			perform = function()
				dv():move_viewport_page(-1, 1, false)
			end,
		},

		-- Scroll the viewport down by one page without terminal jump behavior
		["doc:scroll-page-down"] = {
			requires = editor_command_requires,
			perform = function()
				dv():move_viewport_page(1, 1, false)
			end,
		},

		-- Scroll the viewport up by one page with terminal jump behavior
		["doc:scroll-cursor-page-up"] = {
			requires = editor_command_requires,
			perform = function()
				if doc():get_selection_count() > 1 then
					doc():move_to(TextEditor.translate.previous_page, dv())

					return
				end

				dv():move_viewport_page(-1, 1, true)
			end,
		},

		-- Scroll the viewport down by one page with terminal jump behavior
		["doc:scroll-cursor-page-down"] = {
			requires = editor_command_requires,
			perform = function()
				if doc():get_selection_count() > 1 then
					doc():move_to(TextEditor.translate.next_page, dv())

					return
				end

				dv():move_viewport_page(1, 1, true)
			end,
		},
}

command.register(commands)

-- Keep TextViewer navigation keys local to the read-only viewer context
keymap.bind("core", {
	text_viewer = {
		-- TextViewer shares word wrapping but keeps editing commands unavailable
		["alt+z"] = "doc:toggle-word-wrap",

		["left"] = "text-viewer:scroll-left",
		["right"] = "text-viewer:scroll-right",
		["up"] = "text-viewer:scroll-up",
		["down"] = "text-viewer:scroll-down",
		["ctrl+left"] = "text-viewer:scroll-left-large",
		["ctrl+right"] = "text-viewer:scroll-right-large",
		["home"] = "text-viewer:scroll-top",
		["end"] = "text-viewer:scroll-bottom",
		["ctrl+home"] = "text-viewer:scroll-leftmost",
		["ctrl+end"] = "text-viewer:scroll-rightmost-visible",
		-- Space follows the usual document-reading page controls
		["shift+space"] = "text-viewer:scroll-page-up",
		["space"] = "text-viewer:scroll-page-down",
		["pageup"] = "text-viewer:scroll-page-up",
		["pagedown"] = "text-viewer:scroll-page-down",
	},
})

-- Keep TextEditor movement and selection keys local to the editor context
keymap.bind("core", {
	text_editor = {
		-- File and editing shortcuts belong only to an editable text surface
		["ctrl+g"] = "doc:go-to-line",
		["ctrl+s"] = "doc:save",
		["ctrl+shift+s"] = "doc:save-as",
		-- Keep Ctrl+Y available for deleting lines while offering the other familiar redo shortcut
		["ctrl+shift+z"] = "doc:redo",
		["ctrl+z"] = "doc:undo",
		["alt+z"] = "doc:toggle-word-wrap",
		["ctrl+y"] = "doc:delete-lines",
		["ctrl+x"] = "doc:cut",
		["ctrl+c"] = "doc:copy",
		["ctrl+v"] = "doc:paste",
		["escape"] = "doc:select-none",
		["tab"] = "doc:indent",
		["shift+tab"] = "doc:unindent",
		["backspace"] = "doc:backspace",
		["shift+backspace"] = "doc:backspace",
		["ctrl+backspace"] = "doc:delete-to-previous-word-start",
		["ctrl+shift+backspace"] = "doc:delete-to-previous-word-start",
		["delete"] = "doc:delete",
		["shift+delete"] = "doc:delete",
		["ctrl+delete"] = "doc:delete-to-next-word-end",
		["ctrl+shift+delete"] = "doc:delete-to-next-word-end",
		["enter"] = "doc:newline",
		["keypad enter"] = "doc:newline",
		["shift+enter"] = "doc:newline-below",
		["shift+keypad enter"] = "doc:newline-below",
		["ctrl+shift+enter"] = "doc:newline-above",
		["ctrl+j"] = "doc:join-lines",
		["ctrl+a"] = "doc:select-all",
		["ctrl+l"] = "doc:select-lines",
		["ctrl+/"] = "doc:toggle-line-comments",
		["shift+ctrl+d"] = "doc:duplicate-lines",

		-- Cursor movement and selection remain local to TextEditor
		["ctrl+alt+up"] = "doc:insert-cursor-above",
		["ctrl+alt+down"] = "doc:insert-cursor-below",
		["shift+alt+i"] = "doc:insert-cursor-at-eol",
		["left"] = "doc:move-to-previous-char",
		["right"] = "doc:move-to-next-char",
		["up"] = "doc:move-to-previous-line",
		["down"] = "doc:move-to-next-line",
		["ctrl+left"] = "doc:move-to-previous-word-start",
		["ctrl+right"] = "doc:move-to-next-word-end",
		["ctrl+["] = "doc:move-to-previous-block-start",
		["ctrl+]"] = "doc:move-to-next-block-end",
		["home"] = "doc:move-to-start-of-line",
		["end"] = "doc:move-to-end-of-line",
		["ctrl+home"] = "doc:move-to-start-of-doc",
		["ctrl+end"] = "doc:move-to-end-of-doc",
		["pageup"] = "doc:scroll-cursor-page-up",
		["pagedown"] = "doc:scroll-cursor-page-down",

		["shift+left"] = "doc:select-to-previous-char",
		["shift+right"] = "doc:select-to-next-char",
		["shift+up"] = "doc:select-to-previous-line",
		["shift+down"] = "doc:select-to-next-line",
		["ctrl+shift+left"] = "doc:select-to-previous-word-start",
		["ctrl+shift+right"] = "doc:select-to-next-word-end",
		["ctrl+shift+["] = "doc:select-to-previous-block-start",
		["ctrl+shift+]"] = "doc:select-to-next-block-end",
		["shift+home"] = "doc:select-to-start-of-line",
		["shift+end"] = "doc:select-to-end-of-line",
		["ctrl+shift+home"] = "doc:select-to-start-of-doc",
		["ctrl+shift+end"] = "doc:select-to-end-of-doc",
		["shift+pageup"] = "doc:select-to-previous-page",
		["shift+pagedown"] = "doc:select-to-next-page",
	},
})

-- Add contextual formatting ahead of inherited shortcut fallbacks
keymap.extend("core", {
	text_editor = {
		["ctrl+b"] = "format:bold(shortcut: true)",
		["ctrl+i"] = "format:italic(shortcut: true)",
		["ctrl+u"] = "format:underline(shortcut: true)",
	},
})
