Quick Family Facts.fh_lua

--[[
@Title:			Quick Family Facts
@Type:				Standard
@Author:			Mike Tate
@Contributors:	
@Version:			1.4
@Keywords:		
@LastUpdated:		03 Feb 2026
@Licence:			This plugin is copyright (c) 2026 Mike Tate & 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:		Quick family details popup snapshot can be invoked with Alt T Q
@V1.8:				Add Check for Updates button; fhInitialise(); Use "NATIVEPARENT"; Reset Window button;
@V1.3:				Cater for UTF-8 accented characters & symbols in .dat filepath;
@V1.2:				Ensure all versions use one Plugin Data .dat file; FH V7 Lua 3.5 IUP 3.28;
@V1.1:				Optional [Rec Id] for each family & person, improve Formatting, avoid duplicated partners, and rename Root Person as Family Root;
@V1.0:				First published version in Plugin Store;
]]

require("iuplua")																-- To access IUP GUI library		-- V1.2
require("luacom")																-- To create File System Object	-- V1.8
local FSO = luacom.CreateObject("Scripting.FileSystemObject")

local strVersion = "1.4"
local strPluginTitle = " Quick Family Facts "..strVersion				-- V1.4

if fhGetAppVersion() > 5 then													-- Cater for Unicode UTF-8 from FH Version 6 onwards
	fhSetStringEncoding("UTF-8")
	iup.SetGlobal("UTF8MODE","YES")
	iup.SetGlobal("CUSTOMQUITMESSAGE","YES")								-- Needed for IUP 3.28 -- V1.2
end

function Encoding()
	if fhGetAppVersion() > 5 then return fhGetStringEncoding() end
	return "ANSI"
end -- function Encoding

