--[[ @Title: Add Maps @Type: Standard @Author: Helen Wright @Contributors: Inspired by Mike Tate's Map Life Facts @Version: 1.1 @LastUpdated: 22 October 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: Geocodes the places and addresses in your project and builds interactive maps of where life events happened, which you can embed into the matching pages of a Family Historian generated website. ]] --[[ChangeLog: Version 1.1: A 'Show' box above the Places and Addresses lists narrows them to, for example, only the places with no coordinates or only the tentative ones; Geocode All, Geocode Selected, Ctrl+A and Remove then act on the rows shown. After geocoding, the Coordinate panel shows the geocoder's own name for the place, and 'Use as Standardised' (Alt+U) copies it into the Standardised box for you to Save; the results table gains a 'Geocoder's name' column. Text in Family Historian's private double square brackets is never sent to the geocoder or shown on a map, and a new Geocoding option, 'Ignore text in [square brackets]', leaves out ordinary bracketed notes when geocoding (your records are not changed). The buttons' Alt+letter shortcuts now work on whichever pane is showing. Version 1.0.4: New Geocoding option 'Only accept results in that country' - with Geoapify, the region code then restricts results to that country instead of only preferring it (OpenCage and LocationIQ already keep to it). The Geocoding pane shows '(this country only)' beside the region code whenever results are kept to that country. Version 1.0.3: Options that are on by default can now be switched off - unticking 'Mark auto-geocoded places tentative', 'Historic map layers', 'Google Maps / Street View links' or the two privacy options was saved but ignored. With tentative marking off, a place marked Not found that is then geocoded loses its Not found status. The Quality column now also says what kind of thing the geocoder matched (for example "35% (city)" or "80% (building)"), as well as its score. Version 1.0.2: Coloured status icons in the Places and Addresses lists; the summary line under the list is no longer cut short; while geocoding, the list and Coordinate panel follow the record being worked on (and the progress window names it). Version 1.0.1: Much faster loading of large selections (tens of thousands of places, addresses or individuals), with a progress bar for big loads. Version 1.0: Initial release. Add Maps geocodes the places and addresses in a Family Historian project and builds interactive maps of where each person's life happened, which it can embed into a Family Historian website. - Geocoding: Geoapify, OpenCage or LocationIQ (your own free key; consent asked once per service, with its privacy policy). Geocode one row, the ticked rows or the whole list, for places and addresses; region bias; Tentative / Not found / Blocked status; Standardised-name editing; hand-correction on a browser map (Show on Map / Get from Map). Changes can be undone with Edit > Undo Plugin Updates until Family Historian closes. - Maps: a page per person or one named combined map; choose any fact types (Options > Events to Map), plus witnessed events and relatives' timeline events; journey line with numbered pins; age at each event; time slider; clustering; per-person legend; Street, Satellite and historic base layers; Google Maps / Street View links; optional temporary maps deleted on close. - Website embedding: each person's map injected into their generated page, with caption, height, collapsible and alignment options and a confirmation before any page is changed. - Historic-map keys: keyless NLS layers for Great Britain; an optional MapTiler key adds more GB and Ireland maps. Published maps are keyless by default, or use a separate domain-restricted key. - Privacy: published maps omit individuals flagged Private or Living (configurable) and drop their events from other people's published maps. ]] -------------------------------------------------------------- -- INITIALISE (FH8 minimum + recommend saving before we change data) -------------------------------------------------------------- -- fhInitialise must be the FIRST FH call and must precede any data change. It enforces the minimum -- FH version and, with "save_recommended", offers to save unsaved changes first (Yes/No/Cancel; -- Cancel terminates the plugin). Saving first also avoids the OneDrive .ged sync-conflict risk. fhInitialise(8, 0, 0, "save_recommended") -------------------------------------------------------------- -- EXTERNAL LIBRARIES -------------------------------------------------------------- do require("utf8data") utf8 = require(".utf8") utf8.config["conversion"] = { uc_lc = utf8_uc_lc, lc_uc = utf8_lc_uc } utf8:init() require("iuplua") -- UI fh = require("fhUtils") -- FH helpers fhfu = require("fhFileUtils") -- UTF-8-aware file handling end require("luacom") -- exposes a GLOBAL `luacom` (its require returns no value in FH); used for WinHttp local lpeg = require("lpeg") -- JSON parsing for geocoder responses (FH8 bundles lpeg) -------------------------------------------------------------- -- ENVIRONMENT CONSTANTS -------------------------------------------------------------- local cstrPluginName = fhGetContextInfo("CI_PLUGIN_NAME") local cstrPluginVersion = "1.1" -- MUST equal @Version in the header (drives window title + About box); tests/geocode_spec.lua fails the build if the two drift local cstrPluginDir = fhGetPluginDataFileName("LOCAL_MACHINE", true) local myHelp, myGeoConfig, myMapConfig, myResults -- Session region-bias override: seeded from the global Geocoding Options default, editable on the -- Geocoding pane for this run only, and deliberately NOT written back (unlike the output folder). local gRegionBias = "" -- Field validator for the region-bias default (forward-declared here, assigned once gcValidRegion is in -- scope below). The Geocoding Options dialog calls it on Save so a bad default can't be stored. local regionFieldValidate local dlgMain -- Whether the user actually geocoded / built maps this session, so on close we show only the relevant -- result window(s) and never the "nothing happened" box for a thing they didn't do. local didGeocode, didMap = false, false -- True while a batch geocode is running. The batch loop pumps the UI (fhSleep / LoopStep), so the -- main window stays live - but closing the plugin mid-batch tears the dialog down underneath the -- running loop and leaves an orphaned window that blocks FH from exiting. The close paths (title-bar -- X and File > Exit) check this and refuse while a batch is in flight. local pluginBusy = false -- FH supports only ONE result set per plugin run, so geocoding and mapping outcomes share a single -- Results object (myResults, built near the foot of the file) with an "Activity" column distinguishing -- the two kinds of row. These helpers keep the combined column order in one place: a geocode row leaves -- the map-only columns blank, a map row leaves the geocode-only columns blank. local function addGeocodeRow(ptr, kind, geocoder, result, lat, lng, quality, standardised, geoName) if not myResults then return end myResults.Update({ ptr, "Geocoded", kind, geocoder, result, lat, lng, quality, standardised, geoName or "", "", "", "", "", "" }) didGeocode = true end local function addMapRow(ptr, eventsMapped, notGeocoded, mapFile, embedded, folder) if not myResults then return end myResults.Update({ ptr, "Map", "", "", "", "", "", "", "", "", eventsMapped, notGeocoded, mapFile, embedded, folder }) didMap = true end -------------------------------------------------------------- -- SHARED BOILERPLATE (spliced in at build by build/assemble.lua) -------------------------------------------------------------- -- Each block below is spliced from the canonical "2 Boilerplate/" module by -- build/assemble.lua (shared engine: "2 Boilerplate/build/fs_splice.lua"). This makes -- Add Maps a single self-contained .fh_lua at release, with no runtime dofile. -- DO NOT edit the spliced content between the markers - edit the source module and -- re-run build/assemble.lua. Order matters (Theme before Dialog; Progress and Facts -- need Dialog). The nine classic modules set their own global, so their directive has -- NO name (run-for-effect); HtmlInject returns a module table, so it is named (assign). --<> ;(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)() --<> --<> ;(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 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)() --<> --<> ;(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 -- makeLongLabel (not makeLabel): makeLabel joins the shared button normaliser, which pins the -- label at button width and clips long text such as a person's full name. local lbl = makeLongLabel({ title = "Starting...", expand = "HORIZONTAL", }) 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 = "QUARTERx", -- width only: height stays natural so the Cancel button is never clipped 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 -- Change the label text without moving the bar (for phases that aren't counted steps) local function status(text) if text and text ~= "" then lbl.title = text iup.Refresh(dlg) iup.LoopStep() end 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, status = status, 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 --- @field _title string|nil 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 --- @param title? string Optional dialog title. Default "Applying..." --- @return ProgressController function ProgressController.new(totalSteps, updatePercent, showThreshold, parentDialog, title) 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._title = title 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, self._title or "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 --- Show a status message without advancing the step count (no-op if the dialog isn't shown) --- @param text string function ProgressController:status(text) if self.dlg then self.dlg.status(text) 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)() --<> --<> ;(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 = , text = "" }` (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 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 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 = , 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 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)() --<> --<> ;(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.4 @V1.4: getBool returned the default when the stored value was false (the `x and y or z` idiom), so a default=true option could never be switched off. Now returns a stored false. @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> ---@field callbacks table> 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) -- NB: not `success and result or default` - when the stored value is false that idiom -- falls through to the default, so a default=true option could never read as false. if success and type(result) == "boolean" then return result end return 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>, 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> 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)() --<> --<> ;(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)() --<> --<> ;(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)() --<> --<> ;(function() --[[ Facts.lua - fact-type enumeration + multi-select picker (shared boilerplate) @Author: Helen Wright @Version: 2.0.1 @LastUpdated: 4 July 2026 A rebuild of the old FH6 Facts.lua. Scope here is the PICKER side only - enumerate the project's fact types and let the user choose a set. Fact AUTHORING (the old Details/Create machinery) is out of scope and largely superseded by fhUtils.createUpdateFact. WHY THE REBUILD - FH has no API that lists all fact types, so we still read the Fact Type definition files (.fhf), but the layout differs from the old V6 one: Standard + Custom live under CI_APP_DATA_FOLDER\ Fact Types (per-machine OR per-user, depending how FH was installed - CI_APP_DATA_FOLDER tracks that), and projects now have their OWN facts under CI_PROJECT_DATA_FOLDER\Fact Types. - Each .fhf is clean INI: a [.index] section (Count + Item1..ItemN) then [FCT-] blocks. We parse it as INI (section-by-section) rather than the old order-dependent single regex, so it is robust to field reordering. Item identifiers encode everything we need: BIRT-IE standard: - EVEN-TEST_PM2-FE custom event: EVEN-- _ATTR-SHARED_DNA_WITH_ROOT-IA custom attribute: _ATTR-- - We do NOT read the per-fact "Field Place/Date/..." capability flags. The picker only chooses WHICH fact types to use; whether a given fact actually has a place is read from the data at the point of use, so capability flags would only add fragility for a cosmetic list trim. The PURE block (parsers) is mirrored verbatim into tests/facts_spec.lua - edit one, mirror the other - so the parsing is unit-tested outside FH against real .fhf content. It is kept at top level (column 0) precisely so the two copies stay byte-identical. ]] --==================== FACTS-PURE (mirror into tests/facts_spec.lua) ====================-- -- Parse FH .fhf / .fhdata INI text into ordered sections. Returns a table keyed by section -- name (each a table of key->value), plus an `order` array of section names in file order. -- ASCII-explicit patterns (FH runs Lua in a cp1252 locale where %w/%a are unreliable). local function parseIni(text) local sections, order = {}, {} local current if type(text) ~= "string" then return sections, order end text = text:gsub("^\239\187\191", "") -- strip a UTF-8 BOM if present for rawline in (text .. "\n"):gmatch("(.-)\n") do local line = rawline:gsub("\r$", "") local name = line:match("^%[(.+)%]%s*$") if name then current = {} sections[name] = current order[#order + 1] = name elseif current then local key, value = line:match("^([^=]-)%s*=%s*(.-)%s*$") if key and key ~= "" then current[key] = value end end end return sections, order end -- Parse a fact-type identifier into its parts, or nil if it is not a fact identifier. -- The trailing 2 chars are ; the leading token (to the first '-') is the -- GEDCOM tag; anything between is the custom id (nil for standard facts). local function parseFactId(id) if type(id) ~= "string" then return nil end local prefix, rec, typ = id:match("^(.-)%-([IF])([EA])$") if not prefix or prefix == "" then return nil end local tag = prefix:match("^([^%-]+)") local customId = prefix:match("^[^%-]+%-(.+)$") -- nil when the prefix is just the tag return { id = id, gedcomTag = tag, customId = customId, isCustom = customId ~= nil, recType = (rec == "I") and "INDI" or "FAM", isAttribute = (typ == "A"), } end -- Build fact-type records from one parsed .fhf, in [.index] order. Each record carries the -- identifier parts plus Name/Label/Abbr/Hidden read from its [FCT-] block. local function factsFromIni(text) local sections = parseIni(text) local index = sections[".index"] local out = {} if not index then return out end local count = tonumber(index.Count or "0") or 0 for i = 1, count do local item = index["Item" .. i] local rec = item and parseFactId(item) if rec then local blk = sections["FCT-" .. item] or {} rec.name = (blk.Name ~= nil and blk.Name ~= "") and blk.Name or item rec.label = (blk.Label ~= nil and blk.Label ~= "") and blk.Label or rec.name rec.abbr = blk.Abbr or "" rec.hidden = (blk.Hidden == "Y") out[#out + 1] = rec end end return out end -- Parse a GroupIndex.fhdata [groups] section into an ascending-priority list of {name,index}. -- Lower index = higher priority (a lower-index set's definition of a shared id wins). local function parseGroupIndex(text) local sections = parseIni(text) local groups = sections.groups or {} local list = {} for name, idx in pairs(groups) do list[#list + 1] = { name = name, index = tonumber(idx) or 0 } end table.sort(list, function(a, b) if a.index ~= b.index then return a.index < b.index end return a.name < b.name end) return list end -- Merge per-source fact lists (given in priority order, highest first) into one sorted list. -- The FIRST (highest-priority) definition of an id wins OUTRIGHT - including its Hidden flag - so a -- hide/override in a higher-priority set can never be resurrected by a lower-priority visible copy. -- Hidden winners are then dropped (unless opts.includeHidden); opts.recType ("INDI"|"FAM") filters. local function mergeFactLists(sources, opts) opts = opts or {} local winner, order = {}, {} for _, list in ipairs(sources) do for _, f in ipairs(list) do if winner[f.id] == nil then winner[f.id] = f order[#order + 1] = f.id end end end local out = {} for _, id in ipairs(order) do local f = winner[id] if (opts.includeHidden or not f.hidden) and (not opts.recType or f.recType == opts.recType) then out[#out + 1] = f end end table.sort(out, function(a, b) if a.label:lower() ~= b.label:lower() then return a.label:lower() < b.label:lower() end return a.id < b.id end) return out end --==================== END FACTS-PURE ====================-- do local M = {} -- Read a UTF-16LE FH data file, or nil if it is missing / unreadable. local function readFhFile(path) local fhfu = require("fhFileUtils") if fhfu and fhfu.fileExists and not fhfu.fileExists(path) then return nil end local ok, text = pcall(fhLoadTextFile, path, "UTF-16LE") if ok and type(text) == "string" then return text end return nil end -- Resolve the .fhf files to read, in priority order (project first, then app sets by ascending -- GroupIndex). Each entry is { path = , source = }; -- missing files are skipped on read. local function factFilePaths() local sep = "\\" local entries = {} local function add(path, source) entries[#entries + 1] = { path = path, source = source } end -- Project facts: \Fact Types\.fhf (highest priority). local projRoot = fhGetContextInfo("CI_PROJECT_DATA_FOLDER") if projRoot and projRoot ~= "" then local projFT = projRoot .. sep .. "Fact Types" local gi = readFhFile(projFT .. sep .. "GroupIndex.fhdata") if gi then for _, g in ipairs(parseGroupIndex(gi)) do add(projFT .. sep .. g.name .. ".fhf", "Project: " .. g.name) end end end -- App facts: Standard group -> Standard\Standard.fhf; every other group -> Custom\.fhf. local appRoot = fhGetContextInfo("CI_APP_DATA_FOLDER") if appRoot and appRoot ~= "" then local appFT = appRoot .. sep .. "Fact Types" local gi = readFhFile(appFT .. sep .. "Standard" .. sep .. "GroupIndex.fhdata") if gi then for _, g in ipairs(parseGroupIndex(gi)) do if g.name == "Standard" then add(appFT .. sep .. "Standard" .. sep .. "Standard.fhf", "Standard") else add(appFT .. sep .. "Custom" .. sep .. g.name .. ".fhf", g.name) end end end end return entries end -- Enumerate the project's fact types. Returns an array of records: -- { id, gedcomTag, customId, isCustom, recType="INDI"|"FAM", isAttribute, name, label, abbr, -- hidden, source } (source = the winning set, for diagnostics) -- deduped by id (first source wins, i.e. project > lower-index app set > higher), hidden facts -- dropped. opts.recType ("INDI"|"FAM") filters; opts.includeHidden keeps hidden facts. function M.enumerate(opts) local sources = {} for _, e in ipairs(factFilePaths()) do local text = readFhFile(e.path) if text then local facts = factsFromIni(text) for _, f in ipairs(facts) do f.source = e.source end sources[#sources + 1] = facts end end return mergeFactLists(sources, opts) end -- "X of Y selected" summary, for a button caption / status label. function M.summary(selected, facts) selected = selected or {} local n = 0 for _ in pairs(selected) do n = n + 1 end return n .. " of " .. #(facts or {}) .. " fact types" end -- Disambiguated display labels for a facts list: identical labels (e.g. two "Military Service") -- get their FACT SET appended - a name users recognise - rather than the cryptic id. local function buildLabels(facts) local labels = {} for i, f in ipairs(facts) do labels[i] = f.label for j, g in ipairs(facts) do if j ~= i and g.label == f.label then labels[i] = f.label .. " (" .. tostring(f.source or f.customId or f.id) .. ")" break end end end return labels end -- The picker dialog is built ONCE and REUSED (never destroyed). Repeatedly destroying+recreating an -- IUP dialog corrupts FH's native state and crashes it intermittently - the exact reason the Config -- dialog also builds once and reuses. Each open just repopulates the list and reseeds the ticks. -- `pickerUI` holds the dialog plus a mutable `state` table the button callbacks read, so they always -- act on the CURRENT call's facts/selection. Buttons sit in a private normaliser (consistent sizes, -- isolated from the global btnnorm); the list stays out of the global textnorm. local pickerUI -- extrasSpec (from the first choose() call) is a stable list of { key, label } checkboxes shown -- under the list - extra, caller-defined options that belong with the selection (e.g. scope toggles). local function buildPickerUI(extrasSpec) local state = { facts = {}, defaultSel = nil, result = nil } local list = makeList({ dropdown = "NO", visiblelines = 22, visiblecolumns = 34, expand = "YES", tip = "Tick the fact types to include. Ctrl/Shift-click to select runs; Select All / None below." }) list.MULTIPLE = "YES" local function setTicks(pred) local v = {} for i, f in ipairs(state.facts) do v[i] = pred(f) and "+" or "-" end list.value = table.concat(v) end -- Caller-supplied extra checkboxes (e.g. scope toggles), keyed for set/collect. local extraToggles = {} local extraRows = {} for _, e in ipairs(extrasSpec or {}) do local t = makeToggle({ title = e.label, value = "OFF" }) extraToggles[e.key] = t extraRows[#extraRows + 1] = t end local lblPrompt = makeLabel({ title = "Tick the fact types to include:" }) local btnAll = makeButton({ title = "Select &All", callback = function() setTicks(function() return true end); return iup.DEFAULT end }) local btnNone = makeButton({ title = "&None", callback = function() setTicks(function() return false end); return iup.DEFAULT end }) local btnDefault = makeButton({ title = "&Default", tip = "Reset to the default fact-type selection", callback = function() local d = state.defaultSel or {} setTicks(function(f) return d[f.id] == true end) return iup.DEFAULT end }) -- Save collects the ticked fact ids + extra-toggle values into state.result, then closes the dialog. local function saveResult() local v = tostring(list.value or "") local ids = {} for i, f in ipairs(state.facts) do if v:sub(i, i) == "+" then ids[f.id] = true end end local extras = {} for key, t in pairs(extraToggles) do extras[key] = (t.value == "ON") end state.result = { ids = ids, extras = extras } return iup.CLOSE end local content = { lblPrompt, iup.frame({ list, expand = "YES" }) } -- List actions sit directly under the list (they act ON the list), left-aligned. content[#content + 1] = iup.hbox({ btnAll, btnNone, btnDefault, iup.fill({}), gap = "6" }) if #extraRows > 0 then content[#content + 1] = makeLabel({ title = "Also include:" }) for _, t in ipairs(extraRows) do content[#content + 1] = t end end content.margin = "8x8" content.gap = "8" content.expand = "YES" -- A File > Save / Cancel menu, matching the Config dialogs (the user's choice over OK/Cancel buttons). -- Save records the result then returns iup.CLOSE; Cancel just closes (state.result stays nil). The -- "cancel" key suppresses MenuBar's automatic Exit; createMenuBar also adds the standard Help menu -- (opens the plugin help index). Menu actions fire within the popup and close it - no popup-in-popup. local menuBarData = MenuBar.createMenuBar({ fileMenu = { aSave = { title = "&Save", action = saveResult }, cancel = { title = "&Cancel", action = function() return iup.CLOSE end }, }, }) -- Esc mirrors Cancel: close without recording a result (state.result stays nil). Save is Alt+S / the -- File menu; no Enter default, as the list swallows Enter for tick toggling. local dlg = makeDialog(iup.vbox(content), { title = "Choose fact types", minbox = "NO", maxbox = "NO", resize = "YES", menu = menuBarData.menuBar, on_escape = function() return iup.CLOSE end }) -- makeDialog normalised this independently; on reuse we re-apply via renormalizeDialog (this -- dialog is built once and reused, never destroyed, and its list is repopulated each open). return { dlg = dlg, list = list, state = state, setTicks = setTicks, lblPrompt = lblPrompt, btnDefault = btnDefault, extraToggles = extraToggles } end -- Show the picker over `facts` (an enumerate() list). opts.selected is a set of ids to pre-tick; -- opts.defaultSelected (a set) enables the Default reset button; opts.extras is a list of -- { key, label, value=bool } checkboxes shown under the list. Returns { ids = , -- extras = { key = bool, ... } }, or nil if cancelled. Needs the Dialog boilerplate. function M.choose(opts) opts = opts or {} local facts = opts.facts or M.enumerate() if not pickerUI then pickerUI = buildPickerUI(opts.extras) end local ui = pickerUI populateList(ui.list, buildLabels(facts)) -- list position i <-> facts[i] ui.state.facts = facts ui.state.defaultSel = opts.defaultSelected ui.state.result = nil ui.lblPrompt.title = opts.prompt or "Tick the fact types to include:" ui.dlg.title = opts.title or "Choose fact types" ui.btnDefault.floating = opts.defaultSelected and "NO" or "YES" -- hide Default if no default given ui.btnDefault.visible = opts.defaultSelected and "YES" or "NO" for _, e in ipairs(opts.extras or {}) do -- reflect this call's extra-toggle values local t = ui.extraToggles[e.key] if t then t.value = e.value and "ON" or "OFF" end end local selected = opts.selected or {} ui.setTicks(function(f) return selected[f.id] == true end) renormalizeDialog(ui.dlg) -- re-size for this call's content iup.Refresh(ui.dlg) -- The picker is built once and REUSED, so the dialog handle remembers whatever size it was last -- left at - including a user-shrunk size that clipped the buttons on reopen. Measure the content's -- natural size now (after renormalise + Refresh, so it reflects THIS call's content) and use it as -- both the floor (minsize, so the buttons can never be clipped again) and the opening size (so it -- always opens tidily at natural size, never a stale dragged-small one). Mirrors the main dialog's -- naturalsize -> minsize pattern in Add Maps.fh_lua. local nat = tostring(ui.dlg.naturalsize or "") if nat:match("^%d+x%d+$") then ui.dlg.minsize = nat ui.dlg.rastersize = nat end ui.dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT) -- reused, never destroyed return ui.state.result -- nil unless OK was pressed end -- The resolved .fhf paths, in priority order (for diagnostics / debugging). M.sources = factFilePaths -- Expose the pure parsers for testing / advanced callers. M._parseIni = parseIni M._parseFactId = parseFactId M._factsFromIni = factsFromIni M._parseGroupIndex = parseGroupIndex M._mergeFactLists = mergeFactLists Facts = M -- global, in keeping with the other boilerplate modules (Config, Dialog, ...) end end)() --<> --<> HtmlInject = (function() --[[ @Title: HtmlInject @Author: Helen Wright @Description: Shared boilerplate (pure): finds a target
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 ', src, htmlAttrEsc(name), h) end -- Inject one person's map iframe into their website page. Returns ok, note. local function embedMapIntoPage(websiteFolder, id, name, iframeSrc, opts) name = scrubPrivate(name) -- the name goes into the page's caption and the iframe title local pagePath = websiteFolder .. "\\" .. opts.pagePrefix .. id .. ".html" if not isPathUnder(websiteFolder, pagePath) then return false, "page path is not under the website folder" end if not fhfu.fileExists(pagePath) then return false, "page not found (" .. opts.pagePrefix .. id .. ".html)" end local html = fhfu.readTextFile(pagePath, true, 8) if type(html) ~= "string" then return false, "could not read " .. opts.pagePrefix .. id .. ".html" end -- Optionally drop a section first (e.g. FH's own "See also" box). Never remove the div we inject into. if opts.removeDiv and opts.removeDivClass ~= "" and opts.removeDivClass ~= opts.divClass then html = HtmlInject.removeDiv(html, opts.removeDivClass) end -- [NAME] -> the person's name (function replacement, so a '%' in the name can't corrupt the result). local caption = (opts.caption ~= "") and (opts.caption:gsub("%[NAME%]", function() return name end)) or nil local iframe = buildMapIframe(iframeSrc, name, opts.frameHeight) local newHtml, err = HtmlInject.inject(html, iframe, id, { markerToken = "AddMaps", -- our own marker label, so a re-run never touches Add Trees' block classToken = opts.divClass, placement = opts.placement, align = opts.align, caption = caption, captionClass = opts.captionClass, containerClass = opts.containerClass, hideable = opts.hideable, fit = true, }) if not newHtml then return false, err or "injection failed" end local ok, werr = fhfu.createTextFile(pagePath, true, true, newHtml, 8) if not ok then return false, "could not write the page: " .. tostring(werr) end return true, "" end -------------------------------------------------------------- -- MAIN DIALOG -------------------------------------------------------------- -- Button access keys (Alt+letter), dispatched by us rather than by IUP's native mnemonics. -- The main window stacks several panes in one dialog (geocode Places, geocode Addresses, Mapping), and -- the two geocode panes each own a COPY of the same buttons with the same '&' letters. IUP keeps ONE -- mnemonic slot per letter per dialog (the last control mapped wins), so Alt+T / Alt+C / Alt+U were -- routed to the hidden Addresses copy - and a click sent to a hidden window is discarded, so the visible -- button never fired. Letters could also collide across panes (e.g. Geocode &All vs another pane's All). -- So each pane exposes a { LETTER = button } table (letters listed to match each button's '&X' title) -- and the dialog's k_any (which IUP calls BEFORE its own mnemonic processing) looks the letter up on the -- pane that is actually showing. -- Map { UPPERCASE LETTER = button } from a { letter = button } list. The letters are given explicitly and -- MUST match the '&X' in each button's title: reading a button's title back from the IUP handle inside FH -- does not return a plain string (live 22 Oct 2026 - ":match" on it failed), so it can't be derived. local function collectMnemonics(buttons) local map = {} for letter, b in pairs(buttons) do if b then map[letter:upper()] = b end end return map end -- The IUP "Alt held" modifier bit, derived from the documented K_mA (Alt+A) and K_A constants rather than -- hard-coded (iup.K_mA is what Add Facts' Alt+A/R/U/D grid shortcuts already use under the FH host). -- 0x40000000 is IUP's documented Alt flag and is only the fallback if the constants are somehow absent. local ALT_FLAG = (type(iup.K_mA) == "number" and type(iup.K_A) == "number" and math.tointeger(iup.K_mA - iup.K_A)) or 0x40000000 -- The upper-case letter for a plain Alt+letter key code, else nil. Ctrl/Shift combinations carry extra -- modifier bits, so they leave a base far above the letter range and are rejected; Alt+u may arrive with -- the upper- or lower-case base code, so both are accepted. local function altLetter(c) c = math.tointeger(c) if not c or c & ALT_FLAG == 0 then return nil end local base = c & ~ALT_FLAG if (base >= 65 and base <= 90) or (base >= 97 and base <= 122) then return string.char(base):upper() end return nil end -- k_any helper: if `c` is Alt+ and the SHOWING pane owns a button for it, click that button's -- action exactly as a mouse click would and swallow the key (iup.IGNORE, so IUP's own mnemonic routing to a -- hidden duplicate never runs). A disabled button swallows the key without acting. Returns nil when the key -- isn't one of this pane's access keys, so the caller falls through and the menu-bar Alt+F/G/M/O/H (handled -- natively by Windows) keep working. local function dispatchMnemonic(mnemonics, c) local letter = altLetter(c) local btn = letter and mnemonics and mnemonics[letter] if not btn then return nil end if btn.active == "NO" then return iup.IGNORE end local act = btn.action if type(act) == "function" and act(btn) == iup.CLOSE then return iup.CLOSE end return iup.IGNORE end -- Build one geocoding tab for a record type. -- recType : "_PLAC" or "_ADDR" -- nouns : lower-case plural for labels, e.g. "places" / "addresses" -- Nouns : capitalised plural, e.g. "Places" / "Addresses" -- Noun : capitalised singular for buttons, e.g. "Place" / "Address" local function buildGeocodeTab(recType, nouns, Nouns, Noun) -- Target-list model (Add Trees "Target Records" pattern). The list holds the records the user has -- chosen via FH's OWN record picker - a subset, a saved query, a smart folder, or Select All - so -- the filtering/searching power is FH's, far beyond a text box we could offer. Each entry carries -- its live record pointer, so reads/writes use it directly (no walking the record list by index). -- Sorted A-Z. The list can be filtered (the "Show:" dropdown above it), so a display ROW is not a -- model INDEX: view[row] = the model index shown on that row, and rowOf[modelIndex] = its row -- (nil when the filter hides it). Everything the list reports - ticked rows, the clicked row - is -- a ROW and must go through view[] before it touches model[]; everything the code keeps (selected, -- the batch's index list) is a MODEL index and goes through rowOf[] before it touches the list. local model = {} local view, rowOf = {}, {} local selected = 0 -- MODEL index of the record shown in the edit panel (0 = none) -- Enables/disables the Coordinate panel's controls for the current selection (Blocked = status-only; -- multi-tick = single-item editing off). Forward-declared; defined once every widget exists. local refreshPanelMode local isPlace = (recType == "_PLAC") -- visiblelines is deliberately small: it sets only the list's NATURAL height. expand=YES makes the -- list fill the available height (and scroll when there are more rows), so the list shrinks -- vertically and the window's minimum height is driven by the Coordinate form + the summary/Geocode -- row beneath - not by the list. local lstRecs = makeList({ dropdown = "NO", sort = "NO", expand = "YES", visiblelines = 6, visiblecolumns = 36, tip = "The " .. nouns .. " you've chosen to work on. Use the Geocoding menu to add or remove.\n\nShortcuts:\n- Ctrl+I: Select " .. nouns .. "\n- Ctrl+T: Clear list\n- Ctrl+A: Select all shown\n- Del: Remove selected\n\nCtrl- or Shift-click to tick several for Geocode Selected.\n\nThe 'Show' box above narrows the list; Geocode All, Geocode Selected, Select all and Remove then act on the rows shown." }) lstRecs.MULTIPLE = "YES" -- tick several rows for "Geocode Selected" (set before the dialog is mapped) lstRecs.SHOWIMAGE = "YES" -- coloured status icon per row (creation-only, so set before mapping); the text marker stays for colour-blind users -- "Show:" filter above the list. Each choice is a label plus a test on a model entry, using the status -- fields that buildEntry / Save / geocodeEntry keep current. FH addresses have no status (so no -- Tentative / Not found / Blocked) - they only get the coordinate-based choices. local cFilters if isPlace then cFilters = { { label = "All", test = function() return true end }, { label = "No coordinates", test = function(e) return not e.hasCoord end }, -- any status { label = "Tentative", test = function(e) return e.statKey == "tentative" end }, { label = "Geocoded (not tentative)", test = function(e) return e.hasCoord and e.statKey ~= "tentative" end }, { label = "Not found", test = function(e) return e.statKey == "not found" end }, { label = "Blocked", test = function(e) return e.statKey == "no auto" end }, } else cFilters = { { label = "All", test = function() return true end }, { label = "No coordinates", test = function(e) return not e.hasCoord end }, { label = "Geocoded", test = function(e) return e.hasCoord end }, } end local filterIdx = 1 -- index into cFilters of the choice in force (1 = All) local function passesFilter(e) return cFilters[filterIdx].test(e) end local filterLabels = {} for i, f in ipairs(cFilters) do filterLabels[i] = f.label end local lstFilter = makeList({ dropdown = "YES", sort = "NO", values = filterLabels, visibleitems = #filterLabels + 1, expand = "NO", -- +1: Windows otherwise shows one row short and scrolls tip = "Show only some of the " .. nouns .. " in the list - for example just those with no coordinates. This only changes what you see, not your project. Geocode All, Geocode Selected, Select all and Remove act on the rows shown." }) lstFilter.value = "1" local txtLat = makeText({ tip = "Latitude in decimal degrees, e.g. 53.763201" }) local txtLng = makeText({ tip = "Longitude in decimal degrees, e.g. -2.70309" }) local txtStan = makeText({ tip = "Standardised name used for geocoding when the name itself won't (e.g. a historic name); blank = geocode the name. Click Save to apply." }) local lblName = makeLongLabel({ title = "(select a " .. Noun:lower() .. ")", size = "240x", ellipsis = "YES", tip = "Selected " .. Noun:lower() }) local lblParent = makeLongLabel({ title = "", size = "240x", ellipsis = "YES", tip = "Parent place (addresses only)" }) local lblMap = makeLongLabel({ title = "", size = "240x", ellipsis = "YES" }) -- in-pane feedback for Get from Map local lblMode = makeLongLabel({ title = "", size = "240x40", wordwrap = "YES" }) -- Blocked / multi-select notice (wraps; height reserves ~2 lines so the panel doesn't jump) -- The geocoder's own tidy full name for the last match (session-only, e.geoName). Fixed width + ellipsis -- so a long name can't widen the panel; the tooltip carries the whole text. local lblGeoName = makeLongLabel({ title = "", size = "200x", ellipsis = "YES" }) -- expand=HORIZONTAL: the label is first mapped with the SHORT "(no places selected ...)" text, which -- would otherwise fix its width and clip the longer counts line shown once the list is filled. local lblSummary = makeLongLabel({ title = "", expand = "HORIZONTAL" }) local maxSummaryLen = 0 -- longest summary title shown so far; only a longer one needs a re-layout -- Status control: an editable dropdown for places (FH addresses have no status). Labels match -- FH's own map-window legend; "(none)" clears the status flag. Setting a status never touches the -- coordinate (they are kept independent). The dropdown is a PENDING edit -- - nothing is written when it changes; Save commits it. local cStatusOptions = { "(none)", "Tentative", "Not found", "Blocked" } local cStatusValueByItem = { "", "tentative", "not found", "no auto" } -- dropdown item index -> stored STAT local cStatusItem = { [""] = 1, tentative = 2, ["not found"] = 3, ["no auto"] = 4 } -- statKey -> item index local lblStat, lstStat if isPlace then lstStat = makeList({ dropdown = "YES", sort = "NO", values = cStatusOptions, visibleitems = 4, active = "NO", tip = "This place's status (FH's own terms). 'Blocked' excludes it from auto-geocoding; '(none)' clears the flag. Click Save to apply." }) else lblStat = makeLabel({ title = "", tip = "Addresses have no status" }) end -- Reflect entry e's status in the status control (nil clears + disables the place dropdown). local function showStatus(e) if isPlace then lstStat.value = tostring(cStatusItem[(e and e.statKey) or ""] or 1) lstStat.active = e and "YES" or "NO" else lblStat.title = (e and e.stat ~= "" and e.stat) or "(none)" end end -- Set the summary text and, only when it is longer than anything shown before, re-lay out the -- dialog so the label isn't clipped (not on every call: the batch loop updates it per record). -- Runs during construction too, before the label is mapped, so the Refresh waits for WID. local function setSummaryTitle(t) lblSummary.title = t if #t > maxSummaryLen then maxSummaryLen = #t if lblSummary.WID then pcall(iup.Refresh, lblSummary) end end end local function refreshSummary() if #model == 0 then setSummaryTitle("(no " .. nouns .. " selected - use the Geocoding menu, or Ctrl+I)") return end local n, withCoord, tentative, noAuto, todo = #model, 0, 0, 0, 0 for _, e in ipairs(model) do if e.hasCoord then withCoord = withCoord + 1 end if e.statKey == "tentative" then tentative = tentative + 1 end if e.statKey == "no auto" then noAuto = noAuto + 1 elseif not e.hasCoord then todo = todo + 1 end end local text = string.format( "%d %s | %d with coordinates | %d tentative | %d blocked | %d to do", n, nouns, withCoord, tentative, noAuto, todo) -- The counts above always describe the WHOLE list. With a filter on, also say how many rows are -- showing - and make an empty list obvious ("nothing matches"), so it isn't mistaken for lost data. if filterIdx ~= 1 then local label = cFilters[filterIdx].label if #view == 0 then text = text .. " | showing none - nothing matches '" .. label .. "'" else text = text .. " | showing " .. #view .. " - " .. label end end setSummaryTitle(text) end local function rowText(e) local t = statusMarker(e) .. e.name if e.parentPlace and e.parentPlace ~= "" then t = t .. " - " .. e.parentPlace end return t end -- Show entry e's geocoder-supplied name in the panel (blank for no selection; a hint until one has -- been geocoded this session). The tooltip carries the full text the ellipsis may cut off. local function showGeoName(e) local name = e and e.geoName if name and name ~= "" then lblGeoName.title = name setTipWithHelp(lblGeoName, name) else lblGeoName.title = e and "(geocode to see it)" or "" setTipWithHelp(lblGeoName, "The geocoder's own name for the place it found. It appears after you geocode this " .. Noun:lower() .. " (it is not stored in your project).") end end local function showSelected() local e = model[selected] if not e then return end lblName.title = e.name lblParent.title = (e.parentPlace and e.parentPlace ~= "") and ("in " .. e.parentPlace) or "" lblMap.title = "" txtLat.value = e.lat txtLng.value = e.lng txtStan.value = e.stan or "" showStatus(e) showGeoName(e) if refreshPanelMode then refreshPanelMode() end end -- Reset the edit panel to "nothing selected". local function clearPanel() selected = 0 lblName.title = "(select a " .. Noun:lower() .. ")" lblParent.title = "" lblMap.title = "" txtLat.value = "" txtLng.value = "" txtStan.value = "" showStatus(nil) showGeoName(nil) if refreshPanelMode then refreshPanelMode() end end -- Rebuild the visible list from the model through the current filter, filling view / rowOf. local function rebuildList() view, rowOf = {}, {} lstRecs.REMOVEITEM = "ALL" for i, e in ipairs(model) do if passesFilter(e) then local row = #view + 1 view[row], rowOf[i] = i, row lstRecs[tostring(row)] = rowText(e) lstRecs["IMAGE" .. row] = statusImage(e) -- after the text: the item must exist first end end end -- Refresh one model entry's row text and status icon in place (a no-op if the filter hides it). The -- icon is set AFTER the text: setting an item's text in IUP's Windows list deletes and reinserts the -- item, losing its image. local function setRow(mi) local row = rowOf[mi] if not row then return end lstRecs[tostring(row)] = rowText(model[mi]) lstRecs["IMAGE" .. row] = statusImage(model[mi]) end -- Model indices (ascending, because view runs in model order) of the TICKED rows. The list reports -- positions in the visible rows, so each goes through view[]; hidden records are never in the result. local function tickedModelIndices() local out = {} for _, posStr in ipairs(getSelectedValues(lstRecs, true)) do local mi = view[tonumber(posStr)] if mi then out[#out + 1] = mi end end return out end -- Bring the visible list into line with the filter after records changed status or the filter itself -- changed (force). The record in the Coordinate panel stays selected if it is still visible, otherwise -- the panel clears; ticks on rows that stay visible are kept. Without force it first checks whether any -- record's visibility actually changed - rows repainted in place by setRow need no rebuild - so a Save -- on a big list costs one cheap pass. NEVER called mid-batch: followRow relies on stable rows. local function reapplyFilter(force) if not force then local changed = false for i, e in ipairs(model) do if passesFilter(e) ~= (rowOf[i] ~= nil) then changed = true; break end end if not changed then return end end local keep = selected local ticks = {} for _, mi in ipairs(tickedModelIndices()) do ticks[mi] = true end rebuildList() local row = (keep ~= 0) and rowOf[keep] or nil if row then ticks[keep] = true end -- the record in the panel stays ticked, as when it was clicked if next(ticks) and type(lstRecs.value) == "string" then -- (value is nil until the dialog is mapped) local marks = {} for r, mi in ipairs(view) do marks[r] = ticks[mi] and "+" or "-" end lstRecs.value = table.concat(marks) end if row then selected = keep lstRecs.TOPITEM = math.max(1, row - 3) if refreshPanelMode then refreshPanelMode() end else clearPanel() end refreshSummary() end local function sortModel() -- lower-case each name once, not on every comparison local key = {} for _, e in ipairs(model) do key[e] = e.name:lower() end table.sort(model, function(a, b) return key[a] < key[b] end) end function lstRecs:action(text, item, state) if state == 1 then selected = view[tonumber(item)] or 0 -- the list reports a ROW; map it to the model index showSelected() -- calls refreshPanelMode elseif refreshPanelMode then refreshPanelMode() -- untick: the tick-count may have crossed the single/multi boundary end return iup.DEFAULT end -- Add record pointers to the target list (keep only this tab's type; dedupe by identity; A-Z), -- then refresh the view. Returns how many were actually added (and true if the user cancelled the -- progress dialog, in which case nothing is added). Linear in the list size: a set of tag:id keys -- is built once per call, so a selection of tens of thousands loads in moments. local function addPtrs(ptrs) ptrs = ptrs or {} local seen = {} for _, e in ipairs(model) do if e.ptr then seen[fhGetTag(e.ptr) .. ":" .. fhGetRecordId(e.ptr)] = true end end -- Big selections take a while to read: show progress (appears from 500 records up) local progress = Progress.new(#ptrs, 5, 500, dlgMain, "Loading " .. nouns .. "...") local fresh = {} for i, p in ipairs(ptrs) do if p and p:IsNotNull() and fhGetTag(p) == recType then local key = fhGetTag(p) .. ":" .. fhGetRecordId(p) if not seen[key] then seen[key] = true local entry = buildEntry(p, recType) fresh[#fresh + 1] = entry progress:update(entry.name) else progress:update() end else progress:update() end if progress:isCancelled() then progress:finish() return 0, true -- leave the list unchanged end if i % 100 == 0 then fhExhibitResponsiveness() end end local added = #fresh if added > 0 then for _, entry in ipairs(fresh) do model[#model + 1] = entry end progress:status("Filling the list...") sortModel() rebuildList() refreshSummary() clearPanel() end progress:finish() return added end -- Seed the list on launch from the current FH selection of this record type, or the property box. local function seedFromSelection() local sel = fhGetCurrentRecordSel(recType) if sel and #sel > 0 then addPtrs(sel) else local pb = fhGetCurrentPropertyBoxRecord() if pb and pb:IsNotNull() and fhGetTag(pb) == recType then addPtrs({ pb }) end end end -- The status dropdown is a PENDING edit (no write here). But "Blocked"/"Not found" are UNGEOCODED -- states in FH, so reflect that in the coordinate fields at once - blank them for those, and put -- the saved coordinate back when switching to a geocoded status. Save commits everything. if isPlace then function lstStat:action(text, item, state) if state ~= 1 then return iup.DEFAULT end local e = model[selected] if not e then return iup.DEFAULT end local v = cStatusValueByItem[tonumber(item)] if v == "no auto" or v == "not found" then txtLat.value, txtLng.value = "", "" elseif txtLat.value == "" and txtLng.value == "" then txtLat.value, txtLng.value = e.lat, e.lng end if refreshPanelMode then refreshPanelMode() end -- e.g. picking/clearing Blocked locks/unlocks the fields return iup.DEFAULT end end -- Validate a decimal coordinate; "" means clear; nil means invalid. local function parseCoord(s, lo, hi) s = (s or ""):gsub("%s+", "") if s == "" then return "" end local nval = tonumber(s) if not nval or nval < lo or nval > hi then return nil end return tostring(nval) end -- Log a manual Save to the on-exit results table as a clickable record row. The Result column -- reflects the status set, so a block shows "Blocked" (not just "Saved"). local function logEdit(ptr, e) local result = (e.statKey == "no auto" and "Blocked") or (e.statKey == "not found" and "Not found") or (e.statKey == "tentative" and "Tentative") or (e.hasCoord and "Saved") or (e.stan ~= "" and "Standardised") or "Cleared" addGeocodeRow(ptr, isPlace and "Place" or "Address", "(manual)", result, e.lat, e.lng, "", e.stan) end local btnSave = makeButton({ title = "&Save", callback = function() if selected == 0 or not model[selected] then MessageBox("info", "Select a " .. Noun:lower() .. " first.", "OK") return end local e = model[selected] local newStatKey = isPlace and (cStatusValueByItem[tonumber(lstStat.value)] or "") or e.statKey -- FH couples status to whether the item is geocoded: "Blocked"/"Not found" are UNGEOCODED -- states (no coordinate); "Tentative" requires one. Enforce that so the status actually takes. -- Only "Blocked" (no auto) is unconditionally ungeocoded - it never keeps a coordinate. Every -- other status may carry one, so we read and validate the fields. local lat, lng if newStatKey == "no auto" then lat, lng = "", "" else lat = parseCoord(txtLat.value, -90, 90) lng = parseCoord(txtLng.value, -180, 180) if lat == nil then MessageBox("warning", "Latitude must be a number between -90 and 90.", "OK") return end if lng == nil then MessageBox("warning", "Longitude must be a number between -180 and 180.", "OK") return end if (lat == "") ~= (lng == "") then MessageBox("warning", "Enter both latitude and longitude, or clear both.", "OK") return end if newStatKey == "tentative" and lat == "" then MessageBox("warning", "'Tentative' is for a geocoded place. Add a coordinate, or choose a different status.", "OK") return end -- A coordinate means the place WAS found, so "Not found" + a coordinate is contradictory: -- keep the coordinate the user typed and clear the status to "(none)", rather than silently -- discarding the coordinate (which is what treating "Not found" as ungeocoded used to do). if newStatKey == "not found" and lat ~= "" then newStatKey = "" end end local ptr = e.ptr if not (ptr and ptr:IsNotNull()) then MessageBox("error", "That record no longer exists - remove it from the list.", "OK") return end local statErr, stanErr if isPlace and newStatKey ~= e.statKey then local ok, err = setStat(ptr, newStatKey) if ok then e.statKey = newStatKey else statErr = err end end -- Standardised value (both types): write it and rebuild the geocode query so a later geocode -- uses the edit straight away. local newStan = (txtStan.value or ""):match("^%s*(.-)%s*$") if newStan ~= e.stan then local ok, err = setStan(ptr, newStan) if ok then e.stan = newStan -- the geocoding query is built from e.stan at send time, so a later geocode uses it else stanErr = err end end writeCoordinate(ptr, lat, lng) e.lat, e.lng = lat, lng e.hasCoord = (lat ~= "" and lng ~= "") txtLat.value, txtLng.value = lat, lng -- reflect any coordinate cleared by a block showStatus(e) -- re-sync the dropdown to the saved status (e.g. a "Not found" that flipped to "(none)") setRow(selected) refreshSummary() logEdit(ptr, e) if refreshPanelMode then refreshPanelMode() end -- a saved status change may lock/unlock the fields if statErr or stanErr then showStatus(e) local msg = "The coordinate was saved, but:" if statErr then msg = msg .. "\n- the status could not be changed (" .. tostring(statErr) .. ")" end if stanErr then msg = msg .. "\n- the standardised value could not be saved (" .. tostring(stanErr) .. ")" end MessageBox("warning", msg, "OK") end -- The saved record may no longer match the Show filter (e.g. a coordinate added while viewing "No -- coordinates"): re-apply it now. Last, so nothing above runs against a cleared panel. reapplyFilter() end }) local btnClear = makeButton({ title = "&Clear", tip = "Blank the coordinate and status here, then Save to apply (nothing is written until you Save)", callback = function() if selected == 0 or not model[selected] then MessageBox("info", "Select a " .. Noun:lower() .. " first.", "OK") return end txtLat.value, txtLng.value = "", "" if isPlace then lstStat.value = "1" end -- "(none)", pending until Save end }) -- Re-entrancy guard for the batch loop (fhSleep pumps the UI, so the button could fire again). -- Declared before the list actions: adding, removing or clearing rows mid-batch would re-sort or -- shrink the model under the batch's captured index list, so those actions are refused meanwhile. local batchRunning = false -- List-management actions (target list). Reached from the per-type menu (Places / Addresses) and -- by keyboard shortcuts on the list (Add Trees / Add Notes pattern). Nothing here changes the -- project - they only manage which records are in the working list. local function actSelect() -- Ctrl+I / menu: add via FH's own record picker if batchRunning then return end local sel = fhPromptUserForRecordSel(recType, -1, getParentWindowHandle()) if sel and #sel > 0 then local n, cancelled = addPtrs(sel) if n == 0 and not cancelled then MessageBox("info", "Those " .. nouns .. " are already in the list.", "OK") end end end local function actRemove() -- Del / menu: remove the ticked rows (only ever rows that are shown) if batchRunning then return end local drop = tickedModelIndices() -- model indices: with a filter on, row numbers are not model positions if #drop == 0 then MessageBox("info", "Tick one or more " .. nouns .. " to remove.\n\n(Ctrl-click or Shift-click to select several.)", "OK") return end for k = #drop, 1, -1 do table.remove(model, drop[k]) end -- highest first, so earlier indices stay valid rebuildList() refreshSummary() clearPanel() end local function actClear() -- Ctrl+T / menu: empty the list (the WHOLE list, whatever the filter hides) if batchRunning or #model == 0 then return end model = {} rebuildList() refreshSummary() clearPanel() end local function actSelectAll() -- Ctrl+A: tick every SHOWN row (the list only holds the rows the filter lets through) local count = tonumber(lstRecs.COUNT) or 0 if count > 0 then lstRecs.value = string.rep("+", count) end -- Setting value programmatically fires no list callback, so switch the panel to its multi-select -- (editing off) mode here - otherwise Geocode This / Save stay live on the last-clicked row. if refreshPanelMode then refreshPanelMode() end end -- Show: dropdown. Changing it only changes which rows are visible - never the project. Refused while a -- batch runs (the list follows the record being geocoded, so its rows must stay put); the dropdown is -- also greyed then (see refreshPanelMode), this is the belt to that pair of braces. function lstFilter:action(text, item, state) if state ~= 1 then return iup.DEFAULT end if batchRunning then lstFilter.value = tostring(filterIdx) return iup.DEFAULT end filterIdx = tonumber(item) or 1 reapplyFilter(true) -- keeps the panel's record if it is still visible, else clears the panel return iup.DEFAULT end -- Ctrl+I / Ctrl+T are now window-global (dispatched from the dialog's accelerators), so they fire -- from anywhere - not only when this list has focus. Ctrl+A (Select All, which has no menu item) and Del -- (kept list-scoped so it never clobbers Delete while editing a field) stay bound to the list here. function lstRecs:k_any(c) if c == iup.K_cA then actSelectAll(); return iup.IGNORE elseif c == iup.K_DEL then actRemove(); return iup.IGNORE end return iup.CONTINUE end -- Geocode buttons: use-time key check now; actual geocoding lands in Phase 3. -- Geocode ONE entry: look it up and, on success, write the coordinate and (places only) set -- STAT="tentative", updating the model entry. Returns the gcParse result (with .statErr set if -- the STAT write failed). No dialogs - callers handle messaging. local function geocodeEntry(e) local ptr = e.ptr local res = geocodeRecord(e) if res.status == "ok" then if not (ptr and ptr:IsNotNull()) then res = { status = "error", message = "That record no longer exists - remove it from the list." } else local latS, lngS = fmtCoord(res.lat), fmtCoord(res.lng) writeCoordinate(ptr, latS, lngS) -- FH's geocode status (tentative) is a PLACE-only field; addresses have none. local doTentative = recType == "_PLAC" and myGeoConfig:getBool("Geocoding", "markTentative", true) -- Not marking tentative: a place now holding a coordinate can't stay "Not found", so clear -- that status (any other status - e.g. an earlier Tentative - is left as it was). local doClearNotFound = recType == "_PLAC" and not doTentative and e.statKey == "not found" local statOk, statErr = true, nil if doTentative then statOk, statErr = setStat(ptr, "tentative") elseif doClearNotFound then statOk, statErr = setStat(ptr, "") end e.lat, e.lng, e.hasCoord = latS, lngS, true e.geoName = res.name -- session-only: shown in the Coordinate panel, never written to the project if doTentative and statOk then e.stat, e.statKey = "Tentative", "tentative" elseif doClearNotFound and statOk then e.stat, e.statKey = "", "" end res.statErr = ((doTentative or doClearNotFound) and not statOk) and statErr or nil end end addGeocodeRow(ptr, (recType == "_PLAC") and "Place" or "Address", (activeGeocoder()), res.nothing and "Nothing to send" or resultLabel(res.status), (res.status == "ok") and e.lat or "", (res.status == "ok") and e.lng or "", qualityText(res), e.stan, (res.status == "ok") and e.geoName or "") return res end -- Run the geocode loop over a list of MODEL indices (chosen from the visible rows, already filtered -- + confirmed by the caller): progress, per-row + summary updates, stop-on-quota/denied, cancel, and -- the closing message. The Show filter is deliberately NOT re-applied while it runs - rows change -- status as they are geocoded, and followRow relies on every row staying where it is; the filter is -- re-applied once the loop has finished. local function runGeocodeBatch(indices, provider) batchRunning = true pluginBusy = true -- block closing the plugin while the loop pumps the UI (see pluginBusy) local progress = Progress.new(#indices, 1, 20, dlgMain) local nOk, nNotFound, nError, nNothing, stopped, cancelled = 0, 0, 0, 0, nil, false -- Follow the record being worked on: select its row ALONE, make it current, show it in the -- Coordinate panel and scroll it into view (a few rows of context above). This deliberately -- REPLACES the user's ticks - the batch's index list was captured before the loop started. local function followRow(i) local row = rowOf[i] -- i is a MODEL index; every batch record was visible at the start and rows stay put if not row then return end local count = tonumber(lstRecs.COUNT) or 0 lstRecs.value = string.rep("-", row - 1) .. "+" .. string.rep("-", count - row) selected = i showSelected() lstRecs.TOPITEM = math.max(1, row - 3) -- Repaint now: the synchronous web request that follows blocks painting, and the progress -- dialog's single LoopStep repaints the list but not the Coordinate panel's fields. iup.Flush() end for n, i in ipairs(indices) do local e = model[i] -- followRow BEFORE progress:update: update pumps the UI (iup.LoopStep), so that one pump -- repaints the list and the progress text together. Done after it, the synchronous web -- request below blocks repainting and the list lags a record behind the progress text. followRow(i) progress:update("Geocoding " .. n .. " of " .. #indices .. ": " .. e.name) if progress:isCancelled() then cancelled = true break end local res = geocodeEntry(e) if res.status == "ok" then nOk = nOk + 1 setRow(i) followRow(i) -- setRow reinserts the item, which may drop its selection; also shows the new coordinate elseif res.status == "not_found" then nNotFound = nNotFound + 1 elseif res.status == "denied" or res.status == "quota" then stopped = res break elseif res.nothing then nNothing = nNothing + 1 -- only private / ignored text: no request was made else nError = nError + 1 end refreshSummary() -- The rate limit only matters when a request actually went out. if not res.nothing then fhSleep(cProviderDelayMs[provider] or 500, 80) end -- per-provider (free tiers differ) end progress:finish() batchRunning = false pluginBusy = false -- Rows changed status during the batch: now it is safe to re-apply the Show filter (the current -- record stays selected if it is still visible). This also refreshes the Coordinate panel's mode. reapplyFilter() if refreshPanelMode then refreshPanelMode() end -- the Coordinate panel was read-only during the batch local summary = string.format("Geocoded: %d\nNo match: %d", nOk, nNotFound) if nNothing > 0 then summary = summary .. string.format("\nNothing to send (only private or bracketed text): %d", nNothing) end if nError > 0 then summary = summary .. string.format("\nErrors: %d", nError) end if stopped then local why = (stopped.status == "quota") and (provider .. " hit its quota or rate limit, so I stopped early.") or ("The geocoder rejected a request (check your " .. provider .. " API key), so I stopped early.") MessageBox("warning", why .. (stopped.message and ("\n" .. stopped.message) or "") .. "\n\n" .. summary, "OK") elseif cancelled then MessageBox("info", "Stopped at your request.\n\n" .. summary, "OK") else MessageBox("info", "Finished geocoding " .. nouns .. ".\n\n" .. summary, "OK") end end local btnGeoAll = makeButton({ title = "Geocode &All", callback = function() if batchRunning then return iup.DEFAULT end if not geocoderReady() or not regionBiasOK() then return iup.DEFAULT end local provider = activeGeocoder() if not geocodeConsent(provider) then return iup.DEFAULT end -- Build the work list from the rows SHOWN (a Show filter narrows what "all" means): entries needing -- a coordinate, plus (if "Re-geocode existing" is on in Options) ones that already have one. "No -- auto" entries are always skipped. toDo holds MODEL indices. local recode = myGeoConfig:getBool("Geocoding", "recodeExisting", false) local toDo, nRedo, nNoAuto = {}, 0, 0 for _, i in ipairs(view) do local e = model[i] if e.statKey == "no auto" then nNoAuto = nNoAuto + 1 elseif not e.hasCoord then toDo[#toDo + 1] = i elseif recode then toDo[#toDo + 1] = i nRedo = nRedo + 1 end end if #toDo == 0 then local hint = (not recode) and "\n\nTo redo ones that already have a coordinate, turn on 'Re-geocode existing coordinates' in Geocoding Options." or "" MessageBox("info", "Nothing to geocode here." .. (filterIdx ~= 1 and "\n\n(Only the " .. nouns .. " shown by the Show filter are considered.)" or "") .. hint, "OK") return iup.DEFAULT end local msg = "Geocode " .. #toDo .. " " .. nouns .. " via " .. provider .. "?" if filterIdx ~= 1 then msg = msg .. "\n(Only the " .. nouns .. " shown by the Show filter, '" .. cFilters[filterIdx].label .. "', are included - not the whole list.)" end if nRedo > 0 then msg = msg .. "\n(" .. nRedo .. " already have a coordinate and will be re-geocoded.)" end if nNoAuto > 0 then msg = msg .. "\n(" .. nNoAuto .. " set to 'Blocked' will be skipped.)" end msg = msg .. "\n\nThis writes the results to your project." if MessageBox("question", msg, "YESNO") ~= "Yes" then return iup.DEFAULT end runGeocodeBatch(toDo, provider) return iup.DEFAULT end }) local btnGeoThis = makeButton({ title = "Geocode &This", callback = function() if not geocoderReady() or not regionBiasOK() then return iup.DEFAULT end if selected == 0 or not model[selected] then MessageBox("info", "Select a " .. Noun:lower() .. " first.", "OK") return iup.DEFAULT end local e = model[selected] if e.statKey == "no auto" then MessageBox("info", "This " .. Noun:lower() .. " is set to 'Blocked' (do not auto-geocode).\n\nChange its status above to geocode it.", "OK") return iup.DEFAULT end -- Geocode This is a deliberate single-item action, so (unlike the bulk actions) it isn't gated by -- "Re-geocode existing" - but it must never overwrite a coordinate silently, so confirm first. if e.hasCoord then if MessageBox("question", e.name .. "\n\nThis " .. Noun:lower() .. " already has a coordinate (" .. e.lat .. ", " .. e.lng .. ").\n\nReplace it by geocoding again?", "YESNO") ~= "Yes" then return iup.DEFAULT end end local provider = activeGeocoder() if not geocodeConsent(provider) then return iup.DEFAULT end local res = geocodeEntry(e) if res.status == "ok" then txtLat.value, txtLng.value = e.lat, e.lng showStatus(e) showGeoName(e) -- the geocoder's name for this match, ready for "Use as Standardised" setRow(selected) refreshSummary() if refreshPanelMode then refreshPanelMode() end -- enables "Use as Standardised" now there is a name local q = qualityText(res) local quality = (q ~= "") and (", quality " .. q) or "" local note = res.statErr and ("\n\nNote: the coordinate was saved, but the status could not be updated:\n" .. tostring(res.statErr)) or "" MessageBox("info", e.name .. "\n\nGeocoded via " .. provider .. quality .. ".\nSaved: " .. e.lat .. ", " .. e.lng .. note, "OK") elseif res.status == "not_found" then MessageBox("info", "No match was found for:\n\n" .. e.name .. "\n\nTry standardising or simplifying the " .. Noun:lower() .. ".", "OK") elseif res.status == "denied" then MessageBox("warning", "The geocoder rejected the request - check your " .. provider .. " API key in Geocoding Options." .. (res.message and ("\n\n" .. res.message) or ""), "OK") elseif res.status == "quota" then MessageBox("warning", provider .. " is over its quota or rate limit. Please try again later." .. (res.message and ("\n\n" .. res.message) or ""), "OK") elseif res.nothing then -- Nothing was sent: the name is empty once private [[text]] (and, if the option is on, [bracketed] -- text) is removed. Say so plainly - this is not a geocoder error. MessageBox("info", res.message .. ", so nothing was sent to the geocoder for:\n\n" .. e.name .. "\n\nEdit the name or its Standardised text if you want it geocoded.", "OK") elseif res.status == "invalid" then MessageBox("warning", "The geocoder could not understand the request for:\n\n" .. e.name .. (res.message and ("\n\n" .. res.message) or ""), "OK") else MessageBox("error", "Geocoding failed." .. (res.message and ("\n\n" .. res.message) or ""), "OK") end -- Deliberately NO reapplyFilter here: the record must stay on screen so the user can check the result, -- read the geocoder's name and click "Use as Standardised". It drops out of a filtered view on Save -- (or when the filter is next changed / a batch finishes). return iup.DEFAULT end }) local btnGeoSelected = makeButton({ title = "Geocode Se&lected", tip = "Geocode the " .. nouns .. " ticked in the list (Ctrl- or Shift-click to select several)", callback = function() if batchRunning then return iup.DEFAULT end if not geocoderReady() or not regionBiasOK() then return iup.DEFAULT end local ticked = tickedModelIndices() -- MODEL indices of the ticked rows (only rows that are shown can be ticked) if #ticked == 0 then MessageBox("info", "Tick one or more " .. nouns .. " in the list first.\n\n(Ctrl-click or Shift-click to select several.)", "OK") return iup.DEFAULT end -- Same rule as Geocode All: skip already-geocoded rows UNLESS "Re-geocode existing" is on, and -- always skip 'no auto'. (Selected used to ignore the option and overwrite silently - the two -- bulk actions now behave identically.) local recode = myGeoConfig:getBool("Geocoding", "recodeExisting", false) local toDo, nNoAuto, nRedo, nSkip = {}, 0, 0, 0 for _, i in ipairs(ticked) do local e = model[i] if e then if e.statKey == "no auto" then nNoAuto = nNoAuto + 1 elseif not e.hasCoord then toDo[#toDo + 1] = i elseif recode then toDo[#toDo + 1] = i nRedo = nRedo + 1 else nSkip = nSkip + 1 end end end if #toDo == 0 then local why = (nSkip > 0) and "The selected " .. nouns .. " already have coordinates.\n\nTo redo them, turn on 'Re-geocode existing coordinates' in Geocoding Options." or "Nothing to geocode - the selected " .. nouns .. " are all set to 'Blocked'." MessageBox("info", why, "OK") return iup.DEFAULT end local provider = activeGeocoder() if not geocodeConsent(provider) then return iup.DEFAULT end local msg = "Geocode " .. #toDo .. " selected " .. nouns .. " via " .. provider .. "?" if nRedo > 0 then msg = msg .. "\n(" .. nRedo .. " already have a coordinate and will be overwritten.)" end if nNoAuto > 0 then msg = msg .. "\n(" .. nNoAuto .. " set to 'Blocked' will be skipped.)" end msg = msg .. "\n\nThis writes the results to your project." if MessageBox("question", msg, "YESNO") ~= "Yes" then return iup.DEFAULT end runGeocodeBatch(toDo, provider) return iup.DEFAULT end }) -- Correction map (Phase 5): show the selected record on a browser map to nudge it by hand, then -- read the nudged coordinate back from the clipboard. Separate from the per-individual web maps. -- Where to centre the map for a record with NO coordinate of its own: its parent place (addresses), -- else the centroid of the records already geocoded in this list, else a whole-world view (NOT a -- GB/region-specific default - users research all over the world) for the user to pan from. local function fallbackCentre(e) if e.parentLat and e.parentLat ~= "" and e.parentLng ~= "" then return e.parentLat, e.parentLng, 11 -- near the address's parent place end local sumLat, sumLng, n = 0, 0, 0 for _, x in ipairs(model) do if x.hasCoord then local a, o = tonumber(x.lat), tonumber(x.lng) if a and o then sumLat, sumLng, n = sumLat + a, sumLng + o, n + 1 end end end if n > 0 then return tostring(sumLat / n), tostring(sumLng / n), 8 end -- among the located records return "20", "0", 2 -- nothing to anchor on: whole-world view end local function showOnMap() if selected == 0 or not model[selected] then MessageBox("info", "Select a " .. Noun:lower() .. " first.", "OK") return end local e = model[selected] local lat, lng, zoom if e.lat ~= "" and e.lng ~= "" then lat, lng, zoom = e.lat, e.lng, myMapConfig:getNumber("Web maps", "defaultZoom", 12) else lat, lng, zoom = fallbackCentre(e) end local label = (e.parentPlace ~= "") and (e.name .. ", " .. e.parentPlace) or e.name local path = correctionHtmlPath() -- The correction page is always a LOCAL file (never published), so it carries the everyday -- unrestricted MapTiler key - same exposure as local event maps (Maps Design.md s6.1.1 #6). local ok, err = fhfu.createTextFile(path, true, true, buildCorrectionHtml(label, lat, lng, zoom, buildHistoricLayers(myMapConfig:getString("Web maps", "maptilerKey", ""))), 8) if not ok then MessageBox("error", "Could not write the map page:\n\n" .. tostring(err), "OK") return end if not pcall(fhShellExecute, path) then MessageBox("warning", "The map page was written to:\n\n" .. path .. "\n\nbut could not be opened automatically. Open it in a browser by hand.", "OK") return end lblMap.title = "Map opened - nudge the pin, Copy, then Get from Map." -- in-pane hint (no extra click) end -- Read one coordinate the map copied to the clipboard (one-shot on click - never poll; a clipboard -- watch timer hard-crashed FH). Fills the panel pending Save; nothing is written until you Save. -- Confirms in-pane (lblMap) rather than a modal, to keep the round-trip to as few clicks as possible. local function getFromMap() if selected == 0 or not model[selected] then MessageBox("info", "Select a " .. Noun:lower() .. " first.", "OK") return end local clip = iup.clipboard{} local txt = (clip and clip.text) or "" pcall(function() iup.Destroy(clip) end) local la, lo = tostring(txt):match("^%s*" .. cClipSentinel .. "%s*(%-?[%d%.]+)%s*,%s*(%-?[%d%.]+)") if not la then MessageBox("info", "No map coordinate was found on the clipboard.\n\nIn the map page, drag the" .. " marker (or click the map), click 'Copy this coordinate', then come back and click Get from Map.", "OK") return end local latV, lngV = parseCoord(la, -90, 90), parseCoord(lo, -180, 180) if not latV or not lngV or latV == "" or lngV == "" then MessageBox("warning", "The coordinate on the clipboard wasn't valid:\n\n" .. tostring(txt), "OK") return end txtLat.value, txtLng.value = latV, lngV lblMap.title = "From map: " .. latV .. ", " .. lngV .. " - click Save to store it." end local btnShow = makeButton({ title = "Sho&w on Map", tip = "Open the selected " .. Noun:lower() .. " on a browser map to nudge its position by hand", callback = function() showOnMap(); return iup.DEFAULT end }) local btnGet = makeButton({ title = "G&et from Map", tip = "Read the coordinate you copied from the map into the panel (then Save)", callback = function() getFromMap(); return iup.DEFAULT end }) -- Copy the geocoder's own name for this place into the Standardised box. Like typing it there, this is a -- PENDING edit: nothing is written to the project until Save, and STAN is never set automatically. -- Its own normaliser (btnshortnorm, which only other short buttons share) so this longer caption doesn't -- stretch every other button in the window. local btnUseGeo = makeButton({ title = "&Use as Standardised", normalizergroup = btnshortnorm, -- Alt+U (free: menus F/G/M/O/H, panel T/W/E/S/C/L/A) tip = "Copy the geocoder's name into the Standardised box. Click Save to keep it.", callback = function() local e = model[selected] if not (e and e.geoName and e.geoName ~= "") then MessageBox("info", "Geocode this " .. Noun:lower() .. " first - the geocoder's name appears once it has been geocoded.", "OK") return end txtStan.value = e.geoName lblMap.title = "Standardised text set from the geocoder's name - click Save to store it." end }) -- Enable/disable the Coordinate panel's controls for the current selection. Two lock reasons: -- * Blocked (no auto): status stays editable (to unblock), but coordinate/standardised/actions off. -- * More than one row ticked: the panel edits a single row, so single-item editing is off entirely -- - use Geocode Selected, or click one row. This is what stops "Geocode This" acting on whichever -- row happened to be highlighted last (a confusing "random item" behaviour otherwise). refreshPanelMode = function() -- Count ticked rows from the list's own +/- string. lstRecs.value is nil until the dialog is -- mapped, and this runs once during construction (via the initial clearPanel), so guard it - -- going via getSelectedValues here would index that nil and crash Add Maps on open. local selStr = lstRecs.value local nTicked = (type(selStr) == "string") and select(2, selStr:gsub("%+", "")) or 0 local e = model[selected] local multi = nTicked > 1 -- Lock reflects the PENDING status shown in the dropdown, not the saved statKey, so choosing or -- clearing "Blocked" locks/unlocks the fields at once (before Save). local pendingStat = isPlace and (cStatusValueByItem[tonumber(lstStat.value)] or "") or (e and e.statKey) or "" local blocked = (e ~= nil) and (pendingStat == "no auto") local YN = function(on) return on and "YES" or "NO" end -- While a batch runs the whole panel is read-only: the list follows the record being geocoded, -- which would otherwise switch rows under a user who is typing in it. if isPlace then lstStat.active = YN(e ~= nil and not multi and not batchRunning) end -- editable even when blocked (to unblock) local editOn = (e ~= nil) and not multi and not blocked and not batchRunning txtLat.active, txtLng.active, txtStan.active = YN(editOn), YN(editOn), YN(editOn) btnShow.active, btnGet.active, btnGeoThis.active, btnClear.active = YN(editOn), YN(editOn), YN(editOn), YN(editOn) -- Only worth offering once there is a geocoder name to copy, and only where the Standardised box is editable. btnUseGeo.active = YN(editOn and e.geoName ~= nil and e.geoName ~= "") lstFilter.active = YN(not batchRunning) -- the list's rows must stay put while a batch follows them btnSave.active = YN(e ~= nil and not multi and not batchRunning) -- Save a status-only change (e.g. unblock) on a single row if batchRunning then lblMode.title = "Geocoding - editing is off until it finishes." elseif multi then lblMode.title = nTicked .. " selected - editing off. Use Geocode Selected, or click one row." elseif blocked then lblMode.title = "Blocked - change the status to unblock; coordinate editing is off." else lblMode.title = "" end end local statusRow = isPlace and iup.hbox({ makeLabel({ title = "Status:" }), lstStat, alignment = "ACENTER" }) or iup.hbox({ makeLabel({ title = "Status:" }), lblStat, alignment = "ACENTER" }) local detail = iup.vbox({ lblName, lblParent, makeLongLabel({ title = "Edit the coordinate and status, then Save. Geocode This saves its result straight away." }), iup.hbox({ makeLabel({ title = "Latitude:" }), txtLat, alignment = "ACENTER" }), iup.hbox({ makeLabel({ title = "Longitude:" }), txtLng, alignment = "ACENTER" }), statusRow, iup.hbox({ makeLabel({ title = "Standardised:" }), txtStan, alignment = "ACENTER" }), -- What the geocoder itself called the place it found (read-only), with a one-click way to adopt it -- as the Standardised text (a pending edit - Save commits it). iup.hbox({ makeLongLabel({ title = "Geocoder's name:" }), lblGeoName, btnUseGeo, alignment = "ACENTER", gap = "6" }), -- Geocode This lives HERE, next to the item it acts on (the row named at the top of this panel), -- not with the bulk actions below - so it's always clear which record it will geocode. iup.hbox({ btnGeoThis, btnShow, btnGet, btnSave, btnClear, gap = "6" }), lblMap, lblMode, gap = "6", }) -- Seed from the current FH selection / property box, then initialise the view (even when empty). seedFromSelection() rebuildList() refreshSummary() clearPanel() -- Session region-bias override (transient: edits drive gRegionBias, used by the geocoder, but are -- never written back to Options). Built on both geocode panes; biasSync re-seeds it from gRegionBias -- when the pane is shown, so the two panes always agree on the one session value. local txtBias = makeText({ value = gRegionBias, default = "", visiblecolumns = 4, tip = "Country code for this session only (e.g. gb, fr). Seeded from Geocoding Options, where you can also choose to accept only results in that country; changing it here is NOT saved." }) -- makeLongLabel (size 80x): a label mapped with an empty title gets ~zero natural width, so the -- longer note set later would be clipped; this also opts out of the shared size normaliser. local lblBias = makeLongLabel({ title = "", size = "80x", tip = "Results are kept to this country. With Geoapify this is the 'Only accept results in that country' setting in Geocoding Options; OpenCage and LocationIQ always do it." }) -- data-entry hint, shown on focus-out: flags an unrecognised code, else says when results are being -- confined to the region country (OpenCage/LocationIQ always; Geoapify only if the option is ticked). local function showBiasValid() local note = "" if not gcValidRegion(gRegionBias) then note = "(unknown code)" elseif (gRegionBias or ""):match("%S") then local ok, restricted = pcall(function() return activeGeocoder() ~= "Geoapify" or myGeoConfig:getBool("Geocoding", "regionRestrict", false) end) if ok and restricted then note = "(this country only)" end end lblBias.title = note end -- Keep gRegionBias current as you type (invisible), but only show the validity hint on focus-out. txtBias.valuechanged_cb = function(self) gRegionBias = (self.value or ""):gsub("^%s+", ""):gsub("%s+$", "") end txtBias.killfocus_cb = function() showBiasValid() end local function biasSync() txtBias.value = gRegionBias; showBiasValid() end showBiasValid() local tabBox = iup.vbox({ -- Two short lines so it doesn't overflow the width (the dialog minsize floors the window at the -- layout's natural width, so neither line is ever clipped). Fuller help is in the list tooltip. makeLongLabel({ title = "Choose " .. nouns .. " from the Geocoding menu (Ctrl+I) - a subset, a query, a smart folder, or all.\nClick a row to edit or geocode it; Ctrl/Shift-click to tick several for Geocode Selected." }), iup.hbox({ -- The list frame takes all the horizontal stretch (and shrink); the Coordinate frame keeps -- its natural width (grows only vertically) so resizing/narrowing the window flexes the list, -- never collapses the form on the right. -- "Show:" filter above the list, inside its frame. iup.frame({ iup.vbox({ iup.hbox({ makeLabel({ title = "Show:" }), lstFilter, alignment = "ACENTER", gap = "6", expand = "HORIZONTAL" }), lstRecs, gap = "4", expand = "YES" }), title = " " .. Nouns .. " ", expand = "YES" }), iup.frame({ detail, title = " Coordinate ", expand = "VERTICAL" }), gap = "8", expand = "YES", }), lblSummary, -- Bulk actions only (Geocode This moved into the Coordinate panel, beside its target row). iup.hbox({ btnGeoSelected, btnGeoAll, iup.fill({}), makeLabel({ title = "Region bias:" }), txtBias, lblBias, alignment = "ACENTER", gap = "6" }), margin = "4x4", gap = "8", expand = "YES", }) -- Expose the list-management actions so the main menu can drive this tab (the menu items mirror -- the keyboard shortcuts wired on the list above), plus biasSync so the view switch keeps the -- session region-bias field in step across the Places / Addresses panes. return { vbox = tabBox, biasSync = biasSync, actions = { select = actSelect, remove = actRemove, clear = actClear }, -- This pane's Alt+letter buttons (see collectMnemonics), for the main dialog's view-aware k_any. mnemonics = collectMnemonics({ T = btnGeoThis, W = btnShow, E = btnGet, S = btnSave, C = btnClear, U = btnUseGeo, L = btnGeoSelected, A = btnGeoAll }) } end -- Mapping pane: an individual target list (FH-native picker, like the Geocode panes) + Build Map(s). -- Returns { vbox, actions } so the Mapping menu can drive Select / Remove / Clear. local function buildWebMapsTab() local model = {} -- selected individuals: { ptr, id, name } local lstIndis = makeList({ dropdown = "NO", sort = "NO", expand = "YES", visiblelines = 6, visiblecolumns = 40, tip = "The individuals whose maps will be built. Use the Mapping menu to add or remove.\n\nShortcuts:\n- Ctrl+I: Select individuals\n- Ctrl+T: Clear list\n- Ctrl+A: Select all\n- Del: Remove selected" }) lstIndis.MULTIPLE = "YES" local lblSummary = makeLongLabel({ title = "" }) -- "Make" choice on the pane (at generation time), initialised from the Options default. local cMakeOptions = { "Automatic", "A page per individual", "One combined map" } local lstMake = makeList({ dropdown = "YES", sort = "NO", values = cMakeOptions, visibleitems = 3, tip = "What to build: Automatic (one combined map for several people, a single page for one), a page each, or one combined map." }) do local cfg = myMapConfig:getString("Web maps", "outputMode", "Automatic") local idx = 1 for i, v in ipairs(cMakeOptions) do if v == cfg then idx = i end end lstMake.value = tostring(idx) end local function indiName(ptr) local ok, n = pcall(fhIndGetName, ptr, true) -- include life dates if ok and type(n) == "string" and n ~= "" then return n end return fhGetDisplayText(ptr) or "?" end -- The person's name WITHOUT life dates, for the embedded map's caption ([NAME]) and iframe title - -- the website page already shows their dates, so repeating them in the caption is noise. local function indiNamePlain(ptr) local ok, n = pcall(fhIndGetName, ptr, false) -- no life dates if ok and type(n) == "string" and n ~= "" then return n end return (indiName(ptr):gsub("%s*%b()%s*$", "")) -- fallback: strip a trailing "(dates)" end local function rebuildList() lstIndis.REMOVEITEM = "ALL" for i, e in ipairs(model) do lstIndis[tostring(i)] = e.name end end local function refreshSummary() lblSummary.title = (#model == 0) and "(no individuals selected - use the Mapping menu, or Ctrl+I)" or string.format("%d individual%s selected", #model, #model == 1 and "" or "s") end local function sortModel() -- lower-case each name once, not on every comparison local key = {} for _, e in ipairs(model) do key[e] = e.name:lower() end table.sort(model, function(a, b) return key[a] < key[b] end) end -- Add individuals to the list (dedupe by identity). Linear in the list size: a set of tag:id keys -- is built once per call. Returns how many were added (and true if the user cancelled the -- progress dialog, in which case nothing is added). local function addPtrs(ptrs) ptrs = ptrs or {} local seen = {} for _, e in ipairs(model) do if e.ptr then seen[fhGetTag(e.ptr) .. ":" .. fhGetRecordId(e.ptr)] = true end end -- Big selections take a while to read: show progress (appears from 500 records up) local progress = Progress.new(#ptrs, 5, 500, dlgMain, "Loading individuals...") local fresh = {} for i, p in ipairs(ptrs) do if p and p:IsNotNull() and fhGetTag(p) == "INDI" then local key = "INDI:" .. fhGetRecordId(p) if not seen[key] then seen[key] = true fresh[#fresh + 1] = { ptr = p, id = fhGetRecordId(p), name = indiName(p) } progress:update(fresh[#fresh].name) else progress:update() end else progress:update() end if progress:isCancelled() then progress:finish() return 0, true -- leave the list unchanged end if i % 100 == 0 then fhExhibitResponsiveness() end end local added = #fresh if added > 0 then for _, entry in ipairs(fresh) do model[#model + 1] = entry end progress:status("Filling the list...") sortModel(); rebuildList(); refreshSummary() end progress:finish() return added end local function seedFromSelection() -- Use the untyped current selection and keep the individuals (more reliable across FH contexts -- than asking for "INDI" specifically); fall back to the property-box individual. local sel = fhGetCurrentRecordSel() local indis = {} if sel then for _, p in ipairs(sel) do if p and p:IsNotNull() and fhGetTag(p) == "INDI" then indis[#indis + 1] = p end end end if #indis > 0 then addPtrs(indis) else local pb = fhGetCurrentPropertyBoxRecord() if pb and pb:IsNotNull() and fhGetTag(pb) == "INDI" then addPtrs({ pb }) end end end local function actSelect() local sel = fhPromptUserForRecordSel("INDI", -1, getParentWindowHandle()) if sel and #sel > 0 then local n, cancelled = addPtrs(sel) if n == 0 and not cancelled then MessageBox("info", "Those individuals are already in the list.", "OK") end end end local function actRemove() if #getSelectedValues(lstIndis, true) == 0 then MessageBox("info", "Tick one or more individuals to remove.\n\n(Ctrl-click or Shift-click to select several.)", "OK") return end removeSelectedItems(lstIndis, model, function() rebuildList(); refreshSummary() end) end local function actClear() if #model == 0 then return end model = {} rebuildList(); refreshSummary() end local function actSelectAll() local count = tonumber(lstIndis.COUNT) or 0 if count > 0 then lstIndis.value = string.rep("+", count) end end -- See lstRecs:k_any - Ctrl+I/Ctrl+T are window-global now; Ctrl+A and Del stay list-scoped. function lstIndis:k_any(c) if c == iup.K_cA then actSelectAll(); return iup.IGNORE elseif c == iup.K_DEL then actRemove(); return iup.IGNORE end return iup.CONTINUE end local txtMapFolder -- the Mapping-pane folder field (assigned below); chooseMapFolder keeps it in step local function outputFolder() local dir = myMapConfig:getString("Web maps", "mapFolder", "") -- "" when unset: callers force a choice rather than silently writing to the plugin data folder. if dir ~= "" and not dir:match("[/\\]$") then dir = dir .. "\\" end return dir end -- Pop the folder picker, persist the choice as this project's default, reflect it in the pane field, -- and return the chosen folder (raw, no trailing slash) or nil if cancelled. Shared by the Folder… -- button and by Build (which forces a choice when none is set yet). local function chooseMapFolder() local start = myMapConfig:getString("Web maps", "mapFolder", "") if start == "" or not (fhfu.folderExists and fhfu.folderExists(start)) then -- FH reports the project Public folder even before it has been created on disk, so use it as -- the picker's start only when it actually exists; otherwise fall back to the plugin data -- folder (always present). Mirrors Add Trees - never assume Public exists. local pub = fhGetContextInfo("CI_PROJECT_PUBLIC_FOLDER") or "" if pub ~= "" and fhfu.folderExists and fhfu.folderExists(pub) then start = pub else start = cstrPluginDir or "" end end local dlg = iup.filedlg({ dialogtype = "DIR", title = "Output folder (maps, or your website's pages when embedding)", directory = start }) dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT) local chosen if tonumber(dlg.status) ~= -1 then local v = dlg.value or "" if v ~= "" then chosen = v myMapConfig:setValues("Web maps", nil, { mapFolder = v }) -- persist as the project default if txtMapFolder then txtMapFolder.value = v end end end dlg:destroy() return chosen end -- Suggested name for a combined map, pre-filled into the build-time prompt. If every mapped -- person shares one surname -> " family"; otherwise fall back to the old "N individuals". -- Surname comes from FH's own name parse (~.NAME:SURNAME), not string-splitting the display name. local function suggestGroupName(ptrs, n) local surname for _, ptr in ipairs(ptrs) do local s = (fhGetItemText(ptr, "~.NAME:SURNAME") or ""):gsub("^%s+", ""):gsub("%s+$", "") if s == "" then return n .. " individuals" end -- a missing surname -> no shared-name claim if surname == nil then surname = s elseif surname:lower() ~= s:lower() then return n .. " individuals" end end return (surname and surname ~= "") and (surname .. " family") or (n .. " individuals") end -- Filesystem-safe output file name derived from the map's title, so several named group maps can -- coexist (the old build wrote one fixed MapGroup.html). ASCII-only ranges (FH runs Lua in a cp1252 -- locale where %w is unreliable on UTF-8); accents/punctuation in the title drop from the file name -- only - the visible page title keeps them. Empty slug -> the historic MapGroup.html. local function groupFileName(title) local slug = (title or ""):gsub("[^A-Za-z0-9 _%-]", ""):gsub("%s+", "-") :gsub("%-+", "-"):gsub("^%-+", ""):gsub("%-+$", "") if slug == "" then return "MapGroup.html" end return "Map-" .. slug .. ".html" end -- Build the map(s) for the listed individuals, honouring the "Make" option. local function buildMaps() if #model == 0 then MessageBox("info", "Choose one or more individuals first (Mapping menu, or Ctrl+I).", "OK") return end -- Resolve the output mode and whether we're embedding up front. Published (embed feature on) maps -- are publish-safe: keyed historic layers dropped unless the advanced opt-in keeps them, and the -- legend links each person to their website page. The page-name prefix is configurable (default -- "ind") for sites generated by other tools. local mode = cMakeOptions[tonumber(lstMake.value)] or "Automatic" -- the pane's Make choice if mode == "Automatic" then mode = (#model > 1) and "One combined map" or "A page per individual" end local published = myMapConfig:getBool("Website embedding", "embedInPages", false) local keepKeyed = myMapConfig:getBool("Embedding (advanced)", "embedKeepKeyed", false) -- advanced opt-in local pagePrefix = myMapConfig:getString("Embedding (advanced)", "pagePrefix", "ind") local embedActive = published and mode == "A page per individual" -- "Temporary maps" (Web maps): build into a throwaway folder wiped when the plugin closes, so a -- quick look leaves nothing to tidy up. Local maps only - published maps must persist to be -- injected, so embedding always wins. local useTemp = myMapConfig:getBool("Web maps", "tempMaps", false) and not published -- Record-flag privacy applies to PUBLISHED maps (embedded in the website). Local/standalone maps are -- the user's own file: honour the selection, but warn if it includes people a published map would hold -- back. privacy = nil for local builds, which disables all filtering in eventsForIndividual. local privOpts = { omit = myMapConfig:getBool("Privacy", "omitPrivate", true), basicFlag = myMapConfig:getBool("Privacy", "basicDetailsEnabled", true) and myMapConfig:getString("Privacy", "basicDetailsFlag", "Living") or nil, } local privacy = published and privOpts or nil if not published then local nSensitive = 0 for _, e in ipairs(model) do if privacyBlocks(e.ptr, privOpts) then nSensitive = nSensitive + 1 end end if nSensitive > 0 and MessageBox("question", string.format( "This is a local map (not embedded in your website), so privacy filtering is NOT applied.\n\n" .. "%d of the listed individual(s) are flagged Private or '%s'; the map file will show their " .. "locations.\n\nBuild it anyway?", nSensitive, tostring(privOpts.basicFlag or "Living")), "OKCANCEL") ~= "OK" then return end end -- Where the maps are written. Temporary maps go to a throwaway folder under the plugin data folder -- (no Output folder needed - that's the point). Otherwise the single Output folder serves both roles: -- where standalone/combined maps are written, and (when embedding) the folder holding the website's -- individual pages - required either way, so prompt if unset. local dir if useTemp then dir = tempMapsDir() local okDir, derr = createFolderTree(dir:gsub("[/\\]+$", "")) if not okDir then MessageBox("error", "Could not create the temporary maps folder:\n" .. dir .. (derr and ("\n\n" .. derr) or ""), "OK") return end else dir = outputFolder() if dir == "" then if not chooseMapFolder() then MessageBox("info", "No output folder chosen - nothing was built.\n\nChoose a folder, then Build.", "OK") return end dir = outputFolder() end end local googleLinks = myMapConfig:getBool("Web maps", "mapGoogleLinks", true) local zoom = myMapConfig:getNumber("Web maps", "defaultZoom", 12) local pathOn = myMapConfig:getBool("Web maps", "pathDefault", false) local size = myMapConfig:getString("Web maps", "markerSize", "Medium") local sliderOn = myMapConfig:getBool("Web maps", "timeSlider", false) -- Pick the MapTiler key the historic layers carry, by where the maps will go: -- * local / standalone -> the unrestricted key (Web maps > MapTiler key); -- * published + opt-in -> the domain-restricted PUBLISHED key (never the unrestricted one, -- which a published page would leak); empty -> keyless + a heads-up; -- * published, no opt-in -> keyless. local localKey = myMapConfig:getString("Web maps", "maptilerKey", "") local publishKey = myMapConfig:getString("Embedding (advanced)", "maptilerPublishKey", "") local histKey if not published then histKey = localKey elseif keepKeyed then histKey = publishKey local pk = (publishKey or ""):gsub("%s+", "") if pk == "" then if MessageBox("question", "'Keep keyed historic layers' is on, but no published MapTiler key " .. "is set (Mapping Options > Embedding (advanced)).\n\nThe MapTiler historic layers will be " .. "omitted from the published maps.\n\nBuild anyway? (Cancel to set a domain-restricted " .. "key first.)", "OKCANCEL") ~= "OK" then return end elseif pk == (localKey or ""):gsub("%s+", "") then -- Identical keys defeat the purpose - and worse, you're about to publish with that key. if MessageBox("question", "Your published MapTiler key is the same as your everyday (Web maps) " .. "key.\n\nA single key can't safely do both jobs: if it is restricted to your website's " .. "domain it will not work in your local maps; if it is unrestricted, publishing these maps " .. "exposes it to everyone on the internet who views your site.\n\nUse two separate keys - an " .. "unrestricted one for local maps and a domain-restricted one for the web.\n\nPublish " .. "anyway?", "OKCANCEL") ~= "OK" then return end end else histKey = "" end local histLayers = buildHistoricLayers(histKey) -- Single-map marker colour: honoured only when a map ends up with one person (per-individual -- pages are always one; the combined map honours it only if exactly one person has events). local singleColour = myMapConfig:getString("Web maps", "singleColour", "") -- Group dedup for timeline events: applies ONLY to a combined map, where everyone shares -- one set of markers, so a relative's event would otherwise appear twice (once as their -- own, once as a timeline event on the other person). For a page-PER-individual each map -- is standalone, so NO dedup - father and son each show the other as a timeline event. -- e.id is the FH numeric record ID; groupSet stays empty for the per-individual case. local groupSet = {} if mode == "One combined map" then for _, e in ipairs(model) do groupSet[e.id] = true end end local people, totalEvents, totalNotGeo, withEvents, privacyHeld = {}, 0, 0, 0, 0 for i, e in ipairs(model) do local events, notGeo = eventsForIndividual(e.ptr, groupSet, privacy) local blocked = (privacy and privacyBlocks(e.ptr, privacy)) or false if blocked then if #events > 0 then privacyHeld = privacyHeld + 1 end else totalEvents, totalNotGeo = totalEvents + #events, totalNotGeo + notGeo if #events > 0 then withEvents = withEvents + 1 end end people[i] = { id = e.id, name = e.name, ptr = e.ptr, colour = personColour(i), events = events, notGeo = notGeo, file = "", embed = blocked and "Held for privacy" or "", blocked = blocked } end if totalEvents == 0 then MessageBox("info", (privacyHeld > 0 and (privacyHeld .. " individual(s) held back for privacy; no other listed individual has a geocoded event to map.") or "None of the listed individuals has a geocoded event to map.") .. (totalNotGeo > 0 and ("\n\n" .. totalNotGeo .. " event(s) have a place or address that isn't geocoded yet - geocode them on the Geocoding tab first.") or ""), "OK") return end -- Website-embedding setup. Only per-individual pages can be embedded (a combined map has no -- single host page; embedActive was resolved above) - a combined build with embedding on still -- produces a publish-safe file, just not injected anywhere. local embedOpts, mapsDir, websiteFolder if published and not embedActive then if MessageBox("question", "Embedding is on, but you're making one combined map, which can't be " .. "injected into a single page.\n\nThe combined map will still be built (publish-safe) but " .. "not embedded.\n\nBuild it? (Cancel and set 'Make' to 'A page per individual' to embed " .. "each person.)", "OKCANCEL") ~= "OK" then return end end local mapsSubfolder if embedActive then -- The Output folder IS the website folder when embedding (it holds the .html pages). websiteFolder = dir:gsub("[/\\]+$", "") if websiteFolder == "" or not fhfu.folderExists(websiteFolder) then MessageBox("error", "The Output folder does not exist:\n" .. websiteFolder .. "\n\nWhen embedding, set the Output folder (Web maps) to the folder holding your website's individual pages.", "OK") return end mapsSubfolder = (myMapConfig:getString("Embedding (advanced)", "embedMapsSubfolder", "maps") or ""):gsub("[/\\]+", "") if mapsSubfolder == "" then mapsSubfolder = "maps" end -- Guard the classic mistake: pointing the Output folder at the maps SUBFOLDER, not the website's -- page folder - which double-nests the maps AND finds no pages to inject into. If its leaf is the -- maps-subfolder name, offer the parent. do local leaf = websiteFolder:match("[^/\\]+$") or "" if leaf:lower() == mapsSubfolder:lower() then local parent = websiteFolder:gsub("[/\\]*[^/\\]+$", "") if parent ~= "" and fhfu.folderExists(parent) and MessageBox("question", "The Output folder ends in '" .. mapsSubfolder .. "', which is the maps subfolder, not your " .. "website's page folder, so the maps would be double-nested and no pages would be found." .. "\n\nUse the parent folder instead?\n" .. parent, "OKCANCEL") == "OK" then websiteFolder = parent end end end -- The Output folder must be the website PAGE folder: at least one selected person's page -- (.html) must exist there, or embedding has nothing to inject into. local anyPage = false for _, p in ipairs(people) do if #p.events > 0 and not p.blocked and fhfu.fileExists(websiteFolder .. "\\" .. pagePrefix .. p.id .. ".html") then anyPage = true; break end end if not anyPage then MessageBox("error", "This folder has no " .. pagePrefix .. "*.html pages. Embedding will fail. " .. "Point the Output folder at your website's page folder.", "OK") return end mapsDir = websiteFolder .. "\\" .. mapsSubfolder local okDir, derr = createFolderTree(mapsDir) if not okDir then MessageBox("error", "Could not create the maps subfolder:\n" .. mapsDir .. (derr and ("\n\n" .. derr) or ""), "OK") return end embedOpts = { pagePrefix = pagePrefix, divClass = myMapConfig:getString("Embedding (advanced)", "embedDivClass", "fhsection fhsecdata"), placement = cEmbedPlacement[myMapConfig:getString("Embedding (advanced)", "embedPlacement", "At top of div")] or "top", align = cEmbedAlign[myMapConfig:getString("Website embedding", "embedAlign", "Centre")] or "centre", caption = myMapConfig:getString("Website embedding", "embedCaption", ""), captionClass = myMapConfig:getString("Embedding (advanced)", "embedCaptionClass", "fs-embed-caption"), containerClass = myMapConfig:getString("Embedding (advanced)", "embedContainerClass", "fs-embed-map"), hideable = myMapConfig:getBool("Website embedding", "embedHideable", false), removeDiv = myMapConfig:getBool("Embedding (advanced)", "embedRemoveDiv", false), removeDivClass = myMapConfig:getString("Embedding (advanced)", "embedRemoveDivClass", ""), frameHeight = myMapConfig:getNumber("Website embedding", "frameHeight", 430), } -- Confirm before changing the user's website files (the plugin keeps no backups of its own). local pageWord = (withEvents == 1) and "page" or "pages" -- Show the RESOLVED paths (pages folder + an example page + where the maps go) so a wrong folder -- is obvious before anything is written. local egPage = "" for _, p in ipairs(people) do if #p.events > 0 and not p.blocked then egPage = websiteFolder .. "\\" .. pagePrefix .. p.id .. ".html"; break end end local msg = string.format( "Embedding is on.\n\nPages folder (%d %s updated):\n%s\ne.g. %s\n\nMaps written to:\n%s\n", withEvents, pageWord, websiteFolder, egPage, mapsDir) if embedOpts.removeDiv and embedOpts.removeDivClass ~= "" and embedOpts.removeDivClass ~= embedOpts.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', embedOpts.removeDivClass) end msg = msg .. "\nBack up your website first if you have not already. Continue?" if MessageBox("question", msg, "OKCANCEL") ~= "OK" then return end end local written, firstPath, writeErr = {}, nil, nil local embedded, firstEmbeddedPage, cancelled = 0, nil, false if mode == "A page per individual" then -- The people who actually get a map written; drives the progress bar's total. local toBuild = {} for _, p in ipairs(people) do if #p.events > 0 and not p.blocked then toBuild[#toBuild + 1] = p end end local progress = Progress.new(#toBuild, 1, 20, dlgMain) -- shows only once there are >= 20 maps for _, p in ipairs(toBuild) do progress:update((embedActive and "Embedding " or "Building ") .. p.name) if progress:isCancelled() then cancelled = true; break end local fileName = "Map" .. p.id .. ".html" local html = buildPeopleMapHtml(p.name, { { name = p.name, colour = p.colour, id = p.id, events = p.events } }, googleLinks, zoom, pathOn, size, singleColour, sliderOn, histLayers, published, pagePrefix) -- Embedding: the publish-safe map goes into the website's "maps" subfolder (so the iframe -- src resolves) and is injected into ind.html; otherwise it's a standalone file in the -- map output folder. local path = embedActive and (mapsDir .. "\\" .. fileName) or (dir .. fileName) local ok, err = fhfu.createTextFile(path, true, true, html, 8) if ok then written[#written + 1] = path; firstPath = firstPath or path p.file = embedActive and (mapsSubfolder .. "\\" .. fileName) or fileName if embedActive then -- The caption's [NAME] and the iframe title use the DATELESS name (the page already -- shows the person's dates); the standalone map title keeps them. local emb, note = embedMapIntoPage(websiteFolder, p.id, indiNamePlain(p.ptr), mapsSubfolder .. "/" .. fileName, embedOpts) if emb then embedded = embedded + 1; p.embed = "Yes" firstEmbeddedPage = firstEmbeddedPage or (websiteFolder .. "\\" .. pagePrefix .. p.id .. ".html") else p.embed = "No - " .. note end end else writeErr = err end end progress:finish() else local mapPeople, mapPtrs = {}, {} for _, p in ipairs(people) do if #p.events > 0 and not p.blocked then mapPeople[#mapPeople + 1] = { name = p.name, colour = p.colour, id = p.id, events = p.events } mapPtrs[#mapPtrs + 1] = p.ptr end end -- Name the combined map. One person -> just their name (no prompt). Several -> ask, pre-filled -- with the auto-derived suggestion; the chosen name is the page title AND drives the file name. local title if #mapPeople == 1 then title = mapPeople[1].name else local suggestion = suggestGroupName(mapPtrs, #mapPeople) local okName, entered = GetText({ strPrompt = "Name this combined map (page title and file name):", strDefault = suggestion, }) if not okName then return end -- Cancel abandons the whole build title = (entered or ""):gsub("^%s+", ""):gsub("%s+$", "") if title == "" then title = suggestion end end local fileName = groupFileName(title) local ok, err = fhfu.createTextFile(dir .. fileName, true, true, buildPeopleMapHtml(title, mapPeople, googleLinks, zoom, pathOn, size, singleColour, sliderOn, histLayers, published, pagePrefix), 8) if ok then written[#written + 1] = dir .. fileName; firstPath = dir .. fileName for _, p in ipairs(people) do if #p.events > 0 then p.file = fileName end end else writeErr = err end end if #written == 0 then MessageBox("error", "Could not write the map page(s):\n\n" .. tostring(writeErr), "OK") return end -- Where the maps actually landed (the website "maps" subfolder when embedding, else the map folder). local reportDir = embedActive and mapsDir or dir -- Log each individual's outcome to the map result window (shown, clickable, on close). for _, p in ipairs(people) do addMapRow(p.ptr, tostring(#p.events), tostring(p.notGeo), (p.file ~= "" and p.file) or (#p.events == 0 and "(no located events)" or "(not written)"), p.embed or "", reportDir) end -- What to open when done. A single map opens in the browser (the injected page when embedding, so -- the map is seen in situ); several maps open the folder the map files were written to - the "maps" -- subfolder when embedding (reportDir), not its parent - rather than one arbitrary person's page. local openTarget if embedActive then openTarget = (#written == 1 and firstEmbeddedPage) or mapsDir else openTarget = (#written == 1 and firstPath) or dir end pcall(fhShellExecute, openTarget) local msg = (#written == 1 and "Built 1 map." or ("Built " .. #written .. " maps.")) .. "\n\n" .. totalEvents .. " event(s) mapped across " .. withEvents .. " individual(s)." if embedActive then local notEmbedded = #written - embedded msg = msg .. "\n" .. embedded .. " of " .. #written .. " embedded into website pages" .. (notEmbedded > 0 and (" (" .. notEmbedded .. " not embedded - see the Embedded column in the results table).") or ".") end if privacyHeld > 0 then msg = msg .. "\n" .. privacyHeld .. " individual(s) held back for privacy (Private / basic-details) - no map embedded." end if cancelled then msg = msg .. "\n\nStopped early - you cancelled before all the maps were built." end if totalNotGeo > 0 then msg = msg .. "\n" .. totalNotGeo .. " event(s) aren't geocoded yet - geocode their places/addresses on the Geocoding tab to include them." end if useTemp then msg = msg .. "\n\nThese are temporary maps: they will be deleted when you close Add Maps. " .. "Keep the browser tab open while you look at them." end MessageBox("info", msg .. "\n\nWritten to:\n" .. reportDir, "OK") end local btnBuild = makeButton({ title = "&Build Map(s)", tip = "Build the map(s) for the listed individuals (per the 'Make' choice)", callback = function() buildMaps(); return iup.DEFAULT end }) local btnOpen = makeButton({ title = "Ope&n Folder", tip = "Open the map output folder", callback = function() local dir = outputFolder() if dir == "" or not (fhfu.folderExists and fhfu.folderExists(dir)) then MessageBox("info", "No map folder yet - choose one with Folder…, or set it in Mapping Options.", "OK") else pcall(fhShellExecute, dir) end return iup.DEFAULT end }) -- Output folder, shown and editable here at build time. It is this project's saved default (Mapping -- Options), so changing it with Folder… both uses it now AND persists it as the project's default. txtMapFolder = makeText({ value = myMapConfig:getString("Web maps", "mapFolder", ""), readonly = "YES", expand = "HORIZONTAL", tip = "Where the maps are written (saved per project). If you want to embed " .. "maps into your website, point this at the folder holding your website's individual pages - the " .. "maps then go into a subfolder of it and are injected into each person's page." }) local btnFolder = makeButton({ title = "Fol&der…", tip = "Choose the output folder (your website's page folder, if embedding)", callback = function() chooseMapFolder(); return iup.DEFAULT end }) seedFromSelection() rebuildList() refreshSummary() local box = iup.vbox({ makeLongLabel({ title = "Use the Mapping menu (or Ctrl+I) to choose individuals - FH's own record picker.\nBuild Leaflet maps of their located events (Address-preferred). Choose which events with Options ▸ Events to Map." }), iup.frame({ lstIndis, title = " Individuals ", expand = "YES" }), lblSummary, iup.hbox({ makeLabel({ title = "Output folder:" }), txtMapFolder, btnFolder, alignment = "ACENTER", gap = "6" }), iup.hbox({ makeLabel({ title = "Make:" }), lstMake, btnBuild, btnOpen, alignment = "ACENTER", gap = "6" }), margin = "4x4", gap = "8", expand = "YES", }) -- folderSync re-reads the saved project folder into the pane field, so a change made in Mapping -- Options shows here too (the pane field and the Options field edit the one stored value). local function folderSync() txtMapFolder.value = myMapConfig:getString("Web maps", "mapFolder", "") end return { vbox = box, folderSync = folderSync, actions = { select = actSelect, remove = actRemove, clear = actClear }, -- This pane's Alt+letter buttons (see collectMnemonics), for the main dialog's view-aware k_any. mnemonics = collectMnemonics({ B = btnBuild, N = btnOpen, D = btnFolder }) } end -- True (and warns) if a batch geocode is running, so the close paths can refuse. Closing mid-batch -- leaves an orphaned window that blocks FH from exiting (the loop pumps the UI), so we hold the close. local function closeHeldByGeocoding() if not pluginBusy then return false end MessageBox("warning", "Geocoding is still running.\n\nPlease wait for it to finish - or click Cancel on the" .. " progress window - before closing.", "OK") return true end -- Title-bar suffix for each mode, so the window always says which mode you're in. local cViewTitle = { places = "Geocoding - Places", addresses = "Geocoding - Addresses", mapping = "Mapping - Event maps", } -- Menus rather than tabs. So there is no tab control: the three panes (geocode Places, geocode -- Addresses, the web maps) are stacked, and only the one for the chosen menu mode is shown. The -- top-level menus are Geocoding (with a Places/Addresses mode, plus the selection-list actions) and -- Mapping; the current mode also shows in the title bar. local function makeMainDialog() local placesTab = buildGeocodeTab("_PLAC", "places", "Places", "Place") local addrTab = buildGeocodeTab("_ADDR", "addresses", "Addresses", "Address") local mappingTab = buildWebMapsTab() local panes = { places = placesTab.vbox, addresses = addrTab.vbox, mapping = mappingTab.vbox } local paneOrder = { panes.places, panes.addresses, panes.mapping } local geoActionsByMode = { places = placesTab.actions, addresses = addrTab.actions } local dlg -- set once makeDialog has built it (used for the title + refresh) local menuData -- set once createMenuBar has run (radio ticks + Select relabel) local currentView = "places" -- which pane is shown: places | addresses | mapping local currentGeoMode = "places" -- last geocoding sub-mode chosen (places | addresses) -- Show one pane (hide the others, the way MenuBar.manageUIVisibility does), and bring the title -- bar, the menu radio ticks and the Select label into line with it. local function setView(name) currentView = name if name == "places" or name == "addresses" then currentGeoMode = name end -- Keep the region-bias field on the pane we're showing in step with the one session value. if name == "places" and placesTab.biasSync then placesTab.biasSync() elseif name == "addresses" and addrTab.biasSync then addrTab.biasSync() elseif name == "mapping" and mappingTab.folderSync then mappingTab.folderSync() end for _, p in ipairs(paneOrder) do local on = (panes[name] == p) p.visible = on and "YES" or "NO" p.floating = on and "NO" or "YES" end if menuData then -- The Places/Addresses ticks track the geocoding mode and persist even while the Mapping -- view is shown (clearer for users; the mode and its target list are retained); Event Maps -- is ticked only while the maps pane is actually showing. menuData.updateValue("viewPlaces", currentGeoMode == "places" and "ON" or "OFF") menuData.updateValue("viewAddresses", currentGeoMode == "addresses" and "ON" or "OFF") menuData.updateValue("viewMapping", currentView == "mapping" and "ON" or "OFF") -- updateTitle re-appends the "\tCtrl+I" accelerator text registered for this item. menuData.updateTitle("geoSelect", "&Select: " .. ((currentGeoMode == "addresses") and "Addresses" or "Places")) end if dlg then dlg.title = cstrPluginName .. " " .. cstrPluginVersion .. " - " .. (cViewTitle[name] or "") iup.Refresh(dlg) end end -- A Geocoding-menu list action acts on the current geocoding sub-mode; if we're in the Mapping -- view, switch back to that sub-mode's pane first so the action is visibly on the right list. local function geoAction(key) return function() if currentView == "mapping" then setView(currentGeoMode) end geoActionsByMode[currentGeoMode][key]() return iup.DEFAULT end end -- A Mapping-menu list action switches to the Mapping view (if not there) and runs on its list. local function mapAction(key) return function() if currentView ~= "mapping" then setView("mapping") end mappingTab.actions[key]() return iup.DEFAULT end end -- Window-global keyboard shortcuts for the list actions, dispatched from the dialog's k_any so they -- fire from anywhere in the window (previously they only worked when a list had focus). Unlike the menu -- items - geoAction/mapAction deliberately switch INTO their section - a shortcut acts on whatever view -- is current. Del and Ctrl+A stay on each list's own k_any: Del would clobber text editing elsewhere, -- and Select-All has no menu item. The menu items carry the shortcut TEXT only (no key), so the menus -- still advertise them without double-binding. local function currentActions() if currentView == "mapping" then return mappingTab.actions end return geoActionsByMode[currentGeoMode] end local mainAccelerators = { { key = iup.K_cI, action = function() currentActions().select() end }, { key = iup.K_cT, action = function() currentActions().clear() end }, } local menuBarData = MenuBar.createMenuBar({ -- Our own Exit (suppresses the automatic one) so it can refuse while a batch is geocoding. It -- routes through the dialog's close_cb (which showTrackedDialog wrapped to save size/position) - -- returning iup.CLOSE directly would skip that, so Exit wouldn't remember the window like X does. fileMenu = { exit = MenuBar.helpers.createMenuItem("E&xit", function() if closeHeldByGeocoding() then return iup.DEFAULT end if dlgMain and dlgMain.close_cb then return dlgMain.close_cb(dlgMain) end return iup.CLOSE end), }, -- Menus, not tabs: Geocoding (Places/Addresses mode + Add Trees-style selection actions) and -- Mapping, then Options, before Help. items = { MenuBar.helpers.createSubmenu("&Geocoding", { MenuBar.helpers.createRadioMenuItem("&Places", "places", "places", function() setView("places"); return iup.DEFAULT end, nil, "viewPlaces"), MenuBar.helpers.createRadioMenuItem("&Addresses", "places", "addresses", function() setView("addresses"); return iup.DEFAULT end, nil, "viewAddresses"), {}, -- separator MenuBar.helpers.createMenuItem("&Select: Places", geoAction("select"), nil, "geoSelect", { text = "Ctrl+I" }), MenuBar.helpers.createMenuItem("&Remove Selected", geoAction("remove"), nil, nil, { text = "Del" }), MenuBar.helpers.createMenuItem("&Clear List", geoAction("clear"), nil, nil, { text = "Ctrl+T" }), }, "menuGeocoding"), MenuBar.helpers.createSubmenu("&Mapping", { MenuBar.helpers.createRadioMenuItem("&Event Maps", "places", "mapping", function() setView("mapping"); return iup.DEFAULT end, nil, "viewMapping"), {}, -- separator MenuBar.helpers.createMenuItem("&Select: Individuals", mapAction("select"), nil, nil, { text = "Ctrl+I" }), MenuBar.helpers.createMenuItem("&Remove Selected", mapAction("remove"), nil, nil, { text = "Del" }), MenuBar.helpers.createMenuItem("&Clear List", mapAction("clear"), nil, nil, { text = "Ctrl+T" }), }, "menuMapping"), -- One Options menu instead of scattered top-level items. Each is a menu item, so the fact- -- type picker fires at MainLoop level (MainLoop -> popup) - never popup-in-popup. MenuBar.helpers.createSubmenu("&Options", { MenuBar.helpers.createMenuItem("&Geocoding Options…", function() myGeoConfig:showConfigDialog(nil, "add-maps-reference#geocoding-options") -- Modal: returns once the dialog closes. Refresh the "(this country only)" note on both -- geocode panes, as the geocoder or the restrict tick may have changed. if placesTab.biasSync then placesTab.biasSync() end if addrTab.biasSync then addrTab.biasSync() end return iup.DEFAULT end, nil, "menuGeoOptions"), MenuBar.helpers.createMenuItem("&Mapping Options…", function() myMapConfig:showConfigDialog(nil, "add-maps-reference#webmaps-options") return iup.DEFAULT end, nil, "menuMapOptions"), {}, -- separator MenuBar.helpers.createMenuItem("&Events to Map…", function() chooseFactTypes(); return iup.DEFAULT end, nil, "menuFactTypes"), }, "menuOptions"), }, helpMenu = { help = MenuBar.helpers.createMenuItem("&Help", function() if myHelp then myHelp:show("") end return iup.DEFAULT end), about = MenuBar.helpers.createMenuItem("&About", function() MessageBox("info", cstrPluginName .. " v" .. cstrPluginVersion .. "\n\nGeocodes the places and addresses in your project and builds interactive maps of where life events happened, which you can embed into the matching pages of a Family Historian generated website.", "OK") return iup.DEFAULT end), }, }) menuData = menuBarData local content = iup.vbox({ panes.places, panes.addresses, panes.mapping, margin = "6x6", gap = "8", expand = "YES" }) -- No explicit size here: EXECUTE measures the true content-minimum first (with no user size set, -- so naturalsize reflects the content, not HALFxHALF), then sets the half-screen opening size. dlg = makeDialog(content, { title = cstrPluginName .. " " .. cstrPluginVersion, expand = "YES", resize = "YES", menubox = "YES", menu = menuBarData.menuBar, name = "AddMapsMain", accelerators = mainAccelerators, -- Ctrl+I / Ctrl+T fire from anywhere in the window -- Esc closes the window, mirroring File > Exit exactly: it refuses while a batch is geocoding and -- otherwise routes through close_cb (which showTrackedDialog wrapped to save size/position). No -- Enter default - the primary action differs per mode (Geocode vs Build) and the lists own Enter. on_escape = function() if closeHeldByGeocoding() then return iup.IGNORE end if dlgMain and dlgMain.close_cb then return dlgMain.close_cb(dlgMain) end return iup.CLOSE end, }) -- Button access keys (Alt+letter): route to the pane that is showing, ahead of IUP's one-slot-per-letter -- mnemonic (see collectMnemonics). Chained in front of makeDialog's own k_any so Ctrl+I / Ctrl+T, Esc and -- F1 behave as before. Alt+letters this pane doesn't own fall through (CONTINUE) so the menu bar's -- Alt+F/G/M/O/H still work. local function currentMnemonics() if currentView == "mapping" then return mappingTab.mnemonics end return (currentGeoMode == "addresses" and addrTab or placesTab).mnemonics end local baseKAny = dlg.k_any dlg.k_any = function(self, c) local r = dispatchMnemonic(currentMnemonics(), c) if r ~= nil then return r end if baseKAny then return baseKAny(self, c) end return iup.CONTINUE end setView("places") -- initial mode: show Places, set the title and the radio ticks return dlg end -------------------------------------------------------------- -- EXECUTE -------------------------------------------------------------- myHelp = Help.new({}) help = myHelp -- global referenced by the Dialog / Config helpers -- Global settings (geocoder keys/behaviour + window geometry) live per-user; mapping settings live -- per-project (CURRENT_PROJECT). The project scope needs project mode - Add Maps always runs inside a -- project (it reads INDI records), but guard anyway and fall back to per-user so it can never error. myGeoConfig = Config.new(tblGeoConfig, "CURRENT_USER", cstrPluginName .. ".ini") local mapScope = ((fhGetContextInfo("CI_PROJECT_FILE") or "") ~= "") and "CURRENT_PROJECT" or "CURRENT_USER" myMapConfig = Config.new(tblMapConfig, mapScope, cstrPluginName .. " - Maps.ini") -- Seed the session region-bias override from the saved global default (editable on the Geocoding pane). gRegionBias = myGeoConfig:getString("Geocoding", "regionBias", "gb") -- Enumerate the project's fact types and load the user's selection (seeding today's default first run). loadFactSelection() -- FH supports only one result set per plugin run, so geocoding and mapping outcomes share this single -- object: "Record" carries the geocoded record or mapped individual, "Activity" says which kind of row -- it is ("Geocoded" / "Map"), and the geocode-only / map-only columns are blank on rows that don't apply -- (see addGeocodeRow / addMapRow above, which own the column order). myResults = Results(15) myResults.Title(cstrPluginName .. " - results") myResults.Headings({ "Record", "Activity", "Kind", "Geocoder", "Result", "Latitude", "Longitude", "Quality", "Standardised", "Geocoder's name", "Events mapped", "Not geocoded", "Map file", "Embedded", "Folder" }) myResults.Types({ "item", "text", "text", "text", "text", "text", "text", "text", "text", "text", "text", "text", "text", "text", "text" }) myResults.Width({ 160, 60, 48, 72, 96, 72, 72, 60, 140, 160, 60, 60, 96, 150, 200 }) -- 1/4-character units (80 ~ 20 chars) myResults.Sort({ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }) -- 0 = no default sort (keep run order); columns stay clickable myResults.Visibility({ "show", "show", "show", "show", "show", "show", "show", "show", "show", "show", "show", "show", "show", "show", "show" }) -- Converges on the shared suppress mechanism: the didGeocode/didMap guard at the foot of the -- file already makes an idle close silent (Display() is never called when nothing was -- attempted); NoResults(nil) additionally keeps a session that DID attempt geocoding or -- mapping silent even if it produced no rows (family UX policy, 23 Jul 2026). myResults.NoResults(nil) dlgMain = makeMainDialog() -- Refuse the title-bar X (and Alt+F4) while a batch is geocoding; set before showTrackedDialog so it -- becomes the "original" close the tracker chains to. iup.IGNORE vetoes the close; iup.CLOSE allows it. dlgMain.close_cb = function() if closeHeldByGeocoding() then return iup.IGNORE end return iup.CLOSE end DoNormalize() -- Measure the true content-minimum now, while no user size is set, so naturalsize reflects the -- content (the Coordinate form + the summary/Geocode row - the list is short and scrolls), not a -- HALFxHALF or saved size. This becomes the window's minimum, so it can't be dragged small enough to -- clip the form, but it doesn't grow when a larger size is saved. iup.Refresh(dlgMain) local contentMin = dlgMain.naturalsize -- "WxH" px, content-driven (the list is short); the window minimum -- Opening size: at least half the screen, but never smaller than the content needs in EITHER -- dimension - otherwise it opens too short and snaps taller the moment you resize. (This content needs -- a little more than half the screen height.) showTrackedDialog uses a saved size instead, when present. do local sw, sh = tostring(iup.GetGlobal("SCREENSIZE")):match("(%d+)x(%d+)") local cw, ch = tostring(contentMin):match("(%d+)x(%d+)") sw, sh, cw, ch = tonumber(sw), tonumber(sh), tonumber(cw), tonumber(ch) if sw and sh and cw and ch then dlgMain.rastersize = math.max(sw // 2, cw) .. "x" .. math.max(sh // 2, ch) else dlgMain.size = "HALFxHALF" end end -- One-off: discard a saved window size left over from the v0.21.4 build (implausibly narrow), so it -- opens at the proper size rather than that collapsed width. do local saved = myGeoConfig:getString("Dialogs", "Dialog_Main.rastersize", "") local w = tonumber(tostring(saved):match("^(%d+)")) if w and w < 500 then myGeoConfig:setValues("Dialogs", "Dialog_Main", { rastersize = "" }) end end myGeoConfig:showTrackedDialog(dlgMain, "Main") if contentMin and contentMin ~= "" then dlgMain.minsize = contentMin end cleanupTempMaps() -- clear any temporary maps a previous session left behind (crash / force-close) iup.MainLoop() cleanupTempMaps() -- delete this session's temporary maps on the way out destroyAllDialogs() -- Show the result window for whatever was actually done this session (geocoding and/or mapping share -- the one Results object - FH supports only one result set per plugin run). if (didGeocode or didMap) and myResults and myResults.Display then myResults.Display() end