-- 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 Lua-owned popup Menu presented by the Window overlay layer
-- @module qui.menu

local qui = require("qui")
local Accelerator = require("qui.accelerator")
local Command = require("qui.command")
local Shortcut = require("qui.shortcut")
local colors = require("qui.colors")
local metrics = require("qui.metrics")
local theme = require("qui.theme")
local Font = require("qui.font")
local Glyph = require("qui.glyph")

local Menu = {}
local separator_marker = {}	-- Private identity prevents ordinary item tables being mistaken for separators
local color_flags = setmetatable({}, { __mode = "k" })	-- One flag table is reused while each Menu draws
local menu_instances = setmetatable({}, { __mode = "k" })	-- Nested items accept only Menu declarations

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

--- Return whether one item may receive keyboard or pointer activation
-- @param item table|nil normalized Menu item
-- @return boolean actionable
local function item_enabled(item)
	return item ~= nil and not item.separator and item.enabled
end

--- Copy and validate caller items while leaving their application data untouched
-- @param source table caller-supplied gapless item list
-- @return table items normalized private records in their original lookup order
local function prepare_items(source)
	assert(type(source) == "table", "Menu items must be a table")

	local count = 0
	local highest = 0
	for key in pairs(source) do
		assert(type(key) == "number" and key >= 1 and key % 1 == 0,
			"Menu items must use positive whole-number positions")
		count = count + 1
		highest = math.max(highest, key)
	end
	assert(highest == count, "Menu items must be a gapless list")

	local items = {}
	local identifiers = {}
	for index, source_item in ipairs(source) do
		if source_item == separator_marker then
			items[index] = { separator = true }
		else
			assert(type(source_item) == "table", "Menu item " .. index .. " must be a table or Menu.separator()")
			assert(type(source_item.id) == "string" or type(source_item.id) == "number",
				"Menu item " .. index .. " id must be a string or number")
			assert(identifiers[source_item.id] == nil, "Menu item ids must be unique")
			assert(source_item.command == nil or Command.is(source_item.command),
				"Menu item " .. index .. " command must be a qui.command")
			assert(type(source_item.text) == "string" or source_item.command and source_item.text == nil,
				"Menu item " .. index .. " text must be a string or inherited from its Command")
			assert(source_item.detail == nil or type(source_item.detail) == "string",
				"Menu item " .. index .. " detail must be a string")
			assert(source_item.shortcut == nil or type(source_item.shortcut) == "string",
				"Menu item " .. index .. " shortcut must be a string")
			assert(source_item.detail == nil or source_item.shortcut == nil,
				"Menu item " .. index .. " cannot use detail and shortcut together")
			assert(source_item.command == nil or source_item.shortcut == nil,
				"Menu item " .. index .. " Command owns its shortcut")
			assert(source_item.command == nil or source_item.enabled == nil and source_item.checked == nil,
				"Menu item " .. index .. " Command owns enabled and checked state")
			assert(source_item.accelerator == nil,
				"Menu item " .. index .. " must mark its accelerator with & inside text")
			assert(source_item.enabled == nil or type(source_item.enabled) == "boolean",
				"Menu item " .. index .. " enabled must be a boolean")
			assert(source_item.checked == nil or type(source_item.checked) == "boolean",
				"Menu item " .. index .. " checked must be a boolean")
			assert(source_item.indicator == nil or
				source_item.indicator == "check" or source_item.indicator == "radio",
				"Menu item " .. index .. " indicator must be check or radio")
			assert(source_item.glyph == nil or
				type(source_item.glyph) == "string" and source_item.glyph ~= "",
				"Menu item " .. index .. " glyph must be a non-empty name")
			assert(source_item.keep_open == nil or type(source_item.keep_open) == "boolean",
				"Menu item " .. index .. " keep_open must be a boolean")
			assert(source_item.style == nil or
				source_item.style == "critical" or source_item.style == "notable",
				"Menu item " .. index .. " style must be critical or notable")
			assert(source_item.menu == nil or menu_instances[source_item.menu],
				"Menu item " .. index .. " menu must be a Menu declaration")
			local item_command = source_item.command
			local source_text = source_item.text or item_command.text
			local text, accelerator, accelerator_column =
				Accelerator.parse(source_text, "Menu item " .. index .. " text")
			local source_shortcut = item_command and item_command.shortcut or source_item.shortcut
			assert(source_item.detail == nil or source_shortcut == nil,
				"Menu item " .. index .. " cannot use detail with its Command shortcut")
			local shortcut_value = source_shortcut and Shortcut.parse(source_shortcut) or nil

			items[index] = {
				id = source_item.id,
				command = item_command,
				text = text,
				detail = source_item.detail or "",
				shortcut = shortcut_value,
				shortcut_text = shortcut_value and Shortcut.format(shortcut_value) or "",
				enabled = item_command and item_command.enabled or source_item.enabled ~= false,
				checked = item_command and item_command.checked or source_item.checked == true,
				indicator = source_item.indicator or "check",
				glyph = source_item.glyph,
				keep_open = source_item.keep_open == true,
				style = source_item.style,	-- Semantic style selects a Theme color without fixing an RGB value
				menu = source_item.menu,
				accelerator = accelerator,
				accelerator_column = accelerator_column,
				data = source_item.data,	-- QUI never interprets application-owned item data
			}
			identifiers[source_item.id] = index
		end
	end

	return items
