-- lx-bundle:true

-- This file is part of Lx and Qui, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Lx Interim License. See LICENSE for details.

--- Provide a self-contained Lua-owned Tree with nested node data
-- @module qui.tree

local qui = require("qui")
local colors = require("qui.colors")
local glyphs = require("qui.glyph")
local metrics = require("qui.metrics")
local theme = require("qui.theme")
local Font = require("qui.font")
local Scrollbar = require("qui.scrollbar")

local Tree = {}
local color_flags = setmetatable({}, { __mode = "k" })	-- One reusable flag set follows each live Tree table

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

	return math.floor(value + 0.5)
end

--- Confirm that one caller table is a gapless list
-- @param source table caller-supplied list
-- @param path string readable declaration location
local function check_list(source, path)
	local count = 0
	local highest = 0
	for key in pairs(source) do
		assert(type(key) == "number" and key >= 1 and key % 1 == 0,
			path .. " must use positive whole-number positions")
		count = count + 1
		highest = math.max(highest, key)
	end
	assert(highest == count, path .. " must be a gapless list")
end

--- Copy nested declarations into private nodes while preserving every node's application data
-- Values are optional because duplicate captions are normal in a Tree; supplied values remain unique selection keys
-- @param source table caller-supplied node list
-- @return table roots normalized root nodes
-- @return table nodes_by_value lookup for explicitly valued nodes
local function prepare_nodes(source)
	assert(type(source) == "table", "Tree items must be a table")

	local nodes_by_value = {}
	local visiting = {}
	local function prepare_list(source_list, parent, depth, path)
		assert(type(source_list) == "table", path .. " must be a table")
		check_list(source_list, path)
		assert(not visiting[source_list], path .. " cannot contain itself")
		visiting[source_list] = true

		local nodes = {}
		for index, source_node in ipairs(source_list) do
			local node_path = path .. "[" .. index .. "]"
			assert(type(source_node) == "table", node_path .. " must be a table")
			assert(type(source_node.text) == "string", node_path .. ".text must be a string")
			assert(source_node.enabled == nil or type(source_node.enabled) == "boolean",
				node_path .. ".enabled must be a boolean")
			assert(source_node.expanded == nil or type(source_node.expanded) == "boolean",
				node_path .. ".expanded must be a boolean")
			local value = source_node.value
			if value ~= nil then
				assert(
					type(value) == "string" or type(value) == "number" or type(value) == "boolean",
					node_path .. ".value must be a string, number, or boolean"
				)
				assert(type(value) ~= "number" or value == value and math.abs(value) < math.huge,
					node_path .. ".value number must be finite")
				assert(nodes_by_value[value] == nil, "Tree node values must be unique")
			end

			local node = {
				text = source_node.text,
				value = value,
				enabled = source_node.enabled ~= false,
				expanded = source_node.expanded == true,
				data = source_node.data,	-- Every branch and leaf keeps its own untouched application value
				parent = parent,
				depth = depth,
				children = {},
			}
			nodes[index] = node
			if value ~= nil then
				nodes_by_value[value] = node
			end
			if source_node.children ~= nil then
				node.children = prepare_list(source_node.children, node, depth + 1, node_path .. ".children")
			end
			node.has_children = #node.children > 0
		end

		visiting[source_list] = nil
		return nodes
	end

	local roots = prepare_list(source, nil, 0, "Tree items")

	return roots, nodes_by_value
end

--- Report whether one row represents a branch
-- @param node table normalized row
-- @return boolean branch
local function node_has_children(node)
	return node.has_children
end