-- Split a string into numbers using " " or "," or "x" separators		-- Any non-number remains as a string
function splitnumbers(strTxt)
	local tblNum = {}
	strTxt = tostring(strTxt or "")
	strTxt:gsub("([^ ,x]+)", function(strNum) tblNum[#tblNum+1] = tonumber(strNum) or strNum end)
	return tblNum[1],tblNum[2],tblNum[3],tblNum[4]
end -- function splitnumbers

-- Report error message --
local function doError(strMessage,errFunction)
	-- strMessage		~ error message text
	-- errFunction	~ optional error reporting function
	if type(errFunction) == "function" then
		errFunction(strMessage)
	else
		error(strMessage)
	end
end -- function doError

-- Convert filename to ANSI alternative and indicate success --
function FileNameToANSI(strFileName,strAnsiName)
	-- strFileName	~ full file path
	-- strAnsiFile	~ ANSI file name & type
	-- return values	~ ANSI file path, true if original path was ANSI compatible
	if Encoding() == "ANSI" then return strFileName, true end
	local isFlag = fhIsConversionLossFlagSet()
	fhSetConversionLossFlag(false)
	local strAnsi = fhConvertUTF8toANSI(strFileName)
	local wasAnsi = true
	if fhIsConversionLossFlagSet() then
		strAnsiName = strAnsiName or "ANSI.ANSI"
		strAnsi = fhGetContextInfo("CI_APP_DATA_FOLDER").."\\Plugin Data\\"..strAnsiName
		wasAnsi = false
	end
	fhSetConversionLossFlag(isFlag)
	return strAnsi, wasAnsi
end -- function FileNameToANSI

-- Get parent folder --
function GetParentFolder(strFileName)
	-- strFileName	~ full file path
	-- return value	~ parent folder path
	local strParent = FSO:GetParentFolderName(strFileName)	--! Faulty in FH v6 with Unicode chars in path
	if fhGetAppVersion() == 6 then
		local _, wasAnsi = FileNameToANSI(strFileName)
		if not wasAnsi then
			strParent = strFileName:match("^(.+)[\\/][^\\/]+[\\/]?$")
		end
	end
	return strParent
end -- function GetParentFolder

-- Check if file exists --
function FlgFileExists(strFileName)
	-- strFileName	~ full file path
	-- return value	~ true if it exists
	return FSO:FileExists(strFileName)
end -- function FlgFileExists

-- Delete a file if it exists --
function DeleteFile(strFileName,errFunction)
	-- strFileName	~ full file path
	-- errFunction	~ optional error reporting function
	-- return value	~ true if file does not exist or is deleted else false
	if FSO:FileExists(strFileName) then
		FSO:DeleteFile(strFileName,true)
		if FSO:FileExists(strFileName) then
			doError("File Not Deleted:\n"..strFileName.."\n",errFunction)
			return false
		end
	end
	return true
end -- function DeleteFile

-- Copy a file if it exists and destination is not a folder --
function CopyFile(strFileName,strDestination)
	-- strFileName	~ full source file path
	-- strDestination~ full target file path
	-- return value	~ true if file exists and is copied else false
	if MakeFolder(GetParentFolder(strDestination)) and FSO:FileExists(strFileName) and not FSO:FolderExists(strDestination) then
		FSO:CopyFile(strFileName,strDestination)
		if FSO:FileExists(strDestination) then
			return true
		end
	end
	return false
end -- function CopyFile

-- Move a file if it exists and destination is not a folder --
function MoveFile(strFileName,strDestination)
	-- strFileName	~ full source file path
	-- strDestination~ full target file path
	-- return value	~ true if file exists and is moved else false
	if MakeFolder(GetParentFolder(strDestination)) and FSO:FileExists(strFileName) and not FSO:FolderExists(strDestination) then
		if DeleteFile(strDestination) then
			FSO:MoveFile(strFileName,strDestination)
			if FSO:FileExists(strDestination) then
				return true
			end
		end
	end
	return false
end -- function MoveFile

-- Open File with ANSI path and return Handle --
function OpenFile(strFileName,strMode)
	-- strFileName	~ full file path
	-- strMode		~ "r", "w", "a" optionally suffixed with "+" &/or "b"
	-- return value	~ file handle
	local fileHandle, strError = io.open(strFileName,strMode)
	if fileHandle == nil then
		error("\n Unable to open file in \""..strMode.."\" mode. \n "..strFileName.." \n "..strError.." \n")
	end
	return fileHandle
end -- function OpenFile

-- Save string to file --
function SaveStringToFile(strContents,strFileName,strFormat)
	-- strContents	~ text string
	-- strFileName	~ full file path
	-- strFormat		~ optional "UTF-8" or "UTF-16LE"
	-- return value	~ true if successful else false
	strFormat = strFormat or "UTF-8"
	if fhGetAppVersion() > 6 then
		return fhSaveTextFile(strFileName,strContents,strFormat)
	end
	local strAnsi, wasAnsi = FileNameToANSI(strFileName)
	local fileHandle = OpenFile(strAnsi,"w")
	fileHandle:write(strContents)
	assert(fileHandle:close())
	if not wasAnsi then
		MoveFile(strAnsi,strFileName)
	end
	return true
end -- function SaveStringToFile

-- Load string from file --
function StrLoadFromFile(strFileName,strFormat)
	-- strFileName	~ full file path
	-- strFormat		~ optional "UTF-8" or "UTF-16LE"
	-- return value	~ file contents
	strFormat = strFormat or "UTF-8"
	if fhGetAppVersion() > 6 then
		return fhLoadTextFile(strFileName,strFormat)
	end
	local strAnsi, wasAnsi = FileNameToANSI(strFileName)
	if not wasAnsi then
		CopyFile(strFileName,strAnsi)
	end
	local fileHandle = OpenFile(strAnsi,"r")
	local strContents = fileHandle:read("*all")
	assert(fileHandle:close())
	return strContents
end -- function StrLoadFromFile

--[[
@Function:		CheckVersionInStore
@Author:			Mike Tate
@Version:			1.4
@LastUpdated:		20 Jan 2026
@Description:		Check plugin version against version in Plugin Store
@Parameter:		Plugin name and version
@Returns:			None
@Requires:		luacom
@V1.4:				Dispense with files and assume called via IUP button;
@V1.3:				Save and retrieve latest version in file;
@V1.2:				Ensure the Plugin Data folder exists;
@V1.1:				Monthly interval between checks; Report if Internet is inaccessible;
@V1.0:				Initial version;
]]

function CheckVersionInStore(strPlugin,strVersion)							-- Check if later Version available in Plugin Store

	require "luacom"

	local function httpRequest(strRequest)										-- Luacom http request protected by pcall() below
		local http = luacom.CreateObject("winhttp.winhttprequest.5.1")
		http:Open("GET",strRequest,false)
		http:Send()
		return http.Responsebody
	end -- local function httpRequest

	local function intVersion(strVersion)										-- Convert version string to comparable integer
		local intVersion = 0
		local arrNumbers = {}
		strVersion:gsub("(%d+)", function(strDigits) table.insert(arrNumbers,strDigits) end)
		for i = 1, 5 do
			intVersion = intVersion * 100 + tonumber(arrNumbers[i] or 0)
		end
		return intVersion
	end -- local function intVersion

	local strLatest = "0"
	if strPlugin then
		local strRequest ="http://www.family-historian.co.uk/lnk/checkpluginversion.php?name="..tostring(strPlugin)
		local isOK, strReturn = pcall(httpRequest,strRequest)
		if not isOK then																-- Problem with Internet access
			fhMessageBox(strReturn.."\n The Internet appears to be inaccessible. ")
		elseif strReturn then
			strLatest = strReturn:match("([%d%.]*),%d*")						-- Version digits & dots then comma and Id digits 
		end
	end
	local strMessage = "No later Version"
	if intVersion(strLatest) > intVersion(strVersion or "0") then
		strMessage = "Later Version "..strLatest
	end
	fhMessageBox(strMessage.." of this Plugin is available from the 'Plugin Store'.")
end -- function CheckVersionInStore

local strMaroon = "128 0 0"													-- Color attributes
local strPurple = "128 0 128"
local strMagenta= "255 0 255"
local strAmber  = "210 110 0"
local strOlive  = "128 128 0"
local strGreen  = "0 128 0"
local strTeal   = "0 128 128"
local strNavy   = "0 0 128"
local strOrange = "255 190 0"
local strGray   = "128 128 128"
local strRed    = "255 0 0"
local strBlue   = "0 0 255"
local strLime   = "0 255 0"
local strCyan   = "0 255 255"
local strYellow = "255 255 0"
local strSilver = "192 192 192"
local strSmoke  = "220 220 220"
local strWhite  = "255 255 255"
local strBlack  = "0 0 0"

function ResetData()
	ArrFather = { "Father";                Parent=true; }					-- List of title, plural, class, events, format, people, weight for each type of person
	ArrMother = { "Mother";                Parent=true; }
	ArrPerson = { "Family Root";           Root = true; }					-- V1.1
	ArrSpouse = { "Partner"; Plural="s";   Partner=true; }
	ArrSiblng = { "Sibling"; Plural="s";   Sibling=true; }
	ArrChilds = { "Child";   Plural="ren"; Children=true; }
	for intArray, arrArray in ipairs ({ArrFather,ArrMother,ArrPerson,ArrSpouse,ArrSiblng,ArrChilds}) do
		arrArray.Events = { { "~.BIRT"; "~.BAPM"; "~.CHR"; }; { "~.MARR"; "~.DIV"; "~.EVEN-SEPARATION"; }; { "~.DEAT"; "~.BURI"; "~.CREM"; }; }
		arrArray.Format = { }													-- Formatting applied to text
		arrArray.Linked = { }													-- Linked line per person with Head and Tail char position of Name
		arrArray.Select = 0														-- Current selected line
		arrArray.People = 0														-- People count per pane
		arrArray.Weight = 0														-- ExpandWeight per row
	end
	ArrRow = { ArrMother; ArrSpouse; ArrChilds; }
	ArrFamily = { }																-- Family and associated colours
	ArrColour = {strMaroon;strPurple;strMagenta;strAmber;strOlive;strTeal;strNavy;strOrange;strGray;strRed;strBlue;strLime;strCyan;strYellow;}
	IntColour = 1																	-- Next available colour
end -- ResetData()

local ArrPeople = { }															-- Array of Family Root pointers for Go Backward/Forward
local TblField  = { }															-- Sticky Settings File fields
local IntPoints = 9																-- 9=default font Point size
local IntEvents = 1																-- 1=Full, 2=Brief, 3=None
local StrRec_Id = "OFF"															-- [Record Id] hidden		-- V1.1
local FontScale = 1.2															-- FontScale of Titles needed for ExpandWeight adjustment
local PluginData = fhGetPluginDataFileName():gsub(" %- V%d.*%.dat$",".dat") -- Ensure root name is used -- V1.2 -- V1.3 -- V1.4

function LoadSettings()															-- Load Settings File in lines with key & val fields	-- V1.4
	if FlgFileExists(PluginData) then
		local strData = StrLoadFromFile(PluginData)
		for strLine in strData:gmatch("[^\r\n]+") do
			local strKey,strVal = strLine:match("([%a_]+)=(.+)$")
			TblField[strKey] = strVal
		end
		IntPoints = tonumber(TblField.Points) or IntPoints
		IntEvents = tonumber(TblField.Events) or IntEvents
		StrRec_Id = TblField.Rec_Id or StrRec_Id							-- V1.1
	end
end -- function LoadSettings

function SaveSettings()															-- Save Settings File lines with key & val fields	-- V1.4
	local tblData = {}
	for strKey,strVal in pairs(TblField) do
		table.insert(tblData,strKey.."="..strVal.."\n")
	end
	local strData = table.concat(tblData,"\n")
	if not SaveStringToFile(strData,PluginData) then
		doError("\nSettings file not saved successfully.\n\nMay need to Delete the following Plugin Data .dat file:\n\n"..PluginData.."\n\nError detected.")
	end	
end -- function SaveSettings

function LoopFacts(arrList,arrFacts,ptrHome)								-- Loop through Facts
	-- arrList	List of facts to update
	-- arrFacts	List of Tags to find
	-- ptrHome	Record to search
	if IntEvents < 3 then
		for intFact, strFact in ipairs (arrFacts) do						-- Loop on each fact Tag unless "None" chosen
			strFact = fhGetDisplayText(ptrHome,strFact)
			if #strFact > 0 then
				table.insert(arrList,"\t"..strFact)							-- Add each Fact to List and break if "Brief" chosen
				if IntEvents == 2 then break end
			end
		end
	end
end -- function LoopFacts

function GetRecId(ptrRec)														-- Get Record Id conditionally -- V1.1
	if StrRec_Id == "OFF" then return "" end
	return " ["..fhGetRecordId(ptrRec).."]"
end -- function GetRecId

function ShowName(ptrIndi)														-- Show Name (LifeDates) [RecId]
	return fhGetDisplayText(ptrIndi).." ("..fhCallBuiltInFunction("LifeDates2",ptrIndi,"STD")..")"..GetRecId(ptrIndi) -- V1.1
end -- function ShowName

function IntLength(strName)													-- Length of name in characters -- V1.1
	if fhGetAppVersion() > 5 then
		strName = fhConvertUTF8toANSI(strName)								-- Convert each multi-byte UTF8 char to one byte ANSI char
		fhSetConversionLossFlag(false)
	end
	return #strName
end

function ListFacts(arrList,ptrIndi,ptrRoot)									-- List the name and facts for one person
	-- arrList	List of facts to fill and parameters
	-- ptrIndi	Link to the current Individual record
	-- ptrRoot	Link to root person Individual record
	if ptrIndi:IsNotNull() then
		arrList.People = arrList.People + 1									-- List starts with Name & (LifeDates) of person
		local strName = ShowName(ptrIndi)
		table.insert(arrList," "..strName)
		arrList.Linked[#arrList] = { Indi=ptrIndi:Clone(); Head=2; Tail=2+IntLength(strName); } -- V1.1
		for intFamily, arrFamily in ipairs (ArrFamily) do					-- Obtain the colour of primary individuals
			local isMatch = false
			if fhGetItemPtr(ptrIndi,"~.FAMC[1]>"):IsSame(arrFamily.Family) then
				isMatch = true
			else
				local ptrFams = fhGetItemPtr(ptrIndi,"~.FAMS[1]")			-- Move to 1st Spouse Family of Individual
				while ptrFams:IsNotNull() do
					if fhGetValueAsLink(ptrFams):IsSame(arrFamily.Family) then
						isMatch = true
						break
					end
					ptrFams:MoveNext("SAME_TAG")								-- Move to next Spouse Family of Individual
				end
			end
			if isMatch then
				arrList.Colour = arrFamily.Colour
				break
			end
		end
		local isRoot = FormatName(arrList,ptrIndi,ptrRoot)
		if not isRoot or arrList.Root then
			for intFacts, arrFacts in ipairs (arrList.Events) do			-- Loop on each event array
				if intFacts == 2 then
					local intSpou = 1
					local ptrFams = fhGetItemPtr(ptrIndi,"~.FAMS")			-- For family facts loop on Family as Spouse tags 
					while ptrFams:IsNotNull() do
						local ptrFrec = fhGetValueAsLink(ptrFams)			-- Family record -- V1.1
						local ptrSpou = fhGetItemPtr(ptrIndi,"~.~SPOU["..intSpou.."]>")
						if ptrSpou:IsNotNull() then
							local strSpou = ShowName(ptrSpou)
							local strTail = ""
							if IntEvents == 3 then								-- If No BMD Events then add (m.year) or (d.year) or (s.year) -- V1.1
								for intFact, strFact in ipairs (arrFacts) do
									local strYear = fhGetItemText(ptrFrec,strFact..".DATE:YEAR")
									if #strYear > 2 then						-- If year exists extract initial letter of fact
										local strInit = strFact:match("~%.[EVEN-]*(%u)"):lower()
										strTail = " ("..strInit.."."..strYear..")"
										break
									end
								end
							end
							local strRecId = GetRecId(ptrFrec)				-- Add each spouse/partner Family [RecId] and Name (LifeDates) [RecId] to List -- V1.1
							table.insert(arrList,"\t="..strRecId.." "..strSpou..strTail)
							arrList.Linked[#arrList] = { Indi=ptrSpou:Clone(); Head=#strRecId+4; Tail=#strRecId+4+IntLength(strSpou); } -- V1.1
							if ptrSpou:IsSame(ptrRoot) then					-- Partner's spouse's colour must match Root's Parents
								arrList.Colour = ArrFamily[#ArrFamily].Colour
							else
								arrList.Colour = nil
								for intFamily, arrFamily in ipairs (ArrFamily) do
									if ptrFrec:IsSame(arrFamily.Family) then
										arrList.Colour = arrFamily.Colour		-- Assign existing Spouse Family colour
										break
									end
								end
								if not arrList.Colour  -- or arrList.Partner or arrList.Sibling or arrList.Children or fhGetItemPtr(ptrSpou,"~.~CHIL[1]>"):IsNull() ) then
								and ( arrList.Parent or arrList.Root )
								and fhGetItemPtr(ptrSpou,"~.~CHIL[1]>"):IsNotNull() then
									arrList.Colour = ArrColour[IntColour]	-- Assign next colour to next Parent/Root Spouse Family
									IntColour = IntColour + 1					-- Recycle through colours if necessary
									if IntColour > #ArrColour then IntColour = 1 end
									table.insert(ArrFamily,1,{ Family=ptrFrec; Colour=arrList.Colour; })
								end
							end
							FormatName(arrList,ptrSpou,ptrRoot)
						end
						LoopFacts(arrList,arrFacts,ptrFrec)
						intSpou = intSpou + 1
						ptrFams:MoveNext("SAME_TAG")
					end
				else
					LoopFacts(arrList,arrFacts,ptrIndi)
				end
			end
		end
	end
end -- function ListFacts

function FormatName(arrList,ptrIndi,ptrRoot)								-- Format name with Bold & Underline for root
	-- arrList	List to hold format
	-- ptrIndi	Individual record
	-- ptrRoot	Family Root record
	local isRoot = ptrIndi:IsSame(ptrRoot)
	if isRoot then
		AddFormat(arrList,"BOLD","SINGLE")
	else
		AddFormat(arrList,"BOLD")
	end
	return isRoot
end -- function FormatName

function Selection(intLine,intHead,intTail)									-- Format the text selection line and chars -- V1.1
	-- intLine	Line of selection
	-- intHead	Head of selection
	-- intTail	Tail of selection
	return intLine..","..intHead..":"..intLine..","..intTail
end -- function Selection

function AddFormat(arrList,strWeight,strUnder,strScale)					-- Add formatting for bold, underline, font scale & size
	-- arrList	List of parameters
	-- strWeight	Bold weight format
	-- strUnder	Underline format optional
	-- strScale	Scale sizing for Title only
	local strColour = arrList.Colour
	local strAlign = "LEFT"
	local intLine = #arrList
	local intHead = 2															-- Head & Tail char positions
	local intTail = 999
	if strScale then
		strColour = strBlack													-- Format for Title
		strAlign = "CENTER"
		intLine = 1
	else
		local dicLink = arrList.Linked[#arrList]							-- Format for Name from Head to Tail -- V1.1
		intHead = dicLink.Head
		intTail = dicLink.Tail
		if intHead > 2 then
			local strSelect = Selection(intLine,2,intHead)					-- Format for Head = [Rec Id] is green bold -- V1.1
			table.insert(arrList.Format,{ Selection=strSelect; FgColor=strGreen; Weight="BOLD"; })
			local strSelect = Selection(intLine,intTail,999)				-- Format for Tail (m.year) is black unbold -- V1.1
			table.insert(arrList.Format,{ Selection=strSelect; FgColor=strBlack; Weight="NONE"; })
		end
	end
	local strSelect = Selection(intLine,intHead,intTail)					-- Format for line between Head & Tail -- V1.1
	table.insert(arrList.Format,{ Selection=strSelect; FgColor=strColour or strBlack; Weight=strWeight or "NONE"; Underline=strUnder or "NONE"; Alignment=strAlign or "LEFT"; FontScale=strScale or "1.0"; FontSize=IntPoints; })
	arrList.Colour = strBlack
end -- function AddFormat

function ListTitle(arrList)													-- List Title
	local strTitle  = arrList[1]
	local strPlural = arrList.Plural or ""
	if arrList.People == 1 and arrList.Sibling then						-- One Sibling
		arrList.People = 0
		arrList[2] = nil
	end
	if arrList.People == 0 then												-- No people
		strTitle = "No "..strTitle..strPlural.." Listed"
	elseif arrList.People > 1 then											-- Multiple people
		strTitle = strTitle..strPlural
	end
	arrList[1] = " "..strTitle
	AddFormat(arrList,"BOLD","NONE",FontScale)								-- FontScale was "1.1" but upsets ExpandWeight especially when only Title, unless adjustment added
end -- function ListTitle

function LoadText(arrList,txtText,isReset)									-- Load variable text and format into Text pane
	-- arrList	List of parameters
	-- txtText	GUI text pane for Father, Mother, Person, etc
	-- isReset	Exists if reset needed
	local iupTags = iup.user{ Bulk="YES"; CleanOut="YES"; }
	iup.Append( iupTags, iup.user{ Selection="1,1:999,999"; TabsArray="15 LEFT "; FontSize=IntPoints; })
	for intFormat, iupFormat in ipairs (arrList.Format) do				-- Append the formatting tags
		local iupUser = iup.user{}
		for iupName, iupData in pairs (iupFormat) do						-- Create the user data
			iupUser[iupName] = iupData
		end
		iup.Append( iupTags, iupUser )
	end
	txtText.VisibleLines = math.min(#arrList-1,2)							-- Improves initial height of dialog
	if isReset then txtText.Value = " " end									-- Ensure local pane font is reset
	local strText = txtText.Value or ""
	local strList = table.concat(arrList,"\n")
	if arrList.Linked[#arrList] then											-- Name is last so needs an extra blank line for doMotion()
		strList = strList.."\n "
	end
	if strText ~= strList then													-- Load new list of names & facts
		txtText.Value = strList
	end
	txtText.AddFormatTag = iupTags											-- Add the formatting tags
end -- function LoadText

function MakeFacts(ptrRoot)													-- Make the names & facts for each pane
	local ptrFath = fhNewItemPtr()
	local ptrMoth = fhNewItemPtr()
	local ptrIndi = fhNewItemPtr()
	local ptrFams = fhGetItemPtr(ptrRoot,"~.FAMS>")
	local ptrFamc = fhGetItemPtr(ptrRoot,"~.FAMC>")
	if ptrFamc:IsNull() then ptrFamc = ptrFams end							-- Add colour for spouse/parent family of root
	table.insert(ArrFamily,{ Family=ptrFamc:Clone(); Colour=ArrColour[IntColour]; })
	IntColour = IntColour + 1

	ListFacts(ArrFather,fhGetItemPtr(ptrRoot,"~.~FATH>"),ptrRoot)		-- List the Father of root Individual
	ListTitle(ArrFather)

	ListFacts(ArrMother,fhGetItemPtr(ptrRoot,"~.~MOTH>"),ptrRoot)		-- List the Mother of root Individual
	ListTitle(ArrMother)

	local arrSibling = {}
	local nxtSibling = 1
	local intIndi = 1															-- List the Siblings of root Individual (must be before root Indiviudal)
	ptrMoth:MoveTo(ptrRoot,"~.~MOTH[1]>")
	ptrIndi:MoveTo(ptrMoth,"~.~CHIL[1]>")									-- Move to 1st Child of Mother i.e. Sibling of root Individual
	while ptrIndi:IsNotNull() do
		table.insert(arrSibling,ptrIndi:Clone())							-- Add next sibling to list
		arrSibling["ID"..fhGetRecordId(ptrIndi)] = true
		intIndi = intIndi + 1
		ptrIndi:MoveTo(ptrMoth,"~.~CHIL["..intIndi.."]>")					-- Move to next Sibling of root Individual
	end
	local intIndi = 1
	ptrFath:MoveTo(ptrRoot,"~.~FATH[1]>")
	ptrIndi:MoveTo(ptrFath,"~.~CHIL[1]>")									-- Move to 1st Child of Father i.e. Sibling of root Individual
	while ptrIndi:IsNotNull() do
		if arrSibling["ID"..fhGetRecordId(ptrIndi)] then					-- Skip existing sibling
			if nxtSibling < #arrSibling and
				arrSibling[#arrSibling]:IsSame(ptrIndi) then
				nxtSibling = nxtSibling + 1									-- Ensure more siblings go after last existing sibling
			end
		else
			table.insert(arrSibling,nxtSibling,ptrIndi:Clone())			-- Add new sibling to list
		end
		nxtSibling = nxtSibling + 1
		intIndi = intIndi + 1
		ptrIndi:MoveTo(ptrFath,"~.~CHIL["..intIndi.."]>")					-- Move to next Sibling of root Individual
	end
	for intIndi, ptrIndi in ipairs (arrSibling) do
		ListFacts(ArrSiblng,ptrIndi,ptrRoot)									-- List each Sibling of root Individual
	end
	ListTitle(ArrSiblng)

	ListFacts(ArrPerson,ptrRoot,ptrRoot)										-- List the root Individual Person
	ListTitle(ArrPerson)

	local intIndi = 1															-- List the Partners of root Individual
	local arrSpou = {}
	ptrFams:MoveTo(ptrRoot,"~.FAMS")											-- Move to 1st Family of root Individual
	while ptrFams:IsNotNull() do
		ptrIndi:MoveTo(ptrRoot,"~.~SPOU["..intIndi.."]>")
		for _, ptrSpou in ipairs (arrSpou) do								-- Has this Spouse already been listed? -- V1.1
			if ptrIndi:IsSame(ptrSpou) then ptrIndi:SetNull() break end
		end
		if ptrIndi:IsNotNull() then											-- V1.1
			table.insert(arrSpou,ptrIndi:Clone())	
			ListFacts(ArrSpouse,ptrIndi,ptrRoot)								-- List each Spouse of root Individual
		end
		intIndi = intIndi + 1
		ptrFams:MoveNext("SAME_TAG")											-- Move to next Family of root Individual
	end
	ListTitle(ArrSpouse)

	local intIndi = 1															-- List the Children of root Individual
	ptrIndi:MoveTo(ptrRoot,"~.~CHIL[1]>")									-- Move to 1st Child of root Individual
	while ptrIndi:IsNotNull() do
		ListFacts(ArrChilds,ptrIndi,ptrRoot)									-- List each Child of root Individual
		intIndi = intIndi + 1
		ptrIndi:MoveTo(ptrRoot,"~.~CHIL["..intIndi.."]>")					-- Move to next Child of root Individual
	end
	ListTitle(ArrChilds)

end -- function MakeFacts

function TextPane()																-- Compose IUP Multiline Read-Only Text pane with Auto-Hidden Scroll-Bars and Formatting
	return iup.text{ MultiLine="YES"; ReadOnly="YES"; Expand="YES"; CanFocus="NO"; ScrollBar="YES"; AutoHide="YES"; Formatting="YES"; TipDelay=9000; Tip="Click highlighted Name to select as new Family Root\nUse click and drag or mouse-wheel to scroll up/down\nUse the Ctrl key + mouse-wheel to alter text font size"; }	-- V1.1
end -- function TextPane

function Hbox(iupPaneA,iupPaneB,intRow)										-- Compose IUP Horizontal box for two panes with row number
	local btnInc = iup.button{ Title="+"; Expand="VERTICAL"; Padding="2x0"; CanFocus="NO"; Tip="Increase height of this row"; action=function() SetWeight(ArrRow[intRow], 1) end; }
	local btnDec = iup.button{ Title="-"; Expand="VERTICAL"; Padding="3x0"; CanFocus="NO"; Tip="Decrease height of this row"; action=function() SetWeight(ArrRow[intRow],-1) end; }
	return iup.hbox{ Alignment="ATOP"; Margin="2x2"; Gap="2"; iupPaneA; iupPaneB; iup.vbox{ btnInc; btnDec; }; }
end -- function Hbox

function Main()
	local arrRoot = fhGetCurrentRecordSel("INDI")							-- Check if any Records pre-selected
	if #arrRoot == 0 then
		arrRoot = fhPromptUserForRecordSel("INDI",1)						-- Prompt for Record selection
	end
	if #arrRoot ~= 1 then return end
	local ptrRoot = arrRoot[1]

	ArrPeople.Index = 1
	table.insert(ArrPeople,ptrRoot:Clone())									-- Initialise list of people for Go Backward/Forwards
	ResetData()
	LoadSettings()																-- Load sticky settings

	local function strPoints()													-- Format the Font Point size
		return string.format(" Font %2u Point ",IntPoints)
	end -- local function strPoints

	local tipPoints = "Choose text font point size with either\nclick and drag, or mouse-wheel, or the\nHome, End, Page Up/Down, arrow keys"

	txtFather = TextPane()														-- Compose popup window of family panes
	txtMother = TextPane()
	boxParent = Hbox(txtFather,txtMother,1)									-- Row 1
	txtPerson = TextPane()
	txtSpouse = TextPane()
	boxSpouse = Hbox(txtPerson,txtSpouse,2)									-- Row 2
	txtSiblng = TextPane()
	txtChilds = TextPane()
	boxJunior = Hbox(txtSiblng,txtChilds,3)									-- Row 3
	lblPrefix = iup.label { CanFocus="NO"; Title="20"; Tip=tipPoints; }
	valPoints = iup.val   { Expand="HORIZONTAL"; Value=IntPoints; Inverted="YES"; Min=4.2; Max=20.2; PageStep=1/16; Step=1/16; ShowTicks=16; Tip=tipPoints; }
	lblSuffix = iup.label { CanFocus="NO"; Title="4" ; Tip=tipPoints; }
	lblPoints = iup.label { CanFocus="NO"; Title=strPoints()  ; FgColor=strGreen; Tip=tipPoints; }
	btnToPrev = iup.button{ CanFocus="NO"; Expand="HORIZONTAL"; FgColor=strGreen; Padding="3x1"; Title="To previous Root" ; Tip="Go backward to previous Family Root"; Active="NO" ; }	-- V1.1
	btnToNext = iup.button{ CanFocus="NO"; Expand="HORIZONTAL"; FgColor=strGreen; Padding="3x1"; Title="To following Root"; Tip="Go forward to following Family Root"; Active="NO" ; }	-- V1.1
	btnUpdate = iup.button{ CanFocus="NO"; Expand="HORIZONTAL"; FgColor=strGreen; Padding="3x1"; Title="Check for Updates"; Tip="Check for Updates in Plugin Store"; }						-- V1.4
	lstEvents = iup.list  { CanFocus="NO"; Expand="HORIZONTAL"; FgColor=strGreen; Value=IntEvents; DropDown="YES"; VisibleColumns="9"; Tip="Choose the events to show with\nclick and select, or mouse-wheel";  " Full BMD Events"; " Brief BMD Events"; " No BMD Events"; }
	tglRec_Id = iup.toggle{ CanFocus="NO"; Expand="HORIZONTAL"; FgColor=strGreen; Value=StrRec_Id; Title=" [Rec Id]"; Tip="Include the Record Id ?"; }											-- V1.1
	btnCentre = iup.button{ CanFocus="NO"; Expand="HORIZONTAL"; FgColor=strGreen; Padding="1x1"; Title="Centre Window"; Tip="Centre plugin window over the FH window"; }					-- V1.4
	btnCancel = iup.button{ CanFocus="NO"; Expand="HORIZONTAL"; FgColor=strRed  ; Padding="1x1"; Title="Close Window" ; Tip="Close this facts popup window and keep settings"; }			-- V1.4
	boxPoints = iup.vbox  { Margin="0x0"; Gap="0"; Alignment="ACENTER"; iup.hbox { Gap="2"; lblPrefix; valPoints; lblSuffix; }; lblPoints; }
	boxOption = iup.hbox  { Margin="2x0"; Gap="9"; Alignment="ACENTER"; btnToPrev; btnToNext; btnUpdate; boxPoints; lstEvents; tglRec_Id; btnCentre; btnCancel; Homogeneous="NO"; }		-- V1.1 -- V1.4
	boxColumn = iup.vbox  { Margin="2x2"; Gap="2"; Alignment="ACENTER"; boxParent; boxSpouse; boxJunior; boxOption; }
	iupDialog = iup.dialog{ boxColumn; Title=strPluginTitle.."  ~  with colour coded relations"; Shrink="YES"; MinBox="NO"; MaxBox="NO"; StartFocus=valPoints; }								-- V1.2 -- V1.4

	iupDialog.move_cb   = function(self,x,y) if iup.MainLoopLevel() > 0 then TblField.CoordX=x TblField.CoordY=y	end end		-- Save window coordinates & size only after displayed	-- V1.4
	iupDialog.resize_cb = function(self)     if iup.MainLoopLevel() > 0 then TblField.Raster=self.RasterSize		end end		-- Removed iup.Map(iupDialog) iup.Redraw(iupDialog,1) 	-- V1.4 

	local function doUpdate()													-- Update all panes, events, styles, colors
		ResetData()
		MakeFacts(ptrRoot)
		LoadText(ArrFather,txtFather,"Reset")								-- Reload all text panes
		LoadText(ArrMother,txtMother,"Reset")
		LoadText(ArrPerson,txtPerson,"Reset")
		LoadText(ArrSpouse,txtSpouse,"Reset")
		LoadText(ArrSiblng,txtSiblng,"Reset")
		LoadText(ArrChilds,txtChilds,"Reset")
		SetWeight()																-- Set their ExpandWeight
	end -- local function doUpdate

	local function goLinked(dicLink,intChar)								-- Go to linked person as new Family Root
		if dicLink and intChar > dicLink.Head and intChar < dicLink.Tail then	-- V1.1
			local ptrLink = dicLink.Indi										-- Cursor is within linked person name
			if not ptrLink:IsSame(ptrRoot) then
				ptrRoot = ptrLink:Clone()										-- Switch to person who is not current Root
				ArrPeople.Index = ArrPeople.Index + 1
				table.insert(ArrPeople,ptrRoot:Clone())						-- Add to list of people
				btnToPrev.Active = "YES"
				doUpdate()
				iup.SetFocus(valPoints)											-- Ensure Font slider keeps focus
			end
		end
	end -- local function goLinked

	function txtFather:caret_cb(intLine,intChar)							-- Action for Father pane mouse click
		goLinked(ArrFather.Linked[intLine],intChar)
	end -- function txtFather:caret_cb

	function txtMother:caret_cb(intLine,intChar)							-- Action for Mother pane mouse click
		goLinked(ArrMother.Linked[intLine],intChar)
	end -- function txtMother:caret_cb

	function txtPerson:caret_cb(intLine,intChar)							-- Action for Person pane mouse click
		goLinked(ArrPerson.Linked[intLine],intChar)
	end -- function txtPerson:caret_cb

	function txtSpouse:caret_cb(intLine,intChar)							-- Action for Spouse pane mouse click
		goLinked(ArrSpouse.Linked[intLine],intChar)
	end -- function txtSpouse:caret_cb

	function txtSiblng:caret_cb(intLine,intChar)							-- Action for Sibling pane mouse click
		goLinked(ArrSiblng.Linked[intLine],intChar)
	end -- function txtSiblng:caret_cb

	function txtChilds:caret_cb(intLine,intChar)							-- Action for Children pane mouse click
		goLinked(ArrChilds.Linked[intLine],intChar)
	end -- function txtChilds:caret_cb

	local function doMotion(arrList,txtText,intX,intY)					-- Highlight person when mouse moves over
		-- arrList	List of parameters
		-- txtText	GUI text pane for Father, Mother, Person, etc
		-- intX,intY	Coordinates of mouse cursor position
		local intPosn = iup.ConvertXYToPos(txtText,intX,intY)				-- Convert coordinates to line & char position
		local intLine,intChar = iup.TextConvertPosToLinCol(txtText,intPosn)
		local dicLink = arrList.Linked[intLine] or { Indi=fhNewItemPtr(); Head=0; Tail=0; } -- V1.1
		local intHead = dicLink.Head
		local intTail = dicLink.Tail
		if not dicLink.Indi:IsSame(ptrRoot)
		and intChar > intHead and intChar < intTail then					-- Cursor is in linked person name other than Root person -- V1.1
			if arrList.Select ~= intLine then
				arrList.Select = intLine
				local strSelect = Selection(intLine,intHead,intTail)		-- Temporarily highlight & underline person -- V1.1
				table.insert(arrList.Format,{ Selection=strSelect; BgColor=strSmoke; Underline="SINGLE"; })
				LoadText(arrList,txtText)
				table.remove(arrList.Format)									-- Cancel that formatting
			end
		else
			if arrList.Select ~= 0 then										-- Mouse over highlight has been applied
				arrList.Select = 0
				LoadText(arrList,txtText)										-- Remove highlight & underline from pane
			end
		end
	end -- local function doMotion

	function txtFather:motion_cb(intX,intY)									-- Action for Father pane mouse move
		doMotion(ArrFather,txtFather,intX,intY)
	end -- function txtFather:motion_cb

	function txtMother:motion_cb(intX,intY)									-- Action for Mother pane mouse move
		doMotion(ArrMother,txtMother,intX,intY)
	end -- function txtMother:motion_cb

	function txtPerson:motion_cb(intX,intY)									-- Action for Person pane mouse move
		doMotion(ArrPerson,txtPerson,intX,intY)
	end -- function txtPerson:motion_cb

	function txtSpouse:motion_cb(intX,intY)									-- Action for Spouse pane mouse move
		doMotion(ArrSpouse,txtSpouse,intX,intY)
	end -- function txtSpouse:motion_cb

	function txtSiblng:motion_cb(intX,intY)									-- Action for Sibling pane mouse move
		doMotion(ArrSiblng,txtSiblng,intX,intY)
	end -- function txtSiblng:motion_cb

	function txtChilds:motion_cb(intX,intY)									-- Action for Children pane mouse move
		doMotion(ArrChilds,txtChilds,intX,intY)
	end -- function txtChilds:motion_cb

	function btnToPrev:action()												-- Action for To Previous Root button
		if ArrPeople.Index > 1 then
			ArrPeople.Index = ArrPeople.Index - 1
			ptrRoot = ArrPeople[ArrPeople.Index]:Clone()					-- Select previous Family Root
			doUpdate()
			btnToNext.Active = "YES"
			if ArrPeople.Index == 1 then
				btnToPrev.Active = "NO"
			end
		end
	end -- function btnToPrev:action

	function btnToNext:action()												-- Action for To Following Root button
		if ArrPeople.Index < #ArrPeople then
			ArrPeople.Index = ArrPeople.Index + 1
			ptrRoot = ArrPeople[ArrPeople.Index]:Clone()					-- Select next Family Root
			doUpdate()
			btnToPrev.Active = "YES"
			if ArrPeople.Index == #ArrPeople then
				btnToNext.Active = "NO"
			end
		end
	end -- function btnToNext:action

	function btnUpdate:action()												-- Action for Check for Updates button	-- V1.4
		iupDialog.Active = "NO"
		CheckVersionInStore("Quick Family Facts",strVersion)
		iupDialog.BringFront = "YES"
		iupDialog.Active = "YES"
	end -- function btnUpdate

	function valPoints:valuechanged_cb()										-- Action for font Points size slider
		local intPoints = IntPoints
		IntPoints = math.floor(valPoints.Value)
		if IntPoints ~= intPoints then
			lblPoints.Title = strPoints()
			doUpdate()
		end
	end -- function valPoints:valuechanged_cb

	function lstEvents:action(strText,intItem,intState)					-- Action for choose BMD Events dropdown
		if intState == 1 then
			IntEvents = intItem
			doUpdate()
			iup.SetFocus(valPoints)												-- Ensure Font slider keeps focus
		end
	end -- function lstEvents:action

	function tglRec_Id:action(intState)										-- Action for the [Record Id] toggle	-- V1.1
		tglRec_Id.Tip  = tglRec_Id.Tip										-- Refresh XP Tooltip
		StrRec_Id = tglRec_Id.Value
		doUpdate()
	end -- function tglRec_Id:action

	if fhGetAppVersion() > 6 then iup.SetAttribute( iupDialog, "NATIVEPARENT", fhGetContextInfo("CI_PARENT_HWND") ) end			-- V1.4

	local function showWindow()												-- Display the plugin window	-- V1.4
		if not TblField.Raster then
			iupDialog:map()
			local intWinW,intWinH = splitnumbers(iupDialog.NaturalSize)	-- Set default window size		-- V1.4
			TblField.Raster = intWinW.."x"..(intWinH * 1.5)				-- Increase height by 50%		-- V1.4
		end
		iupDialog.RasterSize = TblField.Raster
		iupDialog.MinSize = iupDialog.NaturalSize
		local intMinX,intMinY,intMaxW,intMaxH = splitnumbers(iup.GetGlobal("VIRTUALSCREEN"))
		intMaxH = intMaxH - 100													-- Screen size of all monitors allowing for the Taskbar	-- V1.4
		local intWinW,intWinH = splitnumbers(TblField.Raster)				-- Get window dimensions from the previous use of plugin	-- V1.4
		local intX = tonumber(TblField.CoordX)
		local intY = tonumber(TblField.CoordY)
		if  intX and intY														-- Check window dimensions are inside screen dimensions	-- V1.4
		and intX >= intMinX and intY >= intMinY
		and intX + intWinW <= intMaxW
		and intY + intWinH <= intMaxH then
			iup.ShowXY(iupDialog,intX,intY)									-- Show window with previous saved position and size		-- V1.4
		else
			iup.ShowXY(iupDialog,iup.CENTERPARENT,iup.CENTERPARENT)		-- Otherwise centre window on FH window with saved size	-- V1.4
			TblField.CoordX = nil
			TblField.CoordY = nil
		end
		local intX = math.max(tonumber(iupDialog.X),intMinX)				-- Ensure window is all within all monitor screen(s)		-- V1.4
		local intY = math.max(tonumber(iupDialog.Y),intMinY)
		if intX + intWinW > intMaxW then intX = intMaxW - intWinW end
		if intY + intWinH > intMaxH then intY = intMaxH - intWinH end
		iup.ShowXY(iupDialog,intX,intY)
	end -- local function showWindow

	function btnCentre:action()												-- Action for Centre Window button	-- V1.4
		TblField.CoordX = nil
		TblField.CoordY = nil
		local MinSize = iupDialog.MinSize
		showWindow()
		iupDialog.MinSize = MinSize
		TblField.CoordX = nil
		TblField.CoordY = nil
	end -- function btnCentre

	function btnCancel:action()												-- Action for Close Window button		-- V1.4
		return iup.CLOSE
	end -- function btnCancel

	doUpdate()																	-- Update all panes

	showWindow()

 	iup.MainLoop()
	iup.Close()
	TblField.Points = IntPoints												-- Save sticky settings
	TblField.Events = IntEvents
	TblField.Rec_Id = StrRec_Id
	SaveSettings()
end -- function Main

function SetWeight(arrRow,intStep)											-- Set the ExpandWeight per row of panes (must be after Main() so global boxParent/Spouse/Junior exist)

	local function setRow(arrA,arrB)
		local intRow = math.min(math.max(#arrA,#arrB),40)					-- Limit large rows to 40 lines to allow other rows to auto-hide scroll-bar
		intRow = intRow + arrB.Weight + FontScale - 1.1					-- Adjust row proportion for +/- Weight buttons and Title FontScale
		intRow = intRow - math.min(intRow,(20-IntPoints)/5) + 0.1		-- Adjust inverse of Font Point Size, but must never be 0 that inhibits enlarging
		return intRow
	end -- local function setRow

	if arrRow and intStep then													-- Inc/Decrement row Weight in ArrMother/Spouse/Childs driven from +/- buttons
		arrRow.Weight = arrRow.Weight + intStep	 							-- or use + ( intStep * 0.2 * #arrRow )
	end
	local intParent = setRow(ArrFather,ArrMother)
	local intSpouse = setRow(ArrPerson,ArrSpouse)
	local intJunior = setRow(ArrSiblng,ArrChilds)
	local intMean = ( intParent + intSpouse + intJunior ) / 3
	boxParent.ExpandWeight = intParent / intMean							-- Weight = 0.5 means row is 1/6 of window
	boxSpouse.ExpandWeight = intSpouse / intMean							-- Weight = 1.0 means row is 1/3 of window
	boxJunior.ExpandWeight = intJunior / intMean							-- Weight = 1.5 means row is 1/2 of window
	iup.Map(iupDialog)
	iup.Redraw(iupDialog,1)														-- Ensure scroll-bars get redrawn
end -- local function SetWeight

fhInitialise(6,0,0,"save_recommended")										-- V1.4

Main()

Source:Quick-Family-Facts-3.fh_lua