end

--- Find the next actionable item, wrapping once around the Menu
-- @param self table Menu control
-- @param start integer first position to inspect
-- @param direction integer negative for earlier or positive for later
-- @return integer|nil index
local function find_enabled(self, start, direction)
	if #self.items == 0 then
		return nil
	end

	local index = start
	for _ = 1, #self.items do
		if index < 1 then
			index = #self.items
		elseif index > #self.items then
			index = 1
		end
		if item_enabled(self.items[index]) then
			return index
		end
		index = index + direction
	end

	return nil
end

--- Find the first enabled item carrying one caption accelerator
-- Duplicate accelerators deliberately use the first matching item in Menu order
-- @param self table Menu control
-- @param key string normalized keyboard key
-- @return integer|nil index
local function find_accelerator(self, key)
	local requested = key:lower()
	for index, item in ipairs(self.items) do
		if item_enabled(item) and item.accelerator == requested then
			return index
		end
	end

	return nil
end

--- Return the Menu row under one local vertical position
-- @param self table Menu control
-- @param y number local pointer position
-- @return integer|nil index
local function item_at_y(self, y)
	if y < 1 or y >= self.arranged_height - 1 then
		return nil
	end

	local top = 1
	for index, item in ipairs(self.items) do
		local height = item.separator and self.separator_height or self.item_height
		if y >= top and y < top + height then
			return item_enabled(item) and index or nil
		end
		top = top + height
	end

	return nil
end

--- Return the local top edge occupied by one item
-- @param self table Menu control
-- @param requested_index integer item position
-- @return integer top
local function item_top(self, requested_index)
	local top = 1
	for index, item in ipairs(self.items) do
		if index == requested_index then
			return top
		end
		top = top + (item.separator and self.separator_height or self.item_height)
	end

	return top
end

--- Clear transient Lua state throughout one Menu branch after C++ hides its overlays
-- @param self table branch root
local function clear_menu_branch(self)
	if self.open_child_menu then
		clear_menu_branch(self.open_child_menu)
	end
	self.open_child_menu = nil
	self.anchor = nil
	self.active_index = nil
	self.pressed_index = nil
	self.pointer_mode = false
end

--- Return the root Menu that owns one complete popup branch
-- @param self table Menu control
-- @return table root
local function root_menu(self)
	local root = self
	while root.parent_menu do
		root = root.parent_menu
	end

	return root
end

--- Close the current popup session without discarding this reusable Menu
-- @param self table Menu control
-- @return boolean|nil closed
-- @return string|nil error_text
local function close_menu(self)
	local root = root_menu(self)
	local anchor = root.anchor
	if not anchor then
		return true
	end

	local closed, close_error = anchor:hide_overlay(root)
	if closed then
		clear_menu_branch(root)
	end

	return closed, close_error
end