--- Rebuild the ordered rows represented by currently expanded branches
-- @param self table Tree control
local function rebuild_visible(self)
	if self._virtual_rows then
		self._virtual_rows:clear_rows()
		return
	end

	local visible = {}
	local function append(nodes)
		for _, node in ipairs(nodes) do
			visible[#visible + 1] = node
			if node.expanded then
				append(node.children)
			end
		end
	end
	append(self.nodes)

	self.visible_nodes = visible
	self.visible_index = {}
	for index, node in ipairs(visible) do
		self.visible_index[node] = index
	end
end

--- Return whether one node can become the active selection
-- @param node table|nil requested node
-- @return boolean enabled
local function node_enabled(node)
	return node ~= nil and node.enabled
end

--- Find the next enabled visible node from one position
-- @param self table Tree control
-- @param start integer first row to inspect
-- @param direction integer negative for earlier or positive for later
-- @return table|nil node
local function find_enabled(self, start, direction)
	local index = start
	while index >= 1 and index <= #self.visible_nodes do
		local node = self.visible_nodes[index]
		if node.enabled then
			return node
		end
		index = index + direction
	end

	return nil
end

--- Return how many complete rows fit with or without a bottom Scrollbar
-- @param self table Tree control
-- @param horizontal_visible boolean whether the bottom strip consumes row room
-- @return integer count
local function available_row_count(self, horizontal_visible)
	local horizontal_height = horizontal_visible and self.horizontal_scrollbar.thickness or 0
	local inner_height = math.max(
		0,
		self.arranged_height - 2 - (self._header_height or 0) - horizontal_height
	)

	return math.max(1, math.floor(inner_height / math.max(1, self.row_height)))
end

--- Return the row viewport width with or without the right Scrollbar
-- @param self table Tree control
-- @param vertical_visible boolean whether the right strip consumes column room
-- @return integer width
local function available_row_width(self, vertical_visible)
	local vertical_width = vertical_visible and self.scrollbar.thickness or 0

	return math.max(0, self.arranged_width - 2 - vertical_width)
end

--- Resolve both Scrollbars together because either strip can make the other necessary
-- Ordinary Trees have no horizontal source and retain their previous behavior
-- @param self table Tree control
-- @return boolean vertical_visible
-- @return boolean horizontal_visible
local function scrollbar_visibility(self)
	if not self.horizontal_scrollbar or not self._horizontal_content_width then
		return #self.visible_nodes > available_row_count(self, false), false
	end

	local vertical_visible = false
	local horizontal_visible = false
	-- Visibility can only grow: less row or column room cannot remove an overflow
	for _ = 1, 3 do
		vertical_visible = vertical_visible or
			#self.visible_nodes > available_row_count(self, horizontal_visible)
		local available_width = available_row_width(self, vertical_visible)
		horizontal_visible = horizontal_visible or
			self:_horizontal_content_width(available_width) > available_width
	end

	return vertical_visible, horizontal_visible
end

--- Return how many complete rows fit inside the current frame
-- @param self table Tree control
-- @return integer count
local function visible_row_count(self)
	local _, horizontal_visible = scrollbar_visibility(self)

	return available_row_count(self, horizontal_visible)
end

--- Report whether expanded rows overflow the Control
-- @param self table Tree control
-- @return boolean visible
local function scrollbar_visible(self)
	local vertical_visible = scrollbar_visibility(self)

	return vertical_visible
end

--- Report whether columns overflow the Control
-- @param self table Tree control
-- @return boolean visible
local function horizontal_scrollbar_visible(self)
	local _, horizontal_visible = scrollbar_visibility(self)

	return horizontal_visible
end

--- Return the inside-right Scrollbar rectangle
-- @param self table Tree control
-- @return integer x
-- @return integer y
-- @return integer width
-- @return integer height
local function scrollbar_bounds(self)
	local width = math.min(self.scrollbar.thickness, math.max(0, self.arranged_width - 2))
	local x = math.max(1, self.arranged_width - 1 - width)
	local top = 1 + (self._header_height or 0)
	local bottom = self.arranged_height - 1
	if horizontal_scrollbar_visible(self) then
		bottom = bottom - self.horizontal_scrollbar.thickness
	end

	return x, top, width, math.max(0, bottom - top)
end

--- Return the inside-bottom horizontal Scrollbar rectangle
-- @param self table Tree control
-- @return integer x
-- @return integer y
-- @return integer width
-- @return integer height
local function horizontal_scrollbar_bounds(self)
	local height = math.min(self.horizontal_scrollbar.thickness, math.max(0, self.arranged_height - 2))
	local y = math.max(1, self.arranged_height - 1 - height)
	local right = self.arranged_width - 1
	if scrollbar_visible(self) then
		right = scrollbar_bounds(self)
	end

	return 1, y, math.max(0, right - 1), height
end

--- Return the exclusive right edge available to rows
-- @param self table Tree control
-- @return integer right
local function item_right(self)
	if scrollbar_visible(self) then
		local x = scrollbar_bounds(self)
		return x
	end

	return math.max(1, self.arranged_width - 1)
end

--- Return the exclusive bottom edge available to rows
-- @param self table Tree control
-- @return integer bottom
local function item_bottom(self)
	if horizontal_scrollbar_visible(self) then
		local _, y = horizontal_scrollbar_bounds(self)
		return y
	end

	return math.max(1, self.arranged_height - 1)
end

--- Keep the first visible row valid and optionally reveal one node
-- @param self table Tree control
-- @param reveal table|nil node that must remain visible
local function constrain_scroll(self, reveal)
	local rows = visible_row_count(self)
	local maximum_first = math.max(1, #self.visible_nodes - rows + 1)
	self.first_visible = math.max(1, math.min(self.first_visible, maximum_first))

	local reveal_index = reveal and self.visible_index[reveal] or nil
	if reveal_index then
		if reveal_index < self.first_visible then
			self.first_visible = reveal_index
		elseif reveal_index >= self.first_visible + rows then
			self.first_visible = reveal_index - rows + 1
		end
		self.first_visible = math.max(1, math.min(self.first_visible, maximum_first))
	end
end

--- Keep the embedded Scrollbar synchronized with whole Tree rows
-- @param self table Tree control
local function sync_scrollbar(self)
	local rows = visible_row_count(self)
	local maximum_first = math.max(1, #self.visible_nodes - rows + 1)
	self.scrollbar.min = 1
	self.scrollbar.max = maximum_first
	self.scrollbar.value = self.first_visible
	self.scrollbar.line = 1
	self.scrollbar.page = rows
	self.scrollbar.enabled = self.enabled

	if self.horizontal_scrollbar then
		local row_width = available_row_width(self, scrollbar_visible(self))
		local content_width = self:_horizontal_content_width(row_width)
		local maximum_offset = math.max(0, content_width - row_width)
		self.horizontal_offset = math.max(0, math.min(self.horizontal_offset, maximum_offset))
		self.horizontal_scrollbar.min = 0
		self.horizontal_scrollbar.max = maximum_offset
		self.horizontal_scrollbar.value = self.horizontal_offset
		self.horizontal_scrollbar.line = math.max(1, math.floor(row_width / 12))
		self.horizontal_scrollbar.page = row_width
		self.horizontal_scrollbar.enabled = self.enabled
	end
end

--- Keep internal row selection and the public Tree or VirtualTree value in agreement
-- @param self table Tree control
-- @param node table|nil normalized row
local function store_selection(self, node)
	self._selected_node = node
	if self._virtual_rows then
		self.selected_node = node and node.source or nil
		self.selected = self.selected_node
	else
		self.selected_node = node
		self.selected = node and node.value or nil
	end
end

--- Store one selected node and notify application code only for a real change
-- @param self table Tree control
-- @param node table|nil requested node
-- @param invalidate boolean whether live drawing must be requested
-- @return boolean|nil changed
-- @return string|nil error_text
local function select_node(self, node, invalidate)
	if node ~= nil and not node_enabled(node) then
		return nil, "Tree selection must name an enabled node"
	end
	if self._selected_node == node then
		return true
	end

	local previous = self._selected_node
	store_selection(self, node)
	constrain_scroll(self, node)
	if invalidate then
		local invalidated, invalidate_error = self:invalidate()
		if not invalidated then
			store_selection(self, previous)
			constrain_scroll(self, previous)

			return nil, invalidate_error
		end
	end
	if self.on_change then
		self:on_change(self.selected, self.selected_node)
	end

	return true
end

--- Expand or collapse one branch and keep hidden selection on the branch
-- @param self table Tree control
-- @param node table requested branch
-- @param expanded boolean requested state
-- @param invalidate boolean whether live drawing must be requested
-- @return boolean|nil changed
-- @return string|nil error_text
local function set_node_expanded(self, node, expanded, invalidate)
	if self._virtual_rows then
		return self._virtual_rows:set_expanded(self, node, expanded, invalidate)
	end
	if not node_has_children(node) or node.expanded == expanded then
		return true
	end

	local previous_selection = self._selected_node
	node.expanded = expanded
	rebuild_visible(self)
	if not expanded and self._selected_node and self.visible_index[self._selected_node] == nil then
		store_selection(self, node)
	end
	constrain_scroll(self, self._selected_node)

	if invalidate then
		local invalidated, invalidate_error = self:invalidate()
		if not invalidated then
			node.expanded = not expanded
			store_selection(self, previous_selection)
			rebuild_visible(self)
			constrain_scroll(self, previous_selection)

			return nil, invalidate_error
		end
	end
	if self._selected_node ~= previous_selection and self.on_change then
		self:on_change(self.selected, self.selected_node)
	end

	return true
end

--- Return the node under one local pointer position
-- @param self table Tree control
-- @param x number local horizontal position
-- @param y number local vertical position
-- @return table|nil node
-- @return integer|nil row_index
local function node_at_position(self, x, y)
	local top = 1 + (self._header_height or 0)
	if x < 1 or x >= item_right(self) or y < top or y >= item_bottom(self) then
		return nil
	end

	local row = math.floor((y - top) / math.max(1, self.row_height))
	local index = self.first_visible + row

	return self.visible_nodes[index], index
end

--- Report whether the pointer is over one branch's actual arrow slot
-- @param self table Tree control
-- @param node table|nil node under the pointer
-- @param x number local horizontal position
-- @return boolean inside
local function pointer_in_indicator(self, node, x)
	if node == nil or not node_has_children(node) then
		return false
	end

	local indicator_x = self._indicator_x and self:_indicator_x(node, item_right(self))
		or self.padding_x + node.depth * self.indent

	return x >= indicator_x and x < indicator_x + self.indicator_size
end

--- Report whether one pointer event belongs to the embedded Scrollbar
-- @param self table Tree control
-- @param event table local pointer event
-- @return boolean inside
local function pointer_in_scrollbar(self, event)
	if not scrollbar_visible(self) or type(event.x) ~= "number" or type(event.y) ~= "number" then
		return false
	end

	local x, y, width, height = scrollbar_bounds(self)

	return event.x >= x and event.x < x + width and event.y >= y and event.y < y + height
end

--- Report whether one pointer event belongs to the horizontal Scrollbar
-- @param self table Tree control
-- @param event table local pointer event
-- @return boolean inside
local function pointer_in_horizontal_scrollbar(self, event)
	if not horizontal_scrollbar_visible(self) or type(event.x) ~= "number" or type(event.y) ~= "number" then
		return false
	end

	local x, y, width, height = horizontal_scrollbar_bounds(self)

	return event.x >= x and event.x < x + width and event.y >= y and event.y < y + height
end

--- Select the next enabled visible node by an ordinary or page-sized step
-- @param self table Tree control
-- @param direction integer negative for earlier or positive for later
-- @param distance integer requested number of rows
-- @return table|nil node
local function navigation_target(self, direction, distance)
	if #self.visible_nodes == 0 then
		return nil
	end

	local current = self._selected_node and self.visible_index[self._selected_node] or nil
	if current == nil then
		return find_enabled(self, direction > 0 and 1 or #self.visible_nodes, direction)
	end

	local target = math.max(1, math.min(#self.visible_nodes, current + direction * distance))

	return find_enabled(self, target, direction) or find_enabled(self, target, -direction)
end

--- Select one central color identifier for every Tree instance
-- @param self table Tree control
-- @param type string requested visual type
-- @param flags table current Tree or row conditions
-- @return string color_identifier
function Tree.default_color(self, type, flags)
	if type == "background" then
		if flags.down then
			return colors.tree_selection_bg
		elseif flags.hovering then
			return colors.tree_hover_bg
		end

		return colors.tree_bg
	elseif type == "frame" then
		if flags.focused then
			return colors.tree_focused_frame
		elseif flags.hovering then
			return colors.input_hover_frame
		end

		return colors.tree_frame
	elseif type == "text" then
		if flags.grayed then
			return colors.tree_disabled_text
		elseif flags.down then
			return colors.tree_selection_text
		end

		return colors.tree_text
	elseif type == "symbol" then
		if flags.grayed then
			return colors.tree_disabled_symbol
		elseif flags.hovering then
			return colors.tree_hover_symbol
		end

		return colors.tree_symbol
	end

	return nil
end

--- Create one self-contained Tree for use inside 'qui.pane.controls'
-- @param options table nested items, selection, sizing, placement, and optional on_change handler
-- @return table control unrestricted Lua Control declaration
function Tree.new(options)
	assert(type(options) == "table", "Tree expects a table")
	assert(options.on_change == nil or type(options.on_change) == "function", "Tree on_change must be a function")
	assert(options.on_color == nil or type(options.on_color) == "function", "Tree on_color must be a function")
	assert(options.default_color == nil, "Tree default_color is fixed; use on_color for dynamic changes")

	local control = options
	control.nodes, control.nodes_by_value = prepare_nodes(control.items or {})
	control.items = nil	-- The private node tree is the durable self-contained data version
	control.font = control.font or Font.default
	assert(control.font, "Tree requires a font")
	control.padding_x = dip_value(control.padding_x, theme.resolve_metric(metrics.tree_padding_x), "Tree padding_x")
	control.padding_y = dip_value(control.padding_y, theme.resolve_metric(metrics.tree_padding_y), "Tree padding_y")
	control.indent = dip_value(control.indent, theme.resolve_metric(metrics.tree_indent), "Tree indent")
	control.indicator_size = dip_value(
		control.indicator_size, theme.resolve_metric(metrics.tree_indicator_size), "Tree indicator_size")
	control.width = control.width and dip_value(control.width, 0, "Tree width") or nil
	control.height = control.height and dip_value(control.height, 0, "Tree height") or nil
	control.row_height = control.row_height and dip_value(control.row_height, 0, "Tree row_height") or 1
	assert(control.row_height > 0, "Tree row_height must be greater than zero")
	control.enabled = control.enabled ~= false
	control.focusable = control.focusable ~= false
	control.tab_stop = control.tab_stop ~= false
	control.accepts_pointer = control.accepts_pointer ~= false
	control.default_color = Tree.default_color
	control.hovered = false
	control.hovered_node = nil
	control.hovered_indicator_node = nil
	control.focused = false
	control.first_visible = 1
	control.arranged_width = control.width or 0
	control.arranged_height = control.height or 0
	rebuild_visible(control)
	control.scrollbar = Scrollbar {
		focusable = false,
		tab_stop = false,
		on_change = function(_, value)
			control.first_visible = math.floor(value + 0.5)
			constrain_scroll(control)
		end,
	}
	if control._horizontal_content_width then
		control.horizontal_offset = 0
		control.horizontal_scrollbar = Scrollbar {
			horizontal = true,
			focusable = false,
			tab_stop = false,
			on_change = function(_, value)
				control.horizontal_offset = math.floor(value + 0.5)
			end,
		}
	end

	--- Share the final row capacity with VirtualTree refresh and reveal operations
	-- @return integer count
	function control:_visible_row_count()
		return visible_row_count(self)
	end

	local initial_node
	if control.selected ~= nil then
		initial_node = control.nodes_by_value[control.selected]
		assert(initial_node ~= nil, "Tree selected value does not exist")
		assert(initial_node.enabled, "Tree initial selection must name an enabled node")
		-- Open the complete parent chain so the requested initial node is truly visible
		local parent = initial_node.parent
		while parent do
			parent.expanded = true
			parent = parent.parent
		end
		rebuild_visible(control)
	end
	store_selection(control, initial_node)
	color_flags[control] = {
		grayed = not control.enabled,
		down = false,
		hovering = false,
		focused = false,
	}

	--- Measure expanded node captions and derive a compact natural Tree height
	function control:measure(control_canvas, minimum_width, minimum_height, maximum_width, maximum_height)
		local widest = 0
		local _, font_height = control_canvas:measure_text(self.font, "Mg")
		self.row_height = math.max(self.row_height, font_height + self.padding_y * 2)
		local measured_rows = math.min(#self.visible_nodes, 8)
		for index = 1, measured_rows do
			local node = self.visible_nodes[index]
			local text_width = control_canvas:measure_text(self.font, node.text)
			local indent_width = self.padding_x + node.depth * self.indent + self.indent
			widest = math.max(widest, indent_width + text_width + self.padding_x)
		end

		local natural_rows = math.max(1, measured_rows)
		local natural_width = self.width or math.max(140, widest + 2)
		if self.width == nil and #self.visible_nodes > natural_rows then
			natural_width = natural_width + self.scrollbar.thickness
		end
		local natural_height = self.height or self.row_height * natural_rows + 2 + (self._header_height or 0)
		if self.height == nil and self.horizontal_scrollbar and
			self:_horizontal_content_width(math.max(0, natural_width - 2)) > math.max(0, natural_width - 2)
		then
			natural_height = natural_height + self.horizontal_scrollbar.thickness
		end

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

	--- Draw only the expanded rows inside the current viewport
	function control:draw(control_canvas)
		local width, height = control_canvas:size()
		self.arranged_width = width
		self.arranged_height = height
		constrain_scroll(self)
		sync_scrollbar(self)

		local flags = assert(color_flags[self], "Tree color flags are unavailable")
		flags.grayed = not self.enabled
		flags.down = false
		flags.hovering = false
		flags.focused = self.focused
		control_canvas:fill_rect(0, 0, width, height, self:get_color("background", flags))
		if self._draw_header then
			self:_draw_header(control_canvas, item_right(self))
		end

		local final_index = math.min(#self.visible_nodes, self.first_visible + visible_row_count(self) - 1)
		local row_right = item_right(self)
		local row_top = 1 + (self._header_height or 0)
		for index = self.first_visible, final_index do
			local node = self.visible_nodes[index]
			local row_y = row_top + (index - self.first_visible) * self.row_height
			flags.grayed = not self.enabled or not node.enabled
			flags.down = self._virtual_rows and node.source == self.selected_node or node == self._selected_node
			flags.hovering = self.enabled and node.enabled and node == self.hovered_node
			flags.focused = false
			if flags.down or flags.hovering then
				control_canvas:fill_rect(1, row_y, math.max(0, row_right - 1), self.row_height,
					self:get_color("background", flags))
			end

			if self._draw_row then
				self:_draw_row(control_canvas, node, row_y, row_right, flags)
			else
				local indicator_x = self._indicator_x and self:_indicator_x(node, item_right(self))
					or self.padding_x + node.depth * self.indent
				if node_has_children(node) then
					local indicator_y = row_y + math.floor((self.row_height - self.indicator_size) / 2)
					flags.hovering = node == self.hovered_indicator_node
					control_canvas:draw_glyph(
						node.expanded and glyphs.tree_expanded or glyphs.tree_collapsed,
						indicator_x,
						indicator_y,
						self.indicator_size,
						self.indicator_size,
						self:get_color("symbol", flags)
					)
				end
				local _, text_height = control_canvas:measure_text(self.font, node.text)
				local text_y = row_y + math.floor((self.row_height - text_height) / 2)
				control_canvas:draw_text(
					self.font,
					node.text,
					indicator_x + self.indent,
					text_y,
					self:get_color("text", flags)
				)
			end
		end

		if scrollbar_visible(self) then
			local x, y, scrollbar_width, scrollbar_height = scrollbar_bounds(self)
			-- Paint one DIP under the right and bottom frame so fractional scaling cannot expose a seam
			self.scrollbar:draw_at(control_canvas, x, y, scrollbar_width, scrollbar_height, 1, 1)
		end
		if horizontal_scrollbar_visible(self) then
			local x, y, scrollbar_width, scrollbar_height = horizontal_scrollbar_bounds(self)
			-- Paint through the bottom frame while leaving the right Scrollbar's corner untouched
			self.horizontal_scrollbar:draw_at(
				control_canvas,
				x,
				y,
				scrollbar_width,
				scrollbar_height,
				0,
				1
			)
		end

		flags.grayed = not self.enabled
		flags.down = false
		flags.hovering = self.hovered
		flags.focused = self.focused
		control_canvas:draw_rect(0, 0, width, height, 1, self:get_color("frame", flags))
	end

	--- Handle expansion, selection, scrolling, focus, and familiar Tree navigation
	function control:event(event)
		sync_scrollbar(self)
		if self._header_event then
			local header_result = self:_header_event(event, item_right(self))
			if header_result then
				return header_result
			end
		end
		local vertical_was_hovered = self.scrollbar.hovered_thumb
		local horizontal_was_hovered =
			self.horizontal_scrollbar and self.horizontal_scrollbar.hovered_thumb or false
		local targets_horizontal = self.horizontal_scrollbar and
			(self.horizontal_scrollbar.dragging or pointer_in_horizontal_scrollbar(self, event))
		local targets_vertical = self.scrollbar.dragging or pointer_in_scrollbar(self, event)
		if targets_horizontal and
			(event.kind == "pointer_enter" or event.kind == "pointer_move" or
				event.kind == "pointer_down" or event.kind == "pointer_up")
		then
			local x, y, scrollbar_width, scrollbar_height = horizontal_scrollbar_bounds(self)
			self.hovered = true
			self.hovered_node = nil
			self.hovered_indicator_node = nil

			return self.horizontal_scrollbar:event_at(event, x, y, scrollbar_width, scrollbar_height)
		elseif targets_vertical and
			(event.kind == "pointer_enter" or event.kind == "pointer_move" or
				event.kind == "pointer_down" or event.kind == "pointer_up")
		then
			local x, y, scrollbar_width, scrollbar_height = scrollbar_bounds(self)
			self.hovered = true
			self.hovered_node = nil
			self.hovered_indicator_node = nil

			return self.scrollbar:event_at(event, x, y, scrollbar_width, scrollbar_height)
		elseif not self.scrollbar.dragging then
			self.scrollbar.hovered_thumb = false
		end
		if self.horizontal_scrollbar and not self.horizontal_scrollbar.dragging then
			self.horizontal_scrollbar.hovered_thumb = false
		end

		if event.kind == "pointer_enter" or event.kind == "pointer_move" then
			local previous = self.hovered_node
			local previous_indicator = self.hovered_indicator_node
			self.hovered = true
			self.hovered_node = node_at_position(self, event.x, event.y)
			self.hovered_indicator_node = pointer_in_indicator(self, self.hovered_node, event.x)
				and self.hovered_node or nil

			return {
				handled = true,
				redraw = event.kind == "pointer_enter" or
					previous ~= self.hovered_node or
					previous_indicator ~= self.hovered_indicator_node or
					vertical_was_hovered or
					horizontal_was_hovered,
				cursor = self._pointer_cursor and self:_pointer_cursor(event, item_right(self)) or nil,
			}
		elseif event.kind == "pointer_leave" then
			if scrollbar_visible(self) or self.scrollbar.dragging or self.scrollbar.hovered_thumb then
				local x, y, scrollbar_width, scrollbar_height = scrollbar_bounds(self)
				self.scrollbar:event_at(event, x, y, scrollbar_width, scrollbar_height)
			end
			if self.horizontal_scrollbar and
				(horizontal_scrollbar_visible(self) or
					self.horizontal_scrollbar.dragging or
					self.horizontal_scrollbar.hovered_thumb)
			then
				local x, y, scrollbar_width, scrollbar_height = horizontal_scrollbar_bounds(self)
				self.horizontal_scrollbar:event_at(event, x, y, scrollbar_width, scrollbar_height)
			end
			self.hovered = false
			self.hovered_node = nil
			self.hovered_indicator_node = nil
			return { handled = true, redraw = true, cursor = self._pointer_cursor and "arrow" or nil }
		elseif event.kind == "pointer_down" and event.button == "left" then
			local node = node_at_position(self, event.x, event.y)
			if node_enabled(node) then
				local indicator_x = self._indicator_x and self:_indicator_x(node, item_right(self))
					or self.padding_x + node.depth * self.indent
				if node_has_children(node) and event.x >= indicator_x and event.x < indicator_x + self.indent then
					assert(set_node_expanded(self, node, not node.expanded, false))
				else
					assert(select_node(self, node, false))
				end
			end

			return { handled = true, redraw = true, focus = "acquire" }
		elseif event.kind == "wheel" and self.horizontal_scrollbar and
			horizontal_scrollbar_visible(self) and ((event.dx or 0) ~= 0 or event.shift)
		then
			-- Trackpad movement and Shift+wheel move wide columns without disturbing the row position
			if event.dx ~= nil then
				return self.horizontal_scrollbar:event(event)
			end

			local horizontal_event = {}
			for key, value in pairs(event) do
				horizontal_event[key] = value
			end
			horizontal_event.dx = 0

			return self.horizontal_scrollbar:event(horizontal_event)
		elseif event.kind == "wheel" and event.dy ~= 0 then
			local direction = event.dy > 0 and -1 or 1
			local notches = math.max(1, math.floor(math.abs(event.dy) + 0.5))
			local previous = self.first_visible
			self.first_visible = self.first_visible + direction * notches * 3
			constrain_scroll(self)

			return { handled = true, redraw = previous ~= self.first_visible }
		elseif event.kind == "focus_gained" then
			self.focused = true
			return { redraw = true }
		elseif event.kind == "focus_lost" then
			self.focused = false
			local released_capture = false
			if self.scrollbar.dragging then
				local x, y, scrollbar_width, scrollbar_height = scrollbar_bounds(self)
				self.scrollbar:event_at(event, x, y, scrollbar_width, scrollbar_height)
				released_capture = true
			end
			if self.horizontal_scrollbar and self.horizontal_scrollbar.dragging then
				local x, y, scrollbar_width, scrollbar_height = horizontal_scrollbar_bounds(self)
				self.horizontal_scrollbar:event_at(event, x, y, scrollbar_width, scrollbar_height)
				released_capture = true
			end

			return { redraw = true, capture = released_capture and "release" or nil }
		elseif event.kind ~= "key_down" then
			return nil
		end

		local target
		if event.key == "up" then
			target = navigation_target(self, -1, 1)
		elseif event.key == "down" then
			target = navigation_target(self, 1, 1)
		elseif event.key == "home" then
			target = find_enabled(self, 1, 1)
		elseif event.key == "end" then
			target = find_enabled(self, #self.visible_nodes, -1)
		elseif event.key == "pageup" then
			target = navigation_target(self, -1, math.max(1, visible_row_count(self) - 1))
		elseif event.key == "pagedown" then
			target = navigation_target(self, 1, math.max(1, visible_row_count(self) - 1))
		elseif event.key == "left" and self._selected_node then
			if self._selected_node.expanded and node_has_children(self._selected_node) then
				assert(set_node_expanded(self, self._selected_node, false, false))
			elseif self._selected_node.parent and self._selected_node.parent.enabled then
				target = self._selected_node.parent
			end
		elseif event.key == "right" and self._selected_node and node_has_children(self._selected_node) then
			if not self._selected_node.expanded then
				assert(set_node_expanded(self, self._selected_node, true, false))
			elseif self._virtual_rows then
				local current = self.visible_index[self._selected_node]
				local child = current and self.visible_nodes[current + 1] or nil
				if child and child.depth > self._selected_node.depth and child.enabled then
					target = child
				end
			else
				for _, child in ipairs(self._selected_node.children) do
					if child.enabled then
						target = child
						break
					end
				end
			end
		else
			return nil
		end

		if target then
			assert(select_node(self, target, false))
		end
		constrain_scroll(self, self._selected_node)

		return { handled = true, redraw = true }
	end

	--- Select one node by its explicit public value, or clear selection with nil
	-- @param value string|number|boolean|nil requested value
	-- @return boolean|nil changed
	-- @return string|nil error_text
	function control:set_selected(value)
		local node = value == nil and nil or self.nodes_by_value[value]
		if value ~= nil and node == nil then
			return nil, "Tree selected value does not exist"
		end

		return select_node(self, node, true)
	end

	--- Expand or collapse one explicitly valued branch
	-- @param value string|number|boolean branch value
	-- @param expanded boolean requested state
	-- @return boolean|nil changed
	-- @return string|nil error_text
	function control:set_expanded(value, expanded)
		assert(type(expanded) == "boolean", "Tree expanded state must be a boolean")
		local node = self.nodes_by_value[value]
		if node == nil then
			return nil, "Tree node value does not exist"
		end

		return set_node_expanded(self, node, expanded, true)
	end

	return qui.control(control)
end

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

return Tree
