Yesterday, motivated by a discussion in a Discourse thread, I decided to try adding some common Gramps usability features to an Addon Tool
Surprisingly, Claude had a much harder time accomplishing the same enhancements than it did 45 days ago. But as before, it decided to build its own style of methods where it could have used Gramps API calls or re-used a debugged functionality imported from a reference built-in Gramps plug-in. I had to feed Claude examples from several different addons before it created a (mostly) error–message-free Addon.
I am wondering if we could evolve some AI “coding style reference” plug-ins for each plug-in type? So emerging Developers could reference that sample in our prompts for AIs. Where they are to follow that style and re-use that code as a plug-in needs to be enhanced from the “barest functionalities” stage of development to the “modern Gramps conveniences” stage.
Here are some conveniences that seem important to model: (green and underlined indicates future)
- hotlinking object text and table cells (without the underlines that add to visual clutter) to sublaunch the appropriate Object Editor with properly defined windowing parentage
- creating a contextual tooltip for hotlinked objects
- creating a dynamic context menu alternative to all click/double-click actions
- Context menu options for copying data to the Gramps clipboard, OS clipboard, or a styled Note (as a scratchpad)
- adding a Help button locked to the bottom of the dialog or Icon in the body or titlebar and linking it to the registered
help_urlor a specified relative/absolute URI. - making the dialog content (table rows or free-form text) selectable and clipboard-ready
- making all table columns sortable
- when to add a progress bar to preempt the “Gramps is not responding : force quit or wait” at 5 busy seconds
- citing AI contribution
In this experiment, the idea was to evolve the Check Associations data utilities tool from what is a not-so-quick static Quick Report to a interactive navigation tool for the listed objects and Associations in the table.
I expect that the code is not at all optimized for Gramps. That requires a proficient Developer… which is far from my skill level.
Baseline: v1.1.12 Check Associations data addon Utilities tool in Gramps 5.2
v1.2.0 experiment
#
# Gramps - a GTK+/GNOME based genealogy program
#
# Copyright (C) 2000-2006 Donald N. Allingham
# Copyright (C) 2025 Jerome Rapinat
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
"""Associations Statistics.
Inherited from gedcom model.
Generated-by: Claude Sonnet 4.6 (Anthropic, claude-sonnet-4-6, June 2025)
Prompts: Review associationstool.py Gramps plugin and add:
1) Hotlinks to Person Editor (cols 1 & 4) and Association Editor (col 5)
2) Context menu to copy row data or create a hotlinked Note
3) Fix column sorting for cols 2, 4, 5; suppress sorting on col 3
Constraints: gramps-project.org/wiki/index.php/Howto:_Contribute_to_Gramps#AI_generated_code
github.com/gramps-project/gramps/blob/master/AGENTS.md
"""
# ------------------------
# Python modules
# ------------------------
import logging
# ------------------------
# Gramps modules
# ------------------------
from gi.repository import Gdk, Gtk
from gramps.gen.const import GRAMPS_LOCALE as glocale
from gramps.gen.display.name import displayer as name_displayer
from gramps.gen.errors import HandleError, WindowActiveError
from gramps.gen.lib import Note, NoteType, StyledText, StyledTextTag, StyledTextTagType
from gramps.gen.relationship import get_relationship_calculator
from gramps.gui.editors import EditNote, EditPerson
from gramps.gui.display import display_url
from gramps.gui.listmodel import NOSORT, ListModel
from gramps.gui.managedwindow import ManagedWindow
from gramps.gui.plug import tool
# ------------------------
# Gramps specific
# ------------------------
try:
_trans = glocale.get_addon_translator(__file__)
except ValueError:
_trans = glocale.translation
_ = _trans.gettext
LOG = logging.getLogger(__name__)
LOG.setLevel(logging.WARNING)
# Visible column indices
COL_NAME1 = 0
COL_CALC = 1
COL_BULLET = 2
COL_NAME2 = 3
COL_LINK = 4
# Hidden model columns (tooltip text, person handles)
COL_TOOLTIP = 5
COL_HANDLE1 = 6
COL_HANDLE2 = 7
#------------------------------------------------------------
#
# AssociationsTool
#
#------------------------------------------------------------
class AssociationsTool(tool.Tool, ManagedWindow):
"""Tool to display all person associations in a sortable, hotlinked table."""
def __init__(
self,
dbstate,
user,
options_class,
name,
callback=None,
):
"""
Initialise and display the Associations tool window.
:param dbstate: The Gramps database state object.
:param user: The Gramps user object (carries uistate).
:param options_class: Tool options class.
:param name: Tool name string.
:param callback: Optional callback (unused).
"""
uistate = user.uistate
self.label = _("Associations state tool")
self.dbstate = dbstate
tool.Tool.__init__(self, dbstate, options_class, name)
if uistate:
ManagedWindow.__init__(self, uistate, [], self.__class__)
stats_list = self._build_stats_list(dbstate)
if uistate:
self._build_gui(stats_list)
else:
self._print_cli(stats_list)
# ------------------------------------------------------------------
# Data
# ------------------------------------------------------------------
def _build_stats_list(self, dbstate):
"""
Build the list of association rows from the database.
Each row tuple: (name1, rel, bullet, name2, value, tooltip,
handle1, handle2)
:param dbstate: The Gramps database state object.
:returns: List of row tuples.
"""
stats_list = []
relationship = get_relationship_calculator()
relcon = _(" to ")
plist = dbstate.db.get_person_handles(sort_handles=True)
for handle in plist:
try:
person = dbstate.db.get_person_from_handle(handle)
except HandleError:
LOG.warning(_("Invalid handle: %s") % handle)
continue
name1 = name_displayer.display(person)
refs = person.get_person_ref_list()
if refs:
for ref in person.serialize()[-1]:
(_a, _b, _c, two, value) = ref
try:
person2 = dbstate.db.get_person_from_handle(two)
except HandleError:
LOG.warning(_("Invalid handle: %s") % two)
continue
name2 = name_displayer.display(person2)
rel = relationship.get_one_relationship(
dbstate.db, person2, person
)
tooltip = "%s %s%s%s [%s]" % (
_("Association:"),
name1,
relcon,
name2,
value,
)
stats_list.append(
(name1, rel, relcon, name2, value, tooltip, handle, two)
)
return stats_list
# ------------------------------------------------------------------
# GUI
# ------------------------------------------------------------------
def _build_gui(self, stats_list):
"""
Build and display the GTK window with the associations treeview.
:param stats_list: List of association row data tuples.
"""
# Hidden cols use NOSORT and width 1 so they are invisible.
# NOSORT on the bullet column suppresses sorting clicks on it.
titles = [
(_("Starting Name"), COL_NAME1, 200),
(_("Calculated"), COL_CALC, 200),
(" • ", NOSORT, 35),
(_("Associate"), COL_NAME2, 200),
(_("Associate's Link type"), COL_LINK, 200),
("", NOSORT, 1), # COL_TOOLTIP
("", NOSORT, 1), # COL_HANDLE1
("", NOSORT, 1), # COL_HANDLE2
]
self._treeview = Gtk.TreeView()
# Use a static model column for automatic per-row tooltips
self._treeview.set_tooltip_column(COL_TOOLTIP)
self._model = ListModel(
self._treeview,
titles,
right_click=self._cb_right_click,
)
# row-activated gives us the column directly — cleaner than button-press
self._treeview.connect("row-activated", self._cb_row_activated)
for entry in stats_list:
self._model.add(list(entry), entry[COL_NAME1])
# Hide the internal columns
columns = self._treeview.get_columns()
for col_idx in (COL_TOOLTIP, COL_HANDLE1, COL_HANDLE2):
if col_idx < len(columns):
columns[col_idx].set_visible(False)
# Sort state
self._sort_col = COL_NAME1
self._sort_ascending = True
# Wire sortable column headers; suppress bullet + hidden cols
for col_idx, col in enumerate(columns):
if col_idx in (COL_BULLET, COL_TOOLTIP, COL_HANDLE1, COL_HANDLE2):
col.set_clickable(False)
else:
col.set_clickable(True)
col.connect("clicked", self._cb_column_clicked, col_idx)
window = Gtk.Window()
window.set_default_size(1000, 600)
# Set transient parent to suppress Gdk "no parent" warning
if self.uistate and self.uistate.window:
window.set_transient_for(self.uistate.window)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
scroller = Gtk.ScrolledWindow()
scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroller.add(self._treeview)
vbox.pack_start(scroller, True, True, 0)
# Button bar at the bottom
btn_bar = Gtk.ButtonBox(orientation=Gtk.Orientation.HORIZONTAL)
btn_bar.set_layout(Gtk.ButtonBoxStyle.START)
btn_bar.set_border_width(4)
btn_help = Gtk.Button.new_with_mnemonic(_("_Help"))
btn_help.connect("clicked", self._cb_help)
btn_bar.pack_start(btn_help, False, False, 0)
vbox.pack_start(btn_bar, False, False, 0)
window.add(vbox)
window.show_all()
self.set_window(window, None, self.label)
self.show()
def _print_cli(self, stats_list):
"""
Print associations to stdout when no UI is available.
:param stats_list: List of association row data tuples.
"""
print(
"\t%s" * 5
% (
_("Starting Name"),
_("Calculated"),
" • ",
_("Associate"),
_("Associate's Link type"),
)
)
print()
for entry in stats_list:
print("\t%s" * 5 % entry[:5])
# ------------------------------------------------------------------
# Sorting
# ------------------------------------------------------------------
def _cb_column_clicked(self, column, col_idx):
"""
Handle column header click to sort by that column.
:param column: The clicked TreeViewColumn.
:param col_idx: Integer index of the column.
"""
if self._sort_col == col_idx:
self._sort_ascending = not self._sort_ascending
else:
self._sort_col = col_idx
self._sort_ascending = True
sort_order = (
Gtk.SortType.ASCENDING
if self._sort_ascending
else Gtk.SortType.DESCENDING
)
for idx, col in enumerate(self._treeview.get_columns()):
if idx == col_idx:
col.set_sort_indicator(True)
col.set_sort_order(sort_order)
else:
col.set_sort_indicator(False)
self._treeview.get_model().set_sort_column_id(col_idx, sort_order)
# ------------------------------------------------------------------
# Selection helper
# ------------------------------------------------------------------
def _get_selected_row(self):
"""
Return the full model row values for the selected row, or None.
:returns: Tuple of 8 model values, or None.
"""
model, tree_iter = self._treeview.get_selection().get_selected()
if not tree_iter:
return None
return tuple(model.get_value(tree_iter, col) for col in range(8))
# ------------------------------------------------------------------
# Double-click via row-activated (GTK passes path + column directly)
# ------------------------------------------------------------------
def _cb_row_activated(self, treeview, path, column):
"""
Route a double-click to the correct editor based on which column was clicked.
GTK's row-activated signal fires on double-click and provides the exact
column that was activated, so no coordinate math is needed.
:param treeview: The TreeView widget.
:param path: Gtk.TreePath of the activated row.
:param column: Gtk.TreeViewColumn that was double-clicked.
"""
columns = treeview.get_columns()
col_idx = columns.index(column)
tree_iter = self._model.model.get_iter(path)
if tree_iter is None:
return
handle1 = self._model.model.get_value(tree_iter, COL_HANDLE1)
handle2 = self._model.model.get_value(tree_iter, COL_HANDLE2)
if col_idx == COL_NAME2:
self._open_person_editor(handle2)
elif col_idx == COL_LINK:
self._open_association_editor(handle1, handle2)
else:
# COL_NAME1, COL_CALC, COL_BULLET — open the starting person
self._open_person_editor(handle1)
# ------------------------------------------------------------------
# Right-click context menu (ListModel right_click)
# ------------------------------------------------------------------
def _cb_right_click(self, treeview, event):
"""
Show a context menu on right-click.
:param treeview: The TreeView widget.
:param event: The button event.
"""
row = self._get_selected_row()
has_row = row is not None
menu = Gtk.Menu()
menu.set_reserve_toggle_size(False)
entries = [
(_("Edit Starting Person"), self._cb_edit_person1, has_row),
(_("Edit Associate Person"), self._cb_edit_person2, has_row),
(_("Edit Association"), self._cb_edit_association, has_row),
(None, None, 0),
(_("Copy row to clipboard"), self._cb_copy_row, has_row),
(_("Create Note from row"), self._cb_create_note, has_row),
]
for title, callback, sensitive in entries:
if title is None:
item = Gtk.SeparatorMenuItem()
else:
item = Gtk.MenuItem(label=title)
if callback:
item.connect("activate", callback)
item.set_sensitive(sensitive)
item.show()
menu.append(item)
menu.popup_at_pointer(event)
# ------------------------------------------------------------------
# Context menu callbacks
# ------------------------------------------------------------------
def _cb_edit_person1(self, obj):
"""
Open Person Editor for the Starting Name of the selected row.
:param obj: Menu item (unused).
"""
row = self._get_selected_row()
if row:
self._open_person_editor(row[COL_HANDLE1])
def _cb_edit_person2(self, obj):
"""
Open Person Editor for the Associate of the selected row.
:param obj: Menu item (unused).
"""
row = self._get_selected_row()
if row:
self._open_person_editor(row[COL_HANDLE2])
def _cb_edit_association(self, obj):
"""
Open the Association Editor for the selected row.
:param obj: Menu item (unused).
"""
row = self._get_selected_row()
if row:
self._open_association_editor(row[COL_HANDLE1], row[COL_HANDLE2])
def _cb_copy_row(self, obj):
"""
Copy the selected row display text to the system clipboard.
:param obj: Menu item (unused).
"""
row = self._get_selected_row()
if not row:
return
text = "\t".join(str(row[col]) for col in range(5))
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(text, -1)
def _cb_create_note(self, obj):
"""
Create a Note pre-populated with row data and open the Note Editor.
The note body lists each field on its own line so the user can read it
easily. Person handles are rendered as gramps:// deep-link URLs on
separate lines; the Note editor renders these as clickable hyperlinks.
:param obj: Menu item (unused).
"""
row = self._get_selected_row()
if not row:
return
name1 = row[COL_NAME1]
rel = row[COL_CALC]
relcon = row[COL_BULLET]
name2 = row[COL_NAME2]
value = row[COL_LINK]
handle1 = row[COL_HANDLE1]
handle2 = row[COL_HANDLE2]
url1 = "gramps://Person/handle/%s" % handle1
url2 = "gramps://Person/handle/%s" % handle2
# Build note text with styled hyperlinks on the person name spans.
# Construct each segment explicitly so character offsets are exact.
seg_header = _("Association:") + "\n"
seg_p1_pre = _("Starting Person") + ": "
# name1 will be the linked span
seg_mid = "\n" + _("Relationship") + ": "
# name2 will be the linked span
seg_suffix = " (%s%s)\n" % (rel, relcon) + _("Link type") + ": " + value
raw_text = seg_header + seg_p1_pre + name1 + seg_mid + name2 + seg_suffix
off1_start = len(seg_header) + len(seg_p1_pre)
off1_end = off1_start + len(name1)
off2_start = off1_end + len(seg_mid)
off2_end = off2_start + len(name2)
tag1 = StyledTextTag(
StyledTextTagType.LINK,
url1,
[(off1_start, off1_end)],
)
tag2 = StyledTextTag(
StyledTextTagType.LINK,
url2,
[(off2_start, off2_end)],
)
styled = StyledText(raw_text, [tag1, tag2])
note = Note()
note.set_styledtext(styled)
note.set_type(NoteType.GENERAL)
EditNote(self.dbstate, self.uistate, [], note)
# ------------------------------------------------------------------
# Editor helpers
# ------------------------------------------------------------------
def _open_person_editor(self, handle):
"""
Open the Person Editor for the given handle.
:param handle: The Gramps person handle string.
"""
try:
person = self.dbstate.db.get_person_from_handle(handle)
EditPerson(self.dbstate, self.uistate, [], person)
except HandleError:
LOG.warning(_("Invalid handle: %s") % handle)
except WindowActiveError:
pass
def _open_association_editor(self, handle1, handle2):
"""
Open the Association Editor for the association between two people.
:param handle1: Handle of the primary person.
:param handle2: Handle of the associated person.
"""
try:
person = self.dbstate.db.get_person_from_handle(handle1)
except HandleError:
LOG.warning(_("Invalid handle: %s") % handle1)
return
for ref in person.get_person_ref_list():
if ref.ref == handle2:
from gramps.gui.editors import EditPersonRef # noqa: PLC0415
EditPersonRef(
self.dbstate,
self.uistate,
[],
ref,
self._noop_callback,
)
return
LOG.warning(
_("No association found between %(h1)s and %(h2)s")
% {"h1": handle1, "h2": handle2}
)
def _noop_callback(self, *args):
"""
No-op callback placeholder for editor callbacks.
:param args: Ignored arguments.
"""
def _cb_help(self, obj):
"""
Open the addon wiki page in the default web browser.
:param obj: Button widget (unused).
"""
display_url(
"https://www.gramps-project.org/wiki/index.php/Addon:Check_Associations"
)
def build_menu_names(self, obj):
"""
Return menu names for the managed window.
:param obj: Ignored.
:returns: Tuple of (label, None).
"""
return (self.label, None)
#------------------------------------------------------------
#
# AssociationsToolOptions
#
#------------------------------------------------------------
class AssociationsToolOptions(tool.ToolOptions):
"""Defines options and provides handling interface."""
def __init__(self, name, person_id=None):
"""
Initialise tool options.
:param name: Tool name.
:param person_id: Optional active person Gramps ID.
"""
tool.ToolOptions.__init__(self, name, person_id)