--- Close the visible child branch below one parent Menu
-- @param self table parent Menu
-- @return boolean|nil closed
-- @return string|nil error_text
local function close_child_menu(self)
	local child = self.open_child_menu
	if not child then
		return true
	end

	local closed, close_error = self:hide_overlay(child)
	if closed then
		clear_menu_branch(child)
		self.open_child_menu = nil
	end

	return closed, close_error
end

--- Open the child Menu beside one parent row
-- @param self table parent Menu
-- @param index integer item carrying the child
-- @param keyboard boolean whether the first child item should become active
-- @return boolean|nil opened
-- @return string|nil error_text
local function open_child_menu(self, index, keyboard)
	local item = self.items[index]
	if not item_enabled(item) or not item.menu then
		return close_child_menu(self)
	end
	if self.open_child_menu == item.menu and item.menu:is_open() then
		if keyboard and item.menu.active_index == nil then
			item.menu.active_index = find_enabled(item.menu, 1, 1)
		end

		return true
	end

	local closed, close_error = close_child_menu(self)
	if not closed then
		return nil, close_error
	end

	local child = item.menu
	child.anchor = self
	child.pointer_mode = not keyboard
	child.active_index = keyboard and find_enabled(child, 1, 1) or nil
	child.pressed_index = nil
	local opened, open_error = self:show_overlay(child, {
		width = child.width,
		gap = 0,	-- Child frames meet directly so pointer travel never crosses a dead strip
		position = "right",
		anchor_bounds = {
			x = 0,
			y = item_top(self, index),
			width = self.arranged_width,
			height = self.item_height,
		},
	})
	if not opened then
		child.anchor = nil
		return nil, open_error
	end

	self.open_child_menu = child
	return true
end

--- Activate one enabled item and close unless that item deliberately stays open
-- @param self table Menu control
-- @param index integer item position
-- @param keyboard boolean whether keyboard opening should enter a child Menu
-- @return boolean activated
local function activate_item(self, index, keyboard)
	local item = self.items[index]
	if not item_enabled(item) then
		return false
	end
	if item.menu then
		-- Mouse clicks only reveal a child; keyboard use also enters its first item
		local opened, open_error = open_child_menu(self, index, keyboard)
		assert(opened, "Menu child open failed: " .. tostring(open_error))
		return true
	end

	if item.command then
		item.command:run()
	elseif self.on_click then
		self:on_click(item)
	end
	if item.keep_open then
		local invalidated, invalidate_error = self:invalidate()
		assert(invalidated, "Menu item redraw failed: " .. tostring(invalidate_error))
	else
		local closed, close_error = close_menu(self)
		assert(closed, "Menu close failed: " .. tostring(close_error))
	end

	return true
end

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

		return colors.menu_bg
	elseif type == "text" then
		if flags.grayed then
			return colors.menu_disabled_text
		elseif flags.style == "critical" then
			return colors.menu_critical_text
		elseif flags.style == "notable" then
			return colors.menu_notable_text
		end

		return colors.menu_text
	elseif type == "detail" then
		return flags.grayed and colors.menu_disabled_text or colors.menu_detail
	elseif type == "frame" then
		return colors.menu_frame
	elseif type == "separator" then
		return colors.menu_separator
	elseif type == "symbol" then
		return flags.grayed and colors.menu_disabled_text or colors.menu_symbol
	end

	return nil
end

--- Return one explicit separator for use inside a Menu item list
-- @return table separator private immutable marker
function Menu.separator()
	return separator_marker
end

