Add Trees.fh_lua
--[[
@Title: Add Trees
@Type: standard
@Author: Helen Wright
@Version: 1.0
@LastUpdated: 24 September 2026
@Licence: This plugin is copyright (c) 2026 Helen Wright & contributors and is licensed under the MIT License which is hereby incorporated by reference (see https://pluginstore.family-historian.co.uk/fh-plugin-licence)
@Description: Generates a navigation chart for each selected individual: a three-generation SVG (parents, siblings, spouses/partners and children) with clickable links to each person's page. Writes one SVG file per individual and can embed the chart inline into the matching page of a Family Historian generated website.
]]
--
--[[ChangeLog:
Version 1.0: First public release. Draws a three-generation navigation chart for each
selected individual - parents, siblings, spouses/partners and children - saved as one SVG
file per person, and can embed each chart in the matching page of a Family Historian
website.
* Wide and Compact layouts; choice of all, birth-only or first parent family
* Colours, box shapes and web-safe or custom fonts; Classic, High Contrast and Match
System Theme schemes, plus your own saved themes
* Colour and/or shape can show sex or relationship (shape by sex is colour-blind-safe)
* Sizing: scale to a percentage, fit to page width (embedded), or exact width
* Privacy matching a Family Historian website: people flagged Private omitted, those
flagged Living shown without dates
* Website embedding: placement, alignment, caption, optional no-JavaScript collapsible,
configurable page prefix and CSS classes; confirmation before any website page is
changed
* Browser preview of your unsaved settings
* Results listed in Family Historian's Result Set; keyboard access throughout; windows
remember their size and position on each computer and follow the Windows light/dark/
High Contrast theme
]]
--------------------------------------------------------------
--INITIALISE (FH7 minimum + prompt to save so we act on current data)
--------------------------------------------------------------
-- fhInitialise must be the FIRST FH call. It enforces the minimum FH version (7) and, with
-- "save_recommended", offers to save unsaved changes first (Yes/No/Cancel; Cancel exits) so the
-- plugin operates on up-to-date data (and it avoids the OneDrive .ged sync-conflict risk).
fhInitialise(7, 0, 0, "save_recommended")
--------------------------------------------------------------
--EXTERNAL LIBRARIES
--------------------------------------------------------------
do
utf8 = require(".utf8"):init()
require("iuplua") -- UI
fh = require("fhUtils") --useful stuff
fhfu = require("fhFileUtils") -- utf8 compatible file handling library
end
-------------------------------------------------------------
--CONSTANTS
--------------------------------------------------------------
local cstrPluginVersion = "1.0" -- keep in step with @Version above (drives the About box)
---Per-project plugin data folder, falling back to the per-machine one when
---there is no project (fhGetPluginDataFileName("CURRENT_PROJECT") returns ""
---for a standalone GEDCOM file).
---@return string
function getPluginDataFolder()
local folder = fhGetPluginDataFileName("CURRENT_PROJECT", true)
if type(folder) ~= "string" or folder == "" then
folder = fhGetPluginDataFileName("LOCAL_MACHINE", true)
end
return folder
end
---The configuration scope matching that folder, for the Config instance.
---@return "CURRENT_PROJECT"|"LOCAL_MACHINE"
function getConfigScope()
local projectFolder = fhGetPluginDataFileName("CURRENT_PROJECT", true)
if type(projectFolder) == "string" and projectFolder ~= "" then
return "CURRENT_PROJECT"
end
return "LOCAL_MACHINE"
end
-- The project's Public folder is the natural home for website output and the
-- default SVG output folder; fall back to the plugin data folder when absent.
local cstrProjectPublicFolder = fhGetContextInfo("CI_PROJECT_PUBLIC_FOLDER") or ""
local cstrDefaultOutputFolder = (cstrProjectPublicFolder ~= "" and (cstrProjectPublicFolder .. "\\Add Trees"))
or (getPluginDataFolder() .. "\\Add Trees")
--<<FS_SPLICE "../../2 Boilerplate/Theme.lua">>
;(function()
--[[
Theme V1
@Author: Helen Wright
@Version: 1.0
@LastUpdated: 15 June 2026
@Description: Reads the Windows theme so IUP dialogs (and any other consumer)
can follow the user's light / dark / High Contrast preference.
Colours are read from HKCU\Control Panel\Colors via LuaCOM (WScript.Shell
RegRead), degrading gracefully to a light palette when the registry is
unavailable (e.g. under the WINE emulator). The registry reads are the only
Windows-bound part; everything else is pure.
Sets the global `Theme`. Two kinds of consumer:
* IUP dialog theming -> Theme.iupColours() / Theme.isDarkMode() / Theme.isHighContrast()
* a "Match System Theme" colour preset (e.g. for SVG) -> Theme.systemColours()
Load this before Dialog.lua so the dialog helpers pick up the theme; Dialog.lua
also falls back to a light palette if Theme has not been loaded.
@V1.0: Initial version (extracted from the Add Trees plugin).
]]
do
local M = {}
require("luacom")
---@class ThemeSystemColours
---@field window string|nil Background, "R G B" (Control Panel > Colors > Window)
---@field windowText string|nil Foreground, "R G B" (WindowText)
---@field hilight string|nil Selection background, "R G B" (Hilight)
---@field hilightText string|nil Selection text, "R G B" (HilightText)
---@field hotTracking string|nil Hot-tracking colour, "R G B" (HotTrackingColor)
---Cached colours so the registry is read at most once per run.
---@type ThemeSystemColours|nil
local cachedColours
---@type boolean
local cacheLoaded = false
---Read one registry value, returning nil on any failure.
---@param key string Full registry path including the value name.
---@return string|nil
local function getRegKey(key)
local ok, value = pcall(function()
local shell = luacom.CreateObject("WScript.Shell")
return shell:RegRead(key)
end)
if ok then
return value
end
return nil
end
---The raw Windows theme colours as "R G B" strings, or nil when the
---registry is unavailable or malformed (emulators included).
---@return ThemeSystemColours|nil
function M.systemColours()
if cacheLoaded then
return cachedColours
end
cacheLoaded = true
if os.getenv("WINEPREFIX") ~= nil then
cachedColours = nil -- emulator: registry colours unreliable
return nil
end
local root = "HKEY_CURRENT_USER\\Control Panel\\Colors\\"
local window = getRegKey(root .. "Window")
if type(window) ~= "string" or not window:match("^%d+%s+%d+%s+%d+$") then
cachedColours = nil
return nil
end
cachedColours = {
window = window,
windowText = getRegKey(root .. "WindowText"),
hilight = getRegKey(root .. "Hilight"),
hilightText = getRegKey(root .. "HilightText"),
hotTracking = getRegKey(root .. "HotTrackingColor"),
}
return cachedColours
end
---True when Windows is in dark (apps) mode. Modern Windows leaves the legacy
---Control Panel > Colors at the classic white even in dark mode, so this
---reads the AppsUseLightTheme switch instead (0 = dark).
---@return boolean
function M.isDarkMode()
local v = getRegKey(
"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize\\AppsUseLightTheme"
)
return tonumber(v) == 0
end
---True when a Windows High Contrast theme is active (bit 0 of the
---Accessibility HighContrast Flags). Those themes set the legacy Control
---Panel colours correctly, so honour them.
---@return boolean
function M.isHighContrast()
local v = getRegKey("HKEY_CURRENT_USER\\Control Panel\\Accessibility\\HighContrast\\Flags")
return (tonumber(v) or 0) % 2 == 1
end
---Colours for theming the IUP dialogs, in IUP's "R G B" form. A High
---Contrast theme sets the Control Panel colours correctly, so use them;
---otherwise modern dark mode gives a dark palette and the default is light.
---@return {bg: string, fg: string}
function M.iupColours()
local colours = M.systemColours()
-- High Contrast theme: the Control Panel colours are set for it; use them.
if M.isHighContrast() then
return {
bg = (colours and colours.window) or "0 0 0",
fg = (colours and colours.windowText) or "255 255 255",
}
end
-- Otherwise the modern light/dark app theme decides. A plain custom
-- Control Panel colour no longer hijacks this (which left dark mode
-- looking half-themed).
if M.isDarkMode() then
return { bg = "32 32 32", fg = "245 245 245" }
end
return { bg = "255 255 255", fg = "0 0 0" }
end
---The system's SELECTION colour pair (Hilight/HilightText), in IUP's "R G B" form (added 10
---Jul 2026: a High Contrast theme's Window colour IS the dialog background, so tinting it -
---darkening it a fixed percentage, as iupColours()-based callers do for a title strip - has
---nothing to darken FROM under pure black/white High Contrast palettes; the strip collapses
---invisibly into the frame. The selection pair is the theme's own answer to "a colour that
---contrasts with the window background", so it survives High Contrast where a computed tint
---cannot. Falls back to iupColours() when the registry has no Hilight/HilightText (e.g. the
---WINE emulator) so a caller never gets a nil.
---@return {bg: string, fg: string}
function M.highlightColours()
local colours = M.systemColours()
if colours and colours.hilight and colours.hilightText then
return { bg = colours.hilight, fg = colours.hilightText }
end
return M.iupColours()
end
_G.Theme = M
end
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE "../../2 Boilerplate/Help.lua">>
;(function()
--[[
@class Help
@desc Provides online HTML help for Family Historian plugins, with context-sensitive support.
@prerequisites
Online Help:
- A valid help_root URL pointing to the online help location for your plugin.
@usage
-- Paste the Help class definition at the top of your script.
local help = Help.new{version="1.0"}
-- Add to menu: help:menu_item("topic")
-- Show help: help:show("topic")
-- Wire up IUP HELP_CB: help:attach_help_cb(control, "topic")
]]
do
local M = {}
local iup = require("iuplua")
---@class Help
---@field plugin_name string The name of the plugin.
---@field help_root string The root URL for online help.
local Help = {}
Help.__index = Help
---Create a new Help object
---@param opts {help_root?: string}
---@return Help
function M.new(opts)
local self = setmetatable({}, Help)
self.plugin_name = fhGetContextInfo("CI_PLUGIN_NAME")
self.help_root = opts and opts.help_root
or ("http://pluginstore.family-historian.co.uk/page/help/" .. self.plugin_name)
return self
end
---Open help for a topic (online only)
---@param topic string The help topic: can be a page ("options"), full page path ("guides/options.html"), or an anchor on index ("#options")
function Help:show(topic)
topic = topic or ""
-- Supports either a standalone page at help_root/topic or an anchor on index (when topic starts with '#').
local sep = self.help_root:sub(-1) == "/" and "" or "/"
local url
if topic:sub(1, 1) == "#" then
-- Anchor on index page: append fragment directly (no extra slash)
url = self.help_root .. topic
else
-- Treat as a page or path relative to help_root
url = self.help_root .. sep .. topic
end
-- basic normalization
url = url:gsub("%%20", "-")
url = url:gsub(" ", "-")
fhShellExecute(url)
end
---Create a menu item for help
---@param topic string The help topic to show when the menu item is clicked
---@param label string The label for the menu item
---@return iup.menuitem #The IUP menu item object
function Help:menu_item(topic, label)
label = label or "Help"
return iup.item({
title = label,
action = function()
self:show(topic)
end,
})
end
---Attach context help to an IUP control via help_cb
---@param control iup.control The IUP control to attach help to
---@param topic string The help topic to show when help is requested
function Help:attach_help_cb(control, topic)
control.help_cb = function()
self:show(topic)
return iup.IGNORE
end
end
_G.Help = M
end
end)()
--<<FS_SPLICE_END>>
-- Resolve Help once to a real instance so consumers can assume it exists
help = Help.new({})
--<<FS_SPLICE "../../2 Boilerplate/Dialog.lua">>
;(function()
--[[
Dialog V8
@Author: Helen Wright
@Version: 1.10
@LastUpdated: 21 July 2026
@Description: Helper functions for IUP dialogs
@V1.0: Initial version.
@V1.2: Dialogs follow the Windows light / dark / High Contrast theme via the
Theme helper (Theme.lua). Falls back to the light palette when Theme is
not loaded.
@V1.3: EnableContainer/ClearContainer iterate children with iup.GetNextChild.
Integer indexing (ih[i]) only reflects constructor-time children, so
both functions silently skipped anything appended at runtime.
@V1.4: New refloorDialog(dlg): re-floor a dialog at its natural size after a
content change, growing a shown window that now clips (rastersize) and
capping at the screen height. Centralised from the consumer plugins.
@V1.5: RebuildContainer gains destroyOld (detach-only rebuilds leak) and maps
appended children when the container is already shown.
@V1.6: refloorDialog reads the window's real size BEFORE applying the minsize,
and carried a WRONG diagnosis ("the host ignores resizes of shown
dialogs") - corrected in V1.7.
@V1.7: The real culprit (Spike S7, 6 Jul 2026): with shrink=YES - which this
theme sets on every dialog - a SHOWN dialog reports NATURALSIZE equal
to its CURRENT size, so every grow-to-natural computation is a no-op
tautology and resizes "look" ignored. The host resizes shown dialogs
perfectly well. refloorDialog now measures the natural size with
shrink temporarily OFF, then restores it. A dialog whose content can
outgrow the screen still wants a scrollbox (Add Facts's capture
surface), but ordinary content-growth now genuinely grows the window.
Also: handle registration survives re-using a name whose previous
owner was destroyed (safeSetHandle; iuplua's SetHandle errors on the
dead handle - makeButton registers by TITLE, so a destroy-and-recreate
dialog hit this on its second open).
@V1.8: options.on_escape actually fires under the FH host (live 6 Jul 2026):
the dialog k_any never sees Esc there, so on_escape (without a cancel
button) now also creates a hidden, layout-ignored button firing the
handler and points native DEFAULTESC at it - the mechanism the
accessibility pass live-verified. The k_any dispatch is kept for
non-host environments. Call sites are unchanged.
@V1.9: RebuildContainer gains skipRefresh - batched rebuilds refresh once, not
per call.
@V1.10: refloorDialog now also clears the dialog's SIZE user size (dlg.size = iup.NULL)
alongside minsize before re-measuring - leaving it set inflated every re-measure
to the SIZE hint (e.g. a HALFxHALF main dialog), the same family of bug as
Config.lua's V1.3 showTrackedDialog fix. (Shared Config.lua/Dialog.lua
improvement.)
]]
--ENHANCE: Rich Text field. This could be quite difficult -- can't access WebView2 as it isn't available as an OLE control.
---@global iup table The IUP library global variable created by require("iuplua")
do
-- Load necessary libraries
require("iuplua") -- IUP library for GUI components
require("iupluacontrols") --additional iup controls
require("pl.init") -- Penlight library for additional utilities
local tablex = require("pl.tablex") --be explicit about which parts of Penlight I'm using
require("luacom")
fh = require("fhUtils")
-- makeDialog registers each dialog's independent-normalisation re-apply hook here (weak keys, so the
-- entry goes when the dialog does). Declared in the OUTER do-scope so both the theming block (which
-- defines renormalizeDialog) and the Dialog-handling block (makeDialog) can see it.
local dialogRenorm = setmetatable({}, { __mode = "k" })
-- fhUtils.setIupDefaults() is buggy in emulated (WINE) environments: the PDX Font registry read
-- returns nil in some cases and the old code then errored parsing it. Do the equivalent setup inline
-- here, with a nil-guard. This is the ONE place to maintain it; when fhUtils.setIupDefaults is fixed,
-- replace this whole block with a plain fh.setIupDefaults() call again.
do
local stringx = require("pl.stringx") -- splitv, to parse the comma-separated PDX Font value
local function getRegKey(key)
local sh = luacom.CreateObject("WScript.Shell")
local ans
if pcall(function()
ans = sh:RegRead(key)
end) then
return ans
end
return nil, true
end
iup.SetGlobal("CUSTOMQUITMESSAGE", "YES")
local v = getRegKey("HKEY_CURRENT_USER\\Software\\Calico Pie\\Family Historian\\2.0\\Preferences\\PDX Font")
if v == nil then
-- Registry read returned nil (some emulated environments): leave the system default font.
else
local t = { stringx.splitv(v, ",") } -- 1st field is the size (twips), 14th is the font name
iup.SetGlobal("DEFAULTFONT", t[14] .. " " .. t[1] / 20)
end
iup.SetGlobal("UTF8MODE", "YES")
iup.SetGlobal("UTF8MODE_FILE", "YES") -- use UTF8 file names (matches fhUtils.setIupDefaults)
fhSetStringEncoding("UTF-8")
end
-- Use the global help instance resolved by the main plugin (referenced as `help`)
-- Resolve the dark/light theme once for every helper below. Theme is set by
-- Theme.lua; fall back to a light palette if a plugin hasn't loaded it, so
-- this dialog helper still works standalone.
local th = (Theme and Theme.iupColours and Theme.iupColours()) or { bg = "255 255 255", fg = "0 0 0" }
-- Plain dark mode (Windows dark, but NOT High Contrast): native Win32
-- controls (push-buttons, the menu) keep a LIGHT face that IUP can't repaint
-- from a plugin, so their text must stay dark to be readable. High Contrast
-- is themed by the OS, so it uses the normal th colours.
local plainDark = Theme ~= nil and Theme.isDarkMode and Theme.isDarkMode()
and not (Theme.isHighContrast and Theme.isHighContrast())
do --colours, layout, theming
-- Create normalizers for consistent sizing
btnnorm = iup.normalizer({}) --buttons
btnshortnorm = iup.normalizer({}) --short buttons
textnorm = iup.normalizer({}) --text fields and lists
dlgnorm = iup.normalizer({}) --dialogs
donotnorm = iup.normalizer({}) --items that should not be normalized
-- Normalize all GUI components to have a consistent layout
function DoNormalize()
btnnorm.normalize = "HORIZONTAL"
btnshortnorm.normalize = "HORIZONTAL"
textnorm.normalize = "HORIZONTAL"
dlgnorm.normalize = "HORIZONTAL"
end
-- Size a dialog's controls with FRESH per-dialog normalisers, so each dialog normalises
-- INDEPENDENTLY of every other. The shared global normalisers (btnnorm/textnorm) entangle
-- dialogs: re-normalising them when one dialog opens resizes the controls of every other dialog
-- already built (e.g. opening Options reshaped the reused Fact Types picker). This walks the
-- dialog tree, moving its buttons/toggles into a local button normaliser and its text/list
-- controls into a local text normaliser, then sizes them - touching nothing outside this dialog.
-- Labels keep their own handling (makeLabel's normaliser / makeLongLabel's opt-out / Config's
-- labelNorm). Returns a function that re-applies the sizing, for a REUSED dialog whose content
-- changed (call it after repopulating, before showing).
function normalizeIndependently(dlg)
local btnN = iup.normalizer({})
local txtN = iup.normalizer({})
local function walk(el)
local ok, cls = pcall(iup.GetClassName, el)
if ok then
if cls == "button" or cls == "toggle" then
el.normalizergroup = btnN
elseif cls == "text" or cls == "list" then
el.normalizergroup = txtN
end
end
local child = iup.GetNextChild(el)
while child do
walk(child)
child = iup.GetNextChild(el, child)
end
end
walk(dlg)
local function apply()
btnN.normalize = "HORIZONTAL"
txtN.normalize = "HORIZONTAL"
end
apply()
return apply
end
-- makeDialog stores each dialog's re-apply hook in dialogRenorm (declared in the outer scope so
-- the separate Dialog-handling do-block can see it). A REUSED dialog whose content changed calls
-- renormalizeDialog(dlg) after repopulating, before showing.
function renormalizeDialog(dlg)
local fn = dialogRenorm[dlg]
if fn then fn() end
end
--- Re-floor a dialog after its content changed (Stage 4, 5 Jul 2026 - centralised from the
--- consumer plugins, which each carried a copy of this logic): re-measure the natural size,
--- floor future resizes there (minsize), GROW the window when its current size now clips
--- content (minsize alone never grows a shown window - rastersize must be set explicitly),
--- and cap at the screen height so growth never pushes controls off the bottom. Call after
--- rebuilding or appending content in a shown dialog (after renormalizeDialog + iup.Refresh).
function refloorDialog(dlg)
if not dlg then
return
end
-- Read the window's REAL size before measuring (V1.6), and measure the natural
-- size with shrink OFF (V1.7): with shrink=YES - this theme's default - a shown
-- dialog reports NATURALSIZE equal to its CURRENT size, which turned every
-- grow-to-natural below into a no-op tautology (Spike S7, 6 Jul 2026).
local cw, ch = tostring(dlg.rastersize or ""):match("^(%d+)x(%d+)$")
local oldShrink = dlg.shrink
dlg.shrink = "NO"
dlg.minsize = iup.NULL
-- Also clear the SIZE user size (V1.10): it is an initial-open hint, not a floor, and
-- left set it would inflate this re-measure to the SIZE value on a large display (the
-- same family of bug as Config.lua's showTrackedDialog). No restore - once shown, the
-- user size has done its job.
dlg.size = iup.NULL
iup.Refresh(dlg)
local nw, nh = tostring(dlg.naturalsize or ""):match("^(%d+)x(%d+)$")
dlg.shrink = oldShrink
if not nw then
iup.Refresh(dlg)
return
end
nw, nh = tonumber(nw), tonumber(nh)
local scrW, scrH = tostring(iup.GetGlobal("SCREENSIZE") or ""):match("^(%d+)x(%d+)$")
-- SCREENSIZE is the full monitor; leave an allowance for the taskbar and title bar.
local fitW = scrW and math.min(nw, tonumber(scrW)) or nw
local fitH = scrH and math.min(nh, tonumber(scrH) - 80) or nh
dlg.minsize = fitW .. "x" .. fitH
if cw and (tonumber(cw) < fitW or tonumber(ch) < fitH) then
dlg.rastersize = math.max(tonumber(cw), fitW) .. "x" .. math.max(tonumber(ch), fitH)
end
iup.Refresh(dlg) -- re-lay out with shrink restored (and apply any grow above)
end
-- Set global colours for the dialog theme
iup.SetGlobal("DLGBGCOLOR", th.bg) -- dialog background
iup.SetGlobal("TXTBGCOLOR", th.bg) -- text-field background
iup.SetGlobal("TXTFGCOLOR", th.fg) -- text foreground
-- Create theme objects for all IUP elements with minimal styling
local myTheme = iup.user({
IUPDIALOG = iup.user({
expand = "YES",
resize = "YES",
shrink = "YES",
size = iup.NULL,
menubox = "YES",
}),
IUPBUTTON = iup.user({
alignment = "ACENTER:ACENTER",
padding = "DEFAULTBUTTONPADDING",
normalizergroup = btnnorm,
expand = "NO",
-- Native button faces stay light even in dark mode, so in plain
-- dark mode keep a light face with dark text (readable); light and
-- High Contrast use the theme colours.
bgcolor = plainDark and "240 240 240" or th.bg,
fgcolor = plainDark and "32 32 32" or th.fg,
}),
IUPLIST = iup.user({
normalizergroup = textnorm,
editbox = "NO",
sort = "YES",
dropdown = "YES",
multiple = "NO",
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
}),
IUPTEXT = iup.user({
alignment = "ALEFT:ACENTER",
normalizergroup = textnorm,
wordwrap = "YES",
append = "YES",
scrollbar = "NO",
multiline = "NO",
visiblelines = "1",
readonly = "NO",
padding = "2x",
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
}),
IUPLABEL = iup.user({
wordwrap = "NO",
alignment = "ALEFT:ACENTER",
expand = "NO",
padding = "20x10",
fgcolor = th.fg, -- readable on the dialog background in every theme
}),
IUPGRIDBOX = iup.user({
gaplin = "10",
gapcol = "10",
alignmentlin = "ACENTER",
alignmentcol = "LEFT",
normalizesize = "YES",
expand = "YES",
expandchildren = "HORIZONTAL",
orientation = "HORIZONTAL",
numdiv = "2",
}),
IUPFRAME = iup.user({
expand = "YES",
expandchildren = "YES",
}),
IUPTABS = iup.user({
margin = "5x5",
gap = "5",
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
}),
IUPTOGGLE = iup.user({
alignment = "ALEFT:ACENTER",
normalizergroup = btnnorm,
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
}),
IUPEXPANDER = iup.user({
visible = "YES",
}),
IUPMATRIX = iup.user({
markmode = "CELL",
resizematrix = "YES",
scrollbar = "YES",
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
}),
IUPTREE = iup.user({
expand = "YES",
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
selection = "SINGLE",
showrename = "NO",
showdragdrop = "NO",
showtoggle = "NO",
addexpanded = "NO",
}),
IUPVBOX = iup.user({
gap = "5",
margin = "5x5",
expandchildren = "NO",
shrink = "YES",
}),
IUPHBOX = iup.user({
gap = "5",
margin = "5x5",
expandchildren = "NO",
shrink = "YES",
}),
IUPZBOX = iup.user({
expand = "YES",
}),
IUPSCROLLBOX = iup.user({
expand = "YES",
}),
IUPBACKGROUNDBOX = iup.user({
expand = "YES",
}),
IUPRADIO = iup.user({
expand = "NO",
}),
IUPPROGRESSBAR = iup.user({
expand = "HORIZONTAL",
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
}),
})
iup.SetHandle("myTheme", myTheme)
iup.SetGlobal("DEFAULTTHEME", "myTheme")
-- Tooltip theming for consistency
iup.SetGlobal("TIPBGCOLOR", "255 255 225") -- Light yellow
iup.SetGlobal("TIPFGCOLOR", th.fg) -- tooltip text
iup.SetGlobal("TIPFONT", "Segoe UI, 10")
-- Error styling helpers
function markError(control)
control.bgcolor = "255 0 0" -- Red background for errors
end
function clearError(control)
control.bgcolor = th.bg -- Reset to the themed background
end
-- Tip creation
--- Word-wrap tip text to a sensible width so long descriptions don't run off the screen as one
--- line (IUP tooltips honour \n but never wrap on their own). Existing line breaks are kept, and
--- each paragraph is wrapped on word boundaries; an over-long single word is left intact.
local function wrapTip(text, width)
width = width or 72
local lines = {}
for paragraph in ((text or "") .. "\n"):gmatch("(.-)\n") do
if paragraph == "" then
lines[#lines + 1] = ""
else
local line = ""
for word in paragraph:gmatch("%S+") do
if line == "" then
line = word
elseif #line + 1 + #word <= width then
line = line .. " " .. word
else
lines[#lines + 1] = line
line = word
end
end
lines[#lines + 1] = line
end
end
return table.concat(lines, "\n")
end
--- Sets a tooltip (tipballoon) with a title and appends help info if a help topic is specified.
--- @param control iup.element The control to set the tip for
--- @param tip string The tip text
--- @param tiptitle string|nil The tip title (optional)
--- @param help_topic string|nil The help topic (optional)
function setTipWithHelp(control, tip, tiptitle, help_topic)
local full_tip = wrapTip(tip or "")
if help_topic then
full_tip = full_tip .. "\n\nPress F1 for help."
end
control.tip = full_tip
-- Enable balloon style per-control (Windows only attribute)
control.tipballoon = "YES"
if tiptitle then
-- Set both, to support normal and balloon title variants
control.tiptitle = tiptitle
control.tipballoontitle = tiptitle
end
end
end
--- Register a handle name, surviving the case where the name was last used by a control
--- that has since been DESTROYED: iuplua's SetHandle errors while unregistering the dead
--- handle (live 6 Jul 2026 - a destroy-and-recreate dialog whose buttons register by
--- TITLE failed on its second open). Falls back to a fresh random name.
--- @param name string The preferred handle name
--- @param ih userdata The IUP element to register
function safeSetHandle(name, ih)
if not pcall(iup.SetHandle, name, ih) then
pcall(iup.SetHandle, generateRandomDigitString(), ih)
end
end
--- Generates a random string of digits
--- @return string 10 random digits
function generateRandomDigitString()
--TESTED
-- Seed the random number generator
math.randomseed(os.time())
local digits = ""
for i = 1, 10 do
-- Generate a random digit (0-9) and concatenate to the string
digits = digits .. math.random(0, 9)
end
return digits
end
local specialOptions = tablex.makeset({
"callback",
"name",
"values",
"killfocus",
"close",
"default",
"cancel",
"on_escape",
"accelerators",
}) ---options that aren't handled by mergeOptions
--- Merge user provided options with default settings.
-- This function creates a new configuration table based on default values,
-- where any provided user option overrides the corresponding default.
--- @param defaults table -- A table containing the default configuration options.
--- @param options table -- A table provided by the user that may override the default options.
--- @return table -- A new table with merged values from both input tables.
function mergeOptions(defaults, options)
--TESTED: mergeOptions
local config = {} -- Initialize a new table to avoid modifying the original 'options' table.
-- Iterate over the default options
for k, v in pairs(defaults) do
-- If an option is provided by the user, use it, otherwise use the default value.
config[k] = options[k] or v
end
-- Iterate over the user-provided options
for k, v in pairs(options) do
-- Add the option if it's not already in config and is not disallowed
if config[k] == nil and not specialOptions[k] then
config[k] = v
end
end
-- Return the newly created configuration table.
return config
end
--- Set options for an iup element
---@param element iup.element
---@param options table
function setOptions(element, options)
---TESTED: setOptions
for k, v in pairs(options) do
element[k] = v
end
end
do --Dialog handling
--- Returns a table with either {PARENTDIALOG = name} or {NATIVEPARENT = hwnd}
local function getParentDialogInfo()
local focus = iup.GetFocus()
if focus then
local parentDialogHandle = iup.GetDialog(focus)
if parentDialogHandle then
local parentDialogName = iup.GetName(parentDialogHandle)
if parentDialogName then
return { PARENTDIALOG = parentDialogName }
end
end
end
-- Fallback: use FH's main window handle
return { NATIVEPARENT = fhGetContextInfo("CI_PARENT_HWND") }
end
--- Get parent window handle for FH API calls that require a parent window
--- @param parentWindow? any Optional parent window handle
--- @return any Parent window handle suitable for FH API calls
function getParentWindowHandle(parentWindow)
local hParentWnd = parentWindow
if not hParentWnd then
-- Try to get the active dialog
local activeDialog = identifyActiveWindow()
if activeDialog then
hParentWnd = activeDialog.NATIVEPARENT
else
-- Fallback to FH's main window
hParentWnd = fhGetContextInfo("CI_PARENT_HWND")
end
end
return hParentWnd
end
--- @class DialogOptions
--- @field title string|nil Dialog window title
--- @field size string|nil Initial size (e.g., "HALFxHALF")
--- @field expand string|nil Expansion policy
--- @field resize string|nil Whether dialog is resizable ("YES"|"NO")
--- @field menubox string|nil Whether to show native menu box ("YES"|"NO")
--- @field menu iup.menu|nil Menu bar to attach
--- @field name string|nil Optional handle name to register
--- @field show fun(state:string)|nil Optional callback invoked on show
--- @field close fun(self:iup.dialog)|nil Optional callback invoked on close
--- @field help_topic string|nil Optional help topic for F1 help
--- @field default iup.button|nil Primary button; Enter activates it (native DEFAULTENTER, focus-aware)
--- @field cancel iup.button|nil Cancel button; Esc activates it (native DEFAULTESC)
--- @field on_escape fun(self:iup.dialog):any|nil Esc handler for menu-driven dialogs that have no cancel button; return iup.CLOSE (the default when it returns nil) to close
--- @field accelerators {key:integer, action:function, item:any}[]|nil Window-global key shortcuts (typically menuBarData.accelerators); each fires from anywhere in the dialog via k_any
--- Creates and configures a dialog with customization options.
--- @param content iup.element The content to be included in the dialog.
--- @param options? DialogOptions The options to configure the dialog.
--- @return iup.dialog
function makeDialog(content, options)
--TESTED: makeDialog
-- Default values for options (if not provided)
local defaults = {}
options = options or {}
-- Esc for dialogs with no cancel button (options.on_escape): under the FH host the
-- dialog's k_any never sees Esc (live 6 Jul 2026 - the k_any dispatch below stays for
-- non-host use), but IUP's native DEFAULTESC does. So on_escape gets a stand-in
-- button whose action fires it, and DEFAULTESC points at that button (set further
-- down, same registered-name idiom as options.cancel). The button must be MAPPED
-- AND VISIBLE: on Windows IUP activates the DEFAULTESC button with a real BM_CLICK,
-- which a hidden window discards (live 6 Jul 2026 - visible=NO silently broke it).
-- floating=YES keeps it out of the layout; 1x1 and canfocus=NO make it
-- imperceptible and untabbable.
local d -- forward-declared: the stand-in Esc button's action closes over the dialog
local hiddenEsc
if options.on_escape and not options.cancel then
hiddenEsc = iup.button({
title = "",
floating = "YES",
canfocus = "NO",
rastersize = "1x1",
action = function()
local r = options.on_escape(d)
return r == nil and iup.CLOSE or r
end,
})
content = iup.vbox({ content, hiddenEsc })
end
d = iup.dialog({ content })
setOptions(d, mergeOptions(defaults, options))
-- Wire dialog-level help if a help topic is provided
if options.help_topic and help then
d.help_cb = function()
help:show(options.help_topic)
return iup.IGNORE
end
end
-- Always identify and set the parent dialog
local parentInfo = getParentDialogInfo()
for k, v in pairs(parentInfo) do
iup.SetAttribute(d, k, v)
end
-- Register dialog with a unique handle for later reference. Re-registering a name
-- whose previous dialog was DESTROYED makes iuplua's SetHandle error while it
-- unregisters the dead handle (live 6 Jul 2026: a destroy-and-recreate chooser
-- failed on its second open) - fall back to a fresh random name.
local okHandle = pcall(iup.SetHandle, options.name or options.title or generateRandomDigitString(), d)
if not okHandle then
pcall(iup.SetHandle, generateRandomDigitString(), d)
end
-- Ensure layouts refresh properly on resize across monitors/resolutions
local originalResizeCb = d.resize_cb
d.resize_cb = function(self, width, height)
if originalResizeCb and originalResizeCb(self, width, height) == iup.CLOSE then
return iup.CLOSE
end
iup.Refresh(self)
return iup.DEFAULT
end
-- Default F1 behavior: let focused control handle help if it can; otherwise fall back to dialog help.
-- Also handle Esc for menu-driven dialogs that have no cancel button (see options.on_escape below).
local originalKAny = d.k_any
d.k_any = function(self, c)
if originalKAny then
local r = originalKAny(self, c)
if r == iup.CLOSE or r == iup.IGNORE then
return r
end
end
if c == iup.K_ESC and options.on_escape then
local r = options.on_escape(self)
return r == nil and iup.CLOSE or r
end
-- Window-global menu accelerators: fire the matching item's action from any focus. Return
-- iup.CLOSE if the action asked to close; otherwise consume the key (iup.IGNORE).
if options.accelerators then
for _, a in ipairs(options.accelerators) do
if c == a.key then
if a.action(a.item) == iup.CLOSE then
return iup.CLOSE
end
return iup.IGNORE
end
end
end
if c == iup.K_F1 then
local focused = iup.GetFocus()
if focused and focused.help_cb then
return iup.CONTINUE
end
if self.help_cb then
return self:help_cb()
end
return iup.IGNORE
end
return iup.CONTINUE
end
-- Opt-in keyboard defaults (accessibility). Enter activates the primary button, Esc the cancel
-- button, via IUP's native DEFAULTENTER/DEFAULTESC - which are focus-aware, so Enter is NOT
-- hijacked while the focus is in a multiline text or another button. Menu-driven dialogs that have
-- no cancel button pass options.on_escape instead (handled in k_any above) to get Esc-to-close.
-- DEFAULTENTER/DEFAULTESC are set here via a registered handle NAME. (Assigning the handle
-- directly - d.DEFAULTENTER = btn - also works: iuplua implements IupSetAttributeHandle
-- through attribute assignment, spike S6; there is deliberately no iup.SetAttributeHandle
-- function in the binding.) We register a fresh unique name so title-collisions between
-- buttons (makeButton registers by title, so two "OK"s would clash) can't point Enter at
-- the wrong one.
if options.default then
local defName = "defenter_" .. generateRandomDigitString()
iup.SetHandle(defName, options.default)
d.DEFAULTENTER = defName
end
if options.cancel then
local escName = "defesc_" .. generateRandomDigitString()
iup.SetHandle(escName, options.cancel)
d.DEFAULTESC = escName
elseif hiddenEsc then
-- on_escape without a cancel button: Esc activates the hidden button (see the
-- construction above) via the same native, focus-aware DEFAULTESC mechanism.
local escName = "defesc_" .. generateRandomDigitString()
iup.SetHandle(escName, hiddenEsc)
d.DEFAULTESC = escName
end
-- Normalise EVERY dialog with its own normalisers, so dialogs never share sizing (the global
-- btnnorm/textnorm entanglement that made one dialog's layout shift when another opened). This
-- makes independence the default - no plugin or dialog has to remember to ask for it.
dialogRenorm[d] = normalizeIndependently(d)
return d
end
--- Identifies the currently active window among the open IUP dialogs.
--- @return iup.dialoghandle|nil
function identifyActiveWindow()
--TESTED: IdentifyActiveWindow.
-- Retrieve all dialog names, noting that these names are distinct from their handles.
local tblDialogNames = iup.GetAllDialogs()
-- Loop through all dialog names to find the active window.
for _, dialogName in ipairs(tblDialogNames) do
local dialogHandle = iup.GetHandle(dialogName) -- Convert name to handle.
if dialogHandle.ACTIVEWINDOW == "YES" then
return dialogHandle -- Return the active dialog handle.
end
end
-- If no active dialog is found, return nil.
return nil
end
--- Destroys all currently open IUP dialogs to free up resources.
function destroyAllDialogs()
--TESTED: destroyAllDialogs
-- Retrieve all dialog names as in the identification function.
local tblDialogNames = iup.GetAllDialogs()
-- Loop through all dialog names to destroy each dialog.
for _, dialogName in ipairs(tblDialogNames) do
local dialogHandle = iup.GetHandle(dialogName) -- Convert name to handle.
if dialogHandle then -- Ensure the handle is valid before attempting to destroy.
dialogHandle:destroy() -- Destroy the dialog.
end
end
end
--- Updates a dialog's title and refreshes the display
--- @param dialog iup.dialog The dialog to update
--- @param newTitle string The new title for the dialog
function updateDialogTitle(dialog, newTitle)
if dialog and dialog.title then
dialog.title = newTitle
-- Ensure IUP updates the native window text
iup.Refresh(dialog)
end
end
-- Message boxes and Text prompts
local buttonOrder = {
OK = { "OK" },
OKCANCEL = { "OK", "Cancel" },
RETRYCANCEL = { "Retry", "Cancel" },
YESNO = { "Yes", "No" },
YESNOCANCEL = { "Yes", "No", "Cancel" },
}
local buttonSetMap = {
OK = "OK",
OKCANCEL = "OKCANCEL",
RETRYCANCEL = "RETRYCANCEL",
YESNO = "YESNO",
YESNOCANCEL = "YESNOCANCEL",
}
---Create a customizable pop-up message dialog
---@param messageType "error"|"warning"|"question"|"info"|"message" The type of message
---@param messageText string The text content of the message
---@param buttonSet "OK"|"OKCANCEL"|"RETRYCANCEL"|"YESNO"|"YESNOCANCEL" A set of buttons to include
---@return "OK"|"Cancel"|"Retry"|"Yes"|"No" clickedButton The label of the button that was pressed
function MessageBox(messageType, messageText, buttonSet)
-- Map messageType to IUP dialogtype
local dialogTypeMap = {
error = "ERROR",
warning = "WARNING",
question = "QUESTION",
info = "INFORMATION",
message = "INFORMATION",
}
local dialogtype = dialogTypeMap[messageType] or "INFORMATION"
-- Map buttonSet to IUP buttons
local buttons = buttonSetMap[buttonSet] or "OK"
local dlg = iup.messagedlg({
title = "Message",
value = messageText,
dialogtype = dialogtype,
buttons = buttons,
})
-- Set parent dialog attributes
local parentInfo = getParentDialogInfo()
for k, v in pairs(parentInfo) do
dlg[k] = v
end
-- A topmost progress dialog (Progress.lua) would otherwise sit in front of this native
-- message dialog, hiding it; drop its topmost while we show, then restore.
local restoreTopmost = (Progress and Progress.suspendTopmost) and Progress.suspendTopmost() or nil
dlg:popup(iup.CENTER, iup.CENTER)
local result = dlg.buttonresponse
dlg:destroy()
if restoreTopmost then restoreTopmost() end
local order = buttonOrder[buttonSet] or { "OK" }
local idx = tonumber(result)
if idx and order[idx] then
return order[idx]
end
return "OK"
end
--- @class GetTextParams
--- Parameters for the GetText function.
--- @field strPrompt string The prompt text to display.
--- @field strDefault string The default text to display in the input field.
--- @field strMask? string|nil Optional mask to use for the text input.
--- @field bMultiLine? boolean Optional flag to enable multiline input.
--- @field strTickPrompt? string|nil Optional prompt for the tick box.
--- Retrieves text input from the user.
--- @param params GetTextParams A table containing the parameters for the function.
--- @return boolean, string, boolean The OK status, the input text, and the tick status.
function GetText(params)
--TESTED: GetText
-- Extract parameters from the table
local strPrompt = params.strPrompt
local strDefault = params.strDefault or ""
local strMask = params.strMask or ""
local bMultiLine = params.bMultiLine or false
local strTickPrompt = params.strTickPrompt or ""
-- Initialize variables for text input and tick status
local textInput = strDefault
local tickStatus = false
local isOK = false
-- Create the text element
local textOptions = {
value = strDefault,
multiline = bMultiLine and "YES" or "NO",
mask = strMask ~= "" and strMask or nil,
visiblelines = bMultiLine and 8 or 1,
}
local textElement = makeText(textOptions)
-- Create the toggle element if strTickPrompt is provided
local toggleElement
if strTickPrompt ~= "" then
toggleElement = makeToggle({
title = strTickPrompt,
value = "OFF",
})
end
--Create the buttons
local btnOK = makeButton({
title = "OK",
close = true,
size = "64x",
callback = function()
textInput = textElement.value
tickStatus = toggleElement and toggleElement.value == "ON" or false
isOK = true
return true
end,
})
local btnCancel = makeButton({
title = "Cancel",
close = true,
size = "64x",
})
-- Create the dialog content
local dialogContent = iup.vbox({
textElement,
strTickPrompt ~= "" and toggleElement or nil,
iup.hbox({ iup.fill({}), btnOK, btnCancel }),
})
-- Create the dialog. Enter commits (OK), Esc cancels - both focus-aware via makeDialog's
-- native DEFAULTENTER/DEFAULTESC (Enter still inserts a newline inside a multiline text field).
local dialogOptions = {
title = strPrompt,
size = "QUARTERx",
default = btnOK,
cancel = btnCancel,
}
local dialog = makeDialog(dialogContent, dialogOptions)
-- Avoid global normalization to prevent modality/focus issues in nested dialogs
--TODO: fix layout without normalization
-- Show the dialog
dialog:popup(iup.CENTERPARENT, iup.CENTERPARENT)
dialog:destroy()
-- Return the OK status, the input text, and the tick status
return isOK, isOK and textInput or "", isOK and tickStatus or false
end
end
do --Buttons
--- Enables or disables a list of buttons
--- @param tblButtons table List of button objects to be enabled/disabled
--- @param strSetting string "YES" to enable, "NO" to disable
function enableButtons(tblButtons, strSetting)
--TESTED: EnableButtons
for _, v in ipairs(tblButtons) do
v.ACTIVE = strSetting
end
end
--- @class ButtonOptions
--- @field action? function|nil A function to be called when the button is pressed. If nil, this is a cancel button
--- @field close? boolean|nil Whether the button should close the dialog when pressed
--- @field name? string|nil The name to set for the button handle
--- Creates a button with specified options
--- @param options ButtonOptions Configuration options for the button. Optionally include help_topic for F1 help.
--- @return iup.button The created button element
function makeButton(options)
--TESTED: makeButton
--create button action function
local callback = options.callback
local action = function(self)
if callback then
if options.close then
if callback(self) == true then -- this is a close button
return iup.CLOSE
end
else
callback(self)
end
else --this is a cancel button
return iup.CLOSE
end
end
local defaults = {
action = action,
}
local b = iup.button({})
setOptions(b, mergeOptions(defaults, options))
-- safeSetHandle: buttons register by TITLE, so a destroyed-and-recreated dialog
-- re-registers the same names - plain SetHandle errors on the dead handles.
safeSetHandle(options.name or options.title or generateRandomDigitString(), b)
if options.tip then
setTipWithHelp(b, options.tip, options.tiptitle, options.help_topic)
end
if options.help_topic and help then
help:attach_help_cb(b, options.help_topic)
end
return b
end
--- @class AssistantButtonOptions : ButtonOptions
--- @field help_topic? string Optional help topic for F1 help
--- @field close? boolean Whether button closes dialog (defaults to false)
--- @field canFocus? IupVisibility Whether button can receive focus (defaults to "NO")
--- @param options AssistantButtonOptions Optionally include help_topic for F1 help.
--- Creates an assistant button with specified options
--- @return iup.button The created assistant button element
function makeAssistantButton(options)
--TEST: makeButton
options = options or {}
--create button action function
local defaults = {
normalizergroup = btnshortnorm,
close = false,
title = "...",
canFocus = "NO",
size = "20x", -- comfortable click target; "..." alone is tiny
}
local merged = mergeOptions(defaults, options)
-- mergeOptions strips "special" keys such as callback, but makeButton
-- reads callback from the options it receives; without this the button
-- has no action and behaves as a Cancel button (returns iup.CLOSE),
-- closing whatever dialog contains it.
merged.callback = options.callback
local btn = makeButton(merged)
if options.help_topic and help then
help:attach_help_cb(btn, options.help_topic)
end
if options.tip then
setTipWithHelp(btn, options.tip, options.tiptitle, options.help_topic)
end
return btn
end
end
do --Lists
--- @class ListOptions
--- @field killfocus? function Kill focus callback
--- Create and configure a list
--- @param options ListOptions Configuration options for the list. Optionally include help_topic for F1 help.
--- @return iup.list The created list element
function makeList(options)
--TESTED: makeList
local dropdown_option = options.dropdown or "YES"
local defaults = {
visibleitems = dropdown_option == "YES" and 5 or nil,
visiblecolumns = dropdown_option ~= "YES" and 1 or nil,
visiblelines = dropdown_option ~= "YES" and 1 or nil,
expand = dropdown_option ~= "YES" and "YES" or "HORIZONTAL",
killfocus_cb = options.killfocus or nil,
}
local list = iup.list({})
setOptions(list, mergeOptions(defaults, options))
if type(options.values) == "table" then
populateList(list, options.values)
end
safeSetHandle(options.name or generateRandomDigitString(), list)
if options.tip then
setTipWithHelp(list, options.tip, options.tiptitle, options.help_topic)
end
if options.help_topic and help then
help:attach_help_cb(list, options.help_topic)
end
return list
end
--- Populate a list with values from a table
---@param l table The list to populate
---@param tblVals table The table containing values to populate the list with
function populateList(l, tblVals)
--TESTED: PopulateList
local is_indexed = (rawget(tblVals, 1) ~= nil)
l.REMOVEITEM = "ALL"
if not is_indexed then
local i = 1
for k, _ in pairs(tblVals) do
l[tostring(i)] = k
i = i + 1
end
else
for i, v in ipairs(tblVals) do
l[tostring(i)] = v
end
end
end
--- Check if a multi-selection list has multiple items selected
---@param l table The list to check
---@return boolean True if multiple items are selected, otherwise false
function multiListSelectionTrue(l)
--TESTED: MultiListSelectionTrue
return l.value:match("%+") ~= nil
end
--- Clear the selection in a multi-selection list
---@param l table The list to clear
function multiListSelectionClear(l)
--TESTED: MultiListSelectionClear
l.value = string.rep("%-", l.count)
end
--- Searches for a value in a list and returns its position.
--- @param strValue string The value to search for in the list
--- @param list iup.list The list to search within
--- @return integer position The position of the value in the list (0 if not found)
function goToInList(strValue, list)
--TESTED: GoToInList
-- Ensure the list has a COUNT property
local count = tonumber(list.COUNT)
if not count or count <= 0 then
return 0 -- List is empty or count is invalid
end
-- Iterate through the list
for position = 1, count do
if list[tostring(position)] == strValue then
list.value = position -- Set the found position in the list
return position
end
end
return 0 -- Value not found in the list
end
--- Get a selected value from a list if one exists
--- @param list iup.list The list to get the selection from
--- @param returnNumeric boolean If true, return the numeric value; otherwise, return the corresponding string
--- @return string|number|"" The selected value from the list or an empty string if no selection
function getSingleValue(list, returnNumeric)
--TESTED: GetSingleValue
-- Check if the list value is non-zero and return the appropriate value based on returnNumeric
-- Otherwise, return an empty string
if list.value ~= 0 then
if returnNumeric then
return list.value
else
return list[tostring(list.value)]
end
else
return ""
end
end
--- GetSelectedValues retrieves selected items from a list.
--- @param list iup.list The list containing items and their selection states
--- @param returnPositions boolean If true, return positions instead of item texts
--- @return string[] Selected items or positions
function getSelectedValues(list, returnPositions)
--TESTED: GetSelectedValues
local selectedValues = {} -- Table to hold selected values
local itemCount = tonumber(list.COUNT) -- Total number of items in the list
if itemCount and itemCount > 0 then
-- list.value is nil until the dialog is mapped, so a caller that runs during construction
-- would otherwise index nil here; treat "not yet mapped" as "nothing selected".
local selectionState = list.value or "" -- String indicating selection states with + and -
for i = 1, itemCount do
if selectionState:sub(i, i) == "+" then -- Check if item is selected
if returnPositions then
table.insert(selectedValues, tostring(i)) -- Add position as string to the table
else
table.insert(selectedValues, list[tostring(i)]) -- Add selected item text to the table
end
end
end
end
return selectedValues -- Return the table of selected items or positions
end
--- Set the selected values in a multi-selection list
--- @param list iup.list The list to set the selected values in
--- @param tblselected string[] An indexed list of strings to select
--- @return nil
function setSelectedValues(list, tblselected)
--TESTED: SetSelectedValues
local tbl = tablex.index_map(tblselected)
local strselection = ""
for i = 1, tonumber(list.COUNT) do
strselection = strselection .. (tbl[list[tostring(i)]] and "+" or "-")
end
list.value = strselection
end
--- Remove selected items from a list and corresponding collection
--- @param list iup.list The UI list control
--- @param collection table[] The collection to remove items from
--- @param updateDisplay function Function to call to update the display
function removeSelectedItems(list, collection, updateDisplay)
if not list then
return
end
local selectedPositions = getSelectedValues(list, true)
if #selectedPositions > 0 then
-- Sort positions in descending order to avoid index shifting
table.sort(selectedPositions, function(a, b)
return tonumber(a) > tonumber(b)
end)
for _, posStr in ipairs(selectedPositions) do
local pos = tonumber(posStr)
if pos and pos <= #collection then
table.remove(collection, pos)
end
end
-- Update the display
if updateDisplay then
updateDisplay()
end
end
end
end
do -- Text Label and Toggle
--- Creates an IUP text element with various options
--- @param options TextOptions Configuration options for the text element. Optionally include help_topic for F1 help.
--- @return iup.text The created text element
function makeText(options)
--TESTED: makeText
-- Checks if the text value is blank and sets it to a default if necessary.
-- @param self (iup.element): The text element itself (passed implicitly).
local function CheckTextNotBlank(self)
if type(self.value) ~= "string" or self.value == "" then
-- On blank, restore to the field's DEFAULT rather than its prior value, so an optional
-- field can be cleared: a default of "" means a blanked field stays blank; a real
-- default (e.g. "ind") resets to it. Falls back to options.value when no default is
-- passed, so direct callers keep today's behaviour. ("" is truthy in Lua, so an empty
-- options.default is honoured.)
self.value = options.default or options.value or ""
end
end
-- Default values for options (if not provided)
local defaults = {
expand = options.multiline == "YES" and "YES" or "HORIZONTAL",
killfocus_cb = function(self)
CheckTextNotBlank(self)
if options.killfocus then
options.killfocus(self)
end -- Only call the provided killfocus function if it exists
end,
}
local t = iup.text(mergeOptions(defaults, options)) -- Create the IUP text element with the merged options
safeSetHandle(options.name or generateRandomDigitString(), t)
if options.tip then
setTipWithHelp(t, options.tip, options.tiptitle, options.help_topic)
end
if options.help_topic and help then
help:attach_help_cb(t, options.help_topic)
end
return t
end
--- Creates an IUP label element with specified options
--- @param options LabelOptions Configuration options for the label. Optionally include help_topic for F1 help.
--- @return iup.label The created label element
function makeLabel(options)
--TESTED: makeLabel
-- Default options
local defaults = { normalizergroup = btnnorm } --done here rather than in theme to allow for long labels that should not be normalized
-- Merge: user options take precedence
local lbl = iup.label(mergeOptions(defaults, options))
if options.tip then
setTipWithHelp(lbl, options.tip, options.tiptitle, options.help_topic)
end
-- IUP label does not support HELP_CB, so F1 help is not attached to labels.
return lbl
end
--- Creates an IUP label element with no normalization
--- @param options LabelOptions Configuration options for the label. Optionally include help_topic for F1 help.
--- @return iup.label The created label element
function makeLongLabel(options)
--TESTED: makeLabel
-- Default options
-- Create label element
local lbl = iup.label(options)
lbl.normalizergroup = nil
if options.tip then
setTipWithHelp(lbl, options.tip, options.tiptitle, options.help_topic)
end
-- IUP label does not support HELP_CB, so F1 help is not attached to labels.
return lbl
end
--- Creates an IUP toggle element with specified options
--- @param options ToggleOptions Configuration options for the toggle. Optionally include help_topic for F1 help.
--- @return iup.toggle The created toggle element
function makeToggle(options)
--TESTED: makeToggle
-- Default options
local defaults = {}
local t = iup.toggle(mergeOptions(defaults, options))
safeSetHandle(options.name or generateRandomDigitString(), t)
if options.tip then
setTipWithHelp(t, options.tip, options.tiptitle, options.help_topic)
end
if options.help_topic and help then
help:attach_help_cb(t, options.help_topic)
end
return t
end
end
do --Container management
--- Constants used when dealing with containers
local dialogs = dialogs
or tablex.index_map({
"dialog",
"messagedlg",
"progesssdlg",
"fontdg",
"filedlg",
"colordlg",
})
local containers = containers
or tablex.index_map({
"frame",
"hbox",
"vbox",
"zbox",
"tabs",
"radio",
"sbox",
"cbox",
"gridbox",
"multibox",
"scrollbox",
"detachbox",
"expander",
"detachbox",
"split",
"backgroundbox",
"spinbox",
})
local datacontrols = datacontrols
or tablex.index_map({
"list",
"text",
"val",
"link",
"multiline",
"toggle",
})
local static = static
or tablex.index_map({
"fill",
"normalizer",
"button",
"label",
"menu",
"submenu",
"item",
"separator",
"imagergb",
"imagergba",
"image",
"matrix",
"cells",
"clipboard",
"timer",
"user",
"link",
}) -- These will be ignored when clearing the dialog
local toohardtohandle = toohardtohandle or tablex.index_map({ "spin", "canvas", "tree" }) -- Only g*d knows
--- Enables or disables all controls in a container except those specified in the exclude table.
--- @param ih iup.elementhandle The container handle
--- @param tblexcludeih table A table of handles to exclude
--- @param strstate string The state to set ("YES" or "NO")
function EnableContainer(ih, tblexcludeih, strstate)
--TESTED: Enable Container
-- Iterate with iup.GetNextChild, NOT integer indexing: ih[i] reflects only the
-- children supplied in the constructor (the Lua-side wrapper is frozen at
-- construction), so it silently misses anything :append()ed later. GetNextChild
-- walks the real, current child list (proven in FH: Add Facts spike S5, July 2026).
local element = iup.GetNextChild(ih)
while element ~= nil do -- Loop through the elements in the parent
if tblexcludeih[element] == nil then
if dialogs[iup.GetClassName(element)] ~= nil or containers[iup.GetClassName(element)] ~= nil then
EnableContainer(element, tblexcludeih, strstate) -- Recursively enable/disable containers
elseif datacontrols[iup.GetClassName(element)] ~= nil or iup.GetClassName(element) == "button" then
element.active = strstate -- Enable/disable data controls and buttons
end
end
element = iup.GetNextChild(ih, element)
end
end
--- Clears the values of all data controls in a container except those specified in the exclude table.
--- @param ih iup.elementhandle The container handle to clear controls within
--- @param tblexcludeih table<iup.elementhandle, boolean> A table of handles to exclude from clearing
function ClearContainer(ih, tblexcludeih)
--TESTED: ClearContainer
--- Clears a single data control based on its class
--- @param ih iup.elementhandle The data control handle
local function ClearDataControl(ih)
local cclass = iup.GetClassName(ih)
ih.fgcolor = th.fg --reset text colour to match the theme
if cclass == "list" then
if ih.editbox == "YES" or ih.multiple == "YES" then
ih.value = ""
else
ih.value = "0"
end
elseif cclass == "text" or cclass == "multiline" then
ih.value = ""
elseif cclass == "val" then
ih.value = "0.0"
elseif cclass == "toggle" then
ih.value = "OFF"
end
end
-- Iterate with iup.GetNextChild, NOT integer indexing - see EnableContainer.
local element = iup.GetNextChild(ih)
while element ~= nil do -- Loop through the elements in the parent
if tblexcludeih[element] == nil then
if dialogs[iup.GetClassName(element)] ~= nil or containers[iup.GetClassName(element)] ~= nil then
ClearContainer(element, tblexcludeih) -- Recursively clear containers
elseif datacontrols[iup.GetClassName(element)] ~= nil then
ClearDataControl(element) -- Clear data controls
end
end
element = iup.GetNextChild(ih, element)
end
end
local function set_visibility(element, isHidden)
if element.visible ~= nil and element.floating ~= nil then
element.visible = isHidden and "NO" or "YES"
element.floating = isHidden and "YES" or "NO"
end
end
---Shows or hides a control and refreshes the parent dialog
--- @param control iup.control The control
--- @param isHidden boolean indicating whether to hide (true) or show (false) the control
function HideControl(control, isHidden)
--TESTED: HideControl
set_visibility(control, isHidden)
local dialog = iup.GetDialog(control)
if dialog then
iup.Refresh(dialog)
end
end
--- Shows or hides all controls in a container and refreshes the container
--- @param container iup.elementhandle The container handle
--- @param isHidden boolean indicating whether to hide (true) or show (false) the container
function HideContainer(container, isHidden)
--TESTED: HideContainer
-- Loop through the elements in the parent
local e = 1
while container[e] ~= nil do
local element = container[e]
if dialogs[iup.GetClassName(element)] ~= nil or containers[iup.GetClassName(element)] ~= nil then
HideContainer(element, isHidden) -- Recursively hide/show containers
else
set_visibility(element, isHidden) -- Hide/show control
end
e = e + 1
end
iup.Refresh(container)
end
--- Retrieves the value from a control.
--- @param ctl iup.elementhandle The control handle
--- @return iup.element|string # The value of the control
function GetValueFromControl(ctl)
--TESTED: GetValueFromControl
if type(ctl.value) == "string" or type(ctl.value) == "number" then
return ctl.value
else
return ""
end
end
--- Retrieves data from a table of controls.
--- @param tblCtls table The table of controls
--- @return table A table of control values
function GetContainerData(tblCtls)
--TESTED GetContainerData
local is_indexed = (rawget(tblCtls, 1) ~= nil)
local tblVals = {}
if not is_indexed then
for k, v in pairs(tblCtls) do
tblVals[k] = GetValueFromControl(v)
end
else
for k, v in ipairs(tblCtls) do
tblVals[k] = GetValueFromControl(v)
end
end
return tblVals
end
--- Retrieves data elements from a container, adding them to a specified table.
--- @param ih iup.elementhandle The container handle
--- @param tblexcludeih table A table of handles to exclude
--- @param tblElements table A table to store the data elements
--- @param keyname? boolean|nil boolean indicating whether to use control names as keys
--- @return table A table of data controls
function GetDataElements(ih, tblexcludeih, tblElements, keyname)
--TESTED: GetDataElements
if not keyname then
keyname = false
end
local child = iup.GetChild(ih, 0)
while child ~= nil do -- Loop through the elements in the parent and add any data controls to the end of tblElements
if tblexcludeih[child] == nil then
if dialogs[iup.GetClassName(child)] ~= nil or containers[iup.GetClassName(child)] ~= nil then
GetDataElements(child, tblexcludeih, tblElements, keyname) -- Recursively retrieve data elements from containers
elseif datacontrols[iup.GetClassName(child)] ~= nil then
if keyname then
if iup.GetName(child) then
tblElements[iup.GetName(child)] = child
end
else
tblElements[#tblElements + 1] = child
end
end
end
child = iup.GetNextChild(ih, child)
end
return tblElements -- Return a table of data controls
end
--- Rebuilds a container by clearing and repopulating it with new child elements.
--- V1.5 (Stage 4, 5 Jul 2026): optional destroyOld (a detached-only child leaks across
--- repeated rebuilds - template switching rebuilds many times per session), and appended
--- children are now MAPPED when the container is already mapped (content appended to a
--- shown dialog occupies no space and never paints until iup.Map - proven live, Add Facts
--- 0.5.5/0.5.6, which carried its own copy of this dance until this function caught up).
--- @param container iup.elementhandle The container handle to rebuild
--- @param tblNewChildren table An indexed table of new child elements to add
--- @param preserveAttributes? boolean Whether to preserve container attributes (default: true)
--- @param destroyOld? boolean Destroy removed children instead of detaching them (default: false)
--- @param skipRefresh? boolean Omit the trailing full-dialog Refresh; pass true when
--- batching several rebuilds and refreshing once yourself (default: false)
function RebuildContainer(container, tblNewChildren, preserveAttributes, destroyOld, skipRefresh)
if preserveAttributes == nil then
preserveAttributes = true
end
-- Store container attributes if needed
local savedAttrs = {}
if preserveAttributes then
savedAttrs.expand = container.expand
savedAttrs.alignment = container.alignment
savedAttrs.gap = container.gap
savedAttrs.margin = container.margin
savedAttrs.normalizesize = container.normalizesize
end
-- Remove existing children (GetNextChild enumeration - integer indexing only sees
-- constructor-time children, Dialog.lua V1.3 note)
local child = iup.GetChild(container, 0)
while child ~= nil do
local nextChild = iup.GetNextChild(container, child)
iup.Detach(child)
if destroyOld then
pcall(iup.Destroy, child)
end
child = nextChild
end
-- Add new children, mapping them when the container is already mapped (wid set)
local mapped = container.wid ~= nil
for i, newChild in ipairs(tblNewChildren) do
iup.Append(container, newChild)
if mapped then
pcall(iup.Map, newChild)
end
end
-- Restore attributes if preserved
if preserveAttributes then
for attr, val in pairs(savedAttrs) do
if val ~= nil then
container[attr] = val
end
end
end
-- Refresh the parent dialog, unless the caller is batching several rebuilds
if not skipRefresh then
local dialog = iup.GetDialog(container)
if dialog then
iup.Refresh(dialog)
end
end
end
--- Replaces a container with a new one, transferring it into the parent's position.
--- @param oldContainer iup.elementhandle The container handle to replace
--- @param newContainer iup.elementhandle The new container to insert
--- @param destroyOld? boolean Whether to destroy the old container (default: false)
--- @return iup.elementhandle|nil The new container if successful, nil otherwise
function ReplaceContainer(oldContainer, newContainer, destroyOld)
if destroyOld == nil then
destroyOld = false
end
local parent = iup.GetParent(oldContainer)
if parent == nil then
return nil -- Cannot replace a container without a parent
end
-- Find the position of the old container in its parent
local position = nil
local e = 1
while parent[e] ~= nil do
if parent[e] == oldContainer then
position = e
break
end
e = e + 1
end
if position == nil then
return nil -- Could not find old container in parent
end
-- Transfer the name if the old container has one
local oldName = iup.GetName(oldContainer)
-- Detach old container
iup.Detach(oldContainer)
-- Insert new container at the same position
if position == 1 then
iup.Insert(parent, nil, newContainer) -- Insert at beginning
else
local refChild = parent[position - 1]
iup.Insert(parent, refChild, newContainer) -- Insert after reference child
end
-- Transfer name to new container
if oldName then
iup.SetHandle(oldName, newContainer)
end
-- Destroy old container if requested
if destroyOld then
iup.Destroy(oldContainer)
end
-- Refresh the parent dialog
local dialog = iup.GetDialog(parent)
if dialog then
iup.Refresh(dialog)
end
return newContainer
end
end
do -- Additional UI components
--- Creates an expander control
--- @param content iup.element The content to be expanded/collapsed
--- @param title string The title for the expander
--- @param state string The initial state ("OPEN" or "CLOSED")
--- @return iup.expander The created expander element
function makeExpander(content, title, state)
-- The content must be the constructor child (IupExpander has no
-- CONTENT attribute).
local expander = iup.expander({
content,
title = title,
state = state or "CLOSED",
})
return expander
end
--- Creates a gridbox control
--- @param options table Configuration options for the gridbox
--- @return iup.gridbox The created gridbox element
function makeGridbox(options)
options = options or {}
local gridbox = iup.gridbox(options)
return gridbox
end
--- Creates a date field control
--- @param options table Configuration options for the date field
--- @return iup.element The created date field container
function makeDateField(options)
options = options or {}
local title = options.title or "Date"
local tip = options.tip or "Enter date"
local txtDate = makeText({
visiblelines = "1",
expand = "HORIZONTAL",
tip = tip,
name = "date",
})
local labDate = makeLabel({
title = title,
tip = tip,
})
return iup.hbox({ labDate, txtDate, alignment = "ACENTER" })
end
end
end
end)()
--<<FS_SPLICE_END>>
---Displays a hierarchical file/folder selection dialog using a tree control.
---The dialog allows users to browse through directories and select files or folders
---based on specified criteria. Files can be filtered by extension, and the dialog
---supports both single and multiple selection modes.
---
---The function builds a recursive tree structure starting from the root directory,
---separating folders and files, applying extension filters, and sorting items
---alphabetically. Folders are displayed as expandable branches, while files appear
---as leaf nodes. The dialog includes Select All and Clear All buttons for convenience.
---
---@param rootDirectory string Root directory to start browsing from
---@param extensions string[] File extensions to filter by (empty table shows all files)
---@param allowMultiple boolean Whether multiple files can be selected
---@param rootLabel string Label for the root item in the tree
---@param dialogTitle string Title for the selection dialog
---@param showExtensions boolean Whether to show file extensions in the tree
---@param foldersOnly boolean Whether to show only folders (if true, returns folder paths instead of file paths)
---@param parentDialog iup.dialog Optional parent dialog to use for positioning
---@return boolean Whether the function was successful
---@return string[] Table of selected files/folders or nil if unsuccessful
function FileSelector(
rootDirectory,
extensions,
allowMultiple,
rootLabel,
dialogTitle,
showExtensions,
foldersOnly,
parentDialog
)
fhfu = require("fhFileUtils")
if not rootDirectory then
return false, nil --root directory is required
end
extensions = extensions or {}
allowMultiple = allowMultiple or false
rootLabel = rootLabel or "Root"
dialogTitle = dialogTitle or "File Selector"
showExtensions = showExtensions == nil and true or showExtensions
foldersOnly = foldersOnly or false
-- Helper function to recursively build tree structure
local function buildTreeStructure(directoryPath, isRoot)
local treeData = {}
-- Get contents of current directory (non-recursive)
local contents, error = fhfu.getFolderContents(directoryPath, false, false)
if not contents then
if isRoot then
MessageBox("error", "Error reading root directory: " .. error, "OK")
return nil -- Return nil to indicate error
else
return treeData -- Return empty tree if subdirectory can't be read
end
end
local folders = {}
local files = {}
-- Separate folders and files, filter files by extension
for _, item in ipairs(contents) do
local pathParts = fhfu.splitPath(item.path)
if fhfu.folderExists(item.path) then
-- It's a folder
table.insert(folders, {
name = item.name,
path = item.path,
})
else
-- It's a file - check if extension matches
local fileExt = string.lower(pathParts.ext or "")
local matchesExtension = #extensions == 0 -- if no extensions specified, show all files
if not matchesExtension then
for _, ext in ipairs(extensions) do
if string.lower(ext) == fileExt then
matchesExtension = true
break
end
end
end
if matchesExtension then
-- Determine display name based on showExtensions parameter
local displayName = item.name
if not showExtensions then
displayName = pathParts.basename
end
table.insert(files, {
name = item.name,
displayName = displayName,
path = item.path,
})
end
end
end
-- Sort folders and files alphabetically
table.sort(folders, function(a, b)
return a.name < b.name
end)
table.sort(files, function(a, b)
return a.displayName < b.displayName
end)
-- Add folders with their subtrees
for _, folder in ipairs(folders) do
local folderNode = {
branchname = folder.name,
}
-- In folders-only mode, add userid for the folder itself
if foldersOnly then
folderNode.userid = { path = folder.path }
end
-- Recursively build subtree for this folder
local subtree = buildTreeStructure(folder.path, false)
if subtree then
for _, node in ipairs(subtree) do
table.insert(folderNode, node)
end
end
table.insert(treeData, folderNode)
end
-- Add files at this level (only if not in folders-only mode)
if not foldersOnly then
for _, file in ipairs(files) do
table.insert(treeData, {
leafname = file.displayName,
userid = { path = file.path },
})
end
end
return treeData
end
-- Build the tree structure starting from root directory
local rootTree = {
branchname = rootLabel,
}
-- In folders-only mode the root itself is a valid choice
if foldersOnly then
rootTree.userid = { path = rootDirectory }
end
local subtreeData = buildTreeStructure(rootDirectory, true)
if not subtreeData then
return false, nil -- Error already shown by buildTreeStructure
end
for _, item in ipairs(subtreeData) do
table.insert(rootTree, item)
end
local tree = iup.tree({
IMAGELEAF = "IMGPAPER",
markmode = allowMultiple and "MULTIPLE" or "SINGLE",
})
-- Track OK/Cancel
local okPressed = false
local selectedPaths = {}
-- Create selection buttons
local selectAllBtn = makeButton({
title = "Select All",
size = "64x",
expand = "NO",
callback = function()
tree.MARK = "MARKALL"
return iup.DEFAULT
end,
})
local clearAllBtn = makeButton({
title = "Clear All",
size = "64x",
expand = "NO",
callback = function()
tree.MARK = "CLEARALL"
return iup.DEFAULT
end,
})
-- Create OK button
local okBtn = makeButton({
title = "OK",
size = "64x",
expand = "NO",
close = true,
callback = function()
-- Collect selected paths using userid
selectedPaths = {}
-- Get selection state
local markedNodes = tree.MARKEDNODES
-- Get total node count
local nodeCount = tree.count
if nodeCount and tonumber(nodeCount) > 0 then
for i = 1, tonumber(nodeCount) do
local isSelected = markedNodes and markedNodes:sub(i, i) == "+"
if isSelected then
-- The MARKEDNODES string is 1-based, but GetUserId is 0-based
local userid = iup.TreeGetUserId(tree, i - 1)
if userid then
if type(userid) == "table" and userid.path then
table.insert(selectedPaths, userid.path)
end
end
end
end
end
okPressed = true
return true
end,
})
-- Create Cancel button
local cancelBtn = makeButton({
title = "Cancel",
size = "64x",
expand = "NO",
close = true,
})
-- Create dialog content
local buttonBox = {}
-- Only add Select All and Clear All buttons if not in folders-only mode
if not foldersOnly then
table.insert(buttonBox, selectAllBtn)
table.insert(buttonBox, clearAllBtn)
end
-- New Folder button omitted
table.insert(buttonBox, iup.fill({}))
table.insert(buttonBox, okBtn)
table.insert(buttonBox, cancelBtn)
local content = iup.vbox({
tree,
iup.hbox(buttonBox),
})
-- Create dialog
local dlg = makeDialog(content, {
title = dialogTitle,
resize = "YES",
size = "HALFxHALF",
})
dlg:map() --must map the dialog before adding nodes
-- Prepare tree for bulk node insert without redraw
tree.autoredraw = "NO"
-- Ensure nodes are added collapsed
tree.addexpanded = "NO"
iup.TreeAddNodes(tree, rootTree)
-- Set initial focus to root (avoid expanding entire tree)
tree.value = 0
-- Expand only the root
tree["STATE0"] = "EXPANDED"
tree.autoredraw = "YES"
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
dlg:destroy()
return okPressed, selectedPaths
end
--<<FS_SPLICE "../../2 Boilerplate/Progress.lua">>
;(function()
-- Progress dialog and controller (IUP)
-- Provides a lightweight progress dialog and a throttled controller for iterative operations
require("iuplua")
-- Progress dialogs are topmost so they stay visible during a long operation - but that also puts
-- them in front of any MessageBox (a native message dialog, which can't be made topmost), hiding it.
-- We track the shown progress dialogs here; MessageBox calls Progress.suspendTopmost() around its
-- popup so the message comes to the front, then restores. Globally applicable: any plugin that uses
-- this Progress controller and shows a message mid-operation gets readable messages for free.
local shownProgressDialogs = {}
--- Drop topmost on every currently-shown progress dialog, returning a function that restores it.
--- Safe to call when none are shown (returns a no-op). Guarded so a mid-teardown dialog can't error.
local function suspendTopmost()
local toRestore = {}
for dlg in pairs(shownProgressDialogs) do
pcall(function()
if dlg.topmost == "YES" then
dlg.topmost = "NO"
toRestore[#toRestore + 1] = dlg
end
end)
end
return function()
for _, dlg in ipairs(toRestore) do
pcall(function() dlg.topmost = "YES" end)
end
end
end
--- Create and manage a lightweight progress dialog
--- @param totalSteps number Total number of iterations to perform
--- @param title? string Optional dialog title
--- @param parentDialog? any Optional parent dialog handle
--- @return table Progress controller with methods: show(), update(step, text), isCancelled(), close()
local function createProgressDialog(totalSteps, title, parentDialog)
local cancelled = false
local lbl = makeLabel({
title = "Starting...",
})
local bar = iup.progressbar({
min = 0,
max = 1,
value = 0,
expand = "HORIZONTAL",
})
local btnCancel = makeButton({
title = "Cancel",
callback = function(self)
cancelled = true
return true
end,
})
local buttons = iup.hbox({ iup.fill({}), btnCancel })
local box = iup.vbox({
lbl,
bar,
buttons,
margin = "10x10",
gap = "8",
expand = "YES",
})
local dlg = makeDialog(box, {
title = title or "Applying...",
size = "QUARTERxEIGHTH",
dialogframe = "YES",
menubox = "NO",
topmost = "YES",
})
local function update(step, text)
local denom = (totalSteps and totalSteps > 0) and totalSteps or 1
bar.value = math.min(1, (step or 0) / denom)
if text and text ~= "" then
lbl.title = text
end
iup.Refresh(dlg)
iup.LoopStep()
end
return {
show = function()
dlg:showxy(iup.CENTERPARENT, iup.CENTERPARENT)
shownProgressDialogs[dlg] = true -- so MessageBox can drop our topmost while a message is up
iup.LoopStep()
end,
update = update,
isCancelled = function()
return cancelled
end,
close = function()
if dlg then
shownProgressDialogs[dlg] = nil
dlg:destroy()
dlg = nil
end
end,
}
end
--- ProgressController handles showing and throttling progress updates based on parameters
--- @class ProgressController
--- @field totalSteps number
--- @field showThreshold number
--- @field updateStepFraction number
--- @field step number
--- @field lastFraction number
--- @field dlg any
--- @field _parentDialog any
local ProgressController = {}
ProgressController.__index = ProgressController
--- Create a new ProgressController
--- @param totalSteps number
--- @param updatePercent? number Percentage step for updates (1-100). Default 5
--- @param showThreshold? number Minimum total steps to show progress. Default 20
--- @param parentDialog? any Optional parent dialog handle for modality
--- @return ProgressController
function ProgressController.new(totalSteps, updatePercent, showThreshold, parentDialog)
local UPDATE_PERCENT = (type(updatePercent) == "number" and updatePercent >= 1 and updatePercent <= 100)
and updatePercent
or 5
local SHOW_THRESHOLD = (type(showThreshold) == "number" and showThreshold >= 0) and showThreshold or 20
local self = setmetatable({}, ProgressController)
self.totalSteps = totalSteps or 0
self.showThreshold = SHOW_THRESHOLD
self.updateStepFraction = math.max(0.01, UPDATE_PERCENT / 100)
self.step = 0
self.lastFraction = -1
self._parentDialog = parentDialog
self.dlg = nil
return self
end
function ProgressController:shouldShow()
return (self.totalSteps or 0) >= (self.showThreshold or 0)
end
function ProgressController:ensureShown()
if not self.dlg and self:shouldShow() and (self.totalSteps or 0) > 0 then
self.dlg = createProgressDialog(self.totalSteps, "Applying...", self._parentDialog)
self.dlg.show()
end
end
function ProgressController:isCancelled()
return self.dlg and self.dlg.isCancelled() or false
end
function ProgressController:update(text)
self.step = self.step + 1
if not self.dlg then
-- Defer showing until needed
self:ensureShown()
end
if not self.dlg then
return -- Not showing progress (below threshold or zero steps)
end
local fraction = (self.totalSteps > 0) and (self.step / self.totalSteps) or 1
local shouldUpdate = (self.totalSteps <= 20)
or (self.step == 1)
or (self.step == self.totalSteps)
or (fraction >= (self.lastFraction + self.updateStepFraction))
if shouldUpdate then
self.dlg.update(self.step, text)
self.lastFraction = fraction
end
end
function ProgressController:finish()
if self.dlg then
self.dlg.update(self.totalSteps, "Finishing...")
self.dlg.close()
self.dlg = nil
end
end
-- Module-style export akin to Config.lua
Progress = {
new = ProgressController.new,
suspendTopmost = suspendTopmost, -- used by MessageBox so messages aren't hidden behind progress
}
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE "../../2 Boilerplate/Config.lua">>
;(function()
--[[
Configuration Helper for Family Historian Plugins
This file provides tools to save and load settings using .ini files, which are simple text files
that store configuration data in a format like this:
[Section]
key=value
The code creates a class named Config that handles reading and writing these settings,
managing dialog window positions, and creating configuration user interfaces.
@Author: Helen Wright
@Version: 1.3
@V1.3: two beta-retest fixes. (1) showTrackedDialog measured its minimum size AFTER
clearing minsize but BEFORE clearing the dialog's own SIZE attribute, so IUP's
reported natural size - which is max(content natural, SIZE) - inflated the floor
to the SIZE hint (e.g. a HALFxHALF main dialog got a minimum of half the SCREEN
on a large display, unnoticeable on a small one). computePlacement gains a fifth,
optional openSize parameter so a dialog still opens at its SIZE hint (capped) when
there is no saved size, while minsize and the natural-size floor use content-only
measurements. (2) showConfigDialog's Options dialog popped up with
iup.CENTERPARENT, which centres on the parent rectangle but does not clamp to the
screen - with the main window near the top of the display and Options taller than
it, the computed top could sit above the screen, leaving the title bar unreachable
until a resize re-clamped it. New pure placeOverParent helper computes an
explicit, screen-clamped popup position instead.
@V1.2: window geometry (tracked-dialog x/y/rastersize, and the Options
dialog's size) now lives in a per-machine store instead of the
plugin's own config ini, which for some plugins is per-project and so
synced a saved position between machines - a beta showstopper when an
ultra-wide-monitor position opened off-screen on a laptop. The saved
position is also now validated against each monitor's full bounds,
not just its origin, and clamped fully on-screen instead of being
accepted or rejected outright. The Options dialog is now capped at
the screen on first build (its sections already scroll), remembers
its size between sessions, and centres reliably over the plugin's
main window instead of wherever the keyboard focus happened to be.
@V1.1: showTrackedDialog restores a saved size clamped to [natural size,
screen work area] and floors minsize there (it previously measured
the natural size and discarded it, so stale saved sizes clipped
trailing controls and every consumer carried its own fix).
@Date: 2024
]]
do
local M = {} -- Module table
---@class Config
---@field filePath string
---@field defaultConfig table
---@field cache table<string, table<string, any>>
---@field callbacks table<string, table<string, fun(value:any, oldValue:any)>>
local Config = {}
Config.__index = Config
--==================== CONFIG-PLACEMENT-PURE ====================--
-- Dialog placement/sizing maths, pulled out pure (no iup/fh calls) so config_placement_spec.lua
-- can exercise it under plain lua.exe. Mirrored verbatim there - keep the two in step.
-- Vertical allowance left below a dialog's height cap for the title bar and taskbar (there is
-- no equivalent horizontal allowance - width caps use the monitor/screen width as-is).
local DECOR_ALLOWANCE = 80
--- Parse an IUP MONITORSINFO string into an array of monitor rects. Tolerates a nil/malformed
--- string (returns {}); coordinates may be negative (a monitor positioned left of/above the
--- primary display).
---@param miString string? -- multi-line "x y w h" per monitor
---@return {x:number, y:number, w:number, h:number}[]
local function parseMonitorsInfo(miString)
local monitors = {}
if type(miString) ~= "string" then
return monitors
end
for line in miString:gmatch("[^\r\n]+") do
local x, y, w, h = line:match("^%s*(%-?%d+)%s+(%-?%d+)%s+(%-?%d+)%s+(%-?%d+)")
if x then
monitors[#monitors + 1] = { x = tonumber(x), y = tonumber(y), w = tonumber(w), h = tonumber(h) }
end
end
return monitors
end
--- Work out where and how big to (re)open a tracked dialog. Pure: takes parsed inputs, returns
--- a placement, does no measuring or IUP calls itself.
---@param saved {x:number?, y:number?, w:number?, h:number?} -- from the geometry store
---@param natural {w:number?, h:number?} -- the dialog's measured, content-only natural size
---@param monitors {x:number, y:number, w:number, h:number}[] -- parsed MONITORSINFO
---@param screen {w:number?, h:number?} -- primary SCREENSIZE
---@param openSize {w:number?, h:number?}? -- the dialog's SIZE-attribute user size (e.g. "HALFxHALF"),
--- used only when there is no saved size; see the base-size comment at step 3
---@return {x:number?, y:number?, w:number?, h:number?, minW:number?, minH:number?} -- nil x/y means centre on parent
local function computePlacement(saved, natural, monitors, screen, openSize)
saved = saved or {}
natural = natural or {}
monitors = monitors or {}
screen = screen or {}
openSize = openSize or {}
-- 1. Which monitor (if any) does the saved position claim to be on? The margins (100
-- horizontal, 80 vertical) keep enough of the title bar on-screen to grab or close the
-- dialog even when the saved position sits right at a monitor's edge. Exactly (0,0) is the
-- existing "no saved position" sentinel, not a real top-left placement.
local target
if saved.x and saved.y and not (saved.x == 0 and saved.y == 0) then
for _, m in ipairs(monitors) do
if saved.x >= m.x and saved.x <= m.x + m.w - 100 and saved.y >= m.y and saved.y <= m.y + m.h - 80 then
target = m
break
end
end
end
-- 2. Cap dimensions against the target monitor, or the primary screen when centring.
local capW, capH
if target then
capW, capH = target.w, target.h - DECOR_ALLOWANCE
elseif screen.w and screen.h then
capW, capH = screen.w, screen.h - DECOR_ALLOWANCE
end
-- 3. Size: saved wins over openSize wins over natural (a stale saved size from an older,
-- narrower layout must not clip trailing controls, so the result is still floored at
-- natural below regardless of which of the three the base size came from). openSize is
-- the dialog's own SIZE-attribute hint (e.g. "HALFxHALF"): with no saved size the dialog
-- opens at that hint, capped like everything else - only the floor/minsize below are
-- content-only. When the dialog has no SIZE attribute, openSize == natural and this step
-- behaves exactly as before.
local w = saved.w or openSize.w or natural.w
local h = saved.h or openSize.h or natural.h
if natural.w and w then
w = math.max(w, natural.w)
end
if natural.h and h then
h = math.max(h, natural.h)
end
if capW and w then
w = math.min(w, capW)
end
if capH and h then
h = math.min(h, capH)
end
-- 4. Floor future user resizes at the (capped) natural size.
local minW = natural.w and (capW and math.min(natural.w, capW) or natural.w) or nil
local minH = natural.h and (capH and math.min(natural.h, capH) or natural.h) or nil
-- 5. Position: only meaningful with a target monitor (otherwise centre on parent). Clamp
-- using the final w/h so the dialog rect stays fully on the monitor; when w/h are unknown,
-- the already-contained saved position is kept as-is.
local x, y
if target then
x, y = saved.x, saved.y
if w then
x = math.max(target.x, math.min(x, target.x + target.w - w))
end
if h then
y = math.max(target.y, math.min(y, target.y + target.h - h))
end
end
return { x = x, y = y, w = w, h = h, minW = minW, minH = minH }
end
--- Cap an initial dialog size at the primary screen, with a 300x200 sanity floor - but NO
--- natural-size floor (unlike computePlacement above): a deliberately-reduced saved size must
--- survive between sessions. Used for the Options dialog, whose sections live in scrollboxes,
--- so a capped size never clips a field - it just scrolls.
---@param saved {w:number?, h:number?}?
---@param natural {w:number?, h:number?}?
---@param screen {w:number?, h:number?}?
---@return {w:number?, h:number?}
local function capSize(saved, natural, screen)
saved = saved or {}
natural = natural or {}
screen = screen or {}
local w = saved.w or natural.w
local h = saved.h or natural.h
if screen.w and w then
w = math.min(w, screen.w)
end
if screen.h and h then
h = math.min(h, screen.h - DECOR_ALLOWANCE)
end
if w then
w = math.max(w, 300)
end
if h then
h = math.max(h, 200)
end
return { w = w, h = h }
end
--- Work out where to pop up a dialog centred on its parent, clamped fully onto whichever
--- monitor the parent sits on (or the primary screen if none matches). iup.CENTERPARENT alone
--- centres on the parent rectangle but does NOT clamp to the screen: when the parent sits near
--- the top of the display and the popup is taller than it, CENTERPARENT can compute a top
--- above the screen, leaving the title bar unreachable until the next resize re-clamps it (a
--- beta report against the Options dialog).
---@param parent {x:number, y:number, w:number, h:number}? -- the parent dialog's screen rect
---@param size {w:number?, h:number?}? -- the popup dialog's own current size
---@param monitors {x:number, y:number, w:number, h:number}[] -- parsed MONITORSINFO
---@param screen {w:number?, h:number?} -- primary SCREENSIZE
---@return {x:number?, y:number?} -- empty table means "fall back to CENTERPARENT" (no usable parent/size)
local function placeOverParent(parent, size, monitors, screen)
monitors = monitors or {}
screen = screen or {}
size = size or {}
if not (parent and parent.x and parent.y and parent.w and parent.h and size.w and size.h) then
return {}
end
-- Centre on the parent rect.
local x = math.floor(parent.x + (parent.w - size.w) / 2)
local y = math.floor(parent.y + (parent.h - size.h) / 2)
-- Pick the monitor containing the parent's centre point - plain rect containment, no edge
-- margins needed (unlike computePlacement's saved-position check above, which is testing a
-- corner that must leave room to grab a title bar; here we are only choosing which monitor
-- rect to clamp into).
local cx = parent.x + parent.w / 2
local cy = parent.y + parent.h / 2
local clamp
for _, m in ipairs(monitors) do
if cx >= m.x and cx <= m.x + m.w and cy >= m.y and cy <= m.y + m.h then
clamp = m
break
end
end
if not clamp and screen.w and screen.h then
clamp = { x = 0, y = 0, w = screen.w, h = screen.h }
end
if not clamp then
return { x = x, y = y }
end
-- Clamp fully onto the chosen monitor; the lower bound is applied LAST so the title bar
-- (top-left) always wins when the popup is bigger than the monitor.
x = math.min(x, clamp.x + clamp.w - size.w)
x = math.max(clamp.x, x)
y = math.min(y, clamp.y + clamp.h - size.h)
y = math.max(clamp.y, y)
return { x = x, y = y }
end
--==================== END CONFIG-PLACEMENT-PURE ====================--
-- Machine-scope geometry store for ALL dialog geometry (tracked-dialog x/y/rastersize, and the
-- Options dialog's persisted size, further down). Window geometry is per-machine data: it must
-- never travel with a synced project the way the rest of a plugin's config can (Add Trees'
-- config ini is per-project, which is exactly how a saved ultra-wide-monitor position ended up
-- opening off-screen on a laptop - the frozen-looking-FH beta bug this store exists to prevent).
-- Path is resolved lazily, once, and pcall-guarded; on failure geometry persistence just
-- silently degrades to defaults / a centred dialog. No migration from old project-ini positions
-- - those are exactly the cross-machine values this store exists to stop trusting.
local geometryFilePath, geometryFilePathTried
local function getGeometryFilePath()
if not geometryFilePathTried then
geometryFilePathTried = true
local ok, path = pcall(function()
return fhGetPluginDataFileName("LOCAL_MACHINE", true) .. "\\"
.. fhGetContextInfo("CI_PLUGIN_NAME") .. " Window Layout.ini"
end)
if ok then
geometryFilePath = path
end
end
return geometryFilePath
end
local function getGeometryValue(key, fhType, default)
local path = getGeometryFilePath()
if not path then
return default
end
local ok, result = pcall(fhGetIniFileValue, path, "Dialogs", key, fhType, default)
if ok then
return result
end
return default
end
local function setGeometryValue(key, fhType, value)
local path = getGeometryFilePath()
if not path then
return
end
pcall(fhSetIniFileValue, path, "Dialogs", key, fhType, value)
end
--- Constructor for Config
---@param defaultConfig table
---@param scope? string
---@param filename? string
---@return Config
function M.new(defaultConfig, scope, filename)
local self = setmetatable({}, Config)
local pluginName = fhGetContextInfo("CI_PLUGIN_NAME")
scope = scope or "LOCAL_MACHINE"
filename = filename or (pluginName .. ".ini")
self.filePath = fhGetPluginDataFileName(scope, true) .. "\\" .. filename
self.defaultConfig = defaultConfig
self.cache = {}
self.callbacks = {}
local fhfu = require("fhFileUtils")
local fileExists = fhfu.fileExists(self.filePath)
-- Check if file exists and has content
local fileHasContent = false
if fileExists then
local success, fileContent = pcall(function()
return fhLoadTextFile(self.filePath, "UTF-16LE")
end)
if success and fileContent then
fileHasContent = #fileContent:gsub("%s+", "") > 0
end
end
-- Create empty file if it doesn't exist
if not fileExists then
local success = pcall(function()
fhSaveTextFile(self.filePath, "", "UTF-16LE")
end)
-- If creating the file fails, we'll continue with defaults
end
for _, section in ipairs(self.defaultConfig.sections or {}) do
self.cache[section.title] = {}
self.callbacks[section.title] = {}
for _, field in ipairs(section.fields) do
local valueType = type(field.default)
local fhType = valueType == "string" and "text"
or valueType == "number" and "integer"
or valueType == "boolean" and "bool"
or valueType
local value
-- Only try to read from file if it exists and has content
if fileExists and fileHasContent then
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section.title, field.key, fhType, field.default)
end)
if success then
value = result
else
-- If reading fails, use default value
value = field.default
end
else
-- Use default value and write it to the file
value = field.default
local success = pcall(function()
fhSetIniFileValue(self.filePath, section.title, field.key, fhType, value)
end)
-- If writing fails, just continue - this ensures the method doesn't crash
if not success then
-- Log error using fhMessageBox
fhMessageBox(
"Failed to write configuration value to file: "
.. self.filePath
.. "\nSection: "
.. section.title
.. "\nKey: "
.. field.key,
"MB_OK",
"MB_ICONERROR"
)
end
end
self.cache[section.title][field.key] = value
if field.onChange then
self.callbacks[section.title][field.key] = field.onChange
end
end
end
return self
end
function Config:initializeDefaults()
for _, section in ipairs(self.defaultConfig.sections or {}) do
for _, field in ipairs(section.fields) do
local valueType = type(field.default)
local fhType = valueType == "string" and "text"
or valueType == "number" and "integer"
or valueType == "boolean" and "bool"
or valueType
-- Use pcall to handle potential errors when writing to file
local success = pcall(function()
fhSetIniFileValue(self.filePath, section.title, field.key, fhType, field.default)
end)
-- If writing fails, just continue - this ensures the method doesn't crash
if not success then
-- Log error using fhMessageBox
fhMessageBox(
"Failed to write configuration value to file: "
.. self.filePath
.. "\nSection: "
.. section.title
.. "\nKey: "
.. field.key,
"MB_OK",
"MB_ICONERROR"
)
end
end
end
end
function Config:getString(section, key, default)
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section, key, "text", default or "")
end)
return success and result or (default or "")
end
function Config:getNumber(section, key, default)
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section, key, "integer", default or 0)
end)
return success and result or (default or 0)
end
function Config:getBool(section, key, default)
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section, key, "bool", default or false)
end)
return success and result or (default or false)
end
-- Hex-only color handling
--- Opens a color picker dialog, stores the result if confirmed, and returns chosen hex
---@param section string
---@param key string
---@param startColor string|nil -- e.g. "#RRGGBB"
---@return string|nil -- hex or nil if cancelled
function Config:chooseColor(section, key, startColor)
local baseHex = self:getHexColor(section, key, startColor or "#000000")
local dlg = iup.colordlg({})
pcall(function()
dlg.valuehex = baseHex
dlg.showhex = "YES"
end)
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" then
local hex = tostring(dlg.valuehex or baseHex)
self:setHexColor(section, key, hex)
return hex
end
return nil
end
--- Opens a color picker with alpha; stores and returns hex (#RRGGBBAA)
---@param section string
---@param key string
---@param startColor string|nil -- e.g. "#RRGGBBAA"
---@return string|nil
function Config:chooseColorRGBA(section, key, startColor)
local current = self:getHexColorRGBA(section, key, startColor or "#000000FF")
local baseHex = current:sub(1, 7)
local dlg = iup.colordlg({})
pcall(function()
dlg.valuehex = baseHex
dlg.showhex = "YES"
dlg.showalpha = "YES"
-- keep existing alpha in the field if any
dlg.alpha = tostring(tonumber(current:sub(8, 9), 16) or 255)
end)
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" then
local hex = tostring(dlg.valuehex or baseHex)
local aa = tonumber(dlg.alpha) or 255
aa = math.max(0, math.min(255, aa))
hex = string.format("%s%02X", hex, aa)
self:setHexColorRGBA(section, key, hex)
return hex
end
return nil
end
--- Retrieves a font string from config (IUP font syntax, e.g. "Segoe UI, 10")
---@param section string
---@param key string
---@param default string|nil
---@return string
function Config:getFont(section, key, default)
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section, key, "text", default or "")
end)
local value = success and result or (default or "")
self.cache[section] = self.cache[section] or {}
self.cache[section][key] = value
return value
end
-- Hex-first color API (simple string get/set). Preferred for plugins using hex everywhere.
--- Gets a hex color string (#RRGGBB) from config
---@param section string
---@param key string
---@param default string|nil -- e.g. "#000000"
---@return string
function Config:getHexColor(section, key, default)
return self:getString(section, key, default or "#000000")
end
--- Sets a hex color string (#RRGGBB) in config
---@param section string
---@param key string
---@param hex string
function Config:setHexColor(section, key, hex)
pcall(function()
fhSetIniFileValue(self.filePath, section, key, "text", tostring(hex or "#000000"))
end)
self.cache[section] = self.cache[section] or {}
self.cache[section][key] = tostring(hex or "#000000")
end
--- Gets a hex color string with alpha (#RRGGBBAA) from config
---@param section string
---@param key string
---@param default string|nil -- e.g. "#000000FF"
---@return string
function Config:getHexColorRGBA(section, key, default)
return self:getString(section, key, default or "#000000FF")
end
--- Sets a hex color string with alpha (#RRGGBBAA) in config
---@param section string
---@param key string
---@param hex string
function Config:setHexColorRGBA(section, key, hex)
pcall(function()
fhSetIniFileValue(self.filePath, section, key, "text", tostring(hex or "#000000FF"))
end)
self.cache[section] = self.cache[section] or {}
self.cache[section][key] = tostring(hex or "#000000FF")
end
--- Stores a font string in config (IUP font syntax)
---@param section string
---@param key string
---@param font string
function Config:setFont(section, key, font)
pcall(function()
fhSetIniFileValue(self.filePath, section, key, "text", font)
end)
self.cache[section] = self.cache[section] or {}
self.cache[section][key] = font
end
--- Opens a font dialog, stores the result if confirmed, and returns the chosen font string
---@param section string
---@param key string
---@param startFont string|nil
---@return string|nil -- returns nil if cancelled
function Config:chooseFont(section, key, startFont)
local current = self:getFont(section, key, startFont or "")
local dlg = iup.fontdlg({ value = current })
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" and dlg.value and dlg.value ~= "" then
self:setFont(section, key, dlg.value)
return dlg.value
end
return nil
end
--- Shows a dialog with position and size tracking capabilities
--- This function enhances a dialog by:
--- 1. Loading and restoring the dialog's previous position and size from configuration
--- 2. Saving the dialog's position and size when it's closed
--- 3. Ensuring the dialog appears on a valid monitor
--- 4. Preserving any existing dialog callbacks
---@param dialog iup.dialog The dialog to show with tracking
---@param dialogId string Unique identifier for this dialog (used for config storage)
function Config:showTrackedDialog(dialog, dialogId)
local config = self
-- Store the original close callback to preserve existing functionality
local originalClose = dialog.close_cb
-- Load the previously saved position and size from the per-machine geometry store (NOT
-- self - see the geometry-store comment above; a project-scoped position is exactly what
-- produced the off-screen-on-a-laptop beta bug). Missing keys default to 0/"" - the same
-- defaults fhGetIniFileValue would give a first run - so (0,0) still reads as "unset" below.
local savedX = getGeometryValue("Dialog_" .. dialogId .. ".x", "integer", 0)
local savedY = getGeometryValue("Dialog_" .. dialogId .. ".y", "integer", 0)
local savedRaster = getGeometryValue("Dialog_" .. dialogId .. ".rastersize", "text", "")
-- Override the close callback to save position and size when dialog closes
dialog.close_cb = function(dlg)
-- Only save position if dialog is not maximized or minimized
if dialog.maximized == "NO" and dialog.minimized == "NO" then
local pos = dialog.screenposition
if pos then
-- Extract x,y coordinates from the position string (format: "x,y")
local closeX, closeY = pos:match("^(%-?%d+),(%-?%d+)$")
if closeX and closeY then
-- Save the current position and size to the geometry store
setGeometryValue("Dialog_" .. dialogId .. ".x", "integer", tonumber(closeX))
setGeometryValue("Dialog_" .. dialogId .. ".y", "integer", tonumber(closeY))
setGeometryValue("Dialog_" .. dialogId .. ".rastersize", "text", dlg.rastersize)
end
end
end
-- Call the original close callback if it exists
if originalClose then
return originalClose(dlg)
end
return iup.CLOSE
end
-- Measure natural size twice. First with the dialog's own SIZE attribute (if any) still in
-- place: IUP's reported natural size is max(content natural, SIZE), so a dialog built with
-- e.g. size = "HALFxHALF" reports a "natural" size of half the SCREEN on a large display,
-- unnoticeable on a small one - a beta report against Add Trees' main dialog. Then again
-- with SIZE cleared, for the content-only natural size that the floor/minsize below must
-- use (SIZE is an opening-size hint, not a floor). When the dialog has no SIZE attribute
-- the two measurements are identical and behaviour is exactly as before.
dialog.minsize = iup.NULL
iup.Refresh(dialog)
local naturalWithUser = dialog.naturalsize -- includes any SIZE= user size (e.g. HALFxHALF)
dialog.size = iup.NULL -- clear the user size: an opening-size hint, not a floor
iup.Refresh(dialog)
local true_natural = dialog.naturalsize -- content-only natural
-- Work out size/position via the pure placement function (Stage 4, 5 Jul 2026 moved the
-- floor/cap logic here from the consumer plugins; the monitor-bounds validation it relied
-- on only ever checked a monitor's origin, so any large positive coordinate passed and the
-- dialog could open off-screen - computePlacement checks the full monitor rect instead).
local openW, openH = tostring(naturalWithUser or ""):match("^(%d+)x(%d+)$")
local natW, natH = tostring(true_natural or ""):match("^(%d+)x(%d+)$")
local scrW, scrH = tostring(iup.GetGlobal("SCREENSIZE") or ""):match("^(%d+)x(%d+)$")
local monitors = parseMonitorsInfo(iup.GetGlobal("MONITORSINFO"))
local savedW, savedH
if savedRaster ~= "" then
local w, h = savedRaster:match("^(%d+)x(%d+)$")
savedW, savedH = tonumber(w), tonumber(h)
end
local placement = computePlacement(
{ x = savedX, y = savedY, w = savedW, h = savedH },
{ w = natW and tonumber(natW) or nil, h = natH and tonumber(natH) or nil },
monitors,
{ w = scrW and tonumber(scrW) or nil, h = scrH and tonumber(scrH) or nil },
{ w = openW and tonumber(openW) or nil, h = openH and tonumber(openH) or nil }
)
if placement.w and placement.h then
dialog.rastersize = placement.w .. "x" .. placement.h
end
-- Floor future user resizes at the (screen-capped) natural size.
if placement.minW and placement.minH then
dialog.minsize = placement.minW .. "x" .. placement.minH
end
-- Override the show callback to restore minimum size after dialog is shown
local originalShow = dialog.show_cb
dialog.show_cb = function(self, state)
-- Call the original show callback if it exists
if originalShow then
originalShow(self, state)
end
return iup.DEFAULT
end
-- Record the first dialog tracked as the plugin's main window (showConfigDialog uses this
-- to centre the Options dialog reliably - see the PARENTDIALOG comment there).
if not config._trackedMainDialog then
config._trackedMainDialog = dialog
end
-- Position the dialog based on saved coordinates or center it
if placement.x and placement.y then
dialog:showxy(placement.x, placement.y)
else
dialog:showxy(iup.CENTERPARENT, iup.CENTERPARENT)
end
end
function Config:getValue(section, key, default, validator)
local value = self.cache[section] and self.cache[section][key]
if value == nil then
local valueType = type(default)
local fhType = valueType == "string" and "text"
or valueType == "number" and "integer"
or valueType == "boolean" and "bool"
or valueType
-- Use pcall to handle potential errors when reading from file
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section, key, fhType, default)
end)
if success then
value = result
else
-- If reading fails, use the default value
value = default
end
self.cache[section] = self.cache[section] or {}
self.cache[section][key] = value
end
if validator and not validator(value) then
return default
end
return value
end
function Config:setValues(section, prefix, valueTable)
self.cache[section] = self.cache[section] or {}
for key, value in pairs(valueTable) do
local fullKey = prefix and (prefix .. "." .. key) or key
local valueType = type(value)
local fhType = valueType == "string" and "text"
or valueType == "number" and "integer"
or valueType == "boolean" and "bool"
or valueType
local oldValue = self.cache[section][fullKey]
-- Use pcall to handle potential errors when writing to file
local success = pcall(function()
fhSetIniFileValue(self.filePath, section, fullKey, fhType, value)
end)
if success then
self.cache[section][fullKey] = value
if oldValue ~= value and self.callbacks[section] and self.callbacks[section][fullKey] then
self.callbacks[section][fullKey](value, oldValue)
end
else
-- If writing fails, still update the cache but log the error
-- This ensures the application continues to work even if file writing fails
self.cache[section][fullKey] = value
end
end
end
function Config:getValues(section, prefix, defaultTable)
local results = {}
for key, default in pairs(defaultTable) do
local fullKey = prefix and (prefix .. "." .. key) or key
local valueType = type(default)
local fhType = valueType == "string" and "text"
or valueType == "number" and "integer"
or valueType == "boolean" and "bool"
or valueType
-- Use pcall to handle potential errors when reading from file
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section, fullKey, fhType, default)
end)
if success then
results[key] = result
else
-- If reading fails, use the default value
results[key] = default
end
end
return results
end
--- Create a control for a config field. When sectionTitle is passed (createControl(sectionTitle, field, value)),
--- it is available for controls that need section context. Values are saved only when the user chooses Save.
---@param a string|table Section title (3-arg) or field definition (2-arg)
---@param b table|any Field definition (3-arg) or value (2-arg)
---@param c any|nil Current value (3-arg only)
---@return iup.element control The control whose value holds the field value.
---@return iup.element? display Optional composite container to place instead of the control (pickers).
---@return fun()? onValueSet Optional hook to re-sync the control's display after a programmatic value change (colour swatches).
function Config:createControl(a, b, c)
local sectionTitle, field, value
if c ~= nil then
sectionTitle, field, value = a, b, c
else
field, value = a, b
sectionTitle = nil
end
if field.type == "text" or field.type == "string" then
local opts = {
value = value ~= nil and tostring(value) or "",
-- Restore-on-blank target for Dialog.makeText: the field's default. Lets an optional text
-- field be cleared (default "" -> stays blank) instead of snapping back to the old value.
default = field.default,
}
if field.mask == "TOKEN" then
-- Tokens are matched against template text case-sensitively, so
-- keep them upper-case. FILTER converts typed input live (the
-- native Windows edit style), unlike the old "[A-Z0-9.]*" MASK
-- which rejected lower-case keys and kept only the first letter.
opts.value = (opts.value or ""):upper()
opts.filter = "UPPERCASE"
elseif field.mask then
opts.mask = field.mask
end
-- Optional per-field validation when focus leaves the box (e.g. checking a privacy
-- flag exists). Receives the control's current value.
if field.onBlur then
opts.killfocus = function(self)
field.onBlur(self.value)
end
end
return makeText(opts)
elseif field.type == "number" then
return makeText({
value = tostring(value),
mask = iup.MASK_FLOAT, -- FH's iuplua registers iup.MASK_FLOAT; IUP_MASK_FLOAT is nil
})
elseif field.type == "boolean" then
return makeToggle({
title = "",
value = value and "ON" or "OFF",
})
elseif field.type == "list" then
local selectedIndex = 1
for i, opt in ipairs(field.options) do
if opt == value then
selectedIndex = i
break
end
end
-- sort=NO is essential: the value is mapped to its POSITION in
-- field.options, so the list must keep insertion order (the IUPLIST
-- theme default sorts alphabetically, which desynchronises the
-- index from the option). Set the value after the items are
-- populated so the selection actually takes.
local list = makeList({
dropdown = "YES",
sort = "NO",
values = field.options or {},
})
list.value = tostring(selectedIndex)
return list
elseif field.type == "font" then
local btn = iup.button({ title = tostring(value or "Choose..."), expand = "HORIZONTAL" })
btn.__value = tostring(value or "")
if btn.__value ~= "" then
btn.font = btn.__value
end
function btn:action()
local start = self.__value or ""
local dlg = iup.fontdlg({ value = start })
pcall(function()
dlg.parentdialog = iup.GetDialog(self)
end)
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" and dlg.value and dlg.value ~= "" then
self.__value = dlg.value
self.title = dlg.value
self.font = dlg.value
end
return iup.DEFAULT
end
return btn
elseif field.type == "color" or field.type == "colorRGBA" then
local startHex = tostring(value or (field.type == "colorRGBA" and "#000000FF" or "#000000"))
if field.type == "colorRGBA" then
-- Create a flat button for RGBA with integrated color display
local btn = iup.flatbutton({ title = startHex, expand = "HORIZONTAL" })
btn.__value = startHex
btn.border = "YES"
btn.borderwidth = "1"
btn.bordercolor = "128 128 128"
local function applyStyle()
local hex = btn.__value
local base = hex:sub(1, 7) -- Get #RRGGBB part
-- Parse alpha if present
local alpha = 255
if #hex == 9 then -- #RRGGBBAA
alpha = tonumber(hex:sub(8, 9), 16) or 255
end
-- Blend color with dialog background (assume white background)
local r = tonumber(hex:sub(2, 3), 16) or 0
local g = tonumber(hex:sub(4, 5), 16) or 0
local b = tonumber(hex:sub(6, 7), 16) or 0
-- Alpha blend with white background (255, 255, 255)
local alphaRatio = alpha / 255
local blendedR = math.floor(r * alphaRatio + 255 * (1 - alphaRatio))
local blendedG = math.floor(g * alphaRatio + 255 * (1 - alphaRatio))
local blendedB = math.floor(b * alphaRatio + 255 * (1 - alphaRatio))
local blendedHex = string.format("#%02X%02X%02X", blendedR, blendedG, blendedB)
btn.bgcolor = blendedHex -- Use alpha-blended background color
btn.fgcolor = base -- Keep text color as the original color
end
applyStyle()
function btn:map_cb()
applyStyle()
return iup.DEFAULT
end
function btn:flat_action()
local current = self.__value or "#000000FF"
local baseHex = current:sub(1, 7)
local alpha = 255
if #current == 9 then -- #RRGGBBAA
alpha = tonumber(current:sub(8, 9), 16) or 255
end
local dlg = iup.colordlg({})
dlg.valuehex = baseHex
dlg.showhex = "YES"
dlg.showalpha = "YES"
dlg.alpha = tostring(alpha)
dlg.parentdialog = iup.GetDialog(self)
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" then
local hex = tostring(dlg.valuehex or baseHex)
local aa = tonumber(dlg.alpha) or alpha
aa = math.max(0, math.min(255, aa))
hex = string.format("%s%02X", hex, aa)
self.__value = hex
self.title = hex
applyStyle()
end
return iup.DEFAULT
end
return btn, nil, applyStyle
else
-- Regular color button for non-RGBA
local btn = iup.button({ title = startHex, expand = "HORIZONTAL" })
btn.__value = startHex
local function applyStyle()
local base = btn.__value:sub(1, 7) -- Get #RRGGBB part
btn.fgcolor = base
end
applyStyle()
function btn:map_cb()
applyStyle()
return iup.DEFAULT
end
function btn:action()
local current = self.__value or "#000000"
local dlg = iup.colordlg({})
dlg.valuehex = current
dlg.showhex = "YES"
dlg.parentdialog = iup.GetDialog(self)
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" then
local hex = tostring(dlg.valuehex or current)
self.__value = hex
self.title = hex
applyStyle()
end
return iup.DEFAULT
end
return btn, nil, applyStyle
end
elseif field.type == "choice" then
-- Simple dropdown using provided field.choices list
local combo = iup.list({
dropdown = "YES",
expand = "HORIZONTAL",
})
-- Populate options
local choices = field.choices or {}
local selectedIndex = 1
for i, opt in ipairs(choices) do
combo[tostring(i)] = tostring(opt)
if value == opt then
selectedIndex = i
end
end
-- Fallbacks for values not in choices
if value ~= nil and value ~= "" then
if #choices == 0 then
-- No predefined choices: treat current value as the sole option
combo["1"] = tostring(value)
selectedIndex = 1
else
-- choices present: insert custom value at index 0 if not found
local found = false
for _, opt in ipairs(choices) do
if opt == value then
found = true
break
end
end
if not found then
-- Insert at position 0 visually; leave underlying field.choices untouched
combo["0"] = tostring(value)
selectedIndex = 0
end
end
end
combo.value = tostring(selectedIndex)
return combo
elseif field.type == "colour" then
-- A hex colour whose own field background shows the colour (an iup.label's BGCOLOR
-- does not paint on Windows, but a text field's edit-area background does), plus a
-- native picker. The value lives on the text control's .value (a hex string), so it
-- rides the default value paths (savedValueFor/applyValueToControl/controlValue). The
-- returned refresh hook (onValueSet) repaints the swatch after a programmatic change
-- (a scheme/theme load, or Reset), which does not fire valuechanged_cb.
local text = makeText({
value = tostring(value or "#ffffff"),
mask = "#[0-9a-fA-F]+",
})
local function refreshSwatch()
local r, g, b = tostring(text.value or ""):match("^#(%x%x)(%x%x)(%x%x)$")
if r then
local rn, gn, bn = tonumber(r, 16), tonumber(g, 16), tonumber(b, 16)
text.bgcolor = string.format("%d %d %d", rn, gn, bn)
-- Contrasting text so the hex stays readable on its colour.
local luminance = 0.299 * rn + 0.587 * gn + 0.114 * bn
text.fgcolor = (luminance > 140) and "0 0 0" or "255 255 255"
end
end
refreshSwatch()
text.valuechanged_cb = function()
refreshSwatch()
return iup.DEFAULT
end
local pick = makeAssistantButton({
tip = "Choose a colour",
callback = function()
local dlg = iup.colordlg({ title = field.label:gsub(":%s*$", "") })
local r, g, b = tostring(text.value or ""):match("^#(%x%x)(%x%x)(%x%x)$")
if r then
dlg.value = string.format("%d %d %d", tonumber(r, 16), tonumber(g, 16), tonumber(b, 16))
end
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" then
local rr, gg, bb = tostring(dlg.value):match("^(%d+)%s+(%d+)%s+(%d+)")
if rr then
text.value = string.format("#%02x%02x%02x", tonumber(rr), tonumber(gg), tonumber(bb))
refreshSwatch()
end
end
dlg:destroy()
end,
})
return text, iup.hbox({ text, pick, alignment = "ACENTER" }), refreshSwatch
elseif field.type == "folder" or field.type == "file" then
-- A read-only path display plus a native browse dialog. The user
-- may choose ANY folder/file; field.root (a string, or a function
-- returning one) is only a convenient starting directory, not a
-- restriction. The value lives on the text control's own .value
-- (no smuggling on a container).
local fhfu = require("fhFileUtils")
local function pickerRoot()
local root = field.root
if type(root) == "function" then
root = root()
end
if type(root) ~= "string" or root == "" then
root = field.default
end
if type(root) ~= "string" or root == "" then
root = fhGetContextInfo("CI_PROJECT_PUBLIC_FOLDER") or ""
end
return tostring(root or "")
end
local text = makeText({
value = tostring(value or ""),
readonly = "YES",
expand = "HORIZONTAL",
})
local browse = makeAssistantButton({
tip = (field.type == "folder") and "Browse for a folder" or "Browse for a file",
callback = function()
local start = text.value
if start == "" or not (fhfu.folderExists(start) or fhfu.fileExists(start)) then
start = pickerRoot()
end
local dlg = iup.filedlg({
dialogtype = (field.type == "folder") and "DIR" or "OPEN",
title = (field.label or ""):gsub(":%s*$", ""),
directory = (start ~= "") and start or nil,
parentdialog = identifyActiveWindow(),
})
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
-- STATUS is "-1" on cancel; "0"/"1" select an existing/new path.
if tostring(dlg.status) ~= "-1" and dlg.value and dlg.value ~= "" then
text.value = (dlg.value):gsub("[/\\]+$", "")
end
dlg:destroy()
end,
})
return text, iup.hbox({ text, browse, alignment = "ACENTER" })
else
error("Unsupported field type: " .. field.type)
end
end
---@class ConfigDialogApi
---@field setValue fun(sectionTitle: string, key: string, value: any) Push a value into a field's control.
---@field setListOptions fun(sectionTitle: string, key: string, newOptions: string[], selected?: string) Replace a list field's options in place.
---@class ConfigExtraAction
---@field title string Button label.
---@field tip? string Tooltip.
---@field section? string If set, render the button inside that section's tab only, instead of on the bottom row shown beneath every tab.
---@field action fun(values: table<string, table<string, any>>, api: ConfigDialogApi) Receives the current (unsaved) values keyed by section then field key, plus the live-dialog API.
---Show the options dialog.
---@param options? table Configuration structure; defaults to the one passed to new().
---@param help_topic? string Help topic for F1/Help menu.
---@param extraActions? ConfigExtraAction[] Buttons that read the live, unsaved control values (e.g. Preview).
function Config:showConfigDialog(options, help_topic, extraActions)
options = options or self.defaultConfig
help_topic = help_topic or "options"
-- Pop the Options dialog up centred on the plugin's main window, clamped fully onto
-- whichever monitor that window is on - plain iup.CENTERPARENT does not clamp to the
-- screen, so with the main window near the top of the display and Options taller than it,
-- the title bar could open above the screen until the next resize re-clamped it (beta
-- report). Falls back to CENTERPARENT if the parent's geometry can't be read (e.g. no
-- tracked main dialog yet, or a screenposition/rastersize parse failure). Used on every
-- open - both the reuse fast path below and the first-build popup at the end - since the
-- main window can have moved between opens.
local function popupPlaced(dlg)
local parent
local main = self._trackedMainDialog
if main then
local pos, raster = main.screenposition, main.rastersize
if pos and raster then
local px, py = pos:match("^(%-?%d+),(%-?%d+)$")
local pw, ph = raster:match("^(%d+)x(%d+)$")
if px and py and pw and ph then
parent = { x = tonumber(px), y = tonumber(py), w = tonumber(pw), h = tonumber(ph) }
end
end
end
local dw, dh
local dr = dlg.rastersize
if dr then
local w, h = dr:match("^(%d+)x(%d+)$")
dw, dh = tonumber(w), tonumber(h)
end
local monitors = parseMonitorsInfo(iup.GetGlobal("MONITORSINFO"))
local scrW, scrH = tostring(iup.GetGlobal("SCREENSIZE") or ""):match("^(%d+)x(%d+)$")
local r = placeOverParent(
parent,
{ w = dw, h = dh },
monitors,
{ w = scrW and tonumber(scrW) or nil, h = scrH and tonumber(scrH) or nil }
)
if r.x and r.y then
dlg:popup(r.x, r.y)
else
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
end
end
-- Build the options dialog ONCE, then reuse it. Rebuilding it on every
-- open - a fresh dialog, a fresh menu, and the controls re-added to the
-- global normalisers - corrupted native state and silently killed FH
-- on the second open (release builds only; not seen in the plugin
-- debugger). On reopen we just reload the saved values into the
-- existing controls and show the same dialog again.
if self._optionsDialog then
if self._optionsReload then
self._optionsReload()
end
popupPlaced(self._optionsDialog)
return
end
local controls = {}
local mainContent
local allSections = {}
local sectionMenuItems = {}
-- Auto-assign an Alt-key access key to each section-selector menu item. The section items sit in the
-- menu bar beside File and Help, so their mnemonics must not collide with those (F, H), with each
-- other, or with the dialog's action buttons (which the menu bar would otherwise shadow). We insert
-- '&' before the first ASCII letter of the title that is still free; if a title already has an '&'
-- mnemonic we respect it, and if nothing is free we leave the title plain (no mnemonic).
local usedMnemonics = { F = true, H = true } -- File / Help are always present
for _, extra in ipairs(extraActions or {}) do -- reserve each action button's access key
local m = type(extra.title) == "string" and extra.title:match("&([A-Za-z])")
if m then usedMnemonics[m:upper()] = true end
end
local function sectionTitleWithMnemonic(title)
local existing = title:match("&([A-Za-z])")
if existing then
usedMnemonics[existing:upper()] = true
return title
end
for i = 1, #title do
local ch = title:sub(i, i)
if ch:match("[A-Za-z]") and not usedMnemonics[ch:upper()] then
usedMnemonics[ch:upper()] = true
return title:sub(1, i - 1) .. "&" .. title:sub(i)
end
end
return title -- no free letter; leave it without a mnemonic
end
-- Forward declaration: section-targeted action buttons (built in the
-- section loop) call this, but it is defined after the loop.
local collectValues
-- Set one control to one value, per field type. Shared by Reset (with
-- the field default) and by reload-on-reopen (with the saved value).
local function applyValueToControl(field, control, value)
if field.type == "boolean" then
control.value = value and "ON" or "OFF"
elseif field.type == "list" then
for i, opt in ipairs(field.options or {}) do
if opt == value then
control.value = tostring(i)
return
end
end
elseif field.type == "font" then
control.__value = tostring(value or "")
control.title = (control.__value ~= "") and control.__value or "Choose..."
if control.__value ~= "" then
pcall(function() control.font = control.__value end)
end
elseif field.type == "color" or field.type == "colorRGBA" then
local fallback = field.type == "colorRGBA" and "#000000FF" or "#000000"
control.__value = tostring(value or fallback)
control.title = control.__value
-- The swatch is repainted by the field's onValueSet (applyStyle),
-- fired by the caller after this.
elseif field.type == "choice" then
for i, opt in ipairs(field.choices or {}) do
if opt == value then
control.value = tostring(i)
return
end
end
else
control.value = tostring(value)
end
end
-- The saved value of a field, read the same way createControl reads it.
local function savedValueFor(field, sectionTitle, key)
if
field.type == "font"
or field.type == "color"
or field.type == "colorRGBA"
or field.type == "colour"
or field.type == "folder"
or field.type == "file"
then
return self:getString(sectionTitle, key, field.default or "")
end
return self:getValue(sectionTitle, key, field.default)
end
-- Pull a control's current (unsaved) value, coerced to the field type.
local function controlValue(field, control)
if field.type == "number" then
local v = tonumber(control.value)
if v == nil then
v = field.default
end
return v
elseif field.type == "boolean" then
return control.value == "ON"
elseif field.type == "list" then
if control.value == "0" then
return field.default
end
return field.options[tonumber(control.value)]
elseif field.type == "choice" then
local idx = tonumber(control.value)
if idx == 0 then
-- Custom value not in the choices list, held at combo["0"].
return (control["0"] and control["0"] ~= "") and control["0"] or field.default
elseif field.choices and idx and field.choices[idx] then
return field.choices[idx]
end
return field.default
elseif field.type == "font" or field.type == "color" or field.type == "colorRGBA" then
return tostring(control.__value or "")
else
-- text / string / folder / file
local v = control.value
if field.mask == "TOKEN" then
v = (v or ""):upper()
end
return v
end
end
-- The sections live in a zbox, which sizes itself to the LARGEST child
-- and shows one at a time. That gives a stable dialog size that fits
-- every tab - no resizing as you switch, and no controls clipped (the
-- previous visible/floating + Refresh approach resized the dialog to
-- each tab in turn, which shrank it below some tabs' width). The visible
-- child is selected by its 0-based position (VALUEPOS), a plain
-- attribute. (Selecting by handle would also work - iuplua implements
-- IupSetAttributeHandle via attribute assignment, e.g. zbox.value = child,
-- spike S6 - but the position is what we track here anyway.)
local sectionZbox
local function showSection(sectionToShow)
if not sectionZbox then
return
end
for i, section in ipairs(allSections) do
if section == sectionToShow then
sectionZbox.valuepos = tostring(i - 1)
return
end
end
end
-- A small API handed to field liveChange callbacks and extra-action
-- buttons, for updating other live controls in the open dialog.
---@type ConfigDialogApi
local dialogApi = {}
function dialogApi.setValue(sectionTitle, key, value)
local info = controls[sectionTitle] and controls[sectionTitle][key]
if info then
applyValueToControl(info.field, info.control, value)
if info.onValueSet then
info.onValueSet()
end
end
end
function dialogApi.setListOptions(sectionTitle, key, newOptions, selected)
local info = controls[sectionTitle] and controls[sectionTitle][key]
if info and info.field.type == "list" then
info.field.options = newOptions
populateList(info.control, newOptions)
if selected ~= nil then
applyValueToControl(info.field, info.control, selected)
end
end
end
-- Field labels live in their OWN normaliser so they line up with each
-- other inside the Options dialog WITHOUT joining the global btnnorm -
-- a long option label there would otherwise stretch every label and
-- button in every other dialog the plugin opens (the makeLabel /
-- DoNormalize trap). Callers can pass options.labelNormalizer to share
-- this normaliser with other parts of their UI; default is a fresh,
-- dialog-local one. We fire it ourselves below, after DoNormalize.
local labelNorm = options.labelNormalizer or iup.normalizer({})
sectionZbox = iup.zbox({})
mainContent = sectionZbox
for _, section in ipairs(options.sections or {}) do
local sectionContent = iup.vbox({})
controls[section.title] = {}
-- Section action buttons (extra.section) - shown FIRST in the tab and left-aligned (under
-- the label column), so a prominent action like a picker leads the section rather than
-- trailing the fields.
for _, extra in ipairs(extraActions or {}) do
if extra.section == section.title then
sectionContent:append(iup.hbox({
makeButton({
title = extra.title,
tip = extra.tip,
callback = function()
extra.action(collectValues(), dialogApi)
end,
}),
iup.fill({}),
-- Indent to line the button up under the field labels, which carry the theme's
-- 20px horizontal label padding (so the button doesn't sit proud to their left).
margin = "20x0",
}))
end
end
for _, field in ipairs(section.fields) do
local value
if field.type == "font" then
value = self:getString(section.title, field.key, field.default or "")
elseif field.type == "color" then
value = self:getString(section.title, field.key, field.default or "#000000")
elseif field.type == "colorRGBA" then
value = self:getString(section.title, field.key, field.default or "#000000FF")
elseif field.type == "colour" then
value = self:getString(section.title, field.key, field.default or "#ffffff")
elseif field.type == "folder" or field.type == "file" then
-- Read from file so the display matches the saved value (cache
-- may predate the file write); keep cache in step.
value = self:getString(section.title, field.key, field.default or "")
self.cache[section.title] = self.cache[section.title] or {}
self.cache[section.title][field.key] = value
else
value = self:getValue(section.title, field.key, field.default)
end
local label = makeLabel({ title = field.label, normalizergroup = labelNorm })
local control, display, onValueSet = self:createControl(section.title, field, value)
-- The tooltip lives on the CONTROL, not the label - consistent with the plugins' main
-- UIs, which tip the field you interact with (so the tip never jumps side-to-side as you
-- move down the dialog). field.tip lets a plugin override the wording (e.g. a placeholder
-- hint); otherwise the field's description. Routed through setTipWithHelp so long tips wrap.
-- `control` is always the value-bearing widget (for folder/file that's the text box, not
-- the hbox), so this lands on the right place for every field type.
local fieldTip = field.tip or field.description
if control and fieldTip and fieldTip ~= "" then
setTipWithHelp(control, fieldTip)
end
controls[section.title][field.key] = {
control = control,
field = field,
onValueSet = onValueSet,
}
-- Wire list-field live-change callbacks (e.g. a colour-scheme
-- list loading its palette into the colour fields). DIALOG-only;
-- must NOT be named onChange (a save-time (value, oldValue)
-- callback) - firing that here would pass a string where the
-- dialog API is expected.
if field.liveChange and field.type == "list" then
control.action = function(_, text, _, state)
if state == 1 then
field.liveChange(text, dialogApi)
end
return iup.DEFAULT
end
end
-- Validate-on-leave: if a field defines validate(value) -> ok[, message], check it when
-- the control loses focus and warn at once. The Save action re-checks as a backstop. The
-- guard stops the warning's own focus change from re-entering the callback.
if field.validate then
local validating = false
control.killfocus_cb = function(ctrl)
if validating then return end
local ok, message = field.validate(controlValue(field, ctrl))
if not ok then
validating = true
fhMessageBox((field.label or field.key):gsub("%s*:%s*$", "") .. " - "
.. (message or "invalid value"), "MB_OK", "MB_ICONWARNING")
validating = false
end
end
end
sectionContent:append(iup.hbox({
label,
display or control,
alignment = "ACENTER",
}))
end
local sectionBox = iup.scrollbox({ sectionContent, expand = "YES" })
table.insert(allSections, sectionBox)
iup.Append(sectionZbox, sectionBox)
table.insert(sectionMenuItems, {
title = sectionTitleWithMnemonic(section.title),
action = function()
showSection(sectionBox)
return iup.DEFAULT
end,
})
end
-- Read every control's current (unsaved) value, coerced to its type.
-- (Assigns the forward-declared local so section-targeted buttons can
-- reference it.)
---@return table<string, table<string, any>>
collectValues = function()
local all = {}
for section, fields in pairs(controls) do
local values = {}
for key, info in pairs(fields) do
values[key] = controlValue(info.field, info.control)
end
all[section] = values
end
return all
end
-- Save all control values back to config.
local function saveAll()
for section, values in pairs(collectValues()) do
self:setValues(section, nil, values)
end
end
local function resetAllToDefaultsAndRefresh()
local response = iup.Alarm(
"Confirm Reset All",
"Are you sure you want to reset ALL settings in ALL sections to their defaults?",
"Yes",
"No"
)
if response == 1 then
self:resetToDefaults()
for _, sectionControls in pairs(controls) do
for _, info in pairs(sectionControls) do
applyValueToControl(info.field, info.control, info.field.default)
if info.onValueSet then
info.onValueSet()
end
end
end
end
return iup.DEFAULT
end
-- Reload saved values into the existing controls; used when the dialog
-- is reopened (it is built only once). Reading from saved config means
-- closing without Save discards unsaved edits.
local function reload()
for sectionTitle, sectionControls in pairs(controls) do
for key, info in pairs(sectionControls) do
applyValueToControl(info.field, info.control, savedValueFor(info.field, sectionTitle, key))
if info.onValueSet then
info.onValueSet()
end
end
end
if #allSections > 0 then
showSection(allSections[1])
end
end
-- Extra actions are either pinned to a section (extra.section -> a
-- button inside that tab, built above) or shown along the bottom row.
local buttonActions = {}
for _, extra in ipairs(extraActions or {}) do
if not extra.section then
buttonActions[#buttonActions + 1] = extra
end
end
-- Hoisted so the Save/Cancel/Esc closures below (built as part of menuBarData, before
-- makeDialog returns) can read the dialog's live size once it exists - they share this
-- upvalue, which is assigned further down.
local dialog
-- Persist the Options dialog's current size to the per-machine geometry store, so it's
-- restored next open. Skipped while maximized/minimized (rastersize is unreliable in
-- either state - the tracked-dialog close_cb applies the same guard). There is deliberately
-- no close_cb on this dialog (see the comment on makeDialog below), so the title-bar X
-- can't reach this - only Save, Cancel and Esc do.
local function saveOptionsSize()
if dialog and dialog.maximized ~= "YES" and dialog.minimized ~= "YES" then
setGeometryValue("Dialog_Options.rastersize", "text", dialog.rastersize)
end
end
---@type MenuBarData
local menuBarData = MenuBar.createMenuBar({
items = sectionMenuItems,
-- Key names sort into menu order (Save, Reset All, Cancel); the
-- "cancel" key also tells MenuBar not to add an automatic Exit.
fileMenu = {
aSave = {
title = "&Save",
action = function()
-- Per-field validation: a field may define validate(value) -> ok[, message].
-- Any failure blocks the Save and keeps the dialog open so the user can fix it.
local problems = {}
for _, sectionControls in pairs(controls) do
for key, info in pairs(sectionControls) do
if info.field.validate then
local ok, message = info.field.validate(controlValue(info.field, info.control))
if not ok then
local labelText = (info.field.label or key):gsub("%s*:%s*$", "")
problems[#problems + 1] = labelText .. " - " .. (message or "invalid value")
end
end
end
end
if #problems > 0 then
fhMessageBox("Please fix the following before saving:\n\n- "
.. table.concat(problems, "\n- "), "MB_OK", "MB_ICONWARNING")
return iup.DEFAULT
end
saveAll()
saveOptionsSize()
return iup.CLOSE
end,
},
bResetAll = {
title = "&Reset All",
action = function()
return resetAllToDefaultsAndRefresh()
end,
},
cancel = {
title = "&Cancel",
action = function()
saveOptionsSize()
return iup.CLOSE
end,
},
},
helpMenu = {
help = {
title = "&Help",
action = function()
help:show(help_topic)
return iup.DEFAULT
end,
},
},
})
-- Optional action buttons (e.g. Preview) working on the live values,
-- shown on a shared bottom row beneath every tab.
local dialogContent = { mainContent }
if #buttonActions > 0 then
local buttons = { iup.fill({}) }
for _, extra in ipairs(buttonActions) do
buttons[#buttons + 1] = makeButton({
title = extra.title,
tip = extra.tip,
callback = function()
extra.action(collectValues(), dialogApi)
end,
})
end
dialogContent[#dialogContent + 1] = iup.hbox(buttons)
end
dialogContent.margin = "10x10"
dialog = makeDialog(
iup.vbox(dialogContent),
-- Deliberately NO close_cb: for a dialog shown with popup, the
-- title-bar X must take IUP's default close action. Calling
-- hide() in a close_cb hung the teardown, and returning
-- iup.CLOSE from one exits an EXTRA message loop on top of the
-- close itself, ending the plugin's main loop too. Buttons and
-- menu items are different: there, returning iup.CLOSE is the
-- only thing that ends the popup.
{
title = options.title or self.defaultConfig.title,
resize = "YES",
menubox = "YES",
menu = menuBarData.menuBar,
help_topic = help_topic,
-- Esc mirrors Cancel: close and discard unsaved edits. Returning iup.CLOSE from this key
-- handler is safe (it acts like the Cancel menu item, not a close_cb - see the note above).
-- No Enter default: Save is Alt+S / the File menu, and Enter belongs to the focused field.
on_escape = function()
saveOptionsSize()
return iup.CLOSE
end,
}
)
self._optionsReload = reload
self._optionsDialog = dialog
-- makeDialog has already normalised this dialog independently (its own button/text normalisers,
-- not the global DoNormalize that used to re-size every other open dialog). Field labels keep the
-- Options-local labelNorm, fired here.
labelNorm.normalize = "HORIZONTAL"
if #allSections > 0 then
showSection(allSections[1])
end
-- First-build sizing: cap the initial size at the screen (minus the taskbar/title-bar
-- allowance) and restore any saved size. No natural-size floor here, unlike
-- showTrackedDialog's computePlacement: on a small screen the natural size can exceed the
-- cap, and a user's deliberately-reduced height must survive across sessions - each
-- section already scrolls (iup.scrollbox, above), so a capped/reduced size never clips a
-- field, it just hides it behind a scrollbar. capSize's 300x200 sanity floor guards against
-- a corrupt or zeroed saved value producing an unusable dialog.
iup.Refresh(dialog)
local natW, natH = tostring(dialog.naturalsize or ""):match("^(%d+)x(%d+)$")
local savedRaster = getGeometryValue("Dialog_Options.rastersize", "text", "")
local savedW, savedH
if savedRaster ~= "" then
local w, h = savedRaster:match("^(%d+)x(%d+)$")
savedW, savedH = tonumber(w), tonumber(h)
end
local scrW, scrH = tostring(iup.GetGlobal("SCREENSIZE") or ""):match("^(%d+)x(%d+)$")
local size = capSize(
{ w = savedW, h = savedH },
{ w = natW and tonumber(natW) or nil, h = natH and tonumber(natH) or nil },
{ w = scrW and tonumber(scrW) or nil, h = scrH and tonumber(scrH) or nil }
)
if size.w and size.h then
dialog.rastersize = size.w .. "x" .. size.h
end
-- Deterministic parent: makeDialog otherwise picks a parent from whatever control has
-- keyboard focus at build time (Dialog.lua's getParentDialogInfo) - built once, here, on
-- first open, that can be an unrelated control rather than the plugin's main window, so
-- CENTERPARENT centres on the wrong thing. Override it with the first dialog
-- showTrackedDialog tracked, if there is one. (This binding's iuplua has no
-- iup.SetAttributeHandle function - IupSetAttributeHandle is reached via plain attribute
-- assignment instead, the same idiom Dialog.lua uses for DEFAULTENTER/DEFAULTESC.)
if self._trackedMainDialog then
dialog.PARENTDIALOG = self._trackedMainDialog
end
-- Never destroyed (FH frees plugin dialogs at script end); reused on
-- the next open via the fast path at the top of this function. Positioning is explicit
-- (popupPlaced, defined above) rather than plain CENTERPARENT - see its comment.
popupPlaced(dialog)
end
function Config:resetToDefaults(section)
if section then
local sectionConfig = nil
for _, sec in ipairs(self.defaultConfig.sections or {}) do
if sec.title == section then
sectionConfig = sec
break
end
end
if sectionConfig then
local values = {}
for _, field in ipairs(sectionConfig.fields) do
values[field.key] = field.default
end
self:setValues(section, nil, values)
end
else
for _, sec in ipairs(self.defaultConfig.sections or {}) do
local values = {}
for _, field in ipairs(sec.fields) do
values[field.key] = field.default
end
self:setValues(sec.title, nil, values)
end
end
end
_G.Config = M
end
end)()
--<<FS_SPLICE_END>>
--------------------------------------------------------------
--EMBEDDED PURE LAYERS, ADAPTER AND SAMPLE DATA
--------------------------------------------------------------
-- The blocks below are generated from the files in src/ and fixtures/ by
-- build/assemble.lua. Do not edit them here; edit the source files and
-- re-run the assembler. Each module is wrapped so its `return M` becomes a
-- plugin global, and each module resolves its dependencies via
-- `_G.<Global> or require(...)`, which works both embedded and standalone.
--<<FS_SPLICE src/fs_options.lua FsOptions>>
FsOptions = (function()
--[[
@Title: fs_options
@Author: Helen Wright
@Description:
Layer 5 (pure): option defaults and merging, colour-palette resolution,
shape/colour encoding assignment, and the pure colours-to-palette
derivation used by the "Match System Theme" preset.
No FH API, no IUP, no file I/O. Runs identically under standalone
Lua 5.3 and inside the FH7 plugin host.
]]
local M = {}
--------------------------------------------------------------
-- THE MODEL SCHEMA (FsModel)
--------------------------------------------------------------
-- Produced by the FH adapter inside Family Historian, or loaded from fixture
-- files in tests. Plain data; no behaviour.
---@class FsModel One focal individual's chart.
---@field focal FsPerson
---@field parentSets FsParentSet[] Adapter-ordered (birth-type first).
---@field spouseUnits FsSpouseUnit[] Ordered by marriage/partnership date then as recorded.
---@class FsPerson
---@field id integer Raw FH record id; link target is "ind"..id..".html".
---@field name string Raw UTF-8; the renderer XML-escapes.
---@field lifeDates string LifeDates2 output; "-" when no dates exist.
---@field sex FsSexCode
---@field linkable boolean False for the focal person and anyone outside the selection.
---@field private? boolean Set by the adapter when omit-Private is on and the person carries the 'Private' flag; applyPrivacy removes them.
---@field basicOnly? boolean Set by the adapter when the person carries the basic-details flag; the renderer shows name + relationship only (no dates).
---@class FsParentSet
---@field pedi string PEDI value; "" means birth.
---@field father FsPerson|nil Omitted (nil) if unrecorded.
---@field mother FsPerson|nil Omitted (nil) if unrecorded.
---@field siblings FsSibling[] That family's own children in FH recorded order; the focal person is excluded.
---@field focalIndex? integer Displayed siblings before the focal person's recorded place (own sets only).
---@field otherFamily? boolean True for a parent's other family (half-siblings live here, shown with both their parents).
---@field sharedParentId? integer The parent the other family is reached through.
---@field sharedParentName? string Display name of that parent (for the column caption).
---@field sharedParentPrivate? boolean True when the shared parent carries 'Private'; applyPrivacy drops the whole set (its caption would name them).
---@class FsSibling : FsPerson
---@field kind "full"|"half"|"step"
---@class FsSpouseUnit
---@field spouse FsPerson|nil Omitted (nil) if the partner is unrecorded.
---@field children FsPerson[] Birth order.
--------------------------------------------------------------
-- TYPES
--------------------------------------------------------------
---@alias FsSexCode "M"|"F"|"U"
---@alias FsRole "focal"|"parent"|"sibling"|"spouse"|"child"
---@alias FsShape "rect"|"rounded"|"ellipse"|"octagon"
---@alias FsEncoding "theme"|"sex"|"relationship"
---@alias FsLayoutChoice "compact"|"wide"
---@alias FsPresetKey "classic"|"heritage"|"slate"|"highcontrast"|"system"|"custom"
---@alias FsParentFamilies "all"|"birth"|"first"
---@alias FsSizeMode "scale"|"fit"|"exact"
---@class FsRenderOptions Renderer-facing options (a subset of the plugin's persisted configuration).
---@field preset FsPresetKey Colour scheme preset; "custom" uses customPalette.
---@field colourBy FsEncoding What node colour encodes: the base theme, sex, or relationship category.
---@field shapeBy FsEncoding What node shape encodes: uniform ("theme"), sex, or relationship category.
---@field baseShape FsShape Node shape used when shapeBy is "theme" (uniform).
---@field nodeSize number Minimum node width in user units.
---@field fontSize number Label font size in user units.
---@field imageScale number Finished-image scale percentage (25-400); 100 renders at natural size.
---@field fontFamily string CSS font-family for chart text (themed to the target website, not the generating machine).
---@field pagePrefix string Filename prefix for individual website pages; clickable links target "<pagePrefix>"..id..".html".
---@field layout FsLayoutChoice "compact" (wrapped rows, narrow) or "wide" (one row per generation band).
---@field parentFamilies FsParentFamilies Which parent families are charted: every family, birth families only, or only the first found.
---@field links boolean Render clickable links on linkable nodes.
---@field lifeDates boolean Append "(YYYY-YYYY)" life dates to node labels.
---@field embedded boolean True when rendering for inline embedding (no XML declaration); false for a standalone file.
---@field fitWidth boolean Legacy "fit to container width" toggle; superseded by sizeMode, still read for back-compat.
---@field sizeMode FsSizeMode How the SVG width/height are set: "scale" (natural x imageScale), "fit" (width="100%", embedded only), or "exact" (exactWidth px, proportional height).
---@field exactWidth number Width in px when sizeMode is "exact"; the height follows the chart's aspect ratio.
---@field systemColours? FsSystemColours Raw system colours for the "system" preset; ignored otherwise.
---@field customPalette? table Partial FsPalette for the "custom" preset; missing values degrade to Classic.
---@class FsSystemColours Raw Windows colours as "R G B" strings (all optional).
---@field window? string Window background.
---@field windowText? string Window text.
---@field hilight? string Selection highlight.
---@field hotTracking? string Hyperlink / hot-tracking colour.
---@class FsPalette A fully resolved colour palette. All values are "#rrggbb".
---@field background string Chart background.
---@field text string Node label text.
---@field labelText string Group caption and sibling-kind tag text.
---@field stroke string Node outline (box border) colour.
---@field line? string Connector line colour; falls back to stroke when absent.
---@field link string Link text colour (linkable node labels).
---@field focalFill string Focal person's node fill.
---@field focalStroke string Focal person's node outline (emphasised).
---@field defaultFill string Fill when colour encodes nothing ("theme").
---@field sex table<FsSexCode, string> Fills when colour encodes sex.
---@field relationship table<FsRole, string> Fills when colour encodes relationship category.
--------------------------------------------------------------
-- COLOUR ARITHMETIC
--------------------------------------------------------------
---Parse a colour given as "#rrggbb" or "R G B" decimal components.
---@param value string
---@return integer r, integer g, integer b
local function parseColour(value)
local r, g, b = value:match("^#(%x%x)(%x%x)(%x%x)$")
if r then
return tonumber(r, 16), tonumber(g, 16), tonumber(b, 16)
end
r, g, b = value:match("^(%d+)%s+(%d+)%s+(%d+)$")
if r then
return tonumber(r), tonumber(g), tonumber(b)
end
error("Unrecognised colour value: " .. tostring(value))
end
---Format RGB components as "#rrggbb", clamping to 0-255.
---@param r number
---@param g number
---@param b number
---@return string
local function toHex(r, g, b)
local function clamp(c)
return math.max(0, math.min(255, math.floor(c + 0.5)))
end
return string.format("#%02x%02x%02x", clamp(r), clamp(g), clamp(b))
end
---Linearly mix two colours: t = 0 gives a, t = 1 gives b.
---@param a string Colour ("#rrggbb" or "R G B").
---@param b string Colour ("#rrggbb" or "R G B").
---@param t number Mix fraction in [0, 1].
---@return string mixed "#rrggbb"
function M.mix(a, b, t)
local ar, ag, ab = parseColour(a)
local br, bg, bb = parseColour(b)
return toHex(ar + (br - ar) * t, ag + (bg - ag) * t, ab + (bb - ab) * t)
end
---Normalise any accepted colour notation to "#rrggbb".
---@param value string
---@return string
function M.normaliseColour(value)
return toHex(parseColour(value))
end
--------------------------------------------------------------
-- OPTION DEFAULTS AND MERGING
--------------------------------------------------------------
---@type FsRenderOptions
local DEFAULTS = {
preset = "classic",
colourBy = "theme",
shapeBy = "theme",
baseShape = "rounded",
nodeSize = 140,
fontSize = 12,
imageScale = 100,
fontFamily = "Verdana, Arial, Helvetica, sans-serif",
pagePrefix = "ind",
layout = "wide",
parentFamilies = "all",
links = true,
lifeDates = true,
embedded = false,
fitWidth = false,
sizeMode = "scale",
exactWidth = 800,
}
---@type table<string, boolean>
local VALID_PRESET = { classic = true, heritage = true, slate = true, highcontrast = true, system = true, custom = true }
---@type table<string, boolean>
local VALID_ENCODING = { theme = true, sex = true, relationship = true }
---@type table<string, boolean>
local VALID_LAYOUT = { compact = true, wide = true }
---@type table<string, boolean>
local VALID_SHAPE = { rect = true, rounded = true, ellipse = true, octagon = true }
---@type table<string, boolean>
local VALID_PARENT_FAMILIES = { all = true, birth = true, first = true }
---@type table<string, boolean>
local VALID_SIZE_MODE = { scale = true, fit = true, exact = true }
---A fresh copy of the default renderer options.
---@return FsRenderOptions
function M.defaults()
local copy = {}
for k, v in pairs(DEFAULTS) do
copy[k] = v
end
return copy
end
---Merge user-supplied options over the defaults, validating each value.
---Options arrive from the configuration UI (a real boundary), so invalid
---values fall back to the default rather than erroring.
---@param options? table
---@return FsRenderOptions
function M.merge(options)
options = options or {}
local merged = M.defaults()
if VALID_PRESET[options.preset] then
merged.preset = options.preset
end
if VALID_ENCODING[options.colourBy] then
merged.colourBy = options.colourBy
end
if VALID_ENCODING[options.shapeBy] then
merged.shapeBy = options.shapeBy
end
if VALID_SHAPE[options.baseShape] then
merged.baseShape = options.baseShape
end
if type(options.nodeSize) == "number" and options.nodeSize >= 40 and options.nodeSize <= 600 then
merged.nodeSize = options.nodeSize
end
if type(options.fontSize) == "number" and options.fontSize >= 6 and options.fontSize <= 48 then
merged.fontSize = options.fontSize
end
if type(options.imageScale) == "number" and options.imageScale >= 25 and options.imageScale <= 400 then
merged.imageScale = options.imageScale
end
if type(options.fontFamily) == "string" then
-- CSS-sanitise: a font-family list needs only letters, digits, spaces,
-- commas, hyphens and quotes. Strip anything else so a stray or tampered
-- value cannot inject into the chart's scoped inline <style>.
local cleaned = options.fontFamily:gsub("[^A-Za-z0-9%s,'\"%-]", "")
if cleaned:match("%S") then
merged.fontFamily = cleaned
end
end
if type(options.pagePrefix) == "string" then
-- Goes into filenames and link hrefs, so keep it to safe characters.
local cleaned = (options.pagePrefix:gsub("[^A-Za-z0-9_%-]", ""))
if cleaned ~= "" then
merged.pagePrefix = cleaned
end
end
if VALID_LAYOUT[options.layout] then
merged.layout = options.layout
end
if VALID_PARENT_FAMILIES[options.parentFamilies] then
merged.parentFamilies = options.parentFamilies
end
if type(options.customPalette) == "table" then
merged.customPalette = options.customPalette
end
if type(options.links) == "boolean" then
merged.links = options.links
end
if type(options.lifeDates) == "boolean" then
merged.lifeDates = options.lifeDates
end
if type(options.embedded) == "boolean" then
merged.embedded = options.embedded
end
if type(options.fitWidth) == "boolean" then
merged.fitWidth = options.fitWidth
end
if VALID_SIZE_MODE[options.sizeMode] then
merged.sizeMode = options.sizeMode
elseif options.sizeMode == nil and options.fitWidth == true then
-- Back-compat: older configs carried only the "fit to width" toggle.
merged.sizeMode = "fit"
end
if type(options.exactWidth) == "number" and options.exactWidth >= 100 and options.exactWidth <= 4000 then
merged.exactWidth = options.exactWidth
end
if type(options.systemColours) == "table" then
merged.systemColours = options.systemColours
end
return merged
end
--------------------------------------------------------------
-- PRESET PALETTES
--------------------------------------------------------------
---@type table<string, FsPalette>
local PRESETS = {
-- Soft blues and greys on white.
classic = {
background = "#ffffff",
text = "#1a1a1a",
labelText = "#5a5a5a",
stroke = "#4a6785",
link = "#1d4ed8",
focalFill = "#ffe9a8",
focalStroke = "#8a6d1f",
defaultFill = "#e7eef5",
sex = { M = "#cfe3f5", F = "#fbe4ec", U = "#e8e8e8" },
relationship = { parent = "#d9e7f5", sibling = "#e4f0e2", spouse = "#f5ecd9", child = "#ece2f5" },
},
-- Warm sepia tones for heritage-styled sites.
heritage = {
background = "#f7f1e3",
text = "#3b2f2f",
labelText = "#6e5c44",
stroke = "#8b6f47",
link = "#7c4a03",
focalFill = "#d9b86a",
focalStroke = "#7a5c1e",
defaultFill = "#efe5cf",
sex = { M = "#dfd0b8", F = "#f2e3cf", U = "#e9e0d0" },
relationship = { parent = "#e3d3b8", sibling = "#eadfc8", spouse = "#dfc9a8", child = "#f0e6d2" },
},
-- Cool dark scheme with light text.
slate = {
background = "#2b3440",
text = "#e8edf2",
labelText = "#b8c4d0",
stroke = "#94a3b8",
link = "#93c5fd",
focalFill = "#b08a2e",
focalStroke = "#e3c264",
defaultFill = "#46505a",
sex = { M = "#34506b", F = "#63465e", U = "#46505a" },
relationship = { parent = "#34506b", sibling = "#3c6155", spouse = "#6b5634", child = "#59466b" },
},
-- Okabe-Ito colour-blind-safe palette; pairs well with shape-by-sex.
highcontrast = {
background = "#ffffff",
text = "#000000",
labelText = "#000000",
stroke = "#000000",
link = "#000000",
focalFill = "#ffffff",
focalStroke = "#000000",
defaultFill = "#ffffff",
sex = { M = "#56b4e9", F = "#e69f00", U = "#999999" },
-- Siblings: #00aa7b, a lighter tint of the same bluish-green hue as the original #009e73 (both
-- fully saturated, hue ~164°). #009e73 only reached 6.14:1 contrast with black text (WCAG AA);
-- #00aa7b is the darkest tint on that hue/saturation ray that reaches >= 7.0:1 (WCAG AAA) - see
-- highContrastSiblingsColourMigrated below for the one-off migration of any saved scheme.
relationship = { parent = "#56b4e9", sibling = "#00aa7b", spouse = "#e69f00", child = "#f0e442" },
},
}
---Derive a complete palette from raw Windows theme colours. Pure: the caller
---(the plugin's theme helper) supplies the colours; missing values degrade to
---the Classic preset's equivalents so the result is always complete.
---@param sys? FsSystemColours
---@return FsPalette
function M.deriveSystemPalette(sys)
sys = sys or {}
local classic = PRESETS.classic
local ok, palette = pcall(function()
local bg = sys.window and M.normaliseColour(sys.window) or classic.background
local text = sys.windowText and M.normaliseColour(sys.windowText) or classic.text
local accent = sys.hilight and M.normaliseColour(sys.hilight) or classic.stroke
local link = sys.hotTracking and M.normaliseColour(sys.hotTracking) or classic.link
-- Tint the accent towards the background for fills, keeping label text legible.
local function tint(t)
return M.mix(accent, bg, t)
end
return {
background = bg,
text = text,
labelText = M.mix(text, bg, 0.3),
stroke = M.mix(accent, text, 0.3),
link = link,
focalFill = tint(0.45),
focalStroke = M.mix(accent, text, 0.5),
defaultFill = tint(0.82),
sex = { M = tint(0.72), F = tint(0.86), U = M.mix(text, bg, 0.88) },
relationship = {
parent = tint(0.7),
sibling = tint(0.8),
spouse = tint(0.88),
child = tint(0.94),
},
}
end)
if ok then
return palette
end
return PRESETS.classic
end
---Complete a partial palette: every missing value (including missing sex or
---relationship entries) degrades to the Classic preset's equivalent, and
---colour values are normalised to "#rrggbb". Invalid colours fall back too.
---@param partial table
---@return FsPalette
function M.completePalette(partial)
partial = partial or {}
local classic = PRESETS.classic
local function colour(value, fallback)
if type(value) == "string" then
local ok, normalised = pcall(M.normaliseColour, value)
if ok then
return normalised
end
end
return fallback
end
local palette = {
background = colour(partial.background, classic.background),
text = colour(partial.text, classic.text),
labelText = colour(partial.labelText, classic.labelText),
stroke = colour(partial.stroke, classic.stroke),
link = colour(partial.link, classic.link),
focalFill = colour(partial.focalFill, classic.focalFill),
focalStroke = colour(partial.focalStroke, classic.focalStroke),
defaultFill = colour(partial.defaultFill, classic.defaultFill),
sex = {},
relationship = {},
}
-- The connector-line colour is optional; absent means "same as stroke".
if partial.line ~= nil then
palette.line = colour(partial.line, palette.stroke)
end
local partialSex = type(partial.sex) == "table" and partial.sex or {}
for code, fallback in pairs(classic.sex) do
palette.sex[code] = colour(partialSex[code], fallback)
end
local partialRel = type(partial.relationship) == "table" and partial.relationship or {}
for role, fallback in pairs(classic.relationship) do
palette.relationship[role] = colour(partialRel[role], fallback)
end
return palette
end
---Resolve the palette for the given merged options.
---@param options FsRenderOptions
---@return FsPalette
function M.palette(options)
if options.preset == "system" then
return M.deriveSystemPalette(options.systemColours)
end
if options.preset == "custom" then
return M.completePalette(options.customPalette)
end
return PRESETS[options.preset] or PRESETS.classic
end
---The preset keys in display order, for UI option lists and tests.
---@return FsPresetKey[]
function M.presetKeys()
return { "classic", "heritage", "slate", "highcontrast", "system" }
end
--------------------------------------------------------------
-- NODE STYLE RESOLUTION (colour and shape encoding)
--------------------------------------------------------------
---@type table<FsSexCode, FsShape>
local SHAPE_BY_SEX = { M = "rect", F = "ellipse", U = "rounded" }
---@type table<FsRole, FsShape>
local SHAPE_BY_RELATIONSHIP = { focal = "rounded", parent = "rect", sibling = "rounded", spouse = "ellipse", child = "octagon" }
---@class FsNodeStyle
---@field fill string Node fill colour ("#rrggbb").
---@field stroke string Node outline colour.
---@field shape FsShape Node outline shape.
---@field emphasis boolean True for the focal person (thicker outline).
---Resolve fill, stroke and shape for one node from the encoding options.
---The focal person always keeps an emphasised outline; when colour encodes
---sex the focal node is coloured by sex like everyone else, otherwise it
---takes the dedicated focal fill so it stands out.
---@param role FsRole
---@param sex FsSexCode
---@param options FsRenderOptions
---@param palette FsPalette
---@return FsNodeStyle
function M.nodeStyle(role, sex, options, palette)
local fill
if options.colourBy == "sex" then
fill = palette.sex[sex] or palette.sex.U
elseif options.colourBy == "relationship" then
if role == "focal" then
fill = palette.focalFill
else
fill = palette.relationship[role] or palette.defaultFill
end
else -- "theme"
fill = (role == "focal") and palette.focalFill or palette.defaultFill
end
local shape
if options.shapeBy == "sex" then
shape = SHAPE_BY_SEX[sex] or SHAPE_BY_SEX.U
elseif options.shapeBy == "relationship" then
shape = SHAPE_BY_RELATIONSHIP[role]
else -- "theme": uniform, in the user's chosen base shape
shape = options.baseShape or "rounded"
end
return {
fill = fill,
stroke = (role == "focal") and palette.focalStroke or palette.stroke,
shape = shape,
emphasis = (role == "focal"),
}
end
--------------------------------------------------------------
-- GROUP CAPTIONS
--------------------------------------------------------------
---@type table<string, string>
local PEDI_CAPTIONS = {
-- Birth parents are the reader's default assumption, so they carry no
-- caption; only other parent types are labelled.
[""] = "",
birth = "",
adopted = "Adoptive parents",
foster = "Foster parents",
step = "Step-parents",
}
---Caption for a parent-set from its PEDI value. An empty PEDI means birth;
---birth sets are unlabelled (empty caption); unrecognised values are shown
---capitalised so nothing is silently dropped.
---@param pedi string
---@return string
function M.pediCaption(pedi)
pedi = (pedi or ""):lower()
local caption = PEDI_CAPTIONS[pedi]
if caption then
return caption
end
return pedi:sub(1, 1):upper() .. pedi:sub(2) .. " parents"
end
---Stroke dash pattern for a parent-set's connectors, from its PEDI value.
---Birth families use solid lines (nil); each other type gets a distinct
---pattern so tangled charts stay readable.
---@param pedi string
---@return string|nil dasharray SVG stroke-dasharray value, or nil for solid.
function M.pediDash(pedi)
pedi = (pedi or ""):lower()
if pedi == "" or pedi == "birth" then
return nil
elseif pedi == "adopted" then
return "7 4"
elseif pedi == "foster" then
return "2 3"
elseif pedi == "step" then
return "10 4 2 4"
end
return "4 4" -- any unrecognised non-birth type: generic dashes
end
---Filter a model's parent-sets per the parentFamilies option: "all" keeps
---everything, "birth" keeps only birth-PEDI sets, "first" keeps only the
---first set (the adapter orders birth-type sets first). The filter applies
---to the focal person's own sets; a parent's other families are kept only
---when the parent they are reached through survives the filter.
---@param parentSets FsParentSet[]
---@param choice FsParentFamilies
---@return FsParentSet[]
function M.filterParentSets(parentSets, choice)
parentSets = parentSets or {}
if choice ~= "birth" and choice ~= "first" then
return parentSets
end
local kept, keptParentIds = {}, {}
for _, set in ipairs(parentSets) do
if not set.otherFamily then
local pedi = (set.pedi or ""):lower()
local keep
if choice == "birth" then
keep = (pedi == "" or pedi == "birth")
else -- "first"
keep = (#kept == 0)
end
if keep then
kept[#kept + 1] = set
if set.father then
keptParentIds[set.father.id] = true
end
if set.mother then
keptParentIds[set.mother.id] = true
end
end
end
end
for _, set in ipairs(parentSets) do
if set.otherFamily and set.sharedParentId and keptParentIds[set.sharedParentId] then
kept[#kept + 1] = set
end
end
return kept
end
---Apply record-flag privacy to a model: remove everyone the adapter marked
---`private` (omit-Private), in full. Their nodes vanish; a parent slot they
---filled becomes empty; a parent-set or spouse-unit left with nobody visible
---is dropped; and an "other family" column reached through a Private parent is
---dropped whole (its caption would otherwise name that person). The focal
---person is left untouched here - the plugin decides whether to chart them.
---`basicOnly` people are kept; the renderer shows them as name only. A no-op
---when nothing is marked private.
---@param model FsModel
---@return FsModel
function M.applyPrivacy(model)
if not model then
return model
end
local function visible(person)
return person ~= nil and not person.private
end
local parentSets = {}
for _, set in ipairs(model.parentSets or {}) do
if not (set.otherFamily and set.sharedParentPrivate) then
local father = visible(set.father) and set.father or nil
local mother = visible(set.mother) and set.mother or nil
-- Keep sibling order; re-count the focal's place among the
-- survivors so its age-order slot stays correct.
local siblings = {}
local focalIndex = 0
for i, sib in ipairs(set.siblings or {}) do
if visible(sib) then
siblings[#siblings + 1] = sib
if i <= (set.focalIndex or 0) then
focalIndex = focalIndex + 1
end
end
end
-- An other-family column is worth showing for the focal's
-- half-siblings OR the parent's other partner (a navigable
-- relationship). Drop it once privacy has removed both - its lone
-- shared parent is already in the home set. (`father and mother`
-- means the other partner survives: the shared parent is always
-- present here, the partner fills the second slot.) The layout omits
-- the parent drop when nothing hangs below, so a surviving childless
-- couple shows as just the couple. A home set is kept whenever any
-- parent or sibling survives.
local keep
if set.otherFamily then
keep = (father and mother) or #siblings > 0
else
keep = father or mother or #siblings > 0
end
if keep then
local newSet = {}
for k, v in pairs(set) do
newSet[k] = v
end
newSet.father = father
newSet.mother = mother
newSet.siblings = siblings
newSet.focalIndex = focalIndex
parentSets[#parentSets + 1] = newSet
end
end
end
local spouseUnits = {}
for _, unit in ipairs(model.spouseUnits or {}) do
local spouse = visible(unit.spouse) and unit.spouse or nil
local children = {}
for _, child in ipairs(unit.children or {}) do
if visible(child) then
children[#children + 1] = child
end
end
if spouse or #children > 0 then
spouseUnits[#spouseUnits + 1] = { spouse = spouse, children = children }
end
end
return {
focal = model.focal,
parentSets = parentSets,
spouseUnits = spouseUnits,
}
end
---Visible tag for a sibling's kind. Full siblings are the unmarked default;
---half- and step-siblings carry a short tag.
---@param kind "full"|"half"|"step"
---@return string tag Empty string for full siblings.
function M.siblingTag(kind)
if kind == "half" then
return "half"
elseif kind == "step" then
return "step"
end
return ""
end
return M
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE src/fs_layout.lua FsLayout>>
FsLayout = (function()
--[[
@Title: fs_layout
@Author: Helen Wright
@Description:
Layer 5 (pure): bounds and coordinate maths. Turns an FsModel plus merged
render options into a flat list of positioned nodes, group captions and
connector polylines, with overall content bounds for the SVG viewBox.
Both layouts share four generation bands: parent-sets; siblings (one
column per parent-set); the focal person (far left, own row) with spouse
units beside them; and children (one column per spouse unit). The focal
person's column doubles as a clear channel for the parent-bus drop, so
connectors never cross sibling boxes. When the focal person has more than
one own parent-set, only the first ("home") set links back to the real
focal box; each further own set shows the focal person as a faded proxy at
their birth-order slot among that family's children, so no connector has to
span the chart.
Two layouts:
* "wide" - every sibling/child group is a single row. Horizontal
scrolling is expected when embedded.
* "compact" - sibling and child groups wrap into a roughly square grid
of rows, making the chart much narrower.
Connectors carry the parent-set's PEDI dash pattern (solid for birth) so
overlapping sets stay distinguishable.
No FH API, no IUP, no file I/O. Text width is estimated from codepoint
counts (wide CJK codepoints count extra); the renderer has no access to
real font metrics and the charts are navigation aids, so an estimate with
generous padding is the right trade-off.
]]
local fs_options = _G.FsOptions or require("fs_options")
local M = {}
--------------------------------------------------------------
-- TYPES
--------------------------------------------------------------
---@class FsLayoutNode One positioned node instance (a person may appear in more than one group).
---@field id string Instance id, unique within the chart (e.g. "n7").
---@field person FsPerson The underlying person.
---@field role FsRole Relationship category for encoding.
---@field kind? "full"|"half"|"step" Sibling kind (siblings only).
---@field proxy? boolean A faded repeat of a person shown in full elsewhere (additional parent sets).
---@field label string Display text: name plus "(dates)" when shown.
---@field tag string Visible sibling tag ("half"/"step") or "".
---@field x number Left edge.
---@field y number Top edge.
---@field w number Width.
---@field h number Height.
---@class FsCaption A group caption (e.g. "Adoptive parents").
---@field text string
---@field x number Anchor x (text is start-anchored).
---@field y number Baseline y.
---@class FsEdge A connector polyline.
---@field points {x: number, y: number}[]
---@field dash? string SVG stroke-dasharray pattern; nil for solid.
---@field double? boolean Draw as a double (parallel) line - the marriage convention.
---@class FsLayout
---@field width number Content width including margins.
---@field height number Content height including margins.
---@field nodes FsLayoutNode[]
---@field captions FsCaption[]
---@field edges FsEdge[]
--------------------------------------------------------------
-- TEXT MEASUREMENT (estimate)
--------------------------------------------------------------
---Estimate the rendered width of a UTF-8 string in user units.
---Counts codepoints from the byte stream; CJK and other wide codepoints
---count as 1.7 units, the rest as 1. One unit is ~0.62 of the font size.
---@param text string Raw UTF-8.
---@param fontSize number
---@return number width
function M.estimateTextWidth(text, fontSize)
local units = 0.0
local i = 1
local len = #text
while i <= len do
local b = text:byte(i)
local cp, size
if b < 0x80 then
cp, size = b, 1
elseif b < 0xE0 then
cp = (b - 0xC0) * 0x40 + (text:byte(i + 1) or 0) % 0x40
size = 2
elseif b < 0xF0 then
cp = (b - 0xE0) * 0x1000
+ ((text:byte(i + 1) or 0) % 0x40) * 0x40
+ (text:byte(i + 2) or 0) % 0x40
size = 3
else
cp, size = 0x20000, 4 -- astral plane: treat as wide
end
-- CJK unified, compatibility, Hangul, kana and full-width ranges.
local wide = (cp >= 0x1100 and cp <= 0x115F)
or (cp >= 0x2E80 and cp <= 0xA4CF)
or (cp >= 0xAC00 and cp <= 0xD7A3)
or (cp >= 0xF900 and cp <= 0xFAFF)
or (cp >= 0xFF00 and cp <= 0xFF60)
or cp >= 0x20000
units = units + (wide and 1.7 or 1.0)
i = i + size
end
return units * fontSize * 0.62
end
--------------------------------------------------------------
-- LABELS AND NODE SIZING
--------------------------------------------------------------
---Compose the visible label for a person: name, plus "(dates)" when the
---life-dates option is on and the LifeDates2 value is not the bare hyphen.
---LifeDates2 pads living people's dates with a trailing space ("1938- "),
---so the value is trimmed before use.
---@param person FsPerson
---@param options FsRenderOptions
---@return string
function M.nodeLabel(person, options)
local dates = (person.lifeDates or ""):gsub("^%s+", ""):gsub("%s+$", "")
if options.lifeDates and not person.basicOnly and dates ~= "-" and dates ~= "" then
return person.name .. " (" .. dates .. ")"
end
return person.name
end
---Build an unpositioned node for a person; x/y are assigned during placement.
---@param person FsPerson
---@param role FsRole
---@param kind? "full"|"half"|"step"
---@param options FsRenderOptions
---@param state {nextId: integer}
---@return FsLayoutNode
local function makeNode(person, role, kind, options, state)
local fontSize = options.fontSize
local label = M.nodeLabel(person, options)
local tag = kind and fs_options.siblingTag(kind) or ""
local padX = fontSize * 0.7
local textW = M.estimateTextWidth(label, fontSize)
if tag ~= "" then
-- The tag renders in a smaller font after the label.
textW = textW + M.estimateTextWidth(" — " .. tag, fontSize * 0.8)
end
local w = math.max(options.nodeSize, textW + 2 * padX)
-- Ellipses clip their corners, so give them extra room.
local style = fs_options.nodeStyle(role, person.sex, options, fs_options.palette(options))
if style.shape == "ellipse" then
w = w * 1.25
end
local h = fontSize + 2 * (fontSize * 0.55)
state.nextId = state.nextId + 1
return {
id = "n" .. state.nextId,
person = person,
role = role,
kind = kind,
label = label,
tag = tag,
x = 0,
y = 0,
w = w,
h = h,
}
end
---Horizontal centre of a node.
---@param node FsLayoutNode
---@return number
local function cx(node)
return node.x + node.w / 2
end
--------------------------------------------------------------
-- GENERATION-BAND LAYOUT (wide and compact)
--------------------------------------------------------------
---Total width of a row of nodes with the given gap, without placing them.
---@param nodes FsLayoutNode[]
---@param gap number
---@return number
local function rowWidth(nodes, gap)
local w = 0
for i, node in ipairs(nodes) do
w = w + node.w + (i > 1 and gap or 0)
end
return w
end
---Place a list of nodes in a horizontal row starting at (x, y); returns the
---total row width.
---@param nodes FsLayoutNode[]
---@param x number
---@param y number
---@param gap number
---@return number width
local function placeRow(nodes, x, y, gap)
local cursor = x
for i, node in ipairs(nodes) do
if i > 1 then
cursor = cursor + gap
end
node.x = cursor
node.y = y
cursor = cursor + node.w
end
return cursor - x
end
---The number of columns for a group: every node on one row in wide mode; a
---single indented column in compact mode (so each box can be linked on its
---left edge to a shared spine - the clearest "these are all siblings" form,
---and the narrowest).
---@param n integer Group size.
---@param wrap boolean Compact (wrapping) mode.
---@return integer
local function groupCols(n, wrap)
if not wrap then
return n
end
return 1
end
---Footprint of a group without placing it. Wrapped groups use a uniform
---cell width (the widest node) so grid columns align for the connectors.
---@param groupNodes FsLayoutNode[]
---@param gapX number
---@param gapY number
---@param wrap boolean
---@return {width: number, height: number}
local function groupSize(groupNodes, gapX, gapY, wrap)
local n = #groupNodes
if n == 0 then
return { width = 0, height = 0 }
end
local h = groupNodes[1].h
local cols = groupCols(n, wrap)
if cols >= n then
return { width = rowWidth(groupNodes, gapX), height = h }
end
local cellW = 0
for _, node in ipairs(groupNodes) do
cellW = math.max(cellW, node.w)
end
local rows = math.ceil(n / cols)
return {
width = cols * cellW + (cols - 1) * gapX,
height = rows * h + (rows - 1) * gapY,
}
end
---@class FsGroupPlacement
---@field rows FsLayoutNode[][] Nodes by grid row, left to right (one row unless wrapped).
---@field leftX number The group's left edge (for the connector spine).
---Place a group at (xLeft, yTop): one row (wide, or small groups) or a
---roughly square grid (compact). Returns the row structure for the connector.
---@param groupNodes FsLayoutNode[]
---@param xLeft number
---@param yTop number
---@param gapX number
---@param gapY number
---@param wrap boolean
---@return FsGroupPlacement
local function placeGroup(groupNodes, xLeft, yTop, gapX, gapY, wrap)
local n = #groupNodes
if n == 0 then
return { rows = {}, leftX = xLeft }
end
local cols = groupCols(n, wrap)
if cols >= n then
placeRow(groupNodes, xLeft, yTop, gapX)
local row = {}
for _, node in ipairs(groupNodes) do
row[#row + 1] = node
end
return { rows = { row }, leftX = xLeft }
end
local cellW = 0
for _, node in ipairs(groupNodes) do
cellW = math.max(cellW, node.w)
end
local h = groupNodes[1].h
local rows = {}
for i, node in ipairs(groupNodes) do
local r = math.floor((i - 1) / cols) + 1
local c = (i - 1) % cols
-- Left-aligned: every box's left edge lines up with the column (and
-- the connector spine), rather than centred in the cell.
node.x = xLeft + c * (cellW + gapX)
node.y = yTop + (r - 1) * (h + gapY)
rows[r] = rows[r] or {}
table.insert(rows[r], node)
end
return { rows = rows, leftX = xLeft }
end
---Connect a placed group to its parent bus so every member reads as one
---generation (all siblings, or all children of one couple):
--- * one row - a straight drop from the bus to each node;
--- * a grid - a left spine off the bus, a horizontal bus above each grid
--- row, and a short stub down to each node. No connector ever joins two
--- members directly, so a lower-row member is never mistaken for a child
--- of the one above it.
---Returns the x-range the parent bus must span to reach this group.
---@param edges FsEdge[]
---@param grid FsGroupPlacement
---@param busY number
---@param dash string|nil
---@param gapX number
---@param gapY number
---@return number|nil minX, number|nil maxX
local function connectGroup(edges, grid, busY, dash, gapX, gapY)
local rows = grid.rows
if #rows == 0 then
return nil, nil
end
if #rows == 1 then
local minX, maxX = math.huge, -math.huge
for _, node in ipairs(rows[1]) do
edges[#edges + 1] = {
points = { { x = cx(node), y = busY }, { x = cx(node), y = node.y } },
dash = dash,
}
minX = math.min(minX, cx(node))
maxX = math.max(maxX, cx(node))
end
return minX, maxX
end
-- Single indented column (compact): a left spine off the bus, with a
-- horizontal line into each box's LEFT edge at mid-height - the classic
-- indented family-tree look, unmistakably one generation of siblings.
local spineX = grid.leftX - gapX * 0.5
local lastNode = rows[#rows][1]
local lastMidY = lastNode.y + lastNode.h / 2
edges[#edges + 1] = {
points = { { x = spineX, y = busY }, { x = spineX, y = lastMidY } },
dash = dash,
}
for _, row in ipairs(rows) do
local node = row[1]
local midY = node.y + node.h / 2
edges[#edges + 1] = {
points = { { x = spineX, y = midY }, { x = node.x, y = midY } },
dash = dash,
}
end
return spineX, spineX
end
---Per-index bus stagger fraction, so neighbouring buses in the same band
---gap sit at different heights instead of overdrawing each other.
---@param index integer
---@return number
local function staggerFraction(index)
return 0.55 - 0.18 * ((index - 1) % 3)
end
---Lay the model out in four generation bands (see module description).
---@param model FsModel
---@param options FsRenderOptions
---@return FsLayout
local function layoutBands(model, options)
local wrap = options.layout == "compact"
local fontSize = options.fontSize
local gapX = fontSize * 1.2
local gapY = fontSize * 0.9
local colGap = fontSize * 3.0
local rowGapY = fontSize * 4.0
local captionH = fontSize * 1.4
local margin = fontSize * 1.5
local state = { nextId = 0 }
local nodes, captions, edges = {}, {}, {}
-- Build node objects per column first, place afterwards.
---@type {caption: string, dash: string|nil, otherFamily: boolean|nil, focalIndex: integer|nil, parents: FsLayoutNode[], siblings: FsLayoutNode[], sibSize: table, x?: number, w?: number}[]
local parentCols = {}
local homeAssigned = false
for _, set in ipairs(model.parentSets or {}) do
local ownSet = not set.otherFamily
local isHome = ownSet and not homeAssigned
homeAssigned = homeAssigned or isHome
local col = {
caption = set.otherFamily and ("Other family of " .. (set.sharedParentName or "?"))
or fs_options.pediCaption(set.pedi),
dash = fs_options.pediDash(set.pedi),
otherFamily = set.otherFamily,
homeSet = isHome,
focalIndex = ownSet and set.focalIndex or nil,
parents = {},
siblings = {},
}
if set.father then
col.parents[#col.parents + 1] = makeNode(set.father, "parent", nil, options, state)
end
if set.mother then
col.parents[#col.parents + 1] = makeNode(set.mother, "parent", nil, options, state)
end
for _, sib in ipairs(set.siblings or {}) do
col.siblings[#col.siblings + 1] = makeNode(sib, "sibling", sib.kind, options, state)
end
-- An own set other than the home set shows the focal person as a faded
-- proxy at their birth-order slot among that family's children, rather
-- than a connector run back to the single real focal box.
if ownSet and not isHome then
local proxy = makeNode(model.focal, "focal", nil, options, state)
proxy.proxy = true
table.insert(col.siblings, math.min(set.focalIndex or 0, #col.siblings) + 1, proxy)
end
parentCols[#parentCols + 1] = col
end
local focalNode = makeNode(model.focal, "focal", nil, options, state)
-- The focal person sits at the far left of their own row in BOTH layouts;
-- siblings pack tight (no reserved gap). Birth order is shown by the
-- connector, not the box position: each own set's focal connector drops
-- from the sibling bus at the focal person's birth-order gap (slotX) and
-- steps across to the box on the left, so the chart stays narrow.
-- focalIndex (siblings before the focal person, recorded order) locates
-- that gap.
for _, col in ipairs(parentCols) do
col.sibSize = groupSize(col.siblings, gapX, gapY, wrap)
end
---@type {spouse: FsLayoutNode|nil, children: FsLayoutNode[], childSize: table, x?: number, w?: number}[]
local spouseCols = {}
for _, unit in ipairs(model.spouseUnits or {}) do
local col = { spouse = nil, children = {} }
if unit.spouse then
col.spouse = makeNode(unit.spouse, "spouse", nil, options, state)
end
for _, child in ipairs(unit.children or {}) do
col.children[#col.children + 1] = makeNode(child, "child", nil, options, state)
end
col.childSize = groupSize(col.children, gapX, gapY, wrap)
spouseCols[#spouseCols + 1] = col
end
-- Band geometry. The caption band exists only when some set is labelled
-- (birth sets are unlabelled); the sibling band only when anyone is in it.
-- A couple is always shown side by side (parents, like spouses, are never
-- stacked), so the parent band is one box tall.
local nodeH = focalNode.h
local hasParents = #parentCols > 0
local anyCaption = false
local sibBandH = 0
local parentBandH = nodeH
for _, col in ipairs(parentCols) do
anyCaption = anyCaption or col.caption ~= ""
sibBandH = math.max(sibBandH, col.sibSize.height)
end
local hasSiblings = sibBandH > 0
local rowPy = margin + (hasParents and anyCaption and captionH or 0)
local rowSy = hasParents and (rowPy + parentBandH + rowGapY) or margin
local rowFy
if not hasParents then
rowFy = margin
elseif hasSiblings then
rowFy = rowSy + sibBandH + rowGapY
else
rowFy = rowPy + parentBandH + rowGapY
end
-- The focal person sits at the far left of their own row. In compact the
-- column above them is kept clear so the focal connector drops straight
-- up through it; in wide the columns start at the margin and the focal
-- connector steps across from the birth-order gap (see the connector
-- pass), which keeps the chart narrow.
local focalChannel = wrap
focalNode.x, focalNode.y = margin, rowFy
nodes[#nodes + 1] = focalNode
local focalCx = cx(focalNode)
-- An "other family" set branches off the preceding set's shared parent,
-- so it sits snug against it (a small gap) to make the link obvious;
-- independent sets get the full column gap.
local otherFamilyGap = colGap * 0.4
local cursorX = margin + ((hasParents and focalChannel) and (focalNode.w + colGap) or 0)
for colIndex, col in ipairs(parentCols) do
if colIndex > 1 then
cursorX = cursorX + (col.otherFamily and otherFamilyGap or colGap)
end
local parentsW = rowWidth(col.parents, gapX)
local colW = math.max(parentsW, col.sibSize.width, fontSize) -- never zero-width
placeRow(col.parents, cursorX + (colW - parentsW) / 2, rowPy, gapX)
col.sibGrid = placeGroup(col.siblings, cursorX + (colW - col.sibSize.width) / 2, rowSy, gapX, gapY, wrap)
if col.caption ~= "" then
captions[#captions + 1] = {
text = col.caption,
x = cursorX,
y = rowPy - fontSize * 0.5,
}
end
col.x, col.w = cursorX, colW
cursorX = cursorX + colW
for _, n in ipairs(col.parents) do
nodes[#nodes + 1] = n
end
for _, n in ipairs(col.siblings) do
nodes[#nodes + 1] = n
end
end
local parentsRight = hasParents and cursorX or (margin + focalNode.w)
-- Spouse units stay on a single row beside the focal person in both
-- layouts - spouses are always shown side by side. Compact narrows the
-- chart by wrapping each unit's CHILDREN into a grid, not by stacking
-- the spouses.
---@type {cols: table[], spouseY: number, spineY: number, childTopY: number}[]
local unitRows = {}
if #spouseCols > 0 then
unitRows[1] = { cols = {} }
for _, col in ipairs(spouseCols) do
table.insert(unitRows[1].cols, col)
end
end
local spousesRight = focalNode.x + focalNode.w
local spousesBottom = rowFy + nodeH
local unitRowTop = rowFy
for _, unitRow in ipairs(unitRows) do
unitRow.spouseY = unitRowTop
unitRow.spineY = unitRowTop + nodeH / 2
unitRow.childTopY = unitRowTop + nodeH + rowGapY
local bandH = 0
cursorX = focalNode.x + focalNode.w + colGap
for _, col in ipairs(unitRow.cols) do
local spouseW = col.spouse and col.spouse.w or 0
local colW = math.max(spouseW, col.childSize.width, fontSize)
if col.spouse then
col.spouse.x = cursorX + (colW - spouseW) / 2
col.spouse.y = unitRow.spouseY
nodes[#nodes + 1] = col.spouse
end
col.childGrid =
placeGroup(col.children, cursorX + (colW - col.childSize.width) / 2, unitRow.childTopY, gapX, gapY, wrap)
for _, n in ipairs(col.children) do
nodes[#nodes + 1] = n
end
col.x, col.w = cursorX, colW
cursorX = cursorX + colW + colGap
bandH = math.max(bandH, col.childSize.height)
end
spousesRight = math.max(spousesRight, cursorX - colGap)
local rowBottom = (bandH > 0) and (unitRow.childTopY + bandH) or (unitRow.spouseY + nodeH)
spousesBottom = math.max(spousesBottom, rowBottom)
unitRowTop = rowBottom + rowGapY
end
local contentRight = math.max(parentsRight, spousesRight)
-- The x of the focal person's birth-order gap within a (single-row, wide)
-- sibling set: the midpoint of the gap between the siblings recorded
-- either side of them, or just past the end when eldest/youngest.
local function focalSlotX(col)
local sibs = col.siblings
local n = #sibs
if n == 0 then
return nil
end
local fi = math.max(0, math.min(col.focalIndex or 0, n))
if fi <= 0 then
return sibs[1].x - gapX / 2
end
if fi >= n then
return sibs[n].x + sibs[n].w + gapX / 2
end
local leftSib, rightSib = sibs[fi], sibs[fi + 1]
return (leftSib.x + leftSib.w + rightSib.x) / 2
end
-- Parent-set connectors, in the set's dash style: couple line, trunk from
-- the couple line (or single parent) down to a bus, drops to each sibling
-- chain, and the focal connector (straight up the clear channel in
-- compact; a stepped line from the birth-order gap in wide). Buses are
-- staggered per set so several sets do not overdraw each other.
for setIndex, col in ipairs(parentCols) do
if #col.parents > 0 or #col.siblings > 0 then
local bandTop = hasSiblings and rowSy or rowFy
local busY = bandTop - rowGapY * staggerFraction(setIndex)
local trunkX
if #col.parents == 2 then
local first, last = col.parents[1], col.parents[#col.parents]
local coupleY = rowPy + nodeH / 2
-- Couple line between the two boxes; the trunk drops from its
-- midpoint so the vertical always meets the horizontal.
trunkX = (first.x + first.w + last.x) / 2
-- The line joining a married couple is drawn double.
edges[#edges + 1] = {
points = {
{ x = first.x + first.w, y = coupleY },
{ x = last.x, y = coupleY },
},
dash = col.dash,
double = true,
}
-- Drop to the sibling bus only when something hangs below: the
-- siblings, or (home set) the focal. A childless other-family
-- couple shows as just the couple bar, with no dangling drop.
if #col.siblings > 0 or col.homeSet then
edges[#edges + 1] = {
points = { { x = trunkX, y = coupleY }, { x = trunkX, y = busY } },
dash = col.dash,
}
end
elseif #col.parents == 1 then
trunkX = cx(col.parents[1])
if #col.siblings > 0 or col.homeSet then
edges[#edges + 1] = {
points = { { x = trunkX, y = rowPy + nodeH }, { x = trunkX, y = busY } },
dash = col.dash,
}
end
else
trunkX = col.x + col.w / 2
end
-- Where the focal connector meets this set's bus: straight above
-- the box in compact (clear channel), at the birth-order gap in
-- wide. Other families never connect to the focal person.
local focalAttachX
if col.homeSet then
focalAttachX = focalChannel and focalCx or (focalSlotX(col) or trunkX)
end
-- Connect the sibling group (single-row drops or a wrapped grid's
-- spine), then size the bus to span the trunk, the group's reach
-- and the focal attach.
local gMin, gMax = connectGroup(edges, col.sibGrid, busY, col.dash, gapX, gapY)
local busMinX, busMaxX = trunkX, trunkX
if gMin then
busMinX = math.min(busMinX, gMin)
busMaxX = math.max(busMaxX, gMax)
end
if focalAttachX then
busMinX = math.min(busMinX, focalAttachX)
busMaxX = math.max(busMaxX, focalAttachX)
end
edges[#edges + 1] = {
points = { { x = busMinX, y = busY }, { x = busMaxX, y = busY } },
dash = col.dash,
}
if focalAttachX then
if focalChannel then
-- Compact: straight drop up the clear channel.
edges[#edges + 1] = {
points = { { x = focalCx, y = busY }, { x = focalCx, y = focalNode.y } },
dash = col.dash,
}
else
-- Wide: drop at the birth-order gap, then step across to
-- the box on the left, below the sibling row.
local belowY = rowFy - rowGapY * 0.45
edges[#edges + 1] = {
points = {
{ x = focalAttachX, y = busY },
{ x = focalAttachX, y = belowY },
{ x = focalCx, y = belowY },
{ x = focalCx, y = focalNode.y },
},
dash = col.dash,
}
end
end
end
end
-- Spouse spines: one horizontal line at mid-node height per unit-row;
-- spouses sit on their spine. The first row's spine leaves the focal
-- person's right edge; later rows (compact wrapping) branch off a single
-- vertical dropped from the focal person's bottom, in the clear space
-- left of the unit columns. Each unit's children hang from that unit's
-- own drop, so the grouping is unambiguous even when the partner is
-- unrecorded.
if #spouseCols > 0 then
local lastRow = unitRows[#unitRows]
if #unitRows > 1 then
edges[#edges + 1] = {
points = {
{ x = focalCx, y = focalNode.y + focalNode.h },
{ x = focalCx, y = lastRow.spineY },
},
}
end
for r, unitRow in ipairs(unitRows) do
local rowLast = unitRow.cols[#unitRow.cols]
local spineY = unitRow.spineY
local spineStart = (r == 1) and { x = focalNode.x + focalNode.w, y = spineY }
or { x = focalCx, y = spineY }
-- The line joining the focal person to their spouses is drawn double.
edges[#edges + 1] = {
points = { spineStart, { x = rowLast.x + rowLast.w / 2, y = spineY } },
double = true,
}
for unitIndex, col in ipairs(unitRow.cols) do
if #col.children > 0 then
local dropX = col.spouse and cx(col.spouse) or (col.x + col.w / 2)
local dropTop = col.spouse and (col.spouse.y + col.spouse.h) or spineY
local busY = unitRow.childTopY - rowGapY * staggerFraction(unitIndex)
edges[#edges + 1] = {
points = { { x = dropX, y = dropTop }, { x = dropX, y = busY } },
}
local gMin, gMax = connectGroup(edges, col.childGrid, busY, nil, gapX, gapY)
local busMinX, busMaxX = dropX, dropX
if gMin then
busMinX = math.min(busMinX, gMin)
busMaxX = math.max(busMaxX, gMax)
end
edges[#edges + 1] = {
points = { { x = busMinX, y = busY }, { x = busMaxX, y = busY } },
}
end
end
end
end
local height = spousesBottom + margin
return {
width = contentRight + margin,
height = height,
nodes = nodes,
captions = captions,
edges = edges,
}
end
--------------------------------------------------------------
-- ENTRY POINT
--------------------------------------------------------------
---Lay out a model with the given merged options.
---@param model FsModel
---@param options FsRenderOptions
---@return FsLayout
function M.layout(model, options)
return layoutBands(model, options)
end
return M
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE src/svg_render.lua SvgRender>>
SvgRender = (function()
--[[
@Title: svg_render
@Author: Helen Wright
@Description:
Layer 3 (pure): renders an FsModel to a self-contained SVG string.
Deterministic; no FH API, no IUP, no file I/O.
The SVG carries an inline <style> (selector-scoped to the chart's root id
so it cannot leak into a host page), a <title>/<desc> pair referenced by
aria-labelledby, and a <title> on every node. All text content and
attribute values are XML-escaped here; the model holds raw UTF-8.
Sizing: the layout supplies content bounds which become the viewBox, and
the chart renders at natural size times the image-scale option (both
standalone and embedded; an embedded chart wider than its container
scrolls inside the injected overflow:auto wrapper). Only the XML
declaration distinguishes a standalone file.
]]
local fs_options = _G.FsOptions or require("fs_options")
local fs_layout = _G.FsLayout or require("fs_layout")
local M = {}
--------------------------------------------------------------
-- ESCAPING
--------------------------------------------------------------
local XML_ESCAPES = {
["&"] = "&",
["<"] = "<",
[">"] = ">",
['"'] = """,
["'"] = "'",
}
---XML-escape the five reserved characters. Multi-byte UTF-8 sequences pass
---through untouched (their bytes are all >= 0x80, so no escape applies).
---@param text string Raw UTF-8.
---@return string
function M.escape(text)
return (text:gsub('[&<>"\']', XML_ESCAPES))
end
--------------------------------------------------------------
-- FRAGMENT BUILDERS
--------------------------------------------------------------
---Format a number compactly for SVG attributes (trims trailing zeros).
---@param n number
---@return string
local function num(n)
local s = string.format("%.2f", n)
s = s:gsub("%.?0+$", "")
return s
end
---The shape element for a node, from its resolved style.
---@param node FsLayoutNode
---@param style FsNodeStyle
---@return string
local function shapeElement(node, style)
local strokeWidth = style.emphasis and 2.5 or 1.2
local dash = node.proxy and ' stroke-dasharray="5 4"' or ""
local common = string.format(
'fill="%s" stroke="%s" stroke-width="%s"%s',
style.fill,
style.stroke,
num(strokeWidth),
dash
)
if style.shape == "ellipse" then
return string.format(
'<ellipse cx="%s" cy="%s" rx="%s" ry="%s" %s/>',
num(node.x + node.w / 2),
num(node.y + node.h / 2),
num(node.w / 2),
num(node.h / 2),
common
)
elseif style.shape == "octagon" then
-- Corner cut proportional to height.
local c = node.h * 0.3
local x1, y1, x2, y2 = node.x, node.y, node.x + node.w, node.y + node.h
local points = {
{ x1 + c, y1 },
{ x2 - c, y1 },
{ x2, y1 + c },
{ x2, y2 - c },
{ x2 - c, y2 },
{ x1 + c, y2 },
{ x1, y2 - c },
{ x1, y1 + c },
}
local coords = {}
for _, p in ipairs(points) do
coords[#coords + 1] = num(p[1]) .. "," .. num(p[2])
end
return string.format('<polygon points="%s" %s/>', table.concat(coords, " "), common)
end
local rx = (style.shape == "rounded") and (node.h * 0.45) or 0
return string.format(
'<rect x="%s" y="%s" width="%s" height="%s" rx="%s" %s/>',
num(node.x),
num(node.y),
num(node.w),
num(node.h),
num(rx),
common
)
end
---The accessible per-node title: full name plus dates when any exist.
---LifeDates2 pads living people's dates with a trailing space, so trim.
---@param person FsPerson
---@return string
local function nodeTitleText(person)
local dates = (person.lifeDates or ""):gsub("^%s+", ""):gsub("%s+$", "")
if not person.basicOnly and dates ~= "-" and dates ~= "" then
return person.name .. " (" .. dates .. ")"
end
return person.name
end
---One node: shape, label text and accessible title, optionally wrapped in a
---link. Links are emitted only when the option is on AND the model marks the
---person linkable (never the focal person, never anyone outside the
---selection - the adapter encodes that in the linkable flag).
---@param node FsLayoutNode
---@param options FsRenderOptions
---@param palette FsPalette
---@return string
local function nodeElement(node, options, palette)
local style = fs_options.nodeStyle(node.role, node.person.sex, options, palette)
local fontSize = options.fontSize
local parts = {}
local classes = "fs-node fs-role-" .. node.role
if node.kind then
classes = classes .. " fs-kind-" .. node.kind
end
if node.proxy then
classes = classes .. " fs-proxy"
end
parts[#parts + 1] = node.proxy
and string.format('<g class="%s" opacity="0.5">', classes)
or string.format('<g class="%s">', classes)
parts[#parts + 1] = "<title>" .. M.escape(nodeTitleText(node.person)) .. "</title>"
parts[#parts + 1] = shapeElement(node, style)
local textY = node.y + node.h / 2 + fontSize * 0.35
local label = '<text class="fs-label" x="'
.. num(node.x + node.w / 2)
.. '" y="'
.. num(textY)
.. '" text-anchor="middle">'
.. M.escape(node.label)
if node.tag ~= "" then
label = label
.. string.format(
'<tspan class="fs-tag" font-size="%s" fill="%s">',
num(fontSize * 0.8),
palette.labelText
)
.. M.escape(" — " .. node.tag)
.. "</tspan>"
end
label = label .. "</text>"
parts[#parts + 1] = label
parts[#parts + 1] = "</g>"
local element = table.concat(parts)
if options.links and node.person.linkable then
local prefix = options.pagePrefix or "ind"
element = string.format('<a href="%s%d.html">', prefix, node.person.id) .. element .. "</a>"
end
return element
end
--------------------------------------------------------------
-- RENDER
--------------------------------------------------------------
---Counts for the accessible <desc>.
---@param model FsModel
---@return integer siblings, integer spouses, integer children
local function modelCounts(model)
local siblings, spouses, children = 0, 0, 0
for _, set in ipairs(model.parentSets or {}) do
siblings = siblings + #(set.siblings or {})
end
for _, unit in ipairs(model.spouseUnits or {}) do
if unit.spouse then
spouses = spouses + 1
end
children = children + #(unit.children or {})
end
return siblings, spouses, children
end
---Render a model to a self-contained SVG string.
---@param model FsModel
---@param options? table Raw options; merged over the defaults here.
---@return string svg
function M.render(model, options)
local opts = fs_options.merge(options)
local palette = fs_options.palette(opts)
-- Apply the parent-families option before layout so the chart, the
-- accessible description and the counts all agree on what is shown.
if opts.parentFamilies ~= "all" then
model = {
focal = model.focal,
parentSets = fs_options.filterParentSets(model.parentSets, opts.parentFamilies),
spouseUnits = model.spouseUnits,
}
end
-- Remove anyone flagged Private (a no-op when none are). basic-details
-- people are kept and shown as name only by the label builders below.
model = fs_options.applyPrivacy(model)
local layout = fs_layout.layout(model, opts)
local rootId = "fhseealso-ind" .. model.focal.id
local titleId = rootId .. "-title"
local descId = rootId .. "-desc"
local w, h = layout.width, layout.height
local out = {}
-- Sizing (sizeMode). "scale" (default): natural size x imageScale - a
-- standalone file, or an embedded chart that scrolls inside its wrapper.
-- "fit" (embedded only): width="100%", scaling down to the container.
-- "exact": a fixed pixel width with proportional height, for a report slot.
-- (merge() maps the legacy fitWidth toggle to sizeMode="fit".)
local scale = (opts.imageScale or 100) / 100
local sizing
if opts.sizeMode == "exact" and opts.exactWidth and opts.exactWidth > 0 then
-- The viewBox is unchanged, so the drawing scales to fit the exact width;
-- height follows the chart's aspect ratio. Applies to standalone and
-- embedded alike.
local ew = opts.exactWidth
local eh = (w > 0) and (ew * h / w) or h
sizing = string.format('width="%s" height="%s"', num(ew), num(eh))
elseif opts.embedded and opts.sizeMode == "fit" then
sizing = 'width="100%"'
else
sizing = string.format('width="%s" height="%s"', num(w * scale), num(h * scale))
end
if not opts.embedded then
out[#out + 1] = '<?xml version="1.0" encoding="UTF-8"?>\n'
end
out[#out + 1] = string.format(
'<svg xmlns="http://www.w3.org/2000/svg" id="%s" viewBox="0 0 %s %s" %s role="img" aria-labelledby="%s %s">',
rootId,
num(w),
num(h),
sizing,
titleId,
descId
)
out[#out + 1] = string.format(
'<title id="%s">%s</title>',
titleId,
M.escape("See also: " .. nodeTitleText(model.focal))
)
local siblings, spouses, children = modelCounts(model)
out[#out + 1] = string.format(
'<desc id="%s">%s</desc>',
descId,
M.escape(
string.format(
"Navigation chart for %s: %d parent set(s), %d sibling(s), %d spouse(s)/partner(s), %d child(ren). Linked nodes open that person's page.",
model.focal.name,
#(model.parentSets or {}),
siblings,
spouses,
children
)
)
)
-- Scoped inline style: every selector is prefixed with the root id so the
-- chart cannot restyle (or be restyled by) the host page.
out[#out + 1] = string.format(
"<style>"
.. "#%s text{font-family:%s;font-size:%spx;fill:%s;font-variant:normal;}"
.. "#%s .fs-caption{font-size:%spx;fill:%s;font-style:italic;}"
.. "#%s a .fs-label{fill:%s;text-decoration:underline;}"
.. "#%s a{cursor:pointer;}"
.. "</style>",
rootId,
M.escape(opts.fontFamily),
num(opts.fontSize),
palette.text,
rootId,
num(opts.fontSize * 0.9),
palette.labelText,
rootId,
palette.link,
rootId
)
-- Background.
out[#out + 1] = string.format(
'<rect x="0" y="0" width="%s" height="%s" fill="%s"/>',
num(w),
num(h),
palette.background
)
-- Connectors beneath nodes, in the palette's line colour (falling back to
-- the box-border stroke) and the edge's dash pattern (parent-set edges
-- carry their PEDI pattern; birth and spouse/child edges are solid). A
-- "double" edge (the marriage line joining a couple) is drawn as two
-- parallel lines, offset vertically. Degenerate edges (all points
-- coincident, e.g. the bus over a single child) draw nothing, so skip.
local lineColour = palette.line or palette.stroke
for _, edge in ipairs(layout.edges) do
local degenerate = true
local first = edge.points[1]
for _, p in ipairs(edge.points) do
if p.x ~= first.x or p.y ~= first.y then
degenerate = false
end
end
if not degenerate then
local dash = edge.dash and string.format(' stroke-dasharray="%s"', M.escape(edge.dash)) or ""
local offsets = edge.double and { -1.8, 1.8 } or { 0 }
for _, dy in ipairs(offsets) do
local coords = {}
for _, p in ipairs(edge.points) do
coords[#coords + 1] = num(p.x) .. "," .. num(p.y + dy)
end
out[#out + 1] = string.format(
'<polyline points="%s" fill="none" stroke="%s" stroke-width="1.2"%s/>',
table.concat(coords, " "),
lineColour,
dash
)
end
end
end
-- Group captions.
for _, caption in ipairs(layout.captions) do
out[#out + 1] = string.format(
'<text class="fs-caption" x="%s" y="%s">%s</text>',
num(caption.x),
num(caption.y),
M.escape(caption.text)
)
end
-- Nodes.
for _, node in ipairs(layout.nodes) do
out[#out + 1] = nodeElement(node, opts, palette)
end
out[#out + 1] = "</svg>"
return table.concat(out)
end
return M
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE "../../2 Boilerplate/HtmlInject.lua" HtmlInject>>
HtmlInject = (function()
--[[
@Title: HtmlInject
@Author: Helen Wright
@Description:
Shared boilerplate (pure): finds a target <div> in an HTML string by class
token and inserts or replaces a marker-wrapped block inside it, returning
the new HTML string. The block is just a string - it has carried an SVG
(Add Trees) and an <iframe> (Add Maps); the module is content-agnostic. No
file I/O here - a thin wrapper in each plugin does the reading and writing.
Injection is idempotent: the block is wrapped in
<!-- <marker>:start ind<id> --> ... <!-- <marker>:end ind<id> -->
and a re-run replaces the existing block rather than accumulating copies.
TWO INDEPENDENT TOKENS, both defaulting to "FhSeeAlso" for back-compatibility
but distinct concepts:
* classToken - WHICH div to inject into (matched against the div's class
list; a multi-token value like "fhsection fhsecdata" must
match all of them, the way a CSS ".a.b" selector would).
* markerToken - the LABEL in the idempotency comment. Set this PER PLUGIN
(e.g. Add Maps passes "AddMaps") so two plugins can embed
into the same page without overwriting each other's block:
the marker is otherwise keyed only by person id, so a
shared label would let one plugin's re-run clobber the
other's block (notably with top/bottom placement, which
finds a block by marker).
There is no HTML parser in the plugin environment, so the opening div tag
is located by a small scanner (skipping quoted attribute values that might
contain '>') and its attributes are then examined for a class list
containing the class token. This is robust to attribute order, quote
style, extra whitespace and tag-name case.
]]
local M = {}
---The default class token that marks the target div in FH-generated pages, and
---the default marker label for the idempotency comments. The two are separate
---options (see the header) that merely share this back-compatible default.
local CLASS_TOKEN = "FhSeeAlso"
local MARKER_TOKEN = "FhSeeAlso"
---@class FsEmbedOptions Embedding options (all optional).
---@field placement? "replace"|"top"|"bottom" Where the block goes in the div (default "replace").
---@field classToken? string Target div class token (default "FhSeeAlso").
---@field markerToken? string Label for the idempotency marker comments (default "FhSeeAlso"); set per plugin so two plugins can embed into the same page without clobbering each other.
---@field align? "left"|"centre"|"right" Horizontal alignment of the chart in the div (default "centre").
---@field caption? string Optional heading rendered above the chart.
---@field captionClass? string CSS class for the caption div (default "fs-embed-caption"); all caption styling comes from the site's CSS for this class.
---@field fit? boolean True when the SVG itself is width="100%" (no horizontal scroll wrapper).
---@field containerClass? string CSS class on the outer wrapper div (default "fs-embed-chart"); a styling hook for the site's CSS.
---@field hideable? boolean Wrap the chart in a no-JavaScript <details> element (collapsible). Starts expanded; the caption (or "Family chart") is the summary label.
--------------------------------------------------------------
-- LOCATING THE TARGET DIV
--------------------------------------------------------------
---Scan a div's opening tag from just after "<div" to its closing ">", skipping
---quoted attribute values (which may legitimately contain '>'). Plain Lua, so
---the plugin needs no lpeg library.
---@param html string
---@param pos integer Index of the first character after "<div".
---@return string|nil attributes Raw text between "<div" and ">", or nil if the tag never closes.
---@return integer|nil afterTag Index just after the closing ">".
local function scanDivOpen(html, pos)
local i, n = pos, #html
while i <= n do
local c = html:sub(i, i)
if c == '"' or c == "'" then
-- Skip a quoted value whole, including any '>' inside it.
local close = html:find(c, i + 1, true)
if not close then
return nil -- unterminated quote: malformed tag
end
i = close + 1
elseif c == ">" then
return html:sub(pos, i - 1), i + 1
else
i = i + 1
end
end
return nil -- no closing ">"
end
---Does an attribute string carry CLASS_TOKEN as a whole class token?
---Accepts double-quoted, single-quoted and unquoted attribute values.
---@param attributes string The raw text between "<div" and ">".
---@param classToken string One or more class tokens (space-separated); the div must carry ALL of them.
---@return boolean
local function hasSeeAlsoClass(attributes, classToken)
-- A multi-token target (e.g. "fhsection fhsecdata") matches a div that
-- carries every one of those classes - the way a CSS selector ".a.b"
-- would - so Family Historian's own containers can be addressed.
local required = {}
for token in classToken:gmatch("%S+") do
required[#required + 1] = token
end
if #required == 0 then
return false
end
local function tokensContainTarget(value)
local present = {}
for token in value:gmatch("%S+") do
present[token] = true
end
for _, token in ipairs(required) do
if not present[token] then
return false
end
end
return true
end
for name, quote, value in attributes:gmatch([[([A-Za-z0-9_:-]+)%s*=%s*(["'])(.-)%2]]) do
if name:lower() == "class" and tokensContainTarget(value) then
return true
end
end
for name, value in attributes:gmatch([[([A-Za-z0-9_:-]+)%s*=%s*([^%s"'][^%s]*)]]) do
if name:lower() == "class" and tokensContainTarget(value) then
return true
end
end
return false
end
---Locate the first target div: the position of its "<" and the position just
---after its opening ">".
---@param html string
---@param classToken? string Class token to match (default "FhSeeAlso").
---@return integer|nil tagStart, integer|nil afterOpenTag
local function locateDiv(html, classToken)
classToken = classToken or CLASS_TOKEN
local pos = 1
while true do
local tagStart = html:find("<[dD][iI][vV][%s/>]", pos)
if not tagStart then
return nil
end
-- Parse from just after "<div"; divOpen yields the attribute text and
-- the position following ">".
local attributes, afterTag = scanDivOpen(html, tagStart + 4)
if attributes and hasSeeAlsoClass(attributes, classToken) then
return tagStart, afterTag
end
pos = tagStart + 4
end
end
---Find the first target div's opening tag.
---@param html string
---@param classToken? string Class token to match (default "FhSeeAlso").
---@return integer|nil afterOpenTag Position just after the opening tag's ">", or nil if none found.
local function findTargetDiv(html, classToken)
local _, afterTag = locateDiv(html, classToken)
return afterTag
end
---The position of the </div> that closes the div opened just before
---afterOpen, accounting for nested divs.
---@param html string
---@param afterOpen integer Position just after the target div's opening ">".
---@return integer|nil closeStart Position of the matching "</div>"'s "<", or nil if unbalanced.
local function matchingDivClose(html, afterOpen)
local depth = 1
local pos = afterOpen
while true do
local openStart = html:find("<[dD][iI][vV][%s/>]", pos)
local closeStart = html:find("</[dD][iI][vV]%s*>", pos)
if not closeStart then
return nil -- no matching close
end
if openStart and openStart < closeStart then
-- A nested opening div: skip past its tag and go deeper.
local _, afterNested = scanDivOpen(html, openStart + 4)
depth = depth + 1
pos = afterNested or (openStart + 4)
else
depth = depth - 1
if depth == 0 then
return closeStart
end
pos = closeStart + 1
end
end
end
---Whether a page contains a target div at all. inject() reports the same
---condition via its error return; this read-only probe exists for tests and
---diagnostics.
---@param html string
---@param classToken? string Class token to match (default "FhSeeAlso").
---@return boolean
function M.hasTargetDiv(html, classToken)
return findTargetDiv(html, classToken) ~= nil
end
---Remove the first div carrying classToken, in full (its opening tag, content
---and matching close). Independent of injection: a site can drop Family
---Historian's own "See also" box (default "FhSeeAlso") and place the chart in
---a different div instead.
---@param html string
---@param classToken? string Class token to match (default "FhSeeAlso").
---@return string newHtml The page with the div removed (unchanged if none found or unbalanced).
---@return boolean removed True when a div was removed.
function M.removeDiv(html, classToken)
local tagStart, afterTag = locateDiv(html, classToken)
if not tagStart then
return html, false
end
local closeStart = matchingDivClose(html, afterTag)
if not closeStart then
-- Unbalanced markup: leave the page intact rather than guess.
return html, false
end
local _, closeEnd = html:find("</[dD][iI][vV]%s*>", closeStart)
closeEnd = closeEnd or closeStart
return html:sub(1, tagStart - 1) .. html:sub(closeEnd + 1), true
end
--------------------------------------------------------------
-- BLOCK CONSTRUCTION AND INJECTION
--------------------------------------------------------------
---XML-escape the five reserved characters (for the optional caption text).
---@param text string
---@return string
local function escapeText(text)
return (text:gsub('[&<>"\']', {
["&"] = "&",
["<"] = "<",
[">"] = ">",
['"'] = """,
["'"] = "'",
}))
end
---Wrap an inline SVG in the container used both for injection and the
---options-dialog preview, applying alignment, an optional caption, the fit
---mode and (optionally) a no-JavaScript collapsible. The inline styles avoid
---depending on the site's stylesheets; the outer div also carries a CSS class
---(default "fs-embed-chart") as a styling hook.
--- * default (scroll): natural-size SVG, the container scrolls horizontally;
--- * fit: the SVG is width="100%" and simply fills the container;
--- * hideable: the caption + chart sit in a <details> element (starts open),
--- so a reader can collapse the chart with no script. The caption becomes
--- the <summary> label (or "Family chart" when there is no caption).
---@param svg string An embedded-mode SVG.
---@param opts? FsEmbedOptions
---@return string
function M.wrapSvg(svg, opts)
opts = opts or {}
local alignMap = { left = "left", centre = "center", center = "center", right = "right" }
local textAlign = alignMap[opts.align or "centre"] or "center"
local outer = opts.fit and ("max-width:100%;text-align:" .. textAlign)
or ("overflow:auto;max-width:100%;text-align:" .. textAlign)
local containerClass = (opts.containerClass and opts.containerClass:match("%S")) and opts.containerClass
or "fs-embed-chart"
local captionText = (opts.caption and opts.caption:match("%S")) and opts.caption or nil
local inner
if opts.hideable then
-- No-JavaScript collapsible: <details> starts open and the caption (or a
-- default) labels the <summary>. Styling is delegated to the site's CSS
-- (e.g. ".fs-embed-chart summary { ... }").
local summary = captionText or "Family chart"
inner = string.format("<details open><summary>%s</summary>%s</details>", escapeText(summary), svg)
else
local caption = ""
if captionText then
-- All caption styling (font, size, colour, alignment) is delegated to
-- the site's CSS for this class; the plugin only emits the class.
local captionClass = (opts.captionClass and opts.captionClass:match("%S")) and opts.captionClass
or "fs-embed-caption"
caption = string.format('<div class="%s">%s</div>', escapeText(captionClass), escapeText(captionText))
end
inner = caption .. svg
end
return string.format('<div class="%s" style="%s">%s</div>', escapeText(containerClass), outer, inner)
end
---The complete marker-wrapped block for one person.
---@param svg string An embedded-mode SVG.
---@param personId integer Raw FH record id.
---@param opts? FsEmbedOptions
---@return string
function M.makeBlock(svg, personId, opts)
opts = opts or {}
local marker = (opts.markerToken and opts.markerToken:match("%S")) and opts.markerToken or MARKER_TOKEN
return string.format(
"<!-- %s:start ind%d -->\n%s\n<!-- %s:end ind%d -->",
marker,
personId,
M.wrapSvg(svg, opts),
marker,
personId
)
end
---Lua-pattern-escape a literal string.
---@param text string
---@return string
local function escapePattern(text)
return (text:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%0"))
end
---Find an existing marker block for a person.
---@param html string
---@param personId integer
---@param markerToken? string Marker label to match (default "FhSeeAlso"); must match makeBlock's.
---@return integer|nil blockStart, integer|nil blockEnd Inclusive bounds of the whole block.
local function findMarkerBlock(html, personId, markerToken)
local marker = (markerToken and markerToken:match("%S")) and markerToken or MARKER_TOKEN
local id = "ind" .. personId
local startPattern = "<!%-%-%s*" .. escapePattern(marker) .. ":start%s+" .. escapePattern(id) .. "%s*%-%->"
local endPattern = "<!%-%-%s*" .. escapePattern(marker) .. ":end%s+" .. escapePattern(id) .. "%s*%-%->"
local blockStart = html:find(startPattern)
if not blockStart then
return nil
end
local _, blockEnd = html:find(endPattern, blockStart)
if not blockEnd then
-- A start marker without its end marker: treat as absent rather than
-- guessing how much of the page to replace.
return nil
end
return blockStart, blockEnd
end
---Inject (or re-inject) a person's chart into an HTML page.
---
---Placement (opts.placement):
--- * "replace" (default) - the target div's entire content becomes the
--- chart block: the chart appears instead of, not as well as, whatever
--- was there (re-running replaces it again - idempotent);
--- * "top" / "bottom" - the block is inserted at the start / end of the
--- div, keeping any other content; a re-run replaces the existing marker
--- block in place rather than adding another.
---@param html string The page HTML.
---@param svg string An embedded-mode SVG (natural size, no XML declaration).
---@param personId integer Raw FH record id.
---@param opts? FsEmbedOptions
---@return string|nil newHtml The updated page, or nil on failure.
---@return string|nil err Why injection was not possible.
function M.inject(html, svg, personId, opts)
opts = opts or {}
local placement = opts.placement or "replace"
local classToken = opts.classToken or CLASS_TOKEN
local block = M.makeBlock(svg, personId, opts)
local afterTag = findTargetDiv(html, classToken)
if not afterTag then
return nil, 'no <div class="' .. classToken .. '"> found in the page'
end
local closeStart = matchingDivClose(html, afterTag)
if placement == "replace" and closeStart then
-- The div content becomes exactly the block; nothing accumulates.
return html:sub(1, afterTag - 1) .. "\n" .. block .. "\n" .. html:sub(closeStart)
end
-- top/bottom (and replace's fallback when the div is unbalanced): keep
-- other content; a re-run replaces this person's existing block in place.
-- Match on this plugin's own marker so a re-run never touches another
-- plugin's block in the same page.
local blockStart, blockEnd = findMarkerBlock(html, personId, opts.markerToken)
if blockStart then
return html:sub(1, blockStart - 1) .. block .. html:sub(blockEnd + 1)
end
if placement == "bottom" and closeStart then
return html:sub(1, closeStart - 1) .. block .. "\n" .. html:sub(closeStart)
end
-- "top" and the unbalanced fallback: just inside the opening tag.
return html:sub(1, afterTag - 1) .. "\n" .. block .. html:sub(afterTag)
end
return M
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE src/fh_adapter.lua FhAdapter>>
FhAdapter = (function()
--[[
@Title: fh_adapter
@Author: Helen Wright
@Description:
Layer 1: the only FH-dependent code. Walks the Family Historian object
model from one focal individual and emits a plain-data FsModel (schema in
fs_options.lua) for the pure renderer.
Responsibilities resolved here, so the renderer never needs FH:
* names (fhGetDisplayText) and life dates (the LifeDates2 built-in,
which yields a bare hyphen when no dates exist);
* sex normalised to M/F/U;
* parent-sets from the focal person's FAMC links, labelled by PEDI
(empty PEDI means birth), birth-type sets ordered first; each set's
siblings are that family's own children only, and focalIndex records
the focal person's place among them (for the age-order slot);
* a separate "other family" set for each recorded parent's other
families that contain children - so half-siblings appear with both
of their own parents rather than under the focal couple;
* siblings classified full/half/step by counting the biological
parents shared with the focal person (2+ = full, 1 = half, 0 = step);
* spouse units from FAMS links, ordered by marriage/partnership date
(an undated family takes its earliest child's birth) then as
recorded, including unmarried and same-sex partnerships; an
unrecorded partner yields spouse = nil with children kept;
* children and siblings in the order recorded in Family Historian
(users curate child order there; the plugin no longer re-sorts);
* the linkable flag: true only for individuals in the caller's id set,
never for the focal person.
This file cannot run outside FH. Its output shape is checked outside FH
against golden fixtures captured with dump_model.lua.
]]
local M = {}
--------------------------------------------------------------
-- LOW-LEVEL HELPERS
--------------------------------------------------------------
---Iterate the children items of a record/item with a given tag.
---MoveTo's second argument is a data reference, not a bare tag, so the tag
---is prefixed with "~." (relative to the starting item).
---@param ptrParent userdata Parent item pointer.
---@param tag string GEDCOM tag of the wanted children (e.g. "FAMC").
---@return fun(): userdata|nil iterator Yields a fresh clone per item.
local function eachChildItem(ptrParent, tag)
local p = fhNewItemPtr()
p:MoveTo(ptrParent, "~." .. tag)
return function()
if p:IsNull() then
return nil
end
local current = p:Clone()
p:MoveNext("SAME_TAG")
return current
end
end
---The linked record for a link item, or nil when the link is empty/broken.
---@param ptrLink userdata
---@return userdata|nil
local function linkedRecord(ptrLink)
local ptr = fhGetValueAsLink(ptrLink)
if ptr and ptr:IsNotNull() then
return ptr
end
return nil
end
---Sex of an individual normalised to the model's M/F/U.
---@param ptrIndi userdata
---@return FsSexCode
local function sexOf(ptrIndi)
local text = fhGetItemText(ptrIndi, "~.SEX") or ""
local first = text:sub(1, 1):upper()
if first == "M" or first == "F" then
return first
end
return "U"
end
---A sortable number for the first date point of a date field, or nil when
---the field is missing or empty. BC years sort negative; missing month/day
---sort as the start of the period.
---@param ptrItem userdata Item the data reference is relative to.
---@param dataRef string e.g. "~.BIRT.DATE" or "~.MARR.DATE".
---@return number|nil
local function dateSortKey(ptrItem, dataRef)
local ptrDate = fhGetItemPtr(ptrItem, dataRef)
if ptrDate:IsNull() then
return nil
end
local dt = fhGetValueAsDate(ptrDate)
if dt:IsNull() then
return nil
end
local dp = dt:GetDatePt1()
if not dp or dp:IsNull() then
return nil
end
local year = dp:GetYear()
if dp:GetBC() then
year = -year
end
return (year * 12 + math.max(dp:GetMonth(), 1)) * 32 + math.max(dp:GetDay(), 1)
end
---Stable in-place sort of {key:number|nil, i:integer, ...} entries: dated
---entries by date then recorded order; undated entries after them in
---recorded order.
---@param items {key: number|nil, i: integer}[]
local function sortDatedThenRecorded(items)
table.sort(items, function(a, b)
if a.key and b.key then
if a.key ~= b.key then
return a.key < b.key
end
return a.i < b.i
end
if a.key ~= nil then
return true
end
if b.key ~= nil then
return false
end
return a.i < b.i
end)
end
--------------------------------------------------------------
-- MODEL BUILDING BLOCKS
--------------------------------------------------------------
---True if an individual carries the named record flag. Wrapped in pcall so an
---unknown flag name yields false rather than erroring the whole run.
---@param ptrIndi userdata
---@param flagName string
---@return boolean
local function hasFlag(ptrIndi, flagName)
local ok, result = pcall(fhCallBuiltInFunction, "HasFlag", ptrIndi, flagName)
return ok and result == true
end
---Build an FsPerson for an individual record.
---@param ptrIndi userdata
---@param focalId integer
---@param linkableIds table<integer, boolean>
---@param privacy? {omit?: boolean, basicFlag?: string} Privacy options; sets private/basicOnly.
---@return FsPerson
local function personFrom(ptrIndi, focalId, linkableIds, privacy)
local id = fhGetRecordId(ptrIndi)
local person = {
id = id,
name = fhGetDisplayText(ptrIndi),
lifeDates = fhCallBuiltInFunction("LifeDates2", ptrIndi),
sex = sexOf(ptrIndi),
linkable = (id ~= focalId) and (linkableIds[id] == true),
}
if privacy then
if privacy.omit and hasFlag(ptrIndi, "Private") then
person.private = true
end
if privacy.basicFlag and privacy.basicFlag ~= "" and hasFlag(ptrIndi, privacy.basicFlag) then
person.basicOnly = true
end
end
return person
end
---The PEDI value of a FAMC link, lower-cased; "" when absent (= birth).
---@param ptrFamc userdata
---@return string
local function pediOf(ptrFamc)
local p = fhNewItemPtr()
p:MoveTo(ptrFamc, "~.PEDI")
if p:IsNull() then
return ""
end
return (fhGetValueAsText(p) or ""):lower()
end
---The recorded partner records of a family, in HUSB-then-WIFE order. FH7
---records same-sex couples with repeated HUSB or WIFE links, so partners are
---collected by position rather than by assuming one of each tag.
---@param ptrFam userdata
---@return userdata[]
local function partnersOf(ptrFam)
local partners = {}
for _, tag in ipairs({ "HUSB", "WIFE" }) do
for link in eachChildItem(ptrFam, tag) do
local indi = linkedRecord(link)
if indi then
partners[#partners + 1] = indi
end
end
end
return partners
end
---The children of a family in the order recorded in Family Historian.
---FH presents children in the curated record order (which usually is birth
---order, including people dated only by baptism); re-sorting by birth date
---here put undated and baptism-dated children in the wrong place.
---@param ptrFam userdata
---@return userdata[]
local function childrenOf(ptrFam)
local children = {}
for link in eachChildItem(ptrFam, "CHIL") do
local indi = linkedRecord(link)
if indi then
children[#children + 1] = indi
end
end
return children
end
---The ids of an individual's biological parents: the parents of every
---family the individual belongs to as a child with a birth PEDI.
---@param ptrIndi userdata
---@return table<integer, boolean>
local function biologicalParentIds(ptrIndi)
local ids = {}
for famc in eachChildItem(ptrIndi, "FAMC") do
local pedi = pediOf(famc)
if pedi == "" or pedi == "birth" then
local fam = linkedRecord(famc)
if fam then
for _, parent in ipairs(partnersOf(fam)) do
ids[fhGetRecordId(parent)] = true
end
end
end
end
return ids
end
---Classify a sibling by the biological parents they share with the focal
---person: 2+ shared = full, 1 = half, 0 = step (connected only through this
---step/adoptive parent-set).
---@param ptrSibling userdata
---@param focalBioParents table<integer, boolean>
---@return "full"|"half"|"step"
local function siblingKind(ptrSibling, focalBioParents)
local shared = 0
for id in pairs(biologicalParentIds(ptrSibling)) do
if focalBioParents[id] then
shared = shared + 1
end
end
if shared >= 2 then
return "full"
elseif shared == 1 then
return "half"
end
return "step"
end
--------------------------------------------------------------
-- ENTRY POINT
--------------------------------------------------------------
---Build the FsModel for one focal individual.
---@param ptrFocal userdata INDI record pointer.
---@param linkableIds table<integer, boolean> Record ids that may be linked (the current selection).
---@param privacy? {omit?: boolean, basicFlag?: string} Privacy options (omit-Private, basic-details flag).
---@return FsModel
function M.buildModel(ptrFocal, linkableIds, privacy)
local focalId = fhGetRecordId(ptrFocal)
local focalBioParents = biologicalParentIds(ptrFocal)
-- Parent-sets: one per FAMC link, birth-type sets first (stable).
local setEntries = {}
for famc in eachChildItem(ptrFocal, "FAMC") do
local fam = linkedRecord(famc)
if fam then
local pedi = pediOf(famc)
setEntries[#setEntries + 1] = {
fam = fam,
pedi = pedi,
key = (pedi == "" or pedi == "birth") and 0 or 1,
i = #setEntries + 1,
}
end
end
sortDatedThenRecorded(setEntries)
-- Track who is already placed, so each sibling appears exactly once
-- (under the first qualifying set) and the focal person never does.
local placed = { [focalId] = true }
-- The family records of the focal person's own sets, so a parent's
-- other families can be told apart from them.
local focalFamIds = {}
for _, entry in ipairs(setEntries) do
focalFamIds[fhGetRecordId(entry.fam)] = true
end
local parentSets = {}
for _, entry in ipairs(setEntries) do
local fam = entry.fam
local partners = partnersOf(fam)
-- The model carries up to two recorded parents per set. Same-sex
-- couples fill the two slots in recorded order; an unrecorded
-- parent is omitted entirely (nil), never a placeholder.
local father, mother = partners[1], partners[2]
-- Siblings: this family's own children, in recorded order. A
-- parent's other families become separate sets below, so a child
-- is never shown under a couple that is not their own parents.
-- focalIndex counts the displayed siblings before the focal
-- person's place in the recorded order (for the age-order slot).
local siblings = {}
local focalIndex = 0
local beforeFocal = true
for _, child in ipairs(childrenOf(fam)) do
local id = fhGetRecordId(child)
if id == focalId then
beforeFocal = false
elseif not placed[id] then
placed[id] = true
local sibling = personFrom(child, focalId, linkableIds, privacy)
sibling.kind = siblingKind(child, focalBioParents)
siblings[#siblings + 1] = sibling
if beforeFocal then
focalIndex = focalIndex + 1
end
end
end
parentSets[#parentSets + 1] = {
pedi = entry.pedi,
father = father and personFrom(father, focalId, linkableIds, privacy) or nil,
mother = mother and personFrom(mother, focalId, linkableIds, privacy) or nil,
siblings = siblings,
focalIndex = focalIndex,
}
end
-- Other families of each recorded parent (where half-siblings live):
-- one extra set per family with children, showing both of that family's
-- recorded parents.
local emittedFamIds = {}
for _, entry in ipairs(setEntries) do
for _, parent in ipairs(partnersOf(entry.fam)) do
for fams in eachChildItem(parent, "FAMS") do
local otherFam = linkedRecord(fams)
if otherFam then
local otherFamId = fhGetRecordId(otherFam)
if not focalFamIds[otherFamId] and not emittedFamIds[otherFamId] then
emittedFamIds[otherFamId] = true
local children = {}
for _, child in ipairs(childrenOf(otherFam)) do
local id = fhGetRecordId(child)
if not placed[id] then
placed[id] = true
local sibling = personFrom(child, focalId, linkableIds, privacy)
sibling.kind = siblingKind(child, focalBioParents)
children[#children + 1] = sibling
end
end
if #children > 0 then
local otherPartners = partnersOf(otherFam)
parentSets[#parentSets + 1] = {
pedi = "",
otherFamily = true,
sharedParentId = fhGetRecordId(parent),
sharedParentName = fhGetDisplayText(parent),
sharedParentPrivate = (privacy and privacy.omit and hasFlag(parent, "Private")) or nil,
father = otherPartners[1] and personFrom(otherPartners[1], focalId, linkableIds, privacy) or nil,
mother = otherPartners[2] and personFrom(otherPartners[2], focalId, linkableIds, privacy) or nil,
siblings = children,
}
end
end
end
end
end
end
-- Spouse units: one per FAMS link, ordered by marriage/partnership date
-- then as recorded. A family with no marriage/partnership date sorts by
-- its earliest child's birth instead, so undated relationships still
-- land in chronological order. The partner may be unrecorded; children
-- are kept either way.
local unitEntries = {}
for fams in eachChildItem(ptrFocal, "FAMS") do
local fam = linkedRecord(fams)
if fam then
local famChildren = childrenOf(fam)
local key = dateSortKey(fam, "~.MARR.DATE")
if not key then
-- Children are in recorded order, so scan them all for the
-- earliest dated birth.
for _, child in ipairs(famChildren) do
local childKey = dateSortKey(child, "~.BIRT.DATE")
if childKey and (not key or childKey < key) then
key = childKey
end
end
end
unitEntries[#unitEntries + 1] = {
fam = fam,
children = famChildren,
key = key,
i = #unitEntries + 1,
}
end
end
sortDatedThenRecorded(unitEntries)
local spouseUnits = {}
for _, entry in ipairs(unitEntries) do
local spouse
for _, partner in ipairs(partnersOf(entry.fam)) do
if fhGetRecordId(partner) ~= focalId then
spouse = partner
break
end
end
local children = {}
for _, child in ipairs(entry.children) do
children[#children + 1] = personFrom(child, focalId, linkableIds, privacy)
end
spouseUnits[#spouseUnits + 1] = {
spouse = spouse and personFrom(spouse, focalId, linkableIds, privacy) or nil,
children = children,
}
end
return {
focal = personFrom(ptrFocal, focalId, linkableIds, privacy),
parentSets = parentSets,
spouseUnits = spouseUnits,
}
end
return M
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE fixtures/sample_theming.lua SampleTheming>>
SampleTheming = (function()
--[[
@Title: sample_theming
@Author: Helen Wright
@Description:
A small, symmetric FsModel used for previewing colour schemes, sizing and
shape options. Deliberately free of edge cases: one birth parent-set, two
full siblings (one of each sex), one opposite-sex spouse, two children (one
of each sex), every node carrying life dates. Balanced so the five colour
presets and the shape-by-sex / shape-by-relationship options look even.
This is the data structure the pure SVG renderer consumes. See the schema
(FsModel and friends) in the project prompt. All strings are raw UTF-8; the
renderer is responsible for XML-escaping them.
]]
---@type FsModel
return {
focal = {
id = 1001,
name = "Eleanor Hartley",
lifeDates = "1850-1921",
sex = "F",
linkable = false, -- focal person is never linked
},
parentSets = {
{
pedi = "", -- empty == birth
father = {
id = 1010,
name = "George Hartley",
lifeDates = "1820-1888",
sex = "M",
linkable = true,
},
mother = {
id = 1011,
name = "Margaret Hartley",
lifeDates = "1824-1899",
sex = "F",
linkable = true,
},
siblings = {
{
id = 1020,
name = "Thomas Hartley",
lifeDates = "1848-1910",
sex = "M",
linkable = true,
kind = "full",
},
{
id = 1021,
name = "Alice Hartley",
lifeDates = "1853-1934",
sex = "F",
linkable = true,
kind = "full",
},
},
},
},
spouseUnits = {
{
spouse = {
id = 1030,
name = "William Ashford",
lifeDates = "1847-1915",
sex = "M",
linkable = true,
},
children = {
{
id = 1040,
name = "Edward Ashford",
lifeDates = "1872-1944",
sex = "M",
linkable = true,
},
{
id = 1041,
name = "Charlotte Ashford",
lifeDates = "1875-1951",
sex = "F",
linkable = true,
},
},
},
},
}
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE fixtures/sample_family.lua SampleFamily>>
SampleFamily = (function()
--[[
@Title: sample_family
@Author: Helen Wright
@Description:
A deliberately awkward FsModel that exercises every branch of the renderer
and injector. The focal person, Søren O'Brien-Müller, is constructed to cover:
* Multiple parent-sets distinguished by PEDI:
- a birth set (both parents recorded), focalIndex placing the
focal person between two siblings (the age-order slot)
- an adopted set (both parents recorded)
- a step set with ONE parent omitted (mother only)
- an "other family" set (a parent's family with another partner),
which gets its own column and no focal connector
* Siblings grouped under their parent-set, tagged full / half / step.
* Spouse units covering:
- a married opposite-sex union with children (birth order)
- an unmarried partnership with one child
- a same-sex partnership with an adopted child
- a union whose partner is unrecorded (children grouped under focal)
* Missing people omitted (no placeholder nodes).
* LifeDates2 hyphen-only output ("-") where no dates exist.
* UTF-8 names: accents, apostrophe, CJK.
* One adversarial name forcing XML escaping of & and <.
* linkable = false on the focal person and on people outside the selection,
so the renderer must suppress links on those nodes.
All strings are raw UTF-8. The renderer must XML-escape & < > " ' on output
and must not corrupt multi-byte characters. See the FsModel schema in the
project prompt.
]]
---@type FsModel
return {
focal = {
id = 2000,
name = "Søren O'Brien-Müller",
lifeDates = "1862-1937",
sex = "M",
linkable = false,
},
parentSets = {
-- Birth: both parents recorded; full + half siblings. The focal
-- person's recorded place is after Liam (focalIndex 2 of 3).
{
pedi = "birth",
focalIndex = 2,
father = {
id = 2010,
name = "Patrick O'Brien",
lifeDates = "1835-1901",
sex = "M",
linkable = true,
},
mother = {
id = 2011,
name = "Zoé Müller",
lifeDates = "1840-1888",
sex = "F",
linkable = true,
},
siblings = {
{
id = 2020,
name = "Bridget O'Brien",
lifeDates = "1860-1942",
sex = "F",
linkable = true,
kind = "full",
},
{
-- born after focal; tests birth-order sort
id = 2021,
name = "Liam O'Brien",
lifeDates = "1865-1939",
sex = "M",
linkable = true,
kind = "full",
},
{
-- father's child by another partner: half-sibling
id = 2022,
name = "Niamh O'Brien",
lifeDates = "1871-",
sex = "F",
linkable = true,
kind = "half",
},
},
},
-- Adopted: both parents recorded; a step-sibling (no shared blood).
{
pedi = "adopted",
focalIndex = 1,
father = {
id = 2030,
name = "李 Wei",
lifeDates = "1838-1910",
sex = "M",
linkable = true,
},
mother = {
id = 2031,
name = "Æthelflæd Defoe",
lifeDates = "1842-1909",
sex = "F",
linkable = false, -- not in the selection: no link
},
siblings = {
{
id = 2032,
name = "Jean-François Defoe",
lifeDates = "1859-1921",
sex = "M",
linkable = true,
kind = "step",
},
},
},
-- Step: only the mother is recorded (father omitted, not a placeholder).
{
pedi = "step",
focalIndex = 0,
father = nil,
mother = {
id = 2040,
name = "Síle O'Brien",
lifeDates = "-", -- LifeDates2 with no dates
sex = "F",
linkable = true,
},
siblings = {
{
id = 2041,
name = "Cormac O'Brien",
lifeDates = "1869-1944",
sex = "M",
linkable = true,
kind = "step",
},
},
},
-- A parent's other family: Síle's child by another partner gets a
-- column of its own showing both of that family's parents, so the
-- child is never shown under a couple that is not their parents.
{
pedi = "",
otherFamily = true,
sharedParentId = 2040,
sharedParentName = "Síle O'Brien",
father = {
id = 2052,
name = "Dáithí Walsh",
lifeDates = "1850-1920",
sex = "M",
linkable = false,
},
mother = {
id = 2040,
name = "Síle O'Brien",
lifeDates = "-",
sex = "F",
linkable = true,
},
siblings = {
{
id = 2053,
name = "Orla Walsh",
lifeDates = "1875-1950",
sex = "F",
linkable = false,
kind = "step",
},
},
},
},
spouseUnits = {
-- Married, opposite sex, children in birth order.
{
spouse = {
id = 2100,
name = "Eleanor Ashford",
lifeDates = "1866-1948",
sex = "F",
linkable = true,
},
children = {
{
id = 2110,
name = "Conor O'Brien-Müller",
lifeDates = "1888-1961",
sex = "M",
linkable = true,
},
{
id = 2111,
name = "Aoife O'Brien-Müller",
lifeDates = "1890-1975",
sex = "F",
linkable = true,
},
},
},
-- Unmarried partnership with one child.
{
spouse = {
id = 2120,
name = "Mary Sinclair",
lifeDates = "1870-1953",
sex = "F",
linkable = true,
},
children = {
{
id = 2130,
name = "Ruth Sinclair",
lifeDates = "1895-1980",
sex = "F",
linkable = true,
},
},
},
-- Same-sex partnership with an adopted child.
{
spouse = {
id = 2140,
name = "James Holloway",
lifeDates = "1864-1929",
sex = "M",
linkable = true,
},
children = {
{
id = 2150,
name = "Theo Holloway",
lifeDates = "1902-1988",
sex = "M",
linkable = true,
},
},
},
-- Partner unrecorded: spouse omitted, child grouped under the focal person.
-- Adversarial name deliberately contains & and < to force XML escaping.
{
spouse = nil,
children = {
{
id = 2160,
name = "Tom <of Marks & Spencer> O'Brien",
lifeDates = "1899-",
sex = "M",
linkable = true,
},
},
},
},
}
end)()
--<<FS_SPLICE_END>>
--------------------------------------------------------------
--SETTINGS CONFIGURATION
--------------------------------------------------------------
-- Display labels for the option lists, mapped to the internal keys the
-- renderer understands.
---@type table<string, FsPresetKey>
local PRESET_KEYS = {
["Classic"] = "classic",
["High Contrast"] = "highcontrast",
["Match System Theme"] = "system",
}
---@type table<string, FsEncoding>
local ENCODING_KEYS = {
["Theme (uniform)"] = "theme",
["Sex"] = "sex",
["Relationship"] = "relationship",
}
-- Lowercase values are also accepted, for options saved by earlier plugin versions.
---@type table<string, FsLayoutChoice>
local LAYOUT_KEYS = {
["Wide"] = "wide",
["Compact"] = "compact",
["wide"] = "wide",
["compact"] = "compact",
}
---@type table<string, FsParentFamilies>
local PARENT_FAMILY_KEYS = {
["All parent families"] = "all",
["Birth families only"] = "birth",
["First family only"] = "first",
}
---@type table<string, FsShape>
local SHAPE_KEYS = {
["Rounded"] = "rounded",
["Rectangle"] = "rect",
["Ellipse"] = "ellipse",
["Octagon"] = "octagon",
}
-- Web-safe stacks, unquoted (CSS treats space-separated identifiers as one
-- family name, and unquoted names survive XML/HTML escaping untouched).
local FONT_CUSTOM_LABEL = "Custom..."
---@type table<string, string>
local FONT_STACKS = {
["Verdana"] = "Verdana, Arial, Helvetica, sans-serif",
["Arial"] = "Arial, Helvetica, sans-serif",
["Tahoma"] = "Tahoma, Geneva, Verdana, sans-serif",
["Trebuchet MS"] = "Trebuchet MS, Tahoma, sans-serif",
["Georgia"] = "Georgia, Times New Roman, serif",
["Times New Roman"] = "Times New Roman, Times, serif",
["Palatino"] = "Palatino Linotype, Book Antiqua, Palatino, serif",
["Garamond"] = "Garamond, Times New Roman, serif",
["Courier New"] = "Courier New, Courier, monospace",
}
local FONT_LABELS = {
"Verdana",
"Arial",
"Tahoma",
"Trebuchet MS",
"Georgia",
"Times New Roman",
"Palatino",
"Garamond",
"Courier New",
FONT_CUSTOM_LABEL,
}
---Resolve the Font option (plus the custom text field) to a CSS font-family.
---@param fontLabel any
---@param custom any
---@return string
local function resolveFontFamily(fontLabel, custom)
if fontLabel == FONT_CUSTOM_LABEL then
custom = tostring(custom or "")
if custom:match("%S") then
return custom
end
end
return FONT_STACKS[tostring(fontLabel)] or FONT_STACKS.Verdana
end
--------------------------------------------------------------
--SAVED COLOUR THEMES
--------------------------------------------------------------
-- Saved themes live in the plugin INI: "Themes/_list" holds the names
-- ("|" separated, in saved order); "Themes/<name>" holds the colours as
-- "key=#rrggbb" pairs (";" separated). They are read directly (rather than
-- through Config) because the scheme list must be built before Config.new.
local THEME_CUSTOM_LABEL = "Custom (colours below)"
local THEME_COLOUR_KEYS = {
"background",
"textColour",
"lineColour",
"borderColour",
"linkColour",
"fillColour",
"fillM",
"fillF",
"fillU",
"focalFill",
"focalStroke",
-- Relationship-category fills, added after 1.0's initial release; an
-- older saved theme simply has none of these keys, and paletteFromColours
-- leaves the corresponding fields nil so completePalette falls back to
-- the Classic scheme's relationship colours (its usual missing-value
-- fallback) rather than erroring or drawing blank boxes.
"relParent",
"relSibling",
"relSpouse",
"relChild",
}
local configFilePath = fhGetPluginDataFileName(getConfigScope(), true)
.. "\\"
.. fhGetContextInfo("CI_PLUGIN_NAME")
.. ".ini"
---Read a text value from the plugin INI, tolerating a missing file.
---@param section string
---@param key string
---@param default string
---@return string
local function iniGetText(section, key, default)
local ok, result = pcall(fhGetIniFileValue, configFilePath, section, key, "text", default)
if ok and type(result) == "string" then
return result
end
return default
end
---The saved theme names, in saved order.
---@return string[]
local function savedThemeNames()
local names = {}
for name in iniGetText("Themes", "_list", ""):gmatch("[^|]+") do
names[#names + 1] = name
end
return names
end
---Build a renderer customPalette from colour-field values (raw "#rrggbb"
---strings keyed as in THEME_COLOUR_KEYS). Caption/tag text is derived from
---the text and background colours; anything missing degrades to Classic.
---@param colours table<string, any>
---@return table customPalette
local function paletteFromColours(colours)
local palette = {
background = colours.background,
text = colours.textColour,
line = colours.lineColour,
stroke = colours.borderColour,
link = colours.linkColour,
defaultFill = colours.fillColour,
focalFill = colours.focalFill,
focalStroke = colours.focalStroke,
sex = { M = colours.fillM, F = colours.fillF, U = colours.fillU },
relationship = { parent = colours.relParent, sibling = colours.relSibling, spouse = colours.relSpouse, child = colours.relChild },
}
if type(colours.textColour) == "string" and type(colours.background) == "string" then
local ok, mixed = pcall(FsOptions.mix, colours.textColour, colours.background, 0.35)
if ok then
palette.labelText = mixed
end
end
return palette
end
---Load a saved theme as a renderer customPalette, or nil when unknown.
---@param name string
---@return table|nil
local function loadSavedTheme(name)
local raw = iniGetText("Themes", name, "")
if raw == "" then
return nil
end
local colours = {}
for key, value in raw:gmatch("([A-Za-z0-9_]+)=([^;]+)") do
colours[key] = value
end
return paletteFromColours(colours)
end
---The Colour scheme list: built-in presets, saved themes, then Custom.
---@return string[]
local function schemeOptions()
local options = { "Classic", "High Contrast", "Match System Theme" }
for _, name in ipairs(savedThemeNames()) do
options[#options + 1] = name
end
options[#options + 1] = THEME_CUSTOM_LABEL
return options
end
---Resolve a scheme display name (built-in preset or saved theme) to a full
---palette, or nil for the Custom entry / unknown names.
---@param displayName string
---@return FsPalette|nil
local function schemePalette(displayName)
local key = PRESET_KEYS[displayName]
if key then
return FsOptions.palette(FsOptions.merge({ preset = key, systemColours = Theme.systemColours() }))
end
if displayName ~= THEME_CUSTOM_LABEL then
local saved = loadSavedTheme(tostring(displayName or ""))
if saved then
return FsOptions.completePalette(saved)
end
end
return nil
end
---Map a resolved palette onto the colour-field values.
---@param palette FsPalette
---@return table<string, string> values keyed by colour-field key
local function colourFieldValues(palette)
return {
background = palette.background,
textColour = palette.text,
lineColour = palette.line or palette.stroke,
borderColour = palette.stroke,
linkColour = palette.link,
fillColour = palette.defaultFill,
fillM = palette.sex.M,
fillF = palette.sex.F,
fillU = palette.sex.U,
focalFill = palette.focalFill,
focalStroke = palette.focalStroke,
relParent = palette.relationship.parent,
relSibling = palette.relationship.sibling,
relSpouse = palette.relationship.spouse,
relChild = palette.relationship.child,
}
end
---When the user picks a scheme or saved theme, load its colours into the
---colour fields; the chart always renders from those fields, so the choice
---previews immediately and any field can then be tweaked.
---@param displayName string
---@param api ConfigDialogApi
local function onSchemeChange(displayName, api)
local palette = schemePalette(displayName)
if not palette then
return -- Custom: keep the fields as they are
end
for key, value in pairs(colourFieldValues(palette)) do
api.setValue("Colours and Shapes", key, value)
end
end
---True if a record flag with the given name exists in the project. Uses
---fhGetFlagTag with create=false; pcall-guarded so it never errors a run.
---@param flagName string
---@return boolean
local function flagExists(flagName)
local ok, tag = pcall(fhGetFlagTag, flagName, false)
return ok and type(tag) == "string" and tag ~= ""
end
local defaultConfig = {
title = "Add Trees Options",
sections = {
{
title = "Chart",
fields = {
{
key = "layout",
label = "Layout:",
type = "list",
default = "Wide",
options = { "Wide", "Compact" },
description = "Wide: one row per generation, scrolling horizontally when needed. Compact: siblings and children wrap onto extra rows so the chart is much narrower.",
},
{
key = "parentFamilies",
label = "Parent families:",
type = "list",
default = "All parent families",
options = { "All parent families", "Birth families only", "First family only" },
description = "Which of the focus person's parent families are charted.",
},
{
key = "lifeDates",
label = "Include life dates:",
type = "boolean",
default = true,
description = "Show (birth-death) years after each name.",
},
{
key = "links",
label = "Clickable links:",
type = "boolean",
default = true,
description = "Link each charted relative to their own website page.",
},
{
key = "nodeSize",
label = "Box size:",
type = "number",
default = 140,
description = "Minimum box width in SVG pixels (40-600); boxes grow to fit long names.",
},
{
key = "fontSize",
label = "Font size:",
type = "number",
default = 12,
description = "Label size in SVG pixels (6-48); all spacing scales with it.",
},
{
key = "fontFamily",
label = "Font:",
type = "list",
default = "Verdana",
options = FONT_LABELS,
description = "Web-safe font for chart labels - themed to match the target website, not this machine.",
},
{
key = "fontCustom",
label = "Custom font:",
type = "text",
default = "",
description = 'Any CSS font-family list, used when Font is "Custom..." - e.g. Lato, Verdana, sans-serif',
},
{
key = "sizeMode",
label = "Size:",
type = "list",
default = "Scale to percentage",
options = { "Fit to page width", "Scale to percentage", "Exact width" },
description = "How the chart is sized. Fit to page width fills the container (embedded charts only). Scale to percentage uses Image size. Exact width sets a fixed pixel width with proportional height - handy for reports.",
},
{
key = "imageScale",
label = "Image size (%):",
type = "number",
default = 100,
description = "Scales the finished image (25-400) when Size is 'Scale to percentage'. 100 is the natural size set by the box and font sizes.",
},
{
key = "exactWidth",
label = "Exact width (px):",
type = "number",
default = 800,
description = "Width in pixels when Size is 'Exact width' (100-4000); the height follows the chart's proportions.",
},
},
},
{
title = "Colours and Shapes",
fields = {
{
key = "preset",
label = "Colour scheme:",
type = "list",
default = "Classic",
options = schemeOptions(),
liveChange = onSchemeChange,
description = "Picking a scheme or saved theme loads its colours into the fields below; the chart always uses those fields, so you can tweak any of them. Match System Theme loads your Windows colours.",
},
{
key = "colourBy",
label = "Colour indicates:",
type = "list",
default = "Theme (uniform)",
options = { "Theme (uniform)", "Sex", "Relationship" },
description = "What the box fill colour encodes.",
},
{
key = "shapeBy",
label = "Shape indicates:",
type = "list",
default = "Theme (uniform)",
options = { "Theme (uniform)", "Sex", "Relationship" },
description = "What the box shape encodes. Shape by sex is the colour-blind-safe alternative to colour by sex.",
},
{
key = "baseShape",
label = "Box shape:",
type = "list",
default = "Rounded",
options = { "Rounded", "Rectangle", "Ellipse", "Octagon" },
description = "Box outline used when Shape indicates is Theme (uniform).",
},
-- The chart always renders from the colours below, whichever scheme is chosen -
-- picking a scheme or saved theme just loads its colours into these fields, so
-- any of them can then be tweaked (and captured again by Save Colours as Theme).
{
key = "lineColour",
label = "Lines:",
type = "colour",
default = "#4a6785",
description = "Connector line colour.",
},
{
key = "borderColour",
label = "Box borders:",
type = "colour",
default = "#4a6785",
description = "Box border colour.",
},
{
key = "fillColour",
label = "Box fill:",
type = "colour",
default = "#e7eef5",
description = "Box fill when Colour indicates is Theme (uniform).",
},
{
key = "fillM",
label = "Box fill (male):",
type = "colour",
default = "#cfe3f5",
description = "Box fill for men when Colour indicates is Sex.",
},
{
key = "fillF",
label = "Box fill (female):",
type = "colour",
default = "#fbe4ec",
description = "Box fill for women when Colour indicates is Sex.",
},
{
key = "fillU",
label = "Box fill (unknown):",
type = "colour",
default = "#e8e8e8",
description = "Box fill for unknown sex when Colour indicates is Sex.",
},
{
key = "relParent",
label = "Box fill (parents):",
type = "colour",
default = "#d9e7f5",
description = "Box fill for parents when Colour indicates is Relationship.",
},
{
key = "relSibling",
label = "Box fill (siblings):",
type = "colour",
default = "#e4f0e2",
description = "Box fill for siblings when Colour indicates is Relationship.",
},
{
key = "relSpouse",
label = "Box fill (spouses/partners):",
type = "colour",
default = "#f5ecd9",
description = "Box fill for spouses/partners when Colour indicates is Relationship.",
},
{
key = "relChild",
label = "Box fill (children):",
type = "colour",
default = "#ece2f5",
description = "Box fill for children when Colour indicates is Relationship.",
},
{
key = "textColour",
label = "Box text:",
type = "colour",
default = "#1a1a1a",
description = "Label text colour.",
},
{
key = "linkColour",
label = "Link text:",
type = "colour",
default = "#1d4ed8",
description = "Text colour of clickable names.",
},
{
key = "focalFill",
label = "Focus box fill:",
type = "colour",
default = "#ffe9a8",
description = "The focus person's box fill, so they stand out.",
},
{
key = "focalStroke",
label = "Focus box border:",
type = "colour",
default = "#8a6d1f",
description = "The focus person's box border.",
},
{
key = "background",
label = "Background:",
type = "colour",
default = "#ffffff",
description = "Chart background colour.",
},
},
},
{
title = "Privacy",
fields = {
{
key = "omitPrivate",
label = "Omit individuals flagged 'Private':",
type = "boolean",
default = true,
description = "Leave out anyone whose record carries Family Historian's 'Private' flag - no name, no box, no reference anywhere in the chart. Mirrors FH's own website privacy.",
},
{
key = "basicDetailsEnabled",
label = "Basic details only:",
type = "boolean",
default = true,
description = "For individuals carrying the flag below, show names and relationships only - no life dates. (FH's 'basic details' also drops events and attributes, which this chart never shows anyway.)",
},
{
key = "basicDetailsFlag",
label = "...for this flag:",
type = "text",
default = "Living",
description = "The exact name of a record flag in your project (e.g. Living). People with this flag show basic details only. You are warned at once if you type a flag that does not exist in this project.",
onBlur = function(value)
value = tostring(value or "")
if value ~= "" and not flagExists(value) then
MessageBox(
"warning",
"There is no record flag called '"
.. value
.. "' in this project, so basic-details privacy will not apply to anyone. Check the name (Edit > Record Flags)."
)
end
end,
},
},
},
{
title = "Output",
fields = {
{
key = "outputFolder",
label = "SVG output folder:",
type = "folder",
default = cstrDefaultOutputFolder,
root = function()
return cstrProjectPublicFolder
end,
rootLabel = "Project Public Folder",
description = "One ind<id>.svg file is written here per charted individual.",
},
{
key = "embed",
label = "Embed in website:",
type = "boolean",
default = false,
description = "Also inject each chart inline into ind<id>.html in the website folder.",
},
{
key = "websiteFolder",
label = "Website folder:",
type = "folder",
default = "",
root = function()
return cstrProjectPublicFolder
end,
rootLabel = "Project Public Folder",
description = "The folder holding the Family Historian generated website pages (ind<id>.html).",
},
{
key = "embedAlign",
label = "Alignment:",
type = "list",
default = "Centre",
options = { "Centre", "Left", "Right" },
description = "How the chart sits within the page container.",
},
{
key = "embedCaption",
label = "Caption:",
type = "text",
default = "",
description = "Optional heading shown above the embedded chart (e.g. \"See also\"). Leave blank for none.",
},
{
key = "embedHideable",
label = "Collapsible chart:",
type = "boolean",
default = false,
description = "Wrap the embedded chart in a no-JavaScript expander (HTML <details>). It starts open; readers can collapse it. The Caption becomes the expander's label.",
},
},
},
{
title = "Advanced",
fields = {
{
key = "pagePrefix",
label = "Individual page prefix:",
type = "text",
default = "ind",
description = "The filename prefix your website gives each individual's page. A standard Family Historian website uses 'ind' (so ind123.html); some site generators use a different prefix such as 'indi'. Used for the chart's clickable links and, when embedding, to find each person's page.",
},
{
key = "embedDivClass",
label = "Target div class:",
type = "text",
default = "FhSeeAlso",
description = "The class of the page container the chart is injected into. Leave as FhSeeAlso unless your website template uses a different one.",
},
{
key = "embedPlacement",
label = "Placement:",
type = "list",
default = "Replace div contents",
options = { "Replace div contents", "At top of div", "At bottom of div" },
description = "Replace the div's contents with the chart, or keep other content and add the chart at the top or bottom.",
},
{
key = "embedRemoveDiv",
label = "Remove a section:",
type = "boolean",
default = false,
description = "Before embedding, delete a section from the page (e.g. Family Historian's own \"See also\" box) so the chart can sit in a different div. Set Target div class to that other div.",
},
{
key = "embedRemoveDivClass",
label = "Section to remove:",
type = "text",
default = "FhSeeAlso",
description = "The class of the section deleted when 'Remove a section' is on. Ignored if it matches the Target div class.",
},
{
key = "embedContainerClass",
label = "Chart CSS class:",
type = "text",
default = "fs-embed-chart",
description = "CSS class put on the chart's wrapper div, so you can style it (borders, spacing, background) in your website's stylesheet. Alignment is still set by the Alignment option.",
},
{
key = "embedCaptionClass",
label = "Caption CSS class:",
type = "text",
default = "fs-embed-caption",
description = "The CSS class given to the caption. Style it (font, size, colour, alignment) in your website's stylesheet; the plugin only applies the class.",
},
},
},
},
}
-- Per-project options; for a standalone GEDCOM (no project) the scope falls
-- back to the per-machine folder so options still persist somewhere sane.
local myConfig = Config.new(defaultConfig, getConfigScope(), fhGetContextInfo("CI_PLUGIN_NAME") .. ".ini")
-- One-off migration to the fields-are-the-palette model: settings saved by
-- earlier versions hold a scheme name but no meaningful colour fields, so
-- seed the fields from that scheme once. (On a fresh install this writes
-- the Classic values the fields already default to.)
if myConfig:getValue("Colours and Shapes", "customColoursInitialised", false) ~= true then
local savedScheme = myConfig:getValue("Colours and Shapes", "preset", "Classic")
local palette = schemePalette(tostring(savedScheme))
if palette then
myConfig:setValues("Colours and Shapes", nil, colourFieldValues(palette))
end
myConfig:setValues("Colours and Shapes", nil, { customColoursInitialised = true })
end
-- A second one-off migration: relationship-colour fields (relParent etc.)
-- were added after the fields-are-the-palette migration above, so anyone
-- who already ran it has none of these keys yet. Seed them from the
-- currently-selected scheme once, exactly as the first migration did for
-- the original colour fields, so upgrading does not silently start
-- rendering relationship colours from Classic regardless of scheme.
if myConfig:getValue("Colours and Shapes", "relationshipColoursInitialised", false) ~= true then
local savedScheme = myConfig:getValue("Colours and Shapes", "preset", "Classic")
local palette = schemePalette(tostring(savedScheme))
if palette then
myConfig:setValues("Colours and Shapes", nil, {
relParent = palette.relationship.parent,
relSibling = palette.relationship.sibling,
relSpouse = palette.relationship.spouse,
relChild = palette.relationship.child,
})
end
myConfig:setValues("Colours and Shapes", nil, { relationshipColoursInitialised = true })
end
-- A third one-off migration: High Contrast's link colour changed from the
-- Okabe-Ito blue (#0072b2, too close in luminance to that scheme's own
-- fills) to black. Anyone with High Contrast selected and still holding
-- exactly the old blue value gets it swapped for the new default once;
-- anyone who has since customised their link colour is left alone.
if myConfig:getValue("Colours and Shapes", "highContrastLinkColourMigrated", false) ~= true then
local savedScheme = myConfig:getValue("Colours and Shapes", "preset", "Classic")
if tostring(savedScheme) == "High Contrast"
and myConfig:getValue("Colours and Shapes", "linkColour", "") == "#0072b2" then
myConfig:setValues("Colours and Shapes", nil, { linkColour = "#000000" })
end
myConfig:setValues("Colours and Shapes", nil, { highContrastLinkColourMigrated = true })
end
-- A fourth one-off migration: High Contrast's siblings fill changed from #009e73 (6.14:1 contrast
-- with black text - WCAG AA only) to #00aa7b, a lighter tint of the same bluish-green hue that
-- reaches 7.0:1 (WCAG AAA), matching every other High Contrast fill. Anyone with High Contrast
-- selected and still holding exactly the old value gets it swapped once; anyone who has since
-- customised their siblings colour is left alone. A separate flag from the link-colour migration
-- above, since a machine may already have run that one without having this fix.
if myConfig:getValue("Colours and Shapes", "highContrastSiblingsColourMigrated", false) ~= true then
local savedScheme = myConfig:getValue("Colours and Shapes", "preset", "Classic")
if tostring(savedScheme) == "High Contrast"
and myConfig:getValue("Colours and Shapes", "relSibling", "") == "#009e73" then
myConfig:setValues("Colours and Shapes", nil, { relSibling = "#00aa7b" })
end
myConfig:setValues("Colours and Shapes", nil, { highContrastSiblingsColourMigrated = true })
end
---Convert raw option values (saved or live from the dialog) into the
---renderer's option table plus the plugin-level output settings.
---@param raw table<string, table<string, any>> Values keyed by section title then field key.
---@return FsRenderOptions renderOptions
---@return {folder: string, embed: boolean, websiteFolder: string, pagePrefix: string, placement: string, align: string, fit: boolean, divClass: string, caption: string, captionClass: string, containerClass: string, hideable: boolean, removeDiv: boolean, removeDivClass: string, privacy: {omit: boolean, basicFlag: string?}} output
local EMBED_PLACEMENT_KEYS = {
["Replace div contents"] = "replace",
["At top of div"] = "top",
["At bottom of div"] = "bottom",
}
local EMBED_ALIGN_KEYS = { ["Centre"] = "centre", ["Left"] = "left", ["Right"] = "right" }
local EMBED_SIZE_KEYS = { ["Scale to percentage"] = "scale", ["Fit to page width"] = "fit", ["Exact width"] = "exact" }
local function toRenderOptions(raw)
local chart = raw["Chart"] or {}
local colours = raw["Colours and Shapes"] or {}
local output = raw["Output"] or {}
local advanced = raw["Advanced"] or {}
local privacySection = raw["Privacy"] or {}
local sizeMode = EMBED_SIZE_KEYS[chart.sizeMode] or "scale"
-- Page-name prefix for individual website pages (e.g. ind123.html, or
-- indi123.html for some site generators); used by chart links and embedding.
local pagePrefix = (tostring(advanced.pagePrefix or ""):gsub("[^A-Za-z0-9_%-]", ""))
if pagePrefix == "" then
pagePrefix = "ind"
end
-- Privacy options for the adapter: omit-Private, and the basic-details flag
-- (only when the toggle is on and a non-blank flag name is given).
local privacyOpts = { omit = privacySection.omitPrivate == true }
if privacySection.basicDetailsEnabled == true then
local flag = tostring(privacySection.basicDetailsFlag or "")
if flag:match("%S") then
privacyOpts.basicFlag = flag
end
end
-- The chart always renders from the colour fields ("custom" palette);
-- the scheme dropdown's job is loading a preset or saved theme INTO
-- those fields (see onSchemeChange), so any colour the user picks takes
-- effect immediately, no theme-saving required.
local renderOptions = {
preset = "custom",
customPalette = paletteFromColours(colours),
colourBy = ENCODING_KEYS[colours.colourBy] or "theme",
shapeBy = ENCODING_KEYS[colours.shapeBy] or "theme",
baseShape = SHAPE_KEYS[colours.baseShape] or "rounded",
nodeSize = tonumber(chart.nodeSize),
fontSize = tonumber(chart.fontSize),
imageScale = tonumber(chart.imageScale),
fontFamily = resolveFontFamily(chart.fontFamily, chart.fontCustom),
pagePrefix = pagePrefix,
layout = LAYOUT_KEYS[chart.layout] or "wide",
parentFamilies = PARENT_FAMILY_KEYS[chart.parentFamilies] or "all",
links = chart.links,
lifeDates = chart.lifeDates,
sizeMode = sizeMode,
exactWidth = tonumber(chart.exactWidth),
}
return renderOptions, {
folder = tostring(output.outputFolder or ""),
embed = output.embed == true,
websiteFolder = tostring(output.websiteFolder or ""),
pagePrefix = pagePrefix,
-- Embedding presentation options (see html_inject FsEmbedOptions).
placement = EMBED_PLACEMENT_KEYS[advanced.embedPlacement] or "replace",
align = EMBED_ALIGN_KEYS[output.embedAlign] or "centre",
fit = sizeMode == "fit",
divClass = (tostring(advanced.embedDivClass or ""):match("%S") and tostring(advanced.embedDivClass)) or "FhSeeAlso",
caption = tostring(output.embedCaption or ""),
captionClass = (tostring(advanced.embedCaptionClass or ""):match("%S") and tostring(advanced.embedCaptionClass))
or "fs-embed-caption",
containerClass = (tostring(advanced.embedContainerClass or ""):match("%S") and tostring(advanced.embedContainerClass))
or "fs-embed-chart",
hideable = output.embedHideable == true,
removeDiv = advanced.embedRemoveDiv == true,
removeDivClass = (tostring(advanced.embedRemoveDivClass or ""):match("%S") and tostring(advanced.embedRemoveDivClass))
or "FhSeeAlso",
privacy = privacyOpts,
}
end
---Save the dialog's current colour fields as a named theme in the plugin
---INI, and refresh the open dialog's Colour scheme list so the new theme
---appears (selected) straight away.
---@param values table<string, table<string, any>> Live dialog values.
---@param api ConfigDialogApi Live-dialog API for the in-place list refresh.
local function saveColoursAsTheme(values, api)
local colours = values["Colours and Shapes"] or {}
local ok, name = GetText({
strPrompt = "Save Colours as Theme",
strLabel = "Theme name:",
strDefault = "",
})
if not ok then
return
end
-- The name becomes an INI key and a list entry, so keep it plain.
name = tostring(name or ""):gsub("[|;=%[%]]", "-"):gsub("^%s+", ""):gsub("%s+$", "")
if name == "" then
return
end
if PRESET_KEYS[name] or name == THEME_CUSTOM_LABEL or name == "_list" then
MessageBox("warning", 'The name "' .. name .. '" is reserved. Choose another name.')
return
end
local parts = {}
for _, key in ipairs(THEME_COLOUR_KEYS) do
parts[#parts + 1] = key .. "=" .. tostring(colours[key] or "")
end
local existing = savedThemeNames()
local known = false
for _, themeName in ipairs(existing) do
if themeName == name then
known = true
break
end
end
if known and MessageBox("question", 'Theme "' .. name .. '" already exists. Replace it?', "YESNO") ~= "Yes" then
return
end
local okWrite = pcall(fhSetIniFileValue, configFilePath, "Themes", name, "text", table.concat(parts, ";"))
if okWrite and not known then
existing[#existing + 1] = name
okWrite = pcall(fhSetIniFileValue, configFilePath, "Themes", "_list", "text", table.concat(existing, "|"))
end
if not okWrite then
MessageBox("error", "Could not save the theme to:\n" .. configFilePath)
return
end
-- Refresh the scheme list in place and select the new theme.
api.setListOptions("Colours and Shapes", "preset", schemeOptions(), name)
end
---Delete the saved theme currently chosen in the Colour scheme list. Built-in
---presets and the Custom entry cannot be deleted.
---@param values table<string, table<string, any>> Live dialog values.
---@param api ConfigDialogApi Live-dialog API for the in-place list refresh.
local function deleteTheme(values, api)
local colours = values["Colours and Shapes"] or {}
local name = tostring(colours.preset or "")
if name == "" or PRESET_KEYS[name] or name == THEME_CUSTOM_LABEL then
MessageBox(
"info",
"To delete a theme, choose it in the Colour scheme list first.\n\n"
.. "Built-in schemes and Custom cannot be deleted."
)
return
end
if MessageBox("question", 'Delete the saved theme "' .. name .. '"?', "YESNO") ~= "Yes" then
return
end
-- Drop it from the name list and clear its stored value.
local kept = {}
for _, themeName in ipairs(savedThemeNames()) do
if themeName ~= name then
kept[#kept + 1] = themeName
end
end
local ok = pcall(fhSetIniFileValue, configFilePath, "Themes", "_list", "text", table.concat(kept, "|"))
pcall(fhSetIniFileValue, configFilePath, "Themes", name, "text", "")
if not ok then
MessageBox("error", "Could not update the themes in:\n" .. configFilePath)
return
end
-- Refresh the list (the theme is gone) and fall back to Classic, loading
-- its colours into the fields so the dialog is consistent.
api.setListOptions("Colours and Shapes", "preset", schemeOptions(), "Classic")
onSchemeChange("Classic", api)
end
---Read the saved configuration as raw section/key values.
---@return table<string, table<string, any>>
local function savedRawValues()
local raw = {}
for _, section in ipairs(defaultConfig.sections) do
raw[section.title] = {}
for _, field in ipairs(section.fields) do
raw[section.title][field.key] = myConfig:getValue(section.title, field.key, field.default)
end
end
return raw
end
--<<FS_SPLICE "../../2 Boilerplate/Results.lua">>
;(function()
--[[
Results.lua - shared Results class for an FH plugin's end-of-run outcome
@Author: Helen Wright
@Version: 1.1
@LastUpdated: 23 July 2026
@V1.1: NoResults's message is now suppressible - NoResults(nil) (or "") makes Display()
show nothing on zero rows instead of an "informational" fhMessageBox. A consumer
that never calls NoResults, or that sets a string as before, is unaffected: the
default message is unchanged and the box only disappears when a plugin opts in
with a nil/empty message. Family-wide UX policy (23 Jul 2026 beta discussion): an
idle close (no work done) should be silent - the old "No X were Y"-style farewell
boxes are noise now that the run's outcome lands in FH's Result Set window anyway.
]]
---Results class for managing the results display within an FH plugin
---@param intTableCount integer Number of columns in the results table
---@return table Results object with methods for managing result display
function Results(intTableCount)
---Local shallow copy helper - creates a copy of a table without deep copying nested structures
---This is used to safely copy configuration arrays without creating references
---@param tbl table Table to copy
---@return table Shallow copy of the input table
local function shallow_copy(tbl)
local t = {}
for k, v in pairs(tbl) do
t[k] = v
end
return t
end
--public methods and associated private state variables
local iRes = 0 -- index used to track results - counts how many result rows we have
local strTitle = "" -- stores the title for the results window
local strNoResults = "" -- stores the message to display when there are no results
local tblResults = {} --table of results tables - stores all the data for each column
local tblVisibility = {} -- controls which columns are visible in the results
local tblSort = {} -- defines the sort order for each column
local tblResultHeadings = {} -- stores the column headers
local tblResultType = {} -- defines the data type for each column (text, integer, item, etc.)
local tblResultWidth = {} -- defines the width for each column
-- Initialize the results tables - each table will hold one column of data
-- This creates separate arrays for each column to store the data efficiently
for i = 1, intTableCount do
tblResults[i] = {}
end
---Update function: adds a new row of results to the display
---tblNewResults should contain one value for each column
---This is the main method for adding data to the results
---@param tblNewResults table Array of values for the new row
local Update = function(tblNewResults)
iRes = iRes + 1 -- increment the result counter
for i, v in ipairs(tblNewResults) do
tblResults[i][iRes] = v -- store each value in its appropriate column
end
end
---Title function: sets the title for the results window
---This will be displayed at the top of the results window
---@param str string Title for the results window
local Title = function(str)
strTitle = str
end
---NoResults function: sets the message to display when there are no results
---This provides user feedback when no links are found
---@param str string Message to display when there are no results
local NoResults = function(str)
strNoResults = str
end
---Types function: defines the data type for each column
---Types can be: "text", "integer", "item", "date", etc.
---This affects how FH displays and sorts the data
---@param types table Array of data types for each column
local Types = function(types)
tblResultType = shallow_copy(types)
end
---Headings function: sets the column headers
---These are the labels that appear at the top of each column
---@param headings table Array of column header strings
local Headings = function(headings)
tblResultHeadings = shallow_copy(headings)
end
---Visibility function: controls which columns are shown
---Values can be "show" or "hide"
---"buddy" makes a column invisible but keeps it for sorting purposes
---@param visibility table Array of visibility settings for each column
local Visibility = function(visibility)
tblVisibility = shallow_copy(visibility)
end
---Sort function: defines the sort order for each column
---Lower numbers = higher priority in sorting
---This determines the default sort order when results are displayed
---@param sort table Array of sort priorities for each column
local Sort = function(sort)
tblSort = shallow_copy(sort)
end
---Width function: defines the width for each column
---@param width table Array of widths for each column
local Width = function(width)
tblResultWidth = shallow_copy(width)
end
---Display function: outputs all collected results to Family Historian's result window
---This is the final step that shows all the collected data to the user
local Display = function()
if iRes > 0 then -- there are results to display
-- Set the window title
fhOutputResultSetTitles(strTitle)
-- Output each column with its configuration
-- This creates the actual result set in FH's display
for i, _ in ipairs(tblResults) do
fhOutputResultSetColumn(
tblResultHeadings[i], -- Column header
tblResultType[i], -- Data type
tblResults[i], -- The data for this column
iRes, -- Number of rows
tblResultWidth[i] or 80, -- Column width or 80 if not set
"align_left", -- Text alignment
tblSort[i] or 0, -- Sort priority (0 = not part of the initial sort; nil crashes fhOutputResultSetColumn)
true, -- Sortable
"default", -- Sort direction
tblVisibility[i] -- Visibility setting
)
end
-- Update the display to show the results
fhUpdateDisplay()
elseif strNoResults ~= nil and strNoResults ~= "" then
-- No results found - show informational message (suppressed if NoResults(nil)/(""))
fhMessageBox(strNoResults, "MB_OK", "MB_ICONINFORMATION")
end
-- else: zero rows and no message set - silent idle close (NoResults(nil) opt-in)
end
--expose public methods - return an object with all the public functions
-- This creates the public interface for the Results class
return {
Title = Title,
Headings = Headings,
Visibility = Visibility,
Types = Types,
Update = Update,
Display = Display,
Sort = Sort,
NoResults = NoResults,
Width = Width,
}
end
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE "../../2 Boilerplate/MenuBar.lua">>
;(function()
--[[
Enhanced MenuBar Helper Function
Creates an IUP menu bar with flexible File menu and standard Help menu.
The File menu accepts any custom items plus automatically adds Cancel/Exit.
Manages visibility of UI elements associated with menu items.
Includes dynamic state management, menu item registries, and title updates.
@Author: Helen Wright (ColeValleyGirl)
@Version: 2.1
@LastUpdated: 24 September 2026
@Description: Enhanced helper function for creating IUP menu bars with dynamic state management
VERSION HISTORY:
- 2.1 (24 September 2026): Added MenuBar.helpers.closeDialog to work around the FH host
swallowing a MENU callback's returned iup.CLOSE on a MainLoop-shown dialog; the automatic
File > Exit item now uses it instead of `return iup.CLOSE` (see USAGE note 7 below).
USAGE EXAMPLES:
1. Basic Menu Creation:
local menuBarData = MenuBar.createMenuBar({
items = {
MenuBar.helpers.createMenuItem("&Options", function(self)
showOptions()
return iup.DEFAULT
end)
}
})
local dialog = makeDialog(content, { menu = menuBarData.menuBar })
2. Dynamic Menu Updates:
-- Update menu item title
menuBarData.updateTitle("myMenuItem", "New Title")
-- Update menu item value (for checkable items)
menuBarData.updateValue("myCheckItem", "ON")
-- Update menu item active state
menuBarData.updateActive("myMenuItem", "NO")
-- Update all items in a registry group
menuBarData.updateRegistryGroup("targets", function(key, menuItem)
menuItem.active = isSupported(key) and "YES" or "NO"
end)
3. Registry System:
local menuBarData = MenuBar.createMenuBar({
registries = {
targets = {}, -- Group for target menu items
modes = {}, -- Group for mode menu items
noteTypes = {} -- Group for note type menu items
},
items = {
MenuBar.helpers.createSubmenu("&Targets", {
MenuBar.helpers.createMenuItem("Select: &Individuals",
function(self) selectTargets("INDI") end,
nil, "indiTarget") -- registryKey
}, "targetsMenu")
}
})
4. Radio Button Groups:
MenuBar.helpers.createRadioMenuItem("&Option 1", currentValue, "option1",
function() return changeValue("option1") end, nil, "radioOption1")
5. File Menu with Custom Items:
Keys are iterated in sorted order, so choose key names that sort into
the menu order you want (e.g. "aSave" before "bExport"):
fileMenu = {
aSave = MenuBar.helpers.createMenuItem("&Save", function(self)
saveData()
return iup.DEFAULT
end, nil, "menuSave"),
bExport = MenuBar.helpers.createMenuItem("&Export", function(self)
exportData()
return iup.DEFAULT
end, nil, "menuExport")
}
6. Keyboard Accelerators (shortcuts that fire from anywhere in the window):
Pass createMenuItem an `accel = { key = <iup key code>, text = "<display>" }` (5th arg):
- `text` adds the shortcut to the menu, right-aligned (e.g. "Save Ctrl+S").
- `key` (e.g. iup.K_cS, iup.K_F5) ALSO binds it window-globally.
- Give `text` only (omit `key`) to DISPLAY a shortcut that stays bound elsewhere
(e.g. Del handled by a list's own k_any - so it doesn't fire while editing a field).
createMenuBar collects the bound ones into menuBarData.accelerators; hand that to makeDialog,
which dispatches them from the dialog's k_any:
local mb = MenuBar.createMenuBar({ items = {
MenuBar.helpers.createSubmenu("&Edit", {
MenuBar.helpers.createMenuItem("&Save", onSave, nil, "save", { key = iup.K_cS, text = "Ctrl+S" }),
MenuBar.helpers.createMenuItem("&Delete", onDelete, nil, nil, { text = "Del" }), -- display only
}),
}})
local dlg = makeDialog(content, { menu = mb.menuBar, accelerators = mb.accelerators })
updateTitle re-appends the accelerator text automatically, so a dynamic title keeps its shortcut.
Caveat: one key auto-binds to one action. If the SAME shortcut appears on two items with different
actions (e.g. a "select" that means different things per view), give both items `text` only and pass
your own view-aware { key, action } table as makeDialog's accelerators (see Add Maps' mainAccelerators).
7. Closing a Dialog from a Menu Action:
Family Historian's host swallows a MENU callback's `return iup.CLOSE` on a dialog shown via
showxy/showTrackedDialog + iup.MainLoop (the title-bar X, Esc and buttons are unaffected -
they go through IUP's own machinery, not a menu action). MenuBar's own automatic File > Exit
item accounts for this already, but ANY of your own menu actions that need to close such a
dialog must do the same - call MenuBar.helpers.closeDialog(self) instead of `return
iup.CLOSE`. This does not apply to a dialog shown with popup() (Options-style dialogs), where
`return iup.CLOSE` from a menu action works as normal - though closeDialog is safe there too:
MenuBar.helpers.createMenuItem("E&xit", function(self)
return MenuBar.helpers.closeDialog(self)
end)
REGISTRY SYSTEM:
- Use registryKey to register menu items for dynamic updates
- Use registryGroup to organize related menu items
- Access registered items via menuBarData.menuItems[key]
- Update groups via menuBarData.updateRegistryGroup(groupKey, updateFunction)
DYNAMIC UPDATES:
- updateTitle(key, newTitle): Change menu item title
- updateValue(key, "ON"/"OFF"): Update checkable item state
- updateActive(key, "YES"/"NO"): Enable/disable menu item
- updateRegistryGroup(groupKey, updateFunction): Batch update group items
HELPER FUNCTIONS:
- MenuBar.helpers.createMenuItem(title, action, capture, registryKey, accel) -- accel = { key?, text? } (see 6 above)
- MenuBar.helpers.createRadioMenuItem(title, currentValue, expectedValue, action, capture, registryKey)
- MenuBar.helpers.createSubmenu(title, items, registryKey)
- MenuBar.helpers.closeDialog(self_or_dialog) -- close a MainLoop-shown dialog from a menu action (see 7 above)
RETURNS (menuBarData): { menuBar, menuItems, registries, accelerators, updateTitle, updateValue,
updateActive, updateRegistryGroup }. Pass `accelerators` to makeDialog's `options.accelerators`.
]]
---@class menuItem
---@field title string Menu item title (include & for mnemonic key e.g. "&File" for Alt+F)
---@field action? function Callback function when menu item is selected (receives self, returns iup.DEFAULT or iup.CLOSE)
---@field active? string "YES" or "NO" - whether the item is enabled
---@field value? string "ON" or "OFF" for checkable items; defaults to "OFF"
---@field ui? iup.element UI element to show when menu item is selected
---@field isPopup? boolean If true, show UI element as popup instead of embedded
---@field submenu? menuItem[] Submenu items if this is a submenu
---@field beforeUI? boolean Execute action before (true) or after (false) showing UI element (defaults to false)
---@field capture? fun(instance:iup.element) Optional callback to capture the created iup.item/submenu instance
---@field registryKey? string Optional key for registering this menu item for dynamic updates
---@field registryGroup? string Optional group key for organizing menu items in registries
---@field titleFunction? function Optional function to compute dynamic title
---@field stateFunction? function Optional function to compute dynamic state (active/value)
---@field accel? {key?: integer, text?: string} Accelerator: `text` shows the shortcut in the menu; `key` (an iup key code) also binds it window-globally (via makeDialog's options.accelerators)
--- Mnemonics: use '&' in titles (e.g., "&File")
---@class menuBarOptions
---@field items? menuItem[] Additional menu items to insert between File and Help menus
---@field fileMenu? table<string, menuItem> Custom File menu items (keys iterated in sorted order - name them to sort into the menu order you want; a "cancel" or "exit" key suppresses the automatic Exit item)
---@field helpMenu? {help?: menuItem, about?: menuItem} Customizations for Help menu items
---@field registries? table<string, table> Optional registries for grouping menu items (e.g., {targets = {}, modes = {}})
---@class MenuBarData
---@field updateTitle fun(key: string, title: string)
---@field updateValue fun(key: string, value: string)
---@field updateActive fun(key: string, active: string)
---@field updateRegistryGroup fun(groupKey: string, updateFunction: function)
---@field menuBar any
---@field menuItems table
---@field registries table
---@field accelerators {key: integer, action: function, item: any}[] Window-global accelerators; pass to makeDialog as options.accelerators
local M = {}
--- Creates a menu bar with flexible File menu and standard Help menu
---@param options menuBarOptions Configuration for the menu bar
---@return table Created menu bar with enhanced functionality
M.createMenuBar = function(options)
-- Local helper function to find an element in a table
local function findInTable(tbl, value)
for _, v in ipairs(tbl) do
if v == value then
return true
end
end
return false
end
options = options or {}
-- Track UI elements for visibility management
local allUIElements = {}
-- Menu item registries for dynamic updates
local menuRegistries = options.registries or {}
local menuItems = {} -- Store all menu items for dynamic updates
-- Window-global accelerators declared via item.accel = { key = <iup key code>, text = "Ctrl+I" }.
-- Each { key, action } is collected here and returned as menuBarData.accelerators for the caller to
-- hand to makeDialog (options.accelerators), which dispatches them from the dialog's k_any so the
-- shortcut fires from anywhere in the window - not only when a particular control has focus. An accel
-- with text but NO key shows the shortcut in the menu without binding it (e.g. Del kept list-scoped).
local accelerators = {}
-- Accel display text per registryKey, so updateTitle can re-append "\tCtrl+I" when a dynamic title changes.
local accelTextByKey = {}
-- Compose a menu title with its accelerator display text, e.g. "&Clear List" + "Ctrl+T" -> "&Clear List\tCtrl+T".
local function titleWithAccel(title, accel)
if accel and accel.text and accel.text ~= "" then
return title .. "\t" .. accel.text
end
return title
end
--- Controls visibility and floating state of UI elements
---@param uiElements table<number,iup.element> List of UI elements to manage
---@param activeElement iup.element|nil Element to make visible, or nil to hide all
local function manageUIVisibility(uiElements, activeElement)
-- Update visibility and floating state of all elements
for _, element in pairs(uiElements) do
if element == activeElement then
element.visible = "YES"
element.floating = "NO" -- Place in normal layout flow
else
element.visible = "NO"
element.floating = "YES" -- Ready for future display
end
end
-- Refresh dialog to update layout
if activeElement then
local dialog = iup.GetDialog(activeElement)
if dialog then
iup.Refresh(dialog)
end
end
end
--- Creates a menu item with UI handling
---@param item menuItem The menu item configuration
---@return table IUP menu item configuration
local function createMenuItem(item)
if not item.title then
return {} -- separator
end
-- Handle submenu
if item.submenu then
local submenuItems = {}
for _, subItem in ipairs(item.submenu) do
table.insert(submenuItems, createMenuItem(subItem))
end
local submenu = iup.submenu({
iup.menu(submenuItems),
title = item.title,
active = item.active,
})
if item.capture then
item.capture(submenu)
end
-- Register submenu if registry key provided
if item.registryKey then
menuItems[item.registryKey] = submenu
end
return submenu
end
-- Create regular menu item. The accelerator display text (if any) is appended to the title so it
-- shows right-aligned in the menu (IUP splits on the tab).
local menuItem = iup.item({
title = titleWithAccel(item.title, item.accel),
active = item.active,
value = item.value,
})
if item.capture then
item.capture(menuItem)
end
-- Register menu item if registry key provided
if item.registryKey then
menuItems[item.registryKey] = menuItem
-- Remember its accel text so updateTitle can re-append it when the title changes dynamically.
if item.accel and item.accel.text then
accelTextByKey[item.registryKey] = item.accel.text
end
-- Also add to specific registry if provided
if item.registryGroup and menuRegistries[item.registryGroup] then
menuRegistries[item.registryGroup][item.registryKey] = menuItem
end
end
-- Handle action and UI
if item.action or item.ui then
menuItem.action = function(self)
local result = iup.DEFAULT
-- Execute pre-UI action if specified
if item.action and item.beforeUI then
result = item.action(self)
if result == iup.CLOSE then
return result
elseif result == iup.IGNORE then
return result
end
end
-- Handle UI element display
if item.ui then
if item.isPopup then
-- Show as popup (modal by default)
item.ui.floating = "YES"
item.ui:popup()
else
-- Show embedded
if not findInTable(allUIElements, item.ui) then
table.insert(allUIElements, item.ui)
end
manageUIVisibility(allUIElements, item.ui)
end
end
-- Execute post-UI action (default behavior)
if item.action and not item.beforeUI then
result = item.action(self)
end
return result
end
end
-- Register a window-global accelerator when the item declares one with a key. It reuses the item's
-- own (wrapped) action, so firing the shortcut behaves exactly like clicking the menu item.
if item.accel and item.accel.key and menuItem.action then
accelerators[#accelerators + 1] = { key = item.accel.key, action = menuItem.action, item = menuItem }
end
return menuItem
end
-- Define flexible File menu with custom items + automatic Cancel/Exit.
-- Keys are iterated in sorted order so the menu is deterministic
-- (pairs() order varies between runs); callers choose key names that
-- sort into the order they want.
local fileMenuItems = {}
if options.fileMenu then
local keys = {}
for key in pairs(options.fileMenu) do
table.insert(keys, key)
end
table.sort(keys)
for _, key in ipairs(keys) do
-- Add separator before close/exit items
if (key == "close" or key == "exit" or key == "cancel") and #fileMenuItems > 0 then
table.insert(fileMenuItems, {}) -- separator
end
table.insert(fileMenuItems, createMenuItem(options.fileMenu[key]))
end
end
-- Always add Cancel/Exit as the last item(s) in File menu
if options.fileMenu and not options.fileMenu.exit and not options.fileMenu.cancel then
-- Only add separator if there are custom items
if #fileMenuItems > 0 then
table.insert(fileMenuItems, {}) -- separator
end
table.insert(
fileMenuItems,
createMenuItem({
title = "E&xit",
action = function(self)
return M.helpers.closeDialog(self)
end,
})
)
end
local fileMenu = iup.submenu({
iup.menu(fileMenuItems),
title = "&File",
})
-- Define the standard Help menu. About is added only when the caller supplies
-- one (e.g. the main window's version box). There is no default About: the
-- old default opened a non-existent "about" help page, which surfaced on
-- the Options dialog (it supplies only Help).
local helpItemDef = options.helpMenu and options.helpMenu.help or {
title = "&Help",
action = function(self)
if Help then
Help.new({}):show("")
end
return iup.DEFAULT
end,
}
local helpMenu
if options.helpMenu and options.helpMenu.about then
-- Help + About: a proper Help submenu holding both items.
local helpItems = {
createMenuItem(helpItemDef),
{}, -- separator
createMenuItem(options.helpMenu.about),
}
helpMenu = iup.submenu({
iup.menu(helpItems),
title = "&Help",
})
else
-- Only Help to show: promote it to a top-level menu-bar item rather than
-- burying a lone "Help" item inside a "Help" submenu. A top-level iup.item
-- fires its action directly on click (and via Alt+H from the mnemonic).
helpMenu = createMenuItem(helpItemDef)
end
-- Build menu items array
local menuItemsArray = { fileMenu }
-- Add custom items between File and Help
if options.items then
for _, item in ipairs(options.items) do
table.insert(menuItemsArray, createMenuItem(item))
end
end
-- Add Help menu
table.insert(menuItemsArray, helpMenu)
-- Create the menu bar
local menuBar = iup.menu(menuItemsArray)
-- Set a handle for the menu bar for global access
iup.SetHandle("mainmenu", menuBar)
-- Return enhanced menu bar with dynamic update capabilities
return {
menuBar = menuBar,
menuItems = menuItems,
registries = menuRegistries,
-- Window-global accelerators ({ key, action, item }); pass to makeDialog as options.accelerators.
accelerators = accelerators,
--- Update menu item title dynamically. Re-appends the item's accelerator display text (if any),
--- so a dynamic title change doesn't drop the "\tCtrl+I" suffix the menu shows.
---@param key string Registry key of the menu item
---@param title string New title (without the accelerator suffix)
updateTitle = function(key, title)
if menuItems[key] then
local accelText = accelTextByKey[key]
menuItems[key].title = accelText and (title .. "\t" .. accelText) or title
end
end,
--- Update menu item active state dynamically
---@param key string Registry key of the menu item
---@param active string "YES" or "NO"
updateActive = function(key, active)
if menuItems[key] then
menuItems[key].active = active
end
end,
--- Update menu item value (for checkable items) dynamically
---@param key string Registry key of the menu item
---@param value string "ON" or "OFF"
updateValue = function(key, value)
if menuItems[key] then
menuItems[key].value = value
end
end,
--- Update all menu items in a registry group
---@param groupKey string Registry group key
---@param updateFunction function Function to call for each menu item in the group
updateRegistryGroup = function(groupKey, updateFunction)
if menuRegistries[groupKey] then
for key, menuItem in pairs(menuRegistries[groupKey]) do
updateFunction(key, menuItem)
end
end
end,
--- Refresh all dynamic menu items (call title/state functions)
refreshDynamicItems = function()
for key, menuItem in pairs(menuItems) do
-- This would need to be enhanced to call titleFunction and stateFunction
-- if they were stored during creation
end
end,
}
end
-- Helper functions for common menu patterns
M.helpers = {}
--- Create a radio button group menu item
---@param title string Menu item title
---@param currentValue any Current value to check against
---@param expectedValue any Expected value for ON state
---@param action function Action function
---@param capture? function Capture function
---@param registryKey? string Registry key for dynamic updates
---@return menuItem Menu item definition
M.helpers.createRadioMenuItem = function(title, currentValue, expectedValue, action, capture, registryKey)
return {
title = title,
action = action,
capture = capture,
value = (currentValue == expectedValue) and "ON" or "OFF",
registryKey = registryKey,
}
end
--- Create a simple menu item
---@param title string Menu item title
---@param action function Action function
---@param capture? function Capture function
---@param registryKey? string Registry key for dynamic updates
---@param accel? {key?: integer, text?: string} Accelerator: `text` shows the shortcut in the menu (e.g. "Ctrl+I"); `key` (an iup key code, e.g. iup.K_cI) also binds it window-globally via the dialog's accelerators. Give `text` only (no `key`) to display a shortcut that stays bound elsewhere (e.g. Del on a list).
---@return menuItem Menu item definition
M.helpers.createMenuItem = function(title, action, capture, registryKey, accel)
return {
title = title,
action = action,
capture = capture,
registryKey = registryKey,
accel = accel,
}
end
--- Create a submenu with items
---@param title string Submenu title
---@param items menuItem[] Array of menu items
---@param registryKey? string Registry key for dynamic updates
---@return menuItem Submenu definition
M.helpers.createSubmenu = function(title, items, registryKey)
return {
title = title,
submenu = items,
registryKey = registryKey,
}
end
--- Close a dialog from a menu item's action. Family Historian's host swallows a MENU callback's
--- returned iup.CLOSE on a dialog shown via showxy/showTrackedDialog + iup.MainLoop (the title-bar
--- X, Esc and buttons are unaffected - see this file's header USAGE notes), so a menu action that
--- needs to close such a dialog must call this instead of `return iup.CLOSE`.
---
--- Accepts either the menu item (`self`, resolved to its owning dialog via iup.GetDialog) or the
--- dialog itself. Fires the dialog's close_cb - exactly once, since hide() does not itself fire
--- close_cb - and honours a veto: if close_cb returns iup.IGNORE (e.g. a plugin refusing to close
--- mid-batch-operation), the dialog is left open. Otherwise the dialog is hidden: hiding the last
--- visible dialog ends iup.MainLoop, and hiding a popup dialog ends its own popup loop, so this is
--- safe for a MainLoop-shown dialog and a popup dialog alike.
---@param self_or_dialog iup.item|iup.dialog Menu item whose action is closing the dialog, or the dialog itself
---@return integer iup.DEFAULT normally; iup.CLOSE only if no dialog could be resolved
M.helpers.closeDialog = function(self_or_dialog)
local dlg = self_or_dialog
if dlg and iup.GetClassName(dlg) ~= "dialog" then
dlg = iup.GetDialog(self_or_dialog)
end
if not dlg then
return iup.CLOSE
end
if dlg.close_cb then
if dlg.close_cb(dlg) == iup.IGNORE then
return iup.DEFAULT
end
end
dlg:hide()
return iup.DEFAULT
end
_G.MenuBar = M
end)()
--<<FS_SPLICE_END>>
--------------------------------------------------------------
--RESULTS INSTANCE
--------------------------------------------------------------
local myResults = Results(4)
myResults.Title("Add Trees")
-- An idle close is silent: every attempted generation writes a per-individual row, so
-- zero rows means the user chose not to generate anything this session. The gates that
-- stop a generation (validation, declined confirmations) already announce themselves
-- at the time, so no separate farewell box is needed here.
myResults.NoResults(nil)
myResults.Headings({ "Individual", "SVG File", "Embedded", "Notes" })
myResults.Types({ "item", "text", "text", "text" })
myResults.Visibility({ "show", "show", "show", "show" })
myResults.Width({ 220, 280, 80, 240 })
myResults.Sort({ 1, 2, 3, 4 })
--------------------------------------------------------------
--STATE
--------------------------------------------------------------
-- Record display names (this plugin charts individuals only).
local displayNames = {
INDI = "Individual",
}
---@class TargetRecord
---@field recordPointer ItemPointer The Family Historian record pointer.
---@field displayText string The display text for the record.
---@field recordType string The record type tag.
---@class ItemPointer
---@field IsNotNull fun(self:ItemPointer):boolean
---@field IsSame fun(self:ItemPointer, other:ItemPointer):boolean
---@field Clone fun(self:ItemPointer):ItemPointer
---@type TargetRecord[]
local selectedTargets = {}
-- Central UI registry for local references (avoid global IUP handles)
---@type {menuBarData: MenuBarData, menuBar: iup.menu, contentArea: iup.vbox, targetRecordsList: iup.list, mainVBox: iup.vbox}
local ui = {}
--------------------------------------------------------------
--PATH HELPERS
--------------------------------------------------------------
---Normalise a path for comparison: forward slashes, no trailing slash.
---@param p string|nil
---@return string
local function normalizePath(p)
if not p or p == "" then
return ""
end
local s = p:gsub("\\", "/")
s = s:gsub("/+$", "")
return s
end
---Is path inside (or equal to) root? Case-insensitive, separator-agnostic.
---@param root string
---@param path string
---@return boolean
local function isPathUnder(root, path)
local r = normalizePath(root):lower()
local p = normalizePath(path):lower()
if r == "" then
return false
end
return p:sub(1, #r) == r and (#p == #r or p:sub(#r + 1, #r + 1) == "/")
end
---The output path for one individual's standalone SVG file.
---@param folder string
---@param id integer
---@return string
local function svgPath(folder, id)
return folder .. "\\ind" .. id .. ".svg"
end
--------------------------------------------------------------
--TARGET RECORD HELPERS
--------------------------------------------------------------
--- Convert a single record pointer to standard format
--- @param recordPtr ItemPointer The record pointer
--- @param additionalFields? table Additional fields to add
--- @return TargetRecord Formatted record
function convertRecordPointerToStandardFormat(recordPtr, additionalFields)
local recordType = fhGetTag(recordPtr)
local displayText = "["
.. (displayNames[recordType] or recordType)
.. "] "
.. fhGetDisplayText(recordPtr)
.. " (ID: "
.. fhGetRecordId(recordPtr)
.. ")"
local record = {
recordPointer = recordPtr,
displayText = displayText,
recordType = recordType,
}
-- Add any additional fields
if additionalFields then
for key, value in pairs(additionalFields) do
record[key] = value
end
end
return record
end
--- Convert selected records to a standardised format
--- @param selectedRecords ItemPointer[] Array of selected record pointers
--- @return TargetRecord[] Array of formatted records
function convertRecordsToStandardFormat(selectedRecords)
local records = {}
for i = 1, #selectedRecords do
table.insert(records, convertRecordPointerToStandardFormat(selectedRecords[i]))
end
return records
end
--- Check if a record already exists in a collection by comparing pointers
--- @param newRecord TargetRecord The new record to check
--- @param existingRecords TargetRecord[] Array of existing records
--- @return boolean True if the record is a duplicate
function isRecordDuplicate(newRecord, existingRecords)
for _, existingRecord in ipairs(existingRecords) do
if newRecord.recordPointer and existingRecord.recordPointer then
if existingRecord.recordPointer:IsSame(newRecord.recordPointer) then
return true
end
end
end
return false
end
--- Add records to a collection with duplicate checking
--- @param newRecords TargetRecord[] New records to add
--- @param existingRecords TargetRecord[] Existing records collection
--- @param updateDisplay function Function to call to update the display
function addRecordsWithDuplicateCheck(newRecords, existingRecords, updateDisplay)
for _, newRecord in ipairs(newRecords) do
if not isRecordDuplicate(newRecord, existingRecords) then
table.insert(existingRecords, newRecord)
end
end
-- Update the display
if updateDisplay then
updateDisplay()
end
end
--- Ask for confirmation before removing selected list items
--- @param listLabel string Human-readable list name (e.g., "Individuals")
--- @param count integer Number of items to remove
--- @return boolean proceed True if user confirmed
function confirmRemoveSelected(listLabel, count)
local itemWord = (count == 1) and "item" or "items"
local answer =
MessageBox("question", string.format("Remove %d %s from the %s list?", count, itemWord, listLabel), "YESNO")
return answer == "Yes"
end
--- Gets initial targets from the current selection or the property box,
--- keeping individuals only (this plugin charts individuals).
--- @return TargetRecord[] Array of initial targets
function getInitialTargets()
local targets = {}
-- Try to get current selection first
local currentSelection = fhGetCurrentRecordSel("INDI")
if currentSelection and #currentSelection > 0 then
for _, recordPtr in ipairs(currentSelection) do
if fhGetTag(recordPtr) == "INDI" then
table.insert(targets, convertRecordPointerToStandardFormat(recordPtr))
end
end
else
-- Fallback to property box record
local propertyBoxRecord = fhGetCurrentPropertyBoxRecord()
if propertyBoxRecord and propertyBoxRecord:IsNotNull() and fhGetTag(propertyBoxRecord) == "INDI" then
table.insert(targets, convertRecordPointerToStandardFormat(propertyBoxRecord))
end
end
return targets
end
--- Populate the target records list
function populateTargetRecords()
local targetList = ui.targetRecordsList
if targetList then
-- Clear the list first
targetList.REMOVEITEM = "ALL"
-- Only populate if we have targets
if #selectedTargets > 0 then
-- Create display text array for populateList
local displayTexts = {}
for _, target in ipairs(selectedTargets) do
table.insert(displayTexts, target.displayText or tostring(target))
end
-- Use populateList helper
populateList(targetList, displayTexts)
end
updateGenerateMenuState()
end
end
--- Prompts the user to add individuals to the chart list
--- @param parentWindow? any Parent window handle for record selection
function selectTargetRecords(parentWindow)
-- Get parent window handle
local hParentWnd = getParentWindowHandle(parentWindow)
-- Prompt user for record selection
local selectedRecords = fhPromptUserForRecordSel("INDI", -1, hParentWnd)
if selectedRecords and #selectedRecords > 0 then
local targets = convertRecordsToStandardFormat(selectedRecords)
addRecordsWithDuplicateCheck(targets, selectedTargets, populateTargetRecords)
end
end
--- Remove selected targets from the list
function removeSelectedTargets()
local list = ui.targetRecordsList
if not list then
return
end
local positions = getSelectedValues(list, true)
local count = #positions
if count == 0 then
MessageBox("info", "Select one or more individuals to remove.")
return
end
if not confirmRemoveSelected("Individuals", count) then
return
end
removeSelectedItems(list, selectedTargets, populateTargetRecords)
end
--- Initialize targets from current selection or property box
function initializeTargets()
selectedTargets = getInitialTargets()
populateTargetRecords()
end
--------------------------------------------------------------
--CONTENT AREA
--------------------------------------------------------------
--- Create the content area: a single managed list of individuals to chart.
---@return iup.vbox
function createContentArea()
local content = iup.vbox({ expand = "YES" })
ui.contentArea = content
local targetLabel = makeLongLabel({
title = "Individuals to Chart:",
})
iup.Append(content, targetLabel)
local targetList = makeList({
dropdown = "NO",
expand = "YES",
multiple = "YES",
visiblelines = "14",
-- Sensible default canvas for the individuals list, whose entries look like
-- "[Individual] Firstname MIDDLE SURNAME (ID: 123)". Font-derived, so it scales with DPI.
visiblecolumns = "50",
name = "targetRecordsList",
tip = "Individuals whose charts will be generated. Use the Individuals menu to add or remove.\n\nUse the File menu to generate the charts.\n\nShortcuts:\n- Ctrl+I: Select Individuals\n- Ctrl+T: Clear list\n- Ctrl+A: Select all\n- Del: Remove selected",
})
iup.Append(content, targetList)
ui.targetRecordsList = targetList
-- Keyboard shortcuts for the list
targetList.k_any = function(self, c)
if c == iup.K_cA then -- Ctrl+A: select all (only if multi-select)
if self.multiple == "YES" then
local count = tonumber(self.COUNT) or 0
if count > 0 then
self.value = string.rep("+", count)
return iup.IGNORE
end
end
elseif c == iup.K_cI then -- Ctrl+I: add individuals
selectTargetRecords()
return iup.IGNORE
elseif c == iup.K_cT then -- Ctrl+T: clear list
selectedTargets = {}
populateTargetRecords()
return iup.IGNORE
elseif c == iup.K_DEL then -- Delete: remove selected
removeSelectedTargets()
return iup.IGNORE
end
return iup.CONTINUE
end
return content
end
--------------------------------------------------------------
--PREVIEW
--------------------------------------------------------------
-- The styling Family Historian gives the FhSeeAlso div on its generated
-- pages, reproduced so the preview looks like the real thing.
local PREVIEW_CSS = [[
.FhSeeAlso p { height:auto; width:auto; font-size:11pt; font-weight:400;
font-family:Verdana, Arial, Helvetica, sans-serif;
font-variant:small-caps; margin-bottom:0; }
/* A sensible default for the standard caption class, so the preview looks
reasonable; your own site's CSS for this class governs the real pages. */
.fs-embed-caption { font-family:Verdana, Arial, Helvetica, sans-serif;
font-size:13pt; font-weight:600; margin:0 0 6px; }
]]
---Render a sample model with the given (unsaved) option values to a
---temporary HTML file and open it in the default browser. Fire and forget:
---fhShellExecute returns immediately and the options dialog stays open, so
---the user can tweak values and preview again.
---@param rawValues table<string, table<string, any>> Live values from the options dialog.
---@param model FsModel The sample model to render.
---@param sampleName string Shown in the preview page heading.
local function previewChart(rawValues, model, sampleName)
local renderOptions, output = toRenderOptions(rawValues)
renderOptions.embedded = true
local svg = SvgRender.render(model, renderOptions)
-- Mirror the real embedding presentation (alignment, caption, fit).
local wrapped = HtmlInject.wrapSvg(svg, {
align = output.align,
caption = output.caption,
captionClass = output.captionClass,
fit = output.fit,
containerClass = output.containerClass,
hideable = output.hideable,
})
local html = table.concat({
"<!DOCTYPE html>",
"<html><head><meta charset=\"utf-8\">",
"<title>Add Trees preview</title>",
"<style>",
PREVIEW_CSS,
"</style></head><body>",
"<h1>Add Trees preview</h1>",
"<p>Sample: " .. sampleName .. ". Save the options to apply them to your own charts.</p>",
'<div class="FhSeeAlso">',
wrapped,
"</div>",
"</body></html>",
}, "\n")
local folder = getPluginDataFolder()
local path = folder .. "\\Add Trees Preview.html"
local ok, err = fhfu.createTextFile(path, true, true, html, 8)
if not ok then
MessageBox("error", "Could not write the preview file:\n" .. path .. "\n" .. (err or ""))
return
end
fhShellExecute(path)
end
-- Preview buttons for the options dialog: the balanced theming sample by
-- default, plus the awkward sample for edge cases.
local configExtraActions = {
{
title = "&Preview",
tip = "Open a browser preview of the standard sample using the current (unsaved) options",
action = function(values)
previewChart(values, SampleTheming, "standard")
end,
},
{
title = "Preview &Awkward Cases",
tip = "Preview the awkward sample: multiple parent-sets, missing people, escaping edge cases",
action = function(values)
previewChart(values, SampleFamily, "awkward cases")
end,
},
{
-- A button on the Colours and Shapes tab only: it's a Colours action,
-- so it shouldn't sit on the Chart/Output tabs' shared button row.
section = "Colours and Shapes",
title = "&Save Colours as Theme...",
tip = "Save the current colour fields as a named theme",
action = saveColoursAsTheme,
},
{
section = "Colours and Shapes",
title = "&Delete Theme...",
tip = "Delete the saved theme currently chosen in the Colour scheme list",
action = deleteTheme,
},
}
--- Show the options dialog with the Preview buttons attached.
function showOptions()
myConfig:showConfigDialog(nil, "add-trees-reference#configuration-options", configExtraActions)
end
--------------------------------------------------------------
--GENERATION
--------------------------------------------------------------
---The record ids of everyone in the target list; people outside this set
---are charted without links (the adapter sets their linkable flag false).
---@return table<integer, boolean>
local function buildLinkableIdSet()
local ids = {}
for _, target in ipairs(selectedTargets) do
if fh.isSet(target.recordPointer) then
ids[fhGetRecordId(target.recordPointer)] = true
end
end
return ids
end
---Create a folder, creating any missing parent folders along the way (like
---"mkdir -p"). fhFileUtils.createFolder is non-recursive: it returns
---"Parent folder not found" when an ancestor is missing. That happens the
---first time charts are generated into the project's Public folder, which
---Family Historian reports as the default output location but does not create
---on disk until something writes there - so both Public and Public\Add Trees
---are absent and creating just the leaf fails.
---@param path string
---@return boolean ok
---@return string? err the failing folder's error, when creation fails
local function createFolderTree(path)
-- Gather the missing folders, deepest first, stopping at the first one
-- that already exists (or when there is no further parent to climb to).
local missing = {}
local current = path
while current and current ~= "" and not fhfu.folderExists(current) do
missing[#missing + 1] = current
local parent = fhfu.getParent(current)
if not parent or parent == current then
break
end
current = parent
end
-- Create from the shallowest missing folder down to the target itself.
for i = #missing, 1, -1 do
local ok, err = fhfu.createFolder(missing[i])
if not ok then
return false, err
end
end
return true
end
---Validate the output settings before generating, prompting where sensible.
---@param output {folder: string, embed: boolean, websiteFolder: string, pagePrefix: string, placement: string, align: string, fit: boolean, divClass: string, caption: string, captionClass: string, containerClass: string, hideable: boolean, removeDiv: boolean, removeDivClass: string, privacy: {omit: boolean, basicFlag: string?}}
---@return boolean ok
local function validateOutputSettings(output)
if output.folder == "" then
MessageBox("error", "No SVG output folder is set. Choose one in Options.")
return false
end
if not fhfu.folderExists(output.folder) then
local answer = MessageBox(
"question",
"The SVG output folder does not exist:\n" .. output.folder .. "\n\nCreate it?",
"YESNO"
)
if answer ~= "Yes" then
return false
end
local ok, err = createFolderTree(output.folder)
if not ok then
MessageBox("error", "Could not create the output folder:\n" .. (err or output.folder))
return false
end
end
if output.embed then
if output.websiteFolder == "" then
MessageBox("error", "Embedding is on but no website folder is set. Choose one in Options.")
return false
end
if not fhfu.folderExists(output.websiteFolder) then
MessageBox("error", "The website folder does not exist:\n" .. output.websiteFolder)
return false
end
end
return true
end
---Embed one chart into its website page. Returns the value for the results
---table's Embedded column plus an explanatory note for failures.
---@param model FsModel
---@param renderOptions FsRenderOptions
---@param output {folder: string, embed: boolean, websiteFolder: string, pagePrefix: string, placement: string, align: string, fit: boolean, divClass: string, caption: string, captionClass: string, containerClass: string, hideable: boolean, removeDiv: boolean, removeDivClass: string, privacy: {omit: boolean, basicFlag: string?}}
---@param id integer
---@return string embeddedText, string note, "missingPage"|"missingDiv"|nil failure
local function embedChart(model, renderOptions, output, id)
renderOptions.embedded = true
local inlineSvg = SvgRender.render(model, renderOptions)
local pagePath = output.websiteFolder .. "\\" .. output.pagePrefix .. id .. ".html"
-- Confirm the target page resolves under the chosen website folder.
if not isPathUnder(output.websiteFolder, pagePath) then
return "No", "Page path is not under the website folder", "missingPage"
end
if not fhfu.fileExists(pagePath) then
return "No", "Page not found: " .. output.pagePrefix .. id .. ".html", "missingPage"
end
local html = fhfu.readTextFile(pagePath, true, 8)
if type(html) ~= "string" then
return "No", "Could not read the page", "missingPage"
end
-- Optionally drop a section first (e.g. FH's own "See also" box) so the
-- chart can live in a different div. Never remove the div we inject into.
if output.removeDiv and output.removeDivClass ~= "" and output.removeDivClass ~= output.divClass then
html = HtmlInject.removeDiv(html, output.removeDivClass)
end
local newHtml, injectErr = HtmlInject.inject(html, inlineSvg, id, {
placement = output.placement,
classToken = output.divClass,
align = output.align,
caption = output.caption,
captionClass = output.captionClass,
fit = output.fit,
containerClass = output.containerClass,
hideable = output.hideable,
})
if not newHtml then
return "No", injectErr or "Injection failed", "missingDiv"
end
local ok, err = fhfu.createTextFile(pagePath, true, true, newHtml, 8)
if not ok then
return "No", "Could not write the page: " .. (err or ""), "missingPage"
end
return "Yes", "", nil
end
--- Generate a chart for every individual in the list: one standalone SVG
--- file each, plus inline embedding into the website page when enabled.
--- @return boolean true once generation actually ran (reached the per-individual loop,
--- even if it was then cancelled part-way through - rows exist, so the Result Set
--- reports the partial outcome); false from every early gate below, so the caller
--- (Generate and Exit) knows whether to close or leave the window open to fix things.
function generateCharts()
if #selectedTargets == 0 then
MessageBox("info", "Add at least one individual to the list first.")
return false
end
local renderOptions, output = toRenderOptions(savedRawValues())
if not validateOutputSettings(output) then
return false
end
-- When embedding, confirm before changing the user's website files, and
-- remind them to back up (the plugin keeps no backups of its own).
if output.embed then
local count = #selectedTargets
local pageWord = (count == 1) and "page" or "pages"
local msg = string.format(
"Embedding is on.\n\nUp to %d website %s in:\n%s\nwill be changed, and one SVG per person written to:\n%s\n",
count,
pageWord,
output.websiteFolder,
output.folder
)
if output.removeDiv and output.removeDivClass ~= "" and output.removeDivClass ~= output.divClass then
msg = msg
.. string.format(
'\n"Remove a section" is on: the section with class "%s" will be removed from each page first.\n',
output.removeDivClass
)
end
msg = msg .. "\nBack up your website first if you have not already. Continue?"
if MessageBox("question", msg, "OKCANCEL") ~= "OK" then
return false
end
end
-- Warn once before overwriting existing SVG files.
local existing = 0
for _, target in ipairs(selectedTargets) do
if fh.isSet(target.recordPointer) and fhfu.fileExists(svgPath(output.folder, fhGetRecordId(target.recordPointer))) then
existing = existing + 1
end
end
if existing > 0 then
local fileWord = (existing == 1) and "file" or "files"
if MessageBox("question", string.format("Overwrite %d existing SVG %s in\n%s?", existing, fileWord, output.folder), "YESNO") ~= "Yes" then
return false
end
end
local linkableIds = buildLinkableIdSet()
local progress = Progress.new(#selectedTargets, 5, 20, dlgmain)
local written, embedded, failures, missingPages, missingDivs = 0, 0, 0, 0, 0
for _, target in ipairs(selectedTargets) do
if progress:isCancelled() then
break
end
local ptr = target.recordPointer
if fh.isSet(ptr) then
local id = fhGetRecordId(ptr)
progress:update(fhGetDisplayText(ptr))
local okModel, model = pcall(FhAdapter.buildModel, ptr, linkableIds, output.privacy)
if not okModel then
failures = failures + 1
myResults.Update({ ptr:Clone(), "", "", "Could not build the chart model: " .. tostring(model) })
elseif model.focal.private then
-- Omit-Private and the focal person is flagged Private: produce
-- no chart at all (mirrors FH's "omit all references").
myResults.Update({ ptr:Clone(), "", "n/a", "Omitted: flagged 'Private'" })
else
renderOptions.embedded = false
local svg = SvgRender.render(model, renderOptions)
local path = svgPath(output.folder, id)
local okWrite, writeErr = fhfu.createTextFile(path, true, true, svg, 8)
if not okWrite then
failures = failures + 1
myResults.Update({ ptr:Clone(), path, "", "Could not write the SVG: " .. (writeErr or "") })
else
written = written + 1
local embeddedText, note = "n/a", ""
if output.embed then
local failure
embeddedText, note, failure = embedChart(model, renderOptions, output, id)
if embeddedText == "Yes" then
embedded = embedded + 1
elseif failure == "missingDiv" then
missingDivs = missingDivs + 1
else
missingPages = missingPages + 1
end
end
myResults.Update({ ptr:Clone(), path, embeddedText, note })
end
end
else
-- The record vanished after selection (e.g. deleted): keep the
-- progress count honest and leave a trace in the results.
progress:update(target.displayText or "(missing record)")
failures = failures + 1
myResults.Update({ fhNewItemPtr(), "", "", "Record no longer exists: " .. (target.displayText or "?") })
end
fhExhibitResponsiveness()
end
progress:finish()
fhUpdateDisplay()
-- One summary message; per-individual detail is in the results table.
local fileWord = (written == 1) and "file" or "files"
local message = string.format("%d SVG %s written to:\n%s", written, fileWord, output.folder)
if output.embed then
message = message .. string.format("\n\n%d chart(s) embedded in website pages", embedded)
if missingDivs > 0 then
message = message
.. string.format(
'\n%d page(s) had no <div class="%s"> - see the results table',
missingDivs,
output.divClass
)
end
if missingPages > 0 then
message = message .. string.format("\n%d page(s) missing or unwritable - see the results table", missingPages)
end
end
if failures > 0 then
message = message .. string.format("\n\n%d individual(s) failed - see the results table", failures)
end
MessageBox("info", message)
return true
end
--------------------------------------------------------------
--MENU HANDLING
--------------------------------------------------------------
-- Menu actions cannot close via `return iup.CLOSE` under this dialog's iup.MainLoop - the FH
-- host swallows a MENU callback's returned CLOSE (though it honours the title-bar X and the
-- on_escape/DEFAULTESC paths, which go through IUP's own machinery, not a menu action). Add
-- Facts hit and fixed the same host quirk (live-verified 7 Jul 2026); the fix is now shared
-- boilerplate - see MenuBar.helpers.closeDialog (2 Boilerplate/MenuBar.lua USAGE note 7). This
-- local wrapper just fixes the dialog as dlgmain, since it's referenced by name at several call
-- sites below.
local function closeMainDialog()
return MenuBar.helpers.closeDialog(dlgmain)
end
--- Enable/disable the Generate menu items depending on the target list.
function updateGenerateMenuState()
local enabled = (#selectedTargets > 0) and "YES" or "NO"
if ui.menuBarData then
ui.menuBarData.updateActive("menuGenerateContinue", enabled)
ui.menuBarData.updateActive("menuGenerateExit", enabled)
end
end
--- Create the main menu bar with all menu items
--- @return iup.menu Menu bar
function createMainMenuBar()
local menuBarData = MenuBar.createMenuBar({
items = {
MenuBar.helpers.createSubmenu("&Individuals", {
MenuBar.helpers.createMenuItem("&Select: Individuals\tCtrl+I", function(self)
selectTargetRecords()
return iup.DEFAULT
end),
{}, -- separator
MenuBar.helpers.createMenuItem("R&emove Selected\tDel", function(self)
removeSelectedTargets()
return iup.DEFAULT
end),
MenuBar.helpers.createMenuItem("&Clear List\tCtrl+T", function(self)
selectedTargets = {}
populateTargetRecords()
return iup.DEFAULT
end),
}, "individualsMenu"),
MenuBar.helpers.createMenuItem("&Options", function(self)
showOptions()
return iup.DEFAULT
end),
},
fileMenu = {
-- Keys are iterated in sorted order (see MenuBar.createMenuBar), so these are
-- prefixed a/b to keep Generate and Continue/Exit above the Exit item below, which
-- must be named exactly "exit" for MenuBar to suppress its own automatic Exit item.
aGenerateContinue = MenuBar.helpers.createMenuItem("Generate and &Continue", function(self)
generateCharts()
return iup.DEFAULT
end, nil, "menuGenerateContinue"),
bGenerateExit = MenuBar.helpers.createMenuItem("&Generate and Exit", function(self)
-- Generate and Exit exits only when the generate half actually happened.
-- Stopped at a fixable gate (validation error, a declined confirmation),
-- the window stays open so the user can fix it and retry - the same
-- policy as Add Facts' Save and Exit "blocked" rule.
if generateCharts() then
return closeMainDialog()
end
return iup.DEFAULT
end, nil, "menuGenerateExit"),
-- Replaces MenuBar's automatic Exit item, whose `return iup.CLOSE` is dead under
-- this dialog's menu-action CLOSE trap (see closeMainDialog above).
exit = MenuBar.helpers.createMenuItem("E&xit", function(self)
return closeMainDialog()
end, nil, "menuExit"),
},
helpMenu = {
about = MenuBar.helpers.createMenuItem("&About", function(self)
MessageBox(
"info",
"Add Trees Plugin v"
.. cstrPluginVersion
.. "\n\nGenerates a three-generation navigation chart for each selected individual, as standalone SVG files and optionally embedded in the pages of a Family Historian generated website."
)
return iup.DEFAULT
end),
},
})
-- Store references for dynamic updates
ui.menuBarData = menuBarData
ui.menuBar = menuBarData.menuBar
-- Initialise menu state
updateGenerateMenuState()
return menuBarData.menuBar
end
--------------------------------------------------------------
--MAIN DIALOG
--------------------------------------------------------------
function makeMainDialog()
-- Create content area
local contentArea = createContentArea()
-- Create the main content area
local mainVBox = iup.vbox({
contentArea,
margin = "10x10",
gap = "10",
})
ui.mainVBox = mainVBox
-- Populate the list initially
initializeTargets()
-- Create menu bar using the centralized function
local menuBar = createMainMenuBar()
-- Create the main dialog
local dialog = makeDialog(mainVBox, {
title = "Add Trees",
expand = "YES",
resize = "YES",
menubox = "YES",
menu = menuBar,
help_topic = "",
close_cb = function(self)
return iup.CLOSE
end,
-- Esc closes the window (menu-driven dialog, no Cancel button). Returning iup.CLOSE fires the
-- close_cb that showTrackedDialog wrapped, so window size/position is still saved.
on_escape = function()
return iup.CLOSE
end,
})
return dialog
end
--------------------------------------------------------------
--EXECUTE
--------------------------------------------------------------
-- Create and show the main dialog
dlgmain = makeMainDialog()
myConfig:showTrackedDialog(dlgmain, "Main")
DoNormalize()
if iup.MainLoopLevel() == 0 then
iup.MainLoop()
end
destroyAllDialogs()
myResults.Display()Source: Add-Trees.fh_lua