From 2234f5a837e1ba7e6330ff1146af0d3d10ada745 Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Sun, 19 Jul 2026 09:42:17 -0700 Subject: [PATCH] Add WordClouds gramplet Adds three word-cloud gramplets (Given Name, Surname, Place) that render names/places as a size-weighted, click-to-navigate word cloud instead of the old plain-text link list, using a new Cairo/Pango WordCloudWidget with a spiral placement algorithm. Includes a slider+entry option widget (SliderOption/GuiSliderOption) registered via BasePluginManager.register_option so no changes to Gramps core are required. Based on ideas from ClmntPnd's gramps core PR https://github.com/gramps-project/gramps/pull/2223, adapted here as a self-contained addon so it works with unmodified stable Gramps releases. Given Name/Surname/Place Word Cloud use new ids and class names distinct from the built-in Given Name Cloud / Surname Cloud gramplets to avoid any plugin registration collision. --- WordClouds/WordClouds.gpr.py | 67 +++ WordClouds/cloudgramplet.py | 233 ++++++++++ WordClouds/givennamewordcloudgramplet.py | 73 ++++ WordClouds/placewordcloudgramplet.py | 80 ++++ WordClouds/slideroption.py | 168 ++++++++ WordClouds/surnamewordcloudgramplet.py | 75 ++++ WordClouds/tests/__init__.py | 0 WordClouds/tests/test_cloudgramplet_logic.py | 166 ++++++++ WordClouds/tests/test_imports.py | 80 ++++ WordClouds/tests/test_slideroption.py | 86 ++++ WordClouds/tests/test_wordcloudwidget.py | 88 ++++ WordClouds/wordcloudwidget.py | 424 +++++++++++++++++++ 12 files changed, 1540 insertions(+) create mode 100644 WordClouds/WordClouds.gpr.py create mode 100644 WordClouds/cloudgramplet.py create mode 100644 WordClouds/givennamewordcloudgramplet.py create mode 100644 WordClouds/placewordcloudgramplet.py create mode 100644 WordClouds/slideroption.py create mode 100644 WordClouds/surnamewordcloudgramplet.py create mode 100644 WordClouds/tests/__init__.py create mode 100644 WordClouds/tests/test_cloudgramplet_logic.py create mode 100644 WordClouds/tests/test_imports.py create mode 100644 WordClouds/tests/test_slideroption.py create mode 100644 WordClouds/tests/test_wordcloudwidget.py create mode 100644 WordClouds/wordcloudwidget.py diff --git a/WordClouds/WordClouds.gpr.py b/WordClouds/WordClouds.gpr.py new file mode 100644 index 000000000..ebe25bf93 --- /dev/null +++ b/WordClouds/WordClouds.gpr.py @@ -0,0 +1,67 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Douglas S. Blank +# +# 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. +# + +register( + GRAMPLET, + id="Given Name Word Cloud", + name=_("Given Name Word Cloud"), + description=_("Gramplet showing all given names as a word cloud"), + status=STABLE, + version="1.0.0", + fname="givennamewordcloudgramplet.py", + height=300, + expand=True, + gramplet="GivenNameWordCloudGramplet", + gramplet_title=_("Given Name Word Cloud"), + gramps_target_version="6.1", + help_url="WordClouds", +) + +register( + GRAMPLET, + id="Surname Word Cloud", + name=_("Surname Word Cloud"), + description=_("Gramplet showing all surnames as a word cloud"), + status=STABLE, + version="1.0.0", + fname="surnamewordcloudgramplet.py", + height=300, + expand=True, + gramplet="SurnameWordCloudGramplet", + gramplet_title=_("Surname Word Cloud"), + gramps_target_version="6.1", + help_url="WordClouds", +) + +register( + GRAMPLET, + id="Place Word Cloud", + name=_("Place Word Cloud"), + description=_("Gramplet showing all places as a word cloud"), + status=STABLE, + version="1.0.0", + fname="placewordcloudgramplet.py", + height=300, + expand=True, + gramplet="PlaceWordCloudGramplet", + gramplet_title=_("Place Word Cloud"), + gramps_target_version="6.1", + help_url="WordClouds", +) diff --git a/WordClouds/cloudgramplet.py b/WordClouds/cloudgramplet.py new file mode 100644 index 000000000..28b71cb8e --- /dev/null +++ b/WordClouds/cloudgramplet.py @@ -0,0 +1,233 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2007-2009 Douglas S. Blank +# Copyright (C) 2026 Douglas S. Blank +# +# 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, see . + +# ------------------------------------------------------------------------ +# +# Python modules +# +# ------------------------------------------------------------------------ +from abc import abstractmethod + +# ------------------------------------------------------------------------ +# +# Gramps modules +# +# ------------------------------------------------------------------------ +from gramps.gen.plug import Gramplet, BasePluginManager +from gramps.gen.config import config +from gramps.gen.const import GRAMPS_LOCALE as glocale + +try: + _trans = glocale.get_addon_translator(__file__) +except ValueError: + _trans = glocale.translation +_ = _trans.sgettext + +from wordcloudwidget import WordCloudWidget +from slideroption import SliderOption, GuiSliderOption + +# ------------------------------------------------------------------------ +# +# Constants +# +# ------------------------------------------------------------------------ + +_YIELD_INTERVAL = 350 + +_DEFAULT_COLOR_LOW = "#99ccff" # (0.6, 0.8, 1.0) +_DEFAULT_COLOR_HIGH = "#003399" # (0.0, 0.2, 0.6) +_DEFAULT_COLOR_HOVER = "#cc0000" # (0.8, 0.0, 0.0) +_DEFAULT_QUALITY = 0.0 + + +def _hex_to_rgb(hex_color): + h = hex_color.lstrip("#") + return tuple(int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) + + +# ------------------------------------------------------------------------ +# +# CloudGramplet class +# +# ------------------------------------------------------------------------ +class CloudGramplet(Gramplet): + """A gramplet that displays a word cloud where word size reflects frequency.""" + + def init(self): + pmgr = BasePluginManager.get_instance() + pmgr.register_option(SliderOption, GuiSliderOption) + + self.top_size = 150 + self.color_low = _DEFAULT_COLOR_LOW + self.color_high = _DEFAULT_COLOR_HIGH + self.color_hover = _DEFAULT_COLOR_HOVER + self.quality = _DEFAULT_QUALITY + self.filter_missing = True + self.value_name = "default_value_name" + self.preference_no_value = "" + self._values_linked_data = {} + + self.word_cloud = WordCloudWidget( + [], + on_click=self._on_word_clicked, + color_low=_hex_to_rgb(self.color_low), + color_high=_hex_to_rgb(self.color_high), + color_hover=_hex_to_rgb(self.color_hover), + quality=self.quality, + ) + self.gui.get_container_widget().remove(self.gui.textview) + self.gui.get_container_widget().add(self.word_cloud) + self.word_cloud.show() + + def set_value_name(self, value_name): + """What the cloud displays. For a name cloud, `value_name` is "name".""" + self.value_name = _(value_name) + + def set_preference_no_value(self, preference_no_value): + """Config key holding the default text to show when there are no values.""" + self.preference_no_value = preference_no_value + + def _on_word_clicked(self, word): + linked_data = self._values_linked_data.get(word) + if linked_data is not None: + self.on_item_clicked(word, linked_data) + + def on_item_clicked(self, word, linked_data): + """Called when the user clicks a word. Subclasses override to navigate.""" + + @abstractmethod + def db_changed(self): + """Connect the cloud with the database. + See the example in surnamewordcloudgramplet.py. + """ + + @abstractmethod + def get_items(self) -> list: + """How to access data in the cloud. Must return an iterator of + (value, linked_data, count) triples. + See the example in surnamewordcloudgramplet.py. + """ + + def on_load(self): + data = self.gui.data + if len(data) >= 1: + self.top_size = int(data[0]) + if len(data) >= 5: + self.color_low = data[1] + self.color_high = data[2] + self.color_hover = data[3] + self.quality = float(data[4]) + if len(data) >= 6: + self.filter_missing = bool(int(data[5])) + self.word_cloud.set_colors( + _hex_to_rgb(self.color_low), + _hex_to_rgb(self.color_high), + _hex_to_rgb(self.color_hover), + ) + self.word_cloud.set_quality(self.quality) + + def _read_options(self): + self.top_size = int( + self.get_option(_("Number of %s") % self.value_name).get_value() + ) + self.color_low = self.get_option(_("Color (low)")).get_value() + self.color_high = self.get_option(_("Color (high)")).get_value() + self.color_hover = self.get_option(_("Hover color")).get_value() + self.quality = float(self.get_option(_("Layout quality")).get_value()) + self.filter_missing = self.get_option( + _("Filter missing/unknown words") + ).get_value() + + def save_update_options(self, widget=None): + self._read_options() + self.gui.data = [ + self.top_size, + self.color_low, + self.color_high, + self.color_hover, + self.quality, + int(self.filter_missing), + ] + self.update() + + def save_options(self): + self._read_options() + + def main(self): + yield True + + yield_counter = 0 + + values_counts = {} + values_linked_data = {} + + for value, linked_data, count in self.get_items(): + if value not in values_counts: + values_linked_data[value] = linked_data + values_counts[value] = count + else: + values_counts[value] += count + + yield_counter += 1 + if not yield_counter % _YIELD_INTERVAL: + yield True + + # count order: [(value, count), ...] + sorted_values = sorted( + list(values_counts.items()), key=(lambda k: k[1]), reverse=True + ) + + # limit to top_size distinct values + selected_values = sorted_values[: self.top_size] + + # Build words list for the widget, resolving empty-value display text + self._values_linked_data = {} + words = [] + for value, count in selected_values: + if len(value) == 0: + if self.preference_no_value != "": + display = config.get(self.preference_no_value) + else: + display = _("[Missing %s]") % self.value_name + else: + display = value + self._values_linked_data[display] = values_linked_data.get(value) + words.append((display, count)) + + self.word_cloud.configure( + quality=self.quality, + color_low=_hex_to_rgb(self.color_low), + color_high=_hex_to_rgb(self.color_high), + color_hover=_hex_to_rgb(self.color_hover), + ) + self.word_cloud.set_words(words) + + def build_options(self): + from gramps.gen.plug.menu import BooleanOption, ColorOption + + self.add_option( + SliderOption(_("Number of %s") % self.value_name, self.top_size, 1, 150) + ) + self.add_option(ColorOption(_("Color (low)"), self.color_low)) + self.add_option(ColorOption(_("Color (high)"), self.color_high)) + self.add_option(ColorOption(_("Hover color"), self.color_hover)) + self.add_option(SliderOption(_("Layout quality"), self.quality, 0.0, 1.0, 0.1)) + self.add_option( + BooleanOption(_("Filter missing/unknown words"), self.filter_missing) + ) diff --git a/WordClouds/givennamewordcloudgramplet.py b/WordClouds/givennamewordcloudgramplet.py new file mode 100644 index 000000000..14fa4b2e1 --- /dev/null +++ b/WordClouds/givennamewordcloudgramplet.py @@ -0,0 +1,73 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2009 Pander Musubi +# Copyright (C) 2009 Douglas S. Blank +# Copyright (C) 2026 Douglas S. Blank +# +# 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, see . + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gui.plug.quick import run_quick_report_by_name + +try: + _trans = glocale.get_addon_translator(__file__) +except ValueError: + _trans = glocale.translation +_ = _trans.sgettext + +from cloudgramplet import CloudGramplet + + +# ------------------------------------------------------------------------- +# +# GivenNameWordCloudGramplet class +# +# ------------------------------------------------------------------------- +class GivenNameWordCloudGramplet(CloudGramplet): + """Implementation of a Cloud gramplet for given names.""" + + def init(self): + CloudGramplet.init(self) + self.set_value_name("given name") + self.set_preference_no_value("preferences.no-given-text") + self.set_tooltip(_("Click given name to view people with that given name")) + + def on_item_clicked(self, word, linked_data): + run_quick_report_by_name( + self.dbstate, self.uistate, "samegivens_misc", linked_data + ) + + def db_changed(self): + self.connect(self.dbstate.db, "person-add", self.update) + self.connect(self.dbstate.db, "person-delete", self.update) + self.connect(self.dbstate.db, "person-update", self.update) + self.connect(self.dbstate.db, "person-rebuild", self.update) + self.connect(self.dbstate.db, "family-rebuild", self.update) + + def get_items(self): + counts = {} + for person in self.dbstate.db.iter_people(): + allnames = [person.get_primary_name()] + person.get_alternate_names() + for name in allnames: + given_name = name.get_first_name().strip() + if self.filter_missing and (not given_name or given_name == "?"): + continue + counts[given_name] = counts.get(given_name, 0) + 1 + return [(given_name, given_name, counts[given_name]) for given_name in counts] diff --git a/WordClouds/placewordcloudgramplet.py b/WordClouds/placewordcloudgramplet.py new file mode 100644 index 000000000..d762b4d43 --- /dev/null +++ b/WordClouds/placewordcloudgramplet.py @@ -0,0 +1,80 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2009 Pander Musubi +# Copyright (C) 2009 Douglas S. Blank +# Copyright (C) 2026 Douglas S. Blank +# +# 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, see . + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gen.display.place import displayer as place_displayer +from gramps.gui.plug.quick import run_quick_report_by_name + +try: + _trans = glocale.get_addon_translator(__file__) +except ValueError: + _trans = glocale.translation +_ = _trans.sgettext + +from cloudgramplet import CloudGramplet + + +# ------------------------------------------------------------------------- +# +# PlaceWordCloudGramplet class +# +# ------------------------------------------------------------------------- +class PlaceWordCloudGramplet(CloudGramplet): + """Implementation of a Cloud gramplet for place names. + + Word size reflects how many times each place is referenced in the database. + """ + + def init(self): + CloudGramplet.init(self) + self.set_value_name("place name") + self.set_tooltip(_("Click place name to view references")) + + def on_item_clicked(self, word, linked_data): + run_quick_report_by_name( + self.dbstate, self.uistate, "placereferences", linked_data + ) + + def db_changed(self): + self.connect(self.dbstate.db, "place-add", self.update) + self.connect(self.dbstate.db, "place-delete", self.update) + self.connect(self.dbstate.db, "place-update", self.update) + self.connect(self.dbstate.db, "event-add", self.update) + self.connect(self.dbstate.db, "event-update", self.update) + self.connect(self.dbstate.db, "event-delete", self.update) + + def get_items(self) -> list: + # Use the full hierarchical name so each place maps to a unique string, + # and count by backlinks so word size reflects how often it is used. + items = [] + for place in self.dbstate.db.iter_places(): + handle = place.handle + count = len(list(self.dbstate.db.find_backlink_handles(handle))) + if count > 0: + placename = place_displayer.display(self.dbstate.db, place) + if self.filter_missing and placename in (None, "", "?"): + continue + items.append((placename, handle, count)) + return items diff --git a/WordClouds/slideroption.py b/WordClouds/slideroption.py new file mode 100644 index 000000000..0fc98074a --- /dev/null +++ b/WordClouds/slideroption.py @@ -0,0 +1,168 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2007-2009 Douglas S. Blank +# Copyright (C) 2026 Douglas S. Blank +# +# 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, see . +# +""" +A NumberOption rendered as a horizontal slider with a text entry, and the +matching GTK widget. Registered with the plugin manager as an external +option so it does not require any change to Gramps core. +""" + +# ------------------------------------------------------------------------- +# +# Standard Python modules +# +# ------------------------------------------------------------------------- +import math + +# ------------------------------------------------------------------------- +# +# GTK/Gnome modules +# +# ------------------------------------------------------------------------- +from gi.repository import Gtk + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- +from gramps.gen.plug.menu import NumberOption + + +# ------------------------------------------------------------------------- +# +# SliderOption class +# +# ------------------------------------------------------------------------- +class SliderOption(NumberOption): + """ + A NumberOption rendered as a horizontal slider + text entry widget. + Saves only on mouse-up or entry commit, not on every drag tick. + All min/max/step/value logic is inherited from NumberOption. + """ + + +# ------------------------------------------------------------------------- +# +# GuiSliderOption class +# +# ------------------------------------------------------------------------- +class GuiSliderOption(Gtk.Box): + """ + Displays a number option as a horizontal slider alongside a text entry. + The option value is only committed on mouse-up or entry activation, + not on every drag tick. + """ + + def __init__(self, option, dbstate, uistate, track, override): + self.__option = option + + step = self.__option.get_step() + self.__decimals = 0 + if step < 1: + self.__decimals = int(math.log10(step) * -1) + + Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + + adj = Gtk.Adjustment( + value=self.__option.get_value(), + lower=self.__option.get_min(), + upper=self.__option.get_max(), + step_increment=step, + ) + self.__scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + self.__scale.set_digits(self.__decimals) + self.__scale.set_hexpand(True) + self.__scale.set_draw_value(False) + + self.__entry = Gtk.Entry() + self.__entry.set_width_chars(6) + self.__entry.set_max_width_chars(8) + self.__entry.set_text(self.__format(self.__option.get_value())) + + self.pack_start(self.__scale, True, True, 0) + self.pack_start(self.__entry, False, False, 0) + + # Live sync: update entry text while dragging, but do not commit yet. + self.scalekey = self.__scale.connect("value-changed", self.__scale_moved) + # Commit on mouse-up only. + self.__scale.connect("button-release-event", self.__scale_released) + # Commit entry on Enter or focus-out. + self.entrykey = self.__entry.connect("activate", self.__entry_activated) + self.__entry.connect("focus-out-event", self.__entry_activated) + + # Programmatic option change -> update both widgets. + self.valuekey = self.__option.connect("value-changed", self.__value_changed) + self.conkey = self.__option.connect("avail-changed", self.__update_avail) + self.__update_avail() + + self.set_tooltip_text(self.__option.get_help()) + + def __format(self, value): + if self.__decimals == 0: + return str(int(value)) + return "{:.{}f}".format(value, self.__decimals) + + def __scale_moved(self, obj): + """Update entry text live during drag without committing to the option.""" + self.__entry.handler_block(self.entrykey) + self.__entry.set_text(self.__format(self.__scale.get_value())) + self.__entry.handler_unblock(self.entrykey) + + def __scale_released(self, obj, event): + """Commit the slider value to the option on mouse-up.""" + vtype = type(self.__option.get_value()) + self.__scale.handler_block(self.scalekey) + self.__option.set_value(vtype(self.__scale.get_value())) + self.__scale.handler_unblock(self.scalekey) + + def __entry_activated(self, obj, event=None): + """Commit a typed value from the entry to the option.""" + try: + vtype = type(self.__option.get_value()) + value = vtype(float(self.__entry.get_text())) + value = max(self.__option.get_min(), min(self.__option.get_max(), value)) + except (ValueError, TypeError): + self.__entry.set_text(self.__format(self.__option.get_value())) + return + if value == self.__option.get_value(): + return + self.__scale.handler_block(self.scalekey) + self.__scale.set_value(value) + self.__scale.handler_unblock(self.scalekey) + self.__option.set_value(value) + + def __value_changed(self): + """Handle a programmatic change to the option value.""" + value = self.__option.get_value() + self.__scale.handler_block(self.scalekey) + self.__entry.handler_block(self.entrykey) + self.__scale.set_value(value) + self.__entry.set_text(self.__format(value)) + self.__entry.handler_unblock(self.entrykey) + self.__scale.handler_unblock(self.scalekey) + + def __update_avail(self): + avail = self.__option.get_available() + self.set_sensitive(avail) + + def clean_up(self): + self.__option.disconnect(self.valuekey) + self.__option.disconnect(self.conkey) + self.__option = None diff --git a/WordClouds/surnamewordcloudgramplet.py b/WordClouds/surnamewordcloudgramplet.py new file mode 100644 index 000000000..858c53553 --- /dev/null +++ b/WordClouds/surnamewordcloudgramplet.py @@ -0,0 +1,75 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2007-2009 Douglas S. Blank +# Copyright (C) 2026 Douglas S. Blank +# +# 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, see . + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gui.plug.quick import run_quick_report_by_name + +try: + _trans = glocale.get_addon_translator(__file__) +except ValueError: + _trans = glocale.translation +_ = _trans.sgettext + +from cloudgramplet import CloudGramplet + + +# ------------------------------------------------------------------------- +# +# SurnameWordCloudGramplet class +# +# ------------------------------------------------------------------------- +class SurnameWordCloudGramplet(CloudGramplet): + """Implementation of a Cloud gramplet for surnames.""" + + def init(self): + CloudGramplet.init(self) + self.set_value_name("surname") + self.set_preference_no_value("preferences.no-surname-text") + self.set_tooltip(_("Click surname to view people with that surname")) + + def on_item_clicked(self, word, linked_data): + run_quick_report_by_name( + self.dbstate, self.uistate, "samesurnames", linked_data + ) + + def db_changed(self): + self.connect(self.dbstate.db, "person-add", self.update) + self.connect(self.dbstate.db, "person-delete", self.update) + self.connect(self.dbstate.db, "person-update", self.update) + self.connect(self.dbstate.db, "person-rebuild", self.update) + self.connect(self.dbstate.db, "family-rebuild", self.update) + + def get_items(self): + counts = {} + handles = {} + for person in self.dbstate.db.iter_people(): + allnames = [person.get_primary_name()] + person.get_alternate_names() + for name in allnames: + surname = name.get_surname().strip() + if self.filter_missing and (not surname or surname == "?"): + continue + counts[surname] = counts.get(surname, 0) + 1 + if surname not in handles: + handles[surname] = person.handle + return [(surname, handles[surname], counts[surname]) for surname in counts] diff --git a/WordClouds/tests/__init__.py b/WordClouds/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/WordClouds/tests/test_cloudgramplet_logic.py b/WordClouds/tests/test_cloudgramplet_logic.py new file mode 100644 index 000000000..87dd66216 --- /dev/null +++ b/WordClouds/tests/test_cloudgramplet_logic.py @@ -0,0 +1,166 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Douglas S. Blank +# +# 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. +# + +""" +Functional tests for each gramplet's get_items() against a real in-memory +Gramps database, bypassing Gramplet.__init__ (and so all GTK/GUI setup) +since get_items() only touches self.dbstate and self.filter_missing. +""" + +import os +import sys +import types +import unittest + +try: + import gi + + gi.require_version("Gtk", "3.0") +except (ImportError, ValueError) as err: + raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from gramps.gen.db import DbTxn +from gramps.gen.db.utils import make_database +from gramps.gen.lib import Event, EventType, Name, Place, PlaceName, Person, Surname + +from cloudgramplet import _hex_to_rgb +from givennamewordcloudgramplet import GivenNameWordCloudGramplet +from placewordcloudgramplet import PlaceWordCloudGramplet +from surnamewordcloudgramplet import SurnameWordCloudGramplet + + +def _make_gramplet(cls, db, filter_missing): + """Build a gramplet instance without running Gramplet.__init__/init(), + which would require a live GUI. get_items() only needs dbstate/filter_missing. + """ + gramplet = object.__new__(cls) + gramplet.dbstate = types.SimpleNamespace(db=db) + gramplet.filter_missing = filter_missing + return gramplet + + +class CloudGrampletLogicTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.db = make_database("sqlite") + cls.db.load(":memory:") + + with DbTxn("Add test objects", cls.db) as trans: + cls.smith1 = cls._add_person(cls.db, trans, "John", "Smith") + cls.smith2 = cls._add_person(cls.db, trans, "Jane", "Smith") + cls.doe = cls._add_person(cls.db, trans, "John", "Doe") + cls.blank = cls._add_person(cls.db, trans, "", "") + + cls.place = cls._add_place(cls.db, trans, "Springfield") + cls.unused_place = cls._add_place(cls.db, trans, "Shelbyville") + + event = Event() + event.set_type(EventType(EventType.BIRTH)) + event.set_place_handle(cls.place.handle) + cls.db.add_event(event, trans) + + @classmethod + def tearDownClass(cls): + cls.db.close() + + @staticmethod + def _add_person(db, trans, given, surname): + person = Person() + name = Name() + name.set_first_name(given) + gramps_surname = Surname() + gramps_surname.set_surname(surname) + name.set_surname_list([gramps_surname]) + person.set_primary_name(name) + db.add_person(person, trans) + return person + + @staticmethod + def _add_place(db, trans, place_name): + place = Place() + place.set_name(PlaceName(value=place_name)) + db.add_place(place, trans) + return place + + +class TestGivenNameWordCloudGramplet(CloudGrampletLogicTest): + def test_counts_given_names(self): + gramplet = _make_gramplet( + GivenNameWordCloudGramplet, self.db, filter_missing=True + ) + items = dict((value, count) for value, _linked, count in gramplet.get_items()) + self.assertEqual(items["John"], 2) + self.assertEqual(items["Jane"], 1) + self.assertNotIn("", items) + + def test_filter_missing_false_includes_blank(self): + gramplet = _make_gramplet( + GivenNameWordCloudGramplet, self.db, filter_missing=False + ) + items = dict((value, count) for value, _linked, count in gramplet.get_items()) + self.assertIn("", items) + + +class TestSurnameWordCloudGramplet(CloudGrampletLogicTest): + def test_counts_surnames_and_links_a_handle(self): + gramplet = _make_gramplet( + SurnameWordCloudGramplet, self.db, filter_missing=True + ) + items = { + value: (linked, count) for value, linked, count in gramplet.get_items() + } + self.assertEqual(items["Smith"][1], 2) + self.assertEqual(items["Doe"][1], 1) + self.assertIn(items["Smith"][0], (self.smith1.handle, self.smith2.handle)) + self.assertNotIn("", items) + + def test_filter_missing_false_includes_blank(self): + gramplet = _make_gramplet( + SurnameWordCloudGramplet, self.db, filter_missing=False + ) + items = dict((value, count) for value, _linked, count in gramplet.get_items()) + self.assertIn("", items) + + +class TestPlaceWordCloudGramplet(CloudGrampletLogicTest): + def test_only_referenced_places_are_included(self): + gramplet = _make_gramplet(PlaceWordCloudGramplet, self.db, filter_missing=True) + items = { + value: (linked, count) for value, linked, count in gramplet.get_items() + } + self.assertIn("Springfield", items) + self.assertEqual(items["Springfield"][0], self.place.handle) + self.assertEqual(items["Springfield"][1], 1) + # Shelbyville has no backlinks, so it must not appear. + self.assertNotIn("Shelbyville", items) + + +class TestHexToRgb(unittest.TestCase): + def test_black(self): + self.assertEqual(_hex_to_rgb("#000000"), (0.0, 0.0, 0.0)) + + def test_white(self): + self.assertEqual(_hex_to_rgb("#ffffff"), (1.0, 1.0, 1.0)) + + +if __name__ == "__main__": + unittest.main() diff --git a/WordClouds/tests/test_imports.py b/WordClouds/tests/test_imports.py new file mode 100644 index 000000000..7740b9839 --- /dev/null +++ b/WordClouds/tests/test_imports.py @@ -0,0 +1,80 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Douglas S. Blank +# +# 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. +# + +""" +Regression test: every WordClouds module must import cleanly and expose +the class named in WordClouds.gpr.py, and each gramplet class must be a +Gramplet subclass. +""" + +import os +import sys +import unittest + +try: + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") +except (ImportError, ValueError) as err: + raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) + +# Make sure addon modules are importable from the parent directory. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +class TestWordCloudsImports(unittest.TestCase): + """Every module registered in WordClouds.gpr.py must import cleanly.""" + + def test_wordcloudwidget_imports(self): + import wordcloudwidget + + self.assertTrue(hasattr(wordcloudwidget, "WordCloudWidget")) + + def test_slideroption_imports(self): + import slideroption + + self.assertTrue(hasattr(slideroption, "SliderOption")) + self.assertTrue(hasattr(slideroption, "GuiSliderOption")) + + def test_cloudgramplet_imports(self): + import cloudgramplet + + self.assertTrue(hasattr(cloudgramplet, "CloudGramplet")) + + def test_gramplet_classes_are_gramplet_subclasses(self): + from gramps.gen.plug import Gramplet + + from givennamewordcloudgramplet import GivenNameWordCloudGramplet + from surnamewordcloudgramplet import SurnameWordCloudGramplet + from placewordcloudgramplet import PlaceWordCloudGramplet + + for cls in ( + GivenNameWordCloudGramplet, + SurnameWordCloudGramplet, + PlaceWordCloudGramplet, + ): + self.assertTrue( + issubclass(cls, Gramplet), "%s must be a Gramplet subclass" % cls + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/WordClouds/tests/test_slideroption.py b/WordClouds/tests/test_slideroption.py new file mode 100644 index 000000000..2d004fce2 --- /dev/null +++ b/WordClouds/tests/test_slideroption.py @@ -0,0 +1,86 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Douglas S. Blank +# +# 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. +# + +""" +Tests for SliderOption / GuiSliderOption: the vendored NumberOption +subclass and its GTK widget, registered with the plugin manager as an +external option so WordClouds needs no Gramps core changes. +""" + +import os +import sys +import unittest + +try: + import gi + + gi.require_version("Gtk", "3.0") +except (ImportError, ValueError) as err: + raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) + +from gi.repository import Gtk + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from slideroption import GuiSliderOption, SliderOption + + +class TestSliderOption(unittest.TestCase): + """SliderOption inherits all min/max/step/value logic from NumberOption.""" + + def test_initial_value(self): + option = SliderOption("Quality", 0.5, 0.0, 1.0, 0.1) + self.assertEqual(option.get_value(), 0.5) + self.assertEqual(option.get_min(), 0.0) + self.assertEqual(option.get_max(), 1.0) + self.assertEqual(option.get_step(), 0.1) + + def test_set_value(self): + option = SliderOption("Count", 10, 1, 150) + option.set_value(75) + self.assertEqual(option.get_value(), 75) + + +class TestGuiSliderOption(unittest.TestCase): + """Smoke tests for the GTK widget wrapping a SliderOption.""" + + def _make_widget(self, option): + return GuiSliderOption(option, None, None, [], False) + + def test_widget_reflects_initial_value(self): + option = SliderOption("Count", 10, 1, 150) + widget = self._make_widget(option) + self.assertIsInstance(widget, Gtk.Box) + + def test_option_value_change_updates_widget_without_error(self): + option = SliderOption("Count", 10, 1, 150) + self._make_widget(option) + # Should not raise: exercises __value_changed via the signal. + option.set_value(42) + self.assertEqual(option.get_value(), 42) + + def test_clean_up_disconnects_without_error(self): + option = SliderOption("Count", 10, 1, 150) + widget = self._make_widget(option) + widget.clean_up() + + +if __name__ == "__main__": + unittest.main() diff --git a/WordClouds/tests/test_wordcloudwidget.py b/WordClouds/tests/test_wordcloudwidget.py new file mode 100644 index 000000000..687c7a25d --- /dev/null +++ b/WordClouds/tests/test_wordcloudwidget.py @@ -0,0 +1,88 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Douglas S. Blank +# +# 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. +# + +""" +Tests for the pure layout-math helpers in wordcloudwidget.py: font-size and +color interpolation, and axis-aligned bounding-box overlap detection. +""" + +import os +import sys +import unittest + +try: + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("Pango", "1.0") + gi.require_version("PangoCairo", "1.0") +except (ImportError, ValueError) as err: + raise unittest.SkipTest("GTK 3.0 / PyGObject not available: %s" % err) + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from wordcloudwidget import _aabbs_overlap, _count_to_color, _count_to_fontsize + + +class TestCountToFontsize(unittest.TestCase): + def test_min_and_max_count_map_to_min_and_max_font(self): + self.assertAlmostEqual(_count_to_fontsize(1, 1, 100, 8, 20), 8) + self.assertAlmostEqual(_count_to_fontsize(100, 1, 100, 8, 20), 20) + + def test_equal_min_and_max_count_returns_midpoint(self): + self.assertAlmostEqual(_count_to_fontsize(5, 5, 5, 8, 20), 14) + + def test_higher_count_never_yields_smaller_font(self): + low = _count_to_fontsize(2, 1, 100, 8, 20) + high = _count_to_fontsize(50, 1, 100, 8, 20) + self.assertLessEqual(low, high) + + +class TestCountToColor(unittest.TestCase): + def test_min_count_is_low_color(self): + color = _count_to_color(1, 1, 100, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0)) + self.assertEqual(color, (0.0, 0.0, 0.0)) + + def test_max_count_is_high_color(self): + color = _count_to_color(100, 1, 100, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0)) + self.assertEqual(color, (1.0, 1.0, 1.0)) + + def test_equal_min_and_max_count_returns_midpoint_color(self): + color = _count_to_color(5, 5, 5, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0)) + self.assertEqual(color, (0.5, 0.5, 0.5)) + + +class TestAabbsOverlap(unittest.TestCase): + def test_identical_boxes_overlap(self): + self.assertTrue(_aabbs_overlap(0, 0, 10, 10, 0, 0, 10, 10)) + + def test_disjoint_boxes_do_not_overlap(self): + self.assertFalse(_aabbs_overlap(0, 0, 10, 10, 20, 20, 10, 10)) + + def test_edge_touching_boxes_do_not_overlap(self): + # Box B starts exactly where box A ends: touching, not overlapping. + self.assertFalse(_aabbs_overlap(0, 0, 10, 10, 10, 0, 10, 10)) + + def test_partial_overlap_is_detected(self): + self.assertTrue(_aabbs_overlap(0, 0, 10, 10, 5, 5, 10, 10)) + + +if __name__ == "__main__": + unittest.main() diff --git a/WordClouds/wordcloudwidget.py b/WordClouds/wordcloudwidget.py new file mode 100644 index 000000000..de001c960 --- /dev/null +++ b/WordClouds/wordcloudwidget.py @@ -0,0 +1,424 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2007-2009 Douglas S. Blank +# Copyright (C) 2026 Douglas S. Blank +# +# 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, see . +# +""" +Provides a GTK word cloud widget. +""" + +# ------------------------------------------------------------------------- +# +# Standard Python modules +# +# ------------------------------------------------------------------------- +import math +import random +from functools import lru_cache + +# ------------------------------------------------------------------------- +# +# GTK/Cairo/Pango modules +# +# ------------------------------------------------------------------------- +import cairo +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Pango", "1.0") +gi.require_version("PangoCairo", "1.0") +from gi.repository import Gtk, Gdk, GLib, Pango, PangoCairo + +# ------------------------------------------------------------------------- +# +# Constants +# +# ------------------------------------------------------------------------- +_SPIRAL_STEP = 2.5 +_MAX_THETA = 50 * math.pi +_MAX_SHRINK_STEPS = 3 + +_QUALITY_LEVELS = [ + (10, 72, 4), + (8, 64, 3), + (7, 56, 2), + (6, 48, 1), +] + + +# ------------------------------------------------------------------------- +# +# Helper functions +# +# ------------------------------------------------------------------------- +def _search_params(quality): + q = max(0.0, min(1.0, quality)) + theta_step = 2.0 * (0.025**q) + n_fallbacks = round(q * 2) + return theta_step, n_fallbacks + + +def _count_to_fontsize(count, min_c, max_c, min_font, max_font): + if min_c == max_c: + return (min_font + max_font) / 2 + count = max(count, 1) + min_c = max(min_c, 1) + max_c = max(max_c, 1) + t = (math.log(count) - math.log(min_c)) / (math.log(max_c) - math.log(min_c)) + t = max(0.0, min(1.0, t)) + return min_font + t * (max_font - min_font) + + +def _count_to_color(count, min_c, max_c, color_low, color_high): + if min_c == max_c: + t = 0.5 + else: + min_c = max(min_c, 1) + max_c = max(max_c, 1) + t = (math.log(max(count, 1)) - math.log(min_c)) / ( + math.log(max_c) - math.log(min_c) + ) + t = max(0.0, min(1.0, t)) + r = color_low[0] + t * (color_high[0] - color_low[0]) + g = color_low[1] + t * (color_high[1] - color_low[1]) + b = color_low[2] + t * (color_high[2] - color_low[2]) + return (r, g, b) + + +def _aabbs_overlap(ax, ay, aw, ah, bx, by, bw, bh): + return not (ax + aw <= bx or bx + bw <= ax or ay + ah <= by or by + bh <= ay) + + +def _spiral_positions(cx, cy, theta_step, max_theta, theta_offset=0.0): + theta = theta_offset + while theta - theta_offset < max_theta: + r = _SPIRAL_STEP * theta + yield (cx + r * math.cos(theta), cy + r * math.sin(theta)) + theta += theta_step + + +def _make_font_desc(font_size, style=Pango.Style.NORMAL): + desc = Pango.FontDescription() + desc.set_family("Sans") + desc.set_weight(Pango.Weight.BOLD) + desc.set_style(style) + desc.set_absolute_size(font_size * Pango.SCALE) + return desc + + +@lru_cache(maxsize=None) +def _measure_word(word, font_size): + surf = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1) + ctx = cairo.Context(surf) + layout = PangoCairo.create_layout(ctx) + layout.set_font_description(_make_font_desc(font_size)) + layout.set_text(word, -1) + tw, th = layout.get_pixel_size() + return tw, th + + +def _try_place(word, font_size, cx, cy, vertical, placed, canvas_w, canvas_h, padding): + tw, th = _measure_word(word, font_size) + if vertical: + aw = th + padding * 2 + ah = tw + padding * 2 + else: + aw = tw + padding * 2 + ah = th + padding * 2 + + ax = cx - aw / 2 + ay = cy - ah / 2 + + if ax < 0 or ay < 0 or ax + aw > canvas_w or ay + ah > canvas_h: + return None + + for p in placed: + if _aabbs_overlap(ax, ay, aw, ah, p["ax"], p["ay"], p["aw"], p["ah"]): + return None + + return { + "word": word, + "font_size": font_size, + "ax": ax, + "ay": ay, + "aw": aw, + "ah": ah, + "vertical": vertical, + "tw": tw, + "th": th, + "padding": padding, + } + + +def _place_word(word, font_size, canvas_w, canvas_h, placed, padding, quality=1.0): + theta_step, n_fallbacks = _search_params(quality) + + cx0, cy0 = canvas_w / 2, canvas_h / 2 + jx = random.uniform(-canvas_w / 6, canvas_w / 6) + jy = random.uniform(-canvas_h / 6, canvas_h / 6) + cx, cy = cx0 + jx, cy0 + jy + + orientations = [False, True] if random.random() < 0.5 else [True, False] + + for shrink in range(_MAX_SHRINK_STEPS + 1): + fs = font_size * (0.9**shrink) + for px, py in _spiral_positions(cx, cy, theta_step, _MAX_THETA): + for vertical in orientations: + result = _try_place( + word, fs, px, py, vertical, placed, canvas_w, canvas_h, padding + ) + if result is not None: + return result + fallback_offsets = [math.pi / 3, 2 * math.pi / 3] + for theta_offset in fallback_offsets[:n_fallbacks]: + for px, py in _spiral_positions( + cx0, cy0, theta_step, _MAX_THETA, theta_offset + ): + for vertical in orientations: + result = _try_place( + word, + fs, + px, + py, + vertical, + placed, + canvas_w, + canvas_h, + padding, + ) + if result is not None: + return result + + return None + + +# ------------------------------------------------------------------------- +# +# WordCloudWidget class +# +# ------------------------------------------------------------------------- +class WordCloudWidget(Gtk.DrawingArea): + """ + A GTK DrawingArea that renders a word cloud. + + words : list of (word: str, count: int) + on_click : callable(word: str) or None + color_low : RGB tuple (0-1) for the lowest count + color_high : RGB tuple (0-1) for the highest count + color_hover : RGB tuple (0-1) drawn when the mouse is over a word + quality : 0-1; 1 = tightest packing (slow), 0 = greedy (fast) + """ + + def __init__( + self, + words, + on_click=None, + color_low=(0.6, 0.8, 1.0), + color_high=(0.0, 0.2, 0.6), + color_hover=(0.8, 0.0, 0.0), + quality=0.0, + ): + super().__init__() + self._words = words + self._on_click = on_click + self._color_low = color_low + self._color_high = color_high + self._color_hover = color_hover + self._quality = max(0.0, min(1.0, quality)) + self._layout = [] + self._layout_size = (0, 0) + self._hovered = None + self._resize_timer = None + self._computing = False + self._compute_id = None + + self.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK + ) + self.connect("draw", self._on_draw) + self.connect("size-allocate", self._on_size_allocate) + self.connect("button-press-event", self._on_click_event) + self.connect("motion-notify-event", self._on_motion) + + def set_words(self, words): + self._words = words + self._invalidate() + + def set_quality(self, quality): + self._quality = max(0.0, min(1.0, quality)) + self._invalidate() + + def set_colors(self, color_low, color_high, color_hover): + self._color_low = color_low + self._color_high = color_high + self._color_hover = color_hover + self.queue_draw() + + def configure( + self, quality=None, color_low=None, color_high=None, color_hover=None + ): + """Update settings without triggering a redraw; call set_words() after.""" + if quality is not None: + self._quality = max(0.0, min(1.0, quality)) + if color_low is not None: + self._color_low = color_low + if color_high is not None: + self._color_high = color_high + if color_hover is not None: + self._color_hover = color_hover + + def _invalidate(self): + self._layout_size = (0, 0) + self._computing = True + if self._compute_id is not None: + GLib.source_remove(self._compute_id) + self._compute_id = None + self.queue_draw() + + def _compute_layout(self, canvas_w, canvas_h): + self._layout = [] + if not self._words: + return + + counts = [max(c, 1) for _, c in self._words] + min_c, max_c = min(counts), max(counts) + + best_placed = [] + for min_font, max_font, padding in _QUALITY_LEVELS: + word_info = [] + for (word, count), c in zip(self._words, counts): + fs = _count_to_fontsize(c, min_c, max_c, min_font, max_font) + color = _count_to_color( + c, min_c, max_c, self._color_low, self._color_high + ) + word_info.append((word, c, fs, color)) + word_info.sort(key=lambda x: x[2], reverse=True) + + placed = [] + for word, count, fs, color in word_info: + result = _place_word( + word, fs, canvas_w, canvas_h, placed, padding, self._quality + ) + if result is not None: + result["color"] = color + placed.append(result) + + if len(placed) > len(best_placed): + best_placed = placed + + if len(placed) == len(self._words): + break + + self._layout = best_placed + self._layout_size = (canvas_w, canvas_h) + + def _on_size_allocate(self, widget, allocation): + new_size = (allocation.width, allocation.height) + if new_size != self._layout_size: + if self._resize_timer is not None: + GLib.source_remove(self._resize_timer) + self._resize_timer = GLib.timeout_add(300, self._on_resize_done) + + def _on_resize_done(self): + self._resize_timer = None + self._invalidate() + return False + + def _draw_computing_message(self, cr, w, h): + cr.set_source_rgb(0.97, 0.97, 0.97) + cr.paint() + cr.set_source_rgb(0.5, 0.5, 0.5) + layout = PangoCairo.create_layout(cr) + layout.set_font_description(_make_font_desc(18, style=Pango.Style.ITALIC)) + layout.set_text("Drawing…", -1) + tw, th = layout.get_pixel_size() + cr.move_to(w / 2 - tw / 2, h / 2 - th / 2) + PangoCairo.show_layout(cr, layout) + + def _do_compute_layout(self, w, h): + self._compute_id = None + self._compute_layout(w, h) + self._computing = False + self.queue_draw() + return False + + def _on_draw(self, widget, cr): + alloc = widget.get_allocation() + w, h = alloc.width, alloc.height + + if self._resize_timer is not None: + cr.set_source_rgb(0.97, 0.97, 0.97) + cr.paint() + return + + if self._computing: + self._draw_computing_message(cr, w, h) + if self._compute_id is None: + self._compute_id = GLib.idle_add(self._do_compute_layout, w, h) + return + + if self._layout_size != (w, h): + self._compute_layout(w, h) + + cr.set_source_rgb(0.97, 0.97, 0.97) + cr.paint() + + for pw in self._layout: + hovered = pw["word"] == self._hovered + cr.save() + cr.set_source_rgb(*(self._color_hover if hovered else pw["color"])) + + layout = PangoCairo.create_layout(cr) + layout.set_font_description(_make_font_desc(pw["font_size"])) + layout.set_text(pw["word"], -1) + + p = pw["padding"] + if pw["vertical"]: + cr.translate(pw["ax"] + p + pw["th"], pw["ay"] + p) + cr.rotate(math.pi / 2) + cr.move_to(0, 0) + else: + cr.move_to(pw["ax"] + p, pw["ay"] + p) + + PangoCairo.show_layout(cr, layout) + cr.restore() + + def _on_click_event(self, widget, event): + if self._on_click is None: + return + x, y = event.x, event.y + for pw in reversed(self._layout): + if ( + pw["ax"] <= x <= pw["ax"] + pw["aw"] + and pw["ay"] <= y <= pw["ay"] + pw["ah"] + ): + self._on_click(pw["word"]) + return + + def _on_motion(self, widget, event): + x, y = event.x, event.y + hit = None + for pw in reversed(self._layout): + if ( + pw["ax"] <= x <= pw["ax"] + pw["aw"] + and pw["ay"] <= y <= pw["ay"] + pw["ah"] + ): + hit = pw["word"] + break + if hit != self._hovered: + self._hovered = hit + self.queue_draw()