--- Create one reusable popup Menu for a Form's overlays list
-- @param options table items plus optional sizing, colors, and on_click handler
-- @return table control unrestricted Lua Control declaration
function Menu.new(options)
	assert(type(options) == "table", "Menu expects a table")
	assert(options.on_click == nil or type(options.on_click) == "function", "Menu on_click must be a function")
	assert(options.on_color == nil or type(options.on_color) == "function", "Menu on_color must be a function")
	assert(options.default_color == nil, "Menu default_color is fixed; use on_color for dynamic changes")
	assert(options.overlays == nil, "Menu overlays are supplied by item.menu declarations")

	local control = options
	control.items = prepare_items(control.items or {})
	local child_menus = {}
	for index, item in ipairs(control.items) do
		if item.menu then
			assert(item.menu.parent_menu == nil, "Menu item " .. index .. " reuses a child Menu")
			child_menus[#child_menus + 1] = item.menu
		end
	end
	control.overlays = #child_menus > 0 and child_menus or nil
	control.font = control.font or Font.compact
	assert(control.font, "Menu requires a font")
	control.padding_x = dip_value(
		control.padding_x, theme.resolve_metric(metrics.menu_padding_x), "Menu padding_x")
	control.padding_y = dip_value(
		control.padding_y, theme.resolve_metric(metrics.menu_padding_y), "Menu padding_y")
	control.item_height = dip_value(
		control.item_height, theme.resolve_metric(metrics.menu_item_height), "Menu item_height")
	control.separator_height = dip_value(
		control.separator_height, theme.resolve_metric(metrics.menu_separator_height), "Menu separator_height")
	control.detail_gap = dip_value(
		control.detail_gap, theme.resolve_metric(metrics.menu_detail_gap), "Menu detail_gap")
	control.indicator_width = dip_value(
		control.indicator_width, theme.resolve_metric(metrics.menu_indicator_width), "Menu indicator_width")
	control.indicator_size = dip_value(
		control.indicator_size, theme.resolve_metric(metrics.menu_indicator_size), "Menu indicator_size")
	control.indicator_gap = dip_value(
		control.indicator_gap, theme.resolve_metric(metrics.menu_indicator_gap), "Menu indicator_gap")
	control.submenu_width = dip_value(
		control.submenu_width, theme.resolve_metric(metrics.menu_submenu_width), "Menu submenu_width")
	control.submenu_size = dip_value(
		control.submenu_size, theme.resolve_metric(metrics.menu_submenu_size), "Menu submenu_size")
	control.submenu_gap = dip_value(
		control.submenu_gap, theme.resolve_metric(metrics.menu_submenu_gap), "Menu submenu_gap")
	control.accelerator_underline_min_width = dip_value(
		control.accelerator_underline_min_width,
		theme.resolve_metric(metrics.accelerator_underline_min_width),
		"Menu accelerator_underline_min_width")
	control.accelerator_underline_thickness = dip_value(
		control.accelerator_underline_thickness,
		theme.resolve_metric(metrics.accelerator_underline_thickness),
		"Menu accelerator_underline_thickness")
	control.accelerator_underline_inset = dip_value(
		control.accelerator_underline_inset,
		theme.resolve_metric(metrics.accelerator_underline_inset),
		"Menu accelerator_underline_inset")
	assert(control.indicator_size <= control.indicator_width,
		"Menu indicator_size must fit inside indicator_width")
	assert(control.submenu_size <= control.submenu_width,
		"Menu submenu_size must fit inside submenu_width")
	assert(control.accelerator_underline_thickness > 0,
		"Menu accelerator_underline_thickness must be positive")
	control.width = control.width and dip_value(control.width, 0, "Menu width") or nil
	assert(control.show_accelerators == nil or type(control.show_accelerators) == "boolean",
		"Menu show_accelerators must be a boolean")
	control.show_accelerators = control.show_accelerators ~= false
	control.enabled = true
	control.focusable = false
	control.tab_stop = false
	control.accepts_pointer = true
	control.default_color = Menu.default_color
	control.anchor = nil
	control.active_index = nil
	control.pressed_index = nil
	control.pointer_mode = false
	control.open_child_menu = nil
	control.has_submenus = #child_menus > 0
	control.arranged_width = control.width or 0
	control.arranged_height = 0
	color_flags[control] = {
		grayed = false,
		hovering = false,
		style = nil,
	}
	control.shortcut_targets = {}
	control.command_refs = {}
	for index, item in ipairs(control.items) do
		if item.command then
			control.command_refs[#control.command_refs + 1] = item.command
		elseif item.shortcut then
			assert(item.menu == nil, "Menu item " .. index .. " cannot use shortcut with a child Menu")
			local shortcut_index = index
			local shortcut_item = item
			control.shortcut_targets[#control.shortcut_targets + 1] = {
				shortcut = item.shortcut,
				available = function()
					return shortcut_item.enabled
				end,
				activate = function()
					return activate_item(control, shortcut_index, false)
				end,
			}
		end
	end

	--- Measure all rows so an anchored Menu can open without a hard-coded rectangle
	function control:measure(control_canvas, minimum_width, minimum_height, maximum_width, maximum_height)
		local widest_text = 0
		local widest_detail = 0
		local _, font_height = control_canvas:measure_text(self.font, "Mg")
		self.item_height = math.max(self.item_height, font_height + self.padding_y * 2)
		local natural_height = 2
		for _, item in ipairs(self.items) do
			if item.separator then
				natural_height = natural_height + self.separator_height
			else
				local text_width = control_canvas:measure_text(self.font, item.text)
				-- Disabled details are hidden, so they must not make the popup wider
				local right_text = item.shortcut_text ~= "" and item.shortcut_text or item.detail
				local detail_width = item.enabled and right_text ~= "" and
					control_canvas:measure_text(self.font, right_text) or 0
				widest_text = math.max(widest_text, text_width)
				widest_detail = math.max(widest_detail, detail_width)
				natural_height = natural_height + self.item_height
			end
		end

		-- Separate columns keep every detail comfortably clear of the longest caption
		local detail_width = widest_detail == 0 and 0 or self.detail_gap + widest_detail
		local submenu_width = self.has_submenus and self.submenu_gap + self.submenu_width or 0
		local content_width =
			self.indicator_width + self.indicator_gap + widest_text + detail_width + submenu_width
		local natural_width = self.width or math.max(140, content_width + self.padding_x * 2 + 2)
		return
			math.max(minimum_width, math.min(natural_width, maximum_width)),
			math.max(minimum_height, math.min(natural_height, maximum_height))
	end

	--- Draw Menu rows with one stable leading area for checks, radios, and named glyphs
	function control:draw(control_canvas)
		local width, height = control_canvas:size()
		self.arranged_width = width
		self.arranged_height = height
		local flags = assert(color_flags[self], "Menu color flags are unavailable")
		flags.grayed = false
		flags.hovering = false
		control_canvas:fill_rect(0, 0, width, height, self:get_color("background", flags))

		local top = 1
		for index, item in ipairs(self.items) do
			if item.separator then
				local line_y = top + math.floor(self.separator_height / 2)
				control_canvas:fill_rect(
					0,
					line_y,
					width,
					1,
					self:get_color("separator", flags)
				)
				top = top + self.separator_height
			else
				flags.grayed = not item.enabled
				flags.hovering = item.enabled and index == self.active_index
				flags.style = item.style
				if flags.hovering then
					control_canvas:fill_rect(1, top, math.max(0, width - 2), self.item_height,
						self:get_color("background", flags))
				end

				local _, text_height = control_canvas:measure_text(self.font, item.text)
				local text_y = top + math.floor((self.item_height - text_height) / 2)
				local indicator_slot_x = self.padding_x + 1
				local indicator_x = indicator_slot_x + math.floor((self.indicator_width - self.indicator_size) / 2)
				local indicator_y = top + math.floor((self.item_height - self.indicator_size) / 2)
				local text_x = indicator_slot_x + self.indicator_width + self.indicator_gap
				if item.glyph then
					control_canvas:draw_glyph(
						item.glyph,
						indicator_x,
						indicator_y,
						self.indicator_size,
						self.indicator_size,
						self:get_color("symbol", flags)
					)
				elseif item.checked and item.indicator == "radio" then
					local radius = math.max(1, math.floor(self.indicator_size / 4))
					local diameter = radius * 2
					control_canvas:fill_circle(
						indicator_x + math.floor((self.indicator_size - diameter) / 2),
						indicator_y + math.floor((self.indicator_size - diameter) / 2),
						diameter,
						self:get_color("symbol", flags)
					)
				elseif item.checked then
					control_canvas:draw_glyph(
						Glyph.check,
						indicator_x,
						indicator_y,
						self.indicator_size,
						self.indicator_size,
						self:get_color("symbol", flags)
					)
				end
				control_canvas:draw_text(
					self.font,
					item.text,
					text_x,
					text_y,
					self:get_color("text", flags)
				)
				if self.show_accelerators and item.accelerator_column then
					Accelerator.draw(
						control_canvas,
						self.font,
						item.text,
						item.accelerator_column,
						text_x,
						text_y,
						text_height,
						self:get_color("text", flags),
						self
					)
				end
				local right_text = item.shortcut_text ~= "" and item.shortcut_text or item.detail
				if item.enabled and right_text ~= "" then
					local detail_width = control_canvas:measure_text(self.font, right_text)
					local submenu_width = self.has_submenus and
						self.submenu_gap + self.submenu_width or 0
					control_canvas:draw_text(
						self.font,
						right_text,
						math.max(text_x, width - self.padding_x - detail_width - submenu_width - 1),
						text_y,
						self:get_color("detail", flags)
					)
				end
				if item.menu then
					local submenu_x = width - self.padding_x - self.submenu_width
					local submenu_y = top + math.floor((self.item_height - self.submenu_size) / 2)
					control_canvas:draw_glyph(
						Glyph.submenu,
						submenu_x + math.floor((self.submenu_width - self.submenu_size) / 2),
						submenu_y,
						self.submenu_size,
						self.submenu_size,
						self:get_color("symbol", flags)
					)
				end
				top = top + self.item_height
			end
		end

		flags.grayed = false
		flags.hovering = false
		flags.style = nil
		control_canvas:draw_rect(0, 0, width, height, 1, self:get_color("frame", flags))
	end

	--- Handle modal pointer use, standard Menu navigation, and one-character accelerators
	function control:event(event)
		if event.kind == "pointer_enter" or event.kind == "pointer_move" then
			local previous = self.active_index
			self.pointer_mode = true
			self.active_index = item_at_y(self, event.y)
			if previous ~= self.active_index then
				local active_item = self.active_index and self.items[self.active_index] or nil
				-- Pointer-opened children switch immediately, as ordinary desktop menus do
				local changed, change_error
				if active_item and active_item.menu then
					changed, change_error = open_child_menu(self, self.active_index, false)
				else
					changed, change_error = close_child_menu(self)
				end
				assert(changed, "Menu child change failed: " .. tostring(change_error))
			end
			return { handled = true, redraw = previous ~= self.active_index }
		elseif event.kind == "pointer_leave" then
			if self.open_child_menu then
				-- Keep the parent row visibly active while the pointer crosses into its child
				return { handled = true }
			end
			local changed = self.active_index ~= nil or self.pressed_index ~= nil
			self.active_index = nil
			self.pressed_index = nil
			return { handled = true, redraw = changed }
		elseif event.kind == "pointer_down" and event.button == "left" then
			self.pointer_mode = true
			self.pressed_index = item_at_y(self, event.y)
			self.active_index = self.pressed_index
			return { handled = true, redraw = true }
		elseif event.kind == "pointer_up" and event.button == "left" then
			local released = item_at_y(self, event.y)
			local activate = released ~= nil and released == self.pressed_index
			self.pressed_index = nil
			if activate then
				activate_item(self, released, false)
			end
			return { handled = true, redraw = true }
		elseif event.kind == "key_up" or event.kind == "text_input" or event.kind == "text_editing" then
			-- A Menu owns keyboard input while open, even when one key has no Menu meaning
			return { handled = true }
		elseif event.kind ~= "key_down" then
			return nil
		end

		if event.key == "up" then
			self.pointer_mode = false
			self.active_index = find_enabled(self, (self.active_index or 1) - 1, -1)
		elseif event.key == "down" then
			self.pointer_mode = false
			self.active_index = find_enabled(self, (self.active_index or 0) + 1, 1)
		elseif event.key == "home" then
			self.pointer_mode = false
			self.active_index = find_enabled(self, 1, 1)
		elseif event.key == "end" then
			self.pointer_mode = false
			self.active_index = find_enabled(self, #self.items, -1)
		elseif event.key == "right" then
			if self.active_index and self.items[self.active_index].menu then
				local opened, open_error = open_child_menu(self, self.active_index, true)
				assert(opened, "Menu child open failed: " .. tostring(open_error))
			end
		elseif event.key == "left" and self.parent_menu then
			local parent = self.parent_menu
			local closed, close_error = close_child_menu(parent)
			assert(closed, "Menu child close failed: " .. tostring(close_error))
			local invalidated, invalidate_error = parent:invalidate()
			assert(invalidated, "Menu parent redraw failed: " .. tostring(invalidate_error))
		elseif event.key == "return" or event.key == "space" then
			if not event["repeat"] and self.active_index then
				activate_item(self, self.active_index, true)
			end
		elseif not event["repeat"] then
			local shortcut_value = Shortcut.from_event(event)
			local shortcut_index
			for index, item in ipairs(self.items) do
				if item_enabled(item) and item.shortcut == shortcut_value then
					shortcut_index = index
					break
				end
			end
			if shortcut_index then
				activate_item(self, shortcut_index, true)
			elseif not event.control and not event.system then
				local accelerator_index = find_accelerator(self, event.key)
				if accelerator_index then
					activate_item(self, accelerator_index, true)
				else
					return { handled = true }
				end
			else
				return { handled = true }
			end
		else
			return { handled = true }
		end

		return { handled = true, redraw = true }
	end

	local declared = qui.control(control)
	menu_instances[declared] = true
	-- Normalized items stay alive with the Menu and receive shared state changes directly
	for _, item in ipairs(declared.items) do
		if item.command then
			--- Apply live state from the Command while the Menu is open or closed
			-- @param shared_command table attached Command
			-- @param field string changed field or 'all'
			function item:command_changed(shared_command, field)
				if field == "enabled" or field == "all" then
					self.enabled = shared_command.enabled
				end
				if field == "checked" or field == "all" then
					self.checked = shared_command.checked
				end
				declared:invalidate()
			end
			Command.bind(item.command, item)
		end
	end
	-- Claim children only after the parent itself has passed every construction check
	for index, item in ipairs(declared.items) do
		if item.menu then
			item.menu.parent_menu = declared
			item.menu.parent_index = index
		end
	end

	--- Open this Menu below one ordinary Control while the Window owns popup modality
	-- @param open_options table anchor plus optional width, height, gap, position, and keyboard flag
	-- @return boolean|nil opened
	-- @return string|nil error_text
	function declared:open(open_options)
		assert(type(open_options) == "table", "Menu.open expects a table")
		assert(type(open_options.anchor) == "table", "Menu.open requires an anchor Control")
		assert(open_options.position == nil or open_options.position == "bottom_left",
			"Menu position currently supports only bottom_left")
		if self.anchor and not self:is_open() then
			-- Native dismissal may hide the branch without entering Lua, so discard its stale state here
			clear_menu_branch(self)
		end
		self.anchor = open_options.anchor
		self.pointer_mode = open_options.keyboard ~= true
		self.active_index = open_options.keyboard == true and find_enabled(self, 1, 1) or nil
		self.pressed_index = nil

		local opened, open_error = self.anchor:show_overlay(self, {
			width = open_options.width or self.width,
			height = open_options.height,
			gap = open_options.gap or 0,
			position = "below",
		})
		if not opened then
			self.anchor = nil
		end

		return opened, open_error
	end

	--- Close this Menu while retaining every item for later reuse
	-- @return boolean|nil closed
	-- @return string|nil error_text
	function declared:close()
		return close_menu(self)
	end

	--- Toggle this Menu from one opener without duplicating popup state in that Button
	-- @param open_options table same options accepted by 'open'
	-- @return boolean|nil changed
	-- @return string|nil error_text
	function declared:toggle(open_options)
		if self:is_open() then
			return self:close()
		end

		return self:open(open_options)
	end

	--- Report whether this Menu currently owns the Window popup session
	-- @return boolean visible
	function declared:is_open()
		return self.anchor ~= nil and self.anchor:overlay_visible(self)
	end

	return declared
end

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

return Menu
