Plugins and Language Packs for Family Historian

Add Notes.fh_lua

--[[
@Title: Add Notes
@Type: standard
@Author: Helen Wright
@Contributors:
@Version: 1.7
@LastUpdated: 24 September 2026
@Licence: This plugin is copyright (c) 2025 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: Plugin to attach Shared Notes or Research Notes to selected target records. Supports both selecting existing notes and creating new notes from autotext.
]]
--
--[[ChangeLog:
	Version 1.7: Fixes: Shared Notes can no longer be attached to Place records (Family Historian doesn't support them - Research Notes still work); Address records, where your version of Family Historian has them, appear in the Targets menu and likewise take Research Notes only. After you change note type, a target in the list that the new type can't take is flagged "(not supported for ...)" and, if you apply anyway, listed in the Result Set as skipped; Targets menu items that become available after a note-type change now work straight away. File > Exit and Apply and Exit now reliably close the window, and the outcome of every run appears in Family Historian's Result Set (closing without applying anything is silent; Apply and Exit no longer shows an "Applied" message first). Keyboard: Esc closes the main window and cancels the Options dialog; the Options section names carry Alt-key access keys; Help is a single command on the Options menu bar. Windows remember a sensible size and position and can no longer open too small to show all their controls.
	Version 1.6: the Options dialog's Help menu no longer shows an About item (it pointed at a non-existent help page); About is unchanged on the main window.
	Version 1.5: The Options dialog now follows the Windows theme - light, dark or High Contrast - instead of always being white. Colours are read from the system at run time (via the shared Theme helper); in plain dark mode the buttons keep a light face with dark text, because Windows paints native button faces itself and a plugin can't repaint them. Behaviour and output are unchanged.
	Version 1.4: The Name/Link placeholder fields now force UPPERCASE. Typed input converts to upper-case live (the field's UPPERCASE filter, which still allows digits); an existing token shows upper-cased when Options opens; and it is upper-cased on save. Because a placeholder is matched against the template text case-sensitively (as {NAME}), forcing upper-case removes the template/config case mismatch that 1.3's free-text fields allowed.
	Version 1.3: Fixes: the name/link placeholder fields no longer restrict input to a single uppercase character (the input mask is removed - placeholder text is user-defined); when an AutoText template save is rejected for being outside the AutoText folder, the dialog reopens back in the AutoText folder.
	Version 1.2: Reliability: the Options dialog is now built once and reused, rather than rebuilt on every open. Rebuilding it (a fresh dialog, menu and re-registered controls) corrupted native state and could silently crash FH the second time Options was opened in one run (the same fault fixed in the Add Trees plugin). Reopening now reloads the saved values into the existing dialog.
	Version 1.1: Bug fixes: File menu items now appear in a fixed order (was random); number fields in Options regained their numeric input mask; corrected trailing-slash handling in autotext path validation; assistant ("...") buttons no longer lose their action and close the containing dialog; the Options dialog closes and reopens cleanly (no close callback fighting the popup teardown, and no destroy that corrupted menu state when closed via Save/Cancel)
	Version 1.0: Initial release
]]

--------------------------------------------------------------
--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.
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
--------------------------------------------------------------
-- Environment variables
local PLUGIN_VERSION = "1.7"
local AUTOTEXT_DIR = fhGetContextInfo("CI_APP_DATA_FOLDER") .. "/Autotext"

-- Note Types
local NOTE_TYPE_SHARED = "Shared Notes"
local NOTE_TYPE_RESEARCH = "Research Notes"

-- Operation Modes
local MODE_SELECT_EXISTING = "Select Existing"
local MODE_CREATE_FROM_AUTOTEXT = "Create from AutoText"

-- Record Tags
local TAG_NOTE = "NOTE"
local TAG_RESEARCH_NOTE = "_RNOT"
local TAG_TEXT = "TEXT"

-- File Extensions
local EXT_AUTOTEXT = "ftf"
--------------------------------------------------------------
-- RECORD DISPLAY NAMES
--------------------------------------------------------------
local displayNames = {
	INDI = "Individual",
	FAM = "Family",
	SOUR = "Source",
	REPO = "Repository",
	NOTE = "Note",
	OBJE = "Media",
	SUBN = "Submitter",
	SUBM = "Submission",
	_PLAC = "Place",
	_ADDR = "Address",
	_HEAD = "Header",
	_RNOT = "Research Note",
	_SRCT = "Source Template",
}

local menuNames = {
	INDI = "&Individuals",
	FAM = "&Families",
	SOUR = "&Sources",
	REPO = "&Repositories",
	NOTE = "Notes",
	OBJE = "&Media",
	SUBN = "Submitters",
	SUBM = "Submissions",
	_PLAC = "&Places",
	_ADDR = "&Addresses",
	_HEAD = "Headers",
	_RNOT = "Research Notes",
	_SRCT = "Source &Templates",
}

--------------------------------------------------------------
--RECORD TYPE SUPPORT CONFIGURATION
--------------------------------------------------------------
-- Configuration for which record types are supported for each note type
-- This makes it easy to modify support without changing code logic
local recordTypeSupport = {
	-- Record types that are completely unsupported (won't appear in menu)
	completelyUnsupported = {
		"HEAD", --can't have notes on headers
		"NOTE", --can't have notes on notes except by embedding them
		"_RNOT", --can't have notes on research notes except by embedding them
		"SUBM", --technically valid, but I don't think anyone would want to use this
		"SUBN", --technically valid, but I don't think anyone would want to use this
	},

	-- Record types that are unsupported for Shared Notes only
	sharedNotesUnsupported = {
		"_PLAC", --FH doesn't support Shared Notes on Place records
		"_ADDR", --FH doesn't support Shared Notes on Address records
	},

	-- Record types that are unsupported for Research Notes only
	researchNotesUnsupported = {
		"_SRCT", --can only have notes on source templates, not research notes
	},
}

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

	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>>
local defaultConfig = {
	title = "Add Notes Configuration",
	sections = {
		{
			title = "Preferences",
			fields = {
				{
					key = "noteType",
					label = "Default Note Type:",
					type = "list",
					default = NOTE_TYPE_SHARED,
					options = { NOTE_TYPE_SHARED, NOTE_TYPE_RESEARCH },
					description = "The type of notes to work with by default",
				},
				{
					key = "operationMode",
					label = "Default Operation Mode:",
					type = "list",
					default = MODE_SELECT_EXISTING,
					options = { MODE_SELECT_EXISTING, MODE_CREATE_FROM_AUTOTEXT },
					description = "Whether to select existing notes or create new ones from autotext by default",
				},
				{
					key = "useLastSettings",
					label = "Use Last Settings:",
					type = "boolean",
					default = true,
					description = "Whether to start with the last used settings",
				},
				{
					key = "nameToken",
					label = "Name Placeholder:",
					type = "text",
					default = "",
					description = "The placeholder for the name of the target record in the note",
					mask = "TOKEN",
					tip = "Placeholder word, e.g. NAME; reference it in templates as {NAME}",
				},
				{
					key = "linkToken",
					label = "Link Placeholder:",
					type = "text",
					default = "",
					description = "The placeholder for the link to the target record in the note",
					mask = "TOKEN",
					tip = "Placeholder word, e.g. NAME; reference it in templates as {NAME}",
				},
			},
		},
	},
}
local myConfig = Config.new(defaultConfig, "LOCAL_MACHINE", fhGetContextInfo("CI_PLUGIN_NAME") .. ".ini")

-- Current state variables (initialized from config where appropriate)
local currentNoteType = myConfig:getValue("Preferences", "noteType", NOTE_TYPE_SHARED)
local currentOperationMode = myConfig:getValue("Preferences", "operationMode", MODE_SELECT_EXISTING)
local selectedTargets = {} -- Array of TargetRecord objects
local selectedNotes = {}
-- Central UI registry for local references (avoid global IUP handles)
---@type {menuBarData: MenuBarData, menuBar: iup.menu, contentArea: iup.vbox, targetRecordsList: iup.list, notesList: iup.list, notesLabel: iup.label, mainVBox: iup.vbox}
local ui = {}
--<<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>>

local myResults = Results(3)
myResults.Title("Add Notes")
-- An idle close is silent: every apply writes a result row (linked, already-linked,
-- created and linked), so zero rows means nothing was applied this session. Retired
-- 23 Jul 2026 - the outcome is in the Result Set window instead.
myResults.NoResults(nil)
myResults.Headings({ "Action", "Target", "Note" }) -- Column headers
myResults.Types({ "text", "item", "item" }) -- Data types for each column
myResults.Visibility({ "show", "show", "show" }) -- Which columns to show
myResults.Width({ 200, 200, 200 }) -- Width for each column
myResults.Sort({ 1, 2, 3 }) -- Sort by Action, then Target, and then Note

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

--- Compute dynamic Notes menu item titles based on mode and type
function getSelectMenuTitle()
	if currentOperationMode == MODE_SELECT_EXISTING then
		return "&Select Existing " .. currentNoteType
	else
		return "&Select AutoText"
	end
end

function getNewMenuTitle()
	if currentOperationMode == MODE_SELECT_EXISTING then
		return "&New " .. currentNoteType:gsub("s$", "")
	else
		return "&New AutoText Template"
	end
end

function updateMenuTitles()
	if ui.menuBarData then
		ui.menuBarData.updateTitle("menuNewItem", getNewMenuTitle())
		ui.menuBarData.updateTitle("menuSelectItem", getSelectMenuTitle())
	end
end

-- Update check marks for Note Type and Mode menu items
function updateMenuChecks()
	if ui.menuBarData then
		ui.menuBarData.updateValue("menuNoteTypeShared", (currentNoteType == NOTE_TYPE_SHARED) and "ON" or "OFF")
		ui.menuBarData.updateValue("menuNoteTypeResearch", (currentNoteType == NOTE_TYPE_RESEARCH) and "ON" or "OFF")
		ui.menuBarData.updateValue("menuModeSelect", (currentOperationMode == MODE_SELECT_EXISTING) and "ON" or "OFF")
		ui.menuBarData.updateValue(
			"menuModeCreate",
			(currentOperationMode == MODE_CREATE_FROM_AUTOTEXT) and "ON" or "OFF"
		)
	end
end

--- Enable/disable Apply menu items depending on selections
function updateApplyMenuState()
	local hasTargets = (#selectedTargets or 0) > 0
	local hasNotes = (#selectedNotes or 0) > 0
	local enabled = (hasTargets and hasNotes) and "YES" or "NO"

	if ui.menuBarData then
		ui.menuBarData.updateActive("menuApplyExit", enabled)
		ui.menuBarData.updateActive("menuApplyContinue", enabled)
	end
end

--- Update the active state of target menu items based on current note type
function updateTargetMenuStates()
	if ui.menuBarData then
		ui.menuBarData.updateRegistryGroup("targets", function(key, menuItem)
			local isSupported = getSupportedRecordTypes()[key] ~= false
			menuItem.active = isSupported and "YES" or "NO"
		end)
	end
end

--- Check if a record type should be visible in the menu (supported for at least one note type)
--- @param recordTag string The record type tag to check
--- @return boolean True if the record type should be visible
function isRecordTypeVisible(recordTag)
	-- Check if it's completely unsupported (won't appear in menu)
	for _, tag in ipairs(recordTypeSupport.completelyUnsupported) do
		if tag == recordTag then
			return false
		end
	end

	-- If not completely unsupported, it should be visible
	return true
end

-- 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 path, which go through IUP's own machinery, not a menu action). Add Trees and Add
-- Facts hit and fixed the same host quirk; 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

--- Create the main menu bar with all menu items
--- @return table Menu bar definition

function createMainMenuBar()
	-- Build the targets submenu dynamically
	local targetsSubmenu = {}

	-- Get supported record types and add menu items for each
	local supportedTypes = getSupportedRecordTypes()
	local recordTypes = getRecordTypesInfo(supportedTypes)

	-- Add record type selection menu items first
	for _, recordType in ipairs(recordTypes) do
		-- Only show record types that are supported for at least one note type
		if isRecordTypeVisible(recordType.tag) then
			local accel = ""
			if recordType.tag == "INDI" then
				accel = "\tCtrl+I"
			end
			if recordType.tag == "FAM" then
				accel = "\tCtrl+F"
			end
			-- no shortcut for NOTE in Targets menu
			local menuItem = MenuBar.helpers.createMenuItem("Select: " .. recordType.menuName .. accel, function(self)
				-- Check against the CURRENT note type: recordType.isSupported is fixed when the menu
				-- is built, but the user can change note type afterwards.
				if isRecordTypeSupportedForNoteType(recordType.tag, currentNoteType) then
					selectTargetRecords(recordType.tag)
				end
				return iup.DEFAULT
			end, nil, recordType.tag)
			menuItem.active = recordType.isSupported and "YES" or "NO"
			menuItem.registryGroup = "targets"
			table.insert(targetsSubmenu, menuItem)
		end
	end

	-- Add separator
	table.insert(targetsSubmenu, {})

	-- Add utility menu items
	table.insert(
		targetsSubmenu,
		MenuBar.helpers.createMenuItem("R&emove Selected\tDel", function(self)
			removeSelectedTargets()
			return iup.DEFAULT
		end)
	)
	table.insert(
		targetsSubmenu,
		MenuBar.helpers.createMenuItem("&Clear Targets\tCtrl+T", function(self)
			selectedTargets = {}
			populateTargetRecords()
			return iup.DEFAULT
		end)
	)

	-- Create menu bar
	local menuBarData = MenuBar.createMenuBar({
		registries = {
			targets = {}, -- For target menu items
			modes = {}, -- For mode menu items
			noteTypes = {}, -- For note type menu items
		},
		items = {
			MenuBar.helpers.createSubmenu("&Targets", targetsSubmenu, "targetsMenu"),
			MenuBar.helpers.createSubmenu("&Notes", {
				MenuBar.helpers.createMenuItem(getNewMenuTitle(), function(self)
					createNewNote()
					return iup.DEFAULT
				end, nil, "menuNewItem"),
				MenuBar.helpers.createMenuItem(getSelectMenuTitle(), function(self)
					if currentOperationMode == MODE_SELECT_EXISTING then
						selectExistingNotes(currentNoteType)
					else
						selectAutotextViaTree()
					end
					return iup.DEFAULT
				end, nil, "menuSelectItem"),
				{}, -- separator
				MenuBar.helpers.createSubmenu("Note &Type", {
					MenuBar.helpers.createRadioMenuItem("&Shared Notes", currentNoteType, NOTE_TYPE_SHARED, function()
						return changeNoteType(NOTE_TYPE_SHARED)
					end, nil, "menuNoteTypeShared"),
					MenuBar.helpers.createRadioMenuItem(
						"&Research Notes",
						currentNoteType,
						NOTE_TYPE_RESEARCH,
						function()
							return changeNoteType(NOTE_TYPE_RESEARCH)
						end,
						nil,
						"menuNoteTypeResearch"
					),
				}, "noteTypeMenu"),
				MenuBar.helpers.createSubmenu("&Mode", {
					MenuBar.helpers.createRadioMenuItem(
						"&Select Existing",
						currentOperationMode,
						MODE_SELECT_EXISTING,
						function()
							return changeMode(MODE_SELECT_EXISTING)
						end,
						nil,
						"menuModeSelect"
					),
					MenuBar.helpers.createRadioMenuItem(
						"&Create from AutoText",
						currentOperationMode,
						MODE_CREATE_FROM_AUTOTEXT,
						function()
							return changeMode(MODE_CREATE_FROM_AUTOTEXT)
						end,
						nil,
						"menuModeCreate"
					),
				}, "modeMenu"),
				{}, -- separator
				MenuBar.helpers.createMenuItem("&New...\tCtrl+N", function(self)
					createNewNote()
					return iup.DEFAULT
				end),
				MenuBar.helpers.createMenuItem("&View/Edit Selected\tCtrl+E", function(self)
					editSelectedNoteOrAutotext()
					return iup.DEFAULT
				end),
				MenuBar.helpers.createMenuItem("R&emove Selected\tDel", function(self)
					removeSelectedNotes()
					return iup.DEFAULT
				end),
				MenuBar.helpers.createMenuItem("&Clear Notes\tCtrl+Shift+C", function(self)
					selectedNotes = {}
					populateNotesList()
					return iup.DEFAULT
				end),
			}, "notesMenu"),
			MenuBar.helpers.createMenuItem("&Options", function(self)
				myConfig:showConfigDialog(
					nil,
					"add-notes-reference#configuration-options"
				)
				return iup.DEFAULT
			end),
		},
		fileMenu = {
			-- Keys are iterated in sorted order (see MenuBar.createMenuBar), so these are
			-- named to keep Apply and Continue/Exit above the Exit item below, which must be
			-- named exactly "exit" for MenuBar to suppress its own automatic Exit item.
			applyContinue = MenuBar.helpers.createMenuItem("Apply and &Continue", function(self)
				showApplyResults(applySelections())
				return iup.DEFAULT
			end, nil, "menuApplyContinue"),
			applyExit = MenuBar.helpers.createMenuItem("&Apply and Exit", function(self)
				-- No receipt message box here: the dialog is closing, so the
				-- outcome is shown in FH's own Result Set instead (a box first
				-- would just repeat it). Apply and Continue (above) still shows
				-- one, because the window stays open and the Result Set only
				-- appears once it eventually closes.
				applySelections()
				return closeMainDialog()
			end, nil, "menuApplyExit"),
			-- 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 Notes Plugin v"
						.. PLUGIN_VERSION
						.. "\n\nPlugin to attach Shared Notes or Research Notes to selected target records.\n\nSupports both selecting existing notes and creating new notes from autotext."
				)
				return iup.DEFAULT
			end),
		},
	})

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

	-- Initialize menu item titles and states
	updateMenuTitles()
	updateTargetMenuStates()
	updateApplyMenuState()

	return menuBarData.menuBar
end

--------------------------------------------------------------
--APPLY FUNCTIONALITY
--------------------------------------------------------------

--- Create a new note record from a RichText object for a given note type
--- @param noteType string Either NOTE_TYPE_RESEARCH or NOTE_TYPE_SHARED
--- @param richText RichText The rich text object to use as content
--- @return ItemPointer|nil ptrNote The created note record pointer, or nil on error
---@class ItemPointer
---@field IsNotNull fun(self:ItemPointer):boolean
---@field Clone fun(self:ItemPointer):ItemPointer

---@class RichText
---@field SetText fun(self:RichText, text:string, format?:boolean, tokens?:boolean)
---@field GetText fun(self:RichText):string
---@field Clone fun(self:RichText):RichText

local function createNoteRecordFromRichText(noteType, richText)
	local tag = (noteType == NOTE_TYPE_RESEARCH) and TAG_RESEARCH_NOTE or TAG_NOTE
	local ptrNote = fhCreateItem(tag)
	local ptrText = fhCreateItem(TAG_TEXT, ptrNote, true)
	local success = fhSetValueAsRichText(ptrText, richText)
	if not success then
		MessageBox("error", "Failed to set content for new " .. noteType:gsub("s$", ""))
		return nil
	end
	return ptrNote
end
--- Check whether a target already has a link to the given note
--- @param targetPtr ItemPointer Target record pointer
--- @param notePtr ItemPointer Note record pointer
--- @param noteLinkTag string Link tag to scan on the target ("NOTE" or "_RNOT")
--- @return boolean
local function isNoteLinkedToTarget(targetPtr, notePtr, noteLinkTag)
	--we can't use LinksTo or LinksFrom because they include embedded links in the counts they return, so we need to scan the target manually
	local p = fhNewItemPtr()
	p:MoveTo(targetPtr, "~." .. noteLinkTag)
	while p:IsNotNull() do
		local linked = fhGetValueAsLink(p)
		if linked and linked:IsNotNull() and linked:IsSame(notePtr) then
			return true
		end
		p:MoveNext("SAME_TAG")
	end
	return false
end
--- Link an existing note record to a target if not already linked
--- @param targetPtr ItemPointer
--- @param notePtr ItemPointer
--- @param noteLinkTag string
--- @return boolean linked True if a link was created
local function linkNoteToTarget(targetPtr, notePtr, noteLinkTag)
	if isNoteLinkedToTarget(targetPtr, notePtr, noteLinkTag) then
		return false -- Already linked
	end
	local linkItem = fhCreateItem(noteLinkTag, targetPtr)
	fhSetValueAsLink(linkItem, notePtr)
	return true
end
--- Create a new note record from an AutoText template file
--- @param template table One entry from selectedNotes with fields .filePath
--- @param noteType string Either NOTE_TYPE_SHARED or NOTE_TYPE_RESEARCH
--- @param targetPtr? ItemPointer Optional target record pointer for link embedding
--- @return ItemPointer|nil ptrNewNote The created note record pointer
local function createNoteRecordFromAutoText(template, noteType, targetPtr)
	local content, err = fhfu.readTextFile(template.filePath, true, 8)
	if not content then
		MessageBox(
			"error",
			"AutoText file could not be read: "
				.. (template.displayText or template.filePath)
				.. (err and (" - " .. err) or "")
		)
		return nil
	end
	if fh.isSet(targetPtr) then
		local linkToken = myConfig:getValue("Preferences", "linkToken", "")
		local safeRecordName = fhGetDisplayText(targetPtr)
		if not fh.isSet(safeRecordName) then
			safeRecordName = "(unnamed)"
		end --handle the case where the name comes back blank, to avoid leaving tokens in the autotext
		safeRecordName = fhFtfEncode(safeRecordName)
		if fh.isSet(linkToken) then
			local link = string.format('<rec=%s,"%s",auto>', fhGetQualifiedRecordId(targetPtr), safeRecordName)
			content = string.gsub(content, "{" .. linkToken .. "}", link)
		end
		local nameToken = myConfig:getValue("Preferences", "nameToken", "")
		if fh.isSet(nameToken) then
			content = string.gsub(content, "{" .. nameToken .. "}", safeRecordName)
		end
	end
	local rt = fhNewRichText()
	rt:SetText(content, true, true)
	local ptrNote = createNoteRecordFromRichText(noteType, rt) --create the note record
	if not ptrNote then
		MessageBox("error", "Failed to set content from AutoText: " .. (template.displayText or template.filePath))
		return nil
	else
		return ptrNote
	end
end
--- Choose "a" or "an" for a display name, e.g. "a Place record" / "an Address record"
local function articleFor(displayName)
	local firstChar = displayName:sub(1, 1):upper()
	if firstChar == "A" or firstChar == "E" or firstChar == "I" or firstChar == "O" or firstChar == "U" then
		return "an"
	else
		return "a"
	end
end
--- Report targets whose record type doesn't support the current note type, so a skip is
-- never silent - one result row per unsupported target, regardless of how many notes/
-- templates are selected.
local function reportUnsupportedTargets()
	for _, target in ipairs(selectedTargets) do
		local targetPtr = target.recordPointer
		if fh.isSet(targetPtr) and not isRecordTypeSupportedForNoteType(target.recordType, currentNoteType) then
			local recordTypeName = displayNames[target.recordType] or target.recordType
			myResults.Update({
				"Skipped - " .. currentNoteType .. " can't be attached to " .. articleFor(recordTypeName) .. " " .. recordTypeName .. " record", -- Action
				targetPtr:Clone(), -- Target
				fhNewItemPtr(), -- Note (not applicable)
			})
		end
	end
end
--- Apply the current selections to targets
function applySelections()
	local noteLinkTag = (currentNoteType == NOTE_TYPE_RESEARCH) and TAG_RESEARCH_NOTE or TAG_NOTE
	local totalLinked = 0
	local totalCreated = 0

	-- Prepare progress controller
	local numNotes = #selectedNotes
	local numTargets = #selectedTargets
	local totalSteps = numNotes * numTargets
	-- Tunables: tweak here in code (not user-configurable)
	local UPDATE_PERCENT = 5
	local SHOW_THRESHOLD = 20
	local progress = Progress.new(totalSteps, UPDATE_PERCENT, SHOW_THRESHOLD, dlgmain)

	-- progress dialog shows itself on demand via ProgressController

	reportUnsupportedTargets()

	if currentOperationMode == MODE_SELECT_EXISTING then
		-- Link existing notes to each target
		for _, note in ipairs(selectedNotes) do
			local notePtr = note.recordPointer
			if fh.isSet(notePtr) then
				for _, target in ipairs(selectedTargets) do
					local targetPtr = target.recordPointer
					if fh.isSet(targetPtr) and isRecordTypeSupportedForNoteType(target.recordType, currentNoteType) then
						if progress:isCancelled() then
							progress:update("Cancelling...")
							progress:finish()
							fhUpdateDisplay()
							return { created = totalCreated, linked = totalLinked }
						end
						if linkNoteToTarget(targetPtr, notePtr, noteLinkTag) then
							myResults.Update({
								currentNoteType:gsub("s$", "") .. " Linked", -- Action
								targetPtr:Clone(), -- Target
								notePtr:Clone(), -- Note
							})
							totalLinked = totalLinked + 1
						else
							-- Note was already linked
							myResults.Update({
								currentNoteType:gsub("s$", "") .. " Already Linked", -- Action
								targetPtr:Clone(), -- Target
								notePtr:Clone(), -- Note
							})
						end
						progress:update(string.format("%d/%d", (progress.step + 1), progress.totalSteps))
					end
				end
			end
		end
	else
		-- Create a new note from each AutoText for each target and link it
		for _, template in ipairs(selectedNotes) do
			if template.type == "autotext" and template.filePath then
				for _, target in ipairs(selectedTargets) do
					local targetPtr = target.recordPointer
					if fh.isSet(targetPtr) and isRecordTypeSupportedForNoteType(target.recordType, currentNoteType) then
						if progress:isCancelled() then
							progress:update("Cancelling...")
							progress:finish()
							fhUpdateDisplay()
							return { created = totalCreated, linked = totalLinked }
						end
						local ptrNote = createNoteRecordFromAutoText(template, currentNoteType, targetPtr)
						if fh.isSet(ptrNote) then
							-- For autotext mode, create a single result entry for "Created and Linked"
							local linkItem = fhCreateItem(noteLinkTag, targetPtr)
							fhSetValueAsLink(linkItem, ptrNote)
							myResults.Update({
								currentNoteType:gsub("s$", "") .. " Created and Linked", -- Action
								targetPtr:Clone(), -- Target
								ptrNote:Clone(), -- Note
							})
							totalCreated = totalCreated + 1
							totalLinked = totalLinked + 1
						end
						progress:update(string.format("%d/%d", (progress.step + 1), progress.totalSteps))
					end
				end
			end
		end
	end

	progress:finish()
	fhUpdateDisplay()
	return { created = totalCreated, linked = totalLinked }
end
-- Helper function to show apply results message
function showApplyResults(res)
	local message
	if currentOperationMode == MODE_CREATE_FROM_AUTOTEXT then
		-- For autotext mode, show more informative message
		if res.created and res.created > 0 then
			local numTemplates = #selectedNotes
			local numTargets = #selectedTargets
			if numTemplates > 0 and numTargets > 0 then
				local noteText = res.created == 1 and "note" or "notes"
				local targetText = numTargets == 1 and "target" or "targets"
				message = string.format("%d %s created for %d %s", res.created, noteText, numTargets, targetText)
			else
				local noteText = res.created == 1 and "note" or "notes"
				message = string.format("%d %s created", res.created, noteText)
			end
		else
			message = "No notes created"
		end
	else
		-- For existing notes mode, show traditional message
		local parts = {}
		if res.created and res.created > 0 then
			table.insert(parts, string.format("%d created", res.created))
		end
		if res.linked and res.linked > 0 then
			table.insert(parts, string.format("%d linked", res.linked))
		end
		if #parts > 0 then
			message = "Applied: " .. table.concat(parts, ", ")
		else
			message = "No changes applied"
		end
	end

	MessageBox("info", message)
end

--------------------------------------------------------------
--NEW NOTE CREATION FUNCTIONALITY
--------------------------------------------------------------

--- Create a new autotext template file
--- @param richText RichText The rich text object to save
function createNewAutoTextTemplate(richText)
	-- Re-open dialog until user selects valid path inside AUTOTEXT_DIR or cancels
	local function normalizePath(p)
		if not p or p == "" then
			return ""
		end
		local s = p:gsub("\\", "/")
		-- remove trailing slash ("$" is the end-of-string anchor; "%$" would match a literal dollar sign)
		s = s:gsub("/+$", "")
		return s
	end

	local rootNormLower = normalizePath(AUTOTEXT_DIR):lower()
	local filedlg = iup.filedlg({
		dialogtype = "SAVE",
		title = "Save AutoText Template",
		directory = AUTOTEXT_DIR,
		extfilter = "Family Historian AutoText (*.ftf)|*.ftf",
		file = "New AutoText Template",
		nochangedir = "YES",
		parentdialog = identifyActiveWindow(),
	})

	while true do
		-- Always (re)open in the AutoText folder, so a rejected attempt
		-- outside it returns the user to the right place instead of wherever
		-- they had navigated to.
		filedlg.directory = AUTOTEXT_DIR
		filedlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)

		if filedlg.status ~= "1" then
			break
		end

		local chosenPath = filedlg.value or ""

		if chosenPath ~= "" then
			-- Ensure .ftf extension using fhFileUtils.splitPath
			local pathWithExt = chosenPath
			local parts = fhfu.splitPath(chosenPath)
			if (parts.ext or ""):lower() ~= EXT_AUTOTEXT then
				pathWithExt = chosenPath .. "." .. EXT_AUTOTEXT
			end

			-- Ensure within AUTOTEXT_DIR (normalize separators and compare prefix)
			local fileNormLower = normalizePath(pathWithExt):lower()
			if
				fileNormLower:sub(1, #rootNormLower) == rootNormLower
				and (
					fileNormLower:sub(#rootNormLower + 1, #rootNormLower + 1) == "/"
					or #fileNormLower == #rootNormLower
				)
			then
				-- Overwrite check
				if
					not fhfu.fileExists(pathWithExt)
					or MessageBox("question", "File already exists. Do you want to overwrite it?", "YESNO") == "Yes"
				then
					local rtString = richText:GetText()
					local success, error = fhfu.createTextFile(pathWithExt, true, true, rtString, 8)
					if success then
						local templates = convertFilePathsToTemplates({ pathWithExt })
						local template = templates[1]
						table.insert(selectedNotes, template)
						populateNotesList()
						MessageBox("info", "AutoText template created successfully: " .. template.displayText)
						myResults.Update({
							"AutoText Template Created: " .. template.displayText, -- Action
							fhNewItemPtr(), -- Null pointer for target
							fhNewItemPtr(), -- Null pointer for note
						})
						break
					else
						MessageBox("error", "Failed to save AutoText template: " .. error)
						-- Loop again to allow user to retry
					end
				end
			else
				MessageBox("error", "AutoText templates must be saved within the AutoText directory.")
				-- Loop continues to re-open the dialog
			end
		end
	end
	filedlg:destroy()
end
--- Create a new note record
--- @param richText RichText The rich text object for the note
function createNewNoteRecord(richText)
	local ptrNote = createNoteRecordFromRichText(currentNoteType, richText)
	if not ptrNote then
		return
	end

	-- Add result entry for the created note
	myResults.Update({
		currentNoteType:gsub("s$", "") .. " Created", -- Action
		fhNewItemPtr(), -- Null pointer for target
		ptrNote:Clone(), -- Note,
	})
	local tag = (currentNoteType == NOTE_TYPE_RESEARCH) and TAG_RESEARCH_NOTE or TAG_NOTE
	local note = {
		recordPointer = ptrNote,
		displayText = fhGetDisplayText(ptrNote) .. " (ID: " .. fhGetRecordId(ptrNote) .. ")",
		recordType = tag,
		noteType = currentNoteType,
	}
	table.insert(selectedNotes, note)
	populateNotesList()
	MessageBox("info", "New " .. currentNoteType:gsub("s$", "") .. " created successfully: " .. note.displayText)
end
--- Create a new note or autotext template based on current mode
function createNewNote()
	-- Create a new rich text object for the prompt
	local initialRichText = fhNewRichText("", true)

	-- Display rich text prompt
	local richText = fhPromptUserForRichText(initialRichText)

	if not richText then
		-- User cancelled
		return
	end
	if currentOperationMode == MODE_CREATE_FROM_AUTOTEXT then
		createNewAutoTextTemplate(richText)
	else
		createNewNoteRecord(richText)
	end
	fhUpdateDisplay()
end

--------------------------------------------------------------
--HELPER FUNCTIONS
--------------------------------------------------------------

--- Convert selected records to a standardized format
--- @param selectedRecords ItemPointer[] Array of selected record pointers
--- @param recordTag string The record type tag
--- @param additionalFields? table Additional fields to add to each record
--- @return table[] Array of formatted records
function convertRecordsToStandardFormat(selectedRecords, recordTag, additionalFields)
	local records = {}
	for i = 1, #selectedRecords do
		local displayText = "["
			.. (displayNames[recordTag] or recordTag)
			.. "] "
			.. fhGetDisplayText(selectedRecords[i])
			.. " (ID: "
			.. fhGetRecordId(selectedRecords[i])
			.. ")"

		local record = {
			recordPointer = selectedRecords[i],
			displayText = displayText,
			recordType = recordTag,
		}

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

		table.insert(records, record)
	end
	return records
end

--- Check if a record already exists in a collection by comparing  pointers
--- @param newRecord table The new record to check
--- @param existingRecords table[] Array of existing records
--- @return boolean True if the record is a duplicate
function isRecordDuplicate(newRecord, existingRecords)
	for _, existingRecord in ipairs(existingRecords) do
		-- For autotext templates, compare file paths
		if newRecord.type == "autotext" and existingRecord.type == "autotext" then
			if newRecord.filePath == existingRecord.filePath then
				return true
			end
		-- For regular records, compare record pointers
		elseif 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 table[] New records to add
--- @param existingRecords table[] Existing records collection
--- @param updateDisplay function Function to call to update the display
function addRecordsWithDuplicateCheck(newRecords, existingRecords, updateDisplay)
	for i, 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

--- Convert a single record pointer to standard format
--- @param recordPtr ItemPointer The record pointer
--- @param additionalFields? table Additional fields to add
--- @return table 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

--- Compute and apply the dialog title based on current mode and note type

function getDialogTitle()
	local modePart = (currentOperationMode == MODE_SELECT_EXISTING) and "Select Existing" or "Create from AutoText"
	local typePart = currentNoteType
	return "Add Notes — " .. modePart .. " — " .. typePart
end

function updateAddNotesDialogTitle()
	if dlgmain and dlgmain.title then
		updateDialogTitle(dlgmain, getDialogTitle())
	end
end

--- Clear the Notes list UI and any stored selections
function clearNotesList()
	selectedNotes = {}
	if ui.notesList then
		ui.notesList.REMOVEITEM = "ALL"
		iup.Refresh(ui.notesList)
	end
end

--- Remove selected entries from the Notes/AutoText list
function removeSelectedNotes()
	local list = ui.notesList
	if not list then
		return
	end
	local positions = getSelectedValues(list, true)
	local count = #positions
	if count == 0 then
		MessageBox("info", "Select one or more notes/autotext entries to remove.")
		return
	end
	if not confirmRemoveSelected("Notes", count) then
		return
	end
	removeSelectedItems(list, selectedNotes, populateNotesList)
end

--- View/Edit the currently selected note or autotext entry
function editSelectedNoteOrAutotext()
	local list = ui.notesList
	if not list then
		return
	end
	local positions = getSelectedValues(list, true)
	if #positions == 0 then
		MessageBox("info", "Select a note or AutoText to view/edit.")
		return
	end
	if #positions > 1 then
		MessageBox("warning", "Please select only one item to view/edit.")
		return
	end
	local index = tonumber(positions[1]) or 0
	if index < 1 or index > #selectedNotes then
		return
	end
	local entry = selectedNotes[index]

	-- Editing an existing Note record
	if entry and entry.recordPointer and entry.recordPointer:IsNotNull() then
		local ptrText = fhGetItemPtr(entry.recordPointer, "~.TEXT")
		if not (ptrText and ptrText:IsNotNull()) then
			MessageBox("error", "Could not locate the TEXT field for this note.")
			return
		end
		local currentRt = fhGetValueAsRichText(ptrText)
		local originalText = currentRt:GetText()
		local editedRt = fhPromptUserForRichText(currentRt)
		if not editedRt then
			return -- user cancelled
		end
		local editedText = editedRt:GetText()
		if originalText == editedText then
			-- No changes made, just viewed
			return
		end
		local ok = fhSetValueAsRichText(ptrText, editedRt)
		if not ok then
			MessageBox("error", "Failed to save edited note content.")
			return
		end
		-- Refresh display text for the edited note
		entry.displayText = fhGetDisplayText(entry.recordPointer)
			.. " (ID: "
			.. fhGetRecordId(entry.recordPointer)
			.. ")"
		-- Report change in results
		myResults.Update({
			currentNoteType:gsub("s$", "") .. " Edited",
			fhNewItemPtr(), -- no specific target
			entry.recordPointer:Clone(),
		})
		populateNotesList()
		fhUpdateDisplay()
		return
	end

	-- Editing an AutoText template file
	if entry and entry.type == "autotext" and entry.filePath then
		local content, err = fhfu.readTextFile(entry.filePath, true, 8)
		if not content then
			MessageBox(
				"error",
				"AutoText file could not be read: "
					.. (entry.displayText or entry.filePath)
					.. (err and (" - " .. err) or "")
			)
			return
		end
		local rt = fhNewRichText()
		rt:SetText(content, true, true)
		local originalRtString = rt:GetText()
		local editedRt = fhPromptUserForRichText(rt)
		if not editedRt then
			return -- user cancelled
		end
		local editedRtString = editedRt:GetText()
		if originalRtString == editedRtString then
			-- No changes made, just viewed
			return
		end
		local success, error = fhfu.createTextFile(entry.filePath, true, true, editedRtString, 8)
		if success then
			MessageBox("info", "AutoText template saved: " .. (entry.displayText or entry.filePath))
			-- Report change in results
			myResults.Update({
				"AutoText Edited: " .. (entry.displayText or entry.filePath),
				fhNewItemPtr(), -- target N/A
				fhNewItemPtr(), -- note N/A (file-based)
			})
		else
			MessageBox("error", "Failed to save AutoText template: " .. (error or "unknown error"))
		end
		return
	end

	MessageBox("error", "Unsupported selection type for view/edit.")
end

--- Ask user to confirm clearing the Notes list before a context-changing action
--- @return boolean proceed True if user confirmed
function confirmClearNotes(contextLabel, newValue)
	local function notesExistToClear()
		local notesList = ui.notesList
		if not notesList then
			return false
		end
		local count = tonumber(notesList.COUNT) or 0
		return count > 0
	end
	if not notesExistToClear() then
		return true
	end
	local answer = MessageBox(
		"question",
		"Switching "
			.. contextLabel
			.. " to '"
			.. tostring(newValue)
			.. "' will clear the selected Notes list. Do you want to continue?",
		"YESNO"
	)
	if answer == "Yes" then
		clearNotesList()
		return true
	end
	return false
end

--- Ask for confirmation before removing selected list items
--- @param listLabel string Human-readable list name (e.g., "Targets", "Notes")
--- @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

--- Get the current note type from configuration or default
---@return string
function getCurrentNoteType()
	if myConfig:getBool("Preferences", "useLastSettings", true) then
		return myConfig:getString("Preferences", "noteType", NOTE_TYPE_SHARED)
	else
		return NOTE_TYPE_SHARED
	end
end

--- Get the current operation mode from configuration or default
---@return string
function getCurrentOperationMode()
	if myConfig:getBool("Preferences", "useLastSettings", true) then
		return myConfig:getString("Preferences", "operationMode", MODE_SELECT_EXISTING)
	else
		return MODE_SELECT_EXISTING
	end
end

--- Save current preferences to configuration
function savePreferences()
	if myConfig:getBool("Preferences", "useLastSettings", true) then
		myConfig:setValues("Preferences", nil, {
			noteType = currentNoteType,
			operationMode = currentOperationMode,
		})
	end
end

--- Update the content area when mode or note type changes
function updateContentArea()
	if not (ui.contentArea and ui.targetRecordsList and ui.notesList and ui.notesLabel) then
		return
	end

	-- Update label title and list selection mode
	if currentOperationMode == MODE_SELECT_EXISTING then
		ui.notesLabel.title = "Existing " .. currentNoteType .. ":"
		ui.notesList.multiple = "YES"
		ui.notesList.tip =
			"Selected existing notes. Use the Notes menu to add or remove.\n\nShortcuts:\n- Ctrl+N: New\n- Ctrl+E: Edit selected\n- Ctrl+A: Select all\n- Enter/Space: View/Edit selected\n- Del: Remove selected"
	else
		ui.notesLabel.title = "AutoText for " .. currentNoteType .. ":"
		ui.notesList.multiple = "YES"
		ui.notesList.tip =
			"Selected AutoText templates for note creation. Use the Notes menu to add or remove.\n\nShortcuts:\n- Ctrl+N: New\n- Ctrl+E: Edit selected\n- F5: Refresh list\n- Ctrl+A: Select all\n- Enter/Space: View/Edit selected\n- Del: Remove selected"
	end
	-- Repopulate the lists
	populateTargetRecords()
	populateNotesList()
	iup.Refresh(dlgmain)
	updateMenuTitles()
	updateMenuChecks()
	updateAddNotesDialogTitle()
end

-- Handle state transitions
function changeNoteType(newType)
	if currentNoteType == newType then
		return iup.DEFAULT
	end
	if not confirmClearNotes("Note Type", newType) then
		updateMenuChecks()
		return iup.DEFAULT
	end
	currentNoteType = newType
	savePreferences()
	updateContentArea()
	updateMenuTitles()
	updateMenuChecks()
	updateTargetMenuStates() -- Update target menu item states
	updateAddNotesDialogTitle()
	return iup.DEFAULT
end

function changeMode(newMode)
	if currentOperationMode == newMode then
		return iup.DEFAULT
	end
	if
		not confirmClearNotes(
			"Mode",
			(newMode == MODE_SELECT_EXISTING) and "Select Existing Notes" or MODE_CREATE_FROM_AUTOTEXT
		)
	then
		updateMenuChecks()
		return iup.DEFAULT
	end
	currentOperationMode = newMode
	savePreferences()
	updateContentArea()
	updateMenuTitles()
	updateMenuChecks()
	updateAddNotesDialogTitle()
	return iup.DEFAULT
end

--- Create the content area with two separate boxes side by side
---@return iup.vbox
function createContentArea()
	local content = iup.vbox({ expand = "YES" })
	ui.contentArea = content

	-- Create the two boxes side by side
	local leftBox = iup.vbox({ expand = "YES" })
	local rightBox = iup.vbox({ expand = "YES" })

	-- Target records box (left side)
	local targetLabel = makeLongLabel({
		title = "Target Records:",
	})
	iup.Append(leftBox, targetLabel)

	local targetList = makeList({
		dropdown = "NO",
		expand = "YES",
		multiple = "YES",
		visiblelines = "12",
		name = "targetRecordsList",
		tip = "Selected target records. Use the Targets menu to add or remove.\n\nUse the File menu to add notes to the selected records\n\nShortcuts:\n- Ctrl+I: Select Individuals\n- Ctrl+F: Select Families\n- Ctrl+T: Clear Targets\n- Ctrl+A: Select all\n- Del: Remove selected",
	})
	iup.Append(leftBox, targetList)
	ui.targetRecordsList = targetList

	-- Keyboard shortcuts for targets 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_cT then -- Ctrl+T: clear targets
			selectedTargets = {}
			populateTargetRecords()
			return iup.IGNORE
		elseif c == iup.K_DEL then -- Delete: remove selected
			removeSelectedTargets()
			return iup.IGNORE
		end
		return iup.CONTINUE
	end

	-- Notes/Autotext box (right side)
	local notesLabel = makeLongLabel({
		title = (currentOperationMode == MODE_SELECT_EXISTING) and ("Existing " .. currentNoteType .. ":")
			or ("AutoText Templates for " .. currentNoteType .. ":"),
	})
	iup.Append(rightBox, notesLabel)
	ui.notesLabel = notesLabel

	local notesList = makeList({
		dropdown = "NO",
		expand = "YES",
		multiple = "YES",
		visiblelines = "12",
		name = "notesList",
		tip = (function()
			local common =
				"\n\nUse the Notes menu to add or remove, or to change the Note Type or Mode.\n\nUse the Apply menu to add notes to the selected records.\n\nShortcuts:\n- Ctrl+N: New\n- Ctrl+E: Edit selected\n- F5: Refresh list\n- Ctrl+A: Select all\n- Enter/Space: View/Edit selected\n- Del: Remove selected"
			local prefix = (currentOperationMode == MODE_SELECT_EXISTING) and "Selected existing notes."
				or "Selected AutoText templates for note creation."
			return prefix .. common
		end)(),
	})
	iup.Append(rightBox, notesList)
	ui.notesList = notesList

	-- Keyboard shortcuts for notes list
	notesList.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_cN then -- Ctrl+N: new note/autotext
			createNewNote()
			return iup.IGNORE
		elseif c == iup.K_cE then -- Ctrl+E: edit selected
			editSelectedNoteOrAutotext()
			return iup.IGNORE
		elseif c == iup.K_F5 then -- Refresh list
			populateNotesList()
			return iup.IGNORE
		elseif c == iup.K_CR or c == iup.K_SP then -- Enter or Space: view/edit
			editSelectedNoteOrAutotext()
			return iup.IGNORE
		elseif c == iup.K_DEL then -- Delete: remove selected
			removeSelectedNotes()
			return iup.IGNORE
		end
		return iup.CONTINUE
	end

	-- Put the two boxes side by side using a grid to unify widths
	local grid = makeGridbox({
		leftBox,
		rightBox,
		numdiv = "2",
		alignmentlin = "ATOP",
		expandchildren = "HORIZONTAL",
		shrink = "YES",
	})
	iup.Append(content, grid)

	return content
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
				local text = target.displayText or tostring(target)
				if not isRecordTypeSupportedForNoteType(target.recordType, currentNoteType) then
					text = text .. " (not supported for " .. currentNoteType .. ")"
				end
				table.insert(displayTexts, text)
			end

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

		updateApplyMenuState()
	end
end

--- Populate the notes/autotext list
function populateNotesList()
	local notesList = ui.notesList
	if notesList then
		-- Clear the list first
		notesList.REMOVEITEM = "ALL"

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

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

		updateApplyMenuState()
	end
end

--------------------------------------------------------------
--TARGET AND NOTE SELECTION FUNCTIONALITY
--------------------------------------------------------------

--- Convert file paths to autotext template format
--- @param filePaths string[] Array of file paths
--- @return table[] Array of autotext template objects
function convertFilePathsToTemplates(filePaths)
	local templates = {}
	for _, filePath in ipairs(filePaths) do
		-- Use fhFileUtils.splitPath to decompose the path
		local pathParts = fhfu.splitPath(filePath)

		-- Create relative path under autotext root for display
		local relativePath = filePath:sub(#AUTOTEXT_DIR + 2) -- Remove root dir + separator
		local displayText = relativePath:gsub("\\", " / ") -- Replace backslashes with forward slashes for readability
		displayText = displayText:gsub("%." .. EXT_AUTOTEXT .. "$", "") -- Remove .ftf extension
		local template = {
			type = "autotext",
			filePath = filePath,
			fileName = pathParts.filename,
			baseName = pathParts.basename,
			directory = pathParts.parent,
			extension = pathParts.ext,
			displayText = displayText,
		}
		table.insert(templates, template)
	end
	return templates
end
--- @class RecordTypeInfo
--- @field tag string The record type tag (e.g., "INDI", "FAM", "SOUR")
--- @field displayName string Human-readable name for the record type
--- @field menuName string Menu string for the record type
--- @field isSupported boolean Whether this record type is supported in the current mode
--- @field hasRecords boolean Whether there are any records of this type in the current project
--- @field count number Number of records of this type in the current project

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

--- Gets information about all available record types
--- @param supportedTypes? table<string, boolean> Table of record type tags that are supported in current mode
--- @return RecordTypeInfo[] Array of record type information
function getRecordTypesInfo(supportedTypes)
	supportedTypes = supportedTypes or {}
	local recordTypes = {}

	-- Get count of record types
	local iCount = fhGetRecordTypeCount()

	-- Loop through record types and gather information
	for i = 1, iCount do
		local tag = fhGetRecordTypeTag(i)
		local isSupported = supportedTypes[tag] ~= false -- Default to supported unless explicitly disabled

		-- Create display name from tag (convert INDI to Individual, FAM to Family, etc.)
		local displayName = displayNames[tag] or tag
		local menuName = menuNames[tag] or (displayName .. "s")

		table.insert(recordTypes, {
			tag = tag,
			displayName = displayName,
			menuName = menuName,
			isSupported = isSupported,
		})
	end

	return recordTypes
end

--- Prompts user to select records of a specific type
--- @param recordTag string The record type tag to select
--- @param parentWindow? any Parent window handle for record selection
function selectTargetRecords(recordTag, parentWindow)
	-- Get parent window handle
	local hParentWnd = getParentWindowHandle(parentWindow)

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

	if selectedRecords and #selectedRecords > 0 then
		-- Convert to target record format and add to selected targets
		local targets = convertRecordsToStandardFormat(selectedRecords, recordTag)
		addRecordsWithDuplicateCheck(targets, selectedTargets, populateTargetRecords)
	end
end

--- Prompts user to select existing notes
--- @param noteType string The note type ("Shared Notes" or "Research Notes")
--- @param parentWindow? any Parent window handle for record selection
function selectExistingNotes(noteType, parentWindow)
	-- Get parent window handle
	local hParentWnd = getParentWindowHandle(parentWindow)

	-- Determine the record tag based on note type
	local recordTag = (noteType == NOTE_TYPE_RESEARCH) and TAG_RESEARCH_NOTE or TAG_NOTE

	-- Prompt user for note selection
	local selectedRecords = fhPromptUserForRecordSel(recordTag, -1, hParentWnd)

	if selectedRecords and #selectedRecords > 0 then
		-- Build note entries directly without prefixes
		local notes = {}
		for i = 1, #selectedRecords do
			local ptr = selectedRecords[i]
			table.insert(notes, {
				recordPointer = ptr,
				displayText = fhGetDisplayText(ptr) .. " (ID: " .. fhGetRecordId(ptr) .. ")",
				recordType = recordTag,
				noteType = noteType,
			})
		end
		addRecordsWithDuplicateCheck(notes, selectedNotes, populateNotesList)
	end
end

--- Present Autotext file selection dialog
function selectAutotextViaTree()
	-- Use simple file dialog instead of complex FileSelector
	local filedlg = iup.filedlg({
		dialogtype = "OPEN",
		title = "Select AutoText Template(s)",
		directory = AUTOTEXT_DIR,
		extfilter = "AutoText Templates (*.ftf)|*.ftf",
		multiple = "YES",
		nochangedir = "YES",
		parentdialog = dlgmain,
	})

	filedlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)

	if filedlg.status == "1" or filedlg.status == "0" then
		local selectedPaths = {}
		local value = filedlg.value
		if value and value ~= "" then
			-- Handle multiple file selection
			if filedlg.status == "1" then
				-- Single file
				table.insert(selectedPaths, value)
			else
				-- Multiple files - value contains multiple paths separated by semicolons
				for path in value:gmatch("[^;]+") do
					path = path:match("^%s*(.-)%s*$") -- trim whitespace
					if path ~= "" then
						table.insert(selectedPaths, path)
					end
				end
			end
		end

		filedlg:destroy()

		if #selectedPaths > 0 then
			-- Convert selected file paths to autotext template format
			local templates = convertFilePathsToTemplates(selectedPaths)
			-- Add templates to selected notes with duplicate checking
			addRecordsWithDuplicateCheck(templates, selectedNotes, populateNotesList)
		end
	else
		filedlg:destroy()
	end
end

--- Gets initial targets from current selection or property box
--- @param initialTargets? TargetRecord[] Pre-defined initial targets
--- @return TargetRecord[] Array of initial targets
function getInitialTargets(initialTargets)
	if initialTargets and #initialTargets > 0 then
		return initialTargets
	end
	local targets = {}

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

	return targets
end

--- Get the supported record types based on current note type and mode
--- @return table<string, boolean> Table of supported record type tags
function getSupportedRecordTypes()
	local supportedTypes = {}

	-- Get all record types and check their support status
	local iCount = fhGetRecordTypeCount()
	for i = 1, iCount do
		local tag = fhGetRecordTypeTag(i)
		supportedTypes[tag] = isRecordTypeSupportedForNoteType(tag, currentNoteType)
	end

	return supportedTypes
end

--- Check if a record type is supported for a specific note type
--- @param recordTag string The record type tag to check
--- @param noteType string The note type to check ("Shared Notes" or "Research Notes")
--- @return boolean True if the record type is supported for the note type
function isRecordTypeSupportedForNoteType(recordTag, noteType)
	-- Check if it's completely unsupported
	for _, tag in ipairs(recordTypeSupport.completelyUnsupported) do
		if tag == recordTag then
			return false
		end
	end

	-- Check note-type-specific restrictions
	if noteType == NOTE_TYPE_RESEARCH then
		for _, tag in ipairs(recordTypeSupport.researchNotesUnsupported) do
			if tag == recordTag then
				return false
			end
		end
	else -- Shared Notes
		for _, tag in ipairs(recordTypeSupport.sharedNotesUnsupported) do
			if tag == recordTag then
				return false
			end
		end
	end

	return true
end

--- Get all record types that are supported for a specific note type
--- @param noteType string The note type to check ("Shared Notes" or "Research Notes")
--- @return string[] Array of supported record type tags
function getSupportedRecordTypesForNoteType(noteType)
	local supportedTypes = {}

	local iCount = fhGetRecordTypeCount()
	for i = 1, iCount do
		local tag = fhGetRecordTypeTag(i)
		if isRecordTypeSupportedForNoteType(tag, noteType) then
			table.insert(supportedTypes, tag)
		end
	end

	return supportedTypes
end

--- Get the current record type support configuration
--- @return table The current record type support configuration
function getRecordTypeSupportConfig()
	return recordTypeSupport
end

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

--- Add new targets to the current selection
--- @param newTargets TargetRecord[] New targets to add
function addTargets(newTargets)
	addRecordsWithDuplicateCheck(newTargets, selectedTargets, populateTargetRecords)
end

--- Add new notes to the current selection
--- @param newNotes table[] New notes to add
function addNotes(newNotes)
	addRecordsWithDuplicateCheck(newNotes, selectedNotes, populateNotesList)
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 targets to remove.")
		return
	end
	if not confirmRemoveSelected("Targets", count) then
		return
	end
	removeSelectedItems(list, selectedTargets, populateTargetRecords)
end

--------------------------------------------------------------
--MAIN DIALOG ACTIONS
--------------------------------------------------------------
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 lists initially
	initializeTargets()
	populateNotesList()

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

	-- Create the main dialog
	local dialog = makeDialog(mainVBox, {
		title = "Add Notes",
		size = "HALFxHALF",
		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,
	})

	-- Apply initial dynamic title reflecting current state
	dialog.title = getDialogTitle()

	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-Notes-4.fh_lua