Plugins and Language Packs for Family Historian

Add Trees.fh_lua

--[[
@Title: Add Trees
@Type: standard
@Author: Helen Wright
@Version: 1.0
@LastUpdated: 24 September 2026
@Licence: This plugin is copyright (c) 2026 Helen Wright & contributors and is licensed under the MIT License which is hereby incorporated by reference (see https://pluginstore.family-historian.co.uk/fh-plugin-licence)
@Description: Generates a navigation chart for each selected individual: a three-generation SVG (parents, siblings, spouses/partners and children) with clickable links to each person's page. Writes one SVG file per individual and can embed the chart inline into the matching page of a Family Historian generated website.
]]
--
--[[ChangeLog:
	Version 1.0: First public release. Draws a three-generation navigation chart for each
	selected individual - parents, siblings, spouses/partners and children - saved as one SVG
	file per person, and can embed each chart in the matching page of a Family Historian
	website.
	  * Wide and Compact layouts; choice of all, birth-only or first parent family
	  * Colours, box shapes and web-safe or custom fonts; Classic, High Contrast and Match
	    System Theme schemes, plus your own saved themes
	  * Colour and/or shape can show sex or relationship (shape by sex is colour-blind-safe)
	  * Sizing: scale to a percentage, fit to page width (embedded), or exact width
	  * Privacy matching a Family Historian website: people flagged Private omitted, those
	    flagged Living shown without dates
	  * Website embedding: placement, alignment, caption, optional no-JavaScript collapsible,
	    configurable page prefix and CSS classes; confirmation before any website page is
	    changed
	  * Browser preview of your unsaved settings
	  * Results listed in Family Historian's Result Set; keyboard access throughout; windows
	    remember their size and position on each computer and follow the Windows light/dark/
	    High Contrast theme
]]

--------------------------------------------------------------
--INITIALISE  (FH7 minimum + prompt to save so we act on current data)
--------------------------------------------------------------
-- fhInitialise must be the FIRST FH call. It enforces the minimum FH version (7) and, with
-- "save_recommended", offers to save unsaved changes first (Yes/No/Cancel; Cancel exits) so the
-- plugin operates on up-to-date data (and it avoids the OneDrive .ged sync-conflict risk).
fhInitialise(7, 0, 0, "save_recommended")
--------------------------------------------------------------
--EXTERNAL LIBRARIES
--------------------------------------------------------------
do
	utf8 = require(".utf8"):init()
	require("iuplua") -- UI
	fh = require("fhUtils") --useful stuff
	fhfu = require("fhFileUtils") -- utf8 compatible file handling library
end

-------------------------------------------------------------
--CONSTANTS
--------------------------------------------------------------
local cstrPluginVersion = "1.0" -- keep in step with @Version above (drives the About box)

---Per-project plugin data folder, falling back to the per-machine one when
---there is no project (fhGetPluginDataFileName("CURRENT_PROJECT") returns ""
---for a standalone GEDCOM file).
---@return string
function getPluginDataFolder()
	local folder = fhGetPluginDataFileName("CURRENT_PROJECT", true)
	if type(folder) ~= "string" or folder == "" then
		folder = fhGetPluginDataFileName("LOCAL_MACHINE", true)
	end
	return folder
end

---The configuration scope matching that folder, for the Config instance.
---@return "CURRENT_PROJECT"|"LOCAL_MACHINE"
function getConfigScope()
	local projectFolder = fhGetPluginDataFileName("CURRENT_PROJECT", true)
	if type(projectFolder) == "string" and projectFolder ~= "" then
		return "CURRENT_PROJECT"
	end
	return "LOCAL_MACHINE"
end

-- The project's Public folder is the natural home for website output and the
-- default SVG output folder; fall back to the plugin data folder when absent.
local cstrProjectPublicFolder = fhGetContextInfo("CI_PROJECT_PUBLIC_FOLDER") or ""
local cstrDefaultOutputFolder = (cstrProjectPublicFolder ~= "" and (cstrProjectPublicFolder .. "\\Add Trees"))
	or (getPluginDataFolder() .. "\\Add Trees")

--<<FS_SPLICE "../../2 Boilerplate/Theme.lua">>
;(function()
--[[
Theme V1
@Author: Helen Wright
@Version: 1.0
@LastUpdated: 15 June 2026
@Description: Reads the Windows theme so IUP dialogs (and any other consumer)
can follow the user's light / dark / High Contrast preference.

Colours are read from HKCU\Control Panel\Colors via LuaCOM (WScript.Shell
RegRead), degrading gracefully to a light palette when the registry is
unavailable (e.g. under the WINE emulator). The registry reads are the only
Windows-bound part; everything else is pure.

Sets the global `Theme`. Two kinds of consumer:
  * IUP dialog theming -> Theme.iupColours() / Theme.isDarkMode() / Theme.isHighContrast()
  * a "Match System Theme" colour preset (e.g. for SVG) -> Theme.systemColours()

Load this before Dialog.lua so the dialog helpers pick up the theme; Dialog.lua
also falls back to a light palette if Theme has not been loaded.
@V1.0: Initial version (extracted from the Add Trees plugin).
]]
do
	local M = {}
	require("luacom")

	---@class ThemeSystemColours
	---@field window string|nil      Background, "R G B" (Control Panel > Colors > Window)
	---@field windowText string|nil  Foreground, "R G B" (WindowText)
	---@field hilight string|nil     Selection background, "R G B" (Hilight)
	---@field hilightText string|nil Selection text, "R G B" (HilightText)
	---@field hotTracking string|nil Hot-tracking colour, "R G B" (HotTrackingColor)

	---Cached colours so the registry is read at most once per run.
	---@type ThemeSystemColours|nil
	local cachedColours
	---@type boolean
	local cacheLoaded = false

	---Read one registry value, returning nil on any failure.
	---@param key string Full registry path including the value name.
	---@return string|nil
	local function getRegKey(key)
		local ok, value = pcall(function()
			local shell = luacom.CreateObject("WScript.Shell")
			return shell:RegRead(key)
		end)
		if ok then
			return value
		end
		return nil
	end

	---The raw Windows theme colours as "R G B" strings, or nil when the
	---registry is unavailable or malformed (emulators included).
	---@return ThemeSystemColours|nil
	function M.systemColours()
		if cacheLoaded then
			return cachedColours
		end
		cacheLoaded = true
		if os.getenv("WINEPREFIX") ~= nil then
			cachedColours = nil -- emulator: registry colours unreliable
			return nil
		end
		local root = "HKEY_CURRENT_USER\\Control Panel\\Colors\\"
		local window = getRegKey(root .. "Window")
		if type(window) ~= "string" or not window:match("^%d+%s+%d+%s+%d+$") then
			cachedColours = nil
			return nil
		end
		cachedColours = {
			window = window,
			windowText = getRegKey(root .. "WindowText"),
			hilight = getRegKey(root .. "Hilight"),
			hilightText = getRegKey(root .. "HilightText"),
			hotTracking = getRegKey(root .. "HotTrackingColor"),
		}
		return cachedColours
	end

	---True when Windows is in dark (apps) mode. Modern Windows leaves the legacy
	---Control Panel > Colors at the classic white even in dark mode, so this
	---reads the AppsUseLightTheme switch instead (0 = dark).
	---@return boolean
	function M.isDarkMode()
		local v = getRegKey(
			"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize\\AppsUseLightTheme"
		)
		return tonumber(v) == 0
	end

	---True when a Windows High Contrast theme is active (bit 0 of the
	---Accessibility HighContrast Flags). Those themes set the legacy Control
	---Panel colours correctly, so honour them.
	---@return boolean
	function M.isHighContrast()
		local v = getRegKey("HKEY_CURRENT_USER\\Control Panel\\Accessibility\\HighContrast\\Flags")
		return (tonumber(v) or 0) % 2 == 1
	end

	---Colours for theming the IUP dialogs, in IUP's "R G B" form. A High
	---Contrast theme sets the Control Panel colours correctly, so use them;
	---otherwise modern dark mode gives a dark palette and the default is light.
	---@return {bg: string, fg: string}
	function M.iupColours()
		local colours = M.systemColours()
		-- High Contrast theme: the Control Panel colours are set for it; use them.
		if M.isHighContrast() then
			return {
				bg = (colours and colours.window) or "0 0 0",
				fg = (colours and colours.windowText) or "255 255 255",
			}
		end
		-- Otherwise the modern light/dark app theme decides. A plain custom
		-- Control Panel colour no longer hijacks this (which left dark mode
		-- looking half-themed).
		if M.isDarkMode() then
			return { bg = "32 32 32", fg = "245 245 245" }
		end
		return { bg = "255 255 255", fg = "0 0 0" }
	end

	---The system's SELECTION colour pair (Hilight/HilightText), in IUP's "R G B" form (added 10
	---Jul 2026: a High Contrast theme's Window colour IS the dialog background, so tinting it -
	---darkening it a fixed percentage, as iupColours()-based callers do for a title strip - has
	---nothing to darken FROM under pure black/white High Contrast palettes; the strip collapses
	---invisibly into the frame. The selection pair is the theme's own answer to "a colour that
	---contrasts with the window background", so it survives High Contrast where a computed tint
	---cannot. Falls back to iupColours() when the registry has no Hilight/HilightText (e.g. the
	---WINE emulator) so a caller never gets a nil.
	---@return {bg: string, fg: string}
	function M.highlightColours()
		local colours = M.systemColours()
		if colours and colours.hilight and colours.hilightText then
			return { bg = colours.hilight, fg = colours.hilightText }
		end
		return M.iupColours()
	end

	_G.Theme = M
end

end)()
--<<FS_SPLICE_END>>

--<<FS_SPLICE "../../2 Boilerplate/Help.lua">>
;(function()
--[[
@class Help
@desc Provides online HTML help for Family Historian plugins, with context-sensitive support.
@prerequisites
  Online Help:
    - A valid help_root URL pointing to the online help location for your plugin.
@usage
  -- Paste the Help class definition at the top of your script.
  local help = Help.new{version="1.0"}
  -- Add to menu: help:menu_item("topic")
  -- Show help: help:show("topic")
  -- Wire up IUP HELP_CB: help:attach_help_cb(control, "topic")
]]
do
	local M = {}

	local iup = require("iuplua")

	---@class Help
	---@field plugin_name string The name of the plugin.
	---@field help_root string The root URL for online help.
	local Help = {}
	Help.__index = Help

	---Create a new Help object
	---@param opts {help_root?: string}
	---@return Help
	function M.new(opts)
		local self = setmetatable({}, Help)
		self.plugin_name = fhGetContextInfo("CI_PLUGIN_NAME")
		self.help_root = opts and opts.help_root
			or ("http://pluginstore.family-historian.co.uk/page/help/" .. self.plugin_name)
		return self
	end

	---Open help for a topic (online only)
	---@param topic string The help topic: can be a page ("options"), full page path ("guides/options.html"), or an anchor on index ("#options")
	function Help:show(topic)
		topic = topic or ""
		-- Supports either a standalone page at help_root/topic or an anchor on index (when topic starts with '#').
		local sep = self.help_root:sub(-1) == "/" and "" or "/"
		local url
		if topic:sub(1, 1) == "#" then
			-- Anchor on index page: append fragment directly (no extra slash)
			url = self.help_root .. topic
		else
			-- Treat as a page or path relative to help_root
			url = self.help_root .. sep .. topic
		end
		-- basic normalization
		url = url:gsub("%%20", "-")
		url = url:gsub(" ", "-")
		fhShellExecute(url)
	end

	---Create a menu item for help
	---@param topic string The help topic to show when the menu item is clicked
	---@param label string The label for the menu item
	---@return iup.menuitem #The IUP menu item object
	function Help:menu_item(topic, label)
		label = label or "Help"
		return iup.item({
			title = label,
			action = function()
				self:show(topic)
			end,
		})
	end

	---Attach context help to an IUP control via help_cb
	---@param control iup.control The IUP control to attach help to
	---@param topic string The help topic to show when help is requested
	function Help:attach_help_cb(control, topic)
		control.help_cb = function()
			self:show(topic)
			return iup.IGNORE
		end
	end

	_G.Help = M
end

end)()
--<<FS_SPLICE_END>>
-- Resolve Help once to a real instance so consumers can assume it exists
help = Help.new({})


--<<FS_SPLICE "../../2 Boilerplate/Dialog.lua">>
;(function()
--[[
Dialog V8
@Author: Helen Wright
@Version: 1.10
@LastUpdated: 21 July 2026
@Description: Helper functions for IUP dialogs
@V1.0: Initial version.
@V1.2: Dialogs follow the Windows light / dark / High Contrast theme via the
       Theme helper (Theme.lua). Falls back to the light palette when Theme is
       not loaded.
@V1.3: EnableContainer/ClearContainer iterate children with iup.GetNextChild.
       Integer indexing (ih[i]) only reflects constructor-time children, so
       both functions silently skipped anything appended at runtime.
@V1.4: New refloorDialog(dlg): re-floor a dialog at its natural size after a
       content change, growing a shown window that now clips (rastersize) and
       capping at the screen height. Centralised from the consumer plugins.
@V1.5: RebuildContainer gains destroyOld (detach-only rebuilds leak) and maps
       appended children when the container is already shown.
@V1.6: refloorDialog reads the window's real size BEFORE applying the minsize,
       and carried a WRONG diagnosis ("the host ignores resizes of shown
       dialogs") - corrected in V1.7.
@V1.7: The real culprit (Spike S7, 6 Jul 2026): with shrink=YES - which this
       theme sets on every dialog - a SHOWN dialog reports NATURALSIZE equal
       to its CURRENT size, so every grow-to-natural computation is a no-op
       tautology and resizes "look" ignored. The host resizes shown dialogs
       perfectly well. refloorDialog now measures the natural size with
       shrink temporarily OFF, then restores it. A dialog whose content can
       outgrow the screen still wants a scrollbox (Add Facts's capture
       surface), but ordinary content-growth now genuinely grows the window.
       Also: handle registration survives re-using a name whose previous
       owner was destroyed (safeSetHandle; iuplua's SetHandle errors on the
       dead handle - makeButton registers by TITLE, so a destroy-and-recreate
       dialog hit this on its second open).
@V1.8: options.on_escape actually fires under the FH host (live 6 Jul 2026):
       the dialog k_any never sees Esc there, so on_escape (without a cancel
       button) now also creates a hidden, layout-ignored button firing the
       handler and points native DEFAULTESC at it - the mechanism the
       accessibility pass live-verified. The k_any dispatch is kept for
       non-host environments. Call sites are unchanged.
@V1.9: RebuildContainer gains skipRefresh - batched rebuilds refresh once, not
       per call.
@V1.10: refloorDialog now also clears the dialog's SIZE user size (dlg.size = iup.NULL)
        alongside minsize before re-measuring - leaving it set inflated every re-measure
        to the SIZE hint (e.g. a HALFxHALF main dialog), the same family of bug as
        Config.lua's V1.3 showTrackedDialog fix. (Shared Config.lua/Dialog.lua
        improvement.)
]]
--ENHANCE: Rich Text field. This could be quite difficult -- can't access WebView2 as it isn't available as an OLE control.

---@global iup table The IUP library global variable created by require("iuplua")

do
	-- Load necessary libraries
	require("iuplua") -- IUP library for GUI components
	require("iupluacontrols") --additional iup controls
	require("pl.init") -- Penlight library for additional utilities
	local tablex = require("pl.tablex") --be explicit about which parts of Penlight I'm using
	require("luacom")
	fh = require("fhUtils")

	-- makeDialog registers each dialog's independent-normalisation re-apply hook here (weak keys, so the
	-- entry goes when the dialog does). Declared in the OUTER do-scope so both the theming block (which
	-- defines renormalizeDialog) and the Dialog-handling block (makeDialog) can see it.
	local dialogRenorm = setmetatable({}, { __mode = "k" })
	-- fhUtils.setIupDefaults() is buggy in emulated (WINE) environments: the PDX Font registry read
	-- returns nil in some cases and the old code then errored parsing it. Do the equivalent setup inline
	-- here, with a nil-guard. This is the ONE place to maintain it; when fhUtils.setIupDefaults is fixed,
	-- replace this whole block with a plain fh.setIupDefaults() call again.
	do
		local stringx = require("pl.stringx") -- splitv, to parse the comma-separated PDX Font value
		local function getRegKey(key)
			local sh = luacom.CreateObject("WScript.Shell")
			local ans
			if pcall(function()
				ans = sh:RegRead(key)
			end) then
				return ans
			end
			return nil, true
		end
		iup.SetGlobal("CUSTOMQUITMESSAGE", "YES")
		local v = getRegKey("HKEY_CURRENT_USER\\Software\\Calico Pie\\Family Historian\\2.0\\Preferences\\PDX Font")
		if v == nil then
			-- Registry read returned nil (some emulated environments): leave the system default font.
		else
			local t = { stringx.splitv(v, ",") } -- 1st field is the size (twips), 14th is the font name
			iup.SetGlobal("DEFAULTFONT", t[14] .. " " .. t[1] / 20)
		end
		iup.SetGlobal("UTF8MODE", "YES")
		iup.SetGlobal("UTF8MODE_FILE", "YES") -- use UTF8 file names (matches fhUtils.setIupDefaults)
		fhSetStringEncoding("UTF-8")
	end
	-- Use the global help instance resolved by the main plugin (referenced as `help`)

	-- Resolve the dark/light theme once for every helper below. Theme is set by
	-- Theme.lua; fall back to a light palette if a plugin hasn't loaded it, so
	-- this dialog helper still works standalone.
	local th = (Theme and Theme.iupColours and Theme.iupColours()) or { bg = "255 255 255", fg = "0 0 0" }
	-- Plain dark mode (Windows dark, but NOT High Contrast): native Win32
	-- controls (push-buttons, the menu) keep a LIGHT face that IUP can't repaint
	-- from a plugin, so their text must stay dark to be readable. High Contrast
	-- is themed by the OS, so it uses the normal th colours.
	local plainDark = Theme ~= nil and Theme.isDarkMode and Theme.isDarkMode()
		and not (Theme.isHighContrast and Theme.isHighContrast())

	do --colours, layout, theming
		-- Create normalizers for consistent sizing
		btnnorm = iup.normalizer({}) --buttons
		btnshortnorm = iup.normalizer({}) --short buttons
		textnorm = iup.normalizer({}) --text fields and lists
		dlgnorm = iup.normalizer({}) --dialogs
		donotnorm = iup.normalizer({}) --items that should not be normalized

		-- Normalize all GUI components to have a consistent layout
		function DoNormalize()
			btnnorm.normalize = "HORIZONTAL"
			btnshortnorm.normalize = "HORIZONTAL"
			textnorm.normalize = "HORIZONTAL"
			dlgnorm.normalize = "HORIZONTAL"
		end

		-- Size a dialog's controls with FRESH per-dialog normalisers, so each dialog normalises
		-- INDEPENDENTLY of every other. The shared global normalisers (btnnorm/textnorm) entangle
		-- dialogs: re-normalising them when one dialog opens resizes the controls of every other dialog
		-- already built (e.g. opening Options reshaped the reused Fact Types picker). This walks the
		-- dialog tree, moving its buttons/toggles into a local button normaliser and its text/list
		-- controls into a local text normaliser, then sizes them - touching nothing outside this dialog.
		-- Labels keep their own handling (makeLabel's normaliser / makeLongLabel's opt-out / Config's
		-- labelNorm). Returns a function that re-applies the sizing, for a REUSED dialog whose content
		-- changed (call it after repopulating, before showing).
		function normalizeIndependently(dlg)
			local btnN = iup.normalizer({})
			local txtN = iup.normalizer({})
			local function walk(el)
				local ok, cls = pcall(iup.GetClassName, el)
				if ok then
					if cls == "button" or cls == "toggle" then
						el.normalizergroup = btnN
					elseif cls == "text" or cls == "list" then
						el.normalizergroup = txtN
					end
				end
				local child = iup.GetNextChild(el)
				while child do
					walk(child)
					child = iup.GetNextChild(el, child)
				end
			end
			walk(dlg)
			local function apply()
				btnN.normalize = "HORIZONTAL"
				txtN.normalize = "HORIZONTAL"
			end
			apply()
			return apply
		end

		-- makeDialog stores each dialog's re-apply hook in dialogRenorm (declared in the outer scope so
		-- the separate Dialog-handling do-block can see it). A REUSED dialog whose content changed calls
		-- renormalizeDialog(dlg) after repopulating, before showing.
		function renormalizeDialog(dlg)
			local fn = dialogRenorm[dlg]
			if fn then fn() end
		end

		--- Re-floor a dialog after its content changed (Stage 4, 5 Jul 2026 - centralised from the
		--- consumer plugins, which each carried a copy of this logic): re-measure the natural size,
		--- floor future resizes there (minsize), GROW the window when its current size now clips
		--- content (minsize alone never grows a shown window - rastersize must be set explicitly),
		--- and cap at the screen height so growth never pushes controls off the bottom. Call after
		--- rebuilding or appending content in a shown dialog (after renormalizeDialog + iup.Refresh).
		function refloorDialog(dlg)
			if not dlg then
				return
			end
			-- Read the window's REAL size before measuring (V1.6), and measure the natural
			-- size with shrink OFF (V1.7): with shrink=YES - this theme's default - a shown
			-- dialog reports NATURALSIZE equal to its CURRENT size, which turned every
			-- grow-to-natural below into a no-op tautology (Spike S7, 6 Jul 2026).
			local cw, ch = tostring(dlg.rastersize or ""):match("^(%d+)x(%d+)$")
			local oldShrink = dlg.shrink
			dlg.shrink = "NO"
			dlg.minsize = iup.NULL
			-- Also clear the SIZE user size (V1.10): it is an initial-open hint, not a floor, and
			-- left set it would inflate this re-measure to the SIZE value on a large display (the
			-- same family of bug as Config.lua's showTrackedDialog). No restore - once shown, the
			-- user size has done its job.
			dlg.size = iup.NULL
			iup.Refresh(dlg)
			local nw, nh = tostring(dlg.naturalsize or ""):match("^(%d+)x(%d+)$")
			dlg.shrink = oldShrink
			if not nw then
				iup.Refresh(dlg)
				return
			end
			nw, nh = tonumber(nw), tonumber(nh)
			local scrW, scrH = tostring(iup.GetGlobal("SCREENSIZE") or ""):match("^(%d+)x(%d+)$")
			-- SCREENSIZE is the full monitor; leave an allowance for the taskbar and title bar.
			local fitW = scrW and math.min(nw, tonumber(scrW)) or nw
			local fitH = scrH and math.min(nh, tonumber(scrH) - 80) or nh
			dlg.minsize = fitW .. "x" .. fitH
			if cw and (tonumber(cw) < fitW or tonumber(ch) < fitH) then
				dlg.rastersize = math.max(tonumber(cw), fitW) .. "x" .. math.max(tonumber(ch), fitH)
			end
			iup.Refresh(dlg) -- re-lay out with shrink restored (and apply any grow above)
		end

		-- Set global colours for the dialog theme
		iup.SetGlobal("DLGBGCOLOR", th.bg) -- dialog background
		iup.SetGlobal("TXTBGCOLOR", th.bg) -- text-field background
		iup.SetGlobal("TXTFGCOLOR", th.fg) -- text foreground

		-- Create theme objects for all IUP elements with minimal styling
		local myTheme = iup.user({
			IUPDIALOG = iup.user({
				expand = "YES",
				resize = "YES",
				shrink = "YES",
				size = iup.NULL,
				menubox = "YES",
			}),
			IUPBUTTON = iup.user({
				alignment = "ACENTER:ACENTER",
				padding = "DEFAULTBUTTONPADDING",
				normalizergroup = btnnorm,
				expand = "NO",
				-- Native button faces stay light even in dark mode, so in plain
				-- dark mode keep a light face with dark text (readable); light and
				-- High Contrast use the theme colours.
				bgcolor = plainDark and "240 240 240" or th.bg,
				fgcolor = plainDark and "32 32 32" or th.fg,
			}),
			IUPLIST = iup.user({
				normalizergroup = textnorm,
				editbox = "NO",
				sort = "YES",
				dropdown = "YES",
				multiple = "NO",
				bgcolor = th.bg, -- themed background
				fgcolor = th.fg, -- themed text
			}),
			IUPTEXT = iup.user({
				alignment = "ALEFT:ACENTER",
				normalizergroup = textnorm,
				wordwrap = "YES",
				append = "YES",
				scrollbar = "NO",
				multiline = "NO",
				visiblelines = "1",
				readonly = "NO",
				padding = "2x",
				bgcolor = th.bg, -- themed background
				fgcolor = th.fg, -- themed text
			}),
			IUPLABEL = iup.user({
				wordwrap = "NO",
				alignment = "ALEFT:ACENTER",
				expand = "NO",
				padding = "20x10",
				fgcolor = th.fg, -- readable on the dialog background in every theme
			}),
			IUPGRIDBOX = iup.user({
				gaplin = "10",
				gapcol = "10",
				alignmentlin = "ACENTER",
				alignmentcol = "LEFT",
				normalizesize = "YES",
				expand = "YES",
				expandchildren = "HORIZONTAL",
				orientation = "HORIZONTAL",
				numdiv = "2",
			}),
			IUPFRAME = iup.user({
				expand = "YES",
				expandchildren = "YES",
			}),
			IUPTABS = iup.user({
				margin = "5x5",
				gap = "5",
				bgcolor = th.bg, -- themed background
				fgcolor = th.fg, -- themed text
			}),
			IUPTOGGLE = iup.user({
				alignment = "ALEFT:ACENTER",
				normalizergroup = btnnorm,
				bgcolor = th.bg, -- themed background
				fgcolor = th.fg, -- themed text
			}),
			IUPEXPANDER = iup.user({
				visible = "YES",
			}),
			IUPMATRIX = iup.user({
				markmode = "CELL",
				resizematrix = "YES",
				scrollbar = "YES",
				bgcolor = th.bg, -- themed background
				fgcolor = th.fg, -- themed text
			}),
			IUPTREE = iup.user({
				expand = "YES",
				bgcolor = th.bg, -- themed background
				fgcolor = th.fg, -- themed text
				selection = "SINGLE",
				showrename = "NO",
				showdragdrop = "NO",
				showtoggle = "NO",
				addexpanded = "NO",
			}),
			IUPVBOX = iup.user({
				gap = "5",
				margin = "5x5",
				expandchildren = "NO",
				shrink = "YES",
			}),
			IUPHBOX = iup.user({
				gap = "5",
				margin = "5x5",
				expandchildren = "NO",
				shrink = "YES",
			}),
			IUPZBOX = iup.user({
				expand = "YES",
			}),
			IUPSCROLLBOX = iup.user({
				expand = "YES",
			}),
			IUPBACKGROUNDBOX = iup.user({
				expand = "YES",
			}),
			IUPRADIO = iup.user({
				expand = "NO",
			}),
			IUPPROGRESSBAR = iup.user({
				expand = "HORIZONTAL",
				bgcolor = th.bg, -- themed background
				fgcolor = th.fg, -- themed text
			}),
		})
		iup.SetHandle("myTheme", myTheme)
		iup.SetGlobal("DEFAULTTHEME", "myTheme")

		-- Tooltip theming for consistency
		iup.SetGlobal("TIPBGCOLOR", "255 255 225") -- Light yellow
		iup.SetGlobal("TIPFGCOLOR", th.fg) -- tooltip text
		iup.SetGlobal("TIPFONT", "Segoe UI, 10")

		-- Error styling helpers
		function markError(control)
			control.bgcolor = "255 0 0" -- Red background for errors
		end
		function clearError(control)
			control.bgcolor = th.bg -- Reset to the themed background
		end

		-- Tip creation
		--- Word-wrap tip text to a sensible width so long descriptions don't run off the screen as one
		--- line (IUP tooltips honour \n but never wrap on their own). Existing line breaks are kept, and
		--- each paragraph is wrapped on word boundaries; an over-long single word is left intact.
		local function wrapTip(text, width)
			width = width or 72
			local lines = {}
			for paragraph in ((text or "") .. "\n"):gmatch("(.-)\n") do
				if paragraph == "" then
					lines[#lines + 1] = ""
				else
					local line = ""
					for word in paragraph:gmatch("%S+") do
						if line == "" then
							line = word
						elseif #line + 1 + #word <= width then
							line = line .. " " .. word
						else
							lines[#lines + 1] = line
							line = word
						end
					end
					lines[#lines + 1] = line
				end
			end
			return table.concat(lines, "\n")
		end

		--- Sets a tooltip (tipballoon) with a title and appends help info if a help topic is specified.
		--- @param control iup.element The control to set the tip for
		--- @param tip string The tip text
		--- @param tiptitle string|nil The tip title (optional)
		--- @param help_topic string|nil The help topic (optional)
		function setTipWithHelp(control, tip, tiptitle, help_topic)
			local full_tip = wrapTip(tip or "")
			if help_topic then
				full_tip = full_tip .. "\n\nPress F1 for help."
			end
			control.tip = full_tip
			-- Enable balloon style per-control (Windows only attribute)
			control.tipballoon = "YES"
			if tiptitle then
				-- Set both, to support normal and balloon title variants
				control.tiptitle = tiptitle
				control.tipballoontitle = tiptitle
			end
		end
	end

	--- Register a handle name, surviving the case where the name was last used by a control
	--- that has since been DESTROYED: iuplua's SetHandle errors while unregistering the dead
	--- handle (live 6 Jul 2026 - a destroy-and-recreate dialog whose buttons register by
	--- TITLE failed on its second open). Falls back to a fresh random name.
	--- @param name string The preferred handle name
	--- @param ih userdata The IUP element to register
	function safeSetHandle(name, ih)
		if not pcall(iup.SetHandle, name, ih) then
			pcall(iup.SetHandle, generateRandomDigitString(), ih)
		end
	end

	--- Generates a random string of digits
	--- @return string 10 random digits
	function generateRandomDigitString()
		--TESTED
		-- Seed the random number generator
		math.randomseed(os.time())

		local digits = ""
		for i = 1, 10 do
			-- Generate a random digit (0-9) and concatenate to the string
			digits = digits .. math.random(0, 9)
		end
		return digits
	end

	local specialOptions = tablex.makeset({
		"callback",
		"name",
		"values",
		"killfocus",
		"close",
		"default",
		"cancel",
		"on_escape",
		"accelerators",
	}) ---options that aren't handled by mergeOptions
	--- Merge user provided options with default settings.
	-- This function creates a new configuration table based on default values,
	-- where any provided user option overrides the corresponding default.
	--- @param defaults table -- A table containing the default configuration options.
	--- @param options table -- A table provided by the user that may override the default options.
	--- @return table -- A new table with merged values from both input tables.
	function mergeOptions(defaults, options)
		--TESTED: mergeOptions
		local config = {} -- Initialize a new table to avoid modifying the original 'options' table.

		-- Iterate over the default options
		for k, v in pairs(defaults) do
			-- If an option is provided by the user, use it, otherwise use the default value.
			config[k] = options[k] or v
		end

		-- Iterate over the user-provided options
		for k, v in pairs(options) do
			-- Add the option if it's not already in config and is not disallowed
			if config[k] == nil and not specialOptions[k] then
				config[k] = v
			end
		end

		-- Return the newly created configuration table.
		return config
	end
	--- Set options for an iup element
	---@param element iup.element
	---@param options table
	function setOptions(element, options)
		---TESTED: setOptions
		for k, v in pairs(options) do
			element[k] = v
		end
	end
	do --Dialog handling
		--- Returns a table with either {PARENTDIALOG = name} or {NATIVEPARENT = hwnd}
		local function getParentDialogInfo()
			local focus = iup.GetFocus()
			if focus then
				local parentDialogHandle = iup.GetDialog(focus)
				if parentDialogHandle then
					local parentDialogName = iup.GetName(parentDialogHandle)
					if parentDialogName then
						return { PARENTDIALOG = parentDialogName }
					end
				end
			end
			-- Fallback: use FH's main window handle
			return { NATIVEPARENT = fhGetContextInfo("CI_PARENT_HWND") }
		end

		--- Get parent window handle for FH API calls that require a parent window
		--- @param parentWindow? any Optional parent window handle
		--- @return any Parent window handle suitable for FH API calls
		function getParentWindowHandle(parentWindow)
			local hParentWnd = parentWindow
			if not hParentWnd then
				-- Try to get the active dialog
				local activeDialog = identifyActiveWindow()
				if activeDialog then
					hParentWnd = activeDialog.NATIVEPARENT
				else
					-- Fallback to FH's main window
					hParentWnd = fhGetContextInfo("CI_PARENT_HWND")
				end
			end
			return hParentWnd
		end

		--- @class DialogOptions
		--- @field title string|nil Dialog window title
		--- @field size string|nil Initial size (e.g., "HALFxHALF")
		--- @field expand string|nil Expansion policy
		--- @field resize string|nil Whether dialog is resizable ("YES"|"NO")
		--- @field menubox string|nil Whether to show native menu box ("YES"|"NO")
		--- @field menu iup.menu|nil Menu bar to attach
		--- @field name string|nil Optional handle name to register
		--- @field show fun(state:string)|nil Optional callback invoked on show
		--- @field close fun(self:iup.dialog)|nil Optional callback invoked on close
		--- @field help_topic string|nil Optional help topic for F1 help
		--- @field default iup.button|nil Primary button; Enter activates it (native DEFAULTENTER, focus-aware)
		--- @field cancel iup.button|nil Cancel button; Esc activates it (native DEFAULTESC)
		--- @field on_escape fun(self:iup.dialog):any|nil Esc handler for menu-driven dialogs that have no cancel button; return iup.CLOSE (the default when it returns nil) to close
		--- @field accelerators {key:integer, action:function, item:any}[]|nil Window-global key shortcuts (typically menuBarData.accelerators); each fires from anywhere in the dialog via k_any
		--- Creates and configures a dialog with customization options.
		--- @param content iup.element The content to be included in the dialog.
		--- @param options? DialogOptions The options to configure the dialog.
		--- @return iup.dialog
		function makeDialog(content, options)
			--TESTED: makeDialog
			-- Default values for options (if not provided)
			local defaults = {}
			options = options or {}
			-- Esc for dialogs with no cancel button (options.on_escape): under the FH host the
			-- dialog's k_any never sees Esc (live 6 Jul 2026 - the k_any dispatch below stays for
			-- non-host use), but IUP's native DEFAULTESC does. So on_escape gets a stand-in
			-- button whose action fires it, and DEFAULTESC points at that button (set further
			-- down, same registered-name idiom as options.cancel). The button must be MAPPED
			-- AND VISIBLE: on Windows IUP activates the DEFAULTESC button with a real BM_CLICK,
			-- which a hidden window discards (live 6 Jul 2026 - visible=NO silently broke it).
			-- floating=YES keeps it out of the layout; 1x1 and canfocus=NO make it
			-- imperceptible and untabbable.
			local d -- forward-declared: the stand-in Esc button's action closes over the dialog
			local hiddenEsc
			if options.on_escape and not options.cancel then
				hiddenEsc = iup.button({
					title = "",
					floating = "YES",
					canfocus = "NO",
					rastersize = "1x1",
					action = function()
						local r = options.on_escape(d)
						return r == nil and iup.CLOSE or r
					end,
				})
				content = iup.vbox({ content, hiddenEsc })
			end
			d = iup.dialog({ content })
			setOptions(d, mergeOptions(defaults, options))

			-- Wire dialog-level help if a help topic is provided
			if options.help_topic and help then
				d.help_cb = function()
					help:show(options.help_topic)
					return iup.IGNORE
				end
			end

			-- Always identify and set the parent dialog
			local parentInfo = getParentDialogInfo()
			for k, v in pairs(parentInfo) do
				iup.SetAttribute(d, k, v)
			end

			-- Register dialog with a unique handle for later reference. Re-registering a name
			-- whose previous dialog was DESTROYED makes iuplua's SetHandle error while it
			-- unregisters the dead handle (live 6 Jul 2026: a destroy-and-recreate chooser
			-- failed on its second open) - fall back to a fresh random name.
			local okHandle = pcall(iup.SetHandle, options.name or options.title or generateRandomDigitString(), d)
			if not okHandle then
				pcall(iup.SetHandle, generateRandomDigitString(), d)
			end

			-- Ensure layouts refresh properly on resize across monitors/resolutions
			local originalResizeCb = d.resize_cb
			d.resize_cb = function(self, width, height)
				if originalResizeCb and originalResizeCb(self, width, height) == iup.CLOSE then
					return iup.CLOSE
				end
				iup.Refresh(self)
				return iup.DEFAULT
			end

			-- Default F1 behavior: let focused control handle help if it can; otherwise fall back to dialog help.
			-- Also handle Esc for menu-driven dialogs that have no cancel button (see options.on_escape below).
			local originalKAny = d.k_any
			d.k_any = function(self, c)
				if originalKAny then
					local r = originalKAny(self, c)
					if r == iup.CLOSE or r == iup.IGNORE then
						return r
					end
				end
				if c == iup.K_ESC and options.on_escape then
					local r = options.on_escape(self)
					return r == nil and iup.CLOSE or r
				end
				-- Window-global menu accelerators: fire the matching item's action from any focus. Return
				-- iup.CLOSE if the action asked to close; otherwise consume the key (iup.IGNORE).
				if options.accelerators then
					for _, a in ipairs(options.accelerators) do
						if c == a.key then
							if a.action(a.item) == iup.CLOSE then
								return iup.CLOSE
							end
							return iup.IGNORE
						end
					end
				end
				if c == iup.K_F1 then
					local focused = iup.GetFocus()
					if focused and focused.help_cb then
						return iup.CONTINUE
					end
					if self.help_cb then
						return self:help_cb()
					end
					return iup.IGNORE
				end
				return iup.CONTINUE
			end
			-- Opt-in keyboard defaults (accessibility). Enter activates the primary button, Esc the cancel
			-- button, via IUP's native DEFAULTENTER/DEFAULTESC - which are focus-aware, so Enter is NOT
			-- hijacked while the focus is in a multiline text or another button. Menu-driven dialogs that have
			-- no cancel button pass options.on_escape instead (handled in k_any above) to get Esc-to-close.
			-- DEFAULTENTER/DEFAULTESC are set here via a registered handle NAME. (Assigning the handle
			-- directly - d.DEFAULTENTER = btn - also works: iuplua implements IupSetAttributeHandle
			-- through attribute assignment, spike S6; there is deliberately no iup.SetAttributeHandle
			-- function in the binding.) We register a fresh unique name so title-collisions between
			-- buttons (makeButton registers by title, so two "OK"s would clash) can't point Enter at
			-- the wrong one.
			if options.default then
				local defName = "defenter_" .. generateRandomDigitString()
				iup.SetHandle(defName, options.default)
				d.DEFAULTENTER = defName
			end
			if options.cancel then
				local escName = "defesc_" .. generateRandomDigitString()
				iup.SetHandle(escName, options.cancel)
				d.DEFAULTESC = escName
			elseif hiddenEsc then
				-- on_escape without a cancel button: Esc activates the hidden button (see the
				-- construction above) via the same native, focus-aware DEFAULTESC mechanism.
				local escName = "defesc_" .. generateRandomDigitString()
				iup.SetHandle(escName, hiddenEsc)
				d.DEFAULTESC = escName
			end
			-- Normalise EVERY dialog with its own normalisers, so dialogs never share sizing (the global
			-- btnnorm/textnorm entanglement that made one dialog's layout shift when another opened). This
			-- makes independence the default - no plugin or dialog has to remember to ask for it.
			dialogRenorm[d] = normalizeIndependently(d)
			return d
		end

		--- Identifies the currently active window among the open IUP dialogs.
		--- @return iup.dialoghandle|nil
		function identifyActiveWindow()
			--TESTED: IdentifyActiveWindow.
			-- Retrieve all dialog names, noting that these names are distinct from their handles.
			local tblDialogNames = iup.GetAllDialogs()
			-- Loop through all dialog names to find the active window.
			for _, dialogName in ipairs(tblDialogNames) do
				local dialogHandle = iup.GetHandle(dialogName) -- Convert name to handle.
				if dialogHandle.ACTIVEWINDOW == "YES" then
					return dialogHandle -- Return the active dialog handle.
				end
			end
			-- If no active dialog is found, return nil.
			return nil
		end

		--- Destroys all currently open IUP dialogs to free up resources.
		function destroyAllDialogs()
			--TESTED: destroyAllDialogs
			-- Retrieve all dialog names as in the identification function.
			local tblDialogNames = iup.GetAllDialogs()
			-- Loop through all dialog names to destroy each dialog.
			for _, dialogName in ipairs(tblDialogNames) do
				local dialogHandle = iup.GetHandle(dialogName) -- Convert name to handle.
				if dialogHandle then -- Ensure the handle is valid before attempting to destroy.
					dialogHandle:destroy() -- Destroy the dialog.
				end
			end
		end

		--- Updates a dialog's title and refreshes the display
		--- @param dialog iup.dialog The dialog to update
		--- @param newTitle string The new title for the dialog
		function updateDialogTitle(dialog, newTitle)
			if dialog and dialog.title then
				dialog.title = newTitle
				-- Ensure IUP updates the native window text
				iup.Refresh(dialog)
			end
		end

		-- Message boxes and Text prompts
		local buttonOrder = {
			OK = { "OK" },
			OKCANCEL = { "OK", "Cancel" },
			RETRYCANCEL = { "Retry", "Cancel" },
			YESNO = { "Yes", "No" },
			YESNOCANCEL = { "Yes", "No", "Cancel" },
		}

		local buttonSetMap = {
			OK = "OK",
			OKCANCEL = "OKCANCEL",
			RETRYCANCEL = "RETRYCANCEL",
			YESNO = "YESNO",
			YESNOCANCEL = "YESNOCANCEL",
		}

		---Create a customizable pop-up message dialog
		---@param messageType "error"|"warning"|"question"|"info"|"message" The type of message
		---@param messageText string The text content of the message
		---@param buttonSet "OK"|"OKCANCEL"|"RETRYCANCEL"|"YESNO"|"YESNOCANCEL" A set of buttons to include
		---@return "OK"|"Cancel"|"Retry"|"Yes"|"No" clickedButton The label of the button that was pressed
		function MessageBox(messageType, messageText, buttonSet)
			-- Map messageType to IUP dialogtype
			local dialogTypeMap = {
				error = "ERROR",
				warning = "WARNING",
				question = "QUESTION",
				info = "INFORMATION",
				message = "INFORMATION",
			}
			local dialogtype = dialogTypeMap[messageType] or "INFORMATION"

			-- Map buttonSet to IUP buttons
			local buttons = buttonSetMap[buttonSet] or "OK"

			local dlg = iup.messagedlg({
				title = "Message",
				value = messageText,
				dialogtype = dialogtype,
				buttons = buttons,
			})
			-- Set parent dialog attributes
			local parentInfo = getParentDialogInfo()
			for k, v in pairs(parentInfo) do
				dlg[k] = v
			end
			-- A topmost progress dialog (Progress.lua) would otherwise sit in front of this native
			-- message dialog, hiding it; drop its topmost while we show, then restore.
			local restoreTopmost = (Progress and Progress.suspendTopmost) and Progress.suspendTopmost() or nil
			dlg:popup(iup.CENTER, iup.CENTER)
			local result = dlg.buttonresponse
			dlg:destroy()
			if restoreTopmost then restoreTopmost() end
			local order = buttonOrder[buttonSet] or { "OK" }
			local idx = tonumber(result)
			if idx and order[idx] then
				return order[idx]
			end
			return "OK"
		end
		--- @class GetTextParams
		--- Parameters for the GetText function.
		--- @field strPrompt string The prompt text to display.
		--- @field strDefault string The default text to display in the input field.
		--- @field strMask? string|nil Optional mask to use for the text input.
		--- @field bMultiLine? boolean Optional flag to enable multiline input.
		--- @field strTickPrompt? string|nil Optional prompt for the tick box.
		--- Retrieves text input from the user.
		--- @param params GetTextParams A table containing the parameters for the function.
		--- @return boolean, string, boolean The OK status, the input text, and the tick status.
		function GetText(params)
			--TESTED: GetText
			-- Extract parameters from the table
			local strPrompt = params.strPrompt
			local strDefault = params.strDefault or ""
			local strMask = params.strMask or ""
			local bMultiLine = params.bMultiLine or false
			local strTickPrompt = params.strTickPrompt or ""

			-- Initialize variables for text input and tick status
			local textInput = strDefault
			local tickStatus = false
			local isOK = false

			-- Create the text element
			local textOptions = {
				value = strDefault,
				multiline = bMultiLine and "YES" or "NO",
				mask = strMask ~= "" and strMask or nil,
				visiblelines = bMultiLine and 8 or 1,
			}
			local textElement = makeText(textOptions)

			-- Create the toggle element if strTickPrompt is provided
			local toggleElement
			if strTickPrompt ~= "" then
				toggleElement = makeToggle({
					title = strTickPrompt,
					value = "OFF",
				})
			end

			--Create the buttons
			local btnOK = makeButton({
				title = "OK",
				close = true,
				size = "64x",
				callback = function()
					textInput = textElement.value
					tickStatus = toggleElement and toggleElement.value == "ON" or false
					isOK = true
					return true
				end,
			})
			local btnCancel = makeButton({
				title = "Cancel",
				close = true,
				size = "64x",
			})

			-- Create the dialog content
			local dialogContent = iup.vbox({
				textElement,
				strTickPrompt ~= "" and toggleElement or nil,
				iup.hbox({ iup.fill({}), btnOK, btnCancel }),
			})

			-- Create the dialog. Enter commits (OK), Esc cancels - both focus-aware via makeDialog's
			-- native DEFAULTENTER/DEFAULTESC (Enter still inserts a newline inside a multiline text field).
			local dialogOptions = {
				title = strPrompt,
				size = "QUARTERx",
				default = btnOK,
				cancel = btnCancel,
			}
			local dialog = makeDialog(dialogContent, dialogOptions)

			-- Avoid global normalization to prevent modality/focus issues in nested dialogs
			--TODO: fix layout without normalization

			-- Show the dialog
			dialog:popup(iup.CENTERPARENT, iup.CENTERPARENT)
			dialog:destroy()

			-- Return the OK status, the input text, and the tick status
			return isOK, isOK and textInput or "", isOK and tickStatus or false
		end
	end
	do --Buttons
		--- Enables or disables a list of buttons
		--- @param tblButtons table List of button objects to be enabled/disabled
		--- @param strSetting string "YES" to enable, "NO" to disable
		function enableButtons(tblButtons, strSetting)
			--TESTED: EnableButtons
			for _, v in ipairs(tblButtons) do
				v.ACTIVE = strSetting
			end
		end
		--- @class ButtonOptions
		--- @field action? function|nil A function to be called when the button is pressed. If nil, this is a cancel button
		--- @field close? boolean|nil Whether the button should close the dialog when pressed
		--- @field name? string|nil The name to set for the button handle
		--- Creates a button with specified options
		--- @param options ButtonOptions Configuration options for the button. Optionally include help_topic for F1 help.
		--- @return iup.button The created button element
		function makeButton(options)
			--TESTED: makeButton
			--create button action function
			local callback = options.callback
			local action = function(self)
				if callback then
					if options.close then
						if callback(self) == true then -- this is a close button
							return iup.CLOSE
						end
					else
						callback(self)
					end
				else --this is a cancel button
					return iup.CLOSE
				end
			end
			local defaults = {
				action = action,
			}
			local b = iup.button({})
			setOptions(b, mergeOptions(defaults, options))
			-- safeSetHandle: buttons register by TITLE, so a destroyed-and-recreated dialog
			-- re-registers the same names - plain SetHandle errors on the dead handles.
			safeSetHandle(options.name or options.title or generateRandomDigitString(), b)
			if options.tip then
				setTipWithHelp(b, options.tip, options.tiptitle, options.help_topic)
			end
			if options.help_topic and help then
				help:attach_help_cb(b, options.help_topic)
			end
			return b
		end
		--- @class AssistantButtonOptions : ButtonOptions
		--- @field help_topic? string Optional help topic for F1 help
		--- @field close? boolean Whether button closes dialog (defaults to false)
		--- @field canFocus? IupVisibility Whether button can receive focus (defaults to "NO")
		--- @param options AssistantButtonOptions Optionally include help_topic for F1 help.
		--- Creates an assistant button with specified options
		--- @return iup.button The created assistant button element
		function makeAssistantButton(options)
			--TEST: makeButton
			options = options or {}
			--create button action function
			local defaults = {
				normalizergroup = btnshortnorm,
				close = false,
				title = "...",
				canFocus = "NO",
				size = "20x", -- comfortable click target; "..." alone is tiny
			}
			local merged = mergeOptions(defaults, options)
			-- mergeOptions strips "special" keys such as callback, but makeButton
			-- reads callback from the options it receives; without this the button
			-- has no action and behaves as a Cancel button (returns iup.CLOSE),
			-- closing whatever dialog contains it.
			merged.callback = options.callback
			local btn = makeButton(merged)
			if options.help_topic and help then
				help:attach_help_cb(btn, options.help_topic)
			end
			if options.tip then
				setTipWithHelp(btn, options.tip, options.tiptitle, options.help_topic)
			end
			return btn
		end
	end
	do --Lists
		--- @class ListOptions
		--- @field killfocus? function Kill focus callback
		--- Create and configure a list
		--- @param options ListOptions Configuration options for the list. Optionally include help_topic for F1 help.
		--- @return iup.list The created list element
		function makeList(options)
			--TESTED: makeList
			local dropdown_option = options.dropdown or "YES"
			local defaults = {
				visibleitems = dropdown_option == "YES" and 5 or nil,
				visiblecolumns = dropdown_option ~= "YES" and 1 or nil,
				visiblelines = dropdown_option ~= "YES" and 1 or nil,
				expand = dropdown_option ~= "YES" and "YES" or "HORIZONTAL",
				killfocus_cb = options.killfocus or nil,
			}
			local list = iup.list({})
			setOptions(list, mergeOptions(defaults, options))
			if type(options.values) == "table" then
				populateList(list, options.values)
			end
			safeSetHandle(options.name or generateRandomDigitString(), list)
			if options.tip then
				setTipWithHelp(list, options.tip, options.tiptitle, options.help_topic)
			end
			if options.help_topic and help then
				help:attach_help_cb(list, options.help_topic)
			end
			return list
		end
		--- Populate a list with values from a table
		---@param l table The list to populate
		---@param tblVals table The table containing values to populate the list with
		function populateList(l, tblVals)
			--TESTED: PopulateList
			local is_indexed = (rawget(tblVals, 1) ~= nil)
			l.REMOVEITEM = "ALL"
			if not is_indexed then
				local i = 1
				for k, _ in pairs(tblVals) do
					l[tostring(i)] = k
					i = i + 1
				end
			else
				for i, v in ipairs(tblVals) do
					l[tostring(i)] = v
				end
			end
		end
		--- Check if a multi-selection list has multiple items selected
		---@param l table The list to check
		---@return boolean True if multiple items are selected, otherwise false
		function multiListSelectionTrue(l)
			--TESTED: MultiListSelectionTrue
			return l.value:match("%+") ~= nil
		end
		--- Clear the selection in a multi-selection list
		---@param l table The list to clear
		function multiListSelectionClear(l)
			--TESTED: MultiListSelectionClear
			l.value = string.rep("%-", l.count)
		end
		--- Searches for a value in a list and returns its position.
		--- @param strValue string The value to search for in the list
		--- @param list iup.list The list to search within
		--- @return integer position The position of the value in the list (0 if not found)
		function goToInList(strValue, list)
			--TESTED: GoToInList
			-- Ensure the list has a COUNT property
			local count = tonumber(list.COUNT)
			if not count or count <= 0 then
				return 0 -- List is empty or count is invalid
			end

			-- Iterate through the list
			for position = 1, count do
				if list[tostring(position)] == strValue then
					list.value = position -- Set the found position in the list
					return position
				end
			end

			return 0 -- Value not found in the list
		end
		--- Get a selected value from a list if one exists
		--- @param list iup.list The list to get the selection from
		--- @param returnNumeric boolean If true, return the numeric value; otherwise, return the corresponding string
		--- @return string|number|"" The selected value from the list or an empty string if no selection
		function getSingleValue(list, returnNumeric)
			--TESTED: GetSingleValue
			-- Check if the list value is non-zero and return the appropriate value based on returnNumeric
			-- Otherwise, return an empty string
			if list.value ~= 0 then
				if returnNumeric then
					return list.value
				else
					return list[tostring(list.value)]
				end
			else
				return ""
			end
		end
		--- GetSelectedValues retrieves selected items from a list.
		--- @param list iup.list The list containing items and their selection states
		--- @param returnPositions boolean If true, return positions instead of item texts
		--- @return string[] Selected items or positions
		function getSelectedValues(list, returnPositions)
			--TESTED: GetSelectedValues
			local selectedValues = {} -- Table to hold selected values
			local itemCount = tonumber(list.COUNT) -- Total number of items in the list

			if itemCount and itemCount > 0 then
				-- list.value is nil until the dialog is mapped, so a caller that runs during construction
				-- would otherwise index nil here; treat "not yet mapped" as "nothing selected".
				local selectionState = list.value or "" -- String indicating selection states with + and -
				for i = 1, itemCount do
					if selectionState:sub(i, i) == "+" then -- Check if item is selected
						if returnPositions then
							table.insert(selectedValues, tostring(i)) -- Add position as string to the table
						else
							table.insert(selectedValues, list[tostring(i)]) -- Add selected item text to the table
						end
					end
				end
			end
			return selectedValues -- Return the table of selected items or positions
		end
		--- Set the selected values in a multi-selection list
		--- @param list iup.list The list to set the selected values in
		--- @param tblselected string[] An indexed list of strings to select
		--- @return nil
		function setSelectedValues(list, tblselected)
			--TESTED: SetSelectedValues
			local tbl = tablex.index_map(tblselected)
			local strselection = ""
			for i = 1, tonumber(list.COUNT) do
				strselection = strselection .. (tbl[list[tostring(i)]] and "+" or "-")
			end
			list.value = strselection
		end

		--- Remove selected items from a list and corresponding collection
		--- @param list iup.list The UI list control
		--- @param collection table[] The collection to remove items from
		--- @param updateDisplay function Function to call to update the display
		function removeSelectedItems(list, collection, updateDisplay)
			if not list then
				return
			end

			local selectedPositions = getSelectedValues(list, true)
			if #selectedPositions > 0 then
				-- Sort positions in descending order to avoid index shifting
				table.sort(selectedPositions, function(a, b)
					return tonumber(a) > tonumber(b)
				end)

				for _, posStr in ipairs(selectedPositions) do
					local pos = tonumber(posStr)
					if pos and pos <= #collection then
						table.remove(collection, pos)
					end
				end

				-- Update the display
				if updateDisplay then
					updateDisplay()
				end
			end
		end
	end

	do -- Text Label and Toggle
		--- Creates an IUP text element with various options
		--- @param options TextOptions Configuration options for the text element. Optionally include help_topic for F1 help.
		--- @return iup.text The created text element
		function makeText(options)
			--TESTED: makeText
			-- Checks if the text value is blank and sets it to a default if necessary.
			-- @param self (iup.element): The text element itself (passed implicitly).
			local function CheckTextNotBlank(self)
				if type(self.value) ~= "string" or self.value == "" then
					-- On blank, restore to the field's DEFAULT rather than its prior value, so an optional
					-- field can be cleared: a default of "" means a blanked field stays blank; a real
					-- default (e.g. "ind") resets to it. Falls back to options.value when no default is
					-- passed, so direct callers keep today's behaviour. ("" is truthy in Lua, so an empty
					-- options.default is honoured.)
					self.value = options.default or options.value or ""
				end
			end
			-- Default values for options (if not provided)
			local defaults = {
				expand = options.multiline == "YES" and "YES" or "HORIZONTAL",
				killfocus_cb = function(self)
					CheckTextNotBlank(self)
					if options.killfocus then
						options.killfocus(self)
					end -- Only call the provided killfocus function if it exists
				end,
			}
			local t = iup.text(mergeOptions(defaults, options)) -- Create the IUP text element with the merged options
			safeSetHandle(options.name or generateRandomDigitString(), t)
			if options.tip then
				setTipWithHelp(t, options.tip, options.tiptitle, options.help_topic)
			end
			if options.help_topic and help then
				help:attach_help_cb(t, options.help_topic)
			end
			return t
		end

		--- Creates an IUP label element with specified options
		--- @param options LabelOptions Configuration options for the label. Optionally include help_topic for F1 help.
		--- @return iup.label The created label element
		function makeLabel(options)
			--TESTED: makeLabel
			-- Default options
			local defaults = { normalizergroup = btnnorm } --done here rather than in theme to allow for long labels that should not be normalized
			-- Merge: user options take precedence
			local lbl = iup.label(mergeOptions(defaults, options))
			if options.tip then
				setTipWithHelp(lbl, options.tip, options.tiptitle, options.help_topic)
			end
			-- IUP label does not support HELP_CB, so F1 help is not attached to labels.
			return lbl
		end

		--- Creates an IUP label element with no normalization
		--- @param options LabelOptions Configuration options for the label. Optionally include help_topic for F1 help.
		--- @return iup.label The created label element
		function makeLongLabel(options)
			--TESTED: makeLabel
			-- Default options
			-- Create label element
			local lbl = iup.label(options)
			lbl.normalizergroup = nil
			if options.tip then
				setTipWithHelp(lbl, options.tip, options.tiptitle, options.help_topic)
			end
			-- IUP label does not support HELP_CB, so F1 help is not attached to labels.
			return lbl
		end

		--- Creates an IUP toggle element with specified options
		--- @param options ToggleOptions Configuration options for the toggle. Optionally include help_topic for F1 help.
		--- @return iup.toggle The created toggle element
		function makeToggle(options)
			--TESTED: makeToggle
			-- Default options
			local defaults = {}
			local t = iup.toggle(mergeOptions(defaults, options))
			safeSetHandle(options.name or generateRandomDigitString(), t)
			if options.tip then
				setTipWithHelp(t, options.tip, options.tiptitle, options.help_topic)
			end
			if options.help_topic and help then
				help:attach_help_cb(t, options.help_topic)
			end
			return t
		end
	end

	do --Container management
		--- Constants used when dealing with containers
		local dialogs = dialogs
			or tablex.index_map({
				"dialog",
				"messagedlg",
				"progesssdlg",
				"fontdg",
				"filedlg",
				"colordlg",
			})
		local containers = containers
			or tablex.index_map({
				"frame",
				"hbox",
				"vbox",
				"zbox",
				"tabs",
				"radio",
				"sbox",
				"cbox",
				"gridbox",
				"multibox",
				"scrollbox",
				"detachbox",
				"expander",
				"detachbox",
				"split",
				"backgroundbox",
				"spinbox",
			})
		local datacontrols = datacontrols
			or tablex.index_map({
				"list",
				"text",
				"val",
				"link",
				"multiline",
				"toggle",
			})
		local static = static
			or tablex.index_map({
				"fill",
				"normalizer",
				"button",
				"label",
				"menu",
				"submenu",
				"item",
				"separator",
				"imagergb",
				"imagergba",
				"image",
				"matrix",
				"cells",
				"clipboard",
				"timer",
				"user",
				"link",
			}) -- These will be ignored when clearing the dialog
		local toohardtohandle = toohardtohandle or tablex.index_map({ "spin", "canvas", "tree" }) -- Only g*d knows

		--- Enables or disables all controls in a container except those specified in the exclude table.
		--- @param ih iup.elementhandle The container handle
		--- @param tblexcludeih table A table of handles to exclude
		--- @param strstate string The state to set ("YES" or "NO")
		function EnableContainer(ih, tblexcludeih, strstate)
			--TESTED: Enable Container
			-- Iterate with iup.GetNextChild, NOT integer indexing: ih[i] reflects only the
			-- children supplied in the constructor (the Lua-side wrapper is frozen at
			-- construction), so it silently misses anything :append()ed later. GetNextChild
			-- walks the real, current child list (proven in FH: Add Facts spike S5, July 2026).
			local element = iup.GetNextChild(ih)
			while element ~= nil do -- Loop through the elements in the parent
				if tblexcludeih[element] == nil then
					if dialogs[iup.GetClassName(element)] ~= nil or containers[iup.GetClassName(element)] ~= nil then
						EnableContainer(element, tblexcludeih, strstate) -- Recursively enable/disable containers
					elseif datacontrols[iup.GetClassName(element)] ~= nil or iup.GetClassName(element) == "button" then
						element.active = strstate -- Enable/disable data controls and buttons
					end
				end
				element = iup.GetNextChild(ih, element)
			end
		end

		--- Clears the values of all data controls in a container except those specified in the exclude table.
		--- @param ih iup.elementhandle The container handle to clear controls within
		--- @param tblexcludeih table<iup.elementhandle, boolean> A table of handles to exclude from clearing
		function ClearContainer(ih, tblexcludeih)
			--TESTED: ClearContainer
			--- Clears a single data control based on its class
			--- @param ih iup.elementhandle The data control handle
			local function ClearDataControl(ih)
				local cclass = iup.GetClassName(ih)
				ih.fgcolor = th.fg --reset text colour to match the theme
				if cclass == "list" then
					if ih.editbox == "YES" or ih.multiple == "YES" then
						ih.value = ""
					else
						ih.value = "0"
					end
				elseif cclass == "text" or cclass == "multiline" then
					ih.value = ""
				elseif cclass == "val" then
					ih.value = "0.0"
				elseif cclass == "toggle" then
					ih.value = "OFF"
				end
			end
			-- Iterate with iup.GetNextChild, NOT integer indexing - see EnableContainer.
			local element = iup.GetNextChild(ih)
			while element ~= nil do -- Loop through the elements in the parent
				if tblexcludeih[element] == nil then
					if dialogs[iup.GetClassName(element)] ~= nil or containers[iup.GetClassName(element)] ~= nil then
						ClearContainer(element, tblexcludeih) -- Recursively clear containers
					elseif datacontrols[iup.GetClassName(element)] ~= nil then
						ClearDataControl(element) -- Clear data controls
					end
				end
				element = iup.GetNextChild(ih, element)
			end
		end

		local function set_visibility(element, isHidden)
			if element.visible ~= nil and element.floating ~= nil then
				element.visible = isHidden and "NO" or "YES"
				element.floating = isHidden and "YES" or "NO"
			end
		end

		---Shows or hides a control and refreshes the parent dialog
		--- @param control iup.control The control
		--- @param isHidden boolean indicating whether to hide (true) or show (false) the control
		function HideControl(control, isHidden)
			--TESTED: HideControl
			set_visibility(control, isHidden)
			local dialog = iup.GetDialog(control)
			if dialog then
				iup.Refresh(dialog)
			end
		end

		--- Shows or hides all controls in a container and refreshes the container
		--- @param container iup.elementhandle The container handle
		--- @param isHidden boolean indicating whether to hide (true) or show (false) the container
		function HideContainer(container, isHidden)
			--TESTED: HideContainer
			-- Loop through the elements in the parent
			local e = 1
			while container[e] ~= nil do
				local element = container[e]
				if dialogs[iup.GetClassName(element)] ~= nil or containers[iup.GetClassName(element)] ~= nil then
					HideContainer(element, isHidden) -- Recursively hide/show containers
				else
					set_visibility(element, isHidden) -- Hide/show control
				end
				e = e + 1
			end
			iup.Refresh(container)
		end

		--- Retrieves the value from a control.
		--- @param ctl iup.elementhandle The control handle
		--- @return iup.element|string # The value of the control
		function GetValueFromControl(ctl)
			--TESTED: GetValueFromControl
			if type(ctl.value) == "string" or type(ctl.value) == "number" then
				return ctl.value
			else
				return ""
			end
		end

		--- Retrieves data from a table of controls.
		--- @param tblCtls table The table of controls
		--- @return table A table of control values
		function GetContainerData(tblCtls)
			--TESTED GetContainerData
			local is_indexed = (rawget(tblCtls, 1) ~= nil)
			local tblVals = {}
			if not is_indexed then
				for k, v in pairs(tblCtls) do
					tblVals[k] = GetValueFromControl(v)
				end
			else
				for k, v in ipairs(tblCtls) do
					tblVals[k] = GetValueFromControl(v)
				end
			end
			return tblVals
		end

		--- Retrieves data elements from a container, adding them to a specified table.
		--- @param ih iup.elementhandle The container handle
		--- @param tblexcludeih table A table of handles to exclude
		--- @param tblElements table A table to store the data elements
		--- @param keyname? boolean|nil boolean indicating whether to use control names as keys
		--- @return table A table of data controls
		function GetDataElements(ih, tblexcludeih, tblElements, keyname)
			--TESTED: GetDataElements
			if not keyname then
				keyname = false
			end
			local child = iup.GetChild(ih, 0)
			while child ~= nil do -- Loop through the elements in the parent and add any data controls to the end of tblElements
				if tblexcludeih[child] == nil then
					if dialogs[iup.GetClassName(child)] ~= nil or containers[iup.GetClassName(child)] ~= nil then
						GetDataElements(child, tblexcludeih, tblElements, keyname) -- Recursively retrieve data elements from containers
					elseif datacontrols[iup.GetClassName(child)] ~= nil then
						if keyname then
							if iup.GetName(child) then
								tblElements[iup.GetName(child)] = child
							end
						else
							tblElements[#tblElements + 1] = child
						end
					end
				end
				child = iup.GetNextChild(ih, child)
			end
			return tblElements -- Return a table of data controls
		end
		--- Rebuilds a container by clearing and repopulating it with new child elements.
		--- V1.5 (Stage 4, 5 Jul 2026): optional destroyOld (a detached-only child leaks across
		--- repeated rebuilds - template switching rebuilds many times per session), and appended
		--- children are now MAPPED when the container is already mapped (content appended to a
		--- shown dialog occupies no space and never paints until iup.Map - proven live, Add Facts
		--- 0.5.5/0.5.6, which carried its own copy of this dance until this function caught up).
		--- @param container iup.elementhandle The container handle to rebuild
		--- @param tblNewChildren table An indexed table of new child elements to add
		--- @param preserveAttributes? boolean Whether to preserve container attributes (default: true)
		--- @param destroyOld? boolean Destroy removed children instead of detaching them (default: false)
		--- @param skipRefresh? boolean Omit the trailing full-dialog Refresh; pass true when
		--- batching several rebuilds and refreshing once yourself (default: false)
		function RebuildContainer(container, tblNewChildren, preserveAttributes, destroyOld, skipRefresh)
			if preserveAttributes == nil then
				preserveAttributes = true
			end

			-- Store container attributes if needed
			local savedAttrs = {}
			if preserveAttributes then
				savedAttrs.expand = container.expand
				savedAttrs.alignment = container.alignment
				savedAttrs.gap = container.gap
				savedAttrs.margin = container.margin
				savedAttrs.normalizesize = container.normalizesize
			end

			-- Remove existing children (GetNextChild enumeration - integer indexing only sees
			-- constructor-time children, Dialog.lua V1.3 note)
			local child = iup.GetChild(container, 0)
			while child ~= nil do
				local nextChild = iup.GetNextChild(container, child)
				iup.Detach(child)
				if destroyOld then
					pcall(iup.Destroy, child)
				end
				child = nextChild
			end

			-- Add new children, mapping them when the container is already mapped (wid set)
			local mapped = container.wid ~= nil
			for i, newChild in ipairs(tblNewChildren) do
				iup.Append(container, newChild)
				if mapped then
					pcall(iup.Map, newChild)
				end
			end

			-- Restore attributes if preserved
			if preserveAttributes then
				for attr, val in pairs(savedAttrs) do
					if val ~= nil then
						container[attr] = val
					end
				end
			end

			-- Refresh the parent dialog, unless the caller is batching several rebuilds
			if not skipRefresh then
				local dialog = iup.GetDialog(container)
				if dialog then
					iup.Refresh(dialog)
				end
			end
		end

		--- Replaces a container with a new one, transferring it into the parent's position.
		--- @param oldContainer iup.elementhandle The container handle to replace
		--- @param newContainer iup.elementhandle The new container to insert
		--- @param destroyOld? boolean Whether to destroy the old container (default: false)
		--- @return iup.elementhandle|nil The new container if successful, nil otherwise
		function ReplaceContainer(oldContainer, newContainer, destroyOld)
			if destroyOld == nil then
				destroyOld = false
			end

			local parent = iup.GetParent(oldContainer)
			if parent == nil then
				return nil -- Cannot replace a container without a parent
			end

			-- Find the position of the old container in its parent
			local position = nil
			local e = 1
			while parent[e] ~= nil do
				if parent[e] == oldContainer then
					position = e
					break
				end
				e = e + 1
			end

			if position == nil then
				return nil -- Could not find old container in parent
			end

			-- Transfer the name if the old container has one
			local oldName = iup.GetName(oldContainer)

			-- Detach old container
			iup.Detach(oldContainer)

			-- Insert new container at the same position
			if position == 1 then
				iup.Insert(parent, nil, newContainer) -- Insert at beginning
			else
				local refChild = parent[position - 1]
				iup.Insert(parent, refChild, newContainer) -- Insert after reference child
			end

			-- Transfer name to new container
			if oldName then
				iup.SetHandle(oldName, newContainer)
			end

			-- Destroy old container if requested
			if destroyOld then
				iup.Destroy(oldContainer)
			end

			-- Refresh the parent dialog
			local dialog = iup.GetDialog(parent)
			if dialog then
				iup.Refresh(dialog)
			end

			return newContainer
		end
	end

	do -- Additional UI components
		--- Creates an expander control
		--- @param content iup.element The content to be expanded/collapsed
		--- @param title string The title for the expander
		--- @param state string The initial state ("OPEN" or "CLOSED")
		--- @return iup.expander The created expander element
		function makeExpander(content, title, state)
			-- The content must be the constructor child (IupExpander has no
			-- CONTENT attribute).
			local expander = iup.expander({
				content,
				title = title,
				state = state or "CLOSED",
			})
			return expander
		end

		--- Creates a gridbox control
		--- @param options table Configuration options for the gridbox
		--- @return iup.gridbox The created gridbox element
		function makeGridbox(options)
			options = options or {}
			local gridbox = iup.gridbox(options)
			return gridbox
		end

		--- Creates a date field control
		--- @param options table Configuration options for the date field
		--- @return iup.element The created date field container
		function makeDateField(options)
			options = options or {}
			local title = options.title or "Date"
			local tip = options.tip or "Enter date"

			local txtDate = makeText({
				visiblelines = "1",
				expand = "HORIZONTAL",
				tip = tip,
				name = "date",
			})

			local labDate = makeLabel({
				title = title,
				tip = tip,
			})

			return iup.hbox({ labDate, txtDate, alignment = "ACENTER" })
		end
	end
end

end)()
--<<FS_SPLICE_END>>

---Displays a hierarchical file/folder selection dialog using a tree control.
---The dialog allows users to browse through directories and select files or folders
---based on specified criteria. Files can be filtered by extension, and the dialog
---supports both single and multiple selection modes.
---
---The function builds a recursive tree structure starting from the root directory,
---separating folders and files, applying extension filters, and sorting items
---alphabetically. Folders are displayed as expandable branches, while files appear
---as leaf nodes. The dialog includes Select All and Clear All buttons for convenience.
---
---@param rootDirectory string Root directory to start browsing from
---@param extensions string[] File extensions to filter by (empty table shows all files)
---@param allowMultiple boolean Whether multiple files can be selected
---@param rootLabel string Label for the root item in the tree
---@param dialogTitle string Title for the selection dialog
---@param showExtensions boolean Whether to show file extensions in the tree
---@param foldersOnly boolean Whether to show only folders (if true, returns folder paths instead of file paths)
---@param parentDialog iup.dialog Optional parent dialog to use for positioning
---@return boolean Whether the function was successful
---@return string[] Table of selected files/folders or nil if unsuccessful
function FileSelector(
	rootDirectory,
	extensions,
	allowMultiple,
	rootLabel,
	dialogTitle,
	showExtensions,
	foldersOnly,
	parentDialog
)
	fhfu = require("fhFileUtils")
	if not rootDirectory then
		return false, nil --root directory is required
	end
	extensions = extensions or {}
	allowMultiple = allowMultiple or false
	rootLabel = rootLabel or "Root"
	dialogTitle = dialogTitle or "File Selector"
	showExtensions = showExtensions == nil and true or showExtensions
	foldersOnly = foldersOnly or false

	-- Helper function to recursively build tree structure
	local function buildTreeStructure(directoryPath, isRoot)
		local treeData = {}

		-- Get contents of current directory (non-recursive)
		local contents, error = fhfu.getFolderContents(directoryPath, false, false)
		if not contents then
			if isRoot then
				MessageBox("error", "Error reading root directory: " .. error, "OK")
				return nil -- Return nil to indicate error
			else
				return treeData -- Return empty tree if subdirectory can't be read
			end
		end

		local folders = {}
		local files = {}

		-- Separate folders and files, filter files by extension
		for _, item in ipairs(contents) do
			local pathParts = fhfu.splitPath(item.path)

			if fhfu.folderExists(item.path) then
				-- It's a folder
				table.insert(folders, {
					name = item.name,
					path = item.path,
				})
			else
				-- It's a file - check if extension matches
				local fileExt = string.lower(pathParts.ext or "")
				local matchesExtension = #extensions == 0 -- if no extensions specified, show all files

				if not matchesExtension then
					for _, ext in ipairs(extensions) do
						if string.lower(ext) == fileExt then
							matchesExtension = true
							break
						end
					end
				end

				if matchesExtension then
					-- Determine display name based on showExtensions parameter
					local displayName = item.name
					if not showExtensions then
						displayName = pathParts.basename
					end

					table.insert(files, {
						name = item.name,
						displayName = displayName,
						path = item.path,
					})
				end
			end
		end

		-- Sort folders and files alphabetically
		table.sort(folders, function(a, b)
			return a.name < b.name
		end)
		table.sort(files, function(a, b)
			return a.displayName < b.displayName
		end)

		-- Add folders with their subtrees
		for _, folder in ipairs(folders) do
			local folderNode = {
				branchname = folder.name,
			}

			-- In folders-only mode, add userid for the folder itself
			if foldersOnly then
				folderNode.userid = { path = folder.path }
			end

			-- Recursively build subtree for this folder
			local subtree = buildTreeStructure(folder.path, false)
			if subtree then
				for _, node in ipairs(subtree) do
					table.insert(folderNode, node)
				end
			end

			table.insert(treeData, folderNode)
		end

		-- Add files at this level (only if not in folders-only mode)
		if not foldersOnly then
			for _, file in ipairs(files) do
				table.insert(treeData, {
					leafname = file.displayName,
					userid = { path = file.path },
				})
			end
		end

		return treeData
	end

	-- Build the tree structure starting from root directory
	local rootTree = {
		branchname = rootLabel,
	}

	-- In folders-only mode the root itself is a valid choice
	if foldersOnly then
		rootTree.userid = { path = rootDirectory }
	end

	local subtreeData = buildTreeStructure(rootDirectory, true)
	if not subtreeData then
		return false, nil -- Error already shown by buildTreeStructure
	end

	for _, item in ipairs(subtreeData) do
		table.insert(rootTree, item)
	end

	local tree = iup.tree({
		IMAGELEAF = "IMGPAPER",
		markmode = allowMultiple and "MULTIPLE" or "SINGLE",
	})

	-- Track OK/Cancel
	local okPressed = false
	local selectedPaths = {}

	-- Create selection buttons
	local selectAllBtn = makeButton({
		title = "Select All",
		size = "64x",
		expand = "NO",
		callback = function()
			tree.MARK = "MARKALL"
			return iup.DEFAULT
		end,
	})

	local clearAllBtn = makeButton({
		title = "Clear All",
		size = "64x",
		expand = "NO",
		callback = function()
			tree.MARK = "CLEARALL"
			return iup.DEFAULT
		end,
	})

	-- Create OK button
	local okBtn = makeButton({
		title = "OK",
		size = "64x",
		expand = "NO",
		close = true,
		callback = function()
			-- Collect selected paths using userid
			selectedPaths = {}

			-- Get selection state
			local markedNodes = tree.MARKEDNODES

			-- Get total node count
			local nodeCount = tree.count

			if nodeCount and tonumber(nodeCount) > 0 then
				for i = 1, tonumber(nodeCount) do
					local isSelected = markedNodes and markedNodes:sub(i, i) == "+"
					if isSelected then
						-- The MARKEDNODES string is 1-based, but GetUserId is 0-based
						local userid = iup.TreeGetUserId(tree, i - 1)
						if userid then
							if type(userid) == "table" and userid.path then
								table.insert(selectedPaths, userid.path)
							end
						end
					end
				end
			end
			okPressed = true
			return true
		end,
	})

	-- Create Cancel button
	local cancelBtn = makeButton({
		title = "Cancel",
		size = "64x",
		expand = "NO",
		close = true,
	})

	-- Create dialog content
	local buttonBox = {}

	-- Only add Select All and Clear All buttons if not in folders-only mode
	if not foldersOnly then
		table.insert(buttonBox, selectAllBtn)
		table.insert(buttonBox, clearAllBtn)
	end

	-- New Folder button omitted

	table.insert(buttonBox, iup.fill({}))
	table.insert(buttonBox, okBtn)
	table.insert(buttonBox, cancelBtn)

	local content = iup.vbox({
		tree,
		iup.hbox(buttonBox),
	})
	-- Create dialog
	local dlg = makeDialog(content, {
		title = dialogTitle,
		resize = "YES",
		size = "HALFxHALF",
	})

	dlg:map() --must map the dialog before adding nodes

	-- Prepare tree for bulk node insert without redraw
	tree.autoredraw = "NO"
	-- Ensure nodes are added collapsed
	tree.addexpanded = "NO"
	iup.TreeAddNodes(tree, rootTree)

	-- Set initial focus to root (avoid expanding entire tree)
	tree.value = 0
	-- Expand only the root
	tree["STATE0"] = "EXPANDED"
	tree.autoredraw = "YES"
	dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
	dlg:destroy()
	return okPressed, selectedPaths
end

--<<FS_SPLICE "../../2 Boilerplate/Progress.lua">>
;(function()
-- Progress dialog and controller (IUP)
-- Provides a lightweight progress dialog and a throttled controller for iterative operations

require("iuplua")

-- Progress dialogs are topmost so they stay visible during a long operation - but that also puts
-- them in front of any MessageBox (a native message dialog, which can't be made topmost), hiding it.
-- We track the shown progress dialogs here; MessageBox calls Progress.suspendTopmost() around its
-- popup so the message comes to the front, then restores. Globally applicable: any plugin that uses
-- this Progress controller and shows a message mid-operation gets readable messages for free.
local shownProgressDialogs = {}

--- Drop topmost on every currently-shown progress dialog, returning a function that restores it.
--- Safe to call when none are shown (returns a no-op). Guarded so a mid-teardown dialog can't error.
local function suspendTopmost()
	local toRestore = {}
	for dlg in pairs(shownProgressDialogs) do
		pcall(function()
			if dlg.topmost == "YES" then
				dlg.topmost = "NO"
				toRestore[#toRestore + 1] = dlg
			end
		end)
	end
	return function()
		for _, dlg in ipairs(toRestore) do
			pcall(function() dlg.topmost = "YES" end)
		end
	end
end

--- Create and manage a lightweight progress dialog
--- @param totalSteps number Total number of iterations to perform
--- @param title? string Optional dialog title
--- @param parentDialog? any Optional parent dialog handle
--- @return table Progress controller with methods: show(), update(step, text), isCancelled(), close()
local function createProgressDialog(totalSteps, title, parentDialog)
	local cancelled = false

	local lbl = makeLabel({
		title = "Starting...",
	})

	local bar = iup.progressbar({
		min = 0,
		max = 1,
		value = 0,
		expand = "HORIZONTAL",
	})

	local btnCancel = makeButton({
		title = "Cancel",
		callback = function(self)
			cancelled = true
			return true
		end,
	})

	local buttons = iup.hbox({ iup.fill({}), btnCancel })

	local box = iup.vbox({
		lbl,
		bar,
		buttons,
		margin = "10x10",
		gap = "8",
		expand = "YES",
	})

	local dlg = makeDialog(box, {
		title = title or "Applying...",
		size = "QUARTERxEIGHTH",
		dialogframe = "YES",
		menubox = "NO",
		topmost = "YES",
	})

	local function update(step, text)
		local denom = (totalSteps and totalSteps > 0) and totalSteps or 1
		bar.value = math.min(1, (step or 0) / denom)
		if text and text ~= "" then
			lbl.title = text
		end
		iup.Refresh(dlg)
		iup.LoopStep()
	end

	return {
		show = function()
			dlg:showxy(iup.CENTERPARENT, iup.CENTERPARENT)
			shownProgressDialogs[dlg] = true -- so MessageBox can drop our topmost while a message is up
			iup.LoopStep()
		end,
		update = update,
		isCancelled = function()
			return cancelled
		end,
		close = function()
			if dlg then
				shownProgressDialogs[dlg] = nil
				dlg:destroy()
				dlg = nil
			end
		end,
	}
end

--- ProgressController handles showing and throttling progress updates based on parameters
--- @class ProgressController
--- @field totalSteps number
--- @field showThreshold number
--- @field updateStepFraction number
--- @field step number
--- @field lastFraction number
--- @field dlg any
--- @field _parentDialog any
local ProgressController = {}
ProgressController.__index = ProgressController

--- Create a new ProgressController
--- @param totalSteps number
--- @param updatePercent? number Percentage step for updates (1-100). Default 5
--- @param showThreshold? number Minimum total steps to show progress. Default 20
--- @param parentDialog? any Optional parent dialog handle for modality
--- @return ProgressController
function ProgressController.new(totalSteps, updatePercent, showThreshold, parentDialog)
	local UPDATE_PERCENT = (type(updatePercent) == "number" and updatePercent >= 1 and updatePercent <= 100)
			and updatePercent
		or 5
	local SHOW_THRESHOLD = (type(showThreshold) == "number" and showThreshold >= 0) and showThreshold or 20

	local self = setmetatable({}, ProgressController)
	self.totalSteps = totalSteps or 0
	self.showThreshold = SHOW_THRESHOLD
	self.updateStepFraction = math.max(0.01, UPDATE_PERCENT / 100)
	self.step = 0
	self.lastFraction = -1
	self._parentDialog = parentDialog
	self.dlg = nil
	return self
end

function ProgressController:shouldShow()
	return (self.totalSteps or 0) >= (self.showThreshold or 0)
end

function ProgressController:ensureShown()
	if not self.dlg and self:shouldShow() and (self.totalSteps or 0) > 0 then
		self.dlg = createProgressDialog(self.totalSteps, "Applying...", self._parentDialog)
		self.dlg.show()
	end
end

function ProgressController:isCancelled()
	return self.dlg and self.dlg.isCancelled() or false
end

function ProgressController:update(text)
	self.step = self.step + 1
	if not self.dlg then
		-- Defer showing until needed
		self:ensureShown()
	end
	if not self.dlg then
		return -- Not showing progress (below threshold or zero steps)
	end
	local fraction = (self.totalSteps > 0) and (self.step / self.totalSteps) or 1
	local shouldUpdate = (self.totalSteps <= 20)
		or (self.step == 1)
		or (self.step == self.totalSteps)
		or (fraction >= (self.lastFraction + self.updateStepFraction))
	if shouldUpdate then
		self.dlg.update(self.step, text)
		self.lastFraction = fraction
	end
end

function ProgressController:finish()
	if self.dlg then
		self.dlg.update(self.totalSteps, "Finishing...")
		self.dlg.close()
		self.dlg = nil
	end
end
-- Module-style export akin to Config.lua
Progress = {
	new = ProgressController.new,
	suspendTopmost = suspendTopmost, -- used by MessageBox so messages aren't hidden behind progress
}

end)()
--<<FS_SPLICE_END>>

--<<FS_SPLICE "../../2 Boilerplate/Config.lua">>
;(function()
--[[
Configuration Helper for Family Historian Plugins

This file provides tools to save and load settings using .ini files, which are simple text files
that store configuration data in a format like this:
[Section]
key=value

The code creates a class named Config that handles reading and writing these settings,
managing dialog window positions, and creating configuration user interfaces.

@Author: Helen Wright
@Version: 1.3
@V1.3: two beta-retest fixes. (1) showTrackedDialog measured its minimum size AFTER
       clearing minsize but BEFORE clearing the dialog's own SIZE attribute, so IUP's
       reported natural size - which is max(content natural, SIZE) - inflated the floor
       to the SIZE hint (e.g. a HALFxHALF main dialog got a minimum of half the SCREEN
       on a large display, unnoticeable on a small one). computePlacement gains a fifth,
       optional openSize parameter so a dialog still opens at its SIZE hint (capped) when
       there is no saved size, while minsize and the natural-size floor use content-only
       measurements. (2) showConfigDialog's Options dialog popped up with
       iup.CENTERPARENT, which centres on the parent rectangle but does not clamp to the
       screen - with the main window near the top of the display and Options taller than
       it, the computed top could sit above the screen, leaving the title bar unreachable
       until a resize re-clamped it. New pure placeOverParent helper computes an
       explicit, screen-clamped popup position instead.
@V1.2: window geometry (tracked-dialog x/y/rastersize, and the Options
       dialog's size) now lives in a per-machine store instead of the
       plugin's own config ini, which for some plugins is per-project and so
       synced a saved position between machines - a beta showstopper when an
       ultra-wide-monitor position opened off-screen on a laptop. The saved
       position is also now validated against each monitor's full bounds,
       not just its origin, and clamped fully on-screen instead of being
       accepted or rejected outright. The Options dialog is now capped at
       the screen on first build (its sections already scroll), remembers
       its size between sessions, and centres reliably over the plugin's
       main window instead of wherever the keyboard focus happened to be.
@V1.1: showTrackedDialog restores a saved size clamped to [natural size,
       screen work area] and floors minsize there (it previously measured
       the natural size and discarded it, so stale saved sizes clipped
       trailing controls and every consumer carried its own fix).
@Date: 2024
]]
do
	local M = {} -- Module table

	---@class Config
	---@field filePath string
	---@field defaultConfig table
	---@field cache table<string, table<string, any>>
	---@field callbacks table<string, table<string, fun(value:any, oldValue:any)>>
	local Config = {}
	Config.__index = Config

	--==================== CONFIG-PLACEMENT-PURE ====================--
	-- Dialog placement/sizing maths, pulled out pure (no iup/fh calls) so config_placement_spec.lua
	-- can exercise it under plain lua.exe. Mirrored verbatim there - keep the two in step.

	-- Vertical allowance left below a dialog's height cap for the title bar and taskbar (there is
	-- no equivalent horizontal allowance - width caps use the monitor/screen width as-is).
	local DECOR_ALLOWANCE = 80

	--- Parse an IUP MONITORSINFO string into an array of monitor rects. Tolerates a nil/malformed
	--- string (returns {}); coordinates may be negative (a monitor positioned left of/above the
	--- primary display).
	---@param miString string? -- multi-line "x y w h" per monitor
	---@return {x:number, y:number, w:number, h:number}[]
	local function parseMonitorsInfo(miString)
		local monitors = {}
		if type(miString) ~= "string" then
			return monitors
		end
		for line in miString:gmatch("[^\r\n]+") do
			local x, y, w, h = line:match("^%s*(%-?%d+)%s+(%-?%d+)%s+(%-?%d+)%s+(%-?%d+)")
			if x then
				monitors[#monitors + 1] = { x = tonumber(x), y = tonumber(y), w = tonumber(w), h = tonumber(h) }
			end
		end
		return monitors
	end

	--- Work out where and how big to (re)open a tracked dialog. Pure: takes parsed inputs, returns
	--- a placement, does no measuring or IUP calls itself.
	---@param saved {x:number?, y:number?, w:number?, h:number?} -- from the geometry store
	---@param natural {w:number?, h:number?} -- the dialog's measured, content-only natural size
	---@param monitors {x:number, y:number, w:number, h:number}[] -- parsed MONITORSINFO
	---@param screen {w:number?, h:number?} -- primary SCREENSIZE
	---@param openSize {w:number?, h:number?}? -- the dialog's SIZE-attribute user size (e.g. "HALFxHALF"),
	---       used only when there is no saved size; see the base-size comment at step 3
	---@return {x:number?, y:number?, w:number?, h:number?, minW:number?, minH:number?} -- nil x/y means centre on parent
	local function computePlacement(saved, natural, monitors, screen, openSize)
		saved = saved or {}
		natural = natural or {}
		monitors = monitors or {}
		screen = screen or {}
		openSize = openSize or {}

		-- 1. Which monitor (if any) does the saved position claim to be on? The margins (100
		-- horizontal, 80 vertical) keep enough of the title bar on-screen to grab or close the
		-- dialog even when the saved position sits right at a monitor's edge. Exactly (0,0) is the
		-- existing "no saved position" sentinel, not a real top-left placement.
		local target
		if saved.x and saved.y and not (saved.x == 0 and saved.y == 0) then
			for _, m in ipairs(monitors) do
				if saved.x >= m.x and saved.x <= m.x + m.w - 100 and saved.y >= m.y and saved.y <= m.y + m.h - 80 then
					target = m
					break
				end
			end
		end

		-- 2. Cap dimensions against the target monitor, or the primary screen when centring.
		local capW, capH
		if target then
			capW, capH = target.w, target.h - DECOR_ALLOWANCE
		elseif screen.w and screen.h then
			capW, capH = screen.w, screen.h - DECOR_ALLOWANCE
		end

		-- 3. Size: saved wins over openSize wins over natural (a stale saved size from an older,
		-- narrower layout must not clip trailing controls, so the result is still floored at
		-- natural below regardless of which of the three the base size came from). openSize is
		-- the dialog's own SIZE-attribute hint (e.g. "HALFxHALF"): with no saved size the dialog
		-- opens at that hint, capped like everything else - only the floor/minsize below are
		-- content-only. When the dialog has no SIZE attribute, openSize == natural and this step
		-- behaves exactly as before.
		local w = saved.w or openSize.w or natural.w
		local h = saved.h or openSize.h or natural.h
		if natural.w and w then
			w = math.max(w, natural.w)
		end
		if natural.h and h then
			h = math.max(h, natural.h)
		end
		if capW and w then
			w = math.min(w, capW)
		end
		if capH and h then
			h = math.min(h, capH)
		end

		-- 4. Floor future user resizes at the (capped) natural size.
		local minW = natural.w and (capW and math.min(natural.w, capW) or natural.w) or nil
		local minH = natural.h and (capH and math.min(natural.h, capH) or natural.h) or nil

		-- 5. Position: only meaningful with a target monitor (otherwise centre on parent). Clamp
		-- using the final w/h so the dialog rect stays fully on the monitor; when w/h are unknown,
		-- the already-contained saved position is kept as-is.
		local x, y
		if target then
			x, y = saved.x, saved.y
			if w then
				x = math.max(target.x, math.min(x, target.x + target.w - w))
			end
			if h then
				y = math.max(target.y, math.min(y, target.y + target.h - h))
			end
		end

		return { x = x, y = y, w = w, h = h, minW = minW, minH = minH }
	end

	--- Cap an initial dialog size at the primary screen, with a 300x200 sanity floor - but NO
	--- natural-size floor (unlike computePlacement above): a deliberately-reduced saved size must
	--- survive between sessions. Used for the Options dialog, whose sections live in scrollboxes,
	--- so a capped size never clips a field - it just scrolls.
	---@param saved {w:number?, h:number?}?
	---@param natural {w:number?, h:number?}?
	---@param screen {w:number?, h:number?}?
	---@return {w:number?, h:number?}
	local function capSize(saved, natural, screen)
		saved = saved or {}
		natural = natural or {}
		screen = screen or {}
		local w = saved.w or natural.w
		local h = saved.h or natural.h
		if screen.w and w then
			w = math.min(w, screen.w)
		end
		if screen.h and h then
			h = math.min(h, screen.h - DECOR_ALLOWANCE)
		end
		if w then
			w = math.max(w, 300)
		end
		if h then
			h = math.max(h, 200)
		end
		return { w = w, h = h }
	end

	--- Work out where to pop up a dialog centred on its parent, clamped fully onto whichever
	--- monitor the parent sits on (or the primary screen if none matches). iup.CENTERPARENT alone
	--- centres on the parent rectangle but does NOT clamp to the screen: when the parent sits near
	--- the top of the display and the popup is taller than it, CENTERPARENT can compute a top
	--- above the screen, leaving the title bar unreachable until the next resize re-clamps it (a
	--- beta report against the Options dialog).
	---@param parent {x:number, y:number, w:number, h:number}? -- the parent dialog's screen rect
	---@param size {w:number?, h:number?}? -- the popup dialog's own current size
	---@param monitors {x:number, y:number, w:number, h:number}[] -- parsed MONITORSINFO
	---@param screen {w:number?, h:number?} -- primary SCREENSIZE
	---@return {x:number?, y:number?} -- empty table means "fall back to CENTERPARENT" (no usable parent/size)
	local function placeOverParent(parent, size, monitors, screen)
		monitors = monitors or {}
		screen = screen or {}
		size = size or {}
		if not (parent and parent.x and parent.y and parent.w and parent.h and size.w and size.h) then
			return {}
		end

		-- Centre on the parent rect.
		local x = math.floor(parent.x + (parent.w - size.w) / 2)
		local y = math.floor(parent.y + (parent.h - size.h) / 2)

		-- Pick the monitor containing the parent's centre point - plain rect containment, no edge
		-- margins needed (unlike computePlacement's saved-position check above, which is testing a
		-- corner that must leave room to grab a title bar; here we are only choosing which monitor
		-- rect to clamp into).
		local cx = parent.x + parent.w / 2
		local cy = parent.y + parent.h / 2
		local clamp
		for _, m in ipairs(monitors) do
			if cx >= m.x and cx <= m.x + m.w and cy >= m.y and cy <= m.y + m.h then
				clamp = m
				break
			end
		end
		if not clamp and screen.w and screen.h then
			clamp = { x = 0, y = 0, w = screen.w, h = screen.h }
		end
		if not clamp then
			return { x = x, y = y }
		end

		-- Clamp fully onto the chosen monitor; the lower bound is applied LAST so the title bar
		-- (top-left) always wins when the popup is bigger than the monitor.
		x = math.min(x, clamp.x + clamp.w - size.w)
		x = math.max(clamp.x, x)
		y = math.min(y, clamp.y + clamp.h - size.h)
		y = math.max(clamp.y, y)

		return { x = x, y = y }
	end
	--==================== END CONFIG-PLACEMENT-PURE ====================--

	-- Machine-scope geometry store for ALL dialog geometry (tracked-dialog x/y/rastersize, and the
	-- Options dialog's persisted size, further down). Window geometry is per-machine data: it must
	-- never travel with a synced project the way the rest of a plugin's config can (Add Trees'
	-- config ini is per-project, which is exactly how a saved ultra-wide-monitor position ended up
	-- opening off-screen on a laptop - the frozen-looking-FH beta bug this store exists to prevent).
	-- Path is resolved lazily, once, and pcall-guarded; on failure geometry persistence just
	-- silently degrades to defaults / a centred dialog. No migration from old project-ini positions
	-- - those are exactly the cross-machine values this store exists to stop trusting.
	local geometryFilePath, geometryFilePathTried
	local function getGeometryFilePath()
		if not geometryFilePathTried then
			geometryFilePathTried = true
			local ok, path = pcall(function()
				return fhGetPluginDataFileName("LOCAL_MACHINE", true) .. "\\"
					.. fhGetContextInfo("CI_PLUGIN_NAME") .. " Window Layout.ini"
			end)
			if ok then
				geometryFilePath = path
			end
		end
		return geometryFilePath
	end

	local function getGeometryValue(key, fhType, default)
		local path = getGeometryFilePath()
		if not path then
			return default
		end
		local ok, result = pcall(fhGetIniFileValue, path, "Dialogs", key, fhType, default)
		if ok then
			return result
		end
		return default
	end

	local function setGeometryValue(key, fhType, value)
		local path = getGeometryFilePath()
		if not path then
			return
		end
		pcall(fhSetIniFileValue, path, "Dialogs", key, fhType, value)
	end

	--- Constructor for Config
	---@param defaultConfig table
	---@param scope? string
	---@param filename? string
	---@return Config
	function M.new(defaultConfig, scope, filename)
		local self = setmetatable({}, Config)
		local pluginName = fhGetContextInfo("CI_PLUGIN_NAME")
		scope = scope or "LOCAL_MACHINE"
		filename = filename or (pluginName .. ".ini")
		self.filePath = fhGetPluginDataFileName(scope, true) .. "\\" .. filename
		self.defaultConfig = defaultConfig
		self.cache = {}
		self.callbacks = {}

		local fhfu = require("fhFileUtils")
		local fileExists = fhfu.fileExists(self.filePath)

		-- Check if file exists and has content
		local fileHasContent = false
		if fileExists then
			local success, fileContent = pcall(function()
				return fhLoadTextFile(self.filePath, "UTF-16LE")
			end)
			if success and fileContent then
				fileHasContent = #fileContent:gsub("%s+", "") > 0
			end
		end

		-- Create empty file if it doesn't exist
		if not fileExists then
			local success = pcall(function()
				fhSaveTextFile(self.filePath, "", "UTF-16LE")
			end)
			-- If creating the file fails, we'll continue with defaults
		end

		for _, section in ipairs(self.defaultConfig.sections or {}) do
			self.cache[section.title] = {}
			self.callbacks[section.title] = {}
			for _, field in ipairs(section.fields) do
				local valueType = type(field.default)
				local fhType = valueType == "string" and "text"
					or valueType == "number" and "integer"
					or valueType == "boolean" and "bool"
					or valueType
				local value

				-- Only try to read from file if it exists and has content
				if fileExists and fileHasContent then
					local success, result = pcall(function()
						return fhGetIniFileValue(self.filePath, section.title, field.key, fhType, field.default)
					end)
					if success then
						value = result
					else
						-- If reading fails, use default value
						value = field.default
					end
				else
					-- Use default value and write it to the file
					value = field.default
					local success = pcall(function()
						fhSetIniFileValue(self.filePath, section.title, field.key, fhType, value)
					end)
					-- If writing fails, just continue - this ensures the method doesn't crash
					if not success then
						-- Log error using fhMessageBox
						fhMessageBox(
							"Failed to write configuration value to file: "
								.. self.filePath
								.. "\nSection: "
								.. section.title
								.. "\nKey: "
								.. field.key,
							"MB_OK",
							"MB_ICONERROR"
						)
					end
				end

				self.cache[section.title][field.key] = value
				if field.onChange then
					self.callbacks[section.title][field.key] = field.onChange
				end
			end
		end

		return self
	end

	function Config:initializeDefaults()
		for _, section in ipairs(self.defaultConfig.sections or {}) do
			for _, field in ipairs(section.fields) do
				local valueType = type(field.default)
				local fhType = valueType == "string" and "text"
					or valueType == "number" and "integer"
					or valueType == "boolean" and "bool"
					or valueType

				-- Use pcall to handle potential errors when writing to file
				local success = pcall(function()
					fhSetIniFileValue(self.filePath, section.title, field.key, fhType, field.default)
				end)

				-- If writing fails, just continue - this ensures the method doesn't crash
				if not success then
					-- Log error using fhMessageBox
					fhMessageBox(
						"Failed to write configuration value to file: "
							.. self.filePath
							.. "\nSection: "
							.. section.title
							.. "\nKey: "
							.. field.key,
						"MB_OK",
						"MB_ICONERROR"
					)
				end
			end
		end
	end

	function Config:getString(section, key, default)
		local success, result = pcall(function()
			return fhGetIniFileValue(self.filePath, section, key, "text", default or "")
		end)
		return success and result or (default or "")
	end

	function Config:getNumber(section, key, default)
		local success, result = pcall(function()
			return fhGetIniFileValue(self.filePath, section, key, "integer", default or 0)
		end)
		return success and result or (default or 0)
	end

	function Config:getBool(section, key, default)
		local success, result = pcall(function()
			return fhGetIniFileValue(self.filePath, section, key, "bool", default or false)
		end)
		return success and result or (default or false)
	end

	-- Hex-only color handling

	--- Opens a color picker dialog, stores the result if confirmed, and returns chosen hex
	---@param section string
	---@param key string
	---@param startColor string|nil  -- e.g. "#RRGGBB"
	---@return string|nil  -- hex or nil if cancelled
	function Config:chooseColor(section, key, startColor)
		local baseHex = self:getHexColor(section, key, startColor or "#000000")
		local dlg = iup.colordlg({})
		pcall(function()
			dlg.valuehex = baseHex
			dlg.showhex = "YES"
		end)
		dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
		if dlg.status == "1" then
			local hex = tostring(dlg.valuehex or baseHex)
			self:setHexColor(section, key, hex)
			return hex
		end
		return nil
	end

	--- Opens a color picker with alpha; stores and returns hex (#RRGGBBAA)
	---@param section string
	---@param key string
	---@param startColor string|nil  -- e.g. "#RRGGBBAA"
	---@return string|nil
	function Config:chooseColorRGBA(section, key, startColor)
		local current = self:getHexColorRGBA(section, key, startColor or "#000000FF")
		local baseHex = current:sub(1, 7)
		local dlg = iup.colordlg({})
		pcall(function()
			dlg.valuehex = baseHex
			dlg.showhex = "YES"
			dlg.showalpha = "YES"
			-- keep existing alpha in the field if any
			dlg.alpha = tostring(tonumber(current:sub(8, 9), 16) or 255)
		end)
		dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
		if dlg.status == "1" then
			local hex = tostring(dlg.valuehex or baseHex)
			local aa = tonumber(dlg.alpha) or 255
			aa = math.max(0, math.min(255, aa))
			hex = string.format("%s%02X", hex, aa)
			self:setHexColorRGBA(section, key, hex)
			return hex
		end
		return nil
	end

	--- Retrieves a font string from config (IUP font syntax, e.g. "Segoe UI, 10")
	---@param section string
	---@param key string
	---@param default string|nil
	---@return string
	function Config:getFont(section, key, default)
		local success, result = pcall(function()
			return fhGetIniFileValue(self.filePath, section, key, "text", default or "")
		end)
		local value = success and result or (default or "")
		self.cache[section] = self.cache[section] or {}
		self.cache[section][key] = value
		return value
	end

	-- Hex-first color API (simple string get/set). Preferred for plugins using hex everywhere.

	--- Gets a hex color string (#RRGGBB) from config
	---@param section string
	---@param key string
	---@param default string|nil  -- e.g. "#000000"
	---@return string
	function Config:getHexColor(section, key, default)
		return self:getString(section, key, default or "#000000")
	end

	--- Sets a hex color string (#RRGGBB) in config
	---@param section string
	---@param key string
	---@param hex string
	function Config:setHexColor(section, key, hex)
		pcall(function()
			fhSetIniFileValue(self.filePath, section, key, "text", tostring(hex or "#000000"))
		end)
		self.cache[section] = self.cache[section] or {}
		self.cache[section][key] = tostring(hex or "#000000")
	end

	--- Gets a hex color string with alpha (#RRGGBBAA) from config
	---@param section string
	---@param key string
	---@param default string|nil  -- e.g. "#000000FF"
	---@return string
	function Config:getHexColorRGBA(section, key, default)
		return self:getString(section, key, default or "#000000FF")
	end

	--- Sets a hex color string with alpha (#RRGGBBAA) in config
	---@param section string
	---@param key string
	---@param hex string
	function Config:setHexColorRGBA(section, key, hex)
		pcall(function()
			fhSetIniFileValue(self.filePath, section, key, "text", tostring(hex or "#000000FF"))
		end)
		self.cache[section] = self.cache[section] or {}
		self.cache[section][key] = tostring(hex or "#000000FF")
	end

	--- Stores a font string in config (IUP font syntax)
	---@param section string
	---@param key string
	---@param font string
	function Config:setFont(section, key, font)
		pcall(function()
			fhSetIniFileValue(self.filePath, section, key, "text", font)
		end)
		self.cache[section] = self.cache[section] or {}
		self.cache[section][key] = font
	end

	--- Opens a font dialog, stores the result if confirmed, and returns the chosen font string
	---@param section string
	---@param key string
	---@param startFont string|nil
	---@return string|nil  -- returns nil if cancelled
	function Config:chooseFont(section, key, startFont)
		local current = self:getFont(section, key, startFont or "")
		local dlg = iup.fontdlg({ value = current })
		dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
		if dlg.status == "1" and dlg.value and dlg.value ~= "" then
			self:setFont(section, key, dlg.value)
			return dlg.value
		end
		return nil
	end

	--- Shows a dialog with position and size tracking capabilities
	--- This function enhances a dialog by:
	--- 1. Loading and restoring the dialog's previous position and size from configuration
	--- 2. Saving the dialog's position and size when it's closed
	--- 3. Ensuring the dialog appears on a valid monitor
	--- 4. Preserving any existing dialog callbacks
	---@param dialog iup.dialog The dialog to show with tracking
	---@param dialogId string Unique identifier for this dialog (used for config storage)
	function Config:showTrackedDialog(dialog, dialogId)
		local config = self

		-- Store the original close callback to preserve existing functionality
		local originalClose = dialog.close_cb

		-- Load the previously saved position and size from the per-machine geometry store (NOT
		-- self - see the geometry-store comment above; a project-scoped position is exactly what
		-- produced the off-screen-on-a-laptop beta bug). Missing keys default to 0/"" - the same
		-- defaults fhGetIniFileValue would give a first run - so (0,0) still reads as "unset" below.
		local savedX = getGeometryValue("Dialog_" .. dialogId .. ".x", "integer", 0)
		local savedY = getGeometryValue("Dialog_" .. dialogId .. ".y", "integer", 0)
		local savedRaster = getGeometryValue("Dialog_" .. dialogId .. ".rastersize", "text", "")

		-- Override the close callback to save position and size when dialog closes
		dialog.close_cb = function(dlg)
			-- Only save position if dialog is not maximized or minimized
			if dialog.maximized == "NO" and dialog.minimized == "NO" then
				local pos = dialog.screenposition
				if pos then
					-- Extract x,y coordinates from the position string (format: "x,y")
					local closeX, closeY = pos:match("^(%-?%d+),(%-?%d+)$")
					if closeX and closeY then
						-- Save the current position and size to the geometry store
						setGeometryValue("Dialog_" .. dialogId .. ".x", "integer", tonumber(closeX))
						setGeometryValue("Dialog_" .. dialogId .. ".y", "integer", tonumber(closeY))
						setGeometryValue("Dialog_" .. dialogId .. ".rastersize", "text", dlg.rastersize)
					end
				end
			end
			-- Call the original close callback if it exists
			if originalClose then
				return originalClose(dlg)
			end
			return iup.CLOSE
		end

		-- Measure natural size twice. First with the dialog's own SIZE attribute (if any) still in
		-- place: IUP's reported natural size is max(content natural, SIZE), so a dialog built with
		-- e.g. size = "HALFxHALF" reports a "natural" size of half the SCREEN on a large display,
		-- unnoticeable on a small one - a beta report against Add Trees' main dialog. Then again
		-- with SIZE cleared, for the content-only natural size that the floor/minsize below must
		-- use (SIZE is an opening-size hint, not a floor). When the dialog has no SIZE attribute
		-- the two measurements are identical and behaviour is exactly as before.
		dialog.minsize = iup.NULL
		iup.Refresh(dialog)
		local naturalWithUser = dialog.naturalsize -- includes any SIZE= user size (e.g. HALFxHALF)
		dialog.size = iup.NULL -- clear the user size: an opening-size hint, not a floor
		iup.Refresh(dialog)
		local true_natural = dialog.naturalsize -- content-only natural

		-- Work out size/position via the pure placement function (Stage 4, 5 Jul 2026 moved the
		-- floor/cap logic here from the consumer plugins; the monitor-bounds validation it relied
		-- on only ever checked a monitor's origin, so any large positive coordinate passed and the
		-- dialog could open off-screen - computePlacement checks the full monitor rect instead).
		local openW, openH = tostring(naturalWithUser or ""):match("^(%d+)x(%d+)$")
		local natW, natH = tostring(true_natural or ""):match("^(%d+)x(%d+)$")
		local scrW, scrH = tostring(iup.GetGlobal("SCREENSIZE") or ""):match("^(%d+)x(%d+)$")
		local monitors = parseMonitorsInfo(iup.GetGlobal("MONITORSINFO"))
		local savedW, savedH
		if savedRaster ~= "" then
			local w, h = savedRaster:match("^(%d+)x(%d+)$")
			savedW, savedH = tonumber(w), tonumber(h)
		end
		local placement = computePlacement(
			{ x = savedX, y = savedY, w = savedW, h = savedH },
			{ w = natW and tonumber(natW) or nil, h = natH and tonumber(natH) or nil },
			monitors,
			{ w = scrW and tonumber(scrW) or nil, h = scrH and tonumber(scrH) or nil },
			{ w = openW and tonumber(openW) or nil, h = openH and tonumber(openH) or nil }
		)

		if placement.w and placement.h then
			dialog.rastersize = placement.w .. "x" .. placement.h
		end
		-- Floor future user resizes at the (screen-capped) natural size.
		if placement.minW and placement.minH then
			dialog.minsize = placement.minW .. "x" .. placement.minH
		end

		-- Override the show callback to restore minimum size after dialog is shown
		local originalShow = dialog.show_cb
		dialog.show_cb = function(self, state)
			-- Call the original show callback if it exists
			if originalShow then
				originalShow(self, state)
			end
			return iup.DEFAULT
		end

		-- Record the first dialog tracked as the plugin's main window (showConfigDialog uses this
		-- to centre the Options dialog reliably - see the PARENTDIALOG comment there).
		if not config._trackedMainDialog then
			config._trackedMainDialog = dialog
		end

		-- Position the dialog based on saved coordinates or center it
		if placement.x and placement.y then
			dialog:showxy(placement.x, placement.y)
		else
			dialog:showxy(iup.CENTERPARENT, iup.CENTERPARENT)
		end
	end

	function Config:getValue(section, key, default, validator)
		local value = self.cache[section] and self.cache[section][key]
		if value == nil then
			local valueType = type(default)
			local fhType = valueType == "string" and "text"
				or valueType == "number" and "integer"
				or valueType == "boolean" and "bool"
				or valueType

			-- Use pcall to handle potential errors when reading from file
			local success, result = pcall(function()
				return fhGetIniFileValue(self.filePath, section, key, fhType, default)
			end)

			if success then
				value = result
			else
				-- If reading fails, use the default value
				value = default
			end

			self.cache[section] = self.cache[section] or {}
			self.cache[section][key] = value
		end
		if validator and not validator(value) then
			return default
		end
		return value
	end

	function Config:setValues(section, prefix, valueTable)
		self.cache[section] = self.cache[section] or {}
		for key, value in pairs(valueTable) do
			local fullKey = prefix and (prefix .. "." .. key) or key
			local valueType = type(value)
			local fhType = valueType == "string" and "text"
				or valueType == "number" and "integer"
				or valueType == "boolean" and "bool"
				or valueType
			local oldValue = self.cache[section][fullKey]

			-- Use pcall to handle potential errors when writing to file
			local success = pcall(function()
				fhSetIniFileValue(self.filePath, section, fullKey, fhType, value)
			end)

			if success then
				self.cache[section][fullKey] = value
				if oldValue ~= value and self.callbacks[section] and self.callbacks[section][fullKey] then
					self.callbacks[section][fullKey](value, oldValue)
				end
			else
				-- If writing fails, still update the cache but log the error
				-- This ensures the application continues to work even if file writing fails
				self.cache[section][fullKey] = value
			end
		end
	end

	function Config:getValues(section, prefix, defaultTable)
		local results = {}
		for key, default in pairs(defaultTable) do
			local fullKey = prefix and (prefix .. "." .. key) or key
			local valueType = type(default)
			local fhType = valueType == "string" and "text"
				or valueType == "number" and "integer"
				or valueType == "boolean" and "bool"
				or valueType

			-- Use pcall to handle potential errors when reading from file
			local success, result = pcall(function()
				return fhGetIniFileValue(self.filePath, section, fullKey, fhType, default)
			end)

			if success then
				results[key] = result
			else
				-- If reading fails, use the default value
				results[key] = default
			end
		end
		return results
	end

--- Create a control for a config field. When sectionTitle is passed (createControl(sectionTitle, field, value)),
--- it is available for controls that need section context. Values are saved only when the user chooses Save.
---@param a string|table Section title (3-arg) or field definition (2-arg)
---@param b table|any Field definition (3-arg) or value (2-arg)
---@param c any|nil Current value (3-arg only)
---@return iup.element control The control whose value holds the field value.
---@return iup.element? display Optional composite container to place instead of the control (pickers).
---@return fun()? onValueSet Optional hook to re-sync the control's display after a programmatic value change (colour swatches).
function Config:createControl(a, b, c)
	local sectionTitle, field, value
	if c ~= nil then
		sectionTitle, field, value = a, b, c
	else
		field, value = a, b
		sectionTitle = nil
	end
		if field.type == "text" or field.type == "string" then
			local opts = {
				value = value ~= nil and tostring(value) or "",
				-- Restore-on-blank target for Dialog.makeText: the field's default. Lets an optional text
				-- field be cleared (default "" -> stays blank) instead of snapping back to the old value.
				default = field.default,
			}
			if field.mask == "TOKEN" then
				-- Tokens are matched against template text case-sensitively, so
				-- keep them upper-case. FILTER converts typed input live (the
				-- native Windows edit style), unlike the old "[A-Z0-9.]*" MASK
				-- which rejected lower-case keys and kept only the first letter.
				opts.value = (opts.value or ""):upper()
				opts.filter = "UPPERCASE"
			elseif field.mask then
				opts.mask = field.mask
			end
			-- Optional per-field validation when focus leaves the box (e.g. checking a privacy
			-- flag exists). Receives the control's current value.
			if field.onBlur then
				opts.killfocus = function(self)
					field.onBlur(self.value)
				end
			end
			return makeText(opts)
		elseif field.type == "number" then
			return makeText({
				value = tostring(value),
				mask = iup.MASK_FLOAT, -- FH's iuplua registers iup.MASK_FLOAT; IUP_MASK_FLOAT is nil
			})
		elseif field.type == "boolean" then
			return makeToggle({
				title = "",
				value = value and "ON" or "OFF",
			})
		elseif field.type == "list" then
			local selectedIndex = 1
			for i, opt in ipairs(field.options) do
				if opt == value then
					selectedIndex = i
					break
				end
			end
			-- sort=NO is essential: the value is mapped to its POSITION in
			-- field.options, so the list must keep insertion order (the IUPLIST
			-- theme default sorts alphabetically, which desynchronises the
			-- index from the option). Set the value after the items are
			-- populated so the selection actually takes.
			local list = makeList({
				dropdown = "YES",
				sort = "NO",
				values = field.options or {},
			})
			list.value = tostring(selectedIndex)
			return list
		elseif field.type == "font" then
			local btn = iup.button({ title = tostring(value or "Choose..."), expand = "HORIZONTAL" })
			btn.__value = tostring(value or "")
			if btn.__value ~= "" then
				btn.font = btn.__value
			end
			function btn:action()
				local start = self.__value or ""
				local dlg = iup.fontdlg({ value = start })
				pcall(function()
					dlg.parentdialog = iup.GetDialog(self)
				end)
				dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
				if dlg.status == "1" and dlg.value and dlg.value ~= "" then
					self.__value = dlg.value
					self.title = dlg.value
					self.font = dlg.value
				end
				return iup.DEFAULT
			end
			return btn
		elseif field.type == "color" or field.type == "colorRGBA" then
			local startHex = tostring(value or (field.type == "colorRGBA" and "#000000FF" or "#000000"))

			if field.type == "colorRGBA" then
				-- Create a flat button for RGBA with integrated color display
				local btn = iup.flatbutton({ title = startHex, expand = "HORIZONTAL" })
				btn.__value = startHex
				btn.border = "YES"
				btn.borderwidth = "1"
				btn.bordercolor = "128 128 128"

				local function applyStyle()
					local hex = btn.__value
					local base = hex:sub(1, 7) -- Get #RRGGBB part

					-- Parse alpha if present
					local alpha = 255
					if #hex == 9 then -- #RRGGBBAA
						alpha = tonumber(hex:sub(8, 9), 16) or 255
					end

					-- Blend color with dialog background (assume white background)
					local r = tonumber(hex:sub(2, 3), 16) or 0
					local g = tonumber(hex:sub(4, 5), 16) or 0
					local b = tonumber(hex:sub(6, 7), 16) or 0

					-- Alpha blend with white background (255, 255, 255)
					local alphaRatio = alpha / 255
					local blendedR = math.floor(r * alphaRatio + 255 * (1 - alphaRatio))
					local blendedG = math.floor(g * alphaRatio + 255 * (1 - alphaRatio))
					local blendedB = math.floor(b * alphaRatio + 255 * (1 - alphaRatio))

					local blendedHex = string.format("#%02X%02X%02X", blendedR, blendedG, blendedB)
					btn.bgcolor = blendedHex -- Use alpha-blended background color
					btn.fgcolor = base -- Keep text color as the original color
				end

				applyStyle()

				function btn:map_cb()
					applyStyle()
					return iup.DEFAULT
				end

				function btn:flat_action()
					local current = self.__value or "#000000FF"
					local baseHex = current:sub(1, 7)
					local alpha = 255
					if #current == 9 then -- #RRGGBBAA
						alpha = tonumber(current:sub(8, 9), 16) or 255
					end
					local dlg = iup.colordlg({})
					dlg.valuehex = baseHex
					dlg.showhex = "YES"
					dlg.showalpha = "YES"
					dlg.alpha = tostring(alpha)
					dlg.parentdialog = iup.GetDialog(self)
					dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
					if dlg.status == "1" then
						local hex = tostring(dlg.valuehex or baseHex)
						local aa = tonumber(dlg.alpha) or alpha
						aa = math.max(0, math.min(255, aa))
						hex = string.format("%s%02X", hex, aa)
						self.__value = hex
						self.title = hex
						applyStyle()
					end
					return iup.DEFAULT
				end

				return btn, nil, applyStyle
			else
				-- Regular color button for non-RGBA
				local btn = iup.button({ title = startHex, expand = "HORIZONTAL" })
				btn.__value = startHex

				local function applyStyle()
					local base = btn.__value:sub(1, 7) -- Get #RRGGBB part
					btn.fgcolor = base
				end

				applyStyle()

				function btn:map_cb()
					applyStyle()
					return iup.DEFAULT
				end

				function btn:action()
					local current = self.__value or "#000000"
					local dlg = iup.colordlg({})
					dlg.valuehex = current
					dlg.showhex = "YES"
					dlg.parentdialog = iup.GetDialog(self)
					dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
					if dlg.status == "1" then
						local hex = tostring(dlg.valuehex or current)
						self.__value = hex
						self.title = hex
						applyStyle()
					end
					return iup.DEFAULT
				end

				return btn, nil, applyStyle
			end
		elseif field.type == "choice" then
			-- Simple dropdown using provided field.choices list
			local combo = iup.list({
				dropdown = "YES",
				expand = "HORIZONTAL",
			})

			-- Populate options
			local choices = field.choices or {}
			local selectedIndex = 1
			for i, opt in ipairs(choices) do
				combo[tostring(i)] = tostring(opt)
				if value == opt then
					selectedIndex = i
				end
			end

			-- Fallbacks for values not in choices
			if value ~= nil and value ~= "" then
				if #choices == 0 then
					-- No predefined choices: treat current value as the sole option
					combo["1"] = tostring(value)
					selectedIndex = 1
				else
					-- choices present: insert custom value at index 0 if not found
					local found = false
					for _, opt in ipairs(choices) do
						if opt == value then
							found = true
							break
						end
					end
					if not found then
						-- Insert at position 0 visually; leave underlying field.choices untouched
						combo["0"] = tostring(value)
						selectedIndex = 0
					end
				end
			end

			combo.value = tostring(selectedIndex)
			return combo
		elseif field.type == "colour" then
			-- A hex colour whose own field background shows the colour (an iup.label's BGCOLOR
			-- does not paint on Windows, but a text field's edit-area background does), plus a
			-- native picker. The value lives on the text control's .value (a hex string), so it
			-- rides the default value paths (savedValueFor/applyValueToControl/controlValue). The
			-- returned refresh hook (onValueSet) repaints the swatch after a programmatic change
			-- (a scheme/theme load, or Reset), which does not fire valuechanged_cb.
			local text = makeText({
				value = tostring(value or "#ffffff"),
				mask = "#[0-9a-fA-F]+",
			})
			local function refreshSwatch()
				local r, g, b = tostring(text.value or ""):match("^#(%x%x)(%x%x)(%x%x)$")
				if r then
					local rn, gn, bn = tonumber(r, 16), tonumber(g, 16), tonumber(b, 16)
					text.bgcolor = string.format("%d %d %d", rn, gn, bn)
					-- Contrasting text so the hex stays readable on its colour.
					local luminance = 0.299 * rn + 0.587 * gn + 0.114 * bn
					text.fgcolor = (luminance > 140) and "0 0 0" or "255 255 255"
				end
			end
			refreshSwatch()
			text.valuechanged_cb = function()
				refreshSwatch()
				return iup.DEFAULT
			end
			local pick = makeAssistantButton({
				tip = "Choose a colour",
				callback = function()
					local dlg = iup.colordlg({ title = field.label:gsub(":%s*$", "") })
					local r, g, b = tostring(text.value or ""):match("^#(%x%x)(%x%x)(%x%x)$")
					if r then
						dlg.value = string.format("%d %d %d", tonumber(r, 16), tonumber(g, 16), tonumber(b, 16))
					end
					dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
					if dlg.status == "1" then
						local rr, gg, bb = tostring(dlg.value):match("^(%d+)%s+(%d+)%s+(%d+)")
						if rr then
							text.value = string.format("#%02x%02x%02x", tonumber(rr), tonumber(gg), tonumber(bb))
							refreshSwatch()
						end
					end
					dlg:destroy()
				end,
			})
			return text, iup.hbox({ text, pick, alignment = "ACENTER" }), refreshSwatch
		elseif field.type == "folder" or field.type == "file" then
			-- A read-only path display plus a native browse dialog. The user
			-- may choose ANY folder/file; field.root (a string, or a function
			-- returning one) is only a convenient starting directory, not a
			-- restriction. The value lives on the text control's own .value
			-- (no smuggling on a container).
			local fhfu = require("fhFileUtils")
			local function pickerRoot()
				local root = field.root
				if type(root) == "function" then
					root = root()
				end
				if type(root) ~= "string" or root == "" then
					root = field.default
				end
				if type(root) ~= "string" or root == "" then
					root = fhGetContextInfo("CI_PROJECT_PUBLIC_FOLDER") or ""
				end
				return tostring(root or "")
			end
			local text = makeText({
				value = tostring(value or ""),
				readonly = "YES",
				expand = "HORIZONTAL",
			})
			local browse = makeAssistantButton({
				tip = (field.type == "folder") and "Browse for a folder" or "Browse for a file",
				callback = function()
					local start = text.value
					if start == "" or not (fhfu.folderExists(start) or fhfu.fileExists(start)) then
						start = pickerRoot()
					end
					local dlg = iup.filedlg({
						dialogtype = (field.type == "folder") and "DIR" or "OPEN",
						title = (field.label or ""):gsub(":%s*$", ""),
						directory = (start ~= "") and start or nil,
						parentdialog = identifyActiveWindow(),
					})
					dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
					-- STATUS is "-1" on cancel; "0"/"1" select an existing/new path.
					if tostring(dlg.status) ~= "-1" and dlg.value and dlg.value ~= "" then
						text.value = (dlg.value):gsub("[/\\]+$", "")
					end
					dlg:destroy()
				end,
			})
			return text, iup.hbox({ text, browse, alignment = "ACENTER" })
		else
			error("Unsupported field type: " .. field.type)
		end
	end

	---@class ConfigDialogApi
	---@field setValue fun(sectionTitle: string, key: string, value: any) Push a value into a field's control.
	---@field setListOptions fun(sectionTitle: string, key: string, newOptions: string[], selected?: string) Replace a list field's options in place.

	---@class ConfigExtraAction
	---@field title string Button label.
	---@field tip? string Tooltip.
	---@field section? string If set, render the button inside that section's tab only, instead of on the bottom row shown beneath every tab.
	---@field action fun(values: table<string, table<string, any>>, api: ConfigDialogApi) Receives the current (unsaved) values keyed by section then field key, plus the live-dialog API.

	---Show the options dialog.
	---@param options? table Configuration structure; defaults to the one passed to new().
	---@param help_topic? string Help topic for F1/Help menu.
	---@param extraActions? ConfigExtraAction[] Buttons that read the live, unsaved control values (e.g. Preview).
	function Config:showConfigDialog(options, help_topic, extraActions)
		options = options or self.defaultConfig
		help_topic = help_topic or "options"

		-- Pop the Options dialog up centred on the plugin's main window, clamped fully onto
		-- whichever monitor that window is on - plain iup.CENTERPARENT does not clamp to the
		-- screen, so with the main window near the top of the display and Options taller than it,
		-- the title bar could open above the screen until the next resize re-clamped it (beta
		-- report). Falls back to CENTERPARENT if the parent's geometry can't be read (e.g. no
		-- tracked main dialog yet, or a screenposition/rastersize parse failure). Used on every
		-- open - both the reuse fast path below and the first-build popup at the end - since the
		-- main window can have moved between opens.
		local function popupPlaced(dlg)
			local parent
			local main = self._trackedMainDialog
			if main then
				local pos, raster = main.screenposition, main.rastersize
				if pos and raster then
					local px, py = pos:match("^(%-?%d+),(%-?%d+)$")
					local pw, ph = raster:match("^(%d+)x(%d+)$")
					if px and py and pw and ph then
						parent = { x = tonumber(px), y = tonumber(py), w = tonumber(pw), h = tonumber(ph) }
					end
				end
			end

			local dw, dh
			local dr = dlg.rastersize
			if dr then
				local w, h = dr:match("^(%d+)x(%d+)$")
				dw, dh = tonumber(w), tonumber(h)
			end

			local monitors = parseMonitorsInfo(iup.GetGlobal("MONITORSINFO"))
			local scrW, scrH = tostring(iup.GetGlobal("SCREENSIZE") or ""):match("^(%d+)x(%d+)$")
			local r = placeOverParent(
				parent,
				{ w = dw, h = dh },
				monitors,
				{ w = scrW and tonumber(scrW) or nil, h = scrH and tonumber(scrH) or nil }
			)
			if r.x and r.y then
				dlg:popup(r.x, r.y)
			else
				dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
			end
		end

		-- Build the options dialog ONCE, then reuse it. Rebuilding it on every
		-- open - a fresh dialog, a fresh menu, and the controls re-added to the
		-- global normalisers - corrupted native state and silently killed FH
		-- on the second open (release builds only; not seen in the plugin
		-- debugger). On reopen we just reload the saved values into the
		-- existing controls and show the same dialog again.
		if self._optionsDialog then
			if self._optionsReload then
				self._optionsReload()
			end
			popupPlaced(self._optionsDialog)
			return
		end

		local controls = {}
		local mainContent
		local allSections = {}
		local sectionMenuItems = {}
		-- Auto-assign an Alt-key access key to each section-selector menu item. The section items sit in the
		-- menu bar beside File and Help, so their mnemonics must not collide with those (F, H), with each
		-- other, or with the dialog's action buttons (which the menu bar would otherwise shadow). We insert
		-- '&' before the first ASCII letter of the title that is still free; if a title already has an '&'
		-- mnemonic we respect it, and if nothing is free we leave the title plain (no mnemonic).
		local usedMnemonics = { F = true, H = true } -- File / Help are always present
		for _, extra in ipairs(extraActions or {}) do -- reserve each action button's access key
			local m = type(extra.title) == "string" and extra.title:match("&([A-Za-z])")
			if m then usedMnemonics[m:upper()] = true end
		end
		local function sectionTitleWithMnemonic(title)
			local existing = title:match("&([A-Za-z])")
			if existing then
				usedMnemonics[existing:upper()] = true
				return title
			end
			for i = 1, #title do
				local ch = title:sub(i, i)
				if ch:match("[A-Za-z]") and not usedMnemonics[ch:upper()] then
					usedMnemonics[ch:upper()] = true
					return title:sub(1, i - 1) .. "&" .. title:sub(i)
				end
			end
			return title -- no free letter; leave it without a mnemonic
		end
		-- Forward declaration: section-targeted action buttons (built in the
		-- section loop) call this, but it is defined after the loop.
		local collectValues

		-- Set one control to one value, per field type. Shared by Reset (with
		-- the field default) and by reload-on-reopen (with the saved value).
		local function applyValueToControl(field, control, value)
			if field.type == "boolean" then
				control.value = value and "ON" or "OFF"
			elseif field.type == "list" then
				for i, opt in ipairs(field.options or {}) do
					if opt == value then
						control.value = tostring(i)
						return
					end
				end
			elseif field.type == "font" then
				control.__value = tostring(value or "")
				control.title = (control.__value ~= "") and control.__value or "Choose..."
				if control.__value ~= "" then
					pcall(function() control.font = control.__value end)
				end
			elseif field.type == "color" or field.type == "colorRGBA" then
				local fallback = field.type == "colorRGBA" and "#000000FF" or "#000000"
				control.__value = tostring(value or fallback)
				control.title = control.__value
				-- The swatch is repainted by the field's onValueSet (applyStyle),
				-- fired by the caller after this.
			elseif field.type == "choice" then
				for i, opt in ipairs(field.choices or {}) do
					if opt == value then
						control.value = tostring(i)
						return
					end
				end
			else
				control.value = tostring(value)
			end
		end

		-- The saved value of a field, read the same way createControl reads it.
		local function savedValueFor(field, sectionTitle, key)
			if
				field.type == "font"
				or field.type == "color"
				or field.type == "colorRGBA"
				or field.type == "colour"
				or field.type == "folder"
				or field.type == "file"
			then
				return self:getString(sectionTitle, key, field.default or "")
			end
			return self:getValue(sectionTitle, key, field.default)
		end

		-- Pull a control's current (unsaved) value, coerced to the field type.
		local function controlValue(field, control)
			if field.type == "number" then
				local v = tonumber(control.value)
				if v == nil then
					v = field.default
				end
				return v
			elseif field.type == "boolean" then
				return control.value == "ON"
			elseif field.type == "list" then
				if control.value == "0" then
					return field.default
				end
				return field.options[tonumber(control.value)]
			elseif field.type == "choice" then
				local idx = tonumber(control.value)
				if idx == 0 then
					-- Custom value not in the choices list, held at combo["0"].
					return (control["0"] and control["0"] ~= "") and control["0"] or field.default
				elseif field.choices and idx and field.choices[idx] then
					return field.choices[idx]
				end
				return field.default
			elseif field.type == "font" or field.type == "color" or field.type == "colorRGBA" then
				return tostring(control.__value or "")
			else
				-- text / string / folder / file
				local v = control.value
				if field.mask == "TOKEN" then
					v = (v or ""):upper()
				end
				return v
			end
		end

		-- The sections live in a zbox, which sizes itself to the LARGEST child
		-- and shows one at a time. That gives a stable dialog size that fits
		-- every tab - no resizing as you switch, and no controls clipped (the
		-- previous visible/floating + Refresh approach resized the dialog to
		-- each tab in turn, which shrank it below some tabs' width). The visible
		-- child is selected by its 0-based position (VALUEPOS), a plain
		-- attribute. (Selecting by handle would also work - iuplua implements
		-- IupSetAttributeHandle via attribute assignment, e.g. zbox.value = child,
		-- spike S6 - but the position is what we track here anyway.)
		local sectionZbox
		local function showSection(sectionToShow)
			if not sectionZbox then
				return
			end
			for i, section in ipairs(allSections) do
				if section == sectionToShow then
					sectionZbox.valuepos = tostring(i - 1)
					return
				end
			end
		end

		-- A small API handed to field liveChange callbacks and extra-action
		-- buttons, for updating other live controls in the open dialog.
		---@type ConfigDialogApi
		local dialogApi = {}
		function dialogApi.setValue(sectionTitle, key, value)
			local info = controls[sectionTitle] and controls[sectionTitle][key]
			if info then
				applyValueToControl(info.field, info.control, value)
				if info.onValueSet then
					info.onValueSet()
				end
			end
		end
		function dialogApi.setListOptions(sectionTitle, key, newOptions, selected)
			local info = controls[sectionTitle] and controls[sectionTitle][key]
			if info and info.field.type == "list" then
				info.field.options = newOptions
				populateList(info.control, newOptions)
				if selected ~= nil then
					applyValueToControl(info.field, info.control, selected)
				end
			end
		end

		-- Field labels live in their OWN normaliser so they line up with each
		-- other inside the Options dialog WITHOUT joining the global btnnorm -
		-- a long option label there would otherwise stretch every label and
		-- button in every other dialog the plugin opens (the makeLabel /
		-- DoNormalize trap). Callers can pass options.labelNormalizer to share
		-- this normaliser with other parts of their UI; default is a fresh,
		-- dialog-local one. We fire it ourselves below, after DoNormalize.
		local labelNorm = options.labelNormalizer or iup.normalizer({})

		sectionZbox = iup.zbox({})
		mainContent = sectionZbox
		for _, section in ipairs(options.sections or {}) do
			local sectionContent = iup.vbox({})
			controls[section.title] = {}
			-- Section action buttons (extra.section) - shown FIRST in the tab and left-aligned (under
			-- the label column), so a prominent action like a picker leads the section rather than
			-- trailing the fields.
			for _, extra in ipairs(extraActions or {}) do
				if extra.section == section.title then
					sectionContent:append(iup.hbox({
						makeButton({
							title = extra.title,
							tip = extra.tip,
							callback = function()
								extra.action(collectValues(), dialogApi)
							end,
						}),
						iup.fill({}),
						-- Indent to line the button up under the field labels, which carry the theme's
						-- 20px horizontal label padding (so the button doesn't sit proud to their left).
						margin = "20x0",
					}))
				end
			end
			for _, field in ipairs(section.fields) do
				local value
				if field.type == "font" then
					value = self:getString(section.title, field.key, field.default or "")
				elseif field.type == "color" then
					value = self:getString(section.title, field.key, field.default or "#000000")
				elseif field.type == "colorRGBA" then
					value = self:getString(section.title, field.key, field.default or "#000000FF")
				elseif field.type == "colour" then
					value = self:getString(section.title, field.key, field.default or "#ffffff")
				elseif field.type == "folder" or field.type == "file" then
					-- Read from file so the display matches the saved value (cache
					-- may predate the file write); keep cache in step.
					value = self:getString(section.title, field.key, field.default or "")
					self.cache[section.title] = self.cache[section.title] or {}
					self.cache[section.title][field.key] = value
				else
					value = self:getValue(section.title, field.key, field.default)
				end
				local label = makeLabel({ title = field.label, normalizergroup = labelNorm })
				local control, display, onValueSet = self:createControl(section.title, field, value)
				-- The tooltip lives on the CONTROL, not the label - consistent with the plugins' main
				-- UIs, which tip the field you interact with (so the tip never jumps side-to-side as you
				-- move down the dialog). field.tip lets a plugin override the wording (e.g. a placeholder
				-- hint); otherwise the field's description. Routed through setTipWithHelp so long tips wrap.
				-- `control` is always the value-bearing widget (for folder/file that's the text box, not
				-- the hbox), so this lands on the right place for every field type.
				local fieldTip = field.tip or field.description
				if control and fieldTip and fieldTip ~= "" then
					setTipWithHelp(control, fieldTip)
				end
				controls[section.title][field.key] = {
					control = control,
					field = field,
					onValueSet = onValueSet,
				}
				-- Wire list-field live-change callbacks (e.g. a colour-scheme
				-- list loading its palette into the colour fields). DIALOG-only;
				-- must NOT be named onChange (a save-time (value, oldValue)
				-- callback) - firing that here would pass a string where the
				-- dialog API is expected.
				if field.liveChange and field.type == "list" then
					control.action = function(_, text, _, state)
						if state == 1 then
							field.liveChange(text, dialogApi)
						end
						return iup.DEFAULT
					end
				end
				-- Validate-on-leave: if a field defines validate(value) -> ok[, message], check it when
				-- the control loses focus and warn at once. The Save action re-checks as a backstop. The
				-- guard stops the warning's own focus change from re-entering the callback.
				if field.validate then
					local validating = false
					control.killfocus_cb = function(ctrl)
						if validating then return end
						local ok, message = field.validate(controlValue(field, ctrl))
						if not ok then
							validating = true
							fhMessageBox((field.label or field.key):gsub("%s*:%s*$", "") .. " - "
								.. (message or "invalid value"), "MB_OK", "MB_ICONWARNING")
							validating = false
						end
					end
				end
				sectionContent:append(iup.hbox({
					label,
					display or control,
					alignment = "ACENTER",
				}))
			end
			local sectionBox = iup.scrollbox({ sectionContent, expand = "YES" })
			table.insert(allSections, sectionBox)
			iup.Append(sectionZbox, sectionBox)
			table.insert(sectionMenuItems, {
				title = sectionTitleWithMnemonic(section.title),
				action = function()
					showSection(sectionBox)
					return iup.DEFAULT
				end,
			})
		end

		-- Read every control's current (unsaved) value, coerced to its type.
		-- (Assigns the forward-declared local so section-targeted buttons can
		-- reference it.)
		---@return table<string, table<string, any>>
		collectValues = function()
			local all = {}
			for section, fields in pairs(controls) do
				local values = {}
				for key, info in pairs(fields) do
					values[key] = controlValue(info.field, info.control)
				end
				all[section] = values
			end
			return all
		end

		-- Save all control values back to config.
		local function saveAll()
			for section, values in pairs(collectValues()) do
				self:setValues(section, nil, values)
			end
		end

		local function resetAllToDefaultsAndRefresh()
			local response = iup.Alarm(
				"Confirm Reset All",
				"Are you sure you want to reset ALL settings in ALL sections to their defaults?",
				"Yes",
				"No"
			)
			if response == 1 then
				self:resetToDefaults()
				for _, sectionControls in pairs(controls) do
					for _, info in pairs(sectionControls) do
						applyValueToControl(info.field, info.control, info.field.default)
						if info.onValueSet then
							info.onValueSet()
						end
					end
				end
			end
			return iup.DEFAULT
		end

		-- Reload saved values into the existing controls; used when the dialog
		-- is reopened (it is built only once). Reading from saved config means
		-- closing without Save discards unsaved edits.
		local function reload()
			for sectionTitle, sectionControls in pairs(controls) do
				for key, info in pairs(sectionControls) do
					applyValueToControl(info.field, info.control, savedValueFor(info.field, sectionTitle, key))
					if info.onValueSet then
						info.onValueSet()
					end
				end
			end
			if #allSections > 0 then
				showSection(allSections[1])
			end
		end

		-- Extra actions are either pinned to a section (extra.section -> a
		-- button inside that tab, built above) or shown along the bottom row.
		local buttonActions = {}
		for _, extra in ipairs(extraActions or {}) do
			if not extra.section then
				buttonActions[#buttonActions + 1] = extra
			end
		end

		-- Hoisted so the Save/Cancel/Esc closures below (built as part of menuBarData, before
		-- makeDialog returns) can read the dialog's live size once it exists - they share this
		-- upvalue, which is assigned further down.
		local dialog

		-- Persist the Options dialog's current size to the per-machine geometry store, so it's
		-- restored next open. Skipped while maximized/minimized (rastersize is unreliable in
		-- either state - the tracked-dialog close_cb applies the same guard). There is deliberately
		-- no close_cb on this dialog (see the comment on makeDialog below), so the title-bar X
		-- can't reach this - only Save, Cancel and Esc do.
		local function saveOptionsSize()
			if dialog and dialog.maximized ~= "YES" and dialog.minimized ~= "YES" then
				setGeometryValue("Dialog_Options.rastersize", "text", dialog.rastersize)
			end
		end

		---@type MenuBarData
		local menuBarData = MenuBar.createMenuBar({
			items = sectionMenuItems,
			-- Key names sort into menu order (Save, Reset All, Cancel); the
			-- "cancel" key also tells MenuBar not to add an automatic Exit.
			fileMenu = {
				aSave = {
					title = "&Save",
					action = function()
						-- Per-field validation: a field may define validate(value) -> ok[, message].
						-- Any failure blocks the Save and keeps the dialog open so the user can fix it.
						local problems = {}
						for _, sectionControls in pairs(controls) do
							for key, info in pairs(sectionControls) do
								if info.field.validate then
									local ok, message = info.field.validate(controlValue(info.field, info.control))
									if not ok then
										local labelText = (info.field.label or key):gsub("%s*:%s*$", "")
										problems[#problems + 1] = labelText .. " - " .. (message or "invalid value")
									end
								end
							end
						end
						if #problems > 0 then
							fhMessageBox("Please fix the following before saving:\n\n- "
								.. table.concat(problems, "\n- "), "MB_OK", "MB_ICONWARNING")
							return iup.DEFAULT
						end
						saveAll()
						saveOptionsSize()
						return iup.CLOSE
					end,
				},
				bResetAll = {
					title = "&Reset All",
					action = function()
						return resetAllToDefaultsAndRefresh()
					end,
				},
				cancel = {
					title = "&Cancel",
					action = function()
						saveOptionsSize()
						return iup.CLOSE
					end,
				},
			},
			helpMenu = {
				help = {
					title = "&Help",
					action = function()
						help:show(help_topic)
						return iup.DEFAULT
					end,
				},
			},
		})

		-- Optional action buttons (e.g. Preview) working on the live values,
		-- shown on a shared bottom row beneath every tab.
		local dialogContent = { mainContent }
		if #buttonActions > 0 then
			local buttons = { iup.fill({}) }
			for _, extra in ipairs(buttonActions) do
				buttons[#buttons + 1] = makeButton({
					title = extra.title,
					tip = extra.tip,
					callback = function()
						extra.action(collectValues(), dialogApi)
					end,
				})
			end
			dialogContent[#dialogContent + 1] = iup.hbox(buttons)
		end
		dialogContent.margin = "10x10"

		dialog = makeDialog(
			iup.vbox(dialogContent),
			-- Deliberately NO close_cb: for a dialog shown with popup, the
			-- title-bar X must take IUP's default close action. Calling
			-- hide() in a close_cb hung the teardown, and returning
			-- iup.CLOSE from one exits an EXTRA message loop on top of the
			-- close itself, ending the plugin's main loop too. Buttons and
			-- menu items are different: there, returning iup.CLOSE is the
			-- only thing that ends the popup.
			{
				title = options.title or self.defaultConfig.title,
				resize = "YES",
				menubox = "YES",
				menu = menuBarData.menuBar,
				help_topic = help_topic,
				-- Esc mirrors Cancel: close and discard unsaved edits. Returning iup.CLOSE from this key
				-- handler is safe (it acts like the Cancel menu item, not a close_cb - see the note above).
				-- No Enter default: Save is Alt+S / the File menu, and Enter belongs to the focused field.
				on_escape = function()
					saveOptionsSize()
					return iup.CLOSE
				end,
			}
		)
		self._optionsReload = reload
		self._optionsDialog = dialog

		-- makeDialog has already normalised this dialog independently (its own button/text normalisers,
		-- not the global DoNormalize that used to re-size every other open dialog). Field labels keep the
		-- Options-local labelNorm, fired here.
		labelNorm.normalize = "HORIZONTAL"
		if #allSections > 0 then
			showSection(allSections[1])
		end

		-- First-build sizing: cap the initial size at the screen (minus the taskbar/title-bar
		-- allowance) and restore any saved size. No natural-size floor here, unlike
		-- showTrackedDialog's computePlacement: on a small screen the natural size can exceed the
		-- cap, and a user's deliberately-reduced height must survive across sessions - each
		-- section already scrolls (iup.scrollbox, above), so a capped/reduced size never clips a
		-- field, it just hides it behind a scrollbar. capSize's 300x200 sanity floor guards against
		-- a corrupt or zeroed saved value producing an unusable dialog.
		iup.Refresh(dialog)
		local natW, natH = tostring(dialog.naturalsize or ""):match("^(%d+)x(%d+)$")
		local savedRaster = getGeometryValue("Dialog_Options.rastersize", "text", "")
		local savedW, savedH
		if savedRaster ~= "" then
			local w, h = savedRaster:match("^(%d+)x(%d+)$")
			savedW, savedH = tonumber(w), tonumber(h)
		end
		local scrW, scrH = tostring(iup.GetGlobal("SCREENSIZE") or ""):match("^(%d+)x(%d+)$")
		local size = capSize(
			{ w = savedW, h = savedH },
			{ w = natW and tonumber(natW) or nil, h = natH and tonumber(natH) or nil },
			{ w = scrW and tonumber(scrW) or nil, h = scrH and tonumber(scrH) or nil }
		)
		if size.w and size.h then
			dialog.rastersize = size.w .. "x" .. size.h
		end

		-- Deterministic parent: makeDialog otherwise picks a parent from whatever control has
		-- keyboard focus at build time (Dialog.lua's getParentDialogInfo) - built once, here, on
		-- first open, that can be an unrelated control rather than the plugin's main window, so
		-- CENTERPARENT centres on the wrong thing. Override it with the first dialog
		-- showTrackedDialog tracked, if there is one. (This binding's iuplua has no
		-- iup.SetAttributeHandle function - IupSetAttributeHandle is reached via plain attribute
		-- assignment instead, the same idiom Dialog.lua uses for DEFAULTENTER/DEFAULTESC.)
		if self._trackedMainDialog then
			dialog.PARENTDIALOG = self._trackedMainDialog
		end

		-- Never destroyed (FH frees plugin dialogs at script end); reused on
		-- the next open via the fast path at the top of this function. Positioning is explicit
		-- (popupPlaced, defined above) rather than plain CENTERPARENT - see its comment.
		popupPlaced(dialog)
	end

	function Config:resetToDefaults(section)
		if section then
			local sectionConfig = nil
			for _, sec in ipairs(self.defaultConfig.sections or {}) do
				if sec.title == section then
					sectionConfig = sec
					break
				end
			end
			if sectionConfig then
				local values = {}
				for _, field in ipairs(sectionConfig.fields) do
					values[field.key] = field.default
				end
				self:setValues(section, nil, values)
			end
		else
			for _, sec in ipairs(self.defaultConfig.sections or {}) do
				local values = {}
				for _, field in ipairs(sec.fields) do
					values[field.key] = field.default
				end
				self:setValues(sec.title, nil, values)
			end
		end
	end

	_G.Config = M
end

end)()
--<<FS_SPLICE_END>>

--------------------------------------------------------------
--EMBEDDED PURE LAYERS, ADAPTER AND SAMPLE DATA
--------------------------------------------------------------
-- The blocks below are generated from the files in src/ and fixtures/ by
-- build/assemble.lua. Do not edit them here; edit the source files and
-- re-run the assembler. Each module is wrapped so its `return M` becomes a
-- plugin global, and each module resolves its dependencies via
-- `_G.<Global> or require(...)`, which works both embedded and standalone.

--<<FS_SPLICE src/fs_options.lua FsOptions>>
FsOptions = (function()
--[[
@Title: fs_options
@Author: Helen Wright
@Description:
    Layer 5 (pure): option defaults and merging, colour-palette resolution,
    shape/colour encoding assignment, and the pure colours-to-palette
    derivation used by the "Match System Theme" preset.

    No FH API, no IUP, no file I/O. Runs identically under standalone
    Lua 5.3 and inside the FH7 plugin host.
]]

local M = {}

--------------------------------------------------------------
-- THE MODEL SCHEMA (FsModel)
--------------------------------------------------------------
-- Produced by the FH adapter inside Family Historian, or loaded from fixture
-- files in tests. Plain data; no behaviour.

---@class FsModel                    One focal individual's chart.
---@field focal FsPerson
---@field parentSets FsParentSet[]   Adapter-ordered (birth-type first).
---@field spouseUnits FsSpouseUnit[] Ordered by marriage/partnership date then as recorded.

---@class FsPerson
---@field id integer                 Raw FH record id; link target is "ind"..id..".html".
---@field name string                Raw UTF-8; the renderer XML-escapes.
---@field lifeDates string           LifeDates2 output; "-" when no dates exist.
---@field sex FsSexCode
---@field linkable boolean           False for the focal person and anyone outside the selection.
---@field private? boolean           Set by the adapter when omit-Private is on and the person carries the 'Private' flag; applyPrivacy removes them.
---@field basicOnly? boolean         Set by the adapter when the person carries the basic-details flag; the renderer shows name + relationship only (no dates).

---@class FsParentSet
---@field pedi string                PEDI value; "" means birth.
---@field father FsPerson|nil        Omitted (nil) if unrecorded.
---@field mother FsPerson|nil        Omitted (nil) if unrecorded.
---@field siblings FsSibling[]       That family's own children in FH recorded order; the focal person is excluded.
---@field focalIndex? integer        Displayed siblings before the focal person's recorded place (own sets only).
---@field otherFamily? boolean       True for a parent's other family (half-siblings live here, shown with both their parents).
---@field sharedParentId? integer    The parent the other family is reached through.
---@field sharedParentName? string   Display name of that parent (for the column caption).
---@field sharedParentPrivate? boolean True when the shared parent carries 'Private'; applyPrivacy drops the whole set (its caption would name them).

---@class FsSibling : FsPerson
---@field kind "full"|"half"|"step"

---@class FsSpouseUnit
---@field spouse FsPerson|nil        Omitted (nil) if the partner is unrecorded.
---@field children FsPerson[]        Birth order.

--------------------------------------------------------------
-- TYPES
--------------------------------------------------------------

---@alias FsSexCode "M"|"F"|"U"
---@alias FsRole "focal"|"parent"|"sibling"|"spouse"|"child"
---@alias FsShape "rect"|"rounded"|"ellipse"|"octagon"
---@alias FsEncoding "theme"|"sex"|"relationship"
---@alias FsLayoutChoice "compact"|"wide"
---@alias FsPresetKey "classic"|"heritage"|"slate"|"highcontrast"|"system"|"custom"
---@alias FsParentFamilies "all"|"birth"|"first"
---@alias FsSizeMode "scale"|"fit"|"exact"

---@class FsRenderOptions          Renderer-facing options (a subset of the plugin's persisted configuration).
---@field preset FsPresetKey       Colour scheme preset; "custom" uses customPalette.
---@field colourBy FsEncoding      What node colour encodes: the base theme, sex, or relationship category.
---@field shapeBy FsEncoding       What node shape encodes: uniform ("theme"), sex, or relationship category.
---@field baseShape FsShape        Node shape used when shapeBy is "theme" (uniform).
---@field nodeSize number          Minimum node width in user units.
---@field fontSize number          Label font size in user units.
---@field imageScale number        Finished-image scale percentage (25-400); 100 renders at natural size.
---@field fontFamily string        CSS font-family for chart text (themed to the target website, not the generating machine).
---@field pagePrefix string        Filename prefix for individual website pages; clickable links target "<pagePrefix>"..id..".html".
---@field layout FsLayoutChoice    "compact" (wrapped rows, narrow) or "wide" (one row per generation band).
---@field parentFamilies FsParentFamilies  Which parent families are charted: every family, birth families only, or only the first found.
---@field links boolean            Render clickable links on linkable nodes.
---@field lifeDates boolean        Append "(YYYY-YYYY)" life dates to node labels.
---@field embedded boolean         True when rendering for inline embedding (no XML declaration); false for a standalone file.
---@field fitWidth boolean          Legacy "fit to container width" toggle; superseded by sizeMode, still read for back-compat.
---@field sizeMode FsSizeMode       How the SVG width/height are set: "scale" (natural x imageScale), "fit" (width="100%", embedded only), or "exact" (exactWidth px, proportional height).
---@field exactWidth number         Width in px when sizeMode is "exact"; the height follows the chart's aspect ratio.
---@field systemColours? FsSystemColours  Raw system colours for the "system" preset; ignored otherwise.
---@field customPalette? table     Partial FsPalette for the "custom" preset; missing values degrade to Classic.

---@class FsSystemColours          Raw Windows colours as "R G B" strings (all optional).
---@field window? string           Window background.
---@field windowText? string       Window text.
---@field hilight? string          Selection highlight.
---@field hotTracking? string      Hyperlink / hot-tracking colour.

---@class FsPalette                A fully resolved colour palette. All values are "#rrggbb".
---@field background string        Chart background.
---@field text string              Node label text.
---@field labelText string         Group caption and sibling-kind tag text.
---@field stroke string            Node outline (box border) colour.
---@field line? string             Connector line colour; falls back to stroke when absent.
---@field link string              Link text colour (linkable node labels).
---@field focalFill string         Focal person's node fill.
---@field focalStroke string       Focal person's node outline (emphasised).
---@field defaultFill string       Fill when colour encodes nothing ("theme").
---@field sex table<FsSexCode, string>      Fills when colour encodes sex.
---@field relationship table<FsRole, string> Fills when colour encodes relationship category.

--------------------------------------------------------------
-- COLOUR ARITHMETIC
--------------------------------------------------------------

---Parse a colour given as "#rrggbb" or "R G B" decimal components.
---@param value string
---@return integer r, integer g, integer b
local function parseColour(value)
	local r, g, b = value:match("^#(%x%x)(%x%x)(%x%x)$")
	if r then
		return tonumber(r, 16), tonumber(g, 16), tonumber(b, 16)
	end
	r, g, b = value:match("^(%d+)%s+(%d+)%s+(%d+)$")
	if r then
		return tonumber(r), tonumber(g), tonumber(b)
	end
	error("Unrecognised colour value: " .. tostring(value))
end

---Format RGB components as "#rrggbb", clamping to 0-255.
---@param r number
---@param g number
---@param b number
---@return string
local function toHex(r, g, b)
	local function clamp(c)
		return math.max(0, math.min(255, math.floor(c + 0.5)))
	end
	return string.format("#%02x%02x%02x", clamp(r), clamp(g), clamp(b))
end

---Linearly mix two colours: t = 0 gives a, t = 1 gives b.
---@param a string Colour ("#rrggbb" or "R G B").
---@param b string Colour ("#rrggbb" or "R G B").
---@param t number Mix fraction in [0, 1].
---@return string mixed "#rrggbb"
function M.mix(a, b, t)
	local ar, ag, ab = parseColour(a)
	local br, bg, bb = parseColour(b)
	return toHex(ar + (br - ar) * t, ag + (bg - ag) * t, ab + (bb - ab) * t)
end

---Normalise any accepted colour notation to "#rrggbb".
---@param value string
---@return string
function M.normaliseColour(value)
	return toHex(parseColour(value))
end

--------------------------------------------------------------
-- OPTION DEFAULTS AND MERGING
--------------------------------------------------------------

---@type FsRenderOptions
local DEFAULTS = {
	preset = "classic",
	colourBy = "theme",
	shapeBy = "theme",
	baseShape = "rounded",
	nodeSize = 140,
	fontSize = 12,
	imageScale = 100,
	fontFamily = "Verdana, Arial, Helvetica, sans-serif",
	pagePrefix = "ind",
	layout = "wide",
	parentFamilies = "all",
	links = true,
	lifeDates = true,
	embedded = false,
	fitWidth = false,
	sizeMode = "scale",
	exactWidth = 800,
}

---@type table<string, boolean>
local VALID_PRESET = { classic = true, heritage = true, slate = true, highcontrast = true, system = true, custom = true }
---@type table<string, boolean>
local VALID_ENCODING = { theme = true, sex = true, relationship = true }
---@type table<string, boolean>
local VALID_LAYOUT = { compact = true, wide = true }
---@type table<string, boolean>
local VALID_SHAPE = { rect = true, rounded = true, ellipse = true, octagon = true }
---@type table<string, boolean>
local VALID_PARENT_FAMILIES = { all = true, birth = true, first = true }
---@type table<string, boolean>
local VALID_SIZE_MODE = { scale = true, fit = true, exact = true }

---A fresh copy of the default renderer options.
---@return FsRenderOptions
function M.defaults()
	local copy = {}
	for k, v in pairs(DEFAULTS) do
		copy[k] = v
	end
	return copy
end

---Merge user-supplied options over the defaults, validating each value.
---Options arrive from the configuration UI (a real boundary), so invalid
---values fall back to the default rather than erroring.
---@param options? table
---@return FsRenderOptions
function M.merge(options)
	options = options or {}
	local merged = M.defaults()
	if VALID_PRESET[options.preset] then
		merged.preset = options.preset
	end
	if VALID_ENCODING[options.colourBy] then
		merged.colourBy = options.colourBy
	end
	if VALID_ENCODING[options.shapeBy] then
		merged.shapeBy = options.shapeBy
	end
	if VALID_SHAPE[options.baseShape] then
		merged.baseShape = options.baseShape
	end
	if type(options.nodeSize) == "number" and options.nodeSize >= 40 and options.nodeSize <= 600 then
		merged.nodeSize = options.nodeSize
	end
	if type(options.fontSize) == "number" and options.fontSize >= 6 and options.fontSize <= 48 then
		merged.fontSize = options.fontSize
	end
	if type(options.imageScale) == "number" and options.imageScale >= 25 and options.imageScale <= 400 then
		merged.imageScale = options.imageScale
	end
	if type(options.fontFamily) == "string" then
		-- CSS-sanitise: a font-family list needs only letters, digits, spaces,
		-- commas, hyphens and quotes. Strip anything else so a stray or tampered
		-- value cannot inject into the chart's scoped inline <style>.
		local cleaned = options.fontFamily:gsub("[^A-Za-z0-9%s,'\"%-]", "")
		if cleaned:match("%S") then
			merged.fontFamily = cleaned
		end
	end
	if type(options.pagePrefix) == "string" then
		-- Goes into filenames and link hrefs, so keep it to safe characters.
		local cleaned = (options.pagePrefix:gsub("[^A-Za-z0-9_%-]", ""))
		if cleaned ~= "" then
			merged.pagePrefix = cleaned
		end
	end
	if VALID_LAYOUT[options.layout] then
		merged.layout = options.layout
	end
	if VALID_PARENT_FAMILIES[options.parentFamilies] then
		merged.parentFamilies = options.parentFamilies
	end
	if type(options.customPalette) == "table" then
		merged.customPalette = options.customPalette
	end
	if type(options.links) == "boolean" then
		merged.links = options.links
	end
	if type(options.lifeDates) == "boolean" then
		merged.lifeDates = options.lifeDates
	end
	if type(options.embedded) == "boolean" then
		merged.embedded = options.embedded
	end
	if type(options.fitWidth) == "boolean" then
		merged.fitWidth = options.fitWidth
	end
	if VALID_SIZE_MODE[options.sizeMode] then
		merged.sizeMode = options.sizeMode
	elseif options.sizeMode == nil and options.fitWidth == true then
		-- Back-compat: older configs carried only the "fit to width" toggle.
		merged.sizeMode = "fit"
	end
	if type(options.exactWidth) == "number" and options.exactWidth >= 100 and options.exactWidth <= 4000 then
		merged.exactWidth = options.exactWidth
	end
	if type(options.systemColours) == "table" then
		merged.systemColours = options.systemColours
	end
	return merged
end

--------------------------------------------------------------
-- PRESET PALETTES
--------------------------------------------------------------

---@type table<string, FsPalette>
local PRESETS = {
	-- Soft blues and greys on white.
	classic = {
		background = "#ffffff",
		text = "#1a1a1a",
		labelText = "#5a5a5a",
		stroke = "#4a6785",
		link = "#1d4ed8",
		focalFill = "#ffe9a8",
		focalStroke = "#8a6d1f",
		defaultFill = "#e7eef5",
		sex = { M = "#cfe3f5", F = "#fbe4ec", U = "#e8e8e8" },
		relationship = { parent = "#d9e7f5", sibling = "#e4f0e2", spouse = "#f5ecd9", child = "#ece2f5" },
	},
	-- Warm sepia tones for heritage-styled sites.
	heritage = {
		background = "#f7f1e3",
		text = "#3b2f2f",
		labelText = "#6e5c44",
		stroke = "#8b6f47",
		link = "#7c4a03",
		focalFill = "#d9b86a",
		focalStroke = "#7a5c1e",
		defaultFill = "#efe5cf",
		sex = { M = "#dfd0b8", F = "#f2e3cf", U = "#e9e0d0" },
		relationship = { parent = "#e3d3b8", sibling = "#eadfc8", spouse = "#dfc9a8", child = "#f0e6d2" },
	},
	-- Cool dark scheme with light text.
	slate = {
		background = "#2b3440",
		text = "#e8edf2",
		labelText = "#b8c4d0",
		stroke = "#94a3b8",
		link = "#93c5fd",
		focalFill = "#b08a2e",
		focalStroke = "#e3c264",
		defaultFill = "#46505a",
		sex = { M = "#34506b", F = "#63465e", U = "#46505a" },
		relationship = { parent = "#34506b", sibling = "#3c6155", spouse = "#6b5634", child = "#59466b" },
	},
	-- Okabe-Ito colour-blind-safe palette; pairs well with shape-by-sex.
	highcontrast = {
		background = "#ffffff",
		text = "#000000",
		labelText = "#000000",
		stroke = "#000000",
		link = "#000000",
		focalFill = "#ffffff",
		focalStroke = "#000000",
		defaultFill = "#ffffff",
		sex = { M = "#56b4e9", F = "#e69f00", U = "#999999" },
		-- Siblings: #00aa7b, a lighter tint of the same bluish-green hue as the original #009e73 (both
		-- fully saturated, hue ~164°). #009e73 only reached 6.14:1 contrast with black text (WCAG AA);
		-- #00aa7b is the darkest tint on that hue/saturation ray that reaches >= 7.0:1 (WCAG AAA) - see
		-- highContrastSiblingsColourMigrated below for the one-off migration of any saved scheme.
		relationship = { parent = "#56b4e9", sibling = "#00aa7b", spouse = "#e69f00", child = "#f0e442" },
	},
}

---Derive a complete palette from raw Windows theme colours. Pure: the caller
---(the plugin's theme helper) supplies the colours; missing values degrade to
---the Classic preset's equivalents so the result is always complete.
---@param sys? FsSystemColours
---@return FsPalette
function M.deriveSystemPalette(sys)
	sys = sys or {}
	local classic = PRESETS.classic
	local ok, palette = pcall(function()
		local bg = sys.window and M.normaliseColour(sys.window) or classic.background
		local text = sys.windowText and M.normaliseColour(sys.windowText) or classic.text
		local accent = sys.hilight and M.normaliseColour(sys.hilight) or classic.stroke
		local link = sys.hotTracking and M.normaliseColour(sys.hotTracking) or classic.link
		-- Tint the accent towards the background for fills, keeping label text legible.
		local function tint(t)
			return M.mix(accent, bg, t)
		end
		return {
			background = bg,
			text = text,
			labelText = M.mix(text, bg, 0.3),
			stroke = M.mix(accent, text, 0.3),
			link = link,
			focalFill = tint(0.45),
			focalStroke = M.mix(accent, text, 0.5),
			defaultFill = tint(0.82),
			sex = { M = tint(0.72), F = tint(0.86), U = M.mix(text, bg, 0.88) },
			relationship = {
				parent = tint(0.7),
				sibling = tint(0.8),
				spouse = tint(0.88),
				child = tint(0.94),
			},
		}
	end)
	if ok then
		return palette
	end
	return PRESETS.classic
end

---Complete a partial palette: every missing value (including missing sex or
---relationship entries) degrades to the Classic preset's equivalent, and
---colour values are normalised to "#rrggbb". Invalid colours fall back too.
---@param partial table
---@return FsPalette
function M.completePalette(partial)
	partial = partial or {}
	local classic = PRESETS.classic
	local function colour(value, fallback)
		if type(value) == "string" then
			local ok, normalised = pcall(M.normaliseColour, value)
			if ok then
				return normalised
			end
		end
		return fallback
	end
	local palette = {
		background = colour(partial.background, classic.background),
		text = colour(partial.text, classic.text),
		labelText = colour(partial.labelText, classic.labelText),
		stroke = colour(partial.stroke, classic.stroke),
		link = colour(partial.link, classic.link),
		focalFill = colour(partial.focalFill, classic.focalFill),
		focalStroke = colour(partial.focalStroke, classic.focalStroke),
		defaultFill = colour(partial.defaultFill, classic.defaultFill),
		sex = {},
		relationship = {},
	}
	-- The connector-line colour is optional; absent means "same as stroke".
	if partial.line ~= nil then
		palette.line = colour(partial.line, palette.stroke)
	end
	local partialSex = type(partial.sex) == "table" and partial.sex or {}
	for code, fallback in pairs(classic.sex) do
		palette.sex[code] = colour(partialSex[code], fallback)
	end
	local partialRel = type(partial.relationship) == "table" and partial.relationship or {}
	for role, fallback in pairs(classic.relationship) do
		palette.relationship[role] = colour(partialRel[role], fallback)
	end
	return palette
end

---Resolve the palette for the given merged options.
---@param options FsRenderOptions
---@return FsPalette
function M.palette(options)
	if options.preset == "system" then
		return M.deriveSystemPalette(options.systemColours)
	end
	if options.preset == "custom" then
		return M.completePalette(options.customPalette)
	end
	return PRESETS[options.preset] or PRESETS.classic
end

---The preset keys in display order, for UI option lists and tests.
---@return FsPresetKey[]
function M.presetKeys()
	return { "classic", "heritage", "slate", "highcontrast", "system" }
end

--------------------------------------------------------------
-- NODE STYLE RESOLUTION (colour and shape encoding)
--------------------------------------------------------------

---@type table<FsSexCode, FsShape>
local SHAPE_BY_SEX = { M = "rect", F = "ellipse", U = "rounded" }
---@type table<FsRole, FsShape>
local SHAPE_BY_RELATIONSHIP = { focal = "rounded", parent = "rect", sibling = "rounded", spouse = "ellipse", child = "octagon" }

---@class FsNodeStyle
---@field fill string   Node fill colour ("#rrggbb").
---@field stroke string Node outline colour.
---@field shape FsShape Node outline shape.
---@field emphasis boolean True for the focal person (thicker outline).

---Resolve fill, stroke and shape for one node from the encoding options.
---The focal person always keeps an emphasised outline; when colour encodes
---sex the focal node is coloured by sex like everyone else, otherwise it
---takes the dedicated focal fill so it stands out.
---@param role FsRole
---@param sex FsSexCode
---@param options FsRenderOptions
---@param palette FsPalette
---@return FsNodeStyle
function M.nodeStyle(role, sex, options, palette)
	local fill
	if options.colourBy == "sex" then
		fill = palette.sex[sex] or palette.sex.U
	elseif options.colourBy == "relationship" then
		if role == "focal" then
			fill = palette.focalFill
		else
			fill = palette.relationship[role] or palette.defaultFill
		end
	else -- "theme"
		fill = (role == "focal") and palette.focalFill or palette.defaultFill
	end

	local shape
	if options.shapeBy == "sex" then
		shape = SHAPE_BY_SEX[sex] or SHAPE_BY_SEX.U
	elseif options.shapeBy == "relationship" then
		shape = SHAPE_BY_RELATIONSHIP[role]
	else -- "theme": uniform, in the user's chosen base shape
		shape = options.baseShape or "rounded"
	end

	return {
		fill = fill,
		stroke = (role == "focal") and palette.focalStroke or palette.stroke,
		shape = shape,
		emphasis = (role == "focal"),
	}
end

--------------------------------------------------------------
-- GROUP CAPTIONS
--------------------------------------------------------------

---@type table<string, string>
local PEDI_CAPTIONS = {
	-- Birth parents are the reader's default assumption, so they carry no
	-- caption; only other parent types are labelled.
	[""] = "",
	birth = "",
	adopted = "Adoptive parents",
	foster = "Foster parents",
	step = "Step-parents",
}

---Caption for a parent-set from its PEDI value. An empty PEDI means birth;
---birth sets are unlabelled (empty caption); unrecognised values are shown
---capitalised so nothing is silently dropped.
---@param pedi string
---@return string
function M.pediCaption(pedi)
	pedi = (pedi or ""):lower()
	local caption = PEDI_CAPTIONS[pedi]
	if caption then
		return caption
	end
	return pedi:sub(1, 1):upper() .. pedi:sub(2) .. " parents"
end

---Stroke dash pattern for a parent-set's connectors, from its PEDI value.
---Birth families use solid lines (nil); each other type gets a distinct
---pattern so tangled charts stay readable.
---@param pedi string
---@return string|nil dasharray SVG stroke-dasharray value, or nil for solid.
function M.pediDash(pedi)
	pedi = (pedi or ""):lower()
	if pedi == "" or pedi == "birth" then
		return nil
	elseif pedi == "adopted" then
		return "7 4"
	elseif pedi == "foster" then
		return "2 3"
	elseif pedi == "step" then
		return "10 4 2 4"
	end
	return "4 4" -- any unrecognised non-birth type: generic dashes
end

---Filter a model's parent-sets per the parentFamilies option: "all" keeps
---everything, "birth" keeps only birth-PEDI sets, "first" keeps only the
---first set (the adapter orders birth-type sets first). The filter applies
---to the focal person's own sets; a parent's other families are kept only
---when the parent they are reached through survives the filter.
---@param parentSets FsParentSet[]
---@param choice FsParentFamilies
---@return FsParentSet[]
function M.filterParentSets(parentSets, choice)
	parentSets = parentSets or {}
	if choice ~= "birth" and choice ~= "first" then
		return parentSets
	end
	local kept, keptParentIds = {}, {}
	for _, set in ipairs(parentSets) do
		if not set.otherFamily then
			local pedi = (set.pedi or ""):lower()
			local keep
			if choice == "birth" then
				keep = (pedi == "" or pedi == "birth")
			else -- "first"
				keep = (#kept == 0)
			end
			if keep then
				kept[#kept + 1] = set
				if set.father then
					keptParentIds[set.father.id] = true
				end
				if set.mother then
					keptParentIds[set.mother.id] = true
				end
			end
		end
	end
	for _, set in ipairs(parentSets) do
		if set.otherFamily and set.sharedParentId and keptParentIds[set.sharedParentId] then
			kept[#kept + 1] = set
		end
	end
	return kept
end

---Apply record-flag privacy to a model: remove everyone the adapter marked
---`private` (omit-Private), in full. Their nodes vanish; a parent slot they
---filled becomes empty; a parent-set or spouse-unit left with nobody visible
---is dropped; and an "other family" column reached through a Private parent is
---dropped whole (its caption would otherwise name that person). The focal
---person is left untouched here - the plugin decides whether to chart them.
---`basicOnly` people are kept; the renderer shows them as name only. A no-op
---when nothing is marked private.
---@param model FsModel
---@return FsModel
function M.applyPrivacy(model)
	if not model then
		return model
	end
	local function visible(person)
		return person ~= nil and not person.private
	end

	local parentSets = {}
	for _, set in ipairs(model.parentSets or {}) do
		if not (set.otherFamily and set.sharedParentPrivate) then
			local father = visible(set.father) and set.father or nil
			local mother = visible(set.mother) and set.mother or nil
			-- Keep sibling order; re-count the focal's place among the
			-- survivors so its age-order slot stays correct.
			local siblings = {}
			local focalIndex = 0
			for i, sib in ipairs(set.siblings or {}) do
				if visible(sib) then
					siblings[#siblings + 1] = sib
					if i <= (set.focalIndex or 0) then
						focalIndex = focalIndex + 1
					end
				end
			end
			-- An other-family column is worth showing for the focal's
			-- half-siblings OR the parent's other partner (a navigable
			-- relationship). Drop it once privacy has removed both - its lone
			-- shared parent is already in the home set. (`father and mother`
			-- means the other partner survives: the shared parent is always
			-- present here, the partner fills the second slot.) The layout omits
			-- the parent drop when nothing hangs below, so a surviving childless
			-- couple shows as just the couple. A home set is kept whenever any
			-- parent or sibling survives.
			local keep
			if set.otherFamily then
				keep = (father and mother) or #siblings > 0
			else
				keep = father or mother or #siblings > 0
			end
			if keep then
				local newSet = {}
				for k, v in pairs(set) do
					newSet[k] = v
				end
				newSet.father = father
				newSet.mother = mother
				newSet.siblings = siblings
				newSet.focalIndex = focalIndex
				parentSets[#parentSets + 1] = newSet
			end
		end
	end

	local spouseUnits = {}
	for _, unit in ipairs(model.spouseUnits or {}) do
		local spouse = visible(unit.spouse) and unit.spouse or nil
		local children = {}
		for _, child in ipairs(unit.children or {}) do
			if visible(child) then
				children[#children + 1] = child
			end
		end
		if spouse or #children > 0 then
			spouseUnits[#spouseUnits + 1] = { spouse = spouse, children = children }
		end
	end

	return {
		focal = model.focal,
		parentSets = parentSets,
		spouseUnits = spouseUnits,
	}
end

---Visible tag for a sibling's kind. Full siblings are the unmarked default;
---half- and step-siblings carry a short tag.
---@param kind "full"|"half"|"step"
---@return string tag Empty string for full siblings.
function M.siblingTag(kind)
	if kind == "half" then
		return "half"
	elseif kind == "step" then
		return "step"
	end
	return ""
end

return M

end)()
--<<FS_SPLICE_END>>

--<<FS_SPLICE src/fs_layout.lua FsLayout>>
FsLayout = (function()
--[[
@Title: fs_layout
@Author: Helen Wright
@Description:
    Layer 5 (pure): bounds and coordinate maths. Turns an FsModel plus merged
    render options into a flat list of positioned nodes, group captions and
    connector polylines, with overall content bounds for the SVG viewBox.

    Both layouts share four generation bands: parent-sets; siblings (one
    column per parent-set); the focal person (far left, own row) with spouse
    units beside them; and children (one column per spouse unit). The focal
    person's column doubles as a clear channel for the parent-bus drop, so
    connectors never cross sibling boxes. When the focal person has more than
    one own parent-set, only the first ("home") set links back to the real
    focal box; each further own set shows the focal person as a faded proxy at
    their birth-order slot among that family's children, so no connector has to
    span the chart.

    Two layouts:
      * "wide"    - every sibling/child group is a single row. Horizontal
                    scrolling is expected when embedded.
      * "compact" - sibling and child groups wrap into a roughly square grid
                    of rows, making the chart much narrower.

    Connectors carry the parent-set's PEDI dash pattern (solid for birth) so
    overlapping sets stay distinguishable.

    No FH API, no IUP, no file I/O. Text width is estimated from codepoint
    counts (wide CJK codepoints count extra); the renderer has no access to
    real font metrics and the charts are navigation aids, so an estimate with
    generous padding is the right trade-off.
]]

local fs_options = _G.FsOptions or require("fs_options")

local M = {}

--------------------------------------------------------------
-- TYPES
--------------------------------------------------------------

---@class FsLayoutNode             One positioned node instance (a person may appear in more than one group).
---@field id string                Instance id, unique within the chart (e.g. "n7").
---@field person FsPerson          The underlying person.
---@field role FsRole              Relationship category for encoding.
---@field kind? "full"|"half"|"step"  Sibling kind (siblings only).
---@field proxy? boolean           A faded repeat of a person shown in full elsewhere (additional parent sets).
---@field label string             Display text: name plus "(dates)" when shown.
---@field tag string               Visible sibling tag ("half"/"step") or "".
---@field x number                 Left edge.
---@field y number                 Top edge.
---@field w number                 Width.
---@field h number                 Height.

---@class FsCaption                A group caption (e.g. "Adoptive parents").
---@field text string
---@field x number                 Anchor x (text is start-anchored).
---@field y number                 Baseline y.

---@class FsEdge                   A connector polyline.
---@field points {x: number, y: number}[]
---@field dash? string             SVG stroke-dasharray pattern; nil for solid.
---@field double? boolean          Draw as a double (parallel) line - the marriage convention.

---@class FsLayout
---@field width number             Content width including margins.
---@field height number            Content height including margins.
---@field nodes FsLayoutNode[]
---@field captions FsCaption[]
---@field edges FsEdge[]

--------------------------------------------------------------
-- TEXT MEASUREMENT (estimate)
--------------------------------------------------------------

---Estimate the rendered width of a UTF-8 string in user units.
---Counts codepoints from the byte stream; CJK and other wide codepoints
---count as 1.7 units, the rest as 1. One unit is ~0.62 of the font size.
---@param text string Raw UTF-8.
---@param fontSize number
---@return number width
function M.estimateTextWidth(text, fontSize)
	local units = 0.0
	local i = 1
	local len = #text
	while i <= len do
		local b = text:byte(i)
		local cp, size
		if b < 0x80 then
			cp, size = b, 1
		elseif b < 0xE0 then
			cp = (b - 0xC0) * 0x40 + (text:byte(i + 1) or 0) % 0x40
			size = 2
		elseif b < 0xF0 then
			cp = (b - 0xE0) * 0x1000
				+ ((text:byte(i + 1) or 0) % 0x40) * 0x40
				+ (text:byte(i + 2) or 0) % 0x40
			size = 3
		else
			cp, size = 0x20000, 4 -- astral plane: treat as wide
		end
		-- CJK unified, compatibility, Hangul, kana and full-width ranges.
		local wide = (cp >= 0x1100 and cp <= 0x115F)
			or (cp >= 0x2E80 and cp <= 0xA4CF)
			or (cp >= 0xAC00 and cp <= 0xD7A3)
			or (cp >= 0xF900 and cp <= 0xFAFF)
			or (cp >= 0xFF00 and cp <= 0xFF60)
			or cp >= 0x20000
		units = units + (wide and 1.7 or 1.0)
		i = i + size
	end
	return units * fontSize * 0.62
end

--------------------------------------------------------------
-- LABELS AND NODE SIZING
--------------------------------------------------------------

---Compose the visible label for a person: name, plus "(dates)" when the
---life-dates option is on and the LifeDates2 value is not the bare hyphen.
---LifeDates2 pads living people's dates with a trailing space ("1938- "),
---so the value is trimmed before use.
---@param person FsPerson
---@param options FsRenderOptions
---@return string
function M.nodeLabel(person, options)
	local dates = (person.lifeDates or ""):gsub("^%s+", ""):gsub("%s+$", "")
	if options.lifeDates and not person.basicOnly and dates ~= "-" and dates ~= "" then
		return person.name .. " (" .. dates .. ")"
	end
	return person.name
end

---Build an unpositioned node for a person; x/y are assigned during placement.
---@param person FsPerson
---@param role FsRole
---@param kind? "full"|"half"|"step"
---@param options FsRenderOptions
---@param state {nextId: integer}
---@return FsLayoutNode
local function makeNode(person, role, kind, options, state)
	local fontSize = options.fontSize
	local label = M.nodeLabel(person, options)
	local tag = kind and fs_options.siblingTag(kind) or ""
	local padX = fontSize * 0.7
	local textW = M.estimateTextWidth(label, fontSize)
	if tag ~= "" then
		-- The tag renders in a smaller font after the label.
		textW = textW + M.estimateTextWidth(" — " .. tag, fontSize * 0.8)
	end
	local w = math.max(options.nodeSize, textW + 2 * padX)
	-- Ellipses clip their corners, so give them extra room.
	local style = fs_options.nodeStyle(role, person.sex, options, fs_options.palette(options))
	if style.shape == "ellipse" then
		w = w * 1.25
	end
	local h = fontSize + 2 * (fontSize * 0.55)
	state.nextId = state.nextId + 1
	return {
		id = "n" .. state.nextId,
		person = person,
		role = role,
		kind = kind,
		label = label,
		tag = tag,
		x = 0,
		y = 0,
		w = w,
		h = h,
	}
end

---Horizontal centre of a node.
---@param node FsLayoutNode
---@return number
local function cx(node)
	return node.x + node.w / 2
end

--------------------------------------------------------------
-- GENERATION-BAND LAYOUT (wide and compact)
--------------------------------------------------------------

---Total width of a row of nodes with the given gap, without placing them.
---@param nodes FsLayoutNode[]
---@param gap number
---@return number
local function rowWidth(nodes, gap)
	local w = 0
	for i, node in ipairs(nodes) do
		w = w + node.w + (i > 1 and gap or 0)
	end
	return w
end

---Place a list of nodes in a horizontal row starting at (x, y); returns the
---total row width.
---@param nodes FsLayoutNode[]
---@param x number
---@param y number
---@param gap number
---@return number width
local function placeRow(nodes, x, y, gap)
	local cursor = x
	for i, node in ipairs(nodes) do
		if i > 1 then
			cursor = cursor + gap
		end
		node.x = cursor
		node.y = y
		cursor = cursor + node.w
	end
	return cursor - x
end

---The number of columns for a group: every node on one row in wide mode; a
---single indented column in compact mode (so each box can be linked on its
---left edge to a shared spine - the clearest "these are all siblings" form,
---and the narrowest).
---@param n integer Group size.
---@param wrap boolean Compact (wrapping) mode.
---@return integer
local function groupCols(n, wrap)
	if not wrap then
		return n
	end
	return 1
end

---Footprint of a group without placing it. Wrapped groups use a uniform
---cell width (the widest node) so grid columns align for the connectors.
---@param groupNodes FsLayoutNode[]
---@param gapX number
---@param gapY number
---@param wrap boolean
---@return {width: number, height: number}
local function groupSize(groupNodes, gapX, gapY, wrap)
	local n = #groupNodes
	if n == 0 then
		return { width = 0, height = 0 }
	end
	local h = groupNodes[1].h
	local cols = groupCols(n, wrap)
	if cols >= n then
		return { width = rowWidth(groupNodes, gapX), height = h }
	end
	local cellW = 0
	for _, node in ipairs(groupNodes) do
		cellW = math.max(cellW, node.w)
	end
	local rows = math.ceil(n / cols)
	return {
		width = cols * cellW + (cols - 1) * gapX,
		height = rows * h + (rows - 1) * gapY,
	}
end

---@class FsGroupPlacement
---@field rows FsLayoutNode[][]  Nodes by grid row, left to right (one row unless wrapped).
---@field leftX number           The group's left edge (for the connector spine).

---Place a group at (xLeft, yTop): one row (wide, or small groups) or a
---roughly square grid (compact). Returns the row structure for the connector.
---@param groupNodes FsLayoutNode[]
---@param xLeft number
---@param yTop number
---@param gapX number
---@param gapY number
---@param wrap boolean
---@return FsGroupPlacement
local function placeGroup(groupNodes, xLeft, yTop, gapX, gapY, wrap)
	local n = #groupNodes
	if n == 0 then
		return { rows = {}, leftX = xLeft }
	end
	local cols = groupCols(n, wrap)
	if cols >= n then
		placeRow(groupNodes, xLeft, yTop, gapX)
		local row = {}
		for _, node in ipairs(groupNodes) do
			row[#row + 1] = node
		end
		return { rows = { row }, leftX = xLeft }
	end
	local cellW = 0
	for _, node in ipairs(groupNodes) do
		cellW = math.max(cellW, node.w)
	end
	local h = groupNodes[1].h
	local rows = {}
	for i, node in ipairs(groupNodes) do
		local r = math.floor((i - 1) / cols) + 1
		local c = (i - 1) % cols
		-- Left-aligned: every box's left edge lines up with the column (and
		-- the connector spine), rather than centred in the cell.
		node.x = xLeft + c * (cellW + gapX)
		node.y = yTop + (r - 1) * (h + gapY)
		rows[r] = rows[r] or {}
		table.insert(rows[r], node)
	end
	return { rows = rows, leftX = xLeft }
end

---Connect a placed group to its parent bus so every member reads as one
---generation (all siblings, or all children of one couple):
---  * one row - a straight drop from the bus to each node;
---  * a grid  - a left spine off the bus, a horizontal bus above each grid
---    row, and a short stub down to each node. No connector ever joins two
---    members directly, so a lower-row member is never mistaken for a child
---    of the one above it.
---Returns the x-range the parent bus must span to reach this group.
---@param edges FsEdge[]
---@param grid FsGroupPlacement
---@param busY number
---@param dash string|nil
---@param gapX number
---@param gapY number
---@return number|nil minX, number|nil maxX
local function connectGroup(edges, grid, busY, dash, gapX, gapY)
	local rows = grid.rows
	if #rows == 0 then
		return nil, nil
	end
	if #rows == 1 then
		local minX, maxX = math.huge, -math.huge
		for _, node in ipairs(rows[1]) do
			edges[#edges + 1] = {
				points = { { x = cx(node), y = busY }, { x = cx(node), y = node.y } },
				dash = dash,
			}
			minX = math.min(minX, cx(node))
			maxX = math.max(maxX, cx(node))
		end
		return minX, maxX
	end
	-- Single indented column (compact): a left spine off the bus, with a
	-- horizontal line into each box's LEFT edge at mid-height - the classic
	-- indented family-tree look, unmistakably one generation of siblings.
	local spineX = grid.leftX - gapX * 0.5
	local lastNode = rows[#rows][1]
	local lastMidY = lastNode.y + lastNode.h / 2
	edges[#edges + 1] = {
		points = { { x = spineX, y = busY }, { x = spineX, y = lastMidY } },
		dash = dash,
	}
	for _, row in ipairs(rows) do
		local node = row[1]
		local midY = node.y + node.h / 2
		edges[#edges + 1] = {
			points = { { x = spineX, y = midY }, { x = node.x, y = midY } },
			dash = dash,
		}
	end
	return spineX, spineX
end

---Per-index bus stagger fraction, so neighbouring buses in the same band
---gap sit at different heights instead of overdrawing each other.
---@param index integer
---@return number
local function staggerFraction(index)
	return 0.55 - 0.18 * ((index - 1) % 3)
end

---Lay the model out in four generation bands (see module description).
---@param model FsModel
---@param options FsRenderOptions
---@return FsLayout
local function layoutBands(model, options)
	local wrap = options.layout == "compact"
	local fontSize = options.fontSize
	local gapX = fontSize * 1.2
	local gapY = fontSize * 0.9
	local colGap = fontSize * 3.0
	local rowGapY = fontSize * 4.0
	local captionH = fontSize * 1.4
	local margin = fontSize * 1.5
	local state = { nextId = 0 }

	local nodes, captions, edges = {}, {}, {}

	-- Build node objects per column first, place afterwards.
	---@type {caption: string, dash: string|nil, otherFamily: boolean|nil, focalIndex: integer|nil, parents: FsLayoutNode[], siblings: FsLayoutNode[], sibSize: table, x?: number, w?: number}[]
	local parentCols = {}
	local homeAssigned = false
	for _, set in ipairs(model.parentSets or {}) do
		local ownSet = not set.otherFamily
		local isHome = ownSet and not homeAssigned
		homeAssigned = homeAssigned or isHome
		local col = {
			caption = set.otherFamily and ("Other family of " .. (set.sharedParentName or "?"))
				or fs_options.pediCaption(set.pedi),
			dash = fs_options.pediDash(set.pedi),
			otherFamily = set.otherFamily,
			homeSet = isHome,
			focalIndex = ownSet and set.focalIndex or nil,
			parents = {},
			siblings = {},
		}
		if set.father then
			col.parents[#col.parents + 1] = makeNode(set.father, "parent", nil, options, state)
		end
		if set.mother then
			col.parents[#col.parents + 1] = makeNode(set.mother, "parent", nil, options, state)
		end
		for _, sib in ipairs(set.siblings or {}) do
			col.siblings[#col.siblings + 1] = makeNode(sib, "sibling", sib.kind, options, state)
		end
		-- An own set other than the home set shows the focal person as a faded
		-- proxy at their birth-order slot among that family's children, rather
		-- than a connector run back to the single real focal box.
		if ownSet and not isHome then
			local proxy = makeNode(model.focal, "focal", nil, options, state)
			proxy.proxy = true
			table.insert(col.siblings, math.min(set.focalIndex or 0, #col.siblings) + 1, proxy)
		end
		parentCols[#parentCols + 1] = col
	end

	local focalNode = makeNode(model.focal, "focal", nil, options, state)

	-- The focal person sits at the far left of their own row in BOTH layouts;
	-- siblings pack tight (no reserved gap). Birth order is shown by the
	-- connector, not the box position: each own set's focal connector drops
	-- from the sibling bus at the focal person's birth-order gap (slotX) and
	-- steps across to the box on the left, so the chart stays narrow.
	-- focalIndex (siblings before the focal person, recorded order) locates
	-- that gap.
	for _, col in ipairs(parentCols) do
		col.sibSize = groupSize(col.siblings, gapX, gapY, wrap)
	end

	---@type {spouse: FsLayoutNode|nil, children: FsLayoutNode[], childSize: table, x?: number, w?: number}[]
	local spouseCols = {}
	for _, unit in ipairs(model.spouseUnits or {}) do
		local col = { spouse = nil, children = {} }
		if unit.spouse then
			col.spouse = makeNode(unit.spouse, "spouse", nil, options, state)
		end
		for _, child in ipairs(unit.children or {}) do
			col.children[#col.children + 1] = makeNode(child, "child", nil, options, state)
		end
		col.childSize = groupSize(col.children, gapX, gapY, wrap)
		spouseCols[#spouseCols + 1] = col
	end

	-- Band geometry. The caption band exists only when some set is labelled
	-- (birth sets are unlabelled); the sibling band only when anyone is in it.
	-- A couple is always shown side by side (parents, like spouses, are never
	-- stacked), so the parent band is one box tall.
	local nodeH = focalNode.h
	local hasParents = #parentCols > 0
	local anyCaption = false
	local sibBandH = 0
	local parentBandH = nodeH
	for _, col in ipairs(parentCols) do
		anyCaption = anyCaption or col.caption ~= ""
		sibBandH = math.max(sibBandH, col.sibSize.height)
	end
	local hasSiblings = sibBandH > 0

	local rowPy = margin + (hasParents and anyCaption and captionH or 0)
	local rowSy = hasParents and (rowPy + parentBandH + rowGapY) or margin
	local rowFy
	if not hasParents then
		rowFy = margin
	elseif hasSiblings then
		rowFy = rowSy + sibBandH + rowGapY
	else
		rowFy = rowPy + parentBandH + rowGapY
	end

	-- The focal person sits at the far left of their own row. In compact the
	-- column above them is kept clear so the focal connector drops straight
	-- up through it; in wide the columns start at the margin and the focal
	-- connector steps across from the birth-order gap (see the connector
	-- pass), which keeps the chart narrow.
	local focalChannel = wrap
	focalNode.x, focalNode.y = margin, rowFy
	nodes[#nodes + 1] = focalNode
	local focalCx = cx(focalNode)

	-- An "other family" set branches off the preceding set's shared parent,
	-- so it sits snug against it (a small gap) to make the link obvious;
	-- independent sets get the full column gap.
	local otherFamilyGap = colGap * 0.4
	local cursorX = margin + ((hasParents and focalChannel) and (focalNode.w + colGap) or 0)
	for colIndex, col in ipairs(parentCols) do
		if colIndex > 1 then
			cursorX = cursorX + (col.otherFamily and otherFamilyGap or colGap)
		end
		local parentsW = rowWidth(col.parents, gapX)
		local colW = math.max(parentsW, col.sibSize.width, fontSize) -- never zero-width
		placeRow(col.parents, cursorX + (colW - parentsW) / 2, rowPy, gapX)
		col.sibGrid = placeGroup(col.siblings, cursorX + (colW - col.sibSize.width) / 2, rowSy, gapX, gapY, wrap)
		if col.caption ~= "" then
			captions[#captions + 1] = {
				text = col.caption,
				x = cursorX,
				y = rowPy - fontSize * 0.5,
			}
		end
		col.x, col.w = cursorX, colW
		cursorX = cursorX + colW
		for _, n in ipairs(col.parents) do
			nodes[#nodes + 1] = n
		end
		for _, n in ipairs(col.siblings) do
			nodes[#nodes + 1] = n
		end
	end
	local parentsRight = hasParents and cursorX or (margin + focalNode.w)

	-- Spouse units stay on a single row beside the focal person in both
	-- layouts - spouses are always shown side by side. Compact narrows the
	-- chart by wrapping each unit's CHILDREN into a grid, not by stacking
	-- the spouses.
	---@type {cols: table[], spouseY: number, spineY: number, childTopY: number}[]
	local unitRows = {}
	if #spouseCols > 0 then
		unitRows[1] = { cols = {} }
		for _, col in ipairs(spouseCols) do
			table.insert(unitRows[1].cols, col)
		end
	end

	local spousesRight = focalNode.x + focalNode.w
	local spousesBottom = rowFy + nodeH
	local unitRowTop = rowFy
	for _, unitRow in ipairs(unitRows) do
		unitRow.spouseY = unitRowTop
		unitRow.spineY = unitRowTop + nodeH / 2
		unitRow.childTopY = unitRowTop + nodeH + rowGapY
		local bandH = 0
		cursorX = focalNode.x + focalNode.w + colGap
		for _, col in ipairs(unitRow.cols) do
			local spouseW = col.spouse and col.spouse.w or 0
			local colW = math.max(spouseW, col.childSize.width, fontSize)
			if col.spouse then
				col.spouse.x = cursorX + (colW - spouseW) / 2
				col.spouse.y = unitRow.spouseY
				nodes[#nodes + 1] = col.spouse
			end
			col.childGrid =
				placeGroup(col.children, cursorX + (colW - col.childSize.width) / 2, unitRow.childTopY, gapX, gapY, wrap)
			for _, n in ipairs(col.children) do
				nodes[#nodes + 1] = n
			end
			col.x, col.w = cursorX, colW
			cursorX = cursorX + colW + colGap
			bandH = math.max(bandH, col.childSize.height)
		end
		spousesRight = math.max(spousesRight, cursorX - colGap)
		local rowBottom = (bandH > 0) and (unitRow.childTopY + bandH) or (unitRow.spouseY + nodeH)
		spousesBottom = math.max(spousesBottom, rowBottom)
		unitRowTop = rowBottom + rowGapY
	end
	local contentRight = math.max(parentsRight, spousesRight)

	-- The x of the focal person's birth-order gap within a (single-row, wide)
	-- sibling set: the midpoint of the gap between the siblings recorded
	-- either side of them, or just past the end when eldest/youngest.
	local function focalSlotX(col)
		local sibs = col.siblings
		local n = #sibs
		if n == 0 then
			return nil
		end
		local fi = math.max(0, math.min(col.focalIndex or 0, n))
		if fi <= 0 then
			return sibs[1].x - gapX / 2
		end
		if fi >= n then
			return sibs[n].x + sibs[n].w + gapX / 2
		end
		local leftSib, rightSib = sibs[fi], sibs[fi + 1]
		return (leftSib.x + leftSib.w + rightSib.x) / 2
	end

	-- Parent-set connectors, in the set's dash style: couple line, trunk from
	-- the couple line (or single parent) down to a bus, drops to each sibling
	-- chain, and the focal connector (straight up the clear channel in
	-- compact; a stepped line from the birth-order gap in wide). Buses are
	-- staggered per set so several sets do not overdraw each other.
	for setIndex, col in ipairs(parentCols) do
		if #col.parents > 0 or #col.siblings > 0 then
			local bandTop = hasSiblings and rowSy or rowFy
			local busY = bandTop - rowGapY * staggerFraction(setIndex)
			local trunkX
			if #col.parents == 2 then
				local first, last = col.parents[1], col.parents[#col.parents]
				local coupleY = rowPy + nodeH / 2
				-- Couple line between the two boxes; the trunk drops from its
				-- midpoint so the vertical always meets the horizontal.
				trunkX = (first.x + first.w + last.x) / 2
				-- The line joining a married couple is drawn double.
				edges[#edges + 1] = {
					points = {
						{ x = first.x + first.w, y = coupleY },
						{ x = last.x, y = coupleY },
					},
					dash = col.dash,
					double = true,
				}
				-- Drop to the sibling bus only when something hangs below: the
				-- siblings, or (home set) the focal. A childless other-family
				-- couple shows as just the couple bar, with no dangling drop.
				if #col.siblings > 0 or col.homeSet then
					edges[#edges + 1] = {
						points = { { x = trunkX, y = coupleY }, { x = trunkX, y = busY } },
						dash = col.dash,
					}
				end
			elseif #col.parents == 1 then
				trunkX = cx(col.parents[1])
				if #col.siblings > 0 or col.homeSet then
					edges[#edges + 1] = {
						points = { { x = trunkX, y = rowPy + nodeH }, { x = trunkX, y = busY } },
						dash = col.dash,
					}
				end
			else
				trunkX = col.x + col.w / 2
			end
			-- Where the focal connector meets this set's bus: straight above
			-- the box in compact (clear channel), at the birth-order gap in
			-- wide. Other families never connect to the focal person.
			local focalAttachX
			if col.homeSet then
				focalAttachX = focalChannel and focalCx or (focalSlotX(col) or trunkX)
			end

			-- Connect the sibling group (single-row drops or a wrapped grid's
			-- spine), then size the bus to span the trunk, the group's reach
			-- and the focal attach.
			local gMin, gMax = connectGroup(edges, col.sibGrid, busY, col.dash, gapX, gapY)
			local busMinX, busMaxX = trunkX, trunkX
			if gMin then
				busMinX = math.min(busMinX, gMin)
				busMaxX = math.max(busMaxX, gMax)
			end
			if focalAttachX then
				busMinX = math.min(busMinX, focalAttachX)
				busMaxX = math.max(busMaxX, focalAttachX)
			end
			edges[#edges + 1] = {
				points = { { x = busMinX, y = busY }, { x = busMaxX, y = busY } },
				dash = col.dash,
			}
			if focalAttachX then
				if focalChannel then
					-- Compact: straight drop up the clear channel.
					edges[#edges + 1] = {
						points = { { x = focalCx, y = busY }, { x = focalCx, y = focalNode.y } },
						dash = col.dash,
					}
				else
					-- Wide: drop at the birth-order gap, then step across to
					-- the box on the left, below the sibling row.
					local belowY = rowFy - rowGapY * 0.45
					edges[#edges + 1] = {
						points = {
							{ x = focalAttachX, y = busY },
							{ x = focalAttachX, y = belowY },
							{ x = focalCx, y = belowY },
							{ x = focalCx, y = focalNode.y },
						},
						dash = col.dash,
					}
				end
			end
		end
	end

	-- Spouse spines: one horizontal line at mid-node height per unit-row;
	-- spouses sit on their spine. The first row's spine leaves the focal
	-- person's right edge; later rows (compact wrapping) branch off a single
	-- vertical dropped from the focal person's bottom, in the clear space
	-- left of the unit columns. Each unit's children hang from that unit's
	-- own drop, so the grouping is unambiguous even when the partner is
	-- unrecorded.
	if #spouseCols > 0 then
		local lastRow = unitRows[#unitRows]
		if #unitRows > 1 then
			edges[#edges + 1] = {
				points = {
					{ x = focalCx, y = focalNode.y + focalNode.h },
					{ x = focalCx, y = lastRow.spineY },
				},
			}
		end
		for r, unitRow in ipairs(unitRows) do
			local rowLast = unitRow.cols[#unitRow.cols]
			local spineY = unitRow.spineY
			local spineStart = (r == 1) and { x = focalNode.x + focalNode.w, y = spineY }
				or { x = focalCx, y = spineY }
			-- The line joining the focal person to their spouses is drawn double.
			edges[#edges + 1] = {
				points = { spineStart, { x = rowLast.x + rowLast.w / 2, y = spineY } },
				double = true,
			}
			for unitIndex, col in ipairs(unitRow.cols) do
				if #col.children > 0 then
					local dropX = col.spouse and cx(col.spouse) or (col.x + col.w / 2)
					local dropTop = col.spouse and (col.spouse.y + col.spouse.h) or spineY
					local busY = unitRow.childTopY - rowGapY * staggerFraction(unitIndex)
					edges[#edges + 1] = {
						points = { { x = dropX, y = dropTop }, { x = dropX, y = busY } },
					}
					local gMin, gMax = connectGroup(edges, col.childGrid, busY, nil, gapX, gapY)
					local busMinX, busMaxX = dropX, dropX
					if gMin then
						busMinX = math.min(busMinX, gMin)
						busMaxX = math.max(busMaxX, gMax)
					end
					edges[#edges + 1] = {
						points = { { x = busMinX, y = busY }, { x = busMaxX, y = busY } },
					}
				end
			end
		end
	end

	local height = spousesBottom + margin
	return {
		width = contentRight + margin,
		height = height,
		nodes = nodes,
		captions = captions,
		edges = edges,
	}
end

--------------------------------------------------------------
-- ENTRY POINT
--------------------------------------------------------------

---Lay out a model with the given merged options.
---@param model FsModel
---@param options FsRenderOptions
---@return FsLayout
function M.layout(model, options)
	return layoutBands(model, options)
end

return M

end)()
--<<FS_SPLICE_END>>

--<<FS_SPLICE src/svg_render.lua SvgRender>>
SvgRender = (function()
--[[
@Title: svg_render
@Author: Helen Wright
@Description:
    Layer 3 (pure): renders an FsModel to a self-contained SVG string.
    Deterministic; no FH API, no IUP, no file I/O.

    The SVG carries an inline <style> (selector-scoped to the chart's root id
    so it cannot leak into a host page), a <title>/<desc> pair referenced by
    aria-labelledby, and a <title> on every node. All text content and
    attribute values are XML-escaped here; the model holds raw UTF-8.

    Sizing: the layout supplies content bounds which become the viewBox, and
    the chart renders at natural size times the image-scale option (both
    standalone and embedded; an embedded chart wider than its container
    scrolls inside the injected overflow:auto wrapper). Only the XML
    declaration distinguishes a standalone file.
]]

local fs_options = _G.FsOptions or require("fs_options")
local fs_layout = _G.FsLayout or require("fs_layout")

local M = {}

--------------------------------------------------------------
-- ESCAPING
--------------------------------------------------------------

local XML_ESCAPES = {
	["&"] = "&amp;",
	["<"] = "&lt;",
	[">"] = "&gt;",
	['"'] = "&quot;",
	["'"] = "&apos;",
}

---XML-escape the five reserved characters. Multi-byte UTF-8 sequences pass
---through untouched (their bytes are all >= 0x80, so no escape applies).
---@param text string Raw UTF-8.
---@return string
function M.escape(text)
	return (text:gsub('[&<>"\']', XML_ESCAPES))
end

--------------------------------------------------------------
-- FRAGMENT BUILDERS
--------------------------------------------------------------

---Format a number compactly for SVG attributes (trims trailing zeros).
---@param n number
---@return string
local function num(n)
	local s = string.format("%.2f", n)
	s = s:gsub("%.?0+$", "")
	return s
end

---The shape element for a node, from its resolved style.
---@param node FsLayoutNode
---@param style FsNodeStyle
---@return string
local function shapeElement(node, style)
	local strokeWidth = style.emphasis and 2.5 or 1.2
	local dash = node.proxy and ' stroke-dasharray="5 4"' or ""
	local common = string.format(
		'fill="%s" stroke="%s" stroke-width="%s"%s',
		style.fill,
		style.stroke,
		num(strokeWidth),
		dash
	)
	if style.shape == "ellipse" then
		return string.format(
			'<ellipse cx="%s" cy="%s" rx="%s" ry="%s" %s/>',
			num(node.x + node.w / 2),
			num(node.y + node.h / 2),
			num(node.w / 2),
			num(node.h / 2),
			common
		)
	elseif style.shape == "octagon" then
		-- Corner cut proportional to height.
		local c = node.h * 0.3
		local x1, y1, x2, y2 = node.x, node.y, node.x + node.w, node.y + node.h
		local points = {
			{ x1 + c, y1 },
			{ x2 - c, y1 },
			{ x2, y1 + c },
			{ x2, y2 - c },
			{ x2 - c, y2 },
			{ x1 + c, y2 },
			{ x1, y2 - c },
			{ x1, y1 + c },
		}
		local coords = {}
		for _, p in ipairs(points) do
			coords[#coords + 1] = num(p[1]) .. "," .. num(p[2])
		end
		return string.format('<polygon points="%s" %s/>', table.concat(coords, " "), common)
	end
	local rx = (style.shape == "rounded") and (node.h * 0.45) or 0
	return string.format(
		'<rect x="%s" y="%s" width="%s" height="%s" rx="%s" %s/>',
		num(node.x),
		num(node.y),
		num(node.w),
		num(node.h),
		num(rx),
		common
	)
end

---The accessible per-node title: full name plus dates when any exist.
---LifeDates2 pads living people's dates with a trailing space, so trim.
---@param person FsPerson
---@return string
local function nodeTitleText(person)
	local dates = (person.lifeDates or ""):gsub("^%s+", ""):gsub("%s+$", "")
	if not person.basicOnly and dates ~= "-" and dates ~= "" then
		return person.name .. " (" .. dates .. ")"
	end
	return person.name
end

---One node: shape, label text and accessible title, optionally wrapped in a
---link. Links are emitted only when the option is on AND the model marks the
---person linkable (never the focal person, never anyone outside the
---selection - the adapter encodes that in the linkable flag).
---@param node FsLayoutNode
---@param options FsRenderOptions
---@param palette FsPalette
---@return string
local function nodeElement(node, options, palette)
	local style = fs_options.nodeStyle(node.role, node.person.sex, options, palette)
	local fontSize = options.fontSize
	local parts = {}

	local classes = "fs-node fs-role-" .. node.role
	if node.kind then
		classes = classes .. " fs-kind-" .. node.kind
	end
	if node.proxy then
		classes = classes .. " fs-proxy"
	end
	parts[#parts + 1] = node.proxy
			and string.format('<g class="%s" opacity="0.5">', classes)
		or string.format('<g class="%s">', classes)
	parts[#parts + 1] = "<title>" .. M.escape(nodeTitleText(node.person)) .. "</title>"
	parts[#parts + 1] = shapeElement(node, style)

	local textY = node.y + node.h / 2 + fontSize * 0.35
	local label = '<text class="fs-label" x="'
		.. num(node.x + node.w / 2)
		.. '" y="'
		.. num(textY)
		.. '" text-anchor="middle">'
		.. M.escape(node.label)
	if node.tag ~= "" then
		label = label
			.. string.format(
				'<tspan class="fs-tag" font-size="%s" fill="%s">',
				num(fontSize * 0.8),
				palette.labelText
			)
			.. M.escape(" — " .. node.tag)
			.. "</tspan>"
	end
	label = label .. "</text>"
	parts[#parts + 1] = label
	parts[#parts + 1] = "</g>"

	local element = table.concat(parts)
	if options.links and node.person.linkable then
		local prefix = options.pagePrefix or "ind"
		element = string.format('<a href="%s%d.html">', prefix, node.person.id) .. element .. "</a>"
	end
	return element
end

--------------------------------------------------------------
-- RENDER
--------------------------------------------------------------

---Counts for the accessible <desc>.
---@param model FsModel
---@return integer siblings, integer spouses, integer children
local function modelCounts(model)
	local siblings, spouses, children = 0, 0, 0
	for _, set in ipairs(model.parentSets or {}) do
		siblings = siblings + #(set.siblings or {})
	end
	for _, unit in ipairs(model.spouseUnits or {}) do
		if unit.spouse then
			spouses = spouses + 1
		end
		children = children + #(unit.children or {})
	end
	return siblings, spouses, children
end

---Render a model to a self-contained SVG string.
---@param model FsModel
---@param options? table Raw options; merged over the defaults here.
---@return string svg
function M.render(model, options)
	local opts = fs_options.merge(options)
	local palette = fs_options.palette(opts)

	-- Apply the parent-families option before layout so the chart, the
	-- accessible description and the counts all agree on what is shown.
	if opts.parentFamilies ~= "all" then
		model = {
			focal = model.focal,
			parentSets = fs_options.filterParentSets(model.parentSets, opts.parentFamilies),
			spouseUnits = model.spouseUnits,
		}
	end

	-- Remove anyone flagged Private (a no-op when none are). basic-details
	-- people are kept and shown as name only by the label builders below.
	model = fs_options.applyPrivacy(model)

	local layout = fs_layout.layout(model, opts)

	local rootId = "fhseealso-ind" .. model.focal.id
	local titleId = rootId .. "-title"
	local descId = rootId .. "-desc"
	local w, h = layout.width, layout.height

	local out = {}

	-- Sizing (sizeMode). "scale" (default): natural size x imageScale - a
	-- standalone file, or an embedded chart that scrolls inside its wrapper.
	-- "fit" (embedded only): width="100%", scaling down to the container.
	-- "exact": a fixed pixel width with proportional height, for a report slot.
	-- (merge() maps the legacy fitWidth toggle to sizeMode="fit".)
	local scale = (opts.imageScale or 100) / 100
	local sizing
	if opts.sizeMode == "exact" and opts.exactWidth and opts.exactWidth > 0 then
		-- The viewBox is unchanged, so the drawing scales to fit the exact width;
		-- height follows the chart's aspect ratio. Applies to standalone and
		-- embedded alike.
		local ew = opts.exactWidth
		local eh = (w > 0) and (ew * h / w) or h
		sizing = string.format('width="%s" height="%s"', num(ew), num(eh))
	elseif opts.embedded and opts.sizeMode == "fit" then
		sizing = 'width="100%"'
	else
		sizing = string.format('width="%s" height="%s"', num(w * scale), num(h * scale))
	end
	if not opts.embedded then
		out[#out + 1] = '<?xml version="1.0" encoding="UTF-8"?>\n'
	end

	out[#out + 1] = string.format(
		'<svg xmlns="http://www.w3.org/2000/svg" id="%s" viewBox="0 0 %s %s" %s role="img" aria-labelledby="%s %s">',
		rootId,
		num(w),
		num(h),
		sizing,
		titleId,
		descId
	)

	out[#out + 1] = string.format(
		'<title id="%s">%s</title>',
		titleId,
		M.escape("See also: " .. nodeTitleText(model.focal))
	)
	local siblings, spouses, children = modelCounts(model)
	out[#out + 1] = string.format(
		'<desc id="%s">%s</desc>',
		descId,
		M.escape(
			string.format(
				"Navigation chart for %s: %d parent set(s), %d sibling(s), %d spouse(s)/partner(s), %d child(ren). Linked nodes open that person's page.",
				model.focal.name,
				#(model.parentSets or {}),
				siblings,
				spouses,
				children
			)
		)
	)

	-- Scoped inline style: every selector is prefixed with the root id so the
	-- chart cannot restyle (or be restyled by) the host page.
	out[#out + 1] = string.format(
		"<style>"
			.. "#%s text{font-family:%s;font-size:%spx;fill:%s;font-variant:normal;}"
			.. "#%s .fs-caption{font-size:%spx;fill:%s;font-style:italic;}"
			.. "#%s a .fs-label{fill:%s;text-decoration:underline;}"
			.. "#%s a{cursor:pointer;}"
			.. "</style>",
		rootId,
		M.escape(opts.fontFamily),
		num(opts.fontSize),
		palette.text,
		rootId,
		num(opts.fontSize * 0.9),
		palette.labelText,
		rootId,
		palette.link,
		rootId
	)

	-- Background.
	out[#out + 1] = string.format(
		'<rect x="0" y="0" width="%s" height="%s" fill="%s"/>',
		num(w),
		num(h),
		palette.background
	)

	-- Connectors beneath nodes, in the palette's line colour (falling back to
	-- the box-border stroke) and the edge's dash pattern (parent-set edges
	-- carry their PEDI pattern; birth and spouse/child edges are solid). A
	-- "double" edge (the marriage line joining a couple) is drawn as two
	-- parallel lines, offset vertically. Degenerate edges (all points
	-- coincident, e.g. the bus over a single child) draw nothing, so skip.
	local lineColour = palette.line or palette.stroke
	for _, edge in ipairs(layout.edges) do
		local degenerate = true
		local first = edge.points[1]
		for _, p in ipairs(edge.points) do
			if p.x ~= first.x or p.y ~= first.y then
				degenerate = false
			end
		end
		if not degenerate then
			local dash = edge.dash and string.format(' stroke-dasharray="%s"', M.escape(edge.dash)) or ""
			local offsets = edge.double and { -1.8, 1.8 } or { 0 }
			for _, dy in ipairs(offsets) do
				local coords = {}
				for _, p in ipairs(edge.points) do
					coords[#coords + 1] = num(p.x) .. "," .. num(p.y + dy)
				end
				out[#out + 1] = string.format(
					'<polyline points="%s" fill="none" stroke="%s" stroke-width="1.2"%s/>',
					table.concat(coords, " "),
					lineColour,
					dash
				)
			end
		end
	end

	-- Group captions.
	for _, caption in ipairs(layout.captions) do
		out[#out + 1] = string.format(
			'<text class="fs-caption" x="%s" y="%s">%s</text>',
			num(caption.x),
			num(caption.y),
			M.escape(caption.text)
		)
	end

	-- Nodes.
	for _, node in ipairs(layout.nodes) do
		out[#out + 1] = nodeElement(node, opts, palette)
	end

	out[#out + 1] = "</svg>"
	return table.concat(out)
end

return M

end)()
--<<FS_SPLICE_END>>

--<<FS_SPLICE "../../2 Boilerplate/HtmlInject.lua" HtmlInject>>
HtmlInject = (function()
--[[
@Title: HtmlInject
@Author: Helen Wright
@Description:
    Shared boilerplate (pure): finds a target <div> in an HTML string by class
    token and inserts or replaces a marker-wrapped block inside it, returning
    the new HTML string. The block is just a string - it has carried an SVG
    (Add Trees) and an <iframe> (Add Maps); the module is content-agnostic. No
    file I/O here - a thin wrapper in each plugin does the reading and writing.

    Injection is idempotent: the block is wrapped in
        <!-- <marker>:start ind<id> --> ... <!-- <marker>:end ind<id> -->
    and a re-run replaces the existing block rather than accumulating copies.

    TWO INDEPENDENT TOKENS, both defaulting to "FhSeeAlso" for back-compatibility
    but distinct concepts:
      * classToken  - WHICH div to inject into (matched against the div's class
                      list; a multi-token value like "fhsection fhsecdata" must
                      match all of them, the way a CSS ".a.b" selector would).
      * markerToken - the LABEL in the idempotency comment. Set this PER PLUGIN
                      (e.g. Add Maps passes "AddMaps") so two plugins can embed
                      into the same page without overwriting each other's block:
                      the marker is otherwise keyed only by person id, so a
                      shared label would let one plugin's re-run clobber the
                      other's block (notably with top/bottom placement, which
                      finds a block by marker).

    There is no HTML parser in the plugin environment, so the opening div tag
    is located by a small scanner (skipping quoted attribute values that might
    contain '>') and its attributes are then examined for a class list
    containing the class token. This is robust to attribute order, quote
    style, extra whitespace and tag-name case.
]]

local M = {}

---The default class token that marks the target div in FH-generated pages, and
---the default marker label for the idempotency comments. The two are separate
---options (see the header) that merely share this back-compatible default.
local CLASS_TOKEN = "FhSeeAlso"
local MARKER_TOKEN = "FhSeeAlso"

---@class FsEmbedOptions            Embedding options (all optional).
---@field placement? "replace"|"top"|"bottom"  Where the block goes in the div (default "replace").
---@field classToken? string        Target div class token (default "FhSeeAlso").
---@field markerToken? string       Label for the idempotency marker comments (default "FhSeeAlso"); set per plugin so two plugins can embed into the same page without clobbering each other.
---@field align? "left"|"centre"|"right"  Horizontal alignment of the chart in the div (default "centre").
---@field caption? string           Optional heading rendered above the chart.
---@field captionClass? string      CSS class for the caption div (default "fs-embed-caption"); all caption styling comes from the site's CSS for this class.
---@field fit? boolean              True when the SVG itself is width="100%" (no horizontal scroll wrapper).
---@field containerClass? string    CSS class on the outer wrapper div (default "fs-embed-chart"); a styling hook for the site's CSS.
---@field hideable? boolean         Wrap the chart in a no-JavaScript <details> element (collapsible). Starts expanded; the caption (or "Family chart") is the summary label.

--------------------------------------------------------------
-- LOCATING THE TARGET DIV
--------------------------------------------------------------

---Scan a div's opening tag from just after "<div" to its closing ">", skipping
---quoted attribute values (which may legitimately contain '>'). Plain Lua, so
---the plugin needs no lpeg library.
---@param html string
---@param pos integer Index of the first character after "<div".
---@return string|nil attributes Raw text between "<div" and ">", or nil if the tag never closes.
---@return integer|nil afterTag Index just after the closing ">".
local function scanDivOpen(html, pos)
	local i, n = pos, #html
	while i <= n do
		local c = html:sub(i, i)
		if c == '"' or c == "'" then
			-- Skip a quoted value whole, including any '>' inside it.
			local close = html:find(c, i + 1, true)
			if not close then
				return nil -- unterminated quote: malformed tag
			end
			i = close + 1
		elseif c == ">" then
			return html:sub(pos, i - 1), i + 1
		else
			i = i + 1
		end
	end
	return nil -- no closing ">"
end

---Does an attribute string carry CLASS_TOKEN as a whole class token?
---Accepts double-quoted, single-quoted and unquoted attribute values.
---@param attributes string The raw text between "<div" and ">".
---@param classToken string One or more class tokens (space-separated); the div must carry ALL of them.
---@return boolean
local function hasSeeAlsoClass(attributes, classToken)
	-- A multi-token target (e.g. "fhsection fhsecdata") matches a div that
	-- carries every one of those classes - the way a CSS selector ".a.b"
	-- would - so Family Historian's own containers can be addressed.
	local required = {}
	for token in classToken:gmatch("%S+") do
		required[#required + 1] = token
	end
	if #required == 0 then
		return false
	end
	local function tokensContainTarget(value)
		local present = {}
		for token in value:gmatch("%S+") do
			present[token] = true
		end
		for _, token in ipairs(required) do
			if not present[token] then
				return false
			end
		end
		return true
	end
	for name, quote, value in attributes:gmatch([[([A-Za-z0-9_:-]+)%s*=%s*(["'])(.-)%2]]) do
		if name:lower() == "class" and tokensContainTarget(value) then
			return true
		end
	end
	for name, value in attributes:gmatch([[([A-Za-z0-9_:-]+)%s*=%s*([^%s"'][^%s]*)]]) do
		if name:lower() == "class" and tokensContainTarget(value) then
			return true
		end
	end
	return false
end

---Locate the first target div: the position of its "<" and the position just
---after its opening ">".
---@param html string
---@param classToken? string Class token to match (default "FhSeeAlso").
---@return integer|nil tagStart, integer|nil afterOpenTag
local function locateDiv(html, classToken)
	classToken = classToken or CLASS_TOKEN
	local pos = 1
	while true do
		local tagStart = html:find("<[dD][iI][vV][%s/>]", pos)
		if not tagStart then
			return nil
		end
		-- Parse from just after "<div"; divOpen yields the attribute text and
		-- the position following ">".
		local attributes, afterTag = scanDivOpen(html, tagStart + 4)
		if attributes and hasSeeAlsoClass(attributes, classToken) then
			return tagStart, afterTag
		end
		pos = tagStart + 4
	end
end

---Find the first target div's opening tag.
---@param html string
---@param classToken? string Class token to match (default "FhSeeAlso").
---@return integer|nil afterOpenTag Position just after the opening tag's ">", or nil if none found.
local function findTargetDiv(html, classToken)
	local _, afterTag = locateDiv(html, classToken)
	return afterTag
end

---The position of the </div> that closes the div opened just before
---afterOpen, accounting for nested divs.
---@param html string
---@param afterOpen integer Position just after the target div's opening ">".
---@return integer|nil closeStart Position of the matching "</div>"'s "<", or nil if unbalanced.
local function matchingDivClose(html, afterOpen)
	local depth = 1
	local pos = afterOpen
	while true do
		local openStart = html:find("<[dD][iI][vV][%s/>]", pos)
		local closeStart = html:find("</[dD][iI][vV]%s*>", pos)
		if not closeStart then
			return nil -- no matching close
		end
		if openStart and openStart < closeStart then
			-- A nested opening div: skip past its tag and go deeper.
			local _, afterNested = scanDivOpen(html, openStart + 4)
			depth = depth + 1
			pos = afterNested or (openStart + 4)
		else
			depth = depth - 1
			if depth == 0 then
				return closeStart
			end
			pos = closeStart + 1
		end
	end
end

---Whether a page contains a target div at all. inject() reports the same
---condition via its error return; this read-only probe exists for tests and
---diagnostics.
---@param html string
---@param classToken? string Class token to match (default "FhSeeAlso").
---@return boolean
function M.hasTargetDiv(html, classToken)
	return findTargetDiv(html, classToken) ~= nil
end

---Remove the first div carrying classToken, in full (its opening tag, content
---and matching close). Independent of injection: a site can drop Family
---Historian's own "See also" box (default "FhSeeAlso") and place the chart in
---a different div instead.
---@param html string
---@param classToken? string Class token to match (default "FhSeeAlso").
---@return string newHtml The page with the div removed (unchanged if none found or unbalanced).
---@return boolean removed True when a div was removed.
function M.removeDiv(html, classToken)
	local tagStart, afterTag = locateDiv(html, classToken)
	if not tagStart then
		return html, false
	end
	local closeStart = matchingDivClose(html, afterTag)
	if not closeStart then
		-- Unbalanced markup: leave the page intact rather than guess.
		return html, false
	end
	local _, closeEnd = html:find("</[dD][iI][vV]%s*>", closeStart)
	closeEnd = closeEnd or closeStart
	return html:sub(1, tagStart - 1) .. html:sub(closeEnd + 1), true
end

--------------------------------------------------------------
-- BLOCK CONSTRUCTION AND INJECTION
--------------------------------------------------------------

---XML-escape the five reserved characters (for the optional caption text).
---@param text string
---@return string
local function escapeText(text)
	return (text:gsub('[&<>"\']', {
		["&"] = "&amp;",
		["<"] = "&lt;",
		[">"] = "&gt;",
		['"'] = "&quot;",
		["'"] = "&apos;",
	}))
end

---Wrap an inline SVG in the container used both for injection and the
---options-dialog preview, applying alignment, an optional caption, the fit
---mode and (optionally) a no-JavaScript collapsible. The inline styles avoid
---depending on the site's stylesheets; the outer div also carries a CSS class
---(default "fs-embed-chart") as a styling hook.
---  * default (scroll): natural-size SVG, the container scrolls horizontally;
---  * fit: the SVG is width="100%" and simply fills the container;
---  * hideable: the caption + chart sit in a <details> element (starts open),
---    so a reader can collapse the chart with no script. The caption becomes
---    the <summary> label (or "Family chart" when there is no caption).
---@param svg string An embedded-mode SVG.
---@param opts? FsEmbedOptions
---@return string
function M.wrapSvg(svg, opts)
	opts = opts or {}
	local alignMap = { left = "left", centre = "center", center = "center", right = "right" }
	local textAlign = alignMap[opts.align or "centre"] or "center"
	local outer = opts.fit and ("max-width:100%;text-align:" .. textAlign)
		or ("overflow:auto;max-width:100%;text-align:" .. textAlign)
	local containerClass = (opts.containerClass and opts.containerClass:match("%S")) and opts.containerClass
		or "fs-embed-chart"
	local captionText = (opts.caption and opts.caption:match("%S")) and opts.caption or nil

	local inner
	if opts.hideable then
		-- No-JavaScript collapsible: <details> starts open and the caption (or a
		-- default) labels the <summary>. Styling is delegated to the site's CSS
		-- (e.g. ".fs-embed-chart summary { ... }").
		local summary = captionText or "Family chart"
		inner = string.format("<details open><summary>%s</summary>%s</details>", escapeText(summary), svg)
	else
		local caption = ""
		if captionText then
			-- All caption styling (font, size, colour, alignment) is delegated to
			-- the site's CSS for this class; the plugin only emits the class.
			local captionClass = (opts.captionClass and opts.captionClass:match("%S")) and opts.captionClass
				or "fs-embed-caption"
			caption = string.format('<div class="%s">%s</div>', escapeText(captionClass), escapeText(captionText))
		end
		inner = caption .. svg
	end
	return string.format('<div class="%s" style="%s">%s</div>', escapeText(containerClass), outer, inner)
end

---The complete marker-wrapped block for one person.
---@param svg string An embedded-mode SVG.
---@param personId integer Raw FH record id.
---@param opts? FsEmbedOptions
---@return string
function M.makeBlock(svg, personId, opts)
	opts = opts or {}
	local marker = (opts.markerToken and opts.markerToken:match("%S")) and opts.markerToken or MARKER_TOKEN
	return string.format(
		"<!-- %s:start ind%d -->\n%s\n<!-- %s:end ind%d -->",
		marker,
		personId,
		M.wrapSvg(svg, opts),
		marker,
		personId
	)
end

---Lua-pattern-escape a literal string.
---@param text string
---@return string
local function escapePattern(text)
	return (text:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%0"))
end

---Find an existing marker block for a person.
---@param html string
---@param personId integer
---@param markerToken? string Marker label to match (default "FhSeeAlso"); must match makeBlock's.
---@return integer|nil blockStart, integer|nil blockEnd Inclusive bounds of the whole block.
local function findMarkerBlock(html, personId, markerToken)
	local marker = (markerToken and markerToken:match("%S")) and markerToken or MARKER_TOKEN
	local id = "ind" .. personId
	local startPattern = "<!%-%-%s*" .. escapePattern(marker) .. ":start%s+" .. escapePattern(id) .. "%s*%-%->"
	local endPattern = "<!%-%-%s*" .. escapePattern(marker) .. ":end%s+" .. escapePattern(id) .. "%s*%-%->"
	local blockStart = html:find(startPattern)
	if not blockStart then
		return nil
	end
	local _, blockEnd = html:find(endPattern, blockStart)
	if not blockEnd then
		-- A start marker without its end marker: treat as absent rather than
		-- guessing how much of the page to replace.
		return nil
	end
	return blockStart, blockEnd
end

---Inject (or re-inject) a person's chart into an HTML page.
---
---Placement (opts.placement):
---  * "replace" (default) - the target div's entire content becomes the
---    chart block: the chart appears instead of, not as well as, whatever
---    was there (re-running replaces it again - idempotent);
---  * "top" / "bottom" - the block is inserted at the start / end of the
---    div, keeping any other content; a re-run replaces the existing marker
---    block in place rather than adding another.
---@param html string The page HTML.
---@param svg string An embedded-mode SVG (natural size, no XML declaration).
---@param personId integer Raw FH record id.
---@param opts? FsEmbedOptions
---@return string|nil newHtml The updated page, or nil on failure.
---@return string|nil err Why injection was not possible.
function M.inject(html, svg, personId, opts)
	opts = opts or {}
	local placement = opts.placement or "replace"
	local classToken = opts.classToken or CLASS_TOKEN
	local block = M.makeBlock(svg, personId, opts)

	local afterTag = findTargetDiv(html, classToken)
	if not afterTag then
		return nil, 'no <div class="' .. classToken .. '"> found in the page'
	end
	local closeStart = matchingDivClose(html, afterTag)

	if placement == "replace" and closeStart then
		-- The div content becomes exactly the block; nothing accumulates.
		return html:sub(1, afterTag - 1) .. "\n" .. block .. "\n" .. html:sub(closeStart)
	end

	-- top/bottom (and replace's fallback when the div is unbalanced): keep
	-- other content; a re-run replaces this person's existing block in place.
	-- Match on this plugin's own marker so a re-run never touches another
	-- plugin's block in the same page.
	local blockStart, blockEnd = findMarkerBlock(html, personId, opts.markerToken)
	if blockStart then
		return html:sub(1, blockStart - 1) .. block .. html:sub(blockEnd + 1)
	end
	if placement == "bottom" and closeStart then
		return html:sub(1, closeStart - 1) .. block .. "\n" .. html:sub(closeStart)
	end
	-- "top" and the unbalanced fallback: just inside the opening tag.
	return html:sub(1, afterTag - 1) .. "\n" .. block .. html:sub(afterTag)
end

return M

end)()
--<<FS_SPLICE_END>>

--<<FS_SPLICE src/fh_adapter.lua FhAdapter>>
FhAdapter = (function()
--[[
@Title: fh_adapter
@Author: Helen Wright
@Description:
    Layer 1: the only FH-dependent code. Walks the Family Historian object
    model from one focal individual and emits a plain-data FsModel (schema in
    fs_options.lua) for the pure renderer.

    Responsibilities resolved here, so the renderer never needs FH:
      * names (fhGetDisplayText) and life dates (the LifeDates2 built-in,
        which yields a bare hyphen when no dates exist);
      * sex normalised to M/F/U;
      * parent-sets from the focal person's FAMC links, labelled by PEDI
        (empty PEDI means birth), birth-type sets ordered first; each set's
        siblings are that family's own children only, and focalIndex records
        the focal person's place among them (for the age-order slot);
      * a separate "other family" set for each recorded parent's other
        families that contain children - so half-siblings appear with both
        of their own parents rather than under the focal couple;
      * siblings classified full/half/step by counting the biological
        parents shared with the focal person (2+ = full, 1 = half, 0 = step);
      * spouse units from FAMS links, ordered by marriage/partnership date
        (an undated family takes its earliest child's birth) then as
        recorded, including unmarried and same-sex partnerships; an
        unrecorded partner yields spouse = nil with children kept;
      * children and siblings in the order recorded in Family Historian
        (users curate child order there; the plugin no longer re-sorts);
      * the linkable flag: true only for individuals in the caller's id set,
        never for the focal person.

    This file cannot run outside FH. Its output shape is checked outside FH
    against golden fixtures captured with dump_model.lua.
]]

local M = {}

--------------------------------------------------------------
-- LOW-LEVEL HELPERS
--------------------------------------------------------------

---Iterate the children items of a record/item with a given tag.
---MoveTo's second argument is a data reference, not a bare tag, so the tag
---is prefixed with "~." (relative to the starting item).
---@param ptrParent userdata Parent item pointer.
---@param tag string GEDCOM tag of the wanted children (e.g. "FAMC").
---@return fun(): userdata|nil iterator Yields a fresh clone per item.
local function eachChildItem(ptrParent, tag)
	local p = fhNewItemPtr()
	p:MoveTo(ptrParent, "~." .. tag)
	return function()
		if p:IsNull() then
			return nil
		end
		local current = p:Clone()
		p:MoveNext("SAME_TAG")
		return current
	end
end

---The linked record for a link item, or nil when the link is empty/broken.
---@param ptrLink userdata
---@return userdata|nil
local function linkedRecord(ptrLink)
	local ptr = fhGetValueAsLink(ptrLink)
	if ptr and ptr:IsNotNull() then
		return ptr
	end
	return nil
end

---Sex of an individual normalised to the model's M/F/U.
---@param ptrIndi userdata
---@return FsSexCode
local function sexOf(ptrIndi)
	local text = fhGetItemText(ptrIndi, "~.SEX") or ""
	local first = text:sub(1, 1):upper()
	if first == "M" or first == "F" then
		return first
	end
	return "U"
end

---A sortable number for the first date point of a date field, or nil when
---the field is missing or empty. BC years sort negative; missing month/day
---sort as the start of the period.
---@param ptrItem userdata Item the data reference is relative to.
---@param dataRef string e.g. "~.BIRT.DATE" or "~.MARR.DATE".
---@return number|nil
local function dateSortKey(ptrItem, dataRef)
	local ptrDate = fhGetItemPtr(ptrItem, dataRef)
	if ptrDate:IsNull() then
		return nil
	end
	local dt = fhGetValueAsDate(ptrDate)
	if dt:IsNull() then
		return nil
	end
	local dp = dt:GetDatePt1()
	if not dp or dp:IsNull() then
		return nil
	end
	local year = dp:GetYear()
	if dp:GetBC() then
		year = -year
	end
	return (year * 12 + math.max(dp:GetMonth(), 1)) * 32 + math.max(dp:GetDay(), 1)
end

---Stable in-place sort of {key:number|nil, i:integer, ...} entries: dated
---entries by date then recorded order; undated entries after them in
---recorded order.
---@param items {key: number|nil, i: integer}[]
local function sortDatedThenRecorded(items)
	table.sort(items, function(a, b)
		if a.key and b.key then
			if a.key ~= b.key then
				return a.key < b.key
			end
			return a.i < b.i
		end
		if a.key ~= nil then
			return true
		end
		if b.key ~= nil then
			return false
		end
		return a.i < b.i
	end)
end

--------------------------------------------------------------
-- MODEL BUILDING BLOCKS
--------------------------------------------------------------

---True if an individual carries the named record flag. Wrapped in pcall so an
---unknown flag name yields false rather than erroring the whole run.
---@param ptrIndi userdata
---@param flagName string
---@return boolean
local function hasFlag(ptrIndi, flagName)
	local ok, result = pcall(fhCallBuiltInFunction, "HasFlag", ptrIndi, flagName)
	return ok and result == true
end

---Build an FsPerson for an individual record.
---@param ptrIndi userdata
---@param focalId integer
---@param linkableIds table<integer, boolean>
---@param privacy? {omit?: boolean, basicFlag?: string} Privacy options; sets private/basicOnly.
---@return FsPerson
local function personFrom(ptrIndi, focalId, linkableIds, privacy)
	local id = fhGetRecordId(ptrIndi)
	local person = {
		id = id,
		name = fhGetDisplayText(ptrIndi),
		lifeDates = fhCallBuiltInFunction("LifeDates2", ptrIndi),
		sex = sexOf(ptrIndi),
		linkable = (id ~= focalId) and (linkableIds[id] == true),
	}
	if privacy then
		if privacy.omit and hasFlag(ptrIndi, "Private") then
			person.private = true
		end
		if privacy.basicFlag and privacy.basicFlag ~= "" and hasFlag(ptrIndi, privacy.basicFlag) then
			person.basicOnly = true
		end
	end
	return person
end

---The PEDI value of a FAMC link, lower-cased; "" when absent (= birth).
---@param ptrFamc userdata
---@return string
local function pediOf(ptrFamc)
	local p = fhNewItemPtr()
	p:MoveTo(ptrFamc, "~.PEDI")
	if p:IsNull() then
		return ""
	end
	return (fhGetValueAsText(p) or ""):lower()
end

---The recorded partner records of a family, in HUSB-then-WIFE order. FH7
---records same-sex couples with repeated HUSB or WIFE links, so partners are
---collected by position rather than by assuming one of each tag.
---@param ptrFam userdata
---@return userdata[]
local function partnersOf(ptrFam)
	local partners = {}
	for _, tag in ipairs({ "HUSB", "WIFE" }) do
		for link in eachChildItem(ptrFam, tag) do
			local indi = linkedRecord(link)
			if indi then
				partners[#partners + 1] = indi
			end
		end
	end
	return partners
end

---The children of a family in the order recorded in Family Historian.
---FH presents children in the curated record order (which usually is birth
---order, including people dated only by baptism); re-sorting by birth date
---here put undated and baptism-dated children in the wrong place.
---@param ptrFam userdata
---@return userdata[]
local function childrenOf(ptrFam)
	local children = {}
	for link in eachChildItem(ptrFam, "CHIL") do
		local indi = linkedRecord(link)
		if indi then
			children[#children + 1] = indi
		end
	end
	return children
end

---The ids of an individual's biological parents: the parents of every
---family the individual belongs to as a child with a birth PEDI.
---@param ptrIndi userdata
---@return table<integer, boolean>
local function biologicalParentIds(ptrIndi)
	local ids = {}
	for famc in eachChildItem(ptrIndi, "FAMC") do
		local pedi = pediOf(famc)
		if pedi == "" or pedi == "birth" then
			local fam = linkedRecord(famc)
			if fam then
				for _, parent in ipairs(partnersOf(fam)) do
					ids[fhGetRecordId(parent)] = true
				end
			end
		end
	end
	return ids
end

---Classify a sibling by the biological parents they share with the focal
---person: 2+ shared = full, 1 = half, 0 = step (connected only through this
---step/adoptive parent-set).
---@param ptrSibling userdata
---@param focalBioParents table<integer, boolean>
---@return "full"|"half"|"step"
local function siblingKind(ptrSibling, focalBioParents)
	local shared = 0
	for id in pairs(biologicalParentIds(ptrSibling)) do
		if focalBioParents[id] then
			shared = shared + 1
		end
	end
	if shared >= 2 then
		return "full"
	elseif shared == 1 then
		return "half"
	end
	return "step"
end

--------------------------------------------------------------
-- ENTRY POINT
--------------------------------------------------------------

---Build the FsModel for one focal individual.
---@param ptrFocal userdata INDI record pointer.
---@param linkableIds table<integer, boolean> Record ids that may be linked (the current selection).
---@param privacy? {omit?: boolean, basicFlag?: string} Privacy options (omit-Private, basic-details flag).
---@return FsModel
function M.buildModel(ptrFocal, linkableIds, privacy)
	local focalId = fhGetRecordId(ptrFocal)
	local focalBioParents = biologicalParentIds(ptrFocal)

	-- Parent-sets: one per FAMC link, birth-type sets first (stable).
	local setEntries = {}
	for famc in eachChildItem(ptrFocal, "FAMC") do
		local fam = linkedRecord(famc)
		if fam then
			local pedi = pediOf(famc)
			setEntries[#setEntries + 1] = {
				fam = fam,
				pedi = pedi,
				key = (pedi == "" or pedi == "birth") and 0 or 1,
				i = #setEntries + 1,
			}
		end
	end
	sortDatedThenRecorded(setEntries)

	-- Track who is already placed, so each sibling appears exactly once
	-- (under the first qualifying set) and the focal person never does.
	local placed = { [focalId] = true }

	-- The family records of the focal person's own sets, so a parent's
	-- other families can be told apart from them.
	local focalFamIds = {}
	for _, entry in ipairs(setEntries) do
		focalFamIds[fhGetRecordId(entry.fam)] = true
	end

	local parentSets = {}
	for _, entry in ipairs(setEntries) do
		local fam = entry.fam
		local partners = partnersOf(fam)

		-- The model carries up to two recorded parents per set. Same-sex
		-- couples fill the two slots in recorded order; an unrecorded
		-- parent is omitted entirely (nil), never a placeholder.
		local father, mother = partners[1], partners[2]

		-- Siblings: this family's own children, in recorded order. A
		-- parent's other families become separate sets below, so a child
		-- is never shown under a couple that is not their own parents.
		-- focalIndex counts the displayed siblings before the focal
		-- person's place in the recorded order (for the age-order slot).
		local siblings = {}
		local focalIndex = 0
		local beforeFocal = true
		for _, child in ipairs(childrenOf(fam)) do
			local id = fhGetRecordId(child)
			if id == focalId then
				beforeFocal = false
			elseif not placed[id] then
				placed[id] = true
				local sibling = personFrom(child, focalId, linkableIds, privacy)
				sibling.kind = siblingKind(child, focalBioParents)
				siblings[#siblings + 1] = sibling
				if beforeFocal then
					focalIndex = focalIndex + 1
				end
			end
		end

		parentSets[#parentSets + 1] = {
			pedi = entry.pedi,
			father = father and personFrom(father, focalId, linkableIds, privacy) or nil,
			mother = mother and personFrom(mother, focalId, linkableIds, privacy) or nil,
			siblings = siblings,
			focalIndex = focalIndex,
		}
	end

	-- Other families of each recorded parent (where half-siblings live):
	-- one extra set per family with children, showing both of that family's
	-- recorded parents.
	local emittedFamIds = {}
	for _, entry in ipairs(setEntries) do
		for _, parent in ipairs(partnersOf(entry.fam)) do
			for fams in eachChildItem(parent, "FAMS") do
				local otherFam = linkedRecord(fams)
				if otherFam then
					local otherFamId = fhGetRecordId(otherFam)
					if not focalFamIds[otherFamId] and not emittedFamIds[otherFamId] then
						emittedFamIds[otherFamId] = true
						local children = {}
						for _, child in ipairs(childrenOf(otherFam)) do
							local id = fhGetRecordId(child)
							if not placed[id] then
								placed[id] = true
								local sibling = personFrom(child, focalId, linkableIds, privacy)
								sibling.kind = siblingKind(child, focalBioParents)
								children[#children + 1] = sibling
							end
						end
						if #children > 0 then
							local otherPartners = partnersOf(otherFam)
							parentSets[#parentSets + 1] = {
								pedi = "",
								otherFamily = true,
								sharedParentId = fhGetRecordId(parent),
								sharedParentName = fhGetDisplayText(parent),
								sharedParentPrivate = (privacy and privacy.omit and hasFlag(parent, "Private")) or nil,
								father = otherPartners[1] and personFrom(otherPartners[1], focalId, linkableIds, privacy) or nil,
								mother = otherPartners[2] and personFrom(otherPartners[2], focalId, linkableIds, privacy) or nil,
								siblings = children,
							}
						end
					end
				end
			end
		end
	end

	-- Spouse units: one per FAMS link, ordered by marriage/partnership date
	-- then as recorded. A family with no marriage/partnership date sorts by
	-- its earliest child's birth instead, so undated relationships still
	-- land in chronological order. The partner may be unrecorded; children
	-- are kept either way.
	local unitEntries = {}
	for fams in eachChildItem(ptrFocal, "FAMS") do
		local fam = linkedRecord(fams)
		if fam then
			local famChildren = childrenOf(fam)
			local key = dateSortKey(fam, "~.MARR.DATE")
			if not key then
				-- Children are in recorded order, so scan them all for the
				-- earliest dated birth.
				for _, child in ipairs(famChildren) do
					local childKey = dateSortKey(child, "~.BIRT.DATE")
					if childKey and (not key or childKey < key) then
						key = childKey
					end
				end
			end
			unitEntries[#unitEntries + 1] = {
				fam = fam,
				children = famChildren,
				key = key,
				i = #unitEntries + 1,
			}
		end
	end
	sortDatedThenRecorded(unitEntries)

	local spouseUnits = {}
	for _, entry in ipairs(unitEntries) do
		local spouse
		for _, partner in ipairs(partnersOf(entry.fam)) do
			if fhGetRecordId(partner) ~= focalId then
				spouse = partner
				break
			end
		end

		local children = {}
		for _, child in ipairs(entry.children) do
			children[#children + 1] = personFrom(child, focalId, linkableIds, privacy)
		end

		spouseUnits[#spouseUnits + 1] = {
			spouse = spouse and personFrom(spouse, focalId, linkableIds, privacy) or nil,
			children = children,
		}
	end

	return {
		focal = personFrom(ptrFocal, focalId, linkableIds, privacy),
		parentSets = parentSets,
		spouseUnits = spouseUnits,
	}
end

return M

end)()
--<<FS_SPLICE_END>>

--<<FS_SPLICE fixtures/sample_theming.lua SampleTheming>>
SampleTheming = (function()
--[[
@Title: sample_theming
@Author: Helen Wright
@Description:
    A small, symmetric FsModel used for previewing colour schemes, sizing and
    shape options. Deliberately free of edge cases: one birth parent-set, two
    full siblings (one of each sex), one opposite-sex spouse, two children (one
    of each sex), every node carrying life dates. Balanced so the five colour
    presets and the shape-by-sex / shape-by-relationship options look even.

    This is the data structure the pure SVG renderer consumes. See the schema
    (FsModel and friends) in the project prompt. All strings are raw UTF-8; the
    renderer is responsible for XML-escaping them.
]]

---@type FsModel
return {
	focal = {
		id = 1001,
		name = "Eleanor Hartley",
		lifeDates = "1850-1921",
		sex = "F",
		linkable = false, -- focal person is never linked
	},

	parentSets = {
		{
			pedi = "", -- empty == birth
			father = {
				id = 1010,
				name = "George Hartley",
				lifeDates = "1820-1888",
				sex = "M",
				linkable = true,
			},
			mother = {
				id = 1011,
				name = "Margaret Hartley",
				lifeDates = "1824-1899",
				sex = "F",
				linkable = true,
			},
			siblings = {
				{
					id = 1020,
					name = "Thomas Hartley",
					lifeDates = "1848-1910",
					sex = "M",
					linkable = true,
					kind = "full",
				},
				{
					id = 1021,
					name = "Alice Hartley",
					lifeDates = "1853-1934",
					sex = "F",
					linkable = true,
					kind = "full",
				},
			},
		},
	},

	spouseUnits = {
		{
			spouse = {
				id = 1030,
				name = "William Ashford",
				lifeDates = "1847-1915",
				sex = "M",
				linkable = true,
			},
			children = {
				{
					id = 1040,
					name = "Edward Ashford",
					lifeDates = "1872-1944",
					sex = "M",
					linkable = true,
				},
				{
					id = 1041,
					name = "Charlotte Ashford",
					lifeDates = "1875-1951",
					sex = "F",
					linkable = true,
				},
			},
		},
	},
}

end)()
--<<FS_SPLICE_END>>

--<<FS_SPLICE fixtures/sample_family.lua SampleFamily>>
SampleFamily = (function()
--[[
@Title: sample_family
@Author: Helen Wright
@Description:
    A deliberately awkward FsModel that exercises every branch of the renderer
    and injector. The focal person, Søren O'Brien-Müller, is constructed to cover:

      * Multiple parent-sets distinguished by PEDI:
          - a birth set (both parents recorded), focalIndex placing the
            focal person between two siblings (the age-order slot)
          - an adopted set (both parents recorded)
          - a step set with ONE parent omitted (mother only)
          - an "other family" set (a parent's family with another partner),
            which gets its own column and no focal connector
      * Siblings grouped under their parent-set, tagged full / half / step.
      * Spouse units covering:
          - a married opposite-sex union with children (birth order)
          - an unmarried partnership with one child
          - a same-sex partnership with an adopted child
          - a union whose partner is unrecorded (children grouped under focal)
      * Missing people omitted (no placeholder nodes).
      * LifeDates2 hyphen-only output ("-") where no dates exist.
      * UTF-8 names: accents, apostrophe, CJK.
      * One adversarial name forcing XML escaping of & and <.
      * linkable = false on the focal person and on people outside the selection,
        so the renderer must suppress links on those nodes.

    All strings are raw UTF-8. The renderer must XML-escape & < > " ' on output
    and must not corrupt multi-byte characters. See the FsModel schema in the
    project prompt.
]]

---@type FsModel
return {
	focal = {
		id = 2000,
		name = "Søren O'Brien-Müller",
		lifeDates = "1862-1937",
		sex = "M",
		linkable = false,
	},

	parentSets = {
		-- Birth: both parents recorded; full + half siblings. The focal
		-- person's recorded place is after Liam (focalIndex 2 of 3).
		{
			pedi = "birth",
			focalIndex = 2,
			father = {
				id = 2010,
				name = "Patrick O'Brien",
				lifeDates = "1835-1901",
				sex = "M",
				linkable = true,
			},
			mother = {
				id = 2011,
				name = "Zoé Müller",
				lifeDates = "1840-1888",
				sex = "F",
				linkable = true,
			},
			siblings = {
				{
					id = 2020,
					name = "Bridget O'Brien",
					lifeDates = "1860-1942",
					sex = "F",
					linkable = true,
					kind = "full",
				},
				{
					-- born after focal; tests birth-order sort
					id = 2021,
					name = "Liam O'Brien",
					lifeDates = "1865-1939",
					sex = "M",
					linkable = true,
					kind = "full",
				},
				{
					-- father's child by another partner: half-sibling
					id = 2022,
					name = "Niamh O'Brien",
					lifeDates = "1871-",
					sex = "F",
					linkable = true,
					kind = "half",
				},
			},
		},

		-- Adopted: both parents recorded; a step-sibling (no shared blood).
		{
			pedi = "adopted",
			focalIndex = 1,
			father = {
				id = 2030,
				name = "李 Wei",
				lifeDates = "1838-1910",
				sex = "M",
				linkable = true,
			},
			mother = {
				id = 2031,
				name = "Æthelflæd Defoe",
				lifeDates = "1842-1909",
				sex = "F",
				linkable = false, -- not in the selection: no link
			},
			siblings = {
				{
					id = 2032,
					name = "Jean-François Defoe",
					lifeDates = "1859-1921",
					sex = "M",
					linkable = true,
					kind = "step",
				},
			},
		},

		-- Step: only the mother is recorded (father omitted, not a placeholder).
		{
			pedi = "step",
			focalIndex = 0,
			father = nil,
			mother = {
				id = 2040,
				name = "Síle O'Brien",
				lifeDates = "-", -- LifeDates2 with no dates
				sex = "F",
				linkable = true,
			},
			siblings = {
				{
					id = 2041,
					name = "Cormac O'Brien",
					lifeDates = "1869-1944",
					sex = "M",
					linkable = true,
					kind = "step",
				},
			},
		},

		-- A parent's other family: Síle's child by another partner gets a
		-- column of its own showing both of that family's parents, so the
		-- child is never shown under a couple that is not their parents.
		{
			pedi = "",
			otherFamily = true,
			sharedParentId = 2040,
			sharedParentName = "Síle O'Brien",
			father = {
				id = 2052,
				name = "Dáithí Walsh",
				lifeDates = "1850-1920",
				sex = "M",
				linkable = false,
			},
			mother = {
				id = 2040,
				name = "Síle O'Brien",
				lifeDates = "-",
				sex = "F",
				linkable = true,
			},
			siblings = {
				{
					id = 2053,
					name = "Orla Walsh",
					lifeDates = "1875-1950",
					sex = "F",
					linkable = false,
					kind = "step",
				},
			},
		},
	},

	spouseUnits = {
		-- Married, opposite sex, children in birth order.
		{
			spouse = {
				id = 2100,
				name = "Eleanor Ashford",
				lifeDates = "1866-1948",
				sex = "F",
				linkable = true,
			},
			children = {
				{
					id = 2110,
					name = "Conor O'Brien-Müller",
					lifeDates = "1888-1961",
					sex = "M",
					linkable = true,
				},
				{
					id = 2111,
					name = "Aoife O'Brien-Müller",
					lifeDates = "1890-1975",
					sex = "F",
					linkable = true,
				},
			},
		},

		-- Unmarried partnership with one child.
		{
			spouse = {
				id = 2120,
				name = "Mary Sinclair",
				lifeDates = "1870-1953",
				sex = "F",
				linkable = true,
			},
			children = {
				{
					id = 2130,
					name = "Ruth Sinclair",
					lifeDates = "1895-1980",
					sex = "F",
					linkable = true,
				},
			},
		},

		-- Same-sex partnership with an adopted child.
		{
			spouse = {
				id = 2140,
				name = "James Holloway",
				lifeDates = "1864-1929",
				sex = "M",
				linkable = true,
			},
			children = {
				{
					id = 2150,
					name = "Theo Holloway",
					lifeDates = "1902-1988",
					sex = "M",
					linkable = true,
				},
			},
		},

		-- Partner unrecorded: spouse omitted, child grouped under the focal person.
		-- Adversarial name deliberately contains & and < to force XML escaping.
		{
			spouse = nil,
			children = {
				{
					id = 2160,
					name = "Tom <of Marks & Spencer> O'Brien",
					lifeDates = "1899-",
					sex = "M",
					linkable = true,
				},
			},
		},
	},
}

end)()
--<<FS_SPLICE_END>>

--------------------------------------------------------------
--SETTINGS CONFIGURATION
--------------------------------------------------------------

-- Display labels for the option lists, mapped to the internal keys the
-- renderer understands.
---@type table<string, FsPresetKey>
local PRESET_KEYS = {
	["Classic"] = "classic",
	["High Contrast"] = "highcontrast",
	["Match System Theme"] = "system",
}
---@type table<string, FsEncoding>
local ENCODING_KEYS = {
	["Theme (uniform)"] = "theme",
	["Sex"] = "sex",
	["Relationship"] = "relationship",
}
-- Lowercase values are also accepted, for options saved by earlier plugin versions.
---@type table<string, FsLayoutChoice>
local LAYOUT_KEYS = {
	["Wide"] = "wide",
	["Compact"] = "compact",
	["wide"] = "wide",
	["compact"] = "compact",
}
---@type table<string, FsParentFamilies>
local PARENT_FAMILY_KEYS = {
	["All parent families"] = "all",
	["Birth families only"] = "birth",
	["First family only"] = "first",
}
---@type table<string, FsShape>
local SHAPE_KEYS = {
	["Rounded"] = "rounded",
	["Rectangle"] = "rect",
	["Ellipse"] = "ellipse",
	["Octagon"] = "octagon",
}
-- Web-safe stacks, unquoted (CSS treats space-separated identifiers as one
-- family name, and unquoted names survive XML/HTML escaping untouched).
local FONT_CUSTOM_LABEL = "Custom..."
---@type table<string, string>
local FONT_STACKS = {
	["Verdana"] = "Verdana, Arial, Helvetica, sans-serif",
	["Arial"] = "Arial, Helvetica, sans-serif",
	["Tahoma"] = "Tahoma, Geneva, Verdana, sans-serif",
	["Trebuchet MS"] = "Trebuchet MS, Tahoma, sans-serif",
	["Georgia"] = "Georgia, Times New Roman, serif",
	["Times New Roman"] = "Times New Roman, Times, serif",
	["Palatino"] = "Palatino Linotype, Book Antiqua, Palatino, serif",
	["Garamond"] = "Garamond, Times New Roman, serif",
	["Courier New"] = "Courier New, Courier, monospace",
}
local FONT_LABELS = {
	"Verdana",
	"Arial",
	"Tahoma",
	"Trebuchet MS",
	"Georgia",
	"Times New Roman",
	"Palatino",
	"Garamond",
	"Courier New",
	FONT_CUSTOM_LABEL,
}

---Resolve the Font option (plus the custom text field) to a CSS font-family.
---@param fontLabel any
---@param custom any
---@return string
local function resolveFontFamily(fontLabel, custom)
	if fontLabel == FONT_CUSTOM_LABEL then
		custom = tostring(custom or "")
		if custom:match("%S") then
			return custom
		end
	end
	return FONT_STACKS[tostring(fontLabel)] or FONT_STACKS.Verdana
end

--------------------------------------------------------------
--SAVED COLOUR THEMES
--------------------------------------------------------------
-- Saved themes live in the plugin INI: "Themes/_list" holds the names
-- ("|" separated, in saved order); "Themes/<name>" holds the colours as
-- "key=#rrggbb" pairs (";" separated). They are read directly (rather than
-- through Config) because the scheme list must be built before Config.new.

local THEME_CUSTOM_LABEL = "Custom (colours below)"
local THEME_COLOUR_KEYS = {
	"background",
	"textColour",
	"lineColour",
	"borderColour",
	"linkColour",
	"fillColour",
	"fillM",
	"fillF",
	"fillU",
	"focalFill",
	"focalStroke",
	-- Relationship-category fills, added after 1.0's initial release; an
	-- older saved theme simply has none of these keys, and paletteFromColours
	-- leaves the corresponding fields nil so completePalette falls back to
	-- the Classic scheme's relationship colours (its usual missing-value
	-- fallback) rather than erroring or drawing blank boxes.
	"relParent",
	"relSibling",
	"relSpouse",
	"relChild",
}

local configFilePath = fhGetPluginDataFileName(getConfigScope(), true)
	.. "\\"
	.. fhGetContextInfo("CI_PLUGIN_NAME")
	.. ".ini"

---Read a text value from the plugin INI, tolerating a missing file.
---@param section string
---@param key string
---@param default string
---@return string
local function iniGetText(section, key, default)
	local ok, result = pcall(fhGetIniFileValue, configFilePath, section, key, "text", default)
	if ok and type(result) == "string" then
		return result
	end
	return default
end

---The saved theme names, in saved order.
---@return string[]
local function savedThemeNames()
	local names = {}
	for name in iniGetText("Themes", "_list", ""):gmatch("[^|]+") do
		names[#names + 1] = name
	end
	return names
end

---Build a renderer customPalette from colour-field values (raw "#rrggbb"
---strings keyed as in THEME_COLOUR_KEYS). Caption/tag text is derived from
---the text and background colours; anything missing degrades to Classic.
---@param colours table<string, any>
---@return table customPalette
local function paletteFromColours(colours)
	local palette = {
		background = colours.background,
		text = colours.textColour,
		line = colours.lineColour,
		stroke = colours.borderColour,
		link = colours.linkColour,
		defaultFill = colours.fillColour,
		focalFill = colours.focalFill,
		focalStroke = colours.focalStroke,
		sex = { M = colours.fillM, F = colours.fillF, U = colours.fillU },
		relationship = { parent = colours.relParent, sibling = colours.relSibling, spouse = colours.relSpouse, child = colours.relChild },
	}
	if type(colours.textColour) == "string" and type(colours.background) == "string" then
		local ok, mixed = pcall(FsOptions.mix, colours.textColour, colours.background, 0.35)
		if ok then
			palette.labelText = mixed
		end
	end
	return palette
end

---Load a saved theme as a renderer customPalette, or nil when unknown.
---@param name string
---@return table|nil
local function loadSavedTheme(name)
	local raw = iniGetText("Themes", name, "")
	if raw == "" then
		return nil
	end
	local colours = {}
	for key, value in raw:gmatch("([A-Za-z0-9_]+)=([^;]+)") do
		colours[key] = value
	end
	return paletteFromColours(colours)
end

---The Colour scheme list: built-in presets, saved themes, then Custom.
---@return string[]
local function schemeOptions()
	local options = { "Classic", "High Contrast", "Match System Theme" }
	for _, name in ipairs(savedThemeNames()) do
		options[#options + 1] = name
	end
	options[#options + 1] = THEME_CUSTOM_LABEL
	return options
end

---Resolve a scheme display name (built-in preset or saved theme) to a full
---palette, or nil for the Custom entry / unknown names.
---@param displayName string
---@return FsPalette|nil
local function schemePalette(displayName)
	local key = PRESET_KEYS[displayName]
	if key then
		return FsOptions.palette(FsOptions.merge({ preset = key, systemColours = Theme.systemColours() }))
	end
	if displayName ~= THEME_CUSTOM_LABEL then
		local saved = loadSavedTheme(tostring(displayName or ""))
		if saved then
			return FsOptions.completePalette(saved)
		end
	end
	return nil
end

---Map a resolved palette onto the colour-field values.
---@param palette FsPalette
---@return table<string, string> values keyed by colour-field key
local function colourFieldValues(palette)
	return {
		background = palette.background,
		textColour = palette.text,
		lineColour = palette.line or palette.stroke,
		borderColour = palette.stroke,
		linkColour = palette.link,
		fillColour = palette.defaultFill,
		fillM = palette.sex.M,
		fillF = palette.sex.F,
		fillU = palette.sex.U,
		focalFill = palette.focalFill,
		focalStroke = palette.focalStroke,
		relParent = palette.relationship.parent,
		relSibling = palette.relationship.sibling,
		relSpouse = palette.relationship.spouse,
		relChild = palette.relationship.child,
	}
end

---When the user picks a scheme or saved theme, load its colours into the
---colour fields; the chart always renders from those fields, so the choice
---previews immediately and any field can then be tweaked.
---@param displayName string
---@param api ConfigDialogApi
local function onSchemeChange(displayName, api)
	local palette = schemePalette(displayName)
	if not palette then
		return -- Custom: keep the fields as they are
	end
	for key, value in pairs(colourFieldValues(palette)) do
		api.setValue("Colours and Shapes", key, value)
	end
end

---True if a record flag with the given name exists in the project. Uses
---fhGetFlagTag with create=false; pcall-guarded so it never errors a run.
---@param flagName string
---@return boolean
local function flagExists(flagName)
	local ok, tag = pcall(fhGetFlagTag, flagName, false)
	return ok and type(tag) == "string" and tag ~= ""
end

local defaultConfig = {
	title = "Add Trees Options",
	sections = {
		{
			title = "Chart",
			fields = {
				{
					key = "layout",
					label = "Layout:",
					type = "list",
					default = "Wide",
					options = { "Wide", "Compact" },
					description = "Wide: one row per generation, scrolling horizontally when needed. Compact: siblings and children wrap onto extra rows so the chart is much narrower.",
				},
				{
					key = "parentFamilies",
					label = "Parent families:",
					type = "list",
					default = "All parent families",
					options = { "All parent families", "Birth families only", "First family only" },
					description = "Which of the focus person's parent families are charted.",
				},
				{
					key = "lifeDates",
					label = "Include life dates:",
					type = "boolean",
					default = true,
					description = "Show (birth-death) years after each name.",
				},
				{
					key = "links",
					label = "Clickable links:",
					type = "boolean",
					default = true,
					description = "Link each charted relative to their own website page.",
				},
				{
					key = "nodeSize",
					label = "Box size:",
					type = "number",
					default = 140,
					description = "Minimum box width in SVG pixels (40-600); boxes grow to fit long names.",
				},
				{
					key = "fontSize",
					label = "Font size:",
					type = "number",
					default = 12,
					description = "Label size in SVG pixels (6-48); all spacing scales with it.",
				},
				{
					key = "fontFamily",
					label = "Font:",
					type = "list",
					default = "Verdana",
					options = FONT_LABELS,
					description = "Web-safe font for chart labels - themed to match the target website, not this machine.",
				},
				{
					key = "fontCustom",
					label = "Custom font:",
					type = "text",
					default = "",
					description = 'Any CSS font-family list, used when Font is "Custom..." - e.g. Lato, Verdana, sans-serif',
				},
				{
					key = "sizeMode",
					label = "Size:",
					type = "list",
					default = "Scale to percentage",
					options = { "Fit to page width", "Scale to percentage", "Exact width" },
					description = "How the chart is sized. Fit to page width fills the container (embedded charts only). Scale to percentage uses Image size. Exact width sets a fixed pixel width with proportional height - handy for reports.",
				},
				{
					key = "imageScale",
					label = "Image size (%):",
					type = "number",
					default = 100,
					description = "Scales the finished image (25-400) when Size is 'Scale to percentage'. 100 is the natural size set by the box and font sizes.",
				},
				{
					key = "exactWidth",
					label = "Exact width (px):",
					type = "number",
					default = 800,
					description = "Width in pixels when Size is 'Exact width' (100-4000); the height follows the chart's proportions.",
				},
			},
		},
		{
			title = "Colours and Shapes",
			fields = {
				{
					key = "preset",
					label = "Colour scheme:",
					type = "list",
					default = "Classic",
					options = schemeOptions(),
					liveChange = onSchemeChange,
					description = "Picking a scheme or saved theme loads its colours into the fields below; the chart always uses those fields, so you can tweak any of them. Match System Theme loads your Windows colours.",
				},
				{
					key = "colourBy",
					label = "Colour indicates:",
					type = "list",
					default = "Theme (uniform)",
					options = { "Theme (uniform)", "Sex", "Relationship" },
					description = "What the box fill colour encodes.",
				},
				{
					key = "shapeBy",
					label = "Shape indicates:",
					type = "list",
					default = "Theme (uniform)",
					options = { "Theme (uniform)", "Sex", "Relationship" },
					description = "What the box shape encodes. Shape by sex is the colour-blind-safe alternative to colour by sex.",
				},
				{
					key = "baseShape",
					label = "Box shape:",
					type = "list",
					default = "Rounded",
					options = { "Rounded", "Rectangle", "Ellipse", "Octagon" },
					description = "Box outline used when Shape indicates is Theme (uniform).",
				},
				-- The chart always renders from the colours below, whichever scheme is chosen -
				-- picking a scheme or saved theme just loads its colours into these fields, so
				-- any of them can then be tweaked (and captured again by Save Colours as Theme).
				{
					key = "lineColour",
					label = "Lines:",
					type = "colour",
					default = "#4a6785",
					description = "Connector line colour.",
				},
				{
					key = "borderColour",
					label = "Box borders:",
					type = "colour",
					default = "#4a6785",
					description = "Box border colour.",
				},
				{
					key = "fillColour",
					label = "Box fill:",
					type = "colour",
					default = "#e7eef5",
					description = "Box fill when Colour indicates is Theme (uniform).",
				},
				{
					key = "fillM",
					label = "Box fill (male):",
					type = "colour",
					default = "#cfe3f5",
					description = "Box fill for men when Colour indicates is Sex.",
				},
				{
					key = "fillF",
					label = "Box fill (female):",
					type = "colour",
					default = "#fbe4ec",
					description = "Box fill for women when Colour indicates is Sex.",
				},
				{
					key = "fillU",
					label = "Box fill (unknown):",
					type = "colour",
					default = "#e8e8e8",
					description = "Box fill for unknown sex when Colour indicates is Sex.",
				},
				{
					key = "relParent",
					label = "Box fill (parents):",
					type = "colour",
					default = "#d9e7f5",
					description = "Box fill for parents when Colour indicates is Relationship.",
				},
				{
					key = "relSibling",
					label = "Box fill (siblings):",
					type = "colour",
					default = "#e4f0e2",
					description = "Box fill for siblings when Colour indicates is Relationship.",
				},
				{
					key = "relSpouse",
					label = "Box fill (spouses/partners):",
					type = "colour",
					default = "#f5ecd9",
					description = "Box fill for spouses/partners when Colour indicates is Relationship.",
				},
				{
					key = "relChild",
					label = "Box fill (children):",
					type = "colour",
					default = "#ece2f5",
					description = "Box fill for children when Colour indicates is Relationship.",
				},
				{
					key = "textColour",
					label = "Box text:",
					type = "colour",
					default = "#1a1a1a",
					description = "Label text colour.",
				},
				{
					key = "linkColour",
					label = "Link text:",
					type = "colour",
					default = "#1d4ed8",
					description = "Text colour of clickable names.",
				},
				{
					key = "focalFill",
					label = "Focus box fill:",
					type = "colour",
					default = "#ffe9a8",
					description = "The focus person's box fill, so they stand out.",
				},
				{
					key = "focalStroke",
					label = "Focus box border:",
					type = "colour",
					default = "#8a6d1f",
					description = "The focus person's box border.",
				},
				{
					key = "background",
					label = "Background:",
					type = "colour",
					default = "#ffffff",
					description = "Chart background colour.",
				},
			},
		},
		{
			title = "Privacy",
			fields = {
				{
					key = "omitPrivate",
					label = "Omit individuals flagged 'Private':",
					type = "boolean",
					default = true,
					description = "Leave out anyone whose record carries Family Historian's 'Private' flag - no name, no box, no reference anywhere in the chart. Mirrors FH's own website privacy.",
				},
				{
					key = "basicDetailsEnabled",
					label = "Basic details only:",
					type = "boolean",
					default = true,
					description = "For individuals carrying the flag below, show names and relationships only - no life dates. (FH's 'basic details' also drops events and attributes, which this chart never shows anyway.)",
				},
				{
					key = "basicDetailsFlag",
					label = "...for this flag:",
					type = "text",
					default = "Living",
					description = "The exact name of a record flag in your project (e.g. Living). People with this flag show basic details only. You are warned at once if you type a flag that does not exist in this project.",
					onBlur = function(value)
						value = tostring(value or "")
						if value ~= "" and not flagExists(value) then
							MessageBox(
								"warning",
								"There is no record flag called '"
									.. value
									.. "' in this project, so basic-details privacy will not apply to anyone. Check the name (Edit > Record Flags)."
							)
						end
					end,
				},
			},
		},
		{
			title = "Output",
			fields = {
				{
					key = "outputFolder",
					label = "SVG output folder:",
					type = "folder",
					default = cstrDefaultOutputFolder,
					root = function()
						return cstrProjectPublicFolder
					end,
					rootLabel = "Project Public Folder",
					description = "One ind<id>.svg file is written here per charted individual.",
				},
				{
					key = "embed",
					label = "Embed in website:",
					type = "boolean",
					default = false,
					description = "Also inject each chart inline into ind<id>.html in the website folder.",
				},
				{
					key = "websiteFolder",
					label = "Website folder:",
					type = "folder",
					default = "",
					root = function()
						return cstrProjectPublicFolder
					end,
					rootLabel = "Project Public Folder",
					description = "The folder holding the Family Historian generated website pages (ind<id>.html).",
				},
				{
					key = "embedAlign",
					label = "Alignment:",
					type = "list",
					default = "Centre",
					options = { "Centre", "Left", "Right" },
					description = "How the chart sits within the page container.",
				},
				{
					key = "embedCaption",
					label = "Caption:",
					type = "text",
					default = "",
					description = "Optional heading shown above the embedded chart (e.g. \"See also\"). Leave blank for none.",
				},
				{
					key = "embedHideable",
					label = "Collapsible chart:",
					type = "boolean",
					default = false,
					description = "Wrap the embedded chart in a no-JavaScript expander (HTML <details>). It starts open; readers can collapse it. The Caption becomes the expander's label.",
				},
			},
		},
		{
			title = "Advanced",
			fields = {
				{
					key = "pagePrefix",
					label = "Individual page prefix:",
					type = "text",
					default = "ind",
					description = "The filename prefix your website gives each individual's page. A standard Family Historian website uses 'ind' (so ind123.html); some site generators use a different prefix such as 'indi'. Used for the chart's clickable links and, when embedding, to find each person's page.",
				},
				{
					key = "embedDivClass",
					label = "Target div class:",
					type = "text",
					default = "FhSeeAlso",
					description = "The class of the page container the chart is injected into. Leave as FhSeeAlso unless your website template uses a different one.",
				},
				{
					key = "embedPlacement",
					label = "Placement:",
					type = "list",
					default = "Replace div contents",
					options = { "Replace div contents", "At top of div", "At bottom of div" },
					description = "Replace the div's contents with the chart, or keep other content and add the chart at the top or bottom.",
				},
				{
					key = "embedRemoveDiv",
					label = "Remove a section:",
					type = "boolean",
					default = false,
					description = "Before embedding, delete a section from the page (e.g. Family Historian's own \"See also\" box) so the chart can sit in a different div. Set Target div class to that other div.",
				},
				{
					key = "embedRemoveDivClass",
					label = "Section to remove:",
					type = "text",
					default = "FhSeeAlso",
					description = "The class of the section deleted when 'Remove a section' is on. Ignored if it matches the Target div class.",
				},
				{
					key = "embedContainerClass",
					label = "Chart CSS class:",
					type = "text",
					default = "fs-embed-chart",
					description = "CSS class put on the chart's wrapper div, so you can style it (borders, spacing, background) in your website's stylesheet. Alignment is still set by the Alignment option.",
				},
				{
					key = "embedCaptionClass",
					label = "Caption CSS class:",
					type = "text",
					default = "fs-embed-caption",
					description = "The CSS class given to the caption. Style it (font, size, colour, alignment) in your website's stylesheet; the plugin only applies the class.",
				},
			},
		},
	},
}

-- Per-project options; for a standalone GEDCOM (no project) the scope falls
-- back to the per-machine folder so options still persist somewhere sane.
local myConfig = Config.new(defaultConfig, getConfigScope(), fhGetContextInfo("CI_PLUGIN_NAME") .. ".ini")

-- One-off migration to the fields-are-the-palette model: settings saved by
-- earlier versions hold a scheme name but no meaningful colour fields, so
-- seed the fields from that scheme once. (On a fresh install this writes
-- the Classic values the fields already default to.)
if myConfig:getValue("Colours and Shapes", "customColoursInitialised", false) ~= true then
	local savedScheme = myConfig:getValue("Colours and Shapes", "preset", "Classic")
	local palette = schemePalette(tostring(savedScheme))
	if palette then
		myConfig:setValues("Colours and Shapes", nil, colourFieldValues(palette))
	end
	myConfig:setValues("Colours and Shapes", nil, { customColoursInitialised = true })
end

-- A second one-off migration: relationship-colour fields (relParent etc.)
-- were added after the fields-are-the-palette migration above, so anyone
-- who already ran it has none of these keys yet. Seed them from the
-- currently-selected scheme once, exactly as the first migration did for
-- the original colour fields, so upgrading does not silently start
-- rendering relationship colours from Classic regardless of scheme.
if myConfig:getValue("Colours and Shapes", "relationshipColoursInitialised", false) ~= true then
	local savedScheme = myConfig:getValue("Colours and Shapes", "preset", "Classic")
	local palette = schemePalette(tostring(savedScheme))
	if palette then
		myConfig:setValues("Colours and Shapes", nil, {
			relParent = palette.relationship.parent,
			relSibling = palette.relationship.sibling,
			relSpouse = palette.relationship.spouse,
			relChild = palette.relationship.child,
		})
	end
	myConfig:setValues("Colours and Shapes", nil, { relationshipColoursInitialised = true })
end

-- A third one-off migration: High Contrast's link colour changed from the
-- Okabe-Ito blue (#0072b2, too close in luminance to that scheme's own
-- fills) to black. Anyone with High Contrast selected and still holding
-- exactly the old blue value gets it swapped for the new default once;
-- anyone who has since customised their link colour is left alone.
if myConfig:getValue("Colours and Shapes", "highContrastLinkColourMigrated", false) ~= true then
	local savedScheme = myConfig:getValue("Colours and Shapes", "preset", "Classic")
	if tostring(savedScheme) == "High Contrast"
		and myConfig:getValue("Colours and Shapes", "linkColour", "") == "#0072b2" then
		myConfig:setValues("Colours and Shapes", nil, { linkColour = "#000000" })
	end
	myConfig:setValues("Colours and Shapes", nil, { highContrastLinkColourMigrated = true })
end

-- A fourth one-off migration: High Contrast's siblings fill changed from #009e73 (6.14:1 contrast
-- with black text - WCAG AA only) to #00aa7b, a lighter tint of the same bluish-green hue that
-- reaches 7.0:1 (WCAG AAA), matching every other High Contrast fill. Anyone with High Contrast
-- selected and still holding exactly the old value gets it swapped once; anyone who has since
-- customised their siblings colour is left alone. A separate flag from the link-colour migration
-- above, since a machine may already have run that one without having this fix.
if myConfig:getValue("Colours and Shapes", "highContrastSiblingsColourMigrated", false) ~= true then
	local savedScheme = myConfig:getValue("Colours and Shapes", "preset", "Classic")
	if tostring(savedScheme) == "High Contrast"
		and myConfig:getValue("Colours and Shapes", "relSibling", "") == "#009e73" then
		myConfig:setValues("Colours and Shapes", nil, { relSibling = "#00aa7b" })
	end
	myConfig:setValues("Colours and Shapes", nil, { highContrastSiblingsColourMigrated = true })
end

---Convert raw option values (saved or live from the dialog) into the
---renderer's option table plus the plugin-level output settings.
---@param raw table<string, table<string, any>> Values keyed by section title then field key.
---@return FsRenderOptions renderOptions
---@return {folder: string, embed: boolean, websiteFolder: string, pagePrefix: string, placement: string, align: string, fit: boolean, divClass: string, caption: string, captionClass: string, containerClass: string, hideable: boolean, removeDiv: boolean, removeDivClass: string, privacy: {omit: boolean, basicFlag: string?}} output
local EMBED_PLACEMENT_KEYS = {
	["Replace div contents"] = "replace",
	["At top of div"] = "top",
	["At bottom of div"] = "bottom",
}
local EMBED_ALIGN_KEYS = { ["Centre"] = "centre", ["Left"] = "left", ["Right"] = "right" }
local EMBED_SIZE_KEYS = { ["Scale to percentage"] = "scale", ["Fit to page width"] = "fit", ["Exact width"] = "exact" }

local function toRenderOptions(raw)
	local chart = raw["Chart"] or {}
	local colours = raw["Colours and Shapes"] or {}
	local output = raw["Output"] or {}
	local advanced = raw["Advanced"] or {}
	local privacySection = raw["Privacy"] or {}
	local sizeMode = EMBED_SIZE_KEYS[chart.sizeMode] or "scale"
	-- Page-name prefix for individual website pages (e.g. ind123.html, or
	-- indi123.html for some site generators); used by chart links and embedding.
	local pagePrefix = (tostring(advanced.pagePrefix or ""):gsub("[^A-Za-z0-9_%-]", ""))
	if pagePrefix == "" then
		pagePrefix = "ind"
	end
	-- Privacy options for the adapter: omit-Private, and the basic-details flag
	-- (only when the toggle is on and a non-blank flag name is given).
	local privacyOpts = { omit = privacySection.omitPrivate == true }
	if privacySection.basicDetailsEnabled == true then
		local flag = tostring(privacySection.basicDetailsFlag or "")
		if flag:match("%S") then
			privacyOpts.basicFlag = flag
		end
	end

	-- The chart always renders from the colour fields ("custom" palette);
	-- the scheme dropdown's job is loading a preset or saved theme INTO
	-- those fields (see onSchemeChange), so any colour the user picks takes
	-- effect immediately, no theme-saving required.
	local renderOptions = {
		preset = "custom",
		customPalette = paletteFromColours(colours),
		colourBy = ENCODING_KEYS[colours.colourBy] or "theme",
		shapeBy = ENCODING_KEYS[colours.shapeBy] or "theme",
		baseShape = SHAPE_KEYS[colours.baseShape] or "rounded",
		nodeSize = tonumber(chart.nodeSize),
		fontSize = tonumber(chart.fontSize),
		imageScale = tonumber(chart.imageScale),
		fontFamily = resolveFontFamily(chart.fontFamily, chart.fontCustom),
		pagePrefix = pagePrefix,
		layout = LAYOUT_KEYS[chart.layout] or "wide",
		parentFamilies = PARENT_FAMILY_KEYS[chart.parentFamilies] or "all",
		links = chart.links,
		lifeDates = chart.lifeDates,
		sizeMode = sizeMode,
		exactWidth = tonumber(chart.exactWidth),
	}
	return renderOptions, {
		folder = tostring(output.outputFolder or ""),
		embed = output.embed == true,
		websiteFolder = tostring(output.websiteFolder or ""),
		pagePrefix = pagePrefix,
		-- Embedding presentation options (see html_inject FsEmbedOptions).
		placement = EMBED_PLACEMENT_KEYS[advanced.embedPlacement] or "replace",
		align = EMBED_ALIGN_KEYS[output.embedAlign] or "centre",
		fit = sizeMode == "fit",
		divClass = (tostring(advanced.embedDivClass or ""):match("%S") and tostring(advanced.embedDivClass)) or "FhSeeAlso",
		caption = tostring(output.embedCaption or ""),
		captionClass = (tostring(advanced.embedCaptionClass or ""):match("%S") and tostring(advanced.embedCaptionClass))
			or "fs-embed-caption",
		containerClass = (tostring(advanced.embedContainerClass or ""):match("%S") and tostring(advanced.embedContainerClass))
			or "fs-embed-chart",
		hideable = output.embedHideable == true,
		removeDiv = advanced.embedRemoveDiv == true,
		removeDivClass = (tostring(advanced.embedRemoveDivClass or ""):match("%S") and tostring(advanced.embedRemoveDivClass))
			or "FhSeeAlso",
		privacy = privacyOpts,
	}
end

---Save the dialog's current colour fields as a named theme in the plugin
---INI, and refresh the open dialog's Colour scheme list so the new theme
---appears (selected) straight away.
---@param values table<string, table<string, any>> Live dialog values.
---@param api ConfigDialogApi Live-dialog API for the in-place list refresh.
local function saveColoursAsTheme(values, api)
	local colours = values["Colours and Shapes"] or {}
	local ok, name = GetText({
		strPrompt = "Save Colours as Theme",
		strLabel = "Theme name:",
		strDefault = "",
	})
	if not ok then
		return
	end
	-- The name becomes an INI key and a list entry, so keep it plain.
	name = tostring(name or ""):gsub("[|;=%[%]]", "-"):gsub("^%s+", ""):gsub("%s+$", "")
	if name == "" then
		return
	end
	if PRESET_KEYS[name] or name == THEME_CUSTOM_LABEL or name == "_list" then
		MessageBox("warning", 'The name "' .. name .. '" is reserved. Choose another name.')
		return
	end
	local parts = {}
	for _, key in ipairs(THEME_COLOUR_KEYS) do
		parts[#parts + 1] = key .. "=" .. tostring(colours[key] or "")
	end
	local existing = savedThemeNames()
	local known = false
	for _, themeName in ipairs(existing) do
		if themeName == name then
			known = true
			break
		end
	end
	if known and MessageBox("question", 'Theme "' .. name .. '" already exists. Replace it?', "YESNO") ~= "Yes" then
		return
	end
	local okWrite = pcall(fhSetIniFileValue, configFilePath, "Themes", name, "text", table.concat(parts, ";"))
	if okWrite and not known then
		existing[#existing + 1] = name
		okWrite = pcall(fhSetIniFileValue, configFilePath, "Themes", "_list", "text", table.concat(existing, "|"))
	end
	if not okWrite then
		MessageBox("error", "Could not save the theme to:\n" .. configFilePath)
		return
	end
	-- Refresh the scheme list in place and select the new theme.
	api.setListOptions("Colours and Shapes", "preset", schemeOptions(), name)
end

---Delete the saved theme currently chosen in the Colour scheme list. Built-in
---presets and the Custom entry cannot be deleted.
---@param values table<string, table<string, any>> Live dialog values.
---@param api ConfigDialogApi Live-dialog API for the in-place list refresh.
local function deleteTheme(values, api)
	local colours = values["Colours and Shapes"] or {}
	local name = tostring(colours.preset or "")
	if name == "" or PRESET_KEYS[name] or name == THEME_CUSTOM_LABEL then
		MessageBox(
			"info",
			"To delete a theme, choose it in the Colour scheme list first.\n\n"
				.. "Built-in schemes and Custom cannot be deleted."
		)
		return
	end
	if MessageBox("question", 'Delete the saved theme "' .. name .. '"?', "YESNO") ~= "Yes" then
		return
	end
	-- Drop it from the name list and clear its stored value.
	local kept = {}
	for _, themeName in ipairs(savedThemeNames()) do
		if themeName ~= name then
			kept[#kept + 1] = themeName
		end
	end
	local ok = pcall(fhSetIniFileValue, configFilePath, "Themes", "_list", "text", table.concat(kept, "|"))
	pcall(fhSetIniFileValue, configFilePath, "Themes", name, "text", "")
	if not ok then
		MessageBox("error", "Could not update the themes in:\n" .. configFilePath)
		return
	end
	-- Refresh the list (the theme is gone) and fall back to Classic, loading
	-- its colours into the fields so the dialog is consistent.
	api.setListOptions("Colours and Shapes", "preset", schemeOptions(), "Classic")
	onSchemeChange("Classic", api)
end

---Read the saved configuration as raw section/key values.
---@return table<string, table<string, any>>
local function savedRawValues()
	local raw = {}
	for _, section in ipairs(defaultConfig.sections) do
		raw[section.title] = {}
		for _, field in ipairs(section.fields) do
			raw[section.title][field.key] = myConfig:getValue(section.title, field.key, field.default)
		end
	end
	return raw
end

--<<FS_SPLICE "../../2 Boilerplate/Results.lua">>
;(function()
--[[
Results.lua - shared Results class for an FH plugin's end-of-run outcome
@Author: Helen Wright
@Version: 1.1
@LastUpdated: 23 July 2026
@V1.1: NoResults's message is now suppressible - NoResults(nil) (or "") makes Display()
       show nothing on zero rows instead of an "informational" fhMessageBox. A consumer
       that never calls NoResults, or that sets a string as before, is unaffected: the
       default message is unchanged and the box only disappears when a plugin opts in
       with a nil/empty message. Family-wide UX policy (23 Jul 2026 beta discussion): an
       idle close (no work done) should be silent - the old "No X were Y"-style farewell
       boxes are noise now that the run's outcome lands in FH's Result Set window anyway.
]]

---Results class for managing the results display within an FH plugin
---@param intTableCount integer Number of columns in the results table
---@return table Results object with methods for managing result display
function Results(intTableCount)
	---Local shallow copy helper - creates a copy of a table without deep copying nested structures
	---This is used to safely copy configuration arrays without creating references
	---@param tbl table Table to copy
	---@return table Shallow copy of the input table
	local function shallow_copy(tbl)
		local t = {}
		for k, v in pairs(tbl) do
			t[k] = v
		end
		return t
	end

	--public methods and associated private state variables
	local iRes = 0 -- index used to track results - counts how many result rows we have
	local strTitle = "" -- stores the title for the results window
	local strNoResults = "" -- stores the message to display when there are no results
	local tblResults = {} --table of results tables - stores all the data for each column
	local tblVisibility = {} -- controls which columns are visible in the results
	local tblSort = {} -- defines the sort order for each column
	local tblResultHeadings = {} -- stores the column headers
	local tblResultType = {} -- defines the data type for each column (text, integer, item, etc.)
	local tblResultWidth = {} -- defines the width for each column

	-- Initialize the results tables - each table will hold one column of data
	-- This creates separate arrays for each column to store the data efficiently
	for i = 1, intTableCount do
		tblResults[i] = {}
	end

	---Update function: adds a new row of results to the display
	---tblNewResults should contain one value for each column
	---This is the main method for adding data to the results
	---@param tblNewResults table Array of values for the new row
	local Update = function(tblNewResults)
		iRes = iRes + 1 -- increment the result counter
		for i, v in ipairs(tblNewResults) do
			tblResults[i][iRes] = v -- store each value in its appropriate column
		end
	end

	---Title function: sets the title for the results window
	---This will be displayed at the top of the results window
	---@param str string Title for the results window
	local Title = function(str)
		strTitle = str
	end

	---NoResults function: sets the message to display when there are no results
	---This provides user feedback when no links are found
	---@param str string Message to display when there are no results
	local NoResults = function(str)
		strNoResults = str
	end

	---Types function: defines the data type for each column
	---Types can be: "text", "integer", "item", "date", etc.
	---This affects how FH displays and sorts the data
	---@param types table Array of data types for each column
	local Types = function(types)
		tblResultType = shallow_copy(types)
	end

	---Headings function: sets the column headers
	---These are the labels that appear at the top of each column
	---@param headings table Array of column header strings
	local Headings = function(headings)
		tblResultHeadings = shallow_copy(headings)
	end

	---Visibility function: controls which columns are shown
	---Values can be "show" or "hide"
	---"buddy" makes a column invisible but keeps it for sorting purposes
	---@param visibility table Array of visibility settings for each column
	local Visibility = function(visibility)
		tblVisibility = shallow_copy(visibility)
	end

	---Sort function: defines the sort order for each column
	---Lower numbers = higher priority in sorting
	---This determines the default sort order when results are displayed
	---@param sort table Array of sort priorities for each column
	local Sort = function(sort)
		tblSort = shallow_copy(sort)
	end

	---Width function: defines the width for each column
	---@param width table Array of widths for each column
	local Width = function(width)
		tblResultWidth = shallow_copy(width)
	end

	---Display function: outputs all collected results to Family Historian's result window
	---This is the final step that shows all the collected data to the user
	local Display = function()
		if iRes > 0 then -- there are results to display
			-- Set the window title
			fhOutputResultSetTitles(strTitle)

			-- Output each column with its configuration
			-- This creates the actual result set in FH's display
			for i, _ in ipairs(tblResults) do
				fhOutputResultSetColumn(
					tblResultHeadings[i], -- Column header
					tblResultType[i], -- Data type
					tblResults[i], -- The data for this column
					iRes, -- Number of rows
					tblResultWidth[i] or 80, -- Column width or 80 if not set
					"align_left", -- Text alignment
					tblSort[i] or 0, -- Sort priority (0 = not part of the initial sort; nil crashes fhOutputResultSetColumn)
					true, -- Sortable
					"default", -- Sort direction
					tblVisibility[i] -- Visibility setting
				)
			end
			-- Update the display to show the results
			fhUpdateDisplay()
		elseif strNoResults ~= nil and strNoResults ~= "" then
			-- No results found - show informational message (suppressed if NoResults(nil)/(""))
			fhMessageBox(strNoResults, "MB_OK", "MB_ICONINFORMATION")
		end
		-- else: zero rows and no message set - silent idle close (NoResults(nil) opt-in)
	end

	--expose public methods - return an object with all the public functions
	-- This creates the public interface for the Results class
	return {
		Title = Title,
		Headings = Headings,
		Visibility = Visibility,
		Types = Types,
		Update = Update,
		Display = Display,
		Sort = Sort,
		NoResults = NoResults,
		Width = Width,
	}
end

end)()
--<<FS_SPLICE_END>>

--<<FS_SPLICE "../../2 Boilerplate/MenuBar.lua">>
;(function()
--[[
Enhanced MenuBar Helper Function
Creates an IUP menu bar with flexible File menu and standard Help menu.
The File menu accepts any custom items plus automatically adds Cancel/Exit.
Manages visibility of UI elements associated with menu items.
Includes dynamic state management, menu item registries, and title updates.

@Author: Helen Wright (ColeValleyGirl)
@Version: 2.1
@LastUpdated: 24 September 2026
@Description: Enhanced helper function for creating IUP menu bars with dynamic state management

VERSION HISTORY:
- 2.1 (24 September 2026): Added MenuBar.helpers.closeDialog to work around the FH host
  swallowing a MENU callback's returned iup.CLOSE on a MainLoop-shown dialog; the automatic
  File > Exit item now uses it instead of `return iup.CLOSE` (see USAGE note 7 below).

USAGE EXAMPLES:

1. Basic Menu Creation:
   local menuBarData = MenuBar.createMenuBar({
       items = {
           MenuBar.helpers.createMenuItem("&Options", function(self)
               showOptions()
               return iup.DEFAULT
           end)
       }
   })
   local dialog = makeDialog(content, { menu = menuBarData.menuBar })

2. Dynamic Menu Updates:
   -- Update menu item title
   menuBarData.updateTitle("myMenuItem", "New Title")
   
   -- Update menu item value (for checkable items)
   menuBarData.updateValue("myCheckItem", "ON")
   
   -- Update menu item active state
   menuBarData.updateActive("myMenuItem", "NO")
   
   -- Update all items in a registry group
   menuBarData.updateRegistryGroup("targets", function(key, menuItem)
       menuItem.active = isSupported(key) and "YES" or "NO"
   end)

3. Registry System:
   local menuBarData = MenuBar.createMenuBar({
       registries = {
           targets = {},    -- Group for target menu items
           modes = {},      -- Group for mode menu items
           noteTypes = {}   -- Group for note type menu items
       },
       items = {
           MenuBar.helpers.createSubmenu("&Targets", {
               MenuBar.helpers.createMenuItem("Select: &Individuals", 
                   function(self) selectTargets("INDI") end, 
                   nil, "indiTarget")  -- registryKey
           }, "targetsMenu")
       }
   })

4. Radio Button Groups:
   MenuBar.helpers.createRadioMenuItem("&Option 1", currentValue, "option1", 
       function() return changeValue("option1") end, nil, "radioOption1")

5. File Menu with Custom Items:
   Keys are iterated in sorted order, so choose key names that sort into
   the menu order you want (e.g. "aSave" before "bExport"):
   fileMenu = {
       aSave = MenuBar.helpers.createMenuItem("&Save", function(self)
           saveData()
           return iup.DEFAULT
       end, nil, "menuSave"),
       bExport = MenuBar.helpers.createMenuItem("&Export", function(self)
           exportData()
           return iup.DEFAULT
       end, nil, "menuExport")
   }

6. Keyboard Accelerators (shortcuts that fire from anywhere in the window):
   Pass createMenuItem an `accel = { key = <iup key code>, text = "<display>" }` (5th arg):
   - `text` adds the shortcut to the menu, right-aligned (e.g. "Save    Ctrl+S").
   - `key` (e.g. iup.K_cS, iup.K_F5) ALSO binds it window-globally.
   - Give `text` only (omit `key`) to DISPLAY a shortcut that stays bound elsewhere
     (e.g. Del handled by a list's own k_any - so it doesn't fire while editing a field).
   createMenuBar collects the bound ones into menuBarData.accelerators; hand that to makeDialog,
   which dispatches them from the dialog's k_any:

       local mb = MenuBar.createMenuBar({ items = {
           MenuBar.helpers.createSubmenu("&Edit", {
               MenuBar.helpers.createMenuItem("&Save", onSave, nil, "save", { key = iup.K_cS, text = "Ctrl+S" }),
               MenuBar.helpers.createMenuItem("&Delete", onDelete, nil, nil, { text = "Del" }), -- display only
           }),
       }})
       local dlg = makeDialog(content, { menu = mb.menuBar, accelerators = mb.accelerators })

   updateTitle re-appends the accelerator text automatically, so a dynamic title keeps its shortcut.
   Caveat: one key auto-binds to one action. If the SAME shortcut appears on two items with different
   actions (e.g. a "select" that means different things per view), give both items `text` only and pass
   your own view-aware { key, action } table as makeDialog's accelerators (see Add Maps' mainAccelerators).

7. Closing a Dialog from a Menu Action:
   Family Historian's host swallows a MENU callback's `return iup.CLOSE` on a dialog shown via
   showxy/showTrackedDialog + iup.MainLoop (the title-bar X, Esc and buttons are unaffected -
   they go through IUP's own machinery, not a menu action). MenuBar's own automatic File > Exit
   item accounts for this already, but ANY of your own menu actions that need to close such a
   dialog must do the same - call MenuBar.helpers.closeDialog(self) instead of `return
   iup.CLOSE`. This does not apply to a dialog shown with popup() (Options-style dialogs), where
   `return iup.CLOSE` from a menu action works as normal - though closeDialog is safe there too:
       MenuBar.helpers.createMenuItem("E&xit", function(self)
           return MenuBar.helpers.closeDialog(self)
       end)

REGISTRY SYSTEM:
- Use registryKey to register menu items for dynamic updates
- Use registryGroup to organize related menu items
- Access registered items via menuBarData.menuItems[key]
- Update groups via menuBarData.updateRegistryGroup(groupKey, updateFunction)

DYNAMIC UPDATES:
- updateTitle(key, newTitle): Change menu item title
- updateValue(key, "ON"/"OFF"): Update checkable item state
- updateActive(key, "YES"/"NO"): Enable/disable menu item
- updateRegistryGroup(groupKey, updateFunction): Batch update group items

HELPER FUNCTIONS:
- MenuBar.helpers.createMenuItem(title, action, capture, registryKey, accel)  -- accel = { key?, text? } (see 6 above)
- MenuBar.helpers.createRadioMenuItem(title, currentValue, expectedValue, action, capture, registryKey)
- MenuBar.helpers.createSubmenu(title, items, registryKey)
- MenuBar.helpers.closeDialog(self_or_dialog)  -- close a MainLoop-shown dialog from a menu action (see 7 above)

RETURNS (menuBarData): { menuBar, menuItems, registries, accelerators, updateTitle, updateValue,
   updateActive, updateRegistryGroup }. Pass `accelerators` to makeDialog's `options.accelerators`.
]]

---@class menuItem
---@field title string Menu item title (include & for mnemonic key e.g. "&File" for Alt+F)
---@field action? function Callback function when menu item is selected (receives self, returns iup.DEFAULT or iup.CLOSE)
---@field active? string "YES" or "NO" - whether the item is enabled
---@field value? string "ON" or "OFF" for checkable items; defaults to "OFF"
---@field ui? iup.element UI element to show when menu item is selected
---@field isPopup? boolean If true, show UI element as popup instead of embedded
---@field submenu? menuItem[] Submenu items if this is a submenu
---@field beforeUI? boolean Execute action before (true) or after (false) showing UI element (defaults to false)
---@field capture? fun(instance:iup.element) Optional callback to capture the created iup.item/submenu instance
---@field registryKey? string Optional key for registering this menu item for dynamic updates
---@field registryGroup? string Optional group key for organizing menu items in registries
---@field titleFunction? function Optional function to compute dynamic title
---@field stateFunction? function Optional function to compute dynamic state (active/value)
---@field accel? {key?: integer, text?: string} Accelerator: `text` shows the shortcut in the menu; `key` (an iup key code) also binds it window-globally (via makeDialog's options.accelerators)
--- Mnemonics: use '&' in titles (e.g., "&File")

---@class menuBarOptions
---@field items? menuItem[] Additional menu items to insert between File and Help menus
---@field fileMenu? table<string, menuItem> Custom File menu items (keys iterated in sorted order - name them to sort into the menu order you want; a "cancel" or "exit" key suppresses the automatic Exit item)
---@field helpMenu? {help?: menuItem, about?: menuItem} Customizations for Help menu items
---@field registries? table<string, table> Optional registries for grouping menu items (e.g., {targets = {}, modes = {}})

---@class MenuBarData
---@field updateTitle fun(key: string, title: string)
---@field updateValue fun(key: string, value: string)
---@field updateActive fun(key: string, active: string)
---@field updateRegistryGroup fun(groupKey: string, updateFunction: function)
---@field menuBar any
---@field menuItems table
---@field registries table
---@field accelerators {key: integer, action: function, item: any}[] Window-global accelerators; pass to makeDialog as options.accelerators

local M = {}

--- Creates a menu bar with flexible File menu and standard Help menu
---@param options menuBarOptions Configuration for the menu bar
---@return table Created menu bar with enhanced functionality
M.createMenuBar = function(options)
	-- Local helper function to find an element in a table
	local function findInTable(tbl, value)
		for _, v in ipairs(tbl) do
			if v == value then
				return true
			end
		end
		return false
	end
	options = options or {}

	-- Track UI elements for visibility management
	local allUIElements = {}

	-- Menu item registries for dynamic updates
	local menuRegistries = options.registries or {}
	local menuItems = {} -- Store all menu items for dynamic updates

	-- Window-global accelerators declared via item.accel = { key = <iup key code>, text = "Ctrl+I" }.
	-- Each { key, action } is collected here and returned as menuBarData.accelerators for the caller to
	-- hand to makeDialog (options.accelerators), which dispatches them from the dialog's k_any so the
	-- shortcut fires from anywhere in the window - not only when a particular control has focus. An accel
	-- with text but NO key shows the shortcut in the menu without binding it (e.g. Del kept list-scoped).
	local accelerators = {}
	-- Accel display text per registryKey, so updateTitle can re-append "\tCtrl+I" when a dynamic title changes.
	local accelTextByKey = {}

	-- Compose a menu title with its accelerator display text, e.g. "&Clear List" + "Ctrl+T" -> "&Clear List\tCtrl+T".
	local function titleWithAccel(title, accel)
		if accel and accel.text and accel.text ~= "" then
			return title .. "\t" .. accel.text
		end
		return title
	end

	--- Controls visibility and floating state of UI elements
	---@param uiElements table<number,iup.element> List of UI elements to manage
	---@param activeElement iup.element|nil Element to make visible, or nil to hide all
	local function manageUIVisibility(uiElements, activeElement)
		-- Update visibility and floating state of all elements
		for _, element in pairs(uiElements) do
			if element == activeElement then
				element.visible = "YES"
				element.floating = "NO" -- Place in normal layout flow
			else
				element.visible = "NO"
				element.floating = "YES" -- Ready for future display
			end
		end

		-- Refresh dialog to update layout
		if activeElement then
			local dialog = iup.GetDialog(activeElement)
			if dialog then
				iup.Refresh(dialog)
			end
		end
	end

	--- Creates a menu item with UI handling
	---@param item menuItem The menu item configuration
	---@return table IUP menu item configuration
	local function createMenuItem(item)
		if not item.title then
			return {} -- separator
		end

		-- Handle submenu
		if item.submenu then
			local submenuItems = {}
			for _, subItem in ipairs(item.submenu) do
				table.insert(submenuItems, createMenuItem(subItem))
			end
			local submenu = iup.submenu({
				iup.menu(submenuItems),
				title = item.title,
				active = item.active,
			})
			if item.capture then
				item.capture(submenu)
			end

			-- Register submenu if registry key provided
			if item.registryKey then
				menuItems[item.registryKey] = submenu
			end

			return submenu
		end

		-- Create regular menu item. The accelerator display text (if any) is appended to the title so it
		-- shows right-aligned in the menu (IUP splits on the tab).
		local menuItem = iup.item({
			title = titleWithAccel(item.title, item.accel),
			active = item.active,
			value = item.value,
		})
		if item.capture then
			item.capture(menuItem)
		end

		-- Register menu item if registry key provided
		if item.registryKey then
			menuItems[item.registryKey] = menuItem

			-- Remember its accel text so updateTitle can re-append it when the title changes dynamically.
			if item.accel and item.accel.text then
				accelTextByKey[item.registryKey] = item.accel.text
			end

			-- Also add to specific registry if provided
			if item.registryGroup and menuRegistries[item.registryGroup] then
				menuRegistries[item.registryGroup][item.registryKey] = menuItem
			end
		end

		-- Handle action and UI
		if item.action or item.ui then
			menuItem.action = function(self)
				local result = iup.DEFAULT

				-- Execute pre-UI action if specified
				if item.action and item.beforeUI then
					result = item.action(self)
					if result == iup.CLOSE then
						return result
					elseif result == iup.IGNORE then
						return result
					end
				end

				-- Handle UI element display
				if item.ui then
					if item.isPopup then
						-- Show as popup (modal by default)
						item.ui.floating = "YES"
						item.ui:popup()
					else
						-- Show embedded
						if not findInTable(allUIElements, item.ui) then
							table.insert(allUIElements, item.ui)
						end
						manageUIVisibility(allUIElements, item.ui)
					end
				end

				-- Execute post-UI action (default behavior)
				if item.action and not item.beforeUI then
					result = item.action(self)
				end

				return result
			end
		end

		-- Register a window-global accelerator when the item declares one with a key. It reuses the item's
		-- own (wrapped) action, so firing the shortcut behaves exactly like clicking the menu item.
		if item.accel and item.accel.key and menuItem.action then
			accelerators[#accelerators + 1] = { key = item.accel.key, action = menuItem.action, item = menuItem }
		end

		return menuItem
	end

	-- Define flexible File menu with custom items + automatic Cancel/Exit.
	-- Keys are iterated in sorted order so the menu is deterministic
	-- (pairs() order varies between runs); callers choose key names that
	-- sort into the order they want.
	local fileMenuItems = {}
	if options.fileMenu then
		local keys = {}
		for key in pairs(options.fileMenu) do
			table.insert(keys, key)
		end
		table.sort(keys)
		for _, key in ipairs(keys) do
			-- Add separator before close/exit items
			if (key == "close" or key == "exit" or key == "cancel") and #fileMenuItems > 0 then
				table.insert(fileMenuItems, {}) -- separator
			end
			table.insert(fileMenuItems, createMenuItem(options.fileMenu[key]))
		end
	end

	-- Always add Cancel/Exit as the last item(s) in File menu
	if options.fileMenu and not options.fileMenu.exit and not options.fileMenu.cancel then
		-- Only add separator if there are custom items
		if #fileMenuItems > 0 then
			table.insert(fileMenuItems, {}) -- separator
		end
		table.insert(
			fileMenuItems,
			createMenuItem({
				title = "E&xit",
				action = function(self)
					return M.helpers.closeDialog(self)
				end,
			})
		)
	end
	local fileMenu = iup.submenu({
		iup.menu(fileMenuItems),
		title = "&File",
	})

	-- Define the standard Help menu. About is added only when the caller supplies
	-- one (e.g. the main window's version box). There is no default About: the
	-- old default opened a non-existent "about" help page, which surfaced on
	-- the Options dialog (it supplies only Help).
	local helpItemDef = options.helpMenu and options.helpMenu.help or {
		title = "&Help",
		action = function(self)
			if Help then
				Help.new({}):show("")
			end
			return iup.DEFAULT
		end,
	}
	local helpMenu
	if options.helpMenu and options.helpMenu.about then
		-- Help + About: a proper Help submenu holding both items.
		local helpItems = {
			createMenuItem(helpItemDef),
			{}, -- separator
			createMenuItem(options.helpMenu.about),
		}
		helpMenu = iup.submenu({
			iup.menu(helpItems),
			title = "&Help",
		})
	else
		-- Only Help to show: promote it to a top-level menu-bar item rather than
		-- burying a lone "Help" item inside a "Help" submenu. A top-level iup.item
		-- fires its action directly on click (and via Alt+H from the mnemonic).
		helpMenu = createMenuItem(helpItemDef)
	end

	-- Build menu items array
	local menuItemsArray = { fileMenu }

	-- Add custom items between File and Help
	if options.items then
		for _, item in ipairs(options.items) do
			table.insert(menuItemsArray, createMenuItem(item))
		end
	end

	-- Add Help menu
	table.insert(menuItemsArray, helpMenu)

	-- Create the menu bar
	local menuBar = iup.menu(menuItemsArray)

	-- Set a handle for the menu bar for global access
	iup.SetHandle("mainmenu", menuBar)

	-- Return enhanced menu bar with dynamic update capabilities
	return {
		menuBar = menuBar,
		menuItems = menuItems,
		registries = menuRegistries,
		-- Window-global accelerators ({ key, action, item }); pass to makeDialog as options.accelerators.
		accelerators = accelerators,

		--- Update menu item title dynamically. Re-appends the item's accelerator display text (if any),
		--- so a dynamic title change doesn't drop the "\tCtrl+I" suffix the menu shows.
		---@param key string Registry key of the menu item
		---@param title string New title (without the accelerator suffix)
		updateTitle = function(key, title)
			if menuItems[key] then
				local accelText = accelTextByKey[key]
				menuItems[key].title = accelText and (title .. "\t" .. accelText) or title
			end
		end,

		--- Update menu item active state dynamically
		---@param key string Registry key of the menu item
		---@param active string "YES" or "NO"
		updateActive = function(key, active)
			if menuItems[key] then
				menuItems[key].active = active
			end
		end,

		--- Update menu item value (for checkable items) dynamically
		---@param key string Registry key of the menu item
		---@param value string "ON" or "OFF"
		updateValue = function(key, value)
			if menuItems[key] then
				menuItems[key].value = value
			end
		end,

		--- Update all menu items in a registry group
		---@param groupKey string Registry group key
		---@param updateFunction function Function to call for each menu item in the group
		updateRegistryGroup = function(groupKey, updateFunction)
			if menuRegistries[groupKey] then
				for key, menuItem in pairs(menuRegistries[groupKey]) do
					updateFunction(key, menuItem)
				end
			end
		end,

		--- Refresh all dynamic menu items (call title/state functions)
		refreshDynamicItems = function()
			for key, menuItem in pairs(menuItems) do
				-- This would need to be enhanced to call titleFunction and stateFunction
				-- if they were stored during creation
			end
		end,
	}
end

-- Helper functions for common menu patterns
M.helpers = {}

--- Create a radio button group menu item
---@param title string Menu item title
---@param currentValue any Current value to check against
---@param expectedValue any Expected value for ON state
---@param action function Action function
---@param capture? function Capture function
---@param registryKey? string Registry key for dynamic updates
---@return menuItem Menu item definition
M.helpers.createRadioMenuItem = function(title, currentValue, expectedValue, action, capture, registryKey)
	return {
		title = title,
		action = action,
		capture = capture,
		value = (currentValue == expectedValue) and "ON" or "OFF",
		registryKey = registryKey,
	}
end

--- Create a simple menu item
---@param title string Menu item title
---@param action function Action function
---@param capture? function Capture function
---@param registryKey? string Registry key for dynamic updates
---@param accel? {key?: integer, text?: string} Accelerator: `text` shows the shortcut in the menu (e.g. "Ctrl+I"); `key` (an iup key code, e.g. iup.K_cI) also binds it window-globally via the dialog's accelerators. Give `text` only (no `key`) to display a shortcut that stays bound elsewhere (e.g. Del on a list).
---@return menuItem Menu item definition
M.helpers.createMenuItem = function(title, action, capture, registryKey, accel)
	return {
		title = title,
		action = action,
		capture = capture,
		registryKey = registryKey,
		accel = accel,
	}
end

--- Create a submenu with items
---@param title string Submenu title
---@param items menuItem[] Array of menu items
---@param registryKey? string Registry key for dynamic updates
---@return menuItem Submenu definition
M.helpers.createSubmenu = function(title, items, registryKey)
	return {
		title = title,
		submenu = items,
		registryKey = registryKey,
	}
end

--- Close a dialog from a menu item's action. Family Historian's host swallows a MENU callback's
--- returned iup.CLOSE on a dialog shown via showxy/showTrackedDialog + iup.MainLoop (the title-bar
--- X, Esc and buttons are unaffected - see this file's header USAGE notes), so a menu action that
--- needs to close such a dialog must call this instead of `return iup.CLOSE`.
---
--- Accepts either the menu item (`self`, resolved to its owning dialog via iup.GetDialog) or the
--- dialog itself. Fires the dialog's close_cb - exactly once, since hide() does not itself fire
--- close_cb - and honours a veto: if close_cb returns iup.IGNORE (e.g. a plugin refusing to close
--- mid-batch-operation), the dialog is left open. Otherwise the dialog is hidden: hiding the last
--- visible dialog ends iup.MainLoop, and hiding a popup dialog ends its own popup loop, so this is
--- safe for a MainLoop-shown dialog and a popup dialog alike.
---@param self_or_dialog iup.item|iup.dialog Menu item whose action is closing the dialog, or the dialog itself
---@return integer iup.DEFAULT normally; iup.CLOSE only if no dialog could be resolved
M.helpers.closeDialog = function(self_or_dialog)
	local dlg = self_or_dialog
	if dlg and iup.GetClassName(dlg) ~= "dialog" then
		dlg = iup.GetDialog(self_or_dialog)
	end
	if not dlg then
		return iup.CLOSE
	end
	if dlg.close_cb then
		if dlg.close_cb(dlg) == iup.IGNORE then
			return iup.DEFAULT
		end
	end
	dlg:hide()
	return iup.DEFAULT
end

_G.MenuBar = M

end)()
--<<FS_SPLICE_END>>

--------------------------------------------------------------
--RESULTS INSTANCE
--------------------------------------------------------------
local myResults = Results(4)
myResults.Title("Add Trees")
-- An idle close is silent: every attempted generation writes a per-individual row, so
-- zero rows means the user chose not to generate anything this session. The gates that
-- stop a generation (validation, declined confirmations) already announce themselves
-- at the time, so no separate farewell box is needed here.
myResults.NoResults(nil)
myResults.Headings({ "Individual", "SVG File", "Embedded", "Notes" })
myResults.Types({ "item", "text", "text", "text" })
myResults.Visibility({ "show", "show", "show", "show" })
myResults.Width({ 220, 280, 80, 240 })
myResults.Sort({ 1, 2, 3, 4 })

--------------------------------------------------------------
--STATE
--------------------------------------------------------------
-- Record display names (this plugin charts individuals only).
local displayNames = {
	INDI = "Individual",
}

---@class TargetRecord
---@field recordPointer ItemPointer The Family Historian record pointer.
---@field displayText string The display text for the record.
---@field recordType string The record type tag.

---@class ItemPointer
---@field IsNotNull fun(self:ItemPointer):boolean
---@field IsSame fun(self:ItemPointer, other:ItemPointer):boolean
---@field Clone fun(self:ItemPointer):ItemPointer

---@type TargetRecord[]
local selectedTargets = {}

-- Central UI registry for local references (avoid global IUP handles)
---@type {menuBarData: MenuBarData, menuBar: iup.menu, contentArea: iup.vbox, targetRecordsList: iup.list, mainVBox: iup.vbox}
local ui = {}

--------------------------------------------------------------
--PATH HELPERS
--------------------------------------------------------------

---Normalise a path for comparison: forward slashes, no trailing slash.
---@param p string|nil
---@return string
local function normalizePath(p)
	if not p or p == "" then
		return ""
	end
	local s = p:gsub("\\", "/")
	s = s:gsub("/+$", "")
	return s
end

---Is path inside (or equal to) root? Case-insensitive, separator-agnostic.
---@param root string
---@param path string
---@return boolean
local function isPathUnder(root, path)
	local r = normalizePath(root):lower()
	local p = normalizePath(path):lower()
	if r == "" then
		return false
	end
	return p:sub(1, #r) == r and (#p == #r or p:sub(#r + 1, #r + 1) == "/")
end

---The output path for one individual's standalone SVG file.
---@param folder string
---@param id integer
---@return string
local function svgPath(folder, id)
	return folder .. "\\ind" .. id .. ".svg"
end

--------------------------------------------------------------
--TARGET RECORD HELPERS
--------------------------------------------------------------

--- Convert a single record pointer to standard format
--- @param recordPtr ItemPointer The record pointer
--- @param additionalFields? table Additional fields to add
--- @return TargetRecord Formatted record
function convertRecordPointerToStandardFormat(recordPtr, additionalFields)
	local recordType = fhGetTag(recordPtr)
	local displayText = "["
		.. (displayNames[recordType] or recordType)
		.. "] "
		.. fhGetDisplayText(recordPtr)
		.. " (ID: "
		.. fhGetRecordId(recordPtr)
		.. ")"

	local record = {
		recordPointer = recordPtr,
		displayText = displayText,
		recordType = recordType,
	}

	-- Add any additional fields
	if additionalFields then
		for key, value in pairs(additionalFields) do
			record[key] = value
		end
	end

	return record
end

--- Convert selected records to a standardised format
--- @param selectedRecords ItemPointer[] Array of selected record pointers
--- @return TargetRecord[] Array of formatted records
function convertRecordsToStandardFormat(selectedRecords)
	local records = {}
	for i = 1, #selectedRecords do
		table.insert(records, convertRecordPointerToStandardFormat(selectedRecords[i]))
	end
	return records
end

--- Check if a record already exists in a collection by comparing pointers
--- @param newRecord TargetRecord The new record to check
--- @param existingRecords TargetRecord[] Array of existing records
--- @return boolean True if the record is a duplicate
function isRecordDuplicate(newRecord, existingRecords)
	for _, existingRecord in ipairs(existingRecords) do
		if newRecord.recordPointer and existingRecord.recordPointer then
			if existingRecord.recordPointer:IsSame(newRecord.recordPointer) then
				return true
			end
		end
	end
	return false
end

--- Add records to a collection with duplicate checking
--- @param newRecords TargetRecord[] New records to add
--- @param existingRecords TargetRecord[] Existing records collection
--- @param updateDisplay function Function to call to update the display
function addRecordsWithDuplicateCheck(newRecords, existingRecords, updateDisplay)
	for _, newRecord in ipairs(newRecords) do
		if not isRecordDuplicate(newRecord, existingRecords) then
			table.insert(existingRecords, newRecord)
		end
	end

	-- Update the display
	if updateDisplay then
		updateDisplay()
	end
end

--- Ask for confirmation before removing selected list items
--- @param listLabel string Human-readable list name (e.g., "Individuals")
--- @param count integer Number of items to remove
--- @return boolean proceed True if user confirmed
function confirmRemoveSelected(listLabel, count)
	local itemWord = (count == 1) and "item" or "items"
	local answer =
		MessageBox("question", string.format("Remove %d %s from the %s list?", count, itemWord, listLabel), "YESNO")
	return answer == "Yes"
end

--- Gets initial targets from the current selection or the property box,
--- keeping individuals only (this plugin charts individuals).
--- @return TargetRecord[] Array of initial targets
function getInitialTargets()
	local targets = {}

	-- Try to get current selection first
	local currentSelection = fhGetCurrentRecordSel("INDI")
	if currentSelection and #currentSelection > 0 then
		for _, recordPtr in ipairs(currentSelection) do
			if fhGetTag(recordPtr) == "INDI" then
				table.insert(targets, convertRecordPointerToStandardFormat(recordPtr))
			end
		end
	else
		-- Fallback to property box record
		local propertyBoxRecord = fhGetCurrentPropertyBoxRecord()
		if propertyBoxRecord and propertyBoxRecord:IsNotNull() and fhGetTag(propertyBoxRecord) == "INDI" then
			table.insert(targets, convertRecordPointerToStandardFormat(propertyBoxRecord))
		end
	end

	return targets
end

--- Populate the target records list
function populateTargetRecords()
	local targetList = ui.targetRecordsList
	if targetList then
		-- Clear the list first
		targetList.REMOVEITEM = "ALL"

		-- Only populate if we have targets
		if #selectedTargets > 0 then
			-- Create display text array for populateList
			local displayTexts = {}
			for _, target in ipairs(selectedTargets) do
				table.insert(displayTexts, target.displayText or tostring(target))
			end

			-- Use populateList helper
			populateList(targetList, displayTexts)
		end

		updateGenerateMenuState()
	end
end

--- Prompts the user to add individuals to the chart list
--- @param parentWindow? any Parent window handle for record selection
function selectTargetRecords(parentWindow)
	-- Get parent window handle
	local hParentWnd = getParentWindowHandle(parentWindow)

	-- Prompt user for record selection
	local selectedRecords = fhPromptUserForRecordSel("INDI", -1, hParentWnd)

	if selectedRecords and #selectedRecords > 0 then
		local targets = convertRecordsToStandardFormat(selectedRecords)
		addRecordsWithDuplicateCheck(targets, selectedTargets, populateTargetRecords)
	end
end

--- Remove selected targets from the list
function removeSelectedTargets()
	local list = ui.targetRecordsList
	if not list then
		return
	end
	local positions = getSelectedValues(list, true)
	local count = #positions
	if count == 0 then
		MessageBox("info", "Select one or more individuals to remove.")
		return
	end
	if not confirmRemoveSelected("Individuals", count) then
		return
	end
	removeSelectedItems(list, selectedTargets, populateTargetRecords)
end

--- Initialize targets from current selection or property box
function initializeTargets()
	selectedTargets = getInitialTargets()
	populateTargetRecords()
end

--------------------------------------------------------------
--CONTENT AREA
--------------------------------------------------------------

--- Create the content area: a single managed list of individuals to chart.
---@return iup.vbox
function createContentArea()
	local content = iup.vbox({ expand = "YES" })
	ui.contentArea = content

	local targetLabel = makeLongLabel({
		title = "Individuals to Chart:",
	})
	iup.Append(content, targetLabel)

	local targetList = makeList({
		dropdown = "NO",
		expand = "YES",
		multiple = "YES",
		visiblelines = "14",
		-- Sensible default canvas for the individuals list, whose entries look like
		-- "[Individual] Firstname MIDDLE SURNAME (ID: 123)". Font-derived, so it scales with DPI.
		visiblecolumns = "50",
		name = "targetRecordsList",
		tip = "Individuals whose charts will be generated. Use the Individuals menu to add or remove.\n\nUse the File menu to generate the charts.\n\nShortcuts:\n- Ctrl+I: Select Individuals\n- Ctrl+T: Clear list\n- Ctrl+A: Select all\n- Del: Remove selected",
	})
	iup.Append(content, targetList)
	ui.targetRecordsList = targetList

	-- Keyboard shortcuts for the list
	targetList.k_any = function(self, c)
		if c == iup.K_cA then -- Ctrl+A: select all (only if multi-select)
			if self.multiple == "YES" then
				local count = tonumber(self.COUNT) or 0
				if count > 0 then
					self.value = string.rep("+", count)
					return iup.IGNORE
				end
			end
		elseif c == iup.K_cI then -- Ctrl+I: add individuals
			selectTargetRecords()
			return iup.IGNORE
		elseif c == iup.K_cT then -- Ctrl+T: clear list
			selectedTargets = {}
			populateTargetRecords()
			return iup.IGNORE
		elseif c == iup.K_DEL then -- Delete: remove selected
			removeSelectedTargets()
			return iup.IGNORE
		end
		return iup.CONTINUE
	end

	return content
end

--------------------------------------------------------------
--PREVIEW
--------------------------------------------------------------

-- The styling Family Historian gives the FhSeeAlso div on its generated
-- pages, reproduced so the preview looks like the real thing.
local PREVIEW_CSS = [[
.FhSeeAlso p { height:auto; width:auto; font-size:11pt; font-weight:400;
               font-family:Verdana, Arial, Helvetica, sans-serif;
               font-variant:small-caps; margin-bottom:0; }
/* A sensible default for the standard caption class, so the preview looks
   reasonable; your own site's CSS for this class governs the real pages. */
.fs-embed-caption { font-family:Verdana, Arial, Helvetica, sans-serif;
                    font-size:13pt; font-weight:600; margin:0 0 6px; }
]]

---Render a sample model with the given (unsaved) option values to a
---temporary HTML file and open it in the default browser. Fire and forget:
---fhShellExecute returns immediately and the options dialog stays open, so
---the user can tweak values and preview again.
---@param rawValues table<string, table<string, any>> Live values from the options dialog.
---@param model FsModel The sample model to render.
---@param sampleName string Shown in the preview page heading.
local function previewChart(rawValues, model, sampleName)
	local renderOptions, output = toRenderOptions(rawValues)
	renderOptions.embedded = true
	local svg = SvgRender.render(model, renderOptions)
	-- Mirror the real embedding presentation (alignment, caption, fit).
	local wrapped = HtmlInject.wrapSvg(svg, {
		align = output.align,
		caption = output.caption,
		captionClass = output.captionClass,
		fit = output.fit,
		containerClass = output.containerClass,
		hideable = output.hideable,
	})
	local html = table.concat({
		"<!DOCTYPE html>",
		"<html><head><meta charset=\"utf-8\">",
		"<title>Add Trees preview</title>",
		"<style>",
		PREVIEW_CSS,
		"</style></head><body>",
		"<h1>Add Trees preview</h1>",
		"<p>Sample: " .. sampleName .. ". Save the options to apply them to your own charts.</p>",
		'<div class="FhSeeAlso">',
		wrapped,
		"</div>",
		"</body></html>",
	}, "\n")
	local folder = getPluginDataFolder()
	local path = folder .. "\\Add Trees Preview.html"
	local ok, err = fhfu.createTextFile(path, true, true, html, 8)
	if not ok then
		MessageBox("error", "Could not write the preview file:\n" .. path .. "\n" .. (err or ""))
		return
	end
	fhShellExecute(path)
end

-- Preview buttons for the options dialog: the balanced theming sample by
-- default, plus the awkward sample for edge cases.
local configExtraActions = {
	{
		title = "&Preview",
		tip = "Open a browser preview of the standard sample using the current (unsaved) options",
		action = function(values)
			previewChart(values, SampleTheming, "standard")
		end,
	},
	{
		title = "Preview &Awkward Cases",
		tip = "Preview the awkward sample: multiple parent-sets, missing people, escaping edge cases",
		action = function(values)
			previewChart(values, SampleFamily, "awkward cases")
		end,
	},
	{
		-- A button on the Colours and Shapes tab only: it's a Colours action,
		-- so it shouldn't sit on the Chart/Output tabs' shared button row.
		section = "Colours and Shapes",
		title = "&Save Colours as Theme...",
		tip = "Save the current colour fields as a named theme",
		action = saveColoursAsTheme,
	},
	{
		section = "Colours and Shapes",
		title = "&Delete Theme...",
		tip = "Delete the saved theme currently chosen in the Colour scheme list",
		action = deleteTheme,
	},
}

--- Show the options dialog with the Preview buttons attached.
function showOptions()
	myConfig:showConfigDialog(nil, "add-trees-reference#configuration-options", configExtraActions)
end

--------------------------------------------------------------
--GENERATION
--------------------------------------------------------------

---The record ids of everyone in the target list; people outside this set
---are charted without links (the adapter sets their linkable flag false).
---@return table<integer, boolean>
local function buildLinkableIdSet()
	local ids = {}
	for _, target in ipairs(selectedTargets) do
		if fh.isSet(target.recordPointer) then
			ids[fhGetRecordId(target.recordPointer)] = true
		end
	end
	return ids
end

---Create a folder, creating any missing parent folders along the way (like
---"mkdir -p"). fhFileUtils.createFolder is non-recursive: it returns
---"Parent folder not found" when an ancestor is missing. That happens the
---first time charts are generated into the project's Public folder, which
---Family Historian reports as the default output location but does not create
---on disk until something writes there - so both Public and Public\Add Trees
---are absent and creating just the leaf fails.
---@param path string
---@return boolean ok
---@return string? err  the failing folder's error, when creation fails
local function createFolderTree(path)
	-- Gather the missing folders, deepest first, stopping at the first one
	-- that already exists (or when there is no further parent to climb to).
	local missing = {}
	local current = path
	while current and current ~= "" and not fhfu.folderExists(current) do
		missing[#missing + 1] = current
		local parent = fhfu.getParent(current)
		if not parent or parent == current then
			break
		end
		current = parent
	end
	-- Create from the shallowest missing folder down to the target itself.
	for i = #missing, 1, -1 do
		local ok, err = fhfu.createFolder(missing[i])
		if not ok then
			return false, err
		end
	end
	return true
end

---Validate the output settings before generating, prompting where sensible.
---@param output {folder: string, embed: boolean, websiteFolder: string, pagePrefix: string, placement: string, align: string, fit: boolean, divClass: string, caption: string, captionClass: string, containerClass: string, hideable: boolean, removeDiv: boolean, removeDivClass: string, privacy: {omit: boolean, basicFlag: string?}}
---@return boolean ok
local function validateOutputSettings(output)
	if output.folder == "" then
		MessageBox("error", "No SVG output folder is set. Choose one in Options.")
		return false
	end
	if not fhfu.folderExists(output.folder) then
		local answer = MessageBox(
			"question",
			"The SVG output folder does not exist:\n" .. output.folder .. "\n\nCreate it?",
			"YESNO"
		)
		if answer ~= "Yes" then
			return false
		end
		local ok, err = createFolderTree(output.folder)
		if not ok then
			MessageBox("error", "Could not create the output folder:\n" .. (err or output.folder))
			return false
		end
	end
	if output.embed then
		if output.websiteFolder == "" then
			MessageBox("error", "Embedding is on but no website folder is set. Choose one in Options.")
			return false
		end
		if not fhfu.folderExists(output.websiteFolder) then
			MessageBox("error", "The website folder does not exist:\n" .. output.websiteFolder)
			return false
		end
	end
	return true
end

---Embed one chart into its website page. Returns the value for the results
---table's Embedded column plus an explanatory note for failures.
---@param model FsModel
---@param renderOptions FsRenderOptions
---@param output {folder: string, embed: boolean, websiteFolder: string, pagePrefix: string, placement: string, align: string, fit: boolean, divClass: string, caption: string, captionClass: string, containerClass: string, hideable: boolean, removeDiv: boolean, removeDivClass: string, privacy: {omit: boolean, basicFlag: string?}}
---@param id integer
---@return string embeddedText, string note, "missingPage"|"missingDiv"|nil failure
local function embedChart(model, renderOptions, output, id)
	renderOptions.embedded = true
	local inlineSvg = SvgRender.render(model, renderOptions)
	local pagePath = output.websiteFolder .. "\\" .. output.pagePrefix .. id .. ".html"
	-- Confirm the target page resolves under the chosen website folder.
	if not isPathUnder(output.websiteFolder, pagePath) then
		return "No", "Page path is not under the website folder", "missingPage"
	end
	if not fhfu.fileExists(pagePath) then
		return "No", "Page not found: " .. output.pagePrefix .. id .. ".html", "missingPage"
	end
	local html = fhfu.readTextFile(pagePath, true, 8)
	if type(html) ~= "string" then
		return "No", "Could not read the page", "missingPage"
	end
	-- Optionally drop a section first (e.g. FH's own "See also" box) so the
	-- chart can live in a different div. Never remove the div we inject into.
	if output.removeDiv and output.removeDivClass ~= "" and output.removeDivClass ~= output.divClass then
		html = HtmlInject.removeDiv(html, output.removeDivClass)
	end
	local newHtml, injectErr = HtmlInject.inject(html, inlineSvg, id, {
		placement = output.placement,
		classToken = output.divClass,
		align = output.align,
		caption = output.caption,
		captionClass = output.captionClass,
		fit = output.fit,
		containerClass = output.containerClass,
		hideable = output.hideable,
	})
	if not newHtml then
		return "No", injectErr or "Injection failed", "missingDiv"
	end
	local ok, err = fhfu.createTextFile(pagePath, true, true, newHtml, 8)
	if not ok then
		return "No", "Could not write the page: " .. (err or ""), "missingPage"
	end
	return "Yes", "", nil
end

--- Generate a chart for every individual in the list: one standalone SVG
--- file each, plus inline embedding into the website page when enabled.
--- @return boolean true once generation actually ran (reached the per-individual loop,
---   even if it was then cancelled part-way through - rows exist, so the Result Set
---   reports the partial outcome); false from every early gate below, so the caller
---   (Generate and Exit) knows whether to close or leave the window open to fix things.
function generateCharts()
	if #selectedTargets == 0 then
		MessageBox("info", "Add at least one individual to the list first.")
		return false
	end

	local renderOptions, output = toRenderOptions(savedRawValues())
	if not validateOutputSettings(output) then
		return false
	end

	-- When embedding, confirm before changing the user's website files, and
	-- remind them to back up (the plugin keeps no backups of its own).
	if output.embed then
		local count = #selectedTargets
		local pageWord = (count == 1) and "page" or "pages"
		local msg = string.format(
			"Embedding is on.\n\nUp to %d website %s in:\n%s\nwill be changed, and one SVG per person written to:\n%s\n",
			count,
			pageWord,
			output.websiteFolder,
			output.folder
		)
		if output.removeDiv and output.removeDivClass ~= "" and output.removeDivClass ~= output.divClass then
			msg = msg
				.. string.format(
					'\n"Remove a section" is on: the section with class "%s" will be removed from each page first.\n',
					output.removeDivClass
				)
		end
		msg = msg .. "\nBack up your website first if you have not already. Continue?"
		if MessageBox("question", msg, "OKCANCEL") ~= "OK" then
			return false
		end
	end

	-- Warn once before overwriting existing SVG files.
	local existing = 0
	for _, target in ipairs(selectedTargets) do
		if fh.isSet(target.recordPointer) and fhfu.fileExists(svgPath(output.folder, fhGetRecordId(target.recordPointer))) then
			existing = existing + 1
		end
	end
	if existing > 0 then
		local fileWord = (existing == 1) and "file" or "files"
		if MessageBox("question", string.format("Overwrite %d existing SVG %s in\n%s?", existing, fileWord, output.folder), "YESNO") ~= "Yes" then
			return false
		end
	end

	local linkableIds = buildLinkableIdSet()
	local progress = Progress.new(#selectedTargets, 5, 20, dlgmain)
	local written, embedded, failures, missingPages, missingDivs = 0, 0, 0, 0, 0

	for _, target in ipairs(selectedTargets) do
		if progress:isCancelled() then
			break
		end
		local ptr = target.recordPointer
		if fh.isSet(ptr) then
			local id = fhGetRecordId(ptr)
			progress:update(fhGetDisplayText(ptr))

			local okModel, model = pcall(FhAdapter.buildModel, ptr, linkableIds, output.privacy)
			if not okModel then
				failures = failures + 1
				myResults.Update({ ptr:Clone(), "", "", "Could not build the chart model: " .. tostring(model) })
			elseif model.focal.private then
				-- Omit-Private and the focal person is flagged Private: produce
				-- no chart at all (mirrors FH's "omit all references").
				myResults.Update({ ptr:Clone(), "", "n/a", "Omitted: flagged 'Private'" })
			else
				renderOptions.embedded = false
				local svg = SvgRender.render(model, renderOptions)
				local path = svgPath(output.folder, id)
				local okWrite, writeErr = fhfu.createTextFile(path, true, true, svg, 8)
				if not okWrite then
					failures = failures + 1
					myResults.Update({ ptr:Clone(), path, "", "Could not write the SVG: " .. (writeErr or "") })
				else
					written = written + 1
					local embeddedText, note = "n/a", ""
					if output.embed then
						local failure
						embeddedText, note, failure = embedChart(model, renderOptions, output, id)
						if embeddedText == "Yes" then
							embedded = embedded + 1
						elseif failure == "missingDiv" then
							missingDivs = missingDivs + 1
						else
							missingPages = missingPages + 1
						end
					end
					myResults.Update({ ptr:Clone(), path, embeddedText, note })
				end
			end
		else
			-- The record vanished after selection (e.g. deleted): keep the
			-- progress count honest and leave a trace in the results.
			progress:update(target.displayText or "(missing record)")
			failures = failures + 1
			myResults.Update({ fhNewItemPtr(), "", "", "Record no longer exists: " .. (target.displayText or "?") })
		end
		fhExhibitResponsiveness()
	end

	progress:finish()
	fhUpdateDisplay()

	-- One summary message; per-individual detail is in the results table.
	local fileWord = (written == 1) and "file" or "files"
	local message = string.format("%d SVG %s written to:\n%s", written, fileWord, output.folder)
	if output.embed then
		message = message .. string.format("\n\n%d chart(s) embedded in website pages", embedded)
		if missingDivs > 0 then
			message = message
				.. string.format(
					'\n%d page(s) had no <div class="%s"> - see the results table',
					missingDivs,
					output.divClass
				)
		end
		if missingPages > 0 then
			message = message .. string.format("\n%d page(s) missing or unwritable - see the results table", missingPages)
		end
	end
	if failures > 0 then
		message = message .. string.format("\n\n%d individual(s) failed - see the results table", failures)
	end
	MessageBox("info", message)
	return true
end

--------------------------------------------------------------
--MENU HANDLING
--------------------------------------------------------------

-- Menu actions cannot close via `return iup.CLOSE` under this dialog's iup.MainLoop - the FH
-- host swallows a MENU callback's returned CLOSE (though it honours the title-bar X and the
-- on_escape/DEFAULTESC paths, which go through IUP's own machinery, not a menu action). Add
-- Facts hit and fixed the same host quirk (live-verified 7 Jul 2026); the fix is now shared
-- boilerplate - see MenuBar.helpers.closeDialog (2 Boilerplate/MenuBar.lua USAGE note 7). This
-- local wrapper just fixes the dialog as dlgmain, since it's referenced by name at several call
-- sites below.
local function closeMainDialog()
	return MenuBar.helpers.closeDialog(dlgmain)
end

--- Enable/disable the Generate menu items depending on the target list.
function updateGenerateMenuState()
	local enabled = (#selectedTargets > 0) and "YES" or "NO"
	if ui.menuBarData then
		ui.menuBarData.updateActive("menuGenerateContinue", enabled)
		ui.menuBarData.updateActive("menuGenerateExit", enabled)
	end
end

--- Create the main menu bar with all menu items
--- @return iup.menu Menu bar
function createMainMenuBar()
	local menuBarData = MenuBar.createMenuBar({
		items = {
			MenuBar.helpers.createSubmenu("&Individuals", {
				MenuBar.helpers.createMenuItem("&Select: Individuals\tCtrl+I", function(self)
					selectTargetRecords()
					return iup.DEFAULT
				end),
				{}, -- separator
				MenuBar.helpers.createMenuItem("R&emove Selected\tDel", function(self)
					removeSelectedTargets()
					return iup.DEFAULT
				end),
				MenuBar.helpers.createMenuItem("&Clear List\tCtrl+T", function(self)
					selectedTargets = {}
					populateTargetRecords()
					return iup.DEFAULT
				end),
			}, "individualsMenu"),
			MenuBar.helpers.createMenuItem("&Options", function(self)
				showOptions()
				return iup.DEFAULT
			end),
		},
		fileMenu = {
			-- Keys are iterated in sorted order (see MenuBar.createMenuBar), so these are
			-- prefixed a/b to keep Generate and Continue/Exit above the Exit item below, which
			-- must be named exactly "exit" for MenuBar to suppress its own automatic Exit item.
			aGenerateContinue = MenuBar.helpers.createMenuItem("Generate and &Continue", function(self)
				generateCharts()
				return iup.DEFAULT
			end, nil, "menuGenerateContinue"),
			bGenerateExit = MenuBar.helpers.createMenuItem("&Generate and Exit", function(self)
				-- Generate and Exit exits only when the generate half actually happened.
				-- Stopped at a fixable gate (validation error, a declined confirmation),
				-- the window stays open so the user can fix it and retry - the same
				-- policy as Add Facts' Save and Exit "blocked" rule.
				if generateCharts() then
					return closeMainDialog()
				end
				return iup.DEFAULT
			end, nil, "menuGenerateExit"),
			-- Replaces MenuBar's automatic Exit item, whose `return iup.CLOSE` is dead under
			-- this dialog's menu-action CLOSE trap (see closeMainDialog above).
			exit = MenuBar.helpers.createMenuItem("E&xit", function(self)
				return closeMainDialog()
			end, nil, "menuExit"),
		},
		helpMenu = {
			about = MenuBar.helpers.createMenuItem("&About", function(self)
				MessageBox(
					"info",
					"Add Trees Plugin v"
						.. cstrPluginVersion
						.. "\n\nGenerates a three-generation navigation chart for each selected individual, as standalone SVG files and optionally embedded in the pages of a Family Historian generated website."
				)
				return iup.DEFAULT
			end),
		},
	})

	-- Store references for dynamic updates
	ui.menuBarData = menuBarData
	ui.menuBar = menuBarData.menuBar

	-- Initialise menu state
	updateGenerateMenuState()

	return menuBarData.menuBar
end

--------------------------------------------------------------
--MAIN DIALOG
--------------------------------------------------------------

function makeMainDialog()
	-- Create content area
	local contentArea = createContentArea()

	-- Create the main content area
	local mainVBox = iup.vbox({
		contentArea,
		margin = "10x10",
		gap = "10",
	})
	ui.mainVBox = mainVBox

	-- Populate the list initially
	initializeTargets()

	-- Create menu bar using the centralized function
	local menuBar = createMainMenuBar()

	-- Create the main dialog
	local dialog = makeDialog(mainVBox, {
		title = "Add Trees",
		expand = "YES",
		resize = "YES",
		menubox = "YES",
		menu = menuBar,
		help_topic = "",
		close_cb = function(self)
			return iup.CLOSE
		end,
		-- Esc closes the window (menu-driven dialog, no Cancel button). Returning iup.CLOSE fires the
		-- close_cb that showTrackedDialog wrapped, so window size/position is still saved.
		on_escape = function()
			return iup.CLOSE
		end,
	})

	return dialog
end

--------------------------------------------------------------
--EXECUTE
--------------------------------------------------------------
-- Create and show the main dialog
dlgmain = makeMainDialog()
myConfig:showTrackedDialog(dlgmain, "Main")
DoNormalize()
if iup.MainLoopLevel() == 0 then
	iup.MainLoop()
end
destroyAllDialogs()
myResults.Display()

Source: Add-Trees.fh_lua