# -*- coding: utf-8 -*- # # Gramps - a GTK+/GNOME based genealogy program # # Copyright (C) 2010-2012 Gary Burton # GraphvizSvgParser is based on the Gramps XML import # DotSvgGenerator is based on the relationship graph # report. # Mouse panning is derived from the pedigree view # Copyright (C) 2012 Mathieu MD # Copyright (C) 2015- Serge Noiraud # Copyright (C) 2016- Ivan Komaritsyn # # Modified in 2026 for the Gramps Graph View Profiles project. # Project website: https://myown-project.dk/ # # 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. # # $Id$ #------------------------------------------------------------------------- # # Python modules # #------------------------------------------------------------------------- import os import json import logging import tempfile from functools import wraps from re import MULTILINE, findall from xml.parsers.expat import ParserCreate import string from subprocess import Popen, PIPE from io import StringIO from threading import Thread from math import sqrt, pow from html import escape from datetime import datetime from collections import abc, deque import gi from gi.repository import Gtk, Gdk, GdkPixbuf, GLib, Pango #------------------------------------------------------------------------- # # Gramps Modules # #------------------------------------------------------------------------- from gramps.gen import datehandler from gramps.gen.config import config from gramps.gen.constfunc import win from gramps.gen.db import DbTxn from gramps.gen.display.name import displayer from gramps.gen.display.place import displayer as place_displayer from gramps.gen.errors import WindowActiveError from gramps.gen.lib import (Person, Family, ChildRef, Name, Surname, ChildRefType, Event, EventRef, EventType, EventRoleType) from gramps.gen.utils.alive import probably_alive from gramps.gen.utils.callback import Callback from gramps.gen.utils.db import (get_birth_or_fallback, get_death_or_fallback, find_children, find_parents, preset_name, find_witnessed_people) from gramps.gen.utils.file import search_for, media_path_full, find_file from gramps.gen.utils.libformatting import FormattingHelper from gramps.gen.utils.thumbnails import get_thumbnail_path from gramps.gui.dialog import (OptionDialog, ErrorDialog, QuestionDialog2, WarningDialog) from gramps.gui.display import display_url from gramps.gui.editors import EditPerson, EditFamily, EditTagList, EditEventRef from gramps.gui.utils import (color_graph_box, color_graph_family, rgb_to_hex, hex_to_rgb_float, process_pending_events) from gramps.gui.views.navigationview import NavigationView from gramps.gui.views.bookmarks import PersonBookmarks from gramps.gui.views.tags import OrganizeTagsDialog from gramps.gui.widgets import progressdialog as progressdlg from gramps.gui.widgets.menuitem import add_menuitem from gramps.gen.utils.symbols import Symbols from gramps.gui.pluginmanager import GuiPluginManager from gramps.gen.plug import CATEGORY_QR_PERSON, CATEGORY_QR_FAMILY from gramps.gui.plug.quick import run_report from gramps.gen.filters import GenericFilterFactory, rules from gramps.gen.const import GRAMPS_LOCALE as glocale try: _trans = glocale.get_addon_translator(__file__) except ValueError: _trans = glocale.translation _ = _trans.gettext if win(): DETACHED_PROCESS = 8 for goo_ver in ('3.0', '2.0'): try: gi.require_version('GooCanvas', goo_ver) from gi.repository import GooCanvas _GOO = True break except (ImportError, ValueError): _GOO = False if os.sys.platform == "win32": _DOT_FOUND = search_for("dot.exe") else: _DOT_FOUND = search_for("dot") SPLINE = {0: 'false', 1: 'true', 2: 'ortho'} FAMILY_TAG = "ProfileTag" WIKI_PAGE = 'https://gramps-project.org/wiki/index.php?title=Graph_View' def batch_profile_graph_refresh(redraw_after=True): """ Suppress intermediate GraphWidget.populate() calls during profile work. Nested profile functions share the same counter. Only the outermost successful Standard/View load performs one final graph rebuild. Temporary close-time restoration uses redraw_after=False. """ def decorator(func): @wraps(func) def wrapped(self, *args, **kwargs): outermost = ( getattr(self, '_profile_graph_refresh_depth', 0) == 0) self._profile_graph_refresh_depth = ( getattr(self, '_profile_graph_refresh_depth', 0) + 1) succeeded = False try: result = func(self, *args, **kwargs) succeeded = bool(result) return result finally: self._profile_graph_refresh_depth = max( getattr(self, '_profile_graph_refresh_depth', 1) - 1, 0) if (outermost and redraw_after and succeeded and self.graph_widget is not None): try: active_handle = self.get_active() except Exception: active_handle = '' if active_handle: self.graph_widget.populate(active_handle) return wrapped return decorator # gtk version gtk_version = float("%s.%s" % (Gtk.MAJOR_VERSION, Gtk.MINOR_VERSION)) #------------------------------------------------------------------------- # # GraphView modules # #------------------------------------------------------------------------- import sys sys.path.append(os.path.abspath(os.path.dirname(__file__))) from search_widget import SearchWidget, Popover, ListBoxRow, get_person_tooltip from avatars import Avatars #------------------------------------------------------------------------- # # GraphView # #------------------------------------------------------------------------- class GraphView(NavigationView): """ View for pedigree tree. Displays the ancestors and descendants of a selected individual. """ # default settings in the config file CONFIGSETTINGS = ( ('interface.graphview-show-images', True), ('interface.graphview-show-id', True), ('interface.graphview-show-avatars', True), ('interface.graphview-avatars-style', 1), ('interface.graphview-avatars-male', ''), # custom avatar ('interface.graphview-avatars-female', ''), # custom avatar ('interface.graphview-avatars-unknown', ''), # custom avatar ('interface.graphview-avatars-other', ''), # custom avatar ('interface.graphview-show-full-dates', False), ('interface.graphview-show-places', False), ('interface.graphview-place-format', 0), ('interface.graphview-show-lines', 1), ('interface.graphview-show-tags', False), ('interface.graphview-filter-family-tag', False), ('interface.graphview-highlight-home-person', True), ('interface.graphview-home-path-color', '#000000'), ('interface.graphview-descendant-generations', 10), ('interface.graphview-ancestor-generations', 3), ('interface.graphview-people-limit', 1000), ('interface.graphview-show-animation', True), ('interface.graphview-animation-speed', 3), ('interface.graphview-animation-count', 4), ('interface.graphview-search-all-db', True), ('interface.graphview-search-show-images', True), ('interface.graphview-search-marked-first', True), ('interface.graphview-ranksep', 5), ('interface.graphview-nodesep', 2), ('interface.graphview-person-theme', 0), ('interface.graphview-scale', 1), ('interface.graphview-person-border-size', 1), ('interface.graphview-active-person-border-size', 3), ('interface.graphview-font', ['', 14]), ('interface.graphview-direction', 0), ('interface.graphview-show-all-connected', False)) def __init__(self, pdata, dbstate, uistate, nav_group=0): NavigationView.__init__(self, _('Graph View'), pdata, dbstate, uistate, PersonBookmarks, nav_group) self.show_images = self._config.get('interface.graphview-show-images') self.show_ID = self._config.get('interface.graphview-show-id') self.show_full_dates = self._config.get( 'interface.graphview-show-full-dates') self.show_places = self._config.get('interface.graphview-show-places') self.show_tag_color = self._config.get('interface.graphview-show-tags') self.highlight_home_person = self._config.get( 'interface.graphview-highlight-home-person') self.home_path_color = self._config.get( 'interface.graphview-home-path-color') self.descendant_generations = self._config.get( 'interface.graphview-descendant-generations') self.ancestor_generations = self._config.get( 'interface.graphview-ancestor-generations') self.people_limit = self._config.get( 'interface.graphview-people-limit') self.dbstate = dbstate self.uistate = uistate self.graph_widget = None # Startup profile handling is checked once per # database/opened family tree. self.standard_profile_auto_checked = False self.standard_profile_auto_scheduled = False self.standard_profile_prompted = False # A valid startup profile may create the first graph # directly, so build_tree must not immediately redraw it. self.startup_profile_created_initial_graph = False # View profile load-only group choices include # Time direction, Limit number, Theme, Path color, Font, Active # person border size, Person border size, Line types, Generations, # Spacings and Zoom & chart position. # Remember the currently loaded/saved View profile. # This makes it possible to save small later changes back to the # same file without choosing a filename again. self.current_view_profile_filename = '' self.current_view_profile_name = '' self.current_view_profile_status_label = None self.standard_profile_status_label = None self.standard_profile_load_button = None # A saved Standard file is not shown as active content # until it has been saved or loaded in the current book session. self.standard_profile_controls_active = False self.profile_feature_enable_checkbox = None self.profile_temporary_checkbox = None self.temporary_profile_restore_snapshot_created = False self.temporary_profile_restore_profile_loaded = False # Remember the temp snapshot for the currently # opened book and whether the user wants it restored on close. # The filename is stored because db context may already be changing # when Gramps starts closing/switching books. self.temporary_profile_restore_snapshot_filename = '' self.temporary_profile_restore_enabled_for_open_book = False self.temporary_profile_restore_restored = False # Snapshot write failures must never block profile # loading. Keep the last write error for one useful warning and allow # temporary use to be forced off for the current open book even if the # startup JSON itself cannot be rewritten. self.temporary_profile_restore_snapshot_last_error = '' self.temporary_profile_restore_forced_off_for_open_book = False self.profile_function_controls = [] self.updating_profile_startup_controls = False self.profile_startup_none_radio = None self.profile_startup_standard_radio = None self.profile_startup_view_radio = None self.current_view_profile_save_button = None self.current_view_profile_delete_button = None self.standard_profile_delete_button = None # Keep a reference to the open Configure/Layout # spinner for Limit number displayed. Profile loads update the # config value immediately, but the already-open spinner needs a # small manual sync so the visible number changes without closing # and reopening Configure. self.people_limit_config_spinner = None self.profiles_page_has_unsaved_changes = False self.profiles_config_window = None self.profiles_config_delete_handler_id = None self.profiles_config_response_handler_id = None self.profiles_config_close_button_ids = set() # The direct Close-button guard detects the # user's attempt to close and opens the unsaved warning. # request_profiles_configure_close() has a different job: after # "Close anyway", it closes Configure through its normal GTK response # path, so the warning and Configure disappear in the same action. self.profiles_config_force_close_once = False # No View profile loaded means a clean View # column. The user selects what a new View profile should contain. self.view_profile_save_choices = self.get_empty_view_profile_save_choices() self.profile_view_checkbox_info = [] self.updating_view_profile_controls = False self.profile_standard_checkbox_info = [] self.updating_standard_profile_controls = False # Profile loading may change many shared config values. # GraphWidget.populate() is suppressed until the whole load is ready. self._profile_graph_refresh_depth = 0 self.dbstate.connect('database-changed', self.change_db) # dict {handle, tooltip_str} of tooltips in markup format self.tags_tooltips = {} # for disable animation options in config dialog self.ani_widgets = [] # for disable custom avatar options in config dialog self.avatar_widgets = [] # Show avatars depends on Show images only inside # Standard/View profile choices. The original Layout and Themes # controls must keep their normal GraphView behaviour. self.profile_standard_show_images_checkbox = None self.profile_standard_show_avatars_checkbox = None self.profile_view_show_images_checkbox = None self.profile_view_show_avatars_checkbox = None # Limit number displayed is always stored in profile # JSON. The profile checkbox is available only for values above 0. self.profile_standard_people_limit_checkbox = None self.profile_view_people_limit_checkbox = None # Keep references to the ordinary Layout and Themes # widgets while Configure is open. Profile loads already update the # shared config values; these references let the visible controls # follow immediately without closing and reopening Configure. self.open_graphview_configdialog = None self.open_config_widgets = {} self.home_path_color_config_label = None self.additional_uis.append(self.additional_ui) self.define_print_actions() self.uistate.connect('font-changed', self.font_changed) def set_profile_config_values_all_connected_last( self, allowed_config_keys, values): """ Apply one profile config batch with All connected as the final value. Graph rebuilds are suppressed by batch_profile_graph_refresh while this helper is used from Standard/View load or temporary restoration. """ all_connected_key = 'interface.graphview-show-all-connected' for config_key in allowed_config_keys: if config_key == all_connected_key: continue if config_key in values: self._config.set(config_key, values[config_key]) if (all_connected_key in allowed_config_keys and all_connected_key in values): self._config.set( all_connected_key, values[all_connected_key]) def on_delete(self): """ Method called on shutdown. See PageView class (../gramps/gui/views/pageview.py). """ # If profiles are used temporarily, restore the # book-opening GraphView settings before Gramps writes shared config. self.restore_temporary_profile_settings_on_close() super().on_delete() # stop search to allow close app properly self.graph_widget.search_widget.stop_search() def font_changed(self): self.graph_widget.font_changed(self.get_active()) #self.goto_handle(None) def define_print_actions(self): """ Associate the print button to the PrintView action. """ self._add_action('PrintView', self.printview, "P") self._add_action('PRIMARY-J', self.jump, 'J') def _connect_db_signals(self): """ Set up callbacks for changes to person and family nodes. """ self.callman.add_db_signal('person-update', self.goto_handle) self.callman.add_db_signal('family-update', self.goto_handle) self.callman.add_db_signal('event-update', self.goto_handle) def change_db(self, _db): """ Set up callback for changes to the database. """ # Restore only when this GraphView instance already has # a snapshot for a genuinely open previous book. On the first # database-changed signal after starting Gramps there is no previous # in-memory book snapshot. Reading an old temp file at that point # would replace the current shared Gramps config before the new # book-opening snapshot is written, effectively recycling yesterday's # temp values. A real book switch has both markers set and still # restores the book being left before changing database context. if (self.temporary_profile_restore_snapshot_created and self.temporary_profile_restore_snapshot_filename): self.restore_temporary_profile_settings_on_close() self._change_db(_db) # ProfileTag is a current-book choice unless a profile # explicitly loads it. GraphView config is shared between family # trees, so clear a value left by the previous book before creating # the new book's temporary snapshot or loading its startup profile. self.reset_family_tag_filter_at_book_open() self.standard_profile_auto_checked = False self.standard_profile_auto_scheduled = False self.standard_profile_prompted = False self.startup_profile_created_initial_graph = False self.standard_profile_controls_active = False self.set_current_view_profile('', '') self.reset_view_profile_save_choices() # Each opened family tree must get a fresh # temporary restore snapshot as early as possible. GraphView # settings are shared between books, and a startup profile can # change them before the user opens Configure. self.temporary_profile_restore_snapshot_created = False self.temporary_profile_restore_profile_loaded = False self.temporary_profile_restore_snapshot_filename = '' self.temporary_profile_restore_enabled_for_open_book = False self.temporary_profile_restore_restored = False self.temporary_profile_restore_snapshot_last_error = '' self.temporary_profile_restore_forced_off_for_open_book = False self.graph_widget.scale = self._config.get( 'interface.graphview-scale') self.create_temporary_profile_restore_snapshot_at_book_open( force=True) if self.active: if self.get_active() != "": self.graph_widget.set_available(True) # Load a valid startup profile before GraphView # creates its ordinary first graph. The profile loader already # performs the one final populate(). If no usable startup # profile exists, fall back to normal GraphView startup. if not self.auto_load_or_offer_standard_profile(): self.graph_widget.populate(self.get_active()) self.schedule_auto_load_standard_profile() else: self.graph_widget.set_available(False) else: self.dirty = True self.graph_widget.set_available(False) def get_stock(self): """ The category stock icon. """ return 'gramps-pedigree' def get_viewtype_stock(self): """ Type of view in category. """ return 'gramps-pedigree' def build_widget(self): """ Builds the widget with canvas and controls. """ self.graph_widget = GraphWidget(self, self.dbstate, self.uistate) return self.graph_widget.get_widget() def build_tree(self): """ There is no separate step to fill the widget with data. The data is populated as part of canvas widget construction. It can be called to rebuild tree. """ if self.active: if self.get_active() != "": # Retry the book-open snapshot here if # change_db ran before the database context was ready. self.create_temporary_profile_restore_snapshot_at_book_open() # Change_db may already have loaded the selected # startup profile and created the first graph. if self.startup_profile_created_initial_graph: return if not self.auto_load_or_offer_standard_profile(): self.graph_widget.populate(self.get_active()) self.schedule_auto_load_standard_profile() additional_ui = [ # Defines the UI string for UIManager '''
win.Back _Back win.Forward _Forward
win.HomePerson _Home
''', '''
win.PrintView _Print...
''', # Following are the Toolbar items ''' go-previous win.Back ''' '''Go to the previous object in the history _Back True False go-next win.Forward ''' '''Go to the next object in the history _Forward True False go-home win.HomePerson ''' '''Go to the default person _Home True False ''', ''' document-print win.PrintView "Save the dot file ''' '''for a later print.\nThis will save a .gv file and a svg file.\n''' '''You must select a .gv file" _Print... True False '''] def navigation_type(self): """ The type of forward and backward navigation to perform. """ return 'Person' def goto_handle(self, handle): """ Go to a named handle. """ if self.active: if self.get_active() != "": self.graph_widget.populate(self.get_active()) self.graph_widget.set_available(True) else: self.dirty = True self.graph_widget.set_available(False) def change_active_person(self, _menuitem=None, person_handle=''): """ Change active person. """ if person_handle: self.change_active(person_handle) def can_configure(self): """ See :class:`~gui.views.pageview.PageView :return: bool """ return True def cb_update_show_images(self, _client, _cnxn_id, entry, _data): """ Called when the configuration menu changes the images setting. """ self.show_images = entry == 'True' self.graph_widget.populate(self.get_active()) def cb_update_show_ID(self, _client, _cnxn_id, entry, _data): """ Called when the configuration menu changes the ID setting. """ self.show_ID = entry == 'True' self.graph_widget.populate(self.get_active()) def cb_update_show_avatars(self, _client, _cnxn_id, entry, _data): """ Called when the configuration menu changes the avatars setting. """ self.show_avatars = entry == 'True' self.graph_widget.populate(self.get_active()) def apply_avatar_profile_save_rules(self, profile): """ Normalize avatar values before a Standard/View profile is saved. Show avatars is always false when Show images is false. The five avatar style/custom-avatar keys are saved only when both options are true; otherwise the profile contains only the false avatar value. """ if not isinstance(profile, dict): return profile display = profile.get('display') if not isinstance(display, dict): return profile images_enabled = bool(display.get( 'interface.graphview-show-images', False)) avatars_enabled = bool( images_enabled and display.get( 'interface.graphview-show-avatars', False)) display['interface.graphview-show-avatars'] = avatars_enabled if not avatars_enabled: style = profile.get('style') if isinstance(style, dict): for config_key in ( 'interface.graphview-avatars-style', 'interface.graphview-avatars-male', 'interface.graphview-avatars-female', 'interface.graphview-avatars-unknown', 'interface.graphview-avatars-other'): style.pop(config_key, None) return profile def apply_avatar_profile_load_rules(self, loaded_values): """ Filter avatar values collected from a profile before loading them. Avatar style/custom values are accepted only when the profile itself explicitly contains Show images=true and Show avatars=true. """ if not isinstance(loaded_values, dict): return loaded_values images_key = 'interface.graphview-show-images' avatars_key = 'interface.graphview-show-avatars' images_explicitly_true = ( images_key in loaded_values and bool(loaded_values[images_key])) avatars_explicitly_true = ( avatars_key in loaded_values and bool(loaded_values[avatars_key])) if images_key in loaded_values and not bool(loaded_values[images_key]): loaded_values[avatars_key] = False avatars_explicitly_true = False if not (images_explicitly_true and avatars_explicitly_true): for config_key in ( 'interface.graphview-avatars-style', 'interface.graphview-avatars-male', 'interface.graphview-avatars-female', 'interface.graphview-avatars-unknown', 'interface.graphview-avatars-other'): loaded_values.pop(config_key, None) return loaded_values def update_profile_avatar_controls_sensitivity(self): """Keep avatar choices dependent on images in both columns.""" column_info = ( ('standard', getattr(self, 'profile_standard_show_images_checkbox', None), getattr(self, 'profile_standard_show_avatars_checkbox', None)), ('view', getattr(self, 'profile_view_show_images_checkbox', None), getattr(self, 'profile_view_show_avatars_checkbox', None)), ) for column_name, images_checkbox, avatars_checkbox in column_info: if images_checkbox is None or avatars_checkbox is None: continue images_enabled = bool(images_checkbox.get_active()) avatars_checkbox.set_sensitive( self.profile_function_is_enabled() and images_enabled) if not images_enabled and avatars_checkbox.get_active(): if column_name == 'standard': self.updating_standard_profile_controls = True try: avatars_checkbox.set_active(False) finally: self.updating_standard_profile_controls = False if not hasattr(self, 'standard_profile_save_choices'): self.standard_profile_save_choices = {} self.standard_profile_save_choices[ 'interface.graphview-show-avatars'] = False else: self.updating_view_profile_controls = True try: avatars_checkbox.set_active(False) finally: self.updating_view_profile_controls = False if not hasattr(self, 'view_profile_save_choices'): self.view_profile_save_choices = ( self.get_empty_view_profile_save_choices()) self.view_profile_save_choices[ 'interface.graphview-show-avatars'] = False return False def cb_update_avatars_style(self, _client, _cnxn_id, entry, _data): """ Called when the configuration menu changes the avatars setting. """ for widget in self.avatar_widgets: widget.set_visible(entry == '0') self.graph_widget.populate(self.get_active()) def cb_on_combo_show(self, combobox): """ Called when the configuration menu show combobox widget for avatars. Used to hide custom avatars settings. """ for widget in self.avatar_widgets: widget.set_visible(combobox.get_active() == 0) def cb_male_avatar_set(self, file_chooser_button): """ Called when the configuration menu changes the male avatar. """ self._config.set('interface.graphview-avatars-male', file_chooser_button.get_filename()) self.graph_widget.populate(self.get_active()) def cb_female_avatar_set(self, file_chooser_button): """ Called when the configuration menu changes the female avatar. """ self._config.set('interface.graphview-avatars-female', file_chooser_button.get_filename()) self.graph_widget.populate(self.get_active()) def cb_unknown_avatar_set(self, file_chooser_button): """ Called when the configuration menu changes the unknown sex avatar. """ self._config.set('interface.graphview-avatars-unknown', file_chooser_button.get_filename()) self.graph_widget.populate(self.get_active()) def cb_other_avatar_set(self, file_chooser_button): """ Called when the configuration menu changes the other sex avatar. """ self._config.set('interface.graphview-avatars-other', file_chooser_button.get_filename()) self.graph_widget.populate(self.get_active()) def cb_update_show_full_dates(self, _client, _cnxn_id, entry, _data): """ Called when the configuration menu changes the date setting. """ self.show_full_dates = entry == 'True' self.graph_widget.populate(self.get_active()) def cb_update_show_places(self, _client, _cnxn_id, entry, _data): """ Called when the configuration menu changes the place setting. """ self.show_places = entry == 'True' self.graph_widget.populate(self.get_active()) def cb_update_place_fmt(self, _client, _cnxn_id, _entry, _data): """ Called when the configuration menu changes the place setting. """ self.graph_widget.populate(self.get_active()) def cb_update_show_tag_color(self, _client, _cnxn_id, entry, _data): """ Called when the configuration menu changes the show tags setting. """ self.show_tag_color = entry == 'True' self.graph_widget.populate(self.get_active()) def cb_update_show_lines(self, _client, _cnxn_id, _entry, _data): """ Called when the configuration menu changes the line setting. """ self.graph_widget.populate(self.get_active()) def cb_update_highlight_home_person(self, _client, _cnxn_id, entry, _data): """ Called when the configuration menu changes the highlight home person setting. """ self.highlight_home_person = entry == 'True' self.graph_widget.populate(self.get_active()) def cb_update_home_path_color(self, _client, _cnxn_id, entry, _data): """ Called when the configuration menu changes the path person color. """ self.home_path_color = entry self.graph_widget.populate(self.get_active()) def cb_update_desc_generations(self, _client, _cnxd_id, entry, _data): """ Called when the configuration menu changes the descendant generation count setting. """ self.descendant_generations = entry self.graph_widget.populate(self.get_active()) def cb_update_ancestor_generations(self, _client, _cnxd_id, entry, _data): """ Called when the configuration menu changes the ancestor generation count setting. """ self.ancestor_generations = entry self.graph_widget.populate(self.get_active()) def cb_update_people_limit(self, _client, _cnxd_id, entry, _data): self.people_limit = entry # 0 means unlimited. The Standard/View profile # checkboxes cannot be selected while the live value is 0, although # the numeric 0 is still written to every profile JSON. self.update_profile_people_limit_controls_sensitivity() self.graph_widget.populate(self.get_active()) def cb_update_show_animation(self, _client, _cnxd_id, entry, _data): """ Called when the configuration menu changes the show animation setting. """ if entry == 'True': self.graph_widget.animation.show_animation = True # enable animate options for widget in self.ani_widgets: widget.set_sensitive(True) else: self.graph_widget.animation.show_animation = False # diable animate options for widget in self.ani_widgets: widget.set_sensitive(False) def cb_update_animation_count(self, _client, _cnxd_id, entry, _data): """ Called when the configuration menu changes the animation count setting. """ self.graph_widget.animation.max_count = int(entry) * 2 def cb_update_animation_speed(self, _client, _cnxd_id, entry, _data): """ Called when the configuration menu changes the animation speed setting. """ self.graph_widget.animation.speed = 50 * int(entry) def cb_update_search_all_db(self, _client, _cnxn_id, entry, _data): """ Called when the configuration menu changes the search setting. """ value = entry == 'True' self.graph_widget.search_widget.set_options(search_all_db=value) def cb_update_search_show_images(self, _client, _cnxn_id, entry, _data): """ Called when the configuration menu changes the search setting. """ value = entry == 'True' self.graph_widget.search_widget.set_options(show_images=value) self.graph_widget.show_images_option = value def cb_update_search_marked_first(self, _client, _cnxn_id, entry, _data): """ Called when the configuration menu changes the search setting. """ value = entry == 'True' self.graph_widget.search_widget.set_options(marked_first=value) def cb_update_spacing(self, _client, _cnxd_id, _entry, _data): """ Called when the ranksep or nodesep setting changed. """ self.graph_widget.populate(self.get_active()) def cb_update_person_theme(self, _client, _cnxd_id, _entry, _data): """ Called when person theme setting changed. """ self.graph_widget.populate(self.get_active()) def cb_show_all_connected(self, _client, _cnxd_id, _entry, _data): """ Called when show all connected setting changed. """ value = _entry == 'True' self.graph_widget.all_connected_btn.set_active(value) self.graph_widget.populate(self.get_active()) def cb_update_family_filter(self, _client, _cnxd_id, _entry, _data): """ Called when family tag filter setting changed. """ if getattr(self, 'updating_family_tag_filter_safety', False): return value = (_entry is True or _entry == 'True') if value and not self.family_tag_filter_value_is_allowed(True): self.turn_off_family_tag_filter_for_missing_tag(show_warning=False) if self.graph_widget and self.get_active(): self.graph_widget.populate(self.get_active()) return self.graph_widget.populate(self.get_active()) def cb_update_active_person_border_size(self, _client, _cnxd_id, entry, _data): """ Called when the active person border size changes """ self.graph_widget.populate(self.get_active()) def cb_update_person_border_size(self, _client, _cnxd_id, entry, _data): """ Called when the person border size changes """ self.graph_widget.populate(self.get_active()) def cb_update_direction(self, _client, _cnxn_id, _entry, _data): """ Called when the configuration menu changes the direction setting. """ self.graph_widget.populate(self.get_active()) def config_change_font(self, font_button): """ Called when font is change. """ font_family = font_button.get_font_family() if font_family is not None: font_name = font_family.get_name() else: font_name = '' # apply Pango.SCALE=1024 to font size font_size = int(font_button.get_font_size() / 1024) self._config.set('interface.graphview-font', [font_name, font_size]) self.graph_widget.retest_font = True self.graph_widget.populate(self.get_active()) def config_connect(self): """ Overwriten from :class:`~gui.views.pageview.PageView method This method will be called after the ini file is initialized, use it to monitor changes in the ini file. """ self._config.connect('interface.graphview-show-images', self.cb_update_show_images) self._config.connect('interface.graphview-show-id', self.cb_update_show_ID) self._config.connect('interface.graphview-show-avatars', self.cb_update_show_avatars) self._config.connect('interface.graphview-avatars-style', self.cb_update_avatars_style) self._config.connect('interface.graphview-show-full-dates', self.cb_update_show_full_dates) self._config.connect('interface.graphview-show-places', self.cb_update_show_places) self._config.connect('interface.graphview-place-format', self.cb_update_place_fmt) self._config.connect('interface.graphview-show-tags', self.cb_update_show_tag_color) self._config.connect('interface.graphview-show-lines', self.cb_update_show_lines) self._config.connect('interface.graphview-highlight-home-person', self.cb_update_highlight_home_person) self._config.connect('interface.graphview-home-path-color', self.cb_update_home_path_color) self._config.connect('interface.graphview-descendant-generations', self.cb_update_desc_generations) self._config.connect('interface.graphview-ancestor-generations', self.cb_update_ancestor_generations) self._config.connect('interface.graphview-people-limit', self.cb_update_people_limit) self._config.connect('interface.graphview-show-animation', self.cb_update_show_animation) self._config.connect('interface.graphview-animation-speed', self.cb_update_animation_speed) self._config.connect('interface.graphview-animation-count', self.cb_update_animation_count) self._config.connect('interface.graphview-search-all-db', self.cb_update_search_all_db) self._config.connect('interface.graphview-search-show-images', self.cb_update_search_show_images) self._config.connect('interface.graphview-search-marked-first', self.cb_update_search_marked_first) self._config.connect('interface.graphview-ranksep', self.cb_update_spacing) self._config.connect('interface.graphview-nodesep', self.cb_update_spacing) self._config.connect('interface.graphview-person-theme', self.cb_update_person_theme) self._config.connect('interface.graphview-show-all-connected', self.cb_show_all_connected) self._config.connect('interface.graphview-filter-family-tag', self.cb_update_family_filter) self._config.connect('interface.graphview-active-person-border-size', self.cb_update_active_person_border_size) self._config.connect('interface.graphview-person-border-size', self.cb_update_person_border_size) self._config.connect('interface.graphview-direction', self.cb_update_direction) def get_graphview_db_profile_info(self): """ Return the family-tree information useful for Graph View profiles. """ db_name = '' media_path = '' db_internal_path = '' try: db_name = self.dbstate.db.get_dbname() or '' except Exception: db_name = '' try: media_path = self.dbstate.db.get_mediapath() or '' except Exception: media_path = '' try: db_internal_path = self.dbstate.db.path or '' except Exception: db_internal_path = '' return db_name, media_path, db_internal_path def graphview_family_tag_exists(self): """ Return True when the open family tree has the required ProfileTag tag. The "Show people with the person tag "ProfileTag"" option filters on a fixed person tag name. If the tag does not exist, enabling the filter would make the graph empty or almost empty. """ try: db = self.dbstate.db for tag_handle in db.get_tag_handles(): tag = db.get_tag_from_handle(tag_handle) if tag and tag.get_name() == FAMILY_TAG: return True except Exception: pass return False def family_tag_filter_value_is_allowed(self, value, show_warning=True): """ Return True if the requested family-tag filter value is safe. """ if not value: return True if self.graphview_family_tag_exists(): return True if show_warning: WarningDialog( _('ProfileTag tag not found'), _('The ProfileTag filter requires a person tag named ' '\"ProfileTag\".'), parent=self.uistate.window) return False def turn_off_family_tag_filter_for_missing_tag(self, show_warning=True): """ Turn off "Show people with the person tag "ProfileTag"" when the ProfileTag tag is missing. Returns True when the value was changed. The small guard prevents the config callback from starting an extra redraw while we are correcting the value. """ if not self._config.get('interface.graphview-filter-family-tag'): return False if self.graphview_family_tag_exists(): return False if show_warning: WarningDialog( _('ProfileTag tag not found'), _('The ProfileTag filter requires a person tag named ' '\"ProfileTag\".'), parent=self.uistate.window) self.updating_family_tag_filter_safety = True try: self._config.set('interface.graphview-filter-family-tag', False) # ConfigDialog's already visible Layout checkbox # does not automatically follow the corrective config write. # Clear it immediately so the rejected choice is also removed # visually. Popup menu items already clear themselves and are # unaffected by this extra synchronization. widget = getattr(self, 'open_config_widgets', {}).get( 'interface.graphview-filter-family-tag') if widget is not None: try: if widget.get_active(): widget.set_active(False) except Exception: pass finally: self.updating_family_tag_filter_safety = False return True def reset_family_tag_filter_at_book_open(self): """ Clear the shared ProfileTag filter silently for every opened book. Manual Layout/popup choices therefore only affect the current open book. A Standard or View profile may activate the filter again after this reset, including through Apply at startup. """ if not self._config.get('interface.graphview-filter-family-tag'): return False self.updating_family_tag_filter_safety = True try: self._config.set('interface.graphview-filter-family-tag', False) # A Configure dialog can remain open while changing books. # Keep only its ordinary Layout checkbox aligned with the reset. widget = getattr(self, 'open_config_widgets', {}).get( 'interface.graphview-filter-family-tag') if widget is not None: try: if widget.get_active(): widget.set_active(False) except Exception: pass finally: self.updating_family_tag_filter_safety = False return True def make_safe_profile_filename(self, name): """ Make a safe Windows filename from a Gramps family-tree name. """ if not name: name = 'GraphView' unsafe_chars = '<>:"/\\|?*' safe_name = ''.join('_' if ch in unsafe_chars else ch for ch in name) safe_name = safe_name.strip().strip('.') if not safe_name: safe_name = 'GraphView' return safe_name def get_standard_profile_filename(self): """ Return filename for the user saved standard profile for this family tree. """ db_name, _media_path, _db_internal_path = self.get_graphview_db_profile_info() profiles_dir = os.path.join(os.path.dirname(__file__), 'profiles') safe_db_name = self.make_safe_profile_filename(db_name) return os.path.join(profiles_dir, '%s__standard.json' % safe_db_name) def get_original_settings_backup_filename(self): """ Return filename for the one-time backup of the user's original Graph View settings for this family tree. """ db_name, _media_path, _db_internal_path = self.get_graphview_db_profile_info() profiles_dir = os.path.join(os.path.dirname(__file__), 'profiles') safe_db_name = self.make_safe_profile_filename(db_name) return os.path.join( profiles_dir, '%s__original_settings_backup.json' % safe_db_name) def profile_function_is_enabled(self): """ Return True only when the user has explicitly enabled profiles. """ try: return bool( self.read_profile_startup_settings().get('profiles_enabled')) except Exception: return False def profile_function_action_allowed(self): """ Return True only when profile actions are allowed. This is the code-side guard behind the Profiles-page master checkbox. """ if self.profile_function_is_enabled(): return True self.sync_profile_startup_controls() return False def register_profile_function_control(self, widget): """ Remember a Profiles-page control that must be locked when the profile function is disabled. The master checkbox itself is not registered here, so it always remains usable. """ if widget is None: return widget if not hasattr(self, 'profile_function_controls'): self.profile_function_controls = [] self.profile_function_controls.append(widget) return widget def update_profile_function_controls_sensitivity(self): """ Lock or unlock all profile controls according to the master checkbox. Some controls also depend on whether a profile file actually exists. """ enabled = self.profile_function_is_enabled() has_current_view_profile = bool(self.current_view_profile_filename) standard_context_ready = self.standard_profile_database_context_ready() standard_profile_exists = ( standard_context_ready and self.standard_graphview_profile_exists()) for widget in getattr(self, 'profile_function_controls', []): try: widget.set_sensitive(enabled) except Exception: pass for radio in ( self.profile_startup_none_radio, self.profile_startup_standard_radio, self.profile_startup_view_radio): if radio is not None: radio.set_sensitive(enabled) if self.profile_startup_standard_radio is not None: self.profile_startup_standard_radio.set_sensitive( enabled and standard_profile_exists) if (standard_context_ready and not standard_profile_exists and self.profile_startup_standard_radio.get_active() and self.profile_startup_none_radio is not None): self.profile_startup_none_radio.set_active(True) if self.profile_startup_view_radio is not None: # "Current View profile" means the # View profile that is currently loaded in this Graph View. # A saved startup filename alone must not make this radio # selectable when the View column/status is clean. However, this # sensitivity update must not change startup_mode to "none": # during Gramps startup the JSON-saved View profile may not have # been auto-loaded yet. The automatic loader still needs the # saved "view" mode and filename. self.profile_startup_view_radio.set_sensitive( enabled and has_current_view_profile) if self.current_view_profile_save_button is not None: self.current_view_profile_save_button.set_sensitive( enabled and has_current_view_profile) if self.current_view_profile_delete_button is not None: self.current_view_profile_delete_button.set_sensitive( enabled and has_current_view_profile) # Load Standard profile is available only when # the open family tree actually has a saved Standard profile. # The loader itself keeps the same check as a safety net in case # the file is removed after the button/menu has been created. if self.standard_profile_load_button is not None: self.standard_profile_load_button.set_sensitive( enabled and standard_profile_exists) if self.standard_profile_delete_button is not None: self.standard_profile_delete_button.set_sensitive( enabled and standard_profile_exists) self.update_profile_people_limit_controls_sensitivity() # The master switch above enables all registered # controls. Reapply the stricter image/avatar dependency afterwards. self.update_profile_avatar_controls_sensitivity() def get_profile_startup_settings_filename(self): """ Return the small JSON file that stores Graph View profile startup settings for this family tree. Keep our profile startup choices in the profiles folder instead of adding more data to the Gramps ini/config file. """ db_name, _media_path, _db_internal_path = self.get_graphview_db_profile_info() profiles_dir = os.path.join(os.path.dirname(__file__), 'profiles') safe_db_name = self.make_safe_profile_filename(db_name) return os.path.join(profiles_dir, '%s__startup.json' % safe_db_name) def get_default_profile_startup_settings(self): """ Return default startup settings for the profile function. """ db_name, media_path, db_internal_path = self.get_graphview_db_profile_info() return { 'settings_version': 1, 'settings_type': 'graphview_profile_startup', 'profiles_enabled': False, 'use_profiles_temporarily': False, 'startup_mode': 'none', 'startup_view_profile_filename': '', 'startup_view_profile_name': '', 'db_name': db_name, 'media_path': media_path, 'db_internal_path': db_internal_path, } def read_legacy_profile_startup_settings(self): """ Read legacy profile startup values from the Gramps configuration. This is only a migration fallback; current values are stored in the profile folder's own startup JSON file. """ settings = {} try: settings['profiles_enabled'] = bool( self._config.get('interface.graphview-profiles-enabled')) except Exception: pass try: startup_mode = self._config.get( 'interface.graphview-profile-startup-mode') if startup_mode in ('none', 'standard', 'view'): settings['startup_mode'] = startup_mode except Exception: pass try: filename = self._config.get( 'interface.graphview-profile-startup-view-filename') or '' if isinstance(filename, str): settings['startup_view_profile_filename'] = filename except Exception: pass try: profile_name = self._config.get( 'interface.graphview-profile-startup-view-name') or '' if isinstance(profile_name, str): settings['startup_view_profile_name'] = profile_name except Exception: pass return settings def read_profile_startup_settings(self): """ Read profile startup settings from our own JSON file. """ settings = self.get_default_profile_startup_settings() filename = self.get_profile_startup_settings_filename() loaded_from_legacy = False try: with open(filename, 'r', encoding='utf-8') as json_file: loaded_settings = json.load(json_file) except (OSError, json.JSONDecodeError): loaded_settings = self.read_legacy_profile_startup_settings() loaded_from_legacy = bool(loaded_settings) if isinstance(loaded_settings, dict): for key in ( 'profiles_enabled', 'use_profiles_temporarily', 'startup_mode', 'startup_view_profile_filename', 'startup_view_profile_name'): if key in loaded_settings: settings[key] = loaded_settings[key] settings['profiles_enabled'] = bool(settings.get('profiles_enabled')) settings['use_profiles_temporarily'] = bool( settings.get('use_profiles_temporarily')) startup_mode = settings.get('startup_mode') if startup_mode not in ('none', 'standard', 'view'): settings['startup_mode'] = 'none' for key in ('startup_view_profile_filename', 'startup_view_profile_name'): if not isinstance(settings.get(key), str): settings[key] = '' # If a legacy version saved startup values in Gramps config, copy # them once to the profile folder's own JSON file. From then on this # code reads/writes that JSON file and leaves Gramps ini alone. if loaded_from_legacy: try: settings['updated_at'] = datetime.now().isoformat( timespec="seconds") os.makedirs(os.path.dirname(filename), exist_ok=True) with open(filename, 'w', encoding='utf-8') as json_file: json.dump(settings, json_file, ensure_ascii=False, indent=2) except OSError: pass return settings def write_profile_startup_settings(self, updates): """ Update our own profile startup JSON file. """ settings = self.read_profile_startup_settings() if isinstance(updates, dict): settings.update(updates) db_name, media_path, db_internal_path = self.get_graphview_db_profile_info() settings['settings_version'] = 1 settings['settings_type'] = 'graphview_profile_startup' settings['db_name'] = db_name settings['media_path'] = media_path settings['db_internal_path'] = db_internal_path settings['updated_at'] = datetime.now().isoformat(timespec="seconds") if settings.get('startup_mode') not in ('none', 'standard', 'view'): settings['startup_mode'] = 'none' settings['profiles_enabled'] = bool(settings.get('profiles_enabled')) settings['use_profiles_temporarily'] = bool( settings.get('use_profiles_temporarily')) filename = self.get_profile_startup_settings_filename() try: os.makedirs(os.path.dirname(filename), exist_ok=True) with open(filename, 'w', encoding='utf-8') as json_file: json.dump(settings, json_file, ensure_ascii=False, indent=2) except OSError as msg: WarningDialog( _('Could not save Graph View startup settings'), str(msg), parent=self.uistate.window) return False return True def set_profile_function_enabled(self, enabled): """ Save whether the profile function is enabled in startup JSON. """ updates = {'profiles_enabled': bool(enabled)} if not enabled: updates.update({ 'use_profiles_temporarily': False, 'startup_mode': 'none', 'startup_view_profile_filename': '', 'startup_view_profile_name': '', }) result = self.write_profile_startup_settings(updates) if result: self.update_temporary_profile_restore_enabled_for_open_book() return result def profiles_temporary_is_enabled(self): """ Return True when loaded profiles should be used temporarily. When used together with Enable profile function, the book-opening GraphView settings are restored when the book is closed. A snapshot failure may force this off for the current open book even when an old startup JSON value could not be rewritten. """ if getattr( self, 'temporary_profile_restore_forced_off_for_open_book', False): return False try: return bool( self.read_profile_startup_settings().get( 'use_profiles_temporarily')) except Exception: return False def set_profiles_temporary_enabled(self, enabled): """ Save whether profile loads should be treated as temporary. """ result = self.write_profile_startup_settings( {'use_profiles_temporarily': bool(enabled)}) if result: self.update_temporary_profile_restore_enabled_for_open_book() return result def temporary_profile_restore_snapshot_is_valid(self): """Return True for this session's readable temporary snapshot.""" if not getattr( self, 'temporary_profile_restore_snapshot_created', False): return False filename = getattr( self, 'temporary_profile_restore_snapshot_filename', '') if not filename or not os.path.isfile(filename): return False try: with open(filename, 'r', encoding='utf-8') as json_file: profile = json.load(json_file) except (OSError, json.JSONDecodeError): return False return bool( isinstance(profile, dict) and profile.get('settings_type') == 'graphview_temporary_profile_restore') def ask_create_temporary_profile_restore_snapshot_now(self): """ Let the user choose whether the current settings should become the restore point when the book-opening snapshot is unavailable. """ dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('Create temporary restore point?')) dialog.format_secondary_text( _('The Graph View settings from when the family tree was opened ' 'are not available. You can save the current Graph View ' 'settings as the restore point now, or cancel temporary use.\n\n' 'Any changes made since the family tree was opened will be ' 'included in the new restore point.')) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button( _('Save current settings'), Gtk.ResponseType.OK) response = dialog.run() dialog.destroy() return response == Gtk.ResponseType.OK def force_profiles_temporary_off_for_open_book(self, show_warning=False): """ Disable temporary use for this book without blocking profile loading. The in-memory guard remains effective even if the startup JSON cannot be rewritten because the same folder is unavailable. """ error_text = getattr( self, 'temporary_profile_restore_snapshot_last_error', '') self.temporary_profile_restore_forced_off_for_open_book = True self.temporary_profile_restore_enabled_for_open_book = False self.set_profiles_temporary_enabled(False) self.sync_profile_startup_controls() if show_warning: message = _( 'The Graph View settings needed for temporary use could not ' 'be saved. "Use profiles temporarily" has been turned off ' 'for this family tree. Profiles will still load normally, ' 'but these settings cannot be restored when the family tree ' 'is closed.') if error_text: message += '\n\n' + error_text WarningDialog( _('Temporary profile use disabled'), message, parent=self.uistate.window) def cb_profiles_temporary_toggled(self, checkbutton): """Save or safely reject the temporary-profile checkbox choice.""" if getattr(self, 'updating_profile_startup_controls', False): return if not self.profile_function_action_allowed(): return requested = bool(checkbutton.get_active()) if not requested: self.temporary_profile_restore_forced_off_for_open_book = False self.set_profiles_temporary_enabled(False) self.update_profile_function_controls_sensitivity() return if self.temporary_profile_restore_snapshot_is_valid(): self.temporary_profile_restore_forced_off_for_open_book = False self.set_profiles_temporary_enabled(True) self.update_profile_function_controls_sensitivity() return if not self.ask_create_temporary_profile_restore_snapshot_now(): # Keep the current book safely off even if writing the startup # preference is impossible for the same filesystem reason. self.temporary_profile_restore_forced_off_for_open_book = True self.temporary_profile_restore_enabled_for_open_book = False self.set_profiles_temporary_enabled(False) self.sync_profile_startup_controls() self.update_profile_function_controls_sensitivity() return # The user knowingly chooses the current settings as the restore point. # Keep the forced-off guard during the write; clear it only on success. if self.create_temporary_profile_restore_snapshot( force=True, warn_on_error=False): self.temporary_profile_restore_forced_off_for_open_book = False if not self.set_profiles_temporary_enabled(True): self.temporary_profile_restore_forced_off_for_open_book = True self.temporary_profile_restore_enabled_for_open_book = False self.sync_profile_startup_controls() else: self.force_profiles_temporary_off_for_open_book( show_warning=True) self.update_profile_function_controls_sensitivity() def get_temporary_profile_restore_filename(self): """ Return filename for the temporary restore snapshot for this family tree. """ db_name, _media_path, _db_internal_path = self.get_graphview_db_profile_info() profiles_dir = os.path.join(os.path.dirname(__file__), 'profiles') safe_db_name = self.make_safe_profile_filename(db_name) return os.path.join( profiles_dir, '%s__temporary_profile_restore.json' % safe_db_name) def build_temporary_profile_restore_data(self): """ Build a snapshot of the current shared GraphView settings. This is the state we may later restore when profiles are used temporarily. It must be captured before a Standard/View profile changes the shared GraphView config values. """ db_name, media_path, db_internal_path = self.get_graphview_db_profile_info() def get_config_value(config_key): return self._config.get(config_key) profile = { 'settings_version': 1, 'settings_type': 'graphview_temporary_profile_restore', 'restore_active': True, 'created_at': datetime.now().isoformat(timespec='seconds'), 'db_name': db_name, 'media_path': media_path, 'db_internal_path': db_internal_path, 'content': { 'interface.graphview-ancestor-generations': get_config_value( 'interface.graphview-ancestor-generations'), 'interface.graphview-descendant-generations': get_config_value( 'interface.graphview-descendant-generations'), 'interface.graphview-show-all-connected': get_config_value( 'interface.graphview-show-all-connected'), 'interface.graphview-people-limit': get_config_value( 'interface.graphview-people-limit'), }, 'layout': { 'interface.graphview-direction': get_config_value( 'interface.graphview-direction'), 'interface.graphview-show-lines': get_config_value( 'interface.graphview-show-lines'), 'interface.graphview-ranksep': get_config_value( 'interface.graphview-ranksep'), 'interface.graphview-nodesep': get_config_value( 'interface.graphview-nodesep'), }, 'display': { 'interface.graphview-show-images': get_config_value( 'interface.graphview-show-images'), 'interface.graphview-show-id': get_config_value( 'interface.graphview-show-id'), 'interface.graphview-show-avatars': get_config_value( 'interface.graphview-show-avatars'), 'interface.graphview-show-full-dates': get_config_value( 'interface.graphview-show-full-dates'), 'interface.graphview-show-places': get_config_value( 'interface.graphview-show-places'), 'interface.graphview-place-format': get_config_value( 'interface.graphview-place-format'), 'interface.graphview-show-tags': get_config_value( 'interface.graphview-show-tags'), 'interface.graphview-filter-family-tag': get_config_value( 'interface.graphview-filter-family-tag'), }, 'style': { 'interface.graphview-highlight-home-person': get_config_value( 'interface.graphview-highlight-home-person'), 'interface.graphview-home-path-color': get_config_value( 'interface.graphview-home-path-color'), 'interface.graphview-person-theme': get_config_value( 'interface.graphview-person-theme'), 'interface.graphview-font': get_config_value( 'interface.graphview-font'), 'interface.graphview-avatars-style': get_config_value( 'interface.graphview-avatars-style'), 'interface.graphview-person-border-size': get_config_value( 'interface.graphview-person-border-size'), 'interface.graphview-active-person-border-size': get_config_value( 'interface.graphview-active-person-border-size'), 'interface.graphview-avatars-male': get_config_value( 'interface.graphview-avatars-male'), 'interface.graphview-avatars-female': get_config_value( 'interface.graphview-avatars-female'), 'interface.graphview-avatars-unknown': get_config_value( 'interface.graphview-avatars-unknown'), 'interface.graphview-avatars-other': get_config_value( 'interface.graphview-avatars-other'), }, } if self.graph_widget: try: profile['view_state_optional'] = { 'interface.graphview-scale': self.graph_widget.scale, 'horizontal_adjustment_value': ( self.graph_widget.hadjustment.get_value()), 'vertical_adjustment_value': ( self.graph_widget.vadjustment.get_value()), } except Exception: pass return profile def temporary_profile_restore_context_ready(self): """ Return True when the open book has enough identity for a safe temporary restore filename. """ db_name, _media_path, db_internal_path = ( self.get_graphview_db_profile_info()) return bool(db_name and db_internal_path) def create_temporary_profile_restore_snapshot(self, force=False, warn_on_error=True): """ Save the current shared GraphView settings. The snapshot is created as early as possible for every opened book, regardless of profile settings. It is restored on close only when both profile controls request that behavior. Within one GraphView session the snapshot is written once, unless force=True is used for a new book/opening. """ if (self.temporary_profile_restore_snapshot_created and not force): return True self.temporary_profile_restore_snapshot_last_error = '' if not self.temporary_profile_restore_context_ready(): return False filename = self.get_temporary_profile_restore_filename() profile = self.build_temporary_profile_restore_data() temp_filename = '' try: directory = os.path.dirname(filename) os.makedirs(directory, exist_ok=True) # Write the complete snapshot beside the real file # first. os.replace() then switches files atomically, so a failed # write cannot leave the previous valid snapshot empty or partial. with tempfile.NamedTemporaryFile( mode='w', encoding='utf-8', dir=directory, prefix=os.path.basename(filename) + '.', suffix='.tmp', delete=False) as json_file: temp_filename = json_file.name json.dump(profile, json_file, ensure_ascii=False, indent=2) json_file.flush() os.fsync(json_file.fileno()) os.replace(temp_filename, filename) temp_filename = '' except (OSError, TypeError, ValueError) as msg: if temp_filename: try: os.remove(temp_filename) except OSError: pass self.temporary_profile_restore_snapshot_last_error = str(msg) if warn_on_error: WarningDialog( _('Could not save temporary Graph View settings'), str(msg), parent=self.uistate.window) return False self.temporary_profile_restore_snapshot_last_error = '' self.temporary_profile_restore_snapshot_created = True self.temporary_profile_restore_snapshot_filename = filename self.temporary_profile_restore_restored = False self.update_temporary_profile_restore_enabled_for_open_book() return True def create_temporary_profile_restore_snapshot_at_book_open(self, force=False): """ Try to capture the opening settings without blocking normal startup. A real write error disables temporary use with one warning when that option was enabled. An early not-ready database context is retried later and is not treated as a file error. """ created = self.create_temporary_profile_restore_snapshot( force=force, warn_on_error=False) if (not created and self.temporary_profile_restore_snapshot_last_error and self.profiles_temporary_is_enabled()): self.force_profiles_temporary_off_for_open_book( show_warning=True) return created def prepare_temporary_profile_restore_snapshot_before_load(self): """ Try once more before a profile changes settings. Failure never rejects the profile. Temporary use is disabled and the graph/profile continues normally. """ created = self.create_temporary_profile_restore_snapshot( warn_on_error=False) if not created and self.profiles_temporary_is_enabled(): self.force_profiles_temporary_off_for_open_book( show_warning=True) return created def update_temporary_profile_restore_enabled_for_open_book(self): """ Remember whether the currently open book should restore the temp GraphView snapshot when it closes. This cached value is important because Gramps may already be changing database context when close/switch callbacks run. The checkbox values are still stored in the startup JSON; this value only protects the close-time decision for the currently open book. """ try: self.temporary_profile_restore_enabled_for_open_book = bool( self.profile_function_is_enabled() and self.profiles_temporary_is_enabled()) except Exception: pass return self.temporary_profile_restore_enabled_for_open_book def restore_temporary_profile_settings_on_close(self): """ Restore the book-opening GraphView settings before the book/view closes. Restoration occurs only when both user controls are enabled: Enable profile function + Use profiles temporarily. The snapshot is otherwise left alone, so Gramps keeps its normal shared settings. """ if getattr(self, 'temporary_profile_restore_restored', False): return False if not getattr(self, 'temporary_profile_restore_enabled_for_open_book', False): # If the cached state was never initialized but context is still # valid, give the current book settings one final chance. if not self.update_temporary_profile_restore_enabled_for_open_book(): return False filename = getattr( self, 'temporary_profile_restore_snapshot_filename', '') if not filename: try: filename = self.get_temporary_profile_restore_filename() except Exception: filename = '' if not filename or not os.path.isfile(filename): return False try: with open(filename, 'r', encoding='utf-8') as json_file: profile = json.load(json_file) except (OSError, json.JSONDecodeError): return False if not isinstance(profile, dict): return False if profile.get('settings_type') != 'graphview_temporary_profile_restore': return False if not self.restore_temporary_profile_restore_data(profile): return False self.temporary_profile_restore_restored = True return True @batch_profile_graph_refresh(redraw_after=False) def restore_temporary_profile_restore_data(self, profile): """ Apply a saved temporary restore snapshot back to shared GraphView config. """ allowed_config_keys = [ 'interface.graphview-direction', 'interface.graphview-show-lines', 'interface.graphview-ranksep', 'interface.graphview-nodesep', 'interface.graphview-ancestor-generations', 'interface.graphview-descendant-generations', 'interface.graphview-show-all-connected', 'interface.graphview-people-limit', 'interface.graphview-show-images', 'interface.graphview-show-id', 'interface.graphview-show-avatars', 'interface.graphview-show-full-dates', 'interface.graphview-show-places', 'interface.graphview-place-format', 'interface.graphview-show-tags', 'interface.graphview-filter-family-tag', 'interface.graphview-highlight-home-person', 'interface.graphview-home-path-color', 'interface.graphview-person-theme', 'interface.graphview-font', 'interface.graphview-avatars-style', 'interface.graphview-person-border-size', 'interface.graphview-active-person-border-size', 'interface.graphview-avatars-male', 'interface.graphview-avatars-female', 'interface.graphview-avatars-unknown', 'interface.graphview-avatars-other', ] restored_values = {} for section_name in ('content', 'layout', 'display', 'style'): section = profile.get(section_name, {}) if isinstance(section, dict): restored_values.update(section) if (restored_values.get('interface.graphview-filter-family-tag') and not self.family_tag_filter_value_is_allowed( True, show_warning=False)): restored_values['interface.graphview-filter-family-tag'] = False try: # Generations and Spacings are controlled by # toolbar spin buttons that save through a short GLib timeout. # Cancel any still-pending spinner write before restoring the # snapshot, otherwise it can write an old profile value back # after the correct temp value has been restored. if (self.graph_widget is not None and hasattr(self.graph_widget, 'cancel_pending_spinner_update')): self.graph_widget.cancel_pending_spinner_update() self.set_profile_config_values_all_connected_last( allowed_config_keys, restored_values) # The four toolbar spinners do not follow config changes by # themselves. Keep their visible values aligned with the temp # snapshot, just as an ordinary Standard/View profile load does. if (self.graph_widget is not None and hasattr(self.graph_widget, 'sync_profile_controls')): self.graph_widget.sync_profile_controls() view_state = profile.get('view_state_optional', {}) if isinstance(view_state, dict): if 'interface.graphview-scale' in view_state: scale = view_state.get('interface.graphview-scale') self._config.set('interface.graphview-scale', scale) if self.graph_widget is not None: self.graph_widget.scale = scale if self.graph_widget is not None: if 'horizontal_adjustment_value' in view_state: self.graph_widget.hadjustment.set_value( view_state.get('horizontal_adjustment_value')) if 'vertical_adjustment_value' in view_state: self.graph_widget.vadjustment.set_value( view_state.get('vertical_adjustment_value')) except Exception: return False return True def get_profile_startup_mode(self): """ Return saved startup mode for the profile function. """ return self.read_profile_startup_settings().get('startup_mode', 'none') def set_profile_startup_mode(self, startup_mode): """ Save startup mode in our own startup JSON file. """ if startup_mode not in ('none', 'standard', 'view'): startup_mode = 'none' return self.write_profile_startup_settings( {'startup_mode': startup_mode}) def get_startup_view_profile_filename(self): """ Return the View-profile JSON filename saved for startup. """ return self.read_profile_startup_settings().get( 'startup_view_profile_filename', '') def get_startup_view_profile_name(self): """ Return the saved display name for the startup View profile. """ settings = self.read_profile_startup_settings() profile_name = settings.get('startup_view_profile_name', '') if not profile_name: filename = settings.get('startup_view_profile_filename', '') if filename: profile_name = os.path.splitext(os.path.basename(filename))[0] return profile_name def remember_startup_view_profile(self, filename, profile_name=None): """ Remember which View-profile JSON file should be used at startup. """ if not filename: return False if profile_name is None: profile_name = os.path.splitext(os.path.basename(filename))[0] return self.write_profile_startup_settings({ 'startup_view_profile_filename': filename, 'startup_view_profile_name': profile_name or '', }) def clear_startup_view_profile(self): """ Forget the saved startup View-profile JSON file. """ return self.write_profile_startup_settings({ 'startup_view_profile_filename': '', 'startup_view_profile_name': '', }) def graphview_people_limit_is_real_limit(self, value): """ Return True only when Limit number displayed is actually a limit. In Graph View, 0 means unlimited. For profile include/exclude checkboxes that should be treated as not having a limit selected. Only values greater than 0 count as a selected limit. 0, negative values, empty values and invalid values do not. """ try: return int(value) > 0 except (TypeError, ValueError): return False def get_profile_people_limit_choice(self, profile, default_value=False): """ Return whether a positive people limit is selected for profile load. The numeric value is stored in every Standard/View profile, including 0. A separate top-level profile_choices flag preserves the include/exclude checkbox for positive limits. Older profiles without this flag retain the former key-presence behavior. """ if not isinstance(profile, dict): return bool(default_value) content = profile.get('content', {}) if not isinstance(content, dict): return False key = 'interface.graphview-people-limit' if key not in content: return False value = content.get(key) if not self.graphview_people_limit_is_real_limit(value): return False choices = profile.get('profile_choices', {}) if isinstance(choices, dict) and 'people_limit' in choices: return bool(choices.get('people_limit')) # Backward compatibility: in older profiles, a positive value was # present only when the checkbox had been selected. return True def set_profile_people_limit_choice(self, profile, selected, value=None): """Always store the value and remember the positive-limit choice.""" if not isinstance(profile, dict): return profile if value is None: value = self._config.get('interface.graphview-people-limit') content = profile.get('content') if not isinstance(content, dict): content = {} profile['content'] = content content['interface.graphview-people-limit'] = value choices = profile.get('profile_choices') if not isinstance(choices, dict): choices = {} profile['profile_choices'] = choices choices['people_limit'] = bool( selected and self.graphview_people_limit_is_real_limit(value)) return profile def apply_profile_people_limit_load_rule(self, profile, loaded_values): """ Apply the people-limit load rule to an already collected config dictionary. A saved 0 always loads because it means unlimited. A positive value loads only when its profile checkbox was selected. """ if not isinstance(profile, dict) or not isinstance(loaded_values, dict): return key = 'interface.graphview-people-limit' if key not in loaded_values: return value = loaded_values.get(key) if self.graphview_people_limit_is_real_limit(value): if not self.get_profile_people_limit_choice(profile): loaded_values.pop(key, None) else: # The supported non-positive value is 0 = unlimited. Keep it so # loading this profile can remove a previously active limit. try: if int(value) != 0: loaded_values.pop(key, None) except (TypeError, ValueError): loaded_values.pop(key, None) def update_profile_people_limit_controls_sensitivity(self): """Lock both profile checkboxes while the current value is 0.""" try: selectable = self.graphview_people_limit_is_real_limit( self._config.get('interface.graphview-people-limit')) except Exception: selectable = False enabled = self.profile_function_is_enabled() standard_check = getattr( self, 'profile_standard_people_limit_checkbox', None) if standard_check is not None: if not selectable: self.updating_standard_profile_controls = True try: standard_check.set_active(False) finally: self.updating_standard_profile_controls = False if not hasattr(self, 'standard_profile_group_choices'): self.standard_profile_group_choices = {} self.standard_profile_group_choices['people_limit'] = False standard_check.set_sensitive(enabled and selectable) view_check = getattr(self, 'profile_view_people_limit_checkbox', None) if view_check is not None: if not selectable: self.updating_view_profile_controls = True try: view_check.set_active(False) finally: self.updating_view_profile_controls = False if not hasattr(self, 'view_profile_save_choices'): self.view_profile_save_choices = ( self.get_empty_view_profile_save_choices()) self.view_profile_save_choices['people_limit'] = False view_check.set_sensitive(enabled and selectable) def build_original_settings_backup_profile_data(self): """ Build a complete View-style backup of the current Graph View. The original-settings backup is an exact safety snapshot, not a Standard profile. It therefore includes every supported setting, Home person, Active person, zoom and chart position. The file is still created only once and is never overwritten. """ profile = self.build_full_current_standard_profile_data() profile['profile_type'] = 'view_profile' active_handle = self.get_active() or '' active_person = None if active_handle: try: active_person = self.dbstate.db.get_person_from_handle( active_handle) except Exception: active_person = None home_person = self.dbstate.db.get_default_person() home_handle = home_person.get_handle() if home_person else '' if active_person: profile['active_person'] = { 'handle': active_handle, 'gramps_id': active_person.get_gramps_id(), 'name': displayer.display(active_person), } if home_person: profile['home_person'] = { 'handle': home_handle, 'gramps_id': home_person.get_gramps_id(), 'name': displayer.display(home_person), } profile['backup_type'] = 'original_graphview_settings' profile['backup_note'] = ( 'Created once when the Graph View profile function was enabled.') return profile def create_original_settings_backup_if_missing(self): """ Create the original-settings backup once. Existing backup files are deliberately left untouched. """ filename = self.get_original_settings_backup_filename() if os.path.isfile(filename): return filename profile = self.build_original_settings_backup_profile_data() profile['profile_name'] = os.path.splitext(os.path.basename(filename))[0] try: os.makedirs(os.path.dirname(filename), exist_ok=True) with open(filename, 'w', encoding='utf-8') as json_file: json.dump(profile, json_file, ensure_ascii=False, indent=2) except OSError as msg: ErrorDialog( _('Could not save Graph View settings backup'), str(msg), parent=self.uistate.window) return '' return filename def copy_backup_to_first_standard_profile(self, backup_filename): """ Copy the original-settings backup to the first Standard profile. An existing Standard profile is not overwritten automatically. """ standard_filename = self.get_standard_profile_filename() if os.path.isfile(standard_filename): return True try: with open(backup_filename, 'r', encoding='utf-8') as json_file: profile = json.load(json_file) except (OSError, json.JSONDecodeError) as msg: ErrorDialog( _('Could not read Graph View settings backup'), str(msg), parent=self.uistate.window) return False profile['profile_name'] = os.path.splitext( os.path.basename(standard_filename))[0] profile['profile_type'] = 'standard_profile' profile['created_at'] = datetime.now().isoformat(timespec="seconds") profile['created_from_backup'] = os.path.basename(backup_filename) # The first Standard profile should not control # Zoom & chart position. Gramps starts by showing the home person, # and the profile function must not override that unless the user # explicitly saves such a choice later. profile.pop('view_state_optional', None) # Keep Limit number displayed in the first # Standard profile even when it is 0 (unlimited). The checkbox is # recorded separately and is false for 0. content = profile.get('content', {}) if not isinstance(content, dict): content = {} profile['content'] = content limit_value = content.get( 'interface.graphview-people-limit', self._config.get('interface.graphview-people-limit')) self.set_profile_people_limit_choice( profile, self.graphview_people_limit_is_real_limit(limit_value), limit_value) try: os.makedirs(os.path.dirname(standard_filename), exist_ok=True) with open(standard_filename, 'w', encoding='utf-8') as json_file: json.dump(profile, json_file, ensure_ascii=False, indent=2) except OSError as msg: ErrorDialog( _('Could not create Graph View standard profile'), str(msg), parent=self.uistate.window) return False return True def clean_first_standard_profile_if_created_from_backup(self): """ Clean the automatically created first Standard profile. This is deliberately limited to profiles marked with created_from_backup, so later user-saved Standard profiles are not silently changed. """ filename = self.get_standard_profile_filename() if not os.path.isfile(filename): return False try: with open(filename, 'r', encoding='utf-8') as json_file: profile = json.load(json_file) except (OSError, json.JSONDecodeError): return False if not isinstance(profile, dict): return False if not profile.get('created_from_backup'): return False changed = False if 'view_state_optional' in profile: profile.pop('view_state_optional', None) changed = True content = profile.get('content') if not isinstance(content, dict): content = {} profile['content'] = content changed = True limit_key = 'interface.graphview-people-limit' if limit_key not in content: # Migrate early Standard profiles created by development versions, which # deliberately removed 0 from the JSON. Prefer the original # backup value and fall back to the current GraphView config. limit_value = None try: backup_filename = self.get_original_settings_backup_filename() with open(backup_filename, 'r', encoding='utf-8') as json_file: backup_profile = json.load(json_file) backup_content = backup_profile.get('content', {}) if isinstance(backup_content, dict): limit_value = backup_content.get(limit_key) except (OSError, json.JSONDecodeError, AttributeError): pass if limit_value is None: limit_value = self._config.get(limit_key) content[limit_key] = limit_value changed = True limit_value = content.get(limit_key) choices = profile.get('profile_choices') if not isinstance(choices, dict): choices = {} profile['profile_choices'] = choices changed = True expected_choice = self.graphview_people_limit_is_real_limit(limit_value) if choices.get('people_limit') != expected_choice: choices['people_limit'] = expected_choice changed = True if not changed: return False try: with open(filename, 'w', encoding='utf-8') as json_file: json.dump(profile, json_file, ensure_ascii=False, indent=2) except OSError: return False return True def enable_profile_function(self): """ Turn on profiles after creating the one-time original-settings backup. Enabling the function no longer creates a Standard profile. Standard and View columns start empty until the user saves or loads a profile explicitly. """ backup_filename = self.create_original_settings_backup_if_missing() if not backup_filename: return False if not self.set_profile_function_enabled(True): return False self.standard_profile_controls_active = False self.set_current_view_profile('', '') self.clear_standard_profile_controls() self.clear_view_profile_controls() self.sync_profile_startup_controls() self.update_profile_function_controls_sensitivity() self.update_profiles_status_labels() self.clear_profiles_page_unsaved() return True def ask_restore_backup_before_disabling_profiles(self): """ Ask whether to restore the original-settings backup before disabling the profile function. """ backup_filename = self.get_original_settings_backup_filename() has_backup = os.path.isfile(backup_filename) dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('Disable profile function?')) if has_backup: dialog.format_secondary_text( _('Restore the original Graph View settings backup first?')) dialog.add_button(_('Restore backup'), 1) else: dialog.format_secondary_text( _('No original Graph View settings backup was found.')) dialog.add_button(_('Disable without restore'), 2) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) response = dialog.run() dialog.destroy() return response def restore_original_settings_backup_profile(self, profile): """Restore the internal one-time backup without making it a profile.""" if not isinstance(profile, dict): return False if profile.get('profile_version') != 1: return False # Current original-settings backups are complete View snapshots. if profile.get('profile_type') != 'view_profile': return False home_data = profile.get('home_person', {}) if isinstance(home_data, dict) and home_data: home_handle, home_person = ( self.resolve_graphview_profile_person_data(home_data)) if home_person: self.dbstate.db.set_default_person_handle(home_handle) active_data = profile.get('active_person', {}) if isinstance(active_data, dict) and active_data: active_handle, active_person = ( self.resolve_graphview_profile_person_data(active_data)) if active_person: self.change_active(active_handle) # Reuse the proven Standard apply path for all config values and the # optional zoom/chart position, but do not register this internal file # as the current View profile. settings_profile = dict(profile) settings_profile['profile_type'] = 'standard_profile' return self.apply_standard_graphview_profile(settings_profile) def disable_profile_function(self): """ Turn off profiles, optionally restoring the original-settings backup. """ response = self.ask_restore_backup_before_disabling_profiles() if response not in (1, 2): return False if response == 1: backup_filename = self.get_original_settings_backup_filename() try: with open(backup_filename, 'r', encoding='utf-8') as json_file: profile = json.load(json_file) except (OSError, json.JSONDecodeError) as msg: ErrorDialog( _('Could not restore Graph View settings backup'), str(msg), parent=self.uistate.window) return False if not self.restore_original_settings_backup_profile(profile): WarningDialog( _('Could not restore Graph View settings backup'), _('The backup could not be applied to this family tree.'), parent=self.uistate.window) return False if not self.set_profile_function_enabled(False): return False self.standard_profile_auto_checked = True self.standard_profile_auto_scheduled = False # Disabling the master function clears both visible # profile columns and forgets the current View profile for this # session. Saved Standard/View profile files are not deleted. self.standard_profile_controls_active = False self.set_current_view_profile('', '') self.clear_standard_profile_controls() self.clear_view_profile_controls() self.sync_profile_startup_controls() self.update_profile_function_controls_sensitivity() self.update_profiles_status_labels() self.clear_profiles_page_unsaved() return True def sync_profile_startup_controls(self): """ Keep Enable profile function and startup radio buttons in sync with the saved configuration values while Configure is open. """ self.updating_profile_startup_controls = True try: enabled = self.profile_function_is_enabled() mode = self.get_profile_startup_mode() # A startup choice must not point to a profile # that no longer exists. However, during Gramps startup the # family-tree identity may not be ready yet. In that early state # we keep the saved Standard-profile choice instead of changing # it to No profile by mistake. standard_context_ready = self.standard_profile_database_context_ready() if (mode == 'standard' and standard_context_ready and not self.standard_graphview_profile_exists()): mode = 'none' self.set_profile_startup_mode('none') if self.profile_feature_enable_checkbox is not None: self.profile_feature_enable_checkbox.set_active(enabled) if self.profile_temporary_checkbox is not None: self.profile_temporary_checkbox.set_active( self.profiles_temporary_is_enabled()) if mode == 'standard' and self.profile_startup_standard_radio is not None: self.profile_startup_standard_radio.set_active(True) elif mode == 'view' and self.profile_startup_view_radio is not None: # If no View profile is currently loaded, show # "No profile" in the open Configure dialog and keep the # "Current View profile" radio locked. Do not rewrite the # startup JSON here. At program startup, this method can run # before the saved View profile has been auto-loaded, and # changing startup_mode to "none" would prevent startup # loading from working. if self.current_view_profile_filename: self.profile_startup_view_radio.set_active(True) elif self.profile_startup_none_radio is not None: self.profile_startup_none_radio.set_active(True) elif self.profile_startup_none_radio is not None: self.profile_startup_none_radio.set_active(True) for radio in ( self.profile_startup_none_radio, self.profile_startup_standard_radio, self.profile_startup_view_radio): if radio is not None: radio.set_sensitive(enabled) self.update_profile_function_controls_sensitivity() finally: self.updating_profile_startup_controls = False def cb_profile_feature_enable_toggled(self, checkbutton): """ Enable/disable the profile function from the Profiles page. """ if getattr(self, 'updating_profile_startup_controls', False): return wanted_enabled = checkbutton.get_active() current_enabled = self.profile_function_is_enabled() if wanted_enabled == current_enabled: return if wanted_enabled: if not self.enable_profile_function(): self.sync_profile_startup_controls() return if not self.disable_profile_function(): self.sync_profile_startup_controls() def cb_profile_startup_mode_toggled(self, radiobutton, startup_mode): """ Save the selected startup mode. Loading is still guarded separately and only runs when the profile function is enabled. """ if getattr(self, 'updating_profile_startup_controls', False): return if not self.profile_function_action_allowed(): return if not radiobutton.get_active(): return if (startup_mode == 'standard' and self.standard_profile_database_context_ready() and not self.standard_graphview_profile_exists()): self.set_profile_startup_mode('none') self.sync_profile_startup_controls() return if startup_mode == 'view': # Do not allow choosing Current View profile unless # one is currently loaded. A remembered startup filename from an # earlier session is not enough for a new user selection. if self.current_view_profile_filename: self.remember_startup_view_profile( self.current_view_profile_filename, self.current_view_profile_name) else: self.set_profile_startup_mode('none') self.sync_profile_startup_controls() return if startup_mode not in ('none', 'standard', 'view'): startup_mode = 'none' self.set_profile_startup_mode(startup_mode) def build_standard_profile_data(self): """ Build a Standard profile for this family tree. Standard profiles contain only layout, display and style settings. """ db_name, media_path, db_internal_path = self.get_graphview_db_profile_info() def get_config_value(config_key): """Read a Graph View config value.""" return self._config.get(config_key) profile = { "profile_version": 1, "profile_name": "", "profile_type": "standard_profile", "created_at": datetime.now().isoformat(timespec="seconds"), "db_name": db_name, "media_path": media_path, "db_internal_path": db_internal_path, "content": { # Always record the actual value, including 0 # (unlimited), independently of the profile checkbox. "interface.graphview-people-limit": get_config_value( "interface.graphview-people-limit"), }, "layout": { "interface.graphview-direction": get_config_value( "interface.graphview-direction"), "interface.graphview-show-lines": get_config_value( "interface.graphview-show-lines"), "interface.graphview-ranksep": get_config_value( "interface.graphview-ranksep"), "interface.graphview-nodesep": get_config_value( "interface.graphview-nodesep"), }, "display": { "interface.graphview-show-images": get_config_value( "interface.graphview-show-images"), "interface.graphview-show-id": get_config_value( "interface.graphview-show-id"), "interface.graphview-show-avatars": get_config_value( "interface.graphview-show-avatars"), "interface.graphview-show-full-dates": get_config_value( "interface.graphview-show-full-dates"), "interface.graphview-show-places": get_config_value( "interface.graphview-show-places"), "interface.graphview-place-format": get_config_value( "interface.graphview-place-format"), "interface.graphview-show-tags": get_config_value( "interface.graphview-show-tags"), "interface.graphview-filter-family-tag": get_config_value( "interface.graphview-filter-family-tag"), }, "style": { "interface.graphview-highlight-home-person": get_config_value( "interface.graphview-highlight-home-person"), "interface.graphview-home-path-color": get_config_value( "interface.graphview-home-path-color"), "interface.graphview-person-theme": get_config_value( "interface.graphview-person-theme"), "interface.graphview-font": get_config_value( "interface.graphview-font"), "interface.graphview-avatars-style": get_config_value( "interface.graphview-avatars-style"), "interface.graphview-person-border-size": get_config_value( "interface.graphview-person-border-size"), "interface.graphview-active-person-border-size": get_config_value( "interface.graphview-active-person-border-size"), "interface.graphview-avatars-male": get_config_value( "interface.graphview-avatars-male"), "interface.graphview-avatars-female": get_config_value( "interface.graphview-avatars-female"), "interface.graphview-avatars-unknown": get_config_value( "interface.graphview-avatars-unknown"), "interface.graphview-avatars-other": get_config_value( "interface.graphview-avatars-other"), }, } return self.apply_avatar_profile_save_rules(profile) def build_full_current_standard_profile_data(self): """Build a Standard profile containing every supported setting. This is used by the beginner-friendly "Save as Standard profile" button. It reads the current Graph View directly and does not depend on the individual profile checkboxes. Saving the snapshot must not change or reload the graph. """ profile = self.build_standard_profile_data() content = profile.get('content') if not isinstance(content, dict): content = {} profile['content'] = content content['interface.graphview-ancestor-generations'] = self._config.get( 'interface.graphview-ancestor-generations') content['interface.graphview-descendant-generations'] = self._config.get( 'interface.graphview-descendant-generations') content['interface.graphview-show-all-connected'] = self._config.get( 'interface.graphview-show-all-connected') limit_value = self._config.get('interface.graphview-people-limit') self.set_profile_people_limit_choice( profile, self.graphview_people_limit_is_real_limit(limit_value), limit_value) if self.graph_widget is not None: try: profile['view_state_optional'] = { 'interface.graphview-scale': self.graph_widget.scale, 'horizontal_adjustment_value': ( self.graph_widget.hadjustment.get_value()), 'vertical_adjustment_value': ( self.graph_widget.vadjustment.get_value()), } except Exception: profile.pop('view_state_optional', None) return self.apply_avatar_profile_save_rules(profile) def get_standard_profile_group_key_map(self): """ Map Standard-profile group checkboxes to the config keys they include/exclude in the standard JSON file. When a group is not selected, its keys are removed from the JSON. The load code will then ignore those settings. """ return { 'time_direction': ( 'layout', ('interface.graphview-direction',)), 'theme': ( 'style', ('interface.graphview-person-theme',)), 'path_color': ( 'style', ('interface.graphview-home-path-color',)), 'font': ( 'style', ('interface.graphview-font',)), 'active_person_border_size': ( 'style', ('interface.graphview-active-person-border-size',)), 'person_border_size': ( 'style', ('interface.graphview-person-border-size',)), 'line_types': ( 'layout', ('interface.graphview-show-lines',)), 'generations': ( 'content', ('interface.graphview-ancestor-generations', 'interface.graphview-descendant-generations')), 'people_limit': ( 'content', ('interface.graphview-people-limit',)), 'spacings': ( 'layout', ('interface.graphview-ranksep', 'interface.graphview-nodesep')), 'view_state': ( 'view_state_optional', ('interface.graphview-scale', 'horizontal_adjustment_value', 'vertical_adjustment_value')), } def standard_profile_group_is_enabled(self, group_name, default_value=True): """ Return whether a Standard-profile group is present in the saved Standard profile. """ group_map = self.get_standard_profile_group_key_map() group_info = group_map.get(group_name) if not group_info: return default_value section_name, config_keys = group_info section = self.get_standard_profile_section_for_choices(section_name) if not section: # No Standard profile means a clean Standard column. return False if group_name == 'people_limit': profile = self.read_standard_profile_for_choices() return self.get_profile_people_limit_choice(profile, False) return all(config_key in section for config_key in config_keys) def read_standard_profile_for_choices(self): """ Read the saved Standard profile used by the Profiles-tab checkboxes. Standard checkbox states must represent the saved Standard JSON file, not the current live Graph View configuration. Therefore Configure reopen must not make the Standard column follow changes made on the other Configure pages. """ try: self.clean_first_standard_profile_if_created_from_backup() filename = self.get_standard_profile_filename() if not os.path.isfile(filename): return {} with open(filename, 'r', encoding='utf-8') as json_file: profile = json.load(json_file) if isinstance(profile, dict): return profile except Exception: pass return {} def get_standard_profile_choice_value(self, profile, config_key, default_value=False): """ Return a saved config value from the Standard-profile JSON. Direct Standard-profile boolean rows such as Show IDs, Show images, Highlight home person, Show people with the person tag "ProfileTag" and All connected are values in the saved profile. They must not be read from self._config when building or refreshing the Profiles page, because self._config is the current live graph state. """ if not isinstance(profile, dict): return default_value if config_key == 'interface.graphview-show-avatars': display = profile.get('display', {}) if isinstance(display, dict): return bool( display.get('interface.graphview-show-images', False) and display.get('interface.graphview-show-avatars', False)) return False for section_name in ('display', 'style', 'content', 'layout'): section = profile.get(section_name, {}) if isinstance(section, dict) and config_key in section: value = section[config_key] if (config_key == 'interface.graphview-filter-family-tag' and value and not self.graphview_family_tag_exists()): return False return value return default_value def apply_standard_profile_save_choices(self, profile): """ Keep the existing JSON values. The simple live Standard-profile boxes are saved as normal true/false values. The remaining Standard-profile boxes are group/include choices: when a group is selected, its current Gramps values are written to the JSON; when a group is not selected, its keys are removed so they are not restored on load. """ def get_config_value(config_key): """Read a Graph View config value.""" return self._config.get(config_key) def get_view_state_value(config_key): """Read current zoom and chart position from Graph Widget.""" if not self.graph_widget: return None if config_key == 'interface.graphview-scale': return self.graph_widget.scale if config_key == 'horizontal_adjustment_value': return self.graph_widget.hadjustment.get_value() if config_key == 'vertical_adjustment_value': return self.graph_widget.vadjustment.get_value() return None sections = { 'layout': profile.get('layout', {}), 'display': profile.get('display', {}), 'style': profile.get('style', {}), 'content': profile.get('content', {}), 'view_state_optional': profile.get('view_state_optional', {}), } if not isinstance(sections['content'], dict): sections['content'] = {} profile['content'] = sections['content'] if not isinstance(sections['view_state_optional'], dict): sections['view_state_optional'] = {} profile['view_state_optional'] = sections['view_state_optional'] # Simple live boolean values: the checkbox is the value itself. # When the Standard profile has been deleted, the # Standard column can be cleared visually without changing the graph. # If the user saves from that clean state, the saved profile should # follow the checkboxes, not the untouched live Graph View config. display = sections.get('display') if isinstance(display, dict): save_choices = getattr(self, 'standard_profile_save_choices', {}) for config_key in ( 'interface.graphview-show-images', 'interface.graphview-show-id', 'interface.graphview-show-avatars', 'interface.graphview-show-full-dates', 'interface.graphview-show-places', 'interface.graphview-show-tags', ): display[config_key] = save_choices.get( config_key, get_config_value(config_key)) # Place format belongs together with Show places, # like avatar style belongs together with Show avatars. display['interface.graphview-place-format'] = ( get_config_value('interface.graphview-place-format')) # This checkbox is a real true/false setting, not a group choice. # It does not redraw live from the Profiles page, # but Save standard profile must store the checkbox value. save_choices = getattr(self, 'standard_profile_save_choices', {}) filter_value = save_choices.get( 'interface.graphview-filter-family-tag', get_config_value('interface.graphview-filter-family-tag')) if (filter_value and not self.family_tag_filter_value_is_allowed(True)): filter_value = False display['interface.graphview-filter-family-tag'] = filter_value style = sections.get('style') if isinstance(style, dict): save_choices = getattr(self, 'standard_profile_save_choices', {}) style['interface.graphview-highlight-home-person'] = ( save_choices.get( 'interface.graphview-highlight-home-person', get_config_value( 'interface.graphview-highlight-home-person'))) # Collect avatar style values here. The dependency rule below removes them # unless both Show images and Show avatars & style are true. for config_key in ( 'interface.graphview-avatars-style', 'interface.graphview-avatars-male', 'interface.graphview-avatars-female', 'interface.graphview-avatars-unknown', 'interface.graphview-avatars-other', ): style[config_key] = get_config_value(config_key) # Standard-profile save-only boolean values: the checkbox is the # value that should be written to the standard profile. These do not # need live redraw from the Profiles page. content = sections.get('content') if isinstance(content, dict): save_choices = getattr(self, 'standard_profile_save_choices', {}) content['interface.graphview-show-all-connected'] = ( save_choices.get( 'interface.graphview-show-all-connected', get_config_value('interface.graphview-show-all-connected'))) # Group/include choices: selected means save and restore later. group_choices = getattr(self, 'standard_profile_group_choices', {}) for group_name, (section_name, config_keys) in ( self.get_standard_profile_group_key_map().items()): section = sections.get(section_name) if not isinstance(section, dict): continue group_enabled = group_choices.get(group_name, True) # Limit number displayed is different from # the other include/exclude groups. Its numeric value is always # written, including 0 = unlimited. The checkbox choice for a # positive value is stored separately. if group_name == 'people_limit': limit_value = get_config_value( 'interface.graphview-people-limit') self.set_profile_people_limit_choice( profile, group_enabled, limit_value) continue if group_enabled: for config_key in config_keys: if section_name == 'view_state_optional': section[config_key] = get_view_state_value(config_key) else: section[config_key] = get_config_value(config_key) else: for config_key in config_keys: section.pop(config_key, None) if isinstance(profile.get('content'), dict) and not profile['content']: profile.pop('content', None) if (isinstance(profile.get('view_state_optional'), dict) and not profile['view_state_optional']): profile.pop('view_state_optional', None) return self.apply_avatar_profile_save_rules(profile) def save_standard_graphview_profile(self, show_message=True, save_all_supported=False): """ Save the Standard profile for this family tree. When save_all_supported is True, capture every supported current Graph View setting instead of using the individual profile choices. """ if not self.profile_function_is_enabled(): return False filename = self.get_standard_profile_filename() if save_all_supported: profile = self.build_full_current_standard_profile_data() else: profile = self.build_standard_profile_data() profile = self.apply_standard_profile_save_choices(profile) profile["profile_name"] = os.path.splitext(os.path.basename(filename))[0] try: os.makedirs(os.path.dirname(filename), exist_ok=True) with open(filename, 'w', encoding='utf-8') as json_file: json.dump(profile, json_file, ensure_ascii=False, indent=2) except OSError as msg: if show_message: ErrorDialog( _('Could not save Graph View standard profile'), str(msg), parent=self.uistate.window) return False self.standard_profile_controls_active = True if show_message: dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, text=_('Standard profile saved')) dialog.format_secondary_text(filename) dialog.run() dialog.destroy() return True def load_standard_graphview_profile(self, _button=None): """ Load this family tree's saved Standard profile manually. A missing Standard profile is never created automatically. The ordinary UI keeps this action disabled while the file is missing; the explicit file check below is a final safety net. Loading a Standard profile also clears the current View profile status, because the active graph is no longer the loaded View profile. """ if not self.profile_function_action_allowed(): return self.clean_first_standard_profile_if_created_from_backup() filename = self.get_standard_profile_filename() # A saved Standard profile is required. This can still # be reached if the file is removed manually # while Configure is open, so keep a clear message as a safety net. if not filename or not os.path.isfile(filename): WarningDialog( _('No standard profile'), _('There is no saved Standard Graph View profile to load for this family tree.'), parent=self.uistate.window) self.update_profiles_status_labels() return try: with open(filename, 'r', encoding='utf-8') as json_file: profile = json.load(json_file) except (OSError, json.JSONDecodeError) as msg: ErrorDialog( _('Could not load Graph View standard profile'), str(msg), parent=self.uistate.window) return # Explain changed family-tree information before # applying anything, while still allowing a restored/moved copy. if not self.confirm_graphview_profile_database_context( profile, 'standard_profile'): return if not self.apply_standard_graphview_profile(profile): WarningDialog( _('Could not load Graph View standard profile'), _('The selected standard profile could not be applied.'), parent=self.uistate.window) return self.set_current_view_profile('', '') self.reset_view_profile_save_choices() self.clear_profiles_page_unsaved() self.update_profiles_status_labels() def get_empty_view_profile_save_choices(self): """ Return a clean/empty View-profile choice state. When no View profile is active, the View column must not show old default choices. Boolean rows are stored here too so the visible checkbox state and the saved JSON stay in sync. """ return { 'home_person': False, 'active_person': False, 'time_direction': False, 'people_limit': False, 'theme': False, 'path_color': False, 'font': False, 'active_person_border_size': False, 'person_border_size': False, 'line_types': False, 'generations': False, 'spacings': False, 'view_state': False, 'interface.graphview-show-id': False, 'interface.graphview-show-images': False, 'interface.graphview-show-avatars': False, 'interface.graphview-highlight-home-person': False, 'interface.graphview-show-full-dates': False, 'interface.graphview-show-places': False, 'interface.graphview-show-tags': False, 'interface.graphview-filter-family-tag': False, 'interface.graphview-show-all-connected': False, } def get_full_current_view_profile_choices(self): """Return choices for a complete snapshot of the current view. Boolean rows store their actual current true/false value, while every supported include/exclude group is selected. A people limit of 0 stays unselected because 0 is always saved and means unlimited. """ show_images = bool( self._config.get('interface.graphview-show-images')) show_avatars = bool( show_images and self._config.get('interface.graphview-show-avatars')) limit_value = self._config.get('interface.graphview-people-limit') return { 'home_person': True, 'active_person': True, 'time_direction': True, 'people_limit': self.graphview_people_limit_is_real_limit( limit_value), 'theme': True, 'path_color': True, 'font': True, 'active_person_border_size': True, 'person_border_size': True, 'line_types': True, 'generations': True, 'spacings': True, 'view_state': True, 'interface.graphview-show-id': bool( self._config.get('interface.graphview-show-id')), 'interface.graphview-show-images': show_images, 'interface.graphview-show-avatars': show_avatars, 'interface.graphview-highlight-home-person': bool( self._config.get( 'interface.graphview-highlight-home-person')), 'interface.graphview-show-full-dates': bool( self._config.get('interface.graphview-show-full-dates')), 'interface.graphview-show-places': bool( self._config.get('interface.graphview-show-places')), 'interface.graphview-show-tags': bool( self._config.get('interface.graphview-show-tags')), 'interface.graphview-filter-family-tag': bool( self._config.get( 'interface.graphview-filter-family-tag')), 'interface.graphview-show-all-connected': bool( self._config.get( 'interface.graphview-show-all-connected')), } def set_current_view_profile(self, filename, profile_name=None): """ Remember which View profile is currently active in this Graph View. """ self.current_view_profile_filename = filename or '' if profile_name is None and filename: profile_name = os.path.splitext(os.path.basename(filename))[0] self.current_view_profile_name = profile_name or '' # If Current View profile is selected for startup, # keep the JSON filename in our startup-settings file. The mode # alone is not enough after Gramps has been closed and opened again. if filename and self.get_profile_startup_mode() == 'view': self.remember_startup_view_profile(filename, profile_name) self.update_profiles_status_labels() self.update_profile_function_controls_sensitivity() def reset_view_profile_save_choices(self): """ Reset View-profile save choices to a clean/no-profile state. """ self.view_profile_save_choices = self.get_empty_view_profile_save_choices() self.sync_view_profile_controls() def cb_profile_view_choice_toggled(self, checkbutton, choice_key): """ Remember what the View profile should save and restore. These checkboxes are include/exclude choices. They do not change the current graph when toggled; they only decide whether the related data is written to the View profile JSON. """ if getattr(self, 'updating_view_profile_controls', False): return if not self.profile_function_action_allowed(): return if not hasattr(self, 'view_profile_save_choices'): self.view_profile_save_choices = {} self.view_profile_save_choices[choice_key] = checkbutton.get_active() self.mark_profiles_page_unsaved() def cb_profile_view_bool_toggled(self, checkbutton, config_key): """ Let selected boolean View-profile boxes update Graph View immediately. These checkboxes are real true/false values, like the same boxes in the Standard profile column. They stay in the JSON as true or false when the View profile is saved. """ if getattr(self, 'updating_view_profile_controls', False): return if not self.profile_function_action_allowed(): return value = checkbutton.get_active() if (config_key == 'interface.graphview-filter-family-tag' and value and not self.family_tag_filter_value_is_allowed(True)): self.updating_view_profile_controls = True try: checkbutton.set_active(False) finally: self.updating_view_profile_controls = False value = False if not hasattr(self, 'view_profile_save_choices'): self.view_profile_save_choices = self.get_empty_view_profile_save_choices() self.view_profile_save_choices[config_key] = value self._config.set(config_key, value) if config_key == 'interface.graphview-show-id': self.show_ID = value elif config_key == 'interface.graphview-show-images': self.show_images = value elif config_key == 'interface.graphview-show-avatars': self.show_avatars = value elif config_key == 'interface.graphview-highlight-home-person': self.highlight_home_person = value elif config_key == 'interface.graphview-show-full-dates': self.show_full_dates = value elif config_key == 'interface.graphview-show-places': self.show_places = value elif config_key == 'interface.graphview-show-tags': self.show_tag_color = value elif config_key == 'interface.graphview-show-all-connected': if self.graph_widget and hasattr(self.graph_widget, 'all_connected_btn'): if self.graph_widget.all_connected_btn.get_active() != value: self.graph_widget.all_connected_btn.set_active(value) self.update_profile_avatar_controls_sensitivity() self.mark_profiles_page_unsaved() if self.graph_widget and self.get_active(): self.graph_widget.populate(self.get_active()) def sync_view_profile_controls(self, profile=None): """ Update View-profile checkboxes from the current loaded profile. Include/exclude choices are shown as selected only when their data is present in a loaded JSON file. Boolean choices show the true/false value that is currently active in Graph View. """ if not hasattr(self, 'view_profile_save_choices'): self.view_profile_save_choices = self.get_empty_view_profile_save_choices() # When there is no active View profile and no profile # was passed in, keep the View column completely clean. Do not copy # current GraphView config values into the View profile column. if not isinstance(profile, dict) and not self.current_view_profile_filename: self.view_profile_save_choices = self.get_empty_view_profile_save_choices() if isinstance(profile, dict): active_data = profile.get('active_person') home_data = profile.get('home_person') display_data = profile.get('display') self.view_profile_save_choices['active_person'] = ( isinstance(active_data, dict) and bool(active_data)) self.view_profile_save_choices['home_person'] = ( isinstance(home_data, dict) and bool(home_data)) # Boolean View-profile rows are real true/false values when they # exist in JSON, but no active View profile should not inherit # current GraphView config values. if isinstance(display_data, dict): for config_key in ( 'interface.graphview-show-id', 'interface.graphview-show-images', 'interface.graphview-show-full-dates', 'interface.graphview-show-places', 'interface.graphview-show-tags'): self.view_profile_save_choices[config_key] = bool( display_data.get(config_key, False)) self.view_profile_save_choices[ 'interface.graphview-show-avatars'] = bool( display_data.get( 'interface.graphview-show-images', False) and display_data.get( 'interface.graphview-show-avatars', False)) else: for config_key in ( 'interface.graphview-show-id', 'interface.graphview-show-images', 'interface.graphview-show-avatars', 'interface.graphview-show-full-dates', 'interface.graphview-show-places', 'interface.graphview-show-tags'): self.view_profile_save_choices[config_key] = False layout_data = profile.get('layout') self.view_profile_save_choices['time_direction'] = ( isinstance(layout_data, dict) and 'interface.graphview-direction' in layout_data) self.view_profile_save_choices['line_types'] = ( isinstance(layout_data, dict) and 'interface.graphview-show-lines' in layout_data) self.view_profile_save_choices['spacings'] = ( isinstance(layout_data, dict) and 'interface.graphview-ranksep' in layout_data and 'interface.graphview-nodesep' in layout_data) content_data = profile.get('content') self.view_profile_save_choices['people_limit'] = ( self.get_profile_people_limit_choice(profile, False)) self.view_profile_save_choices['generations'] = ( isinstance(content_data, dict) and 'interface.graphview-ancestor-generations' in content_data and 'interface.graphview-descendant-generations' in content_data) if isinstance(content_data, dict): self.view_profile_save_choices[ 'interface.graphview-show-all-connected'] = bool( content_data.get( 'interface.graphview-show-all-connected', False)) filter_value = bool( content_data.get( 'interface.graphview-filter-family-tag', False)) if filter_value and not self.graphview_family_tag_exists(): filter_value = False self.view_profile_save_choices[ 'interface.graphview-filter-family-tag'] = filter_value else: self.view_profile_save_choices[ 'interface.graphview-show-all-connected'] = False self.view_profile_save_choices[ 'interface.graphview-filter-family-tag'] = False if (isinstance(content_data, dict) and 'interface.graphview-show-all-connected' in content_data): all_connected_value = bool( content_data['interface.graphview-show-all-connected']) self._config.set( 'interface.graphview-show-all-connected', all_connected_value) if (self.graph_widget and hasattr(self.graph_widget, 'all_connected_btn') and self.graph_widget.all_connected_btn.get_active() != all_connected_value): self.graph_widget.all_connected_btn.set_active( all_connected_value) style_data = profile.get('style') self.view_profile_save_choices['theme'] = ( isinstance(style_data, dict) and 'interface.graphview-person-theme' in style_data) self.view_profile_save_choices['path_color'] = ( isinstance(style_data, dict) and 'interface.graphview-home-path-color' in style_data) self.view_profile_save_choices['font'] = ( isinstance(style_data, dict) and 'interface.graphview-font' in style_data) self.view_profile_save_choices['active_person_border_size'] = ( isinstance(style_data, dict) and 'interface.graphview-active-person-border-size' in style_data) self.view_profile_save_choices['person_border_size'] = ( isinstance(style_data, dict) and 'interface.graphview-person-border-size' in style_data) view_state_data = profile.get('view_state_optional') self.view_profile_save_choices['view_state'] = ( isinstance(view_state_data, dict) and 'interface.graphview-scale' in view_state_data and 'horizontal_adjustment_value' in view_state_data and 'vertical_adjustment_value' in view_state_data) if isinstance(display_data, dict): if 'interface.graphview-show-id' in display_data: self._config.set( 'interface.graphview-show-id', bool(display_data['interface.graphview-show-id'])) self.show_ID = bool( display_data['interface.graphview-show-id']) if 'interface.graphview-show-images' in display_data: self._config.set( 'interface.graphview-show-images', bool(display_data['interface.graphview-show-images'])) self.show_images = bool( display_data['interface.graphview-show-images']) if 'interface.graphview-show-avatars' in display_data: avatar_value = bool( display_data.get( 'interface.graphview-show-images', False) and display_data['interface.graphview-show-avatars']) self._config.set( 'interface.graphview-show-avatars', avatar_value) self.show_avatars = avatar_value if 'interface.graphview-show-full-dates' in display_data: self._config.set( 'interface.graphview-show-full-dates', bool(display_data['interface.graphview-show-full-dates'])) self.show_full_dates = bool( display_data['interface.graphview-show-full-dates']) if 'interface.graphview-show-places' in display_data: self._config.set( 'interface.graphview-show-places', bool(display_data['interface.graphview-show-places'])) self.show_places = bool( display_data['interface.graphview-show-places']) if 'interface.graphview-show-tags' in display_data: self._config.set( 'interface.graphview-show-tags', bool(display_data['interface.graphview-show-tags'])) self.show_tag_color = bool( display_data['interface.graphview-show-tags']) style_data = profile.get('style') if isinstance(style_data, dict): self.view_profile_save_choices[ 'interface.graphview-highlight-home-person'] = bool( style_data.get( 'interface.graphview-highlight-home-person', False)) if 'interface.graphview-highlight-home-person' in style_data: self._config.set( 'interface.graphview-highlight-home-person', bool(style_data['interface.graphview-highlight-home-person'])) self.highlight_home_person = bool( style_data['interface.graphview-highlight-home-person']) self.updating_view_profile_controls = True try: for info in getattr(self, 'profile_view_checkbox_info', []): checkbutton = info.get('checkbutton') if checkbutton is None: continue choice_key = info.get('choice_key') view_config_key = info.get('view_config_key') default_value = info.get('default_value', True) if view_config_key: checkbutton.set_active(bool( self.view_profile_save_choices.get( view_config_key, False))) elif choice_key: checkbutton.set_active(bool( self.view_profile_save_choices.get( choice_key, default_value))) else: checkbutton.set_active(bool(default_value)) finally: self.updating_view_profile_controls = False self.update_profile_avatar_controls_sensitivity() self.update_profile_people_limit_controls_sensitivity() return False def clear_view_profile_controls(self): """ Clear the View-profile column after a View profile is deleted. This is only a profile-page reset. It must not change current Graph View settings or redraw the graph. """ self.view_profile_save_choices = self.get_empty_view_profile_save_choices() self.updating_view_profile_controls = True try: for info in getattr(self, 'profile_view_checkbox_info', []): checkbutton = info.get('checkbutton') if checkbutton is not None: checkbutton.set_active(False) finally: self.updating_view_profile_controls = False self.update_profile_function_controls_sensitivity() return False def mark_profiles_page_unsaved(self): """ Remember that the Profiles page has choices not saved to a Standard/View profile yet. """ self.profiles_page_has_unsaved_changes = True def clear_profiles_page_unsaved(self): """ Clear the Profiles-page unsaved-changes warning flag. """ self.profiles_page_has_unsaved_changes = False def request_profiles_configure_close(self): """ Close the Gramps Configure dialog through its normal response path. The close-button guard opens a modal warning during button-press/key/click handling. After the user chooses "Close anyway", the original button activation can no longer be trusted to finish normally. Schedule a normal GTK CLOSE response and stop the original button event, so one user click closes both the warning and Configure. """ window = getattr(self, 'profiles_config_window', None) if window is None: return False if getattr(self, 'profiles_config_force_close_once', False): return True self.profiles_config_force_close_once = True def do_close(): try: window.response(Gtk.ResponseType.CLOSE) except Exception: # Last-resort fallback only. The normal path above is preferred # because Gramps' ConfigureDialog closes through response. try: window.destroy() except Exception: pass return False try: GLib.idle_add(do_close) except Exception: do_close() return True def confirm_close_profiles_with_unsaved_changes(self): """ Ask before closing Configure when Profiles choices were changed but not saved to a profile. Return True when closing is allowed. """ if not getattr(self, 'profiles_page_has_unsaved_changes', False): return True parent = getattr(self, 'profiles_config_window', None) if parent is None: parent = self.uistate.window dialog = Gtk.MessageDialog( transient_for=parent, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('Profiles have unsaved changes')) dialog.format_secondary_text( _('Some profile choices may not have been saved to a profile.\n' 'Go back and save them?')) dialog.add_button(_('Go back'), Gtk.ResponseType.YES) dialog.add_button(_('Close anyway'), Gtk.ResponseType.NO) response = dialog.run() dialog.destroy() if response == Gtk.ResponseType.YES: return False self.clear_profiles_page_unsaved() return True def cb_profiles_configure_delete_event(self, _window, _event): """ Stop Configure from closing if Profiles choices may be unsaved. """ return not self.confirm_close_profiles_with_unsaved_changes() def cb_profiles_configure_response(self, window, _response_id): """ Extra guard for Configure dialogs that close through a response signal instead of the window delete-event. """ if getattr(self, 'profiles_config_force_close_once', False): self.profiles_config_force_close_once = False return False if self.confirm_close_profiles_with_unsaved_changes(): return False try: window.stop_emission_by_name('response') except Exception: pass return True def cb_profiles_configure_close_button_press(self, button, _event): """ Guard the Configure Close button before its normal click handler runs. Some Gramps Configure windows close from the button activation path instead of the window delete-event. Catching button-press-event gives us one chance before the normal clicked/response handling starts. """ if self.confirm_close_profiles_with_unsaved_changes(): self.request_profiles_configure_close() try: button.stop_emission_by_name('button-press-event') except Exception: pass return True def cb_profiles_configure_close_button_key_press(self, button, event): """ Guard keyboard activation of the Configure Close button. """ try: keyval = event.keyval except Exception: keyval = None if keyval not in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_space): return False if self.confirm_close_profiles_with_unsaved_changes(): self.request_profiles_configure_close() try: button.stop_emission_by_name('key-press-event') except Exception: pass return True def cb_profiles_configure_close_button_clicked(self, button): """ Fallback guard if the button was activated without a button/key press. """ if self.confirm_close_profiles_with_unsaved_changes(): self.request_profiles_configure_close() try: button.stop_emission_by_name('clicked') except Exception: pass return True def install_profiles_close_button_guard(self, window): """ Find the actual Configure Close button and guard it directly. The button is part of Gramps' general Configure dialog, not GraphView itself, so it is found after the Profiles page has been built. """ if window is None: return False close_labels = ('close', 'luk') def normalized_button_label(button): label = '' try: label = button.get_label() or '' except Exception: label = '' return label.replace('_', '').strip().lower() def walk(widget): try: if isinstance(widget, Gtk.Button): label = normalized_button_label(widget) if label in close_labels and id(widget) not in ( self.profiles_config_close_button_ids): self.profiles_config_close_button_ids.add(id(widget)) widget.connect( 'button-press-event', self.cb_profiles_configure_close_button_press) widget.connect( 'key-press-event', self.cb_profiles_configure_close_button_key_press) widget.connect( 'clicked', self.cb_profiles_configure_close_button_clicked) except Exception: pass try: if isinstance(widget, Gtk.Container): for child in widget.get_children(): walk(child) except Exception: pass try: walk(window) except Exception: pass return False def update_profiles_status_labels(self): """ Refresh status labels on the Profiles page if the page is open. """ if self.current_view_profile_status_label is not None: current_text = _('Current View profile: none') if self.current_view_profile_name: current_text = (_('Current View profile: %s') % self.current_view_profile_name) self.current_view_profile_status_label.set_text(current_text) standard_context_ready = self.standard_profile_database_context_ready() standard_profile_exists = ( standard_context_ready and self.standard_graphview_profile_exists()) if self.standard_profile_status_label is not None: standard_text = _('Standard profile: not saved') if standard_profile_exists: standard_text = _('Standard profile: saved') self.standard_profile_status_label.set_text(standard_text) if self.standard_profile_load_button is not None: # Default is not an alternative profile type. # Keep one stable label and disable the button when no saved # Standard profile exists. self.standard_profile_load_button.set_label( _('Load standard profile')) has_current_view_profile = bool(self.current_view_profile_filename) # Startup choice UI only. The current View-profile # startup option can only be selected when a View profile is active. self.sync_profile_startup_controls() self.update_profile_function_controls_sensitivity() def confirm_overwrite_standard_profile(self): """Ask before replacing the saved Standard profile.""" dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('Overwrite Standard profile?')) dialog.format_secondary_text( _('The saved Standard profile will be replaced by the current ' 'Graph View settings.')) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button(_('_Save'), Gtk.ResponseType.OK) response = dialog.run() dialog.destroy() return response == Gtk.ResponseType.OK def standard_profile_active_matches_home_for_save(self): """ Return True when a Standard profile can represent the visible graph. A Standard profile never stores Home person or Active person. Both Standard-profile save buttons therefore use this same guard. """ active_handle = self.get_active() or '' home_person = self.dbstate.db.get_default_person() home_handle = home_person.get_handle() if home_person else '' if active_handle == home_handle: return True WarningDialog( _('Cannot save Standard profile'), _('The active person is different from the home person.\n' 'A Standard profile does not save the home or active person. ' 'Please use a View profile.'), parent=self.uistate.window) return False def standard_profile_has_selected_choices(self): """Return True when at least one Standard-profile box is selected.""" checkbuttons = getattr(self, 'profile_standard_checkbuttons', []) if checkbuttons: for checkbutton in checkbuttons: try: if checkbutton.get_active(): return True except Exception: pass return False # Fallback for callers without an open Profiles page. save_choices = getattr(self, 'standard_profile_save_choices', {}) group_choices = getattr(self, 'standard_profile_group_choices', {}) return bool( any(bool(value) for value in save_choices.values()) or any(bool(value) for value in group_choices.values())) def confirm_save_empty_standard_profile(self): """Ask before saving a Standard profile with no selected boxes.""" dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('No checkboxes are selected for this Standard profile.')) dialog.format_secondary_text(_('Save anyway?')) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button(_('Save anyway'), Gtk.ResponseType.OK) response = dialog.run() dialog.destroy() return response == Gtk.ResponseType.OK def save_selected_standard_profile(self, _button=None): """Save the Standard column after the shared safety checks.""" if not self.profile_function_action_allowed(): return # The lower Save standard profile button follows the # same Active/Home rule as Save as Standard profile above. if not self.standard_profile_active_matches_home_for_save(): return # Match the existing neutral warning for an empty View profile. if not self.standard_profile_has_selected_choices(): if not self.confirm_save_empty_standard_profile(): return self.save_graphview_profile(_button, 'standard_profile') def save_current_view_as_standard_profile(self, _button=None): """Save every supported current setting as Standard profile.""" if not self.profile_function_action_allowed(): return # Both Standard-profile save paths use the same # guard before any overwrite confirmation or file write. if not self.standard_profile_active_matches_home_for_save(): return filename = self.get_standard_profile_filename() if os.path.isfile(filename): if not self.confirm_overwrite_standard_profile(): return if self.save_standard_graphview_profile( show_message=True, save_all_supported=True): # Show exactly what the new file contains, without loading it or # changing the graph. self.sync_standard_profile_controls() self.clear_profiles_page_unsaved() self.update_profiles_status_labels() def save_current_view_as_new_view_profile(self, _button=None): """Save every supported current setting as a new View profile.""" if not self.profile_function_action_allowed(): return self.save_graphview_profile( None, 'view_profile', save_all_supported=True) def confirm_overwrite_current_view_profile(self): """ Ask before overwriting the currently active View profile. """ dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('Overwrite current View profile?')) profile_name = self.current_view_profile_name if not profile_name and self.current_view_profile_filename: profile_name = os.path.splitext( os.path.basename(self.current_view_profile_filename))[0] dialog.format_secondary_text(profile_name or _('Current View profile')) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button(_('_Save'), Gtk.ResponseType.OK) response = dialog.run() dialog.destroy() return response == Gtk.ResponseType.OK def view_profile_has_selected_choices(self): """ Return True when at least one View-profile checkbox is selected. A visually empty View profile is allowed, but it is easy to save one by mistake. The save code uses this to show a neutral warning before saving anyway. """ view_choices = getattr(self, 'view_profile_save_choices', {}) if not isinstance(view_choices, dict): return False for value in view_choices.values(): if bool(value): return True return False def confirm_save_empty_view_profile(self): """ Ask before saving a View profile where no checkboxes are selected. """ dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('No checkboxes are selected for this View profile.')) dialog.format_secondary_text(_('Save anyway?')) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button(_('Save anyway'), Gtk.ResponseType.OK) response = dialog.run() dialog.destroy() return response == Gtk.ResponseType.OK def save_current_view_profile(self, _menuitem=None): """ Save the current graph view back to the already loaded/saved View profile file. """ if not self.profile_function_action_allowed(): return if not self.current_view_profile_filename: return self.save_graphview_profile( None, 'view_profile', self.current_view_profile_filename, True) def delete_current_view_profile(self, _button=None): """ Delete the currently loaded/saved View profile JSON file. """ if not self.profile_function_action_allowed(): return filename = self.current_view_profile_filename if not filename: return profile_name = self.current_view_profile_name if not profile_name: profile_name = os.path.splitext(os.path.basename(filename))[0] if not os.path.isfile(filename): if self.get_profile_startup_mode() == 'view': self.set_profile_startup_mode('none') self.clear_startup_view_profile() self.set_current_view_profile('', '') self.clear_view_profile_controls() self.sync_profile_startup_controls() WarningDialog( _('View profile file not found'), filename, parent=self.uistate.window) return dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('Delete current View profile?')) dialog.format_secondary_text(profile_name or filename) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button(_('_Delete'), Gtk.ResponseType.OK) response = dialog.run() dialog.destroy() if response != Gtk.ResponseType.OK: return try: os.remove(filename) except OSError as err: WarningDialog( _('Could not delete View profile'), str(err), parent=self.uistate.window) return # Deleting a View profile should not load or apply # anything else. If it was selected for startup, fall back to No # profile and clear the View-profile column without changing the graph. if self.get_profile_startup_mode() == 'view': self.set_profile_startup_mode('none') self.set_current_view_profile('', '') self.clear_view_profile_controls() self.sync_profile_startup_controls() dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, text=_('View profile deleted')) dialog.format_secondary_text(filename) dialog.run() dialog.destroy() def normalize_graphview_profile_path(self, path): """ Normalize a path before comparing database identity values. """ if not isinstance(path, str) or not path: return '' try: return os.path.normcase(os.path.abspath(os.path.expanduser(path))) except Exception: return path def get_graphview_profile_database_differences(self, profile): """ Return saved/current family-tree values that differ. A changed name or path does not prove that this is another family tree. A Gramps backup restored into a new book can legitimately change all three values. """ if not isinstance(profile, dict): return [] current_db_name, current_media_path, current_db_path = ( self.get_graphview_db_profile_info()) fields = ( (_('Family tree name'), 'db_name', profile.get('db_name', ''), current_db_name, False), (_('Database location'), 'db_internal_path', profile.get('db_internal_path', ''), current_db_path, True), (_('Media path'), 'media_path', profile.get('media_path', ''), current_media_path, True), ) differences = [] for label, key, saved_value, current_value, is_path in fields: # Missing metadata is not treated as proof of another tree. # Current profiles always save all three fields. if key not in profile: continue saved_value = saved_value if isinstance(saved_value, str) else '' current_value = ( current_value if isinstance(current_value, str) else '') if is_path: saved_compare = self.normalize_graphview_profile_path( saved_value) current_compare = self.normalize_graphview_profile_path( current_value) else: saved_compare = saved_value current_compare = current_value if saved_compare != current_compare: differences.append({ 'label': label, 'saved': saved_value, 'current': current_value, }) return differences def format_graphview_profile_database_differences(self, differences): """Return readable saved/current family-tree differences.""" lines = [] empty_text = _('(not set)') for difference in differences: label = difference.get('label', '') saved_value = difference.get('saved', '') or empty_text current_value = difference.get('current', '') or empty_text lines.extend([ '%s:' % label, '%s: %s' % (_('Profile'), saved_value), '%s: %s' % (_('Current'), current_value), '', ]) return '\n'.join(lines).rstrip() def confirm_graphview_profile_database_context(self, profile, profile_type): """ Check family-tree metadata before any profile values are applied. Standard profiles: changed tree information gives one Cancel/Load anyway dialog. View profiles: changed information plus matching saved people is treated as a likely restored/moved copy; changed information plus an unresolved saved person blocks loading as a likely profile from another family tree. """ differences = self.get_graphview_profile_database_differences(profile) if not differences: return True difference_text = ( self.format_graphview_profile_database_differences(differences)) if profile_type == 'view_profile': missing_people = [] verified_people = [] for role_name, key in ( (_('Home person'), 'home_person'), (_('Active person'), 'active_person')): person_data = profile.get(key, {}) if not isinstance(person_data, dict) or not person_data: continue _handle, person = self.resolve_graphview_profile_person_data( person_data) description = self.get_saved_profile_person_description( role_name, person_data) if person: verified_people.append(description) else: missing_people.append(description) if missing_people: detail = _( 'This View profile appears to belong to another family ' 'tree.') detail += '\n\n' + _( 'The family tree information is different:') detail += '\n\n' + difference_text detail += '\n\n' + _( 'These saved people could not be found in the current ' 'family tree:') detail += '\n\n' + '\n'.join(missing_people) detail += '\n\n' + _('The profile was not loaded.') WarningDialog( _('View profile belongs to another family tree'), detail, parent=self.uistate.window) return False dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('Family tree information is different')) detail = _( 'This View profile was saved with different family tree ' 'information:') detail += '\n\n' + difference_text if verified_people: detail += '\n\n' + _( 'The saved people were found in the current family ' 'tree:') detail += '\n\n' + '\n'.join(verified_people) else: detail += '\n\n' + _( 'This profile does not contain saved Home or Active ' 'persons that can be checked.') detail += '\n\n' + _( 'This may be a restored or moved copy of the same family ' 'tree. If this is the correct tree, load the profile and ' 'save it again for the current family tree.') dialog.format_secondary_text(detail) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button(_('Load anyway'), Gtk.ResponseType.OK) response = dialog.run() dialog.destroy() return response == Gtk.ResponseType.OK dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('Family tree information is different')) detail = _( 'This Standard profile was saved with different family tree ' 'information:') detail += '\n\n' + difference_text detail += '\n\n' + _( 'This may be a restored or moved copy of the same family tree. ' 'If this is the correct tree, load the profile and save it again ' 'for the current family tree.') dialog.format_secondary_text(detail) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button(_('Load anyway'), Gtk.ResponseType.OK) response = dialog.run() dialog.destroy() return response == Gtk.ResponseType.OK def resolve_graphview_profile_person_data(self, person_data): """ Resolve a saved View-profile person safely in the open database. A restored database can change internal handles while preserving Gramps IDs. Never accept a handle that now points to a person with a different saved Gramps ID; fall back to an ID lookup instead. """ if not isinstance(person_data, dict) or not person_data: return '', None person = None handle = person_data.get('handle', '') or '' gramps_id = person_data.get('gramps_id', '') or '' if handle: try: person = self.dbstate.db.get_person_from_handle(handle) except Exception: person = None if person and gramps_id: try: if person.get_gramps_id() != gramps_id: person = None except Exception: person = None if not person and gramps_id and hasattr( self.dbstate.db, 'get_person_from_gramps_id'): try: person = self.dbstate.db.get_person_from_gramps_id(gramps_id) except Exception: person = None if person: try: handle = person.get_handle() except Exception: handle = '' return handle, person def get_saved_profile_person_description(self, role_name, person_data): """ Return a readable Home/Active person line for missing-person dialogs. """ name = '' gramps_id = '' if isinstance(person_data, dict): name = person_data.get('name', '') or '' gramps_id = person_data.get('gramps_id', '') or '' handle = person_data.get('handle', '') or '' else: handle = '' description = name.strip() if isinstance(name, str) else '' if gramps_id: if description: description = '%s (%s)' % (description, gramps_id) else: description = gramps_id if not description and handle: description = handle if not description: description = _('(unknown name)') return '%s: %s' % (role_name, description) def ask_load_view_profile_with_missing_persons(self, missing_people): """ Ask whether a View profile should load when saved Home/Active person choices cannot be found in the open database. """ if not missing_people: return True dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('Home or Active person not found')) detail = _( 'These saved people were not found in this family tree:') detail += '\n\n' + '\n'.join(missing_people) detail += '\n\n' + _( 'Load anyway without them?') dialog.format_secondary_text(detail) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button(_('Load'), Gtk.ResponseType.OK) response = dialog.run() dialog.destroy() return response == Gtk.ResponseType.OK def apply_profile_media_path(self, profile, show_warning=False): """ Restore the family-tree media path saved in a Graph View profile. This only changes the Gramps database media path if Gramps exposes a safe set_mediapath() method and the saved folder still exists. """ if not isinstance(profile, dict): return False media_path = profile.get('media_path', '') if not isinstance(media_path, str) or not media_path: return True media_path = os.path.abspath(os.path.expanduser(media_path)) if not os.path.isdir(media_path): if show_warning: WarningDialog( _('Graph View profile media path not found'), _('The saved media path does not exist:\n%s') % media_path, parent=self.uistate.window) return False try: current_media_path = self.dbstate.db.get_mediapath() or '' except Exception: current_media_path = '' try: current_media_path = os.path.abspath( os.path.expanduser(current_media_path)) except Exception: current_media_path = current_media_path or '' if current_media_path == media_path: return True set_mediapath = getattr(self.dbstate.db, 'set_mediapath', None) if not callable(set_mediapath): if show_warning: WarningDialog( _('Graph View profile media path not restored'), _('This Gramps database does not support set_mediapath().'), parent=self.uistate.window) return False try: set_mediapath(media_path) except Exception as msg: if show_warning: WarningDialog( _('Graph View profile media path not restored'), str(msg), parent=self.uistate.window) return False return True def find_people_limit_config_spinner(self, container=None): """ Return the Limit number displayed spinner from the open Configure dialog, if it exists. Gramps' ConfigureDialog helper owns the normal config binding. When a profile is loaded while Configure is already open, the config value changes correctly, but the visible spin button can keep its old value until the dialog is rebuilt. This helper lets profile loads refresh that one visible widget. """ widget = getattr(self, 'people_limit_config_spinner', None) if isinstance(widget, Gtk.SpinButton): try: if widget.get_parent() is not None: return widget except Exception: pass if container is None: return None try: children = container.get_children() except Exception: return None for child in children: if isinstance(child, Gtk.SpinButton): self.people_limit_config_spinner = child return child found = self.find_people_limit_config_spinner(child) if found is not None: return found return None def remember_people_limit_config_spinner(self, widget_or_container=None): """ Remember the Limit number displayed spin button, if ConfigureDialog exposes it or if it can be found in the Layout grid. """ widget = widget_or_container if isinstance(widget, (list, tuple)): for item in widget: if isinstance(item, Gtk.SpinButton): self.people_limit_config_spinner = item return item found = self.find_people_limit_config_spinner(item) if found is not None: return found return None if isinstance(widget, Gtk.SpinButton): self.people_limit_config_spinner = widget return widget found = self.find_people_limit_config_spinner(widget) if found is not None: return found return None def sync_people_limit_config_spinner(self): """ Refresh the visible Limit number displayed value while Configure is open. Safe to call when Configure is closed. """ spinner = self.find_people_limit_config_spinner() if spinner is None: return try: value = int(self._config.get('interface.graphview-people-limit')) except Exception: value = 0 try: if int(spinner.get_value()) != value: spinner.set_value(value) except Exception: pass def set_open_config_widget_without_callback(self, widget, callback, setter): """ Change a visible Configure widget without writing the same value back through ConfigureDialog and starting another Graph View redraw. """ if widget is None: return try: if widget.get_parent() is None: return except Exception: return callback_blocked = False if callback is not None: try: widget.handler_block_by_func(callback) callback_blocked = True except Exception: callback_blocked = False try: setter() except Exception: pass finally: if callback_blocked: try: widget.handler_unblock_by_func(callback) except Exception: pass def sync_open_layout_and_theme_widgets(self): """ Refresh all profile-controlled fields shown on the ordinary Layout and Themes pages while Configure remains open. This is display synchronization only. The profile load has already written the new values to Graph View config. """ widgets = getattr(self, 'open_config_widgets', {}) if not isinstance(widgets, dict) or not widgets: return configdialog = getattr(self, 'open_graphview_configdialog', None) checkbox_callback = getattr(configdialog, 'update_checkbox', None) combo_callback = getattr(configdialog, 'update_combo', None) color_callback = getattr(configdialog, 'update_color', None) spinner_callback = getattr(configdialog, 'update_spinner', None) checkbox_keys = ( 'interface.graphview-show-images', 'interface.graphview-show-id', 'interface.graphview-show-avatars', 'interface.graphview-highlight-home-person', 'interface.graphview-show-full-dates', 'interface.graphview-show-places', 'interface.graphview-show-tags', 'interface.graphview-filter-family-tag', ) for config_key in checkbox_keys: widget = widgets.get(config_key) if not isinstance(widget, Gtk.CheckButton): continue try: value = bool(self._config.get(config_key)) if widget.get_active() != value: self.set_open_config_widget_without_callback( widget, checkbox_callback, lambda widget=widget, value=value: widget.set_active(value)) except Exception: pass combo_keys = ( 'interface.graphview-place-format', 'interface.graphview-direction', 'interface.graphview-person-theme', 'interface.graphview-avatars-style', ) for config_key in combo_keys: widget = widgets.get(config_key) if not isinstance(widget, Gtk.ComboBox): continue try: value = int(self._config.get(config_key)) if widget.get_active() != value: self.set_open_config_widget_without_callback( widget, combo_callback, lambda widget=widget, value=value: widget.set_active(value)) except Exception: pass spinner_keys = ( 'interface.graphview-people-limit', 'interface.graphview-active-person-border-size', 'interface.graphview-person-border-size', ) for config_key in spinner_keys: widget = widgets.get(config_key) if not isinstance(widget, Gtk.SpinButton): continue try: value = int(self._config.get(config_key)) if int(widget.get_value()) != value: self.set_open_config_widget_without_callback( widget, spinner_callback, lambda widget=widget, value=value: widget.set_value(value)) except Exception: pass color_key = 'interface.graphview-home-path-color' color_widget = widgets.get(color_key) if isinstance(color_widget, Gtk.ColorButton): try: color_value = str(self._config.get(color_key)) rgba = Gdk.RGBA() if rgba.parse(color_value): self.set_open_config_widget_without_callback( color_widget, color_callback, lambda: color_widget.set_rgba(rgba)) color_label = getattr( self, 'home_path_color_config_label', None) if color_label is not None: color_label.set_text(color_value) except Exception: pass font_key = 'interface.graphview-font' font_widget = widgets.get(font_key) if isinstance(font_widget, Gtk.FontButton): try: font = self._config.get(font_key) font_string = '%s, %d' % (font[0], int(font[1])) def set_font_button_value(): if hasattr(font_widget, 'set_font'): font_widget.set_font(font_string) else: font_widget.set_font_name(font_string) self.set_open_config_widget_without_callback( font_widget, self.config_change_font, set_font_button_value) except Exception: pass avatar_file_callbacks = { 'interface.graphview-avatars-male': self.cb_male_avatar_set, 'interface.graphview-avatars-female': self.cb_female_avatar_set, 'interface.graphview-avatars-unknown': self.cb_unknown_avatar_set, 'interface.graphview-avatars-other': self.cb_other_avatar_set, } for config_key, callback in avatar_file_callbacks.items(): widget = widgets.get(config_key) if not isinstance(widget, Gtk.FileChooserButton): continue try: filename = self._config.get(config_key) or '' current_filename = widget.get_filename() or '' if current_filename == filename: continue def set_avatar_filename(widget=widget, filename=filename): if filename: widget.set_filename(filename) else: widget.unselect_all() self.set_open_config_widget_without_callback( widget, callback, set_avatar_filename) except Exception: pass # Keep custom-avatar rows in step with the loaded avatar style. try: custom_avatars_visible = ( int(self._config.get('interface.graphview-avatars-style')) == 0) for widget in self.avatar_widgets: widget.set_visible(custom_avatars_visible) except Exception: pass def sync_open_config_widgets_after_profile_load(self): """ Refresh visible Configure widgets that do not update automatically when profile loading changes config values behind an already-open Configure dialog. """ self.sync_open_layout_and_theme_widgets() self.sync_people_limit_config_spinner() @batch_profile_graph_refresh(redraw_after=True) def apply_standard_graphview_profile(self, profile): """ Apply layout/display/style from a standard profile. """ if not isinstance(profile, dict): return False if profile.get('profile_version') != 1: return False profile_type = profile.get('profile_type', '') if profile_type != 'standard_profile': return False # Database name/path values are informative, not # a hard lock. A Gramps backup restored into a new family tree can # have the same people and settings but a new db_name/db_internal_path. # media_path is also treated as profile information, not as a setting # that should silently change the open database media path. allowed_config_keys = [ 'interface.graphview-direction', 'interface.graphview-show-lines', 'interface.graphview-ranksep', 'interface.graphview-nodesep', 'interface.graphview-ancestor-generations', 'interface.graphview-descendant-generations', 'interface.graphview-show-all-connected', 'interface.graphview-people-limit', 'interface.graphview-show-images', 'interface.graphview-show-id', 'interface.graphview-show-avatars', 'interface.graphview-show-full-dates', 'interface.graphview-show-places', 'interface.graphview-place-format', 'interface.graphview-show-tags', 'interface.graphview-filter-family-tag', 'interface.graphview-highlight-home-person', 'interface.graphview-home-path-color', 'interface.graphview-person-theme', 'interface.graphview-font', 'interface.graphview-avatars-style', 'interface.graphview-person-border-size', 'interface.graphview-active-person-border-size', 'interface.graphview-avatars-male', 'interface.graphview-avatars-female', 'interface.graphview-avatars-unknown', 'interface.graphview-avatars-other', ] loaded_values = {} for section_name in ('content', 'layout', 'display', 'style'): section = profile.get(section_name, {}) if isinstance(section, dict): loaded_values.update(section) self.apply_avatar_profile_load_rules(loaded_values) self.apply_profile_people_limit_load_rule(profile, loaded_values) # Failure to save the temporary restore point may # disable temporary use, but it must never block this profile or the # final graph rebuild. self.prepare_temporary_profile_restore_snapshot_before_load() # A Standard profile does not save Home person or # Active person. Always return to the current book's Home person # before applying it, so a previously loaded View profile cannot # leave the graph centred on a different Active person. try: home_person = self.dbstate.db.get_default_person() except Exception: home_person = None if home_person is not None: try: home_handle = home_person.get_handle() or '' except Exception: home_handle = '' try: active_handle = self.get_active() or '' except Exception: active_handle = '' if home_handle and active_handle != home_handle: self.change_active(home_handle) if (loaded_values.get('interface.graphview-filter-family-tag') and not self.family_tag_filter_value_is_allowed(True)): loaded_values['interface.graphview-filter-family-tag'] = False self.set_profile_config_values_all_connected_last( allowed_config_keys, loaded_values) self.temporary_profile_restore_profile_loaded = True # The config value is changed now, but an already-open # Configure/Layout page may still show the old spin-button value. self.sync_open_config_widgets_after_profile_load() # Keep the Profiles page checkboxes in sync when the Configure # dialog is open while a Standard profile is loaded. self.standard_profile_controls_active = True self.sync_standard_profile_controls() view_state = profile.get('view_state_optional', {}) active_handle = self.get_active() if self.graph_widget and active_handle: self.graph_widget.populate(active_handle) if hasattr(self.graph_widget, 'sync_profile_controls'): GLib.idle_add(self.graph_widget.sync_profile_controls) if (isinstance(view_state, dict) and view_state and hasattr(self.graph_widget, 'restore_profile_view_state')): GLib.timeout_add( 200, self.graph_widget.restore_profile_view_state, view_state) return True def is_standard_profile_context_ready(self): """ Return True only when Graph View has a real open database and person. Gramps can construct the view before a family tree is fully open, so startup profile loading must not run during early startup. """ if not self.graph_widget: return False if not self.active: return False try: active_handle = self.get_active() except Exception: return False if not active_handle: return False try: person = self.dbstate.db.get_person_from_handle(active_handle) except Exception: return False if person is None: return False db_name, _media_path, db_internal_path = self.get_graphview_db_profile_info() if not db_name: return False if not db_internal_path: return False return True def schedule_auto_load_standard_profile(self): """ Schedule automatic standard-profile handling after a real populate. A large graph can still be drawing on screen just after populate() has returned. A short timeout lets GTK finish painting before a possible standard-profile redraw is started. """ if not self.profile_function_is_enabled(): return if self.get_profile_startup_mode() == 'none': return if self.standard_profile_auto_checked: return if self.standard_profile_auto_scheduled: return self.standard_profile_auto_scheduled = True GLib.timeout_add(2500, self.delayed_auto_load_standard_profile) def delayed_auto_load_standard_profile(self): """ Run automatic startup-profile handling after the fallback delay. Returning False always removes this one-shot GLib timeout. """ self.standard_profile_auto_scheduled = False self.auto_load_or_offer_standard_profile() return False def auto_load_or_offer_standard_profile(self): """ Load the configured startup profile when the real book context is ready. Return True only when a valid Standard/View startup profile was applied. The caller can then skip GraphView's ordinary first populate(), because the profile loader already rebuilt the graph. """ if self.standard_profile_auto_checked: return bool(self.startup_profile_created_initial_graph) if not self.profile_function_is_enabled(): return False if not self.is_standard_profile_context_ready(): return False startup_mode = self.get_profile_startup_mode() if startup_mode == 'none': return False self.standard_profile_auto_checked = True loaded = False if startup_mode == 'standard': filename = self.get_standard_profile_filename() # A saved Standard profile is required at startup. if not os.path.isfile(filename): return False try: with open(filename, 'r', encoding='utf-8') as json_file: profile = json.load(json_file) if not self.confirm_graphview_profile_database_context( profile, 'standard_profile'): return False loaded = bool(self.apply_standard_graphview_profile(profile)) if loaded: self.update_profiles_status_labels() except (OSError, json.JSONDecodeError): loaded = False elif startup_mode == 'view': filename = (self.current_view_profile_filename or self.get_startup_view_profile_filename()) if not filename or not os.path.isfile(filename): return False loaded = bool(self.load_graphview_profile_from_filename( filename, startup_load=True)) if loaded: self.startup_profile_created_initial_graph = True self.standard_profile_auto_scheduled = False return loaded def standard_profile_database_context_ready(self): """ Return True when the current family-tree identity is known well enough to decide whether its Standard profile file exists. During Gramps startup the Profiles page/status can be synced before the open database has supplied its real name/path. In that state we must not change a saved startup choice from Standard profile to No profile just because the real Standard-profile filename cannot yet be resolved. """ try: db_name, _media_path, db_internal_path = ( self.get_graphview_db_profile_info()) except Exception: return False return bool(db_name and db_internal_path) def standard_graphview_profile_exists(self): """ Return True if this family tree has a saved Standard profile. """ if not self.standard_profile_database_context_ready(): return False try: filename = self.get_standard_profile_filename() except Exception: return False return os.path.isfile(filename) def delete_standard_graphview_profile(self, _button=None): """ Delete this family tree's saved Standard profile. """ if not self.profile_function_action_allowed(): return filename = self.get_standard_profile_filename() if not os.path.isfile(filename): WarningDialog( _('No standard profile'), _('There is no standard Graph View profile to delete for this family tree.'), parent=self.uistate.window) return dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('Delete standard profile?')) dialog.format_secondary_text( _('This deletes the standard Graph View profile for the open family tree.')) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button(_('_Delete'), Gtk.ResponseType.OK) response = dialog.run() dialog.destroy() if response != Gtk.ResponseType.OK: return try: os.remove(filename) except OSError as err: WarningDialog( _('Could not delete standard profile'), str(err), parent=self.uistate.window) return # Deleting a Standard profile should not load or # apply anything else. If it was selected for startup, fall back to # No profile and clear the Standard-profile column without changing # the graph. if self.get_profile_startup_mode() == 'standard': self.set_profile_startup_mode('none') self.standard_profile_controls_active = False self.clear_standard_profile_controls() self.sync_profile_startup_controls() dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, text=_('Standard profile deleted')) dialog.format_secondary_text(filename) dialog.run() dialog.destroy() self.update_profiles_status_labels() def choose_graphview_profile_type(self): """ Ask which kind of profile should be saved. Returns 'view_profile', 'standard_profile', or None if cancelled. """ if not self.profile_function_action_allowed(): return None dialog = Gtk.MessageDialog( transient_for=self.uistate.window, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=_('Choose profile type')) dialog.format_secondary_text( _('A View profile saves the complete graph view.\n' 'A Standard profile saves layout, display and style settings ' 'for the open family tree.')) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button(_('Standard profile'), 1) dialog.add_button(_('View profile'), 2) response = dialog.run() dialog.destroy() if response == 1: return 'standard_profile' if response == 2: return 'view_profile' return None def save_graphview_profile(self, _menuitem=None, profile_type=None, target_filename=None, confirm_overwrite=False, save_all_supported=False): """ Save a Graph View profile as JSON. View profiles save the full view and ask for filename unless a target filename is supplied. Standard profiles save layout/display/style automatically per family tree. """ if not self.profile_function_action_allowed(): return if profile_type is None: profile_type = self.choose_graphview_profile_type() if profile_type is None: return active_handle = self.get_active() person = None if active_handle: person = self.dbstate.db.get_person_from_handle(active_handle) if profile_type == 'view_profile' and not person: WarningDialog( _('No active person'), _('A Graph View profile needs an active person.'), parent=self.uistate.window) return if (profile_type == 'view_profile' and not save_all_supported and not self.view_profile_has_selected_choices()): if not self.confirm_save_empty_view_profile(): return home_person = self.dbstate.db.get_default_person() home_handle = home_person.get_handle() if home_person else '' db_name, media_path, db_internal_path = self.get_graphview_db_profile_info() def get_config_value(config_key): """Read a Graph View config value.""" return self._config.get(config_key) display_section = { "interface.graphview-show-images": get_config_value( "interface.graphview-show-images"), "interface.graphview-show-id": get_config_value( "interface.graphview-show-id"), "interface.graphview-show-avatars": get_config_value( "interface.graphview-show-avatars"), "interface.graphview-show-full-dates": get_config_value( "interface.graphview-show-full-dates"), "interface.graphview-show-places": get_config_value( "interface.graphview-show-places"), "interface.graphview-place-format": get_config_value( "interface.graphview-place-format"), "interface.graphview-show-tags": get_config_value( "interface.graphview-show-tags"), } style_section = { "interface.graphview-highlight-home-person": get_config_value( "interface.graphview-highlight-home-person"), "interface.graphview-home-path-color": get_config_value( "interface.graphview-home-path-color"), "interface.graphview-person-theme": get_config_value( "interface.graphview-person-theme"), "interface.graphview-font": get_config_value( "interface.graphview-font"), "interface.graphview-avatars-style": get_config_value( "interface.graphview-avatars-style"), "interface.graphview-person-border-size": get_config_value( "interface.graphview-person-border-size"), "interface.graphview-active-person-border-size": get_config_value( "interface.graphview-active-person-border-size"), "interface.graphview-avatars-male": get_config_value( "interface.graphview-avatars-male"), "interface.graphview-avatars-female": get_config_value( "interface.graphview-avatars-female"), "interface.graphview-avatars-unknown": get_config_value( "interface.graphview-avatars-unknown"), "interface.graphview-avatars-other": get_config_value( "interface.graphview-avatars-other"), } layout_section = { "interface.graphview-direction": get_config_value( "interface.graphview-direction"), "interface.graphview-show-lines": get_config_value( "interface.graphview-show-lines"), "interface.graphview-ranksep": get_config_value( "interface.graphview-ranksep"), "interface.graphview-nodesep": get_config_value( "interface.graphview-nodesep"), } profile = { "profile_version": 1, "profile_name": "", "profile_type": profile_type, "created_at": datetime.now().isoformat(timespec="seconds"), "db_name": db_name, "media_path": media_path, "db_internal_path": db_internal_path, "layout": layout_section, "display": display_section, "style": style_section, } if profile_type == 'view_profile': if save_all_supported: view_choices = self.get_full_current_view_profile_choices() else: view_choices = getattr( self, 'view_profile_save_choices', {}) save_active_person = view_choices.get('active_person', False) save_home_person = view_choices.get('home_person', False) save_time_direction = view_choices.get('time_direction', False) save_people_limit = view_choices.get('people_limit', False) save_theme = view_choices.get('theme', False) save_path_color = view_choices.get('path_color', False) save_font = view_choices.get('font', False) save_active_person_border_size = view_choices.get( 'active_person_border_size', False) save_person_border_size = view_choices.get( 'person_border_size', False) save_line_types = view_choices.get('line_types', False) save_generations = view_choices.get('generations', False) save_spacings = view_choices.get('spacings', False) save_view_state = view_choices.get('view_state', False) # Boolean View-profile rows are also taken from # the View column choices, not from whatever GraphView currently # happens to show when no View profile is active. for config_key in ( 'interface.graphview-show-images', 'interface.graphview-show-id', 'interface.graphview-show-avatars', 'interface.graphview-show-full-dates', 'interface.graphview-show-places', 'interface.graphview-show-tags'): display_section[config_key] = bool( view_choices.get(config_key, False)) style_section['interface.graphview-highlight-home-person'] = bool( view_choices.get( 'interface.graphview-highlight-home-person', False)) # Time direction in View profile is an # include/exclude choice. When unchecked, the value is removed # from JSON and therefore not applied when the profile is loaded. if not save_time_direction: layout_section.pop('interface.graphview-direction', None) # Line types follow the same include/exclude # model in View profile. if not save_line_types: layout_section.pop('interface.graphview-show-lines', None) # Spacings is one include/exclude choice for # rank separation and node separation. if not save_spacings: layout_section.pop('interface.graphview-ranksep', None) layout_section.pop('interface.graphview-nodesep', None) profile.update({ "content": { "interface.graphview-ancestor-generations": get_config_value( "interface.graphview-ancestor-generations"), "interface.graphview-descendant-generations": get_config_value( "interface.graphview-descendant-generations"), "interface.graphview-show-all-connected": get_config_value( "interface.graphview-show-all-connected"), "interface.graphview-filter-family-tag": get_config_value( "interface.graphview-filter-family-tag"), "interface.graphview-people-limit": get_config_value( "interface.graphview-people-limit"), }, "view_state_optional": { "interface.graphview-scale": ( self.graph_widget.scale if self.graph_widget else None), "horizontal_adjustment_value": ( self.graph_widget.hadjustment.get_value() if self.graph_widget else None), "vertical_adjustment_value": ( self.graph_widget.vadjustment.get_value() if self.graph_widget else None), }, }) profile['content']['interface.graphview-show-all-connected'] = bool( view_choices.get( 'interface.graphview-show-all-connected', False)) filter_value = bool( view_choices.get( 'interface.graphview-filter-family-tag', False)) if (filter_value and not self.family_tag_filter_value_is_allowed(True)): filter_value = False profile['content']['interface.graphview-filter-family-tag'] = filter_value # Zoom & chart position is saved/restored only # when selected. When unchecked, the whole optional view-state # section is removed from the View profile JSON. if not save_view_state: profile.pop('view_state_optional', None) # Always save the numeric value, including # 0 = unlimited. A separate flag preserves whether a positive # value was selected for profile load. self.set_profile_people_limit_choice( profile, save_people_limit, get_config_value('interface.graphview-people-limit')) # Generations in View profile is one # include/exclude choice for two JSON values. When unchecked, # both generation values are removed from the content section. if not save_generations: profile['content'].pop( 'interface.graphview-ancestor-generations', None) profile['content'].pop( 'interface.graphview-descendant-generations', None) # Theme, Path color to home person and Font in # View profile follow the same include/exclude model as # Time direction. When unchecked, the style value is removed # from JSON and therefore not applied when the profile is loaded. if not save_theme: profile['style'].pop( 'interface.graphview-person-theme', None) if not save_path_color: profile['style'].pop( 'interface.graphview-home-path-color', None) if not save_font: profile['style'].pop( 'interface.graphview-font', None) # Active person border size follows the same # include/exclude model in View profile. if not save_active_person_border_size: profile['style'].pop( 'interface.graphview-active-person-border-size', None) # Person border size follows the same # include/exclude model in View profile. if not save_person_border_size: profile['style'].pop( 'interface.graphview-person-border-size', None) if save_active_person: profile["active_person"] = { "handle": active_handle, "gramps_id": person.get_gramps_id(), "name": displayer.display(person), } if save_home_person: profile["home_person"] = { "handle": home_handle, "gramps_id": (home_person.get_gramps_id() if home_person else ''), "name": (displayer.display(home_person) if home_person else ''), } if profile_type == 'view_profile': profile = self.apply_avatar_profile_save_rules(profile) if profile_type == 'standard_profile': if self.save_standard_graphview_profile(show_message=True): self.clear_profiles_page_unsaved() self.update_profiles_status_labels() return if target_filename: filename = target_filename if confirm_overwrite: if not self.confirm_overwrite_current_view_profile(): return profile_name = os.path.splitext(os.path.basename(filename))[0] profile["profile_name"] = profile_name try: with open(filename, 'w', encoding='utf-8') as json_file: json.dump(profile, json_file, ensure_ascii=False, indent=2) self.set_current_view_profile(filename, profile_name) if save_all_supported: self.view_profile_save_choices = dict(view_choices) self.sync_view_profile_controls() self.clear_profiles_page_unsaved() except OSError as msg: ErrorDialog( _('Could not save Graph View profile'), str(msg), parent=self.uistate.window) return return dialog = Gtk.FileChooserDialog( title=_('Save Graph View profile'), action=Gtk.FileChooserAction.SAVE, transient_for=self.uistate.window) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button(_('_Save'), Gtk.ResponseType.OK) dialog.set_do_overwrite_confirmation(True) if media_path and os.path.isdir(media_path): dialog.set_current_folder(media_path) else: mpath = config.get('paths.report-directory') if mpath: dialog.set_current_folder(os.path.dirname(mpath)) dialog.set_current_name("graphview-view-profile.json") json_filter = Gtk.FileFilter() json_filter.set_name(_('JSON files')) json_filter.add_pattern("*.json") dialog.add_filter(json_filter) response = dialog.run() if response == Gtk.ResponseType.OK: filename = dialog.get_filename() if not filename.lower().endswith('.json'): filename += '.json' profile_name = os.path.splitext(os.path.basename(filename))[0] profile["profile_name"] = profile_name try: with open(filename, 'w', encoding='utf-8') as json_file: json.dump(profile, json_file, ensure_ascii=False, indent=2) self.set_current_view_profile(filename, profile_name) if save_all_supported: self.view_profile_save_choices = dict(view_choices) self.sync_view_profile_controls() self.clear_profiles_page_unsaved() except OSError as msg: ErrorDialog( _('Could not save Graph View profile'), str(msg), parent=dialog) dialog.destroy() @batch_profile_graph_refresh(redraw_after=True) def load_graphview_profile_from_filename(self, filename, startup_load=False): """ Load a View profile from a known JSON filename. Used both by manual View loading and View-profile startup loading. """ try: with open(filename, 'r', encoding='utf-8') as json_file: profile = json.load(json_file) except (OSError, json.JSONDecodeError) as msg: if not startup_load: ErrorDialog( _('Could not load Graph View profile'), str(msg), parent=self.uistate.window) return False if not isinstance(profile, dict): if not startup_load: WarningDialog( _('Invalid Graph View profile'), _('The selected file is not a Graph View profile.'), parent=self.uistate.window) return False if profile.get('profile_version') != 1: if not startup_load: WarningDialog( _('Unsupported Graph View profile'), _('Only profile_version 1 is supported.'), parent=self.uistate.window) return False profile_type = profile.get('profile_type', '') if profile_type != 'view_profile': if not startup_load: WarningDialog( _('Invalid View profile'), _('The selected file is not a View profile. Only files ' 'with profile_type "view_profile" can be loaded here.'), parent=self.uistate.window) return False # View profiles combine family-tree metadata with saved person IDs. # This check happens before snapshots, config values or people change. if not self.confirm_graphview_profile_database_context( profile, 'view_profile'): return False active_person = profile.get('active_person', {}) has_active_person = ( isinstance(active_person, dict) and bool(active_person)) active_handle = '' person = None home_person_data = profile.get('home_person', {}) has_home_person = ( isinstance(home_person_data, dict) and bool(home_person_data)) home_handle = '' home_person = None profile_for_control_sync = profile missing_people = [] if has_active_person: active_handle, person = self.resolve_graphview_profile_person_data( active_person) if not person: missing_people.append( self.get_saved_profile_person_description( _('Active person'), active_person)) else: try: active_handle = self.get_active() except Exception: active_handle = '' if has_home_person: home_handle, home_person = ( self.resolve_graphview_profile_person_data(home_person_data)) if not home_person: missing_people.append( self.get_saved_profile_person_description( _('Home person'), home_person_data)) # With matching family-tree metadata, a person may simply have been # deleted later. Keep the existing one-dialog choice to load the rest. if missing_people: if not self.ask_load_view_profile_with_missing_persons( missing_people): return False profile_for_control_sync = dict(profile) if has_active_person and not person: profile_for_control_sync.pop('active_person', None) has_active_person = False try: active_handle = self.get_active() except Exception: active_handle = '' if has_home_person and not home_person: profile_for_control_sync.pop('home_person', None) has_home_person = False allowed_config_keys = [ 'interface.graphview-ancestor-generations', 'interface.graphview-descendant-generations', 'interface.graphview-show-all-connected', 'interface.graphview-filter-family-tag', 'interface.graphview-people-limit', 'interface.graphview-direction', 'interface.graphview-show-lines', 'interface.graphview-ranksep', 'interface.graphview-nodesep', 'interface.graphview-show-images', 'interface.graphview-show-id', 'interface.graphview-show-avatars', 'interface.graphview-show-full-dates', 'interface.graphview-show-places', 'interface.graphview-place-format', 'interface.graphview-show-tags', 'interface.graphview-highlight-home-person', 'interface.graphview-home-path-color', 'interface.graphview-person-theme', 'interface.graphview-font', 'interface.graphview-avatars-style', 'interface.graphview-person-border-size', 'interface.graphview-active-person-border-size', 'interface.graphview-avatars-male', 'interface.graphview-avatars-female', 'interface.graphview-avatars-unknown', 'interface.graphview-avatars-other', ] loaded_values = {} for section_name in ('content', 'layout', 'display', 'style'): section = profile.get(section_name, {}) if isinstance(section, dict): loaded_values.update(section) self.apply_avatar_profile_load_rules(loaded_values) self.apply_profile_people_limit_load_rule(profile, loaded_values) # Failure to save the temporary restore point may # disable temporary use, but it must never block this View profile or # the final graph rebuild. self.prepare_temporary_profile_restore_snapshot_before_load() if (loaded_values.get('interface.graphview-filter-family-tag') and not self.family_tag_filter_value_is_allowed(True)): loaded_values['interface.graphview-filter-family-tag'] = False if isinstance(profile_for_control_sync, dict): profile_for_control_sync = dict(profile_for_control_sync) content_data = profile_for_control_sync.get('content') if isinstance(content_data, dict): content_data = dict(content_data) content_data[ 'interface.graphview-filter-family-tag'] = False profile_for_control_sync['content'] = content_data self.set_profile_config_values_all_connected_last( allowed_config_keys, loaded_values) self.temporary_profile_restore_profile_loaded = True self.sync_open_config_widgets_after_profile_load() if has_home_person and home_person: self.dbstate.db.set_default_person_handle(home_handle) view_state = profile.get('view_state_optional', {}) if has_active_person: self.change_active(active_handle) profile_name = profile.get('profile_name') or os.path.splitext( os.path.basename(filename))[0] self.set_current_view_profile(filename, profile_name) self.clear_standard_profile_controls() self.sync_view_profile_controls(profile_for_control_sync) if self.graph_widget and active_handle: self.graph_widget.populate(active_handle) if hasattr(self.graph_widget, 'sync_profile_controls'): GLib.idle_add(self.graph_widget.sync_profile_controls) if hasattr(self.graph_widget, 'restore_profile_view_state'): GLib.timeout_add( 200, self.graph_widget.restore_profile_view_state, view_state) self.clear_profiles_page_unsaved() self.update_profiles_status_labels() return True def load_graphview_profile(self, _menuitem=None): """ Load a View profile from JSON. """ if not self.profile_function_action_allowed(): return dialog = Gtk.FileChooserDialog( title=_('Load View profile'), action=Gtk.FileChooserAction.OPEN, transient_for=self.uistate.window) dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dialog.add_button(_('_Open'), Gtk.ResponseType.OK) db_name, media_path, _db_internal_path = self.get_graphview_db_profile_info() if media_path and os.path.isdir(media_path): dialog.set_current_folder(media_path) else: mpath = config.get('paths.report-directory') if mpath: dialog.set_current_folder(os.path.dirname(mpath)) json_filter = Gtk.FileFilter() json_filter.set_name(_('JSON files')) json_filter.add_pattern("*.json") dialog.add_filter(json_filter) response = dialog.run() if response != Gtk.ResponseType.OK: dialog.destroy() return filename = dialog.get_filename() dialog.destroy() self.load_graphview_profile_from_filename(filename, startup_load=False) def _get_configure_page_funcs(self): """ Return a list of functions that create gtk elements to use in the notebook pages of the Configure dialog. :return: list of functions """ return [self.layout_config_panel, self.theme_config_panel, self.profiles_config_panel, self.animation_config_panel, self.search_config_panel] def layout_config_panel(self, configdialog): """ Function that builds the widget in the configuration dialog. See "gramps/gui/configure.py" for details. """ grid = Gtk.Grid() grid.set_border_width(12) grid.set_column_spacing(6) grid.set_row_spacing(6) # Remember the ordinary Configure widgets so profile # loads can refresh their visible values while this dialog is open. self.open_graphview_configdialog = configdialog self.open_config_widgets = {} self.home_path_color_config_label = None row = 0 widget = configdialog.add_checkbox( grid, _('Show images'), row, 'interface.graphview-show-images') self.open_config_widgets['interface.graphview-show-images'] = widget row += 1 widget = configdialog.add_checkbox( grid, _('Show IDs'), row, 'interface.graphview-show-id') self.open_config_widgets['interface.graphview-show-id'] = widget row += 1 widget = configdialog.add_checkbox( grid, _('Show avatars'), row, 'interface.graphview-show-avatars') self.open_config_widgets['interface.graphview-show-avatars'] = widget row += 1 widget = configdialog.add_checkbox( grid, _('Highlight the home person'), row, 'interface.graphview-highlight-home-person') self.open_config_widgets[ 'interface.graphview-highlight-home-person'] = widget row += 1 widget = configdialog.add_checkbox( grid, _('Show full dates'), row, 'interface.graphview-show-full-dates') self.open_config_widgets[ 'interface.graphview-show-full-dates'] = widget row += 1 widget = configdialog.add_checkbox( grid, _('Show places'), row, 'interface.graphview-show-places') self.open_config_widgets['interface.graphview-show-places'] = widget row += 1 # Place format: p_fmts = [(0, _("Default"))] for (indx, fmt) in enumerate(place_displayer.get_formats()): p_fmts.append((indx + 1, fmt.name)) active = self._config.get('interface.graphview-place-format') if active >= len(p_fmts): active = 1 widget = configdialog.add_combo( grid, _('Place format'), row, 'interface.graphview-place-format', p_fmts, setactive=active) self.open_config_widgets['interface.graphview-place-format'] = widget row += 1 widget = configdialog.add_checkbox( grid, _('Show tags'), row, 'interface.graphview-show-tags') self.open_config_widgets['interface.graphview-show-tags'] = widget row += 1 direction_fmts = [(0, _("Vertical: Top to Bottom")), (1, _("Vertical: Bottom to Top")), (2, _("Horizontal: Left to Right")), (3, _("Horizontal: Right to Left"))] active = self._config.get('interface.graphview-direction') widget = configdialog.add_combo( grid, _('Time Direction'), row, 'interface.graphview-direction', direction_fmts, setactive=active) self.open_config_widgets['interface.graphview-direction'] = widget row += 1 widget = configdialog.add_checkbox( grid, _('Show people with the person tag "ProfileTag"'), row, 'interface.graphview-filter-family-tag') self.open_config_widgets[ 'interface.graphview-filter-family-tag'] = widget row += 1 people_limit_spinner = configdialog.add_spinner( grid, _('Limit number of people displayed (use 0 for unlimited)'), row, 'interface.graphview-people-limit', (0, 50_000)) self.open_config_widgets[ 'interface.graphview-people-limit'] = people_limit_spinner self.remember_people_limit_config_spinner(people_limit_spinner) if self.people_limit_config_spinner is None: self.remember_people_limit_config_spinner(grid) self.sync_people_limit_config_spinner() return _('Layout'), grid def theme_config_panel(self, configdialog): """ Function that builds the widget in the configuration dialog. See "gramps/gui/configure.py" for details. """ grid = Gtk.Grid() grid.set_border_width(12) grid.set_column_spacing(6) grid.set_row_spacing(6) self.open_graphview_configdialog = configdialog p_themes = DotSvgGenerator(self.dbstate, self).get_person_themes() themes_list = [] for t in p_themes: themes_list.append((t[0], t[1])) row = 0 widget = configdialog.add_combo( grid, _('Person theme'), row, 'interface.graphview-person-theme', themes_list) self.open_config_widgets['interface.graphview-person-theme'] = widget row += 1 color_btn = configdialog.add_color( grid, _('Path color to home person'), row, 'interface.graphview-home-path-color', col=1) self.open_config_widgets[ 'interface.graphview-home-path-color'] = color_btn try: self.home_path_color_config_label = grid.get_child_at(3, row) except Exception: self.home_path_color_config_label = None row += 1 font_lbl = Gtk.Label(label=_('Font:'), xalign=0) grid.attach(font_lbl, 1, row, 1, 1) font = self._config.get('interface.graphview-font') font_str = '%s, %d' % (font[0], font[1]) font_btn = Gtk.FontButton.new_with_font(font_str) font_btn.set_show_style(False) grid.attach(font_btn, 2, row, 1, 1) font_btn.connect('font-set', self.config_change_font) font_btn.set_filter_func(self.font_filter_func) self.open_config_widgets['interface.graphview-font'] = font_btn # Avatars options # =================================================================== row += 1 avatars = Avatars(self._config) combo = configdialog.add_combo( grid, _('Avatars style'), row, 'interface.graphview-avatars-style', avatars.get_styles_list()) combo.connect('show', self.cb_on_combo_show) self.open_config_widgets['interface.graphview-avatars-style'] = combo file_filter = Gtk.FileFilter() file_filter.set_name(_('PNG files')) file_filter.add_pattern("*.png") self.avatar_widgets.clear() row += 1 lbl = Gtk.Label(label=_('Male avatar:'), halign=Gtk.Align.END) FCB_male = Gtk.FileChooserButton.new(_('Choose male avatar'), Gtk.FileChooserAction.OPEN) FCB_male.add_filter(file_filter) FCB_male.set_filename( self._config.get('interface.graphview-avatars-male')) FCB_male.connect('file-set', self.cb_male_avatar_set) grid.attach(lbl, 1, row, 1, 1) grid.attach(FCB_male, 2, row, 1, 1) self.avatar_widgets.append(lbl) self.avatar_widgets.append(FCB_male) self.open_config_widgets[ 'interface.graphview-avatars-male'] = FCB_male row += 1 lbl = Gtk.Label(label=_('Female avatar:'), halign=Gtk.Align.END) FCB_female = Gtk.FileChooserButton.new(_('Choose female avatar'), Gtk.FileChooserAction.OPEN) FCB_female.connect('file-set', self.cb_female_avatar_set) FCB_female.add_filter(file_filter) FCB_female.set_filename( self._config.get('interface.graphview-avatars-female')) grid.attach(lbl, 1, row, 1, 1) grid.attach(FCB_female, 2, row, 1, 1) self.avatar_widgets.append(lbl) self.avatar_widgets.append(FCB_female) self.open_config_widgets[ 'interface.graphview-avatars-female'] = FCB_female row += 1 lbl = Gtk.Label(label=_('Unknown avatar:'), halign=Gtk.Align.END) FCB_unknown = Gtk.FileChooserButton.new(_('Choose Unknown avatar'), Gtk.FileChooserAction.OPEN) FCB_unknown.add_filter(file_filter) FCB_unknown.set_filename( self._config.get('interface.graphview-avatars-unknown')) FCB_unknown.connect('file-set', self.cb_unknown_avatar_set) grid.attach(lbl, 1, row, 1, 1) grid.attach(FCB_unknown, 2, row, 1, 1) self.avatar_widgets.append(lbl) self.avatar_widgets.append(FCB_unknown) self.open_config_widgets[ 'interface.graphview-avatars-unknown'] = FCB_unknown row += 1 lbl = Gtk.Label(label=_('Other avatar:'), halign=Gtk.Align.END) FCB_other = Gtk.FileChooserButton.new(_('Choose Other avatar'), Gtk.FileChooserAction.OPEN) FCB_other.add_filter(file_filter) FCB_other.set_filename( self._config.get('interface.graphview-avatars-other')) FCB_other.connect('file-set', self.cb_other_avatar_set) grid.attach(lbl, 1, row, 1, 1) grid.attach(FCB_other, 2, row, 1, 1) self.avatar_widgets.append(lbl) self.avatar_widgets.append(FCB_other) self.open_config_widgets[ 'interface.graphview-avatars-other'] = FCB_other # =================================================================== row += 1 widget = configdialog.add_spinner( grid, _('Active person border size'), row, 'interface.graphview-active-person-border-size', (1, 20)) self.open_config_widgets[ 'interface.graphview-active-person-border-size'] = widget row += 1 widget = configdialog.add_spinner( grid, _('Person border size'), row, 'interface.graphview-person-border-size', (1, 20)) self.open_config_widgets[ 'interface.graphview-person-border-size'] = widget return _('Themes'), grid def cb_profile_standard_bool_toggled(self, checkbutton, config_key): """ Let selected boolean Standard-profile boxes in the Profiles tab update Graph View immediately, like the normal configuration tabs do. The same state is also remembered as a Standard-profile save choice. Selected boolean Standard-profile boxes are saved as normal true/false values when Save standard profile is pressed. """ if getattr(self, 'updating_standard_profile_controls', False): return if not self.profile_function_action_allowed(): return value = checkbutton.get_active() if (config_key == 'interface.graphview-filter-family-tag' and value and not self.family_tag_filter_value_is_allowed(True)): self.updating_standard_profile_controls = True try: checkbutton.set_active(False) finally: self.updating_standard_profile_controls = False value = False if not hasattr(self, 'standard_profile_save_choices'): self.standard_profile_save_choices = {} self.standard_profile_save_choices[config_key] = value self._config.set(config_key, value) if config_key == 'interface.graphview-show-id': self.show_ID = value elif config_key == 'interface.graphview-show-images': self.show_images = value elif config_key == 'interface.graphview-show-avatars': self.show_avatars = value elif config_key == 'interface.graphview-highlight-home-person': self.highlight_home_person = value elif config_key == 'interface.graphview-show-full-dates': self.show_full_dates = value elif config_key == 'interface.graphview-show-places': self.show_places = value elif config_key == 'interface.graphview-show-tags': self.show_tag_color = value elif config_key == 'interface.graphview-show-all-connected': if self.graph_widget and hasattr(self.graph_widget, 'all_connected_btn'): if self.graph_widget.all_connected_btn.get_active() != value: self.graph_widget.all_connected_btn.set_active(value) self.update_profile_avatar_controls_sensitivity() self.mark_profiles_page_unsaved() if self.graph_widget and self.get_active(): self.graph_widget.populate(self.get_active()) def cb_profile_standard_save_only_bool_toggled(self, checkbutton, config_key): """ Remember Standard-profile boolean values that should be saved, but do not update/redraw Graph View immediately. """ if getattr(self, 'updating_standard_profile_controls', False): return if not self.profile_function_action_allowed(): return if not hasattr(self, 'standard_profile_save_choices'): self.standard_profile_save_choices = {} self.standard_profile_save_choices[config_key] = checkbutton.get_active() self.mark_profiles_page_unsaved() def cb_profile_standard_group_toggled(self, checkbutton, group_name): """ Remember whether a Standard-profile group should be saved and restored. These group checkboxes do not change Graph View immediately; they only decide whether the related values are present in the standard profile JSON and therefore loaded later. """ if getattr(self, 'updating_standard_profile_controls', False): return if not self.profile_function_action_allowed(): return if not hasattr(self, 'standard_profile_group_choices'): self.standard_profile_group_choices = {} self.standard_profile_group_choices[group_name] = checkbutton.get_active() self.mark_profiles_page_unsaved() def sync_standard_profile_controls(self): """ Update the Standard-profile checkboxes while the Configure dialog is open. This is used after Load standard profile so the Profiles page reflects the values that are now active without closing/reopening the dialog. """ checkbox_info = getattr(self, 'profile_standard_checkbox_info', []) if not checkbox_info: return False if not hasattr(self, 'standard_profile_save_choices'): self.standard_profile_save_choices = {} if not hasattr(self, 'standard_profile_group_choices'): self.standard_profile_group_choices = {} standard_profile_exists = bool( self.standard_profile_controls_active and self.standard_graphview_profile_exists()) standard_profile_data = {} if standard_profile_exists: standard_profile_data = self.read_standard_profile_for_choices() if not standard_profile_data: standard_profile_exists = False self.updating_standard_profile_controls = True try: for info in checkbox_info: checkbutton = info.get('checkbutton') if checkbutton is None: continue standard_default = info.get('standard_default', True) live_config_key = info.get('live_config_key') save_only_config_key = info.get('save_only_config_key') if not standard_profile_exists: checkbutton.set_active(False) if live_config_key: self.standard_profile_save_choices[live_config_key] = False elif save_only_config_key: if save_only_config_key.startswith('group:'): group_name = save_only_config_key.split(':', 1)[1] self.standard_profile_group_choices[group_name] = False else: self.standard_profile_save_choices[save_only_config_key] = False continue if live_config_key: value = self.get_standard_profile_choice_value( standard_profile_data, live_config_key, False) checkbutton.set_active(bool(value)) self.standard_profile_save_choices[live_config_key] = ( checkbutton.get_active()) elif save_only_config_key: if save_only_config_key.startswith('group:'): group_name = save_only_config_key.split(':', 1)[1] value = self.standard_profile_group_is_enabled( group_name, standard_default) checkbutton.set_active(bool(value)) self.standard_profile_group_choices[group_name] = ( checkbutton.get_active()) else: value = self.get_standard_profile_choice_value( standard_profile_data, save_only_config_key, False) checkbutton.set_active(bool(value)) self.standard_profile_save_choices[save_only_config_key] = ( checkbutton.get_active()) else: checkbutton.set_active(bool(standard_default)) finally: self.updating_standard_profile_controls = False self.update_profile_avatar_controls_sensitivity() self.update_profile_people_limit_controls_sensitivity() return False def clear_standard_profile_controls(self): """ Clear the Standard-profile column after the Standard profile is deleted. This is only a profile-page reset. It must not change current Graph View settings or redraw the graph. """ self.standard_profile_controls_active = False if not hasattr(self, 'standard_profile_save_choices'): self.standard_profile_save_choices = {} if not hasattr(self, 'standard_profile_group_choices'): self.standard_profile_group_choices = {} self.updating_standard_profile_controls = True try: for info in getattr(self, 'profile_standard_checkbox_info', []): checkbutton = info.get('checkbutton') if checkbutton is None: continue live_config_key = info.get('live_config_key') save_only_config_key = info.get('save_only_config_key') checkbutton.set_active(False) if live_config_key: self.standard_profile_save_choices[live_config_key] = False elif save_only_config_key: if save_only_config_key.startswith('group:'): group_name = save_only_config_key.split(':', 1)[1] self.standard_profile_group_choices[group_name] = False else: self.standard_profile_save_choices[save_only_config_key] = False finally: self.updating_standard_profile_controls = False self.update_profile_function_controls_sensitivity() return False def get_standard_profile_section_for_choices(self, section_name): """ Read the current Standard-profile section so Profiles-tab group checkboxes can reflect what will actually be restored. If the Standard profile has been deleted, the Standard column remains clean. """ try: self.clean_first_standard_profile_if_created_from_backup() filename = self.get_standard_profile_filename() if not os.path.isfile(filename): return {} with open(filename, 'r', encoding='utf-8') as json_file: profile = json.load(json_file) section = profile.get(section_name, {}) if isinstance(section, dict): return section except Exception: pass return {} def profiles_config_panel(self, configdialog): """ Function that builds the Profiles page in the configuration dialog. Keep the stable Standard/View profile logic. The selected boolean Standard-profile and View-profile boxes update Graph View immediately. Load-only choices are marked with a small footnote in the Profiles page. """ self.profile_function_controls = [] outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) outer.set_border_width(12) # The standard Gramps Configure dialog has a generic # tooltip saying "Any changes are saved immediately". That is # misleading on the Profiles page because some boxes update live, # while the ¹ choices only decide what is saved/restored later. # Remove the generic tooltip only while the Profiles page is active, # and restore it again on the other Graph View Configure pages. try: original_window_tooltip = configdialog.window.get_tooltip_text() except Exception: original_window_tooltip = None def update_window_tooltip_for_profiles_tab(): try: profile_page_number = configdialog.panel.page_num(outer) if (profile_page_number >= 0 and configdialog.panel.get_current_page() == profile_page_number): configdialog.window.set_tooltip_text(None) else: configdialog.window.set_tooltip_text( original_window_tooltip) except Exception: pass return False def on_profiles_tab_switch(_notebook, _page, _page_num): GLib.idle_add(update_window_tooltip_for_profiles_tab) try: configdialog.panel.connect('switch-page', on_profiles_tab_switch) GLib.idle_add(update_window_tooltip_for_profiles_tab) except Exception: pass # Keep the complete, proven close protection. # delete-event guards the window X; response guards normal GTK close; # the direct button handlers detect Gramps' special Close button. # After "Close anyway", request_profiles_configure_close() performs # the actual close through Gtk.ResponseType.CLOSE. try: if self.profiles_config_window is not configdialog.window: self.profiles_config_window = configdialog.window self.profiles_config_close_button_ids = set() self.profiles_config_force_close_once = False self.profiles_config_delete_handler_id = ( configdialog.window.connect( 'delete-event', self.cb_profiles_configure_delete_event)) try: self.profiles_config_response_handler_id = ( configdialog.window.connect( 'response', self.cb_profiles_configure_response)) except Exception: self.profiles_config_response_handler_id = None GLib.idle_add( self.install_profiles_close_button_guard, configdialog.window) except Exception: pass # The profile function is opt-in. When enabled for # the first time, a complete original-settings backup is created. # No Standard profile is created automatically. # Compact Profiles layout for smaller screens. # The two master options share the top row, while startup choices # stay on one line underneath. startup_grid = Gtk.Grid() startup_grid.set_column_spacing(12) startup_grid.set_row_spacing(2) startup_grid.set_column_homogeneous(True) startup_grid.set_margin_top(2) startup_grid.set_margin_bottom(2) outer.pack_start(startup_grid, False, False, 0) profile_feature_check = Gtk.CheckButton( label=_('Enable profile function')) profile_feature_check.set_tooltip_text( _('When enabled, Graph View profiles can be used, and a selected ' 'profile can be loaded at startup.')) profile_feature_check.set_active(self.profile_function_is_enabled()) profile_feature_check.connect( 'toggled', self.cb_profile_feature_enable_toggled) self.profile_feature_enable_checkbox = profile_feature_check startup_grid.attach(profile_feature_check, 0, 0, 2, 1) backup_info = Gtk.Label( label=_( 'When enabled for the first time, the current Graph View ' 'settings are saved as the original settings backup. This ' 'backup is never overwritten.'), xalign=0) backup_info.set_line_wrap(True) backup_info.set_margin_start(22) # Keep both help texts aligned at the top when # the left text wraps onto an extra line on a smaller screen. backup_info.set_valign(Gtk.Align.START) backup_info.set_yalign(0.0) startup_grid.attach(backup_info, 0, 1, 2, 1) temporary_check = Gtk.CheckButton( label=_('Use profiles temporarily')) temporary_check.set_tooltip_text( _('Restore the Graph View settings that were active when the ' 'family tree was opened.')) temporary_check.set_active(self.profiles_temporary_is_enabled()) temporary_check.connect( 'toggled', self.cb_profiles_temporary_toggled) self.profile_temporary_checkbox = temporary_check self.register_profile_function_control(temporary_check) startup_grid.attach(temporary_check, 2, 0, 2, 1) temporary_info = Gtk.Label( label=_( 'Restore the Graph View settings that were active when the ' 'family tree was opened.'), xalign=0) temporary_info.set_line_wrap(True) temporary_info.set_margin_start(22) temporary_info.set_valign(Gtk.Align.START) temporary_info.set_yalign(0.0) self.register_profile_function_control(temporary_info) startup_grid.attach(temporary_info, 2, 1, 2, 1) startup_label = Gtk.Label(label=_('Apply at startup:'), xalign=0) startup_label.set_margin_top(4) startup_label.set_margin_start(28) startup_grid.attach(startup_label, 0, 2, 1, 1) startup_radio_box = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=28) startup_radio_box.set_margin_top(4) startup_grid.attach(startup_radio_box, 1, 2, 3, 1) startup_none_radio = Gtk.RadioButton.new_with_label_from_widget( None, _('No profile')) startup_none_radio.connect( 'toggled', self.cb_profile_startup_mode_toggled, 'none') self.profile_startup_none_radio = startup_none_radio self.register_profile_function_control(startup_none_radio) startup_radio_box.pack_start(startup_none_radio, False, False, 0) startup_standard_radio = Gtk.RadioButton.new_with_label_from_widget( startup_none_radio, _('Standard profile')) startup_standard_radio.connect( 'toggled', self.cb_profile_startup_mode_toggled, 'standard') self.profile_startup_standard_radio = startup_standard_radio self.register_profile_function_control(startup_standard_radio) startup_radio_box.pack_start(startup_standard_radio, False, False, 0) startup_view_radio = Gtk.RadioButton.new_with_label_from_widget( startup_none_radio, _('Current View profile')) startup_view_radio.connect( 'toggled', self.cb_profile_startup_mode_toggled, 'view') self.profile_startup_view_radio = startup_view_radio self.register_profile_function_control(startup_view_radio) startup_radio_box.pack_start(startup_view_radio, False, False, 0) self.sync_profile_startup_controls() # Beginner-friendly full snapshot actions. These buttons # read the current Graph View directly and do not depend on the # individual choices below. quick_save_box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=2) quick_save_box.set_margin_top(4) quick_save_box.set_margin_start(32) outer.pack_start(quick_save_box, False, False, 0) quick_save_title = Gtk.Label(label=_('Save current view:'), xalign=0) quick_save_title.set_markup( '%s' % escape(_('Save current view:'))) self.register_profile_function_control(quick_save_title) quick_save_box.pack_start(quick_save_title, False, False, 0) quick_save_info = Gtk.Label( label=_( 'Save all supported settings from the current Graph View ' 'without changing the graph.'), xalign=0) quick_save_info.set_line_wrap(True) self.register_profile_function_control(quick_save_info) quick_save_box.pack_start(quick_save_info, False, False, 0) quick_save_buttons = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=8) quick_save_buttons.set_halign(Gtk.Align.CENTER) quick_save_buttons.set_margin_top(2) quick_save_box.pack_start(quick_save_buttons, False, False, 0) quick_standard_btn = Gtk.Button( label=_('Save as Standard profile')) quick_standard_btn.set_tooltip_text( _('Save all supported current settings to the Standard profile.')) quick_standard_btn.connect( 'clicked', self.save_current_view_as_standard_profile) self.register_profile_function_control(quick_standard_btn) quick_save_buttons.pack_start( quick_standard_btn, False, False, 0) quick_view_btn = Gtk.Button( label=_('Save as new View profile...')) quick_view_btn.set_tooltip_text( _('Save the complete current view to a new View profile file.')) quick_view_btn.connect( 'clicked', self.save_current_view_as_new_view_profile) self.register_profile_function_control(quick_view_btn) quick_save_buttons.pack_start(quick_view_btn, False, False, 0) custom_title = Gtk.Label( label=_('Or choose settings to include'), xalign=0) custom_title.set_markup( '%s' % escape(_('Or choose settings to include'))) custom_title.set_margin_top(6) custom_title.set_margin_start(32) self.register_profile_function_control(custom_title) outer.pack_start(custom_title, False, False, 0) # A responsive separator between the section heading # and its explanatory text. The equal margins align it with the # surrounding text, while hexpand makes it follow the window width. custom_separator = Gtk.Separator( orientation=Gtk.Orientation.HORIZONTAL) custom_separator.set_margin_start(32) custom_separator.set_margin_end(32) custom_separator.set_margin_top(3) custom_separator.set_margin_bottom(3) custom_separator.set_hexpand(True) self.register_profile_function_control(custom_separator) outer.pack_start(custom_separator, False, True, 0) custom_info = Gtk.Label( label=_( 'Select individual settings for a customised Standard or ' 'View profile.'), xalign=0) custom_info.set_line_wrap(True) custom_info.set_margin_start(32) custom_info.set_margin_end(32) self.register_profile_function_control(custom_info) outer.pack_start(custom_info, False, False, 0) scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) scrolled.set_shadow_type(Gtk.ShadowType.NONE) scrolled.set_margin_start(32) outer.pack_start(scrolled, True, True, 0) grid = Gtk.Grid() grid.set_column_spacing(12) grid.set_row_spacing(4) grid.set_border_width(2) scrolled.add(grid) title_label = Gtk.Label(label=_('Profile content'), xalign=0) title_label.set_markup('%s' % escape(_('Profile content'))) grid.attach(title_label, 0, 0, 1, 1) standard_label = Gtk.Label(label=_('Standard profile'), xalign=0.5) standard_label.set_markup('%s' % escape(_('Standard profile'))) grid.attach(standard_label, 1, 0, 1, 1) view_label = Gtk.Label(label=_('View profile'), xalign=0.5) view_label.set_markup('%s' % escape(_('View profile'))) grid.attach(view_label, 2, 0, 1, 1) # (label, standard default, view default, standard live config key, # standard save-only boolean config key, view save-choice key, # view live boolean config key) # Use None for standard default when Standard profile must not offer # that choice. Media path is intentionally not shown as a user choice. # Selected boolean options update Graph View live and are saved as # normal true/false values. Show people with the person tag "ProfileTag" # updates live in both profile columns. All ¹ rows are load-only # include/exclude choices: selected saves their value(s), unselected # removes their JSON line(s). Limit number displayed is the exception: # its value is always stored, while the positive-limit choice is stored # separately. All connected is true/false and updates live. # Show places includes Place format. # Keep Home and Active person first because they are View-profile # person choices rather than ordinary Graph View settings. The # remaining rows follow their English display names alphabetically, # except Show images stays immediately before the dependent # Show avatars & style option. profile_rows = [ (_('Home person¹'), None, True, None, None, 'home_person', None), (_('Active person¹'), None, True, None, None, 'active_person', None), (_('Active person border size¹'), True, True, None, 'group:active_person_border_size', 'active_person_border_size', None), (_('All connected'), False, True, 'interface.graphview-show-all-connected', None, None, 'interface.graphview-show-all-connected'), (_('Font¹'), True, True, None, 'group:font', 'font', None), (_('Generations¹'), False, True, None, 'group:generations', 'generations', None), (_('Highlight the home person'), True, True, 'interface.graphview-highlight-home-person', None, None, 'interface.graphview-highlight-home-person'), (_('Limit number of people displayed¹'), False, True, None, 'group:people_limit', 'people_limit', None), (_('Lines type¹'), True, True, None, 'group:line_types', 'line_types', None), (_('Path color to home person¹'), True, True, None, 'group:path_color', 'path_color', None), (_('Person border size¹'), True, True, None, 'group:person_border_size', 'person_border_size', None), (_('Show full dates'), True, True, 'interface.graphview-show-full-dates', None, None, 'interface.graphview-show-full-dates'), (_('Show IDs'), True, True, 'interface.graphview-show-id', None, None, 'interface.graphview-show-id'), (_('Show images'), True, True, 'interface.graphview-show-images', None, None, 'interface.graphview-show-images'), (_('Show avatars & style'), True, True, 'interface.graphview-show-avatars', None, None, 'interface.graphview-show-avatars'), (_('Show people with the person tag "ProfileTag"'), False, True, 'interface.graphview-filter-family-tag', None, None, 'interface.graphview-filter-family-tag'), (_('Show places & format'), True, True, 'interface.graphview-show-places', None, None, 'interface.graphview-show-places'), (_('Show tags'), True, True, 'interface.graphview-show-tags', None, None, 'interface.graphview-show-tags'), (_('Spacings¹'), True, True, None, 'group:spacings', 'spacings', None), (_('Theme¹'), True, True, None, 'group:theme', 'theme', None), (_('Time direction¹'), True, True, None, 'group:time_direction', 'time_direction', None), (_('Zoom & chart position¹'), False, True, None, 'group:view_state', 'view_state', None), ] self.profile_standard_checkbuttons = [] self.profile_standard_checkbox_info = [] self.profile_view_checkbuttons = [] self.profile_view_checkbox_info = [] self.profile_standard_show_images_checkbox = None self.profile_standard_show_avatars_checkbox = None self.profile_view_show_images_checkbox = None self.profile_view_show_avatars_checkbox = None self.view_profile_save_choices = getattr( self, 'view_profile_save_choices', self.get_empty_view_profile_save_choices()) if not self.current_view_profile_filename: self.view_profile_save_choices = self.get_empty_view_profile_save_choices() self.standard_profile_save_choices = getattr( self, 'standard_profile_save_choices', {}) self.standard_profile_group_choices = getattr( self, 'standard_profile_group_choices', {}) standard_profile_exists_for_controls = bool( self.standard_profile_controls_active and self.standard_graphview_profile_exists()) standard_profile_data_for_controls = {} if standard_profile_exists_for_controls: standard_profile_data_for_controls = ( self.read_standard_profile_for_choices()) if not standard_profile_data_for_controls: standard_profile_exists_for_controls = False row = 1 for (label_text, standard_default, view_default, live_config_key, save_only_config_key, view_choice_key, view_config_key) in profile_rows: label = Gtk.Label(label=label_text, xalign=0) grid.attach(label, 0, row, 1, 1) if standard_default is None: standard_placeholder = Gtk.Label(label='') grid.attach(standard_placeholder, 1, row, 1, 1) else: standard_check = Gtk.CheckButton() standard_check.set_halign(Gtk.Align.CENTER) if not standard_profile_exists_for_controls: standard_check.set_active(False) if live_config_key: self.standard_profile_save_choices[live_config_key] = False standard_check.connect( 'toggled', self.cb_profile_standard_bool_toggled, live_config_key) elif save_only_config_key: if save_only_config_key.startswith('group:'): group_name = save_only_config_key.split(':', 1)[1] self.standard_profile_group_choices[group_name] = False standard_check.connect( 'toggled', self.cb_profile_standard_group_toggled, group_name) else: self.standard_profile_save_choices[save_only_config_key] = False standard_check.connect( 'toggled', self.cb_profile_standard_save_only_bool_toggled, save_only_config_key) elif live_config_key: standard_check.set_active(bool( self.get_standard_profile_choice_value( standard_profile_data_for_controls, live_config_key, False))) self.standard_profile_save_choices[live_config_key] = ( standard_check.get_active()) standard_check.connect( 'toggled', self.cb_profile_standard_bool_toggled, live_config_key) elif save_only_config_key: if save_only_config_key.startswith('group:'): group_name = save_only_config_key.split(':', 1)[1] group_active = self.standard_profile_group_is_enabled( group_name, standard_default) standard_check.set_active(group_active) self.standard_profile_group_choices[group_name] = ( standard_check.get_active()) standard_check.connect( 'toggled', self.cb_profile_standard_group_toggled, group_name) else: standard_check.set_active(bool( self.get_standard_profile_choice_value( standard_profile_data_for_controls, save_only_config_key, False))) self.standard_profile_save_choices[save_only_config_key] = ( standard_check.get_active()) standard_check.connect( 'toggled', self.cb_profile_standard_save_only_bool_toggled, save_only_config_key) else: standard_check.set_active(standard_default) self.register_profile_function_control(standard_check) grid.attach(standard_check, 1, row, 1, 1) self.profile_standard_checkbuttons.append(standard_check) self.profile_standard_checkbox_info.append({ 'checkbutton': standard_check, 'standard_default': standard_default, 'live_config_key': live_config_key, 'save_only_config_key': save_only_config_key, }) if live_config_key == 'interface.graphview-show-images': self.profile_standard_show_images_checkbox = standard_check elif live_config_key == 'interface.graphview-show-avatars': self.profile_standard_show_avatars_checkbox = standard_check elif save_only_config_key == 'group:people_limit': self.profile_standard_people_limit_checkbox = standard_check view_check = Gtk.CheckButton() view_check.set_halign(Gtk.Align.CENTER) if view_config_key: view_check.set_active(bool( self.view_profile_save_choices.get( view_config_key, False))) view_check.connect( 'toggled', self.cb_profile_view_bool_toggled, view_config_key) elif view_choice_key: view_check.set_active(bool( self.view_profile_save_choices.get( view_choice_key, view_default))) view_check.connect( 'toggled', self.cb_profile_view_choice_toggled, view_choice_key) else: view_check.set_active(view_default) self.register_profile_function_control(view_check) grid.attach(view_check, 2, row, 1, 1) self.profile_view_checkbuttons.append(view_check) self.profile_view_checkbox_info.append({ 'checkbutton': view_check, 'choice_key': view_choice_key, 'view_config_key': view_config_key, 'default_value': view_default, }) if view_config_key == 'interface.graphview-show-images': self.profile_view_show_images_checkbox = view_check elif view_config_key == 'interface.graphview-show-avatars': self.profile_view_show_avatars_checkbox = view_check elif view_choice_key == 'people_limit': self.profile_view_people_limit_checkbox = view_check row += 1 self.update_profile_avatar_controls_sensitivity() self.update_profile_people_limit_controls_sensitivity() note = Gtk.Label( label=_('¹ saved in profiles; applied on load only.'), xalign=0) note.set_line_wrap(True) note.set_margin_start(32) outer.pack_start(note, False, False, 0) # Compact status line. Current View and Standard # status share one row, with Standard status aligned to the right. status_line = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=8) status_line.set_margin_start(32) status_line.set_margin_end(32) outer.pack_start(status_line, False, False, 4) status_title = Gtk.Label(label=_('Status:'), xalign=0) status_title.set_markup('%s' % escape(_('Status:'))) status_line.pack_start(status_title, False, False, 0) current_text = _('Current View profile: none') if self.current_view_profile_name: current_text = (_('Current View profile: %s') % self.current_view_profile_name) self.current_view_profile_status_label = Gtk.Label( label=current_text, xalign=0) status_line.pack_start( self.current_view_profile_status_label, False, False, 0) status_spacer = Gtk.Box() status_line.pack_start(status_spacer, True, True, 0) standard_text = _('Standard profile: not saved') if self.standard_graphview_profile_exists(): standard_text = _('Standard profile: saved') self.standard_profile_status_label = Gtk.Label( label=standard_text, xalign=1) status_line.pack_end( self.standard_profile_status_label, False, False, 0) status_grid = Gtk.Grid() status_grid.set_column_spacing(8) status_grid.set_row_spacing(5) status_grid.set_margin_start(32) status_grid.set_margin_end(32) outer.pack_start(status_grid, False, False, 0) view_buttons_label = Gtk.Label(label=_('View profile:'), xalign=0) status_grid.attach(view_buttons_label, 0, 0, 4, 1) save_current_btn = Gtk.Button(label=_('Save current View profile')) save_current_btn.set_tooltip_text( _('Save changes to the current View profile.')) save_current_btn.connect('clicked', self.save_current_view_profile) self.register_profile_function_control(save_current_btn) save_current_btn.set_sensitive( bool(self.current_view_profile_filename)) self.current_view_profile_save_button = save_current_btn status_grid.attach(save_current_btn, 0, 1, 1, 1) save_as_btn = Gtk.Button(label=_('Save as...')) self.register_profile_function_control(save_as_btn) save_as_btn.connect( 'clicked', lambda button: self.save_graphview_profile( button, 'view_profile')) status_grid.attach(save_as_btn, 1, 1, 1, 1) load_btn = Gtk.Button(label=_('Load...')) self.register_profile_function_control(load_btn) load_btn.connect('clicked', self.load_graphview_profile) status_grid.attach(load_btn, 2, 1, 1, 1) delete_view_btn = Gtk.Button(label=_('Delete')) self.register_profile_function_control(delete_view_btn) delete_view_btn.connect('clicked', self.delete_current_view_profile) delete_view_btn.set_sensitive( bool(self.current_view_profile_filename)) self.current_view_profile_delete_button = delete_view_btn status_grid.attach(delete_view_btn, 3, 1, 1, 1) standard_buttons_label = Gtk.Label( label=_('Standard profile:'), xalign=0) standard_buttons_label.set_margin_top(8) status_grid.attach(standard_buttons_label, 0, 2, 4, 1) save_standard_btn = Gtk.Button(label=_('Save standard profile')) self.register_profile_function_control(save_standard_btn) save_standard_btn.connect( 'clicked', self.save_selected_standard_profile) status_grid.attach(save_standard_btn, 0, 3, 1, 1) load_standard_btn = Gtk.Button(label=_('Load standard profile')) self.register_profile_function_control(load_standard_btn) load_standard_btn.connect( 'clicked', self.load_standard_graphview_profile) load_standard_btn.set_sensitive( self.profile_function_is_enabled() and self.standard_graphview_profile_exists()) self.standard_profile_load_button = load_standard_btn status_grid.attach(load_standard_btn, 1, 3, 1, 1) delete_standard_btn = Gtk.Button(label=_('Delete')) self.register_profile_function_control(delete_standard_btn) delete_standard_btn.connect( 'clicked', self.delete_standard_graphview_profile) delete_standard_btn.set_sensitive( self.standard_graphview_profile_exists()) self.standard_profile_delete_button = delete_standard_btn status_grid.attach(delete_standard_btn, 3, 3, 1, 1) self.update_profiles_status_labels() return _('Profiles'), outer def animation_config_panel(self, configdialog): """ Function that builds the widget in the configuration dialog. See "gramps/gui/configure.py" for details. """ grid = Gtk.Grid() grid.set_border_width(12) grid.set_column_spacing(6) grid.set_row_spacing(6) configdialog.add_checkbox( grid, _('Show animation'), 0, 'interface.graphview-show-animation') self.ani_widgets.clear() widget = configdialog.add_spinner( grid, _('Animation speed (1..5 and 5 is the slower)'), 1, 'interface.graphview-animation-speed', (1, 5)) self.ani_widgets.append(widget) widget = configdialog.add_spinner( grid, _('Animation count (0..8 use 0 to turn off)'), 2, 'interface.graphview-animation-count', (0, 8)) self.ani_widgets.append(widget) # disable animate options if needed if not self.graph_widget.animation.show_animation: for widget in self.ani_widgets: widget.set_sensitive(False) return _('Animation'), grid def search_config_panel(self, configdialog): """ Function that builds the widget in the configuration dialog. See "gramps/gui/configure.py" for details. """ grid = Gtk.Grid() grid.set_border_width(12) grid.set_column_spacing(6) grid.set_row_spacing(6) row = 0 widget = configdialog.add_checkbox( grid, _('Search in all database'), row, 'interface.graphview-search-all-db') widget.set_tooltip_text(_("Also apply search by all database.")) row += 1 widget = configdialog.add_checkbox( grid, _('Show person images'), row, 'interface.graphview-search-show-images') widget.set_tooltip_text( _("Show persons thumbnails in search result list.")) row += 1 widget = configdialog.add_checkbox( grid, _('Show bookmarked first'), row, 'interface.graphview-search-marked-first') widget.set_tooltip_text( _("Show bookmarked persons first in search result list.")) return _('Search'), grid def font_filter_func(self, _family, face): """ Filter function to display only regular fonts. """ desc = face.describe() stretch = desc.get_stretch() if stretch != Pango.Stretch.NORMAL: return False # avoid Condensed or Expanded sty = desc.get_style() if sty != Pango.Style.NORMAL: return False # avoid italic etc. weight = desc.get_weight() if weight != Pango.Weight.NORMAL: return False # avoid Bold return True #------------------------------------------------------------------------- # # Printing functionalities # #------------------------------------------------------------------------- def printview(self, *obj): """ Save the dot file for a later printing with an appropriate tool. """ # ask for the dot file name filter1 = Gtk.FileFilter() filter1.set_name("dot files") filter1.add_pattern("*.gv") dot = Gtk.FileChooserDialog(title=_("Select a dot file name"), action=Gtk.FileChooserAction.SAVE, transient_for=self.uistate.window) dot.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL) dot.add_button(_('_Apply'), Gtk.ResponseType.OK) mpath = config.get('paths.report-directory') dot.set_current_folder(os.path.dirname(mpath)) dot.set_filter(filter1) dot.set_current_name("Graphview.gv") status = dot.run() if status == Gtk.ResponseType.OK: val = dot.get_filename() (spath, _ext) = os.path.splitext(val) val = spath + ".gv" # used to avoid filename without extension # selected path is an existing file and we need a file if os.path.isfile(val): aaa = OptionDialog(_('File already exists'), # parent-OK _('You can choose to either overwrite the ' 'file, or change the selected filename.'), _('_Overwrite'), None, _('_Change filename'), None, parent=dot) if aaa.get_response() == Gtk.ResponseType.YES: dot.destroy() self.printview(obj) return svg = val.replace('.gv', '.svg') # both dot_data and svg_data are bytes, already utf-8 encoded # just write them as binary try: with open(val, 'wb') as __g, open(svg, 'wb') as __s: __g.write(self.graph_widget.dot_data) __s.write(self.graph_widget.svg_data) except IOError as msg: msg2 = _("Could not create %s") % (val + ', ' + svg) ErrorDialog(msg2, str(msg), parent=dot) dot.destroy() #------------------------------------------------------------------------- # # GraphWidget # #------------------------------------------------------------------------- class GraphWidget(object): """ Define the widget with controls and canvas that displays the graph. """ def __init__(self, view, dbstate, uistate): """ :type view: GraphView """ # variables for drag and scroll self._last_x = 0 self._last_y = 0 self._in_move = False self.view = view self.dbstate = dbstate self.uistate = uistate self.parser = None self.active_person_handle = None self.actions = Actions(dbstate, uistate, self.view.bookmarks) self.actions.connect('rebuild-graph', self.view.build_tree) self.actions.connect('active-changed', self.populate) self.actions.connect('focus-person-changed', self.set_person_to_focus) self.actions.connect('path-to-home-person', self.populate) self.dot_data = None self.svg_data = None scrolled_win = Gtk.ScrolledWindow() scrolled_win.set_shadow_type(Gtk.ShadowType.IN) self.hadjustment = scrolled_win.get_hadjustment() self.vadjustment = scrolled_win.get_vadjustment() self.canvas = GooCanvas.Canvas() self.canvas.connect("scroll-event", self.scroll_mouse) self.canvas.props.units = Gtk.Unit.POINTS self.canvas.props.resolution_x = 72 self.canvas.props.resolution_y = 72 scrolled_win.add(self.canvas) self.vbox = Gtk.Box(homogeneous=False, spacing=4, orientation=Gtk.Orientation.VERTICAL) self.vbox.set_border_width(4) self.toolbar = Gtk.Box(homogeneous=False, spacing=4, orientation=Gtk.Orientation.HORIZONTAL) self.vbox.pack_start(self.toolbar, False, False, 0) # add zoom-in button self.zoom_in_btn = Gtk.Button.new_from_icon_name('zoom-in', Gtk.IconSize.MENU) self.zoom_in_btn.set_tooltip_text(_('Zoom in')) self.toolbar.pack_start(self.zoom_in_btn, False, False, 1) self.zoom_in_btn.connect("clicked", self.zoom_in) # add zoom-out button self.zoom_out_btn = Gtk.Button.new_from_icon_name('zoom-out', Gtk.IconSize.MENU) self.zoom_out_btn.set_tooltip_text(_('Zoom out')) self.toolbar.pack_start(self.zoom_out_btn, False, False, 1) self.zoom_out_btn.connect("clicked", self.zoom_out) # add original zoom button self.orig_zoom_btn = Gtk.Button.new_from_icon_name('zoom-original', Gtk.IconSize.MENU) self.orig_zoom_btn.set_tooltip_text(_('Zoom to original')) self.toolbar.pack_start(self.orig_zoom_btn, False, False, 1) self.orig_zoom_btn.connect("clicked", self.set_original_zoom) # add best fit button self.fit_btn = Gtk.Button.new_from_icon_name('zoom-fit-best', Gtk.IconSize.MENU) self.fit_btn.set_tooltip_text(_('Zoom to best fit')) self.toolbar.pack_start(self.fit_btn, False, False, 1) self.fit_btn.connect("clicked", self.fit_to_page) # add 'go to active person' button self.goto_active_btn = Gtk.Button.new_from_icon_name('go-jump', Gtk.IconSize.MENU) self.goto_active_btn.set_tooltip_text(_('Go to active person')) self.toolbar.pack_start(self.goto_active_btn, False, False, 1) self.goto_active_btn.connect("clicked", self.goto_active) # add 'go to bookmark' button self.goto_other_btn = Gtk.Button(label=_('Go to bookmark')) self.goto_other_btn.set_tooltip_text( _('Center view on selected bookmark')) self.toolbar.pack_start(self.goto_other_btn, False, False, 1) self.bkmark_popover = Popover(_('Bookmarks for current graph'), _('Other Bookmarks'), ext_panel=self.build_bkmark_ext_panel()) self.bkmark_popover.set_relative_to(self.goto_other_btn) self.goto_other_btn.connect("clicked", self.show_bkmark_popup) self.goto_other_btn.connect("key-press-event", self.goto_other_btn_key_press_event) self.bkmark_popover.connect('item-activated', self.activate_popover) self.show_images_option = self.view._config.get( 'interface.graphview-search-show-images') # add search widget self.search_widget = SearchWidget(self.dbstate, self.get_person_image, bookmarks=self.view.bookmarks) search_box = self.search_widget.get_widget() self.toolbar.pack_start(search_box, True, True, 1) self.search_widget.set_options( search_all_db=self.view._config.get( 'interface.graphview-search-all-db'), show_images=self.show_images_option) self.search_widget.connect('item-activated', self.activate_popover) # Keep references to toolbar spinners so their visible # values can be updated after Load profile... changes config values. # Must be created before build_spinner() is called below. self.config_spinners = {} self.updating_profile_controls = False # add accelerator to focus search entry accel_group = Gtk.AccelGroup() self.uistate.window.add_accel_group(accel_group) search_box.add_accelerator('grab-focus', accel_group, Gdk.KEY_f, Gdk.ModifierType.CONTROL_MASK, Gtk.AccelFlags.VISIBLE) # add spinners for quick generations change gen_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) box = self.build_spinner('go-up-symbolic', 0, 50, _('Ancestor generations'), 'interface.graphview-ancestor-generations') gen_box.add(box) box = self.build_spinner('go-down-symbolic', 0, 50, _('Descendant generations'), 'interface.graphview-descendant-generations') gen_box.add(box) # pack generation spinners to popover gen_btn = Gtk.Button(label=_('Generations')) self.add_popover(gen_btn, gen_box) self.toolbar.pack_start(gen_btn, False, False, 1) # add spiner for generation (vertical) spacing spacing_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) box = self.build_spinner('object-flip-vertical', 1, 50, _('Vertical spacing between generations'), 'interface.graphview-ranksep') spacing_box.add(box) # add spiner for node (horizontal) spacing box = self.build_spinner('object-flip-horizontal', 1, 50, _('Horizontal spacing between generations'), 'interface.graphview-nodesep') spacing_box.add(box) # pack spacing spinners to popover spacing_btn = Gtk.Button(label=_('Spacings')) self.add_popover(spacing_btn, spacing_box) self.toolbar.pack_start(spacing_btn, False, False, 1) # add button to show all connected persons self.all_connected_btn = Gtk.ToggleButton(label=_('All connected')) self.all_connected_btn.set_tooltip_text( _("Show all connected persons limited by generation restrictions.\n" "Works slow, so don't set large generation values.")) self.all_connected_btn.set_active( self.view._config.get('interface.graphview-show-all-connected')) self.all_connected_btn.connect('clicked', self.toggle_all_connected) self.toolbar.pack_start(self.all_connected_btn, False, False, 1) self.vbox.pack_start(scrolled_win, True, True, 0) # if we have graph lager than graphviz paper size # this coef is needed self.transform_scale = 1 self.scale = self.view._config.get('interface.graphview-scale') self.animation = CanvasAnimation(self.view, self.canvas, scrolled_win) self.search_widget.set_items_list(self.animation.items_list) # person that will focus (once) after graph rebuilding self.person_to_focus = None # for detecting double click self.click_events = [] # for timeout on changing settings by spinners self.timeout_event = False # Gtk style context for scrollwindow to operate with theme colors self.sw_style_context = scrolled_win.get_style_context() # used for popup menu, prevent destroy menu as local variable self.menu = None self.retest_font = True # flag indicates need to resize font self.bold_size = self.norm_size = 0 # font sizes to send to dot def add_popover(self, widget, container): """ Add popover for button. """ popover = Gtk.Popover() popover.set_relative_to(widget) popover.add(container) widget.connect("clicked", self.spinners_popup, popover) container.show_all() def build_spinner(self, icon, start, end, tooltip, conf_const): """ Build spinner with icon and pack it into box. Chenges apply to config with delay. """ box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) img = Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.MENU) box.pack_start(img, False, False, 1) spinner = Gtk.SpinButton.new_with_range(start, end, 1) spinner.set_tooltip_text(tooltip) spinner.set_value(self.view._config.get(conf_const)) spinner.connect("value-changed", self.apply_spinner_delayed, conf_const) self.config_spinners[conf_const] = spinner box.pack_start(spinner, False, False, 1) return box def toggle_all_connected(self, widget): """ Change state for "Show all connected" setting. """ self.view._config.set('interface.graphview-show-all-connected', widget.get_active()) def spinners_popup(self, _widget, popover): """ Popover for generations and spacing params. Different popup depending on gtk version. """ if gtk_version >= 3.22: popover.popup() else: popover.show() def set_available(self, state): """ Set state for GraphView. """ if not state: # if no database is opened self.clear() self.toolbar.set_sensitive(state) def font_changed(self, active): self.sym_font = config.get('utf8.selected-font') if self.parser: self.parser.font_changed() self.populate(active) def set_person_to_focus(self, handle): """ Set person that will focus (once) after graph rebuilding. """ self.person_to_focus = handle def goto_other_btn_key_press_event(self, _widget, event): """ Handle 'Esc' key on bookmarks button to hide popup. """ key = event.keyval if event.keyval == Gdk.KEY_Escape: self.hide_bkmark_popover() elif key == Gdk.KEY_Down: self.bkmark_popover.grab_focus() return True def activate_popover(self, _widget, person_handle): """ Called when some item(person) in search or bookmarks popup(popover) is activated. """ self.hide_bkmark_popover() self.search_widget.hide_search_popover() # move view to person with animation self.move_to_person(None, person_handle, True) def apply_spinner_delayed(self, widget, conf_const): """ Set params by spinners (generations, spacing). Use timeout for better interface responsiveness. """ if getattr(self, 'updating_profile_controls', False): return value = int(widget.get_value()) # try to remove planed event (changing setting) if self.timeout_event and \ not self.timeout_event.is_destroyed(): GLib.source_remove(self.timeout_event.get_id()) # timeout saving setting for better interface responsiveness event_id = GLib.timeout_add(300, self.view._config.set, conf_const, value) context = GLib.main_context_default() self.timeout_event = context.find_source_by_id(event_id) def cancel_pending_spinner_update(self): """ Cancel a delayed Generations/Spacings write that has not run yet. The toolbar spin buttons save through one shared 300 ms timeout. Temp restore must cancel it before applying the opening snapshot, otherwise the delayed callback can overwrite the restored value. """ timeout_event = getattr(self, 'timeout_event', False) if timeout_event and not timeout_event.is_destroyed(): GLib.source_remove(timeout_event.get_id()) self.timeout_event = False def sync_profile_controls(self): """ Update toolbar controls after profile values have been loaded. The checks make startup safe if Graph View is not fully built yet. """ if not hasattr(self, 'config_spinners'): return False self.updating_profile_controls = True try: for config_key, spinner in self.config_spinners.items(): if spinner is not None: spinner.set_value(self.view._config.get(config_key)) all_connected_btn = getattr(self, 'all_connected_btn', None) if all_connected_btn is not None: all_connected_btn.set_active( self.view._config.get( 'interface.graphview-show-all-connected')) finally: self.updating_profile_controls = False return False def restore_profile_view_state(self, view_state): """ Restore optional view state after the graph has been rebuilt. This is intentionally separate from graph/profile content settings. """ if not isinstance(view_state, dict): return False scale = view_state.get('interface.graphview-scale') if scale is not None: try: self.set_zoom(float(scale)) except (TypeError, ValueError): pass h_value = view_state.get('horizontal_adjustment_value') v_value = view_state.get('vertical_adjustment_value') def clamp_adjustment(adjustment, value): if value is None: return try: value = float(value) except (TypeError, ValueError): return lower = adjustment.get_lower() upper = adjustment.get_upper() - adjustment.get_page_size() if upper < lower: upper = lower adjustment.set_value(max(lower, min(value, upper))) clamp_adjustment(self.hadjustment, h_value) clamp_adjustment(self.vadjustment, v_value) return False def build_bkmark_ext_panel(self): """ Build bookmark popover extand panel. """ btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) # add button to add active person to bookmarks # tooltip will be changed in "self.load_bookmarks" self.add_bkmark = Gtk.Button(label=_('Add active person')) self.add_bkmark.connect("clicked", self.add_active_to_bkmarks) btn_box.pack_start(self.add_bkmark, True, True, 2) # add buton to call bookmarks manager manage_bkmarks = Gtk.Button(label=_('Edit')) manage_bkmarks.set_tooltip_text(_('Call the bookmark editor')) manage_bkmarks.connect("clicked", self.edit_bookmarks) btn_box.pack_start(manage_bkmarks, True, True, 2) return btn_box def load_bookmarks(self): """ Load bookmarks in Popover (goto_other_btn). """ # remove all old items from popup self.bkmark_popover.clear_items() active = self.view.get_active() active_in_bkmarks = False found = False found_other = False count = 0 count_other = 0 bookmarks = self.view.bookmarks.get_bookmarks().bookmarks for bkmark in bookmarks: if active == bkmark: active_in_bkmarks = True person = self.dbstate.db.get_person_from_handle(bkmark) if person: name = displayer.display_name(person.get_primary_name()) present = self.animation.get_item_by_title(bkmark) hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) # add person ID label = Gtk.Label("[%s]" % person.gramps_id, xalign=0) hbox.pack_start(label, False, False, 2) # add person name label = Gtk.Label(name, xalign=0) hbox.pack_start(label, True, True, 2) # add person image if needed if self.show_images_option: person_image = self.get_person_image(person, 32, 32) if person_image: hbox.pack_start(person_image, False, True, 2) row = ListBoxRow(person_handle=bkmark, label=name, db=self.dbstate.db) row.add(hbox) if present is not None: found = True count += 1 self.bkmark_popover.main_panel.add_to_panel(row) else: found_other = True count_other += 1 self.bkmark_popover.other_panel.add_to_panel(row) row.show_all() if not found and not found_other: self.bkmark_popover.show_other_panel(False) row = ListBoxRow() row.add(Gtk.Label(_("You don't have any bookmarks yet...\n" "Try to add some frequently used persons " "to speedup navigation."))) self.bkmark_popover.main_panel.add_to_panel(row) row.show_all() else: if not found: row = ListBoxRow() row.add(Gtk.Label(_('No bookmarks for this graph...'))) self.bkmark_popover.main_panel.add_to_panel(row) row.show_all() if not found_other: row = ListBoxRow() row.add(Gtk.Label(_('No other bookmarks...'))) self.bkmark_popover.other_panel.add_to_panel(row) row.show_all() self.bkmark_popover.show_other_panel(True) self.bkmark_popover.main_panel.set_progress(0, _('found: %s') % count) self.bkmark_popover.other_panel.set_progress( 0, _('found: %s') % count_other) # set tooltip for "add_bkmark" button self.add_bkmark.hide() if active and not active_in_bkmarks: person = self.dbstate.db.get_person_from_handle(active) if person: name = displayer.display_name(person.get_primary_name()) val_to_display = "[%s] %s" % (person.gramps_id, name) self.add_bkmark.set_tooltip_text( _('Add active person to bookmarks\n' '%s') % val_to_display) self.add_bkmark.show() def get_person_image(self, person, width=-1, height=-1, kind='image'): """ kind - 'image', 'path', 'both' Returns default person image and path or None. """ # see if we have an image to use for this person image_path = None media_list = person.get_media_list() if media_list: media_handle = media_list[0].get_reference_handle() media = self.dbstate.db.get_media_from_handle(media_handle) media_mime_type = media.get_mime_type() if media_mime_type[0:5] == "image": rectangle = media_list[0].get_rectangle() path = media_path_full(self.dbstate.db, media.get_path()) image_path = get_thumbnail_path(path, rectangle=rectangle) # test if thumbnail actually exists in thumbs # (import of data means media files might not be present image_path = find_file(image_path) if image_path: if kind == 'path': return image_path # get and scale image person_image = GdkPixbuf.Pixbuf.new_from_file_at_scale( filename=image_path, width=width, height=height, preserve_aspect_ratio=True) person_image = Gtk.Image.new_from_pixbuf(person_image) if kind == 'image': return person_image elif kind == 'both': return person_image, image_path return None def add_active_to_bkmarks(self, _widget): """ Add active person to bookmarks. """ self.view.add_bookmark(None) self.load_bookmarks() def edit_bookmarks(self, _widget): """ Call the bookmark editor. """ self.view.edit_bookmarks(None) self.load_bookmarks() def show_bkmark_popup(self, _widget): """ Show bookmark popup. """ self.load_bookmarks() self.bkmark_popover.popup() def hide_bkmark_popover(self, _widget=None, _event=None): """ Hide bookmark popup. """ self.bkmark_popover.popdown() def goto_active(self, button=None): """ Go to active person. """ # check if animation is needed animation = bool(button) self.animation.move_to_person(self.active_person_handle, animation) def move_to_person(self, _menuitem, handle, animate=False): """ Move to specified person (by handle). If person not present in the current graphview tree, show dialog to change active person. """ self.person_to_focus = None if self.animation.get_item_by_title(handle): self.animation.move_to_person(handle, animate) else: person = self.dbstate.db.get_person_from_handle(handle) if not person: return False quest = (_('Person %s is not in the current view.\n' 'Do you want to set it active and rebuild view?') % escape(displayer.display(person))) dialog = QuestionDialog2(_("Change active person?"), quest, _("Yes"), _("No"), self.uistate.window) if dialog.run(): self.view.change_active(handle) def scroll_mouse(self, _canvas, event): """ Zoom by mouse wheel. """ if event.direction == Gdk.ScrollDirection.UP: self.zoom_in() elif event.direction == Gdk.ScrollDirection.DOWN: self.zoom_out() # stop the signal of scroll emission # to prevent window scrolling return True def populate(self, active_person, path_to_home_person=False): """ Populate the graph with widgets derived from Graphviz. """ # Config callbacks and active-person signals may request # many rebuilds while a profile is being applied. Ignore all of those; # the outer profile function performs one final populate when ready. if getattr(self.view, '_profile_graph_refresh_depth', 0) > 0: return # This is a final passive safety net. Ordinary # book opening must never warn because of a value left by another # book. Manual activation and profile loading still show their own # clear warning when ProfileTag is missing. if self.view.turn_off_family_tag_filter_for_missing_tag( show_warning=False): pass # set the busy cursor, so the user knows that we are working self.uistate.set_busy_cursor(True) if self.uistate.window.get_window().is_visible(): process_pending_events() self.clear() self.active_person_handle = active_person # fit the text to boxes self.bold_size, self.norm_size = self.fit_text() self.search_widget.hide_search_popover() self.hide_bkmark_popover() # generate DOT and SVG data dot = DotSvgGenerator(self.dbstate, self.view, bold_size=self.bold_size, norm_size=self.norm_size) graph_data = dot.build_graph(active_person, path_to_home_person) del dot if not graph_data: # something go wrong when build all-connected tree # so turn off this feature self.view._config.set('interface.graphview-show-all-connected', False) return self.dot_data = graph_data[0] self.svg_data = graph_data[1] parser = GraphvizSvgParser(self, self.view) parser.parse(self.svg_data) self.animation.update_items(parser.items_list) # save transform scale self.transform_scale = parser.transform_scale self.set_zoom(self.scale) # focus on edited person if posible if not self.animation.move_to_person(self.person_to_focus, False): self.goto_active() self.person_to_focus = None # update the status bar self.view.change_page() self.uistate.set_busy_cursor(False) def zoom_in(self, _button=None): """ Increase zoom scale. """ scale_coef = self.scale * 1.1 self.set_zoom(scale_coef) def zoom_out(self, _button=None): """ Decrease zoom scale. """ scale_coef = self.scale * 0.9 if scale_coef < 0.01: scale_coef = 0.01 self.set_zoom(scale_coef) def set_original_zoom(self, _button): """ Set original zoom scale = 1. """ self.set_zoom(1) def fit_to_page(self, _button): """ Calculate scale and fit tree to page. """ # get the canvas size bounds = self.canvas.get_root_item().get_bounds() height_canvas = bounds.y2 - bounds.y1 width_canvas = bounds.x2 - bounds.x1 # get scroll window size width = self.hadjustment.get_page_size() height = self.vadjustment.get_page_size() # prevent division by zero if height_canvas == 0: height_canvas = 1 if width_canvas == 0: width_canvas = 1 # calculate minimum scale scale_h = (height / height_canvas) scale_w = (width / width_canvas) if scale_h > scale_w: scale = scale_w else: scale = scale_h scale = scale * self.transform_scale # set scale if it needed, else restore it to default if scale < 1: self.set_zoom(scale) else: self.set_zoom(1) def clear(self): """ Clear the graph by creating a new root item. """ # remove root item (with all children) self.canvas.get_root_item().remove() self.canvas.set_root_item(GooCanvas.CanvasGroup()) def get_widget(self): """ Return the graph display widget that includes the drawing canvas. """ return self.vbox def button_press(self, item, _target, event): """ Enter in scroll mode when left or middle mouse button pressed on background. """ self.search_widget.hide_search_popover() self.hide_bkmark_popover() if not (event.type == getattr(Gdk.EventType, "BUTTON_PRESS") and item == self.canvas.get_root_item()): return False button = event.get_button()[1] if button == 1 or button == 2: window = self.canvas.get_parent().get_window() window.set_cursor(Gdk.Cursor.new(Gdk.CursorType.FLEUR)) self._last_x = event.x_root self._last_y = event.y_root self._in_move = True self.animation.stop_animation() return False if button == 3: self.menu = PopupMenu(self, kind='background') self.menu.show_menu(event) return True return False def button_release(self, item, target, event): """ Exit from scroll mode when button release. """ button = event.get_button()[1] if((button == 1 or button == 2) and event.type == getattr(Gdk.EventType, "BUTTON_RELEASE")): self.motion_notify_event(item, target, event) self.canvas.get_parent().get_window().set_cursor(None) self._in_move = False return True return False def motion_notify_event(self, _item, _target, event): """ Function for motion notify events for drag and scroll mode. """ if self._in_move and (event.type == Gdk.EventType.MOTION_NOTIFY or event.type == Gdk.EventType.BUTTON_RELEASE): # scale coefficient for prevent flicking when drag scale_coef = self.canvas.get_scale() new_x = (self.hadjustment.get_value() - (event.x_root - self._last_x) * scale_coef) self.hadjustment.set_value(new_x) new_y = (self.vadjustment.get_value() - (event.y_root - self._last_y) * scale_coef) self.vadjustment.set_value(new_y) return True return False def set_zoom(self, value): """ Set value for zoom of the canvas widget and apply it. """ self.scale = value self.view._config.set('interface.graphview-scale', value) self.canvas.set_scale(value / self.transform_scale) def select_node(self, item, target, event): """ Perform actions when a node is clicked. If middle mouse was clicked then try to set scroll mode. """ self.search_widget.hide_search_popover() self.hide_bkmark_popover() handle = item.title node_class = item.description button = event.get_button()[1] self.person_to_focus = None # perform double click on node by left mouse button if event.type == getattr(Gdk.EventType, "DOUBLE_BUTTON_PRESS"): # Remove all single click events for click_item in self.click_events: if not click_item.is_destroyed(): GLib.source_remove(click_item.get_id()) self.click_events.clear() if button == 1 and node_class == 'node': GLib.idle_add(self.actions.edit_person, None, handle) return True elif button == 1 and node_class == 'familynode': GLib.idle_add(self.actions.edit_family, None, handle) return True if event.type != getattr(Gdk.EventType, "BUTTON_PRESS"): return False if button == 1 and node_class == 'node': # left mouse if handle == self.active_person_handle: # Find a parent of the active person so that they can become # the active person, if no parents then leave as the current # active person parent_handle = self.find_a_parent(handle) if parent_handle: handle = parent_handle else: return True # redraw the graph based on the selected person # schedule after because double click can occur click_event_id = GLib.timeout_add(200, self.view.change_active, handle) # add single click events to list, it will be removed if necessary context = GLib.main_context_default() self.click_events.append(context.find_source_by_id(click_event_id)) elif button == 3 and node_class: # right mouse if node_class == 'node': self.menu = PopupMenu(self, 'person', handle) self.menu.show_menu(event) elif node_class == 'familynode': self.menu = PopupMenu(self, 'family', handle) self.menu.show_menu(event) elif button == 2: # middle mouse # to enter in scroll mode (we should change "item" to root item) item = self.canvas.get_root_item() self.button_press(item, target, event) return True def find_a_parent(self, handle): """ Locate a parent from the first family that the selected person is a child of. Try and find the father first, then the mother. Either will be OK. """ person = self.dbstate.db.get_person_from_handle(handle) try: fam_handle = person.get_parent_family_handle_list()[0] if fam_handle: family = self.dbstate.db.get_family_from_handle(fam_handle) if family and family.get_father_handle(): handle = family.get_father_handle() elif family and family.get_mother_handle(): handle = family.get_mother_handle() except IndexError: handle = None return handle def update_lines_type(self, _menu_item, lines_type, constant): """ Save the lines type setting. """ self.view._config.set(constant, lines_type) def update_setting(self, menu_item, constant): """ Save changed setting. menu_item should be Gtk.CheckMenuItem. """ value = menu_item.get_active() if (constant == 'interface.graphview-filter-family-tag' and value and not self.view.family_tag_filter_value_is_allowed(True)): menu_item.set_active(False) value = False self.view._config.set(constant, value) def fit_text(self): """ Fit the text to the boxes more exactly. Works by trying some sample text, measuring the results, and trying an increasing size of font sizes to some sample nodes to see which one will fit the expected text size. In other words we are telling dot to use different font sizes than we are actually displaying, since dot doesn't do a good job of determining the text size. """ if not self.retest_font: # skip this uless font changed. return self.bold_size, self.norm_size text = "The quick Brown Fox jumped over the Lazy Dogs 1948-01-01." dot_test = DotSvgGenerator(self.dbstate, self.view) dot_test.init_dot() # These are at the desired font sizes. dot_test.add_node('test_bold', '%s' % text, shape='box') dot_test.add_node('test_norm', text, shape='box') # now add nodes at increasing font sizes for scale in range(35, 140, 2): f_size = dot_test.fontsize * scale / 100.0 dot_test.add_node( 'test_bold' + str(scale), '%(text)s' % {'text': text, 'bsize': f_size}, shape='box') dot_test.add_node( 'test_norm' + str(scale), text, shape='box', fontsize=("%3.1f" % f_size)) # close the graphviz dot code with a brace dot_test.write('}\n') # get DOT and generate SVG data by Graphviz dot_data = dot_test.dot.getvalue().encode('utf8') svg_data = dot_test.make_svg(dot_data) svg_data = svg_data.decode('utf8') # now lest find the box sizes, and font sizes for the generated svg. points_a = findall(r'points="(.*)"', svg_data, MULTILINE) font_fams = findall(r'font-family="(.*)" font-weight', svg_data, MULTILINE) font_sizes = findall(r'font-size="(.*)" fill', svg_data, MULTILINE) box_w = [] for points in points_a: box_pts = points.split() x_1 = box_pts[0].split(',')[0] x_2 = box_pts[1].split(',')[0] box_w.append(float(x_1) - float(x_2) - 16) # adjust for margins text_font = font_fams[0] + ", " + font_sizes[0] + 'px' font_desc = Pango.FontDescription.from_string(text_font) # lets measure the bold text on our canvas at desired font size c_text = GooCanvas.CanvasText(parent=self.canvas.get_root_item(), text='' + text + '', x=100, y=100, anchor=GooCanvas.CanvasAnchorType.WEST, use_markup=True, font_desc=font_desc) bold_b = c_text.get_bounds() # and measure the normal text on our canvas at desired font size c_text.props.text = text norm_b = c_text.get_bounds() # now scan throught test boxes, finding the smallest that will hold # the actual text as measured. And record the dot font that was used. for indx in range(3, len(font_sizes), 2): bold_size = float(font_sizes[indx - 1]) if box_w[indx] > bold_b.x2 - bold_b.x1: break for indx in range(4, len(font_sizes), 2): norm_size = float(font_sizes[indx - 1]) if box_w[indx] > norm_b.x2 - norm_b.x1: break self.retest_font = False # we don't do this again until font changes # return the adjusted font size to tell dot to use. return bold_size, norm_size #------------------------------------------------------------------------- # # GraphvizSvgParser # #------------------------------------------------------------------------- class GraphvizSvgParser(object): """ Parses SVG produces by Graphviz and adds the elements to a GooCanvas. """ def __init__(self, widget, view): """ Initialise the GraphvizSvgParser class. """ self.func = None self.widget = widget self.canvas = widget.canvas self.view = view self.highlight_home_person = self.view._config.get( 'interface.graphview-highlight-home-person') scheme = config.get('colors.scheme') self.home_person_color = config.get('colors.home-person')[scheme] self.font_size = self.view._config.get('interface.graphview-font')[1] self.active_person_border_size = self.view._config.get( 'interface.graphview-active-person-border-size') self.person_border_size = self.view._config.get( 'interface.graphview-person-border-size') self.tlist = [] self.text_attrs = None self.func_list = [] self.handle = None self.func_map = {"g": (self.start_g, self.stop_g), "svg": (self.start_svg, self.stop_svg), "polygon": (self.start_polygon, self.stop_polygon), "path": (self.start_path, self.stop_path), "image": (self.start_image, self.stop_image), "text": (self.start_text, self.stop_text), "ellipse": (self.start_ellipse, self.stop_ellipse), "title": (self.start_title, self.stop_title)} self.text_anchor_map = {"start": GooCanvas.CanvasAnchorType.WEST, "middle": GooCanvas.CanvasAnchorType.CENTER, "end": GooCanvas.CanvasAnchorType.EAST} # This list is used as a LIFO stack so that the SAX parser knows # which Goocanvas object to link the next object to. self.item_hier = [] # list of persons items, used for animation class self.items_list = [] self.transform_scale = 1 def parse(self, ifile): """ Parse an SVG file produced by Graphviz. """ self.item_hier.append(self.canvas.get_root_item()) parser = ParserCreate() parser.StartElementHandler = self.start_element parser.EndElementHandler = self.end_element parser.CharacterDataHandler = self.characters parser.Parse(ifile) for key in list(self.func_map.keys()): del self.func_map[key] del self.func_map del self.func_list del parser def start_g(self, attrs): """ Parse tags. """ # The class attribute defines the group type. There should be one # graph type tag which defines the transform for the whole graph. if attrs.get('class') == 'graph': self.items_list.clear() transform = attrs.get('transform') item = self.canvas.get_root_item() transform_list = transform.split(') ') scale = transform_list[0].split() scale_x = float(scale[0].lstrip('scale(')) scale_y = float(scale[1]) self.transform_scale = scale_x if scale_x > scale_y: self.transform_scale = scale_y # scale should be (0..1) # fix graphviz issue from version > 2.40.1 if self.transform_scale > 1: self.transform_scale = 1 / self.transform_scale item.set_simple_transform(self.bounds[1], self.bounds[3], self.transform_scale, 0) item.connect("button-press-event", self.widget.button_press) item.connect("button-release-event", self.widget.button_release) item.connect("motion-notify-event", self.widget.motion_notify_event) else: item = GooCanvas.CanvasGroup(parent=self.current_parent()) item.connect("button-press-event", self.widget.select_node) self.items_list.append(item) item.description = attrs.get('class') self.item_hier.append(item) def stop_g(self, _tag): """ Parse tags. """ item = self.item_hier.pop() item.title = self.handle def start_svg(self, attrs): """ Parse tags. """ GooCanvas.CanvasGroup(parent=self.current_parent()) view_box = attrs.get('viewBox').split() v_left = float(view_box[0]) v_top = float(view_box[1]) v_right = float(view_box[2]) v_bottom = float(view_box[3]) self.canvas.set_bounds(v_left, v_top, v_right, v_bottom) self.bounds = (v_left, v_top, v_right, v_bottom) def stop_svg(self, tag): """ Parse tags. """ pass def start_title(self, attrs): """ Parse tags. """ pass def stop_title(self, tag): """ Parse tags. Stripping off underscore prefix added to fool Graphviz. """ self.handle = tag.lstrip("_") def start_polygon(self, attrs): """ Parse tags. Polygons define the boxes around individuals on the graph. """ coord_string = attrs.get('points') coord_count = 5 points = GooCanvas.CanvasPoints.new(coord_count) nnn = 0 for i in coord_string.split(): coord = i.split(",") coord_x = float(coord[0]) coord_y = float(coord[1]) points.set_point(nnn, coord_x, coord_y) nnn += 1 style = attrs.get('style') if style: p_style = self.parse_style(style) stroke_color = p_style['stroke'] fill_color = p_style['fill'] else: stroke_color = attrs.get('stroke') fill_color = attrs.get('fill') if self.handle == self.widget.active_person_handle: line_width = self.active_person_border_size else: line_width = self.person_border_size tooltip = self.view.tags_tooltips.get(self.handle) # highlight the home person # stroke_color is not '#...' when tags are drawing, so we check this # maybe this is not good solution to check for tags but it works if self.highlight_home_person and stroke_color[:1] == '#': home_person = self.widget.dbstate.db.get_default_person() if home_person and home_person.handle == self.handle: fill_color = self.home_person_color item = GooCanvas.CanvasPolyline(parent=self.current_parent(), points=points, close_path=True, fill_color=fill_color, line_width=line_width, stroke_color=stroke_color, tooltip=tooltip) # turn on tooltip show if have it if tooltip: item_canvas = item.get_canvas() item_canvas.set_has_tooltip(True) self.item_hier.append(item) def stop_polygon(self, _tag): """ Parse tags. """ self.item_hier.pop() def start_ellipse(self, attrs): """ Parse tags. These define the family nodes of the graph. """ center_x = float(attrs.get('cx')) center_y = float(attrs.get('cy')) radius_x = float(attrs.get('rx')) radius_y = float(attrs.get('ry')) style = attrs.get('style') if style: p_style = self.parse_style(style) stroke_color = p_style['stroke'] fill_color = p_style['fill'] else: stroke_color = attrs.get('stroke') fill_color = attrs.get('fill') tooltip = self.view.tags_tooltips.get(self.handle) item = GooCanvas.CanvasEllipse(parent=self.current_parent(), center_x=center_x, center_y=center_y, radius_x=radius_x, radius_y=radius_y, fill_color=fill_color, stroke_color=stroke_color, line_width=1, tooltip=tooltip) if tooltip: item_canvas = item.get_canvas() item_canvas.set_has_tooltip(True) self.current_parent().description = 'familynode' self.item_hier.append(item) def stop_ellipse(self, _tag): """ Parse tags. """ self.item_hier.pop() def start_path(self, attrs): """ Parse tags. These define the links between nodes. Solid lines represent birth relationships and dashed lines are used when a child has a non-birth relationship to a parent. """ p_data = attrs.get('d') line_width = attrs.get('stroke-width') if line_width is None: line_width = 1 line_width = float(line_width) style = attrs.get('style') if style: p_style = self.parse_style(style) stroke_color = p_style['stroke'] is_dashed = 'stroke-dasharray' in p_style else: stroke_color = attrs.get('stroke') is_dashed = attrs.get('stroke-dasharray') if is_dashed: line_dash = GooCanvas.CanvasLineDash.newv([5.0, 5.0]) item = GooCanvas.CanvasPath(parent=self.current_parent(), data=p_data, stroke_color=stroke_color, line_width=line_width, line_dash=line_dash) else: item = GooCanvas.CanvasPath(parent=self.current_parent(), data=p_data, stroke_color=stroke_color, line_width=line_width) self.item_hier.append(item) def stop_path(self, _tag): """ Parse tags. """ self.item_hier.pop() def start_text(self, attrs): """ Parse tags. """ self.text_attrs = attrs def stop_text(self, tag): """ Parse tags. The text tag contains some textual data. """ tag = escape(tag) pos_x = float(self.text_attrs.get('x')) pos_y = float(self.text_attrs.get('y')) anchor = self.text_attrs.get('text-anchor') style = self.text_attrs.get('style') # does the following always work with symbols? if style: p_style = self.parse_style(style) font_family = p_style['font-family'] text_font = font_family + ", " + p_style['font-size'] + 'px' else: font_family = self.text_attrs.get('font-family') text_font = font_family + ", " + str(self.font_size) + 'px' font_desc = Pango.FontDescription.from_string(text_font) # set bold text using PangoMarkup if self.text_attrs.get('font-weight') == 'bold': tag = '%s' % tag # text color fill_color = self.text_attrs.get('fill') GooCanvas.CanvasText(parent=self.current_parent(), text=tag, x=pos_x, y=pos_y, anchor=self.text_anchor_map[anchor], use_markup=True, font_desc=font_desc, fill_color=fill_color) def start_image(self, attrs): """ Parse tags. """ pos_x = float(attrs.get('x')) pos_y = float(attrs.get('y')) width = float(attrs.get('width').rstrip(string.ascii_letters)) height = float(attrs.get('height').rstrip(string.ascii_letters)) pixbuf = GdkPixbuf.Pixbuf.new_from_file(attrs.get('xlink:href')) item = GooCanvas.CanvasImage(parent=self.current_parent(), x=pos_x, y=pos_y, height=height, width=width, pixbuf=pixbuf) self.item_hier.append(item) def stop_image(self, _tag): """ Parse tags. """ self.item_hier.pop() def start_element(self, tag, attrs): """ Generic parsing function for opening tags. """ self.func_list.append((self.func, self.tlist)) self.tlist = [] try: start_function, self.func = self.func_map[tag] if start_function: start_function(attrs) except KeyError: self.func_map[tag] = (None, None) self.func = None def end_element(self, _tag): """ Generic parsing function for closing tags. """ if self.func: self.func(''.join(self.tlist)) self.func, self.tlist = self.func_list.pop() def characters(self, data): """ Generic parsing function for tag data. """ if self.func: self.tlist.append(data) def current_parent(self): """ Returns the Goocanvas object which should be the parent of any new Goocanvas objects. """ return self.item_hier[len(self.item_hier) - 1] def parse_style(self, style): """ Parse style attributes for Graphviz version < 2.24. """ style = style.rstrip(';') return dict([i.split(':') for i in style.split(';')]) #------------------------------------------------------------------------ # # DotSvgGenerator # #------------------------------------------------------------------------ class DotSvgGenerator(object): """ Generator of graphing instructions in dot format and svg data by Graphviz. """ def __init__(self, dbstate, view, bold_size=0, norm_size=0): """ Initialise the DotSvgGenerator class. """ self.bold_size = bold_size self.norm_size = norm_size self.dbstate = dbstate self.uistate = view.uistate self.database = dbstate.db self.view = view self.dot = None # will be StringIO() # This dictionary contains person handle as the index and the value is # the number of families in which the person is a parent. From this # dictionary is obtained a list of person handles sorted in decreasing # value order which is used to keep multiple spouses positioned # together. self.person_handles_dict = {} self.person_handles = [] # list of persons on path to home person self.current_list = list() self.home_person = None # Gtk style context for scrollwindow self.context = self.view.graph_widget.sw_style_context # font if we use genealogical symbols self.sym_font = None self.avatars = Avatars(self.view._config) def __del__(self): """ Free stream file on destroy. """ if self.dot: self.dot.close() FAMILY_TAG = "ProfileTag" def has_family_tag(self, person): print("Filter:", self.view._config.get('interface.graphview-filter-family-tag')) if not self.view._config.get('interface.graphview-filter-family-tag'): return True if not person: return False for tag_handle in person.get_tag_list(): tag = self.database.get_tag_from_handle(tag_handle) if tag and tag.get_name() == FAMILY_TAG: return True return False def init_dot(self): """ Init/reinit stream for dot file. Load and write config data to start of dot file. """ if self.dot: self.dot.close() self.dot = StringIO() self.current_list.clear() self.person_handles_dict.clear() self.show_images = self.view._config.get( 'interface.graphview-show-images') self.show_ID = self.view._config.get( 'interface.graphview-show-id') self.show_avatars = self.view._config.get( 'interface.graphview-show-avatars') self.show_full_dates = self.view._config.get( 'interface.graphview-show-full-dates') self.show_places = self.view._config.get( 'interface.graphview-show-places') self.place_format = self.view._config.get( 'interface.graphview-place-format') - 1 self.show_tag_color = self.view._config.get( 'interface.graphview-show-tags') spline = self.view._config.get('interface.graphview-show-lines') self.spline = SPLINE.get(int(spline)) self.descendant_generations = self.view._config.get( 'interface.graphview-descendant-generations') self.ancestor_generations = self.view._config.get( 'interface.graphview-ancestor-generations') self.people_limit = self.view._config.get( 'interface.graphview-people-limit') self.person_theme_index = self.view._config.get( 'interface.graphview-person-theme') self.show_all_connected = self.view._config.get( 'interface.graphview-show-all-connected') ranksep = self.view._config.get('interface.graphview-ranksep') ranksep = ranksep * 0.1 nodesep = self.view._config.get('interface.graphview-nodesep') nodesep = nodesep * 0.1 self.avatars.update_current_style() # get background color from gtk theme and convert it to hex # else use white background bg_color = self.context.lookup_color('theme_bg_color') if bg_color[0]: bg_rgb = (bg_color[1].red, bg_color[1].green, bg_color[1].blue) bg_color = rgb_to_hex(bg_rgb) else: bg_color = '#ffffff' # get font color from gtk theme and convert it to hex # else use black font font_color = self.context.lookup_color('theme_fg_color') if font_color[0]: fc_rgb = (font_color[1].red, font_color[1].green, font_color[1].blue) font_color = rgb_to_hex(fc_rgb) else: font_color = '#000000' # get colors from config home_path_color = self.view._config.get( 'interface.graphview-home-path-color') # set of colors self.colors = {'link_color': font_color, 'home_path_color': home_path_color} self.arrowheadstyle = 'none' self.arrowtailstyle = 'none' dpi = 72 # use font from config if needed font = self.view._config.get('interface.graphview-font') fontfamily = self.resolve_font_name(font[0]) self.fontsize = font[1] if not self.bold_size: self.bold_size = self.norm_size = font[1] pagedir = "BL" direction = self.view._config.get('interface.graphview-direction') rankdir = {0: "TB", 1: "BT", 2: "LR", 3: "RL"} ratio = "compress" # as we are not using paper, # choose a large 'page' size with no margin sizew = 100 sizeh = 100 xmargin = 0.00 ymargin = 0.00 self.write('digraph GRAMPS_graph\n') self.write('{\n') self.write(' bgcolor="%s";\n' % bg_color) self.write(' center="false"; \n') self.write(' charset="utf8";\n') self.write(' concentrate="false";\n') self.write(' dpi="%d";\n' % dpi) self.write(' graph [fontsize=%3.1f];\n' % self.fontsize) self.write(' margin="%3.2f,%3.2f"; \n' % (xmargin, ymargin)) self.write(' mclimit="99";\n') self.write(' nodesep="%.2f";\n' % nodesep) self.write(' outputorder="edgesfirst";\n') self.write(' pagedir="%s";\n' % pagedir) self.write(' rankdir="%s";\n' % rankdir.get(direction, "TB")) self.write(' ranksep="%.2f";\n' % ranksep) self.write(' ratio="%s";\n' % ratio) self.write(' searchsize="100";\n') self.write(' size="%3.2f,%3.2f"; \n' % (sizew, sizeh)) self.write(' splines=%s;\n' % self.spline) self.write('\n') self.write(' edge [style=solid fontsize=%d];\n' % self.fontsize) if fontfamily: self.write(' node [style=filled fontname="%s" ' 'fontsize=%3.1f fontcolor="%s"];\n' % (fontfamily, self.norm_size, font_color)) else: self.write(' node [style=filled fontsize=%3.1f fontcolor="%s"];\n' % (self.norm_size, font_color)) self.write('\n') self.uistate.connect('font-changed', self.font_changed) self.symbols = Symbols() self.font_changed() def resolve_font_name(self, font_name): """ Helps to resolve font by graphviz. """ # Sometimes graphviz have problem with font resolving. font_family_map = {"Times New Roman": "Times", "Times Roman": "Times", "Times-Roman": "Times", } font = font_family_map.get(font_name) if font is None: font = font_name return font def font_changed(self): dth_idx = self.uistate.death_symbol if self.uistate.symbols: self.bth = self.symbols.get_symbol_for_string( self.symbols.SYMBOL_BIRTH) self.dth = self.symbols.get_death_symbol_for_char(dth_idx) else: self.bth = self.symbols.get_symbol_fallback( self.symbols.SYMBOL_BIRTH) self.dth = self.symbols.get_death_symbol_fallback(dth_idx) # make sure to display in selected symbols font self.sym_font = config.get('utf8.selected-font') self.bth = '%s' % (self.sym_font, self.bth) self.dth = '%s' % (self.sym_font, self.dth) def build_graph(self, active_person, path_to_home_person): """ Builds a GraphViz tree based on the active person. """ # reinit dot file stream (write starting graphviz dot code to file) self.init_dot() if active_person: self.home_person = self.dbstate.db.get_default_person() self.set_current_list(active_person) self.set_current_list_desc(active_person) self.path_to_home_person = True if path_to_home_person: self.person_handles_dict.update( self.find_path_to_home(active_person)) else: if self.show_all_connected: self.person_handles_dict.update( self.find_connected(active_person)) else: self.person_handles_dict.update( self.find_descendants(active_person)) self.person_handles_dict.update( self.find_ancestors(active_person)) if self.person_handles_dict: self.person_handles = sorted( self.person_handles_dict, key=self.person_handles_dict.__getitem__, reverse=True) self.add_persons_and_families() self.add_child_links_to_families() # close the graphviz dot code with a brace self.write('}\n') # get DOT and generate SVG data by Graphviz dot_data = self.dot.getvalue().encode('utf8') svg_data = self.make_svg(dot_data) return (dot_data, svg_data) def make_svg(self, dot_data): """ Make SVG data by Graphviz. """ if win(): svg_data = Popen(['dot', '-Tsvg'], creationflags=DETACHED_PROCESS, stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(input=dot_data)[0] else: svg_data = Popen(['dot', '-Tsvg'], stdin=PIPE, stdout=PIPE).communicate(input=dot_data)[0] return svg_data def set_current_list(self, active_person, recurs_list=None): """ Get the path from the active person to the home person. Select ancestors. """ if not active_person: return False person = self.database.get_person_from_handle(active_person) if recurs_list is None: recurs_list = set() # make a recursion check list (actually a set) # see if we have a recursion (database loop) elif active_person in recurs_list: logging.warning(_("Relationship loop detected")) return False recurs_list.add(active_person) # record where we have been for check if person == self.home_person: self.current_list.append(active_person) return True else: for fam_handle in person.get_parent_family_handle_list(): family = self.database.get_family_from_handle(fam_handle) if self.set_current_list(family.get_father_handle(), recurs_list=recurs_list): self.current_list.append(active_person) self.current_list.append(fam_handle) return True if self.set_current_list(family.get_mother_handle(), recurs_list=recurs_list): self.current_list.append(active_person) self.current_list.append(fam_handle) return True return False def set_current_list_desc(self, active_person, recurs_list=None): """ Get the path from the active person to the home person. Select children. """ if not active_person: return False person = self.database.get_person_from_handle(active_person) if recurs_list is None: recurs_list = set() # make a recursion check list (actually a set) # see if we have a recursion (database loop) elif active_person in recurs_list: logging.warning(_("Relationship loop detected")) return False recurs_list.add(active_person) # record where we have been for check if person == self.home_person: self.current_list.append(active_person) return True else: for fam_handle in person.get_family_handle_list(): family = self.database.get_family_from_handle(fam_handle) for child in family.get_child_ref_list(): if self.set_current_list_desc(child.ref, recurs_list=recurs_list): self.current_list.append(active_person) self.current_list.append(fam_handle) return True return False def find_connected(self, active_person): """ Spider the database from the active person. """ person = self.database.get_person_from_handle(active_person) person_handles = {} self.add_connected(person, self.descendant_generations, self.ancestor_generations, person_handles) return person_handles def add_connected(self, person, num_desc, num_anc, person_handles): """ Include all connected to active in the list of people to graph. Recursive algorithm is not used becasue some trees have been found that exceed the standard python recursive depth. """ # list of work to do, handles with generation delta, # add to right and pop from left todo = deque([(person, 0)]) while todo: # check for person count if (self.people_limit > 0 and len(person_handles) >= self.people_limit): w_msg = _("This graph would contain at least {people_count} " "people, which exceeds the limit of {people_limit} " "people. For performance reasons, at least " "{people_missing} people won't be shown. You can " "change this limit in the view configuration.".format( people_count=len(todo) + len(person_handles), people_limit=self.people_limit, people_missing=len(todo), ) ) WarningDialog(_("Incomplete graph"), w_msg) return person, delta_gen = todo.popleft() if not person: continue if not self.has_family_tag(person): continue # check generation restrictions if (delta_gen > num_desc) or (delta_gen < -num_anc): continue # check if handle is not already processed if person.handle not in person_handles: spouses_list = person.get_family_handle_list() person_handles[person.handle] = len(spouses_list) else: continue # add descendants for family_handle in spouses_list: family = self.database.get_family_from_handle(family_handle) # add every child recursively if num_desc >= (delta_gen + 1): # generation restriction for child_ref in family.get_child_ref_list(): if (child_ref.ref in person_handles or child_ref.ref in todo): continue todo.append( (self.database.get_person_from_handle(child_ref.ref), delta_gen+1)) # add person spouses for sp_handle in (family.get_father_handle(), family.get_mother_handle()): if sp_handle and (sp_handle not in person_handles and sp_handle not in todo): todo.append( (self.database.get_person_from_handle(sp_handle), delta_gen)) # add ancestors if -num_anc <= (delta_gen - 1): # generation restriction for family_handle in person.get_parent_family_handle_list(): family = self.database.get_family_from_handle(family_handle) # add every ancestor's spouses for sp_handle in (family.get_father_handle(), family.get_mother_handle()): if sp_handle and (sp_handle not in person_handles and sp_handle not in todo): todo.append( (self.database.get_person_from_handle(sp_handle), delta_gen-1)) def find_descendants(self, active_person): """ Spider the database from the active person. """ person = self.database.get_person_from_handle(active_person) person_handles = {} self.add_descendant(person, self.descendant_generations, person_handles) return person_handles def add_descendant(self, person, num_generations, person_handles): """ Include a descendant in the list of people to graph. """ if not person: return if not self.has_family_tag(person): return # check if handle is not already processed # and add self and spouses if person.handle not in person_handles: spouses_list = person.get_family_handle_list() person_handles[person.handle] = len(spouses_list) self.add_spouses(person, person_handles) else: return if num_generations <= 0: return # add every child recursively for family_handle in spouses_list: family = self.database.get_family_from_handle(family_handle) for child_ref in family.get_child_ref_list(): self.add_descendant( self.database.get_person_from_handle(child_ref.ref), num_generations - 1, person_handles) def find_path_to_home(self, active_person): """ Find all the people in the direct path between the active person and the home person. """ home_person = self.dbstate.db.get_default_person() active_person = self.database.get_person_from_handle(active_person) FilterClass = GenericFilterFactory('Person') filter = FilterClass() plist = self.database.iter_person_handles() path = rules.person.RelationshipPathBetween([active_person.gramps_id, home_person.gramps_id]) filter.add_rule(path) person_list = filter.apply(self.database, plist) person_handles = dict.fromkeys(person_list,0) return person_handles def add_spouses(self, person, person_handles): """ Add spouses to the list. """ if not person: return for family_handle in person.get_family_handle_list(): sp_family = self.database.get_family_from_handle(family_handle) for sp_handle in (sp_family.get_father_handle(), sp_family.get_mother_handle()): if sp_handle and sp_handle not in person_handles: # add only spouse (num_generations = 0) self.add_descendant( self.database.get_person_from_handle(sp_handle), 0, person_handles) def find_ancestors(self, active_person): """ Spider the database from the active person. """ person = self.database.get_person_from_handle(active_person) person_handles = {} self.add_ancestor(person, self.ancestor_generations, person_handles) return person_handles def add_ancestor(self, person, num_generations, person_handles): """ Include an ancestor in the list of people to graph. """ if not person: return # Apply the ProfileTag filter to ancestors too. # Parent-family traversal includes foster and adoptive parents, so # untagged parents must be rejected just like descendants and spouses. if not self.has_family_tag(person): return # add self if handle is not already processed if person.handle not in person_handles: person_handles[person.handle] = len(person.get_family_handle_list()) else: return if num_generations <= 0: return for family_handle in person.get_parent_family_handle_list(): family = self.database.get_family_from_handle(family_handle) # add parents sp_persons = [] for sp_handle in (family.get_father_handle(), family.get_mother_handle()): if sp_handle and sp_handle not in person_handles: sp_person = self.database.get_person_from_handle(sp_handle) self.add_ancestor(sp_person, num_generations - 1, person_handles) sp_persons.append(sp_person) # add every other spouses for parents for sp_person in sp_persons: self.add_spouses(sp_person, person_handles) def add_child_links_to_families(self): """ Returns string of GraphViz edges linking parents to families or children. """ for person_handle in self.person_handles: person = self.database.get_person_from_handle(person_handle) for fam_handle in person.get_parent_family_handle_list(): family = self.database.get_family_from_handle(fam_handle) father_handle = family.get_father_handle() mother_handle = family.get_mother_handle() for child_ref in family.get_child_ref_list(): if child_ref.ref == person_handle: frel = child_ref.frel mrel = child_ref.mrel break if((father_handle in self.person_handles) or (mother_handle in self.person_handles)): # link to the family node if either parent is in graph self.add_family_link(person_handle, family, frel, mrel) def add_family_link(self, p_id, family, frel, mrel): """ Links the child to a family. """ style = 'solid' adopted = ((int(frel) != ChildRefType.BIRTH) or (int(mrel) != ChildRefType.BIRTH)) # if birth relation to father is NONE, meaning there is no father and # if birth relation to mother is BIRTH then solid line if((int(frel) == ChildRefType.NONE) and (int(mrel) == ChildRefType.BIRTH)): adopted = False if adopted: style = 'dotted' self.add_link(family.handle, p_id, style, self.arrowheadstyle, self.arrowtailstyle, color=self.colors['home_path_color'], bold=self.is_in_path_to_home(p_id)) def add_parent_link(self, p_id, parent_handle, rel): """ Links the child to a parent. """ style = 'solid' if int(rel) != ChildRefType.BIRTH: style = 'dotted' self.add_link(parent_handle, p_id, style, self.arrowheadstyle, self.arrowtailstyle, color=self.colors['home_path_color'], bold=self.is_in_path_to_home(p_id)) def add_persons_and_families(self): """ Adds nodes for persons and their families. Subgraphs are used to indicate to Graphviz that parents of families should be positioned together. The person_handles list is sorted so that people with the largest number of spouses are at the start of the list. As families are only processed once, this means people with multiple spouses will have their additional spouses included in their subgraph. """ # variable to communicate with get_person_label url = "" # The list of families for which we have output the node, # so we don't do it twice # use set() as it little faster then list() family_nodes_done = set() family_links_done = set() for person_handle in self.person_handles: person = self.database.get_person_from_handle(person_handle) # Output the person's node label = self.get_person_label(person) (shape, style, color, fill) = self.get_gender_style(person) self.add_node(person_handle, label, shape, color, style, fill, url) # Output family nodes where person is a parent family_list = person.get_family_handle_list() for fam_handle in family_list: if fam_handle not in family_nodes_done: family_nodes_done.add(fam_handle) self.__add_family_node(fam_handle) # Output family links where person is a parent subgraph_started = False family_list = person.get_family_handle_list() for fam_handle in family_list: if fam_handle not in family_links_done: family_links_done.add(fam_handle) if not subgraph_started: subgraph_started = True self.start_subgraph(person_handle) self.__add_family_links(fam_handle) if subgraph_started: self.end_subgraph() def is_in_path_to_home(self, f_handle): """ Is the current person in the path to the home person? """ if f_handle in self.current_list: return True return False def __add_family_node(self, fam_handle): """ Add a node for a family. """ fam = self.database.get_family_from_handle(fam_handle) fill, color = color_graph_family(fam, self.dbstate) style = "filled" label = self.get_family_label(fam) self.add_node(fam_handle, label, "ellipse", color, style, fill) def __add_family_links(self, fam_handle): """ Add the links for spouses. """ fam = self.database.get_family_from_handle(fam_handle) f_handle = fam.get_father_handle() m_handle = fam.get_mother_handle() if f_handle in self.person_handles: self.add_link(f_handle, fam_handle, "", self.arrowheadstyle, self.arrowtailstyle, color=self.colors['home_path_color'], bold=self.is_in_path_to_home(f_handle)) if m_handle in self.person_handles: self.add_link(m_handle, fam_handle, "", self.arrowheadstyle, self.arrowtailstyle, color=self.colors['home_path_color'], bold=self.is_in_path_to_home(m_handle)) def get_gender_style(self, person): """ Return gender specific person style. """ gender = person.get_gender() shape = "box" style = "solid, filled" # get alive status of person to get box color try: alive = probably_alive(person, self.dbstate.db) except RuntimeError: alive = False fill, color = color_graph_box(alive, gender) return(shape, style, color, fill) def get_tags_and_table(self, obj): """ Return html tags table for obj (person or family). """ tag_table = '' tags = [] for tag_handle in obj.get_tag_list(): tags.append(self.dbstate.db.get_tag_from_handle(tag_handle)) # prepare html table of tags if tags: tag_table = ('') for tag in tags: rgba = Gdk.RGBA() rgba.parse(tag.get_color()) value = '#%02x%02x%02x' % (int(rgba.red * 255), int(rgba.green * 255), int(rgba.blue * 255)) tag_table += '' % value tag_table += '
' return tags, tag_table def get_person_themes(self, index=-1): """ Person themes. If index == -1 return list of themes. If index out of range return default theme. """ person_themes = [ (0, _('Default'), '' '' '' '' '' '' '
%(img)s
%(name)s' '
%(birth_str)s
%(death_str)s
%(tags)s
' ), (1, _('Image on right side'), '' '' '' '' '' '' '' '' '' '' '' '' ' ' '' '
%(name)s' '
%(birth_wraped)s' '%(img)s
%(death_wraped)s' '
%(tags)s
' ), (2, _('Image on left side'), '' '' '' '' '' '' '' '' '' '' '' '' ' ' '' '
%(name)s' '
%(img)s%(birth_wraped)s' '
%(death_wraped)s' '
%(tags)s
' ), (3, _('Normal'), '' '' '' '' '' '' '
%(img)s
%(name)s' '
%(birth_wraped)s
%(death_wraped)s
%(tags)s
' )] if index < 0: return person_themes if index < len(person_themes): return person_themes[index] else: return person_themes[0] def get_person_label(self, person): """ Return person label string (with tags). """ # Start an HTML table. # Remember to close the table afterwards! # # This isn't a free-form HTML format here...just a few keywords that # happen to be similar to keywords commonly seen in HTML. # For additional information on what is allowed, see: # # http://www.graphviz.org/info/shapes.html#html # # Will use html.escape to avoid '&', '<', '>' in the strings. # FIRST get all strings: img, name, dates, tags # see if we have an image to use for this person image = '' if self.show_images: image = self.view.graph_widget.get_person_image(person, kind='path') if not image and self.show_avatars: image = self.avatars.get_avatar(gender=person.gender) if image is not None: image = '' % image else: image = '' # get the person's name name = displayer.display_name(person.get_primary_name()) # name string should not be empty name = escape(name) if name else ' ' if self.show_ID: name += " (%s)" % person.get_gramps_id() # birth, death is a lists [date, place] birth, death = self.get_date_strings(person) birth_str = '' death_str = '' birth_wraped = '' death_wraped = '' # There are two ways of displaying dates: # 1) full and on two lines: # b. 1890-12-31 - BirthPlace # d. 1960-01-02 - DeathPlace if self.show_full_dates or self.show_places: # add symbols if birth[0]: birth[0] = '%s %s' % (self.bth, birth[0]) birth_wraped = birth[0] birth_str = birth[0] if birth[1]: birth_wraped += '
' birth_str += ' ' elif birth[1]: birth_wraped = _('%s ') % self.bth birth_str = _('%s ') % self.bth birth_wraped += birth[1] birth_str += birth[1] if death[0]: death[0] = '%s %s' % (self.dth, death[0]) death_wraped = death[0] death_str = death[0] if death[1]: death_wraped += '
' death_str += ' ' elif death[1]: death_wraped = _('%s ') % self.dth death_str = _('%s ') % self.dth death_wraped += death[1] death_str += death[1] # 2) simple and on one line: # (1890 - 1960) else: if birth[0] or death[0]: birth_str = '(%s - %s)' % (birth[0], death[0]) # add symbols if image: if birth[0]: birth_wraped = '%s %s' % (self.bth, birth[0]) if death[0]: death_wraped = '%s %s' % (self.dth, death[0]) else: birth_wraped = birth_str # get tags table for person and add tooltip for node tag_table = '' if self.show_tag_color: tags, tag_table = self.get_tags_and_table(person) if tag_table: self.add_tags_tooltip(person.handle, tags) # apply theme to person label if(image or self.person_theme_index == 0 or self.person_theme_index == 3): p_theme = self.get_person_themes(self.person_theme_index) else: # use default theme if no image p_theme = self.get_person_themes(3) label = p_theme[2] % {'img': image, 'name': name, 'birth_str': birth_str, 'death_str': death_str, 'birth_wraped': birth_wraped, 'death_wraped': death_wraped, 'tags': tag_table, 'bsize' : self.bold_size} return label def get_family_label(self, family): """ Return family label string (with tags). """ # start main html table label = ('') # add dates strtings to table event_str = ['', ''] for event_ref in family.get_event_ref_list(): event = self.database.get_event_from_handle(event_ref.ref) if (event.type == EventType.MARRIAGE and (event_ref.get_role() == EventRoleType.FAMILY or event_ref.get_role() == EventRoleType.PRIMARY)): event_str = self.get_event_string(event) break if event_str[0] and event_str[1]: event_str = '%s
%s' % (event_str[0], event_str[1]) elif event_str[0]: event_str = event_str[0] elif event_str[1]: event_str = event_str[1] else: event_str = '' label += '' % event_str # add tags table for family and add tooltip for node if self.show_tag_color: tags, tag_table = self.get_tags_and_table(family) if tag_table: label += '' % tag_table self.add_tags_tooltip(family.handle, tags) # close main table label += '
%s
%s
' return label def get_date_strings(self, person): """ Returns tuple of birth/christening and death/burying date strings. """ birth_event = get_birth_or_fallback(self.database, person) if birth_event: birth = self.get_event_string(birth_event) else: birth = ['', ''] death_event = get_death_or_fallback(self.database, person) if death_event: death = self.get_event_string(death_event) else: death = ['', ''] return (birth, death) def get_event_string(self, event): """ Return string for an event label. Based on the data availability and preferences, we select one of the following for a given event: year only complete date place name empty string """ if event: place_title = place_displayer.display_event(self.database, event, fmt=self.place_format) date_object = event.get_date_object() date = '' place = '' # shall we display full date # or do we have a valid year to display only year if(self.show_full_dates and date_object.get_text() or date_object.get_year_valid()): if self.show_full_dates: date = '%s' % datehandler.get_date(event) else: date = '%i' % date_object.get_year() # shall we add the place? if self.show_places and place_title: place = place_title return [escape(date), escape(place)] else: if place_title and self.show_places: return ['', escape(place_title)] return ['', ''] def add_link(self, id1, id2, style="", head="", tail="", comment="", bold=False, color=""): """ Add a link between two nodes. Gramps handles are used as nodes but need to be prefixed with an underscore because Graphviz does not like IDs that begin with a number. The id is quoted because handles are arbitrary schema-valid strings: Gramps-Web creates UUIDv4 handles containing hyphens, which an unquoted Graphviz ID would split on (bug 13832). """ self.write(' "_%s" -> "_%s"' % (id1, id2)) boldok = False if id1 in self.current_list: if id2 in self.current_list: boldok = True self.write(' [') if style: self.write(' style=%s' % style) if head: self.write(' arrowhead=%s' % head) if tail: self.write(' arrowtail=%s' % tail) if bold and boldok: self.write(' penwidth=%d' % 5) if color: self.write(' color="%s"' % color) else: # if not path to home than set default color of link self.write(' color="%s"' % self.colors['link_color']) self.write(' ]') self.write(';') if comment: self.write(' // %s' % comment) self.write('\n') def add_node(self, node_id, label, shape="", color="", style="", fillcolor="", url="", fontsize=""): """ Add a node to this graph. Nodes can be different shapes like boxes and circles. Gramps handles are used as nodes but need to be prefixed with an underscore because Graphviz does not like IDs that begin with a number. """ text = '[margin="0.11,0.08"' if shape: text += ' shape="%s"' % shape if color: text += ' color="%s"' % color if fillcolor: color = hex_to_rgb_float(fillcolor) yiq = (color[0] * 299 + color[1] * 587 + color[2] * 114) fontcolor = "#ffffff" if yiq < 500 else "#000000" text += ' fillcolor="%s" fontcolor="%s"' % (fillcolor, fontcolor) if style: text += ' style="%s"' % style if fontsize: text += ' fontsize="%s"' % fontsize # note that we always output a label -- even if an empty string -- # otherwise GraphViz uses the node ID as the label which is unlikely # to be what the user wants to see in the graph text += ' label=<%s>' % label if url: text += ' URL="%s"' % url text += " ]" # Quote the id: handles are arbitrary schema-valid strings, and # Gramps-Web creates UUIDv4 handles with hyphens that an unquoted # Graphviz ID would split on, blanking the graph (bug 13832). self.write(' "_%s" %s;\n' % (node_id, text)) def add_tags_tooltip(self, handle, tag_list): """ Add tooltip to dict {handle, tooltip}. """ tooltip_str = _('Tags:') for tag in tag_list: tooltip_str += ('\n - %s' % (tag.get_color(), tag.get_name())) self.view.tags_tooltips[handle] = tooltip_str def start_subgraph(self, graph_id): """ Opens a subgraph which is used to keep together related nodes on the graph. """ # Quote the name: graph_id is a handle (see bug 13832); a UUIDv4 # handle with hyphens would break an unquoted subgraph name. # Graphviz still treats a quoted name beginning with "cluster" as # a cluster subgraph. self.write('\n subgraph "cluster_%s"\n' % graph_id) self.write(' {\n') # no border around subgraph (#0002176) self.write(' style="invis";\n') def end_subgraph(self): """ Closes a subgraph section. """ self.write(' }\n\n') def write(self, text): """ Write text to the dot file. """ if self.dot: self.dot.write(text) #------------------------------------------------------------------------- # # CanvasAnimation # #------------------------------------------------------------------------- class CanvasAnimation(object): """ Produce animation for operations with canvas. """ def __init__(self, view, canvas, scroll_window): """ We need canvas and window in which it placed. And view to get config. """ self.view = view self.canvas = canvas self.hadjustment = scroll_window.get_hadjustment() self.vadjustment = scroll_window.get_vadjustment() self.items_list = [] self.in_motion = False self.max_count = self.view._config.get( 'interface.graphview-animation-count') self.max_count = self.max_count * 2 # must be modulo 2 self.show_animation = self.view._config.get( 'interface.graphview-show-animation') # delay between steps in microseconds self.speed = self.view._config.get( 'interface.graphview-animation-speed') self.speed = 50 * int(self.speed) # length of step self.step_len = 10 # separated counter and direction of shaking # for each item that in shake procedure self.counter = {} self.shake = {} self.in_shake = [] def update_items(self, items_list): """ Update list of items for current graph. """ self.items_list.clear() self.items_list.extend(items_list) self.in_shake.clear() # clear counters and shakes - items not exists anymore self.counter.clear() self.shake.clear() def stop_animation(self): """ Stop move_to animation. And wait while thread is finished. """ self.in_motion = False try: self.thread.join() except: pass def stop_shake_animation(self, item, stoped): """ Processing of 'animation-finished' signal. Stop or keep shaking item depending on counter for item. """ counter = self.counter.get(item.title) shake = self.shake.get(item.title) if (not stoped) and counter and shake and counter < self.max_count: self.shake[item.title] = (-1) * self.shake[item.title] self.counter[item.title] += 1 item.animate(0, self.shake[item.title], 1, 0, False, self.speed, 10, 0) else: item.disconnect_by_func(self.stop_shake_animation) try: self.counter.pop(item.title) self.shake.pop(item.title) except: pass def shake_person(self, person_handle): """ Shake person node to help to see it. Use build-in function of CanvasItem. """ item = self.get_item_by_title(person_handle) if item: self.shake_item(item) def shake_item(self, item): """ Shake item to help to see it. Use build-in function of CanvasItem. """ if item and self.show_animation and self.max_count > 0: if not self.counter.get(item.title): self.in_shake.append(item) self.counter[item.title] = 1 self.shake[item.title] = 10 item.connect('animation-finished', self.stop_shake_animation) item.animate(0, self.shake[item.title], 1, 0, False, self.speed, 10, 0) def get_item_by_title(self, handle): """ Find item by title. """ if handle: for item in self.items_list: if item.title == handle: return item return None def move_to_person(self, handle, animated): """ Move graph to specified person by handle. """ self.stop_animation() item = self.get_item_by_title(handle) if item: bounds = item.get_bounds() # calculate middle of node coordinates xxx = (bounds.x2 - (bounds.x2 - bounds.x1) / 2) yyy = (bounds.y1 - (bounds.y1 - bounds.y2) / 2) self.move_to(item, (xxx, yyy), animated) return True return False def get_trace_to(self, destination): """ Return next point to destination from current position. """ # get current position (left-top corner) with scale start_x = self.hadjustment.get_value() / self.canvas.get_scale() start_y = self.vadjustment.get_value() / self.canvas.get_scale() x_delta = destination[0] - start_x y_delta = destination[1] - start_y # calculate step count depending on length of the trace trace_len = sqrt(pow(x_delta, 2) + pow(y_delta, 2)) steps_count = int(trace_len / self.step_len * self.canvas.get_scale()) # prevent division by 0 if steps_count > 0: x_step = x_delta / steps_count y_step = y_delta / steps_count point = (start_x + x_step, start_y + y_step) else: point = destination return point def scroll_canvas(self, point): """ Scroll window to point on canvas. """ self.canvas.scroll_to(point[0], point[1]) def animation(self, item, destination): """ Animate scrolling to destination point in thread. Dynamically get points to destination one by one and try to scroll to them. """ self.in_motion = True while self.in_motion: # correct destination to window centre h_offset = self.hadjustment.get_page_size() / 2 v_offset = self.vadjustment.get_page_size() / 3 # apply the scaling factor so the offset is adjusted to the scale h_offset = h_offset / self.canvas.get_scale() v_offset = v_offset / self.canvas.get_scale() dest = (destination[0] - h_offset, destination[1] - v_offset) # get maximum scroll of window max_scroll_x = ((self.hadjustment.get_upper() - self.hadjustment.get_page_size()) / self.canvas.get_scale()) max_scroll_y = ((self.vadjustment.get_upper() - self.vadjustment.get_page_size()) / self.canvas.get_scale()) # fix destination to fit in max scroll if dest[0] > max_scroll_x: dest = (max_scroll_x, dest[1]) if dest[0] < 0: dest = (0, dest[1]) if dest[1] > max_scroll_y: dest = (dest[0], max_scroll_y) if dest[1] < 0: dest = (dest[0], 0) cur_pos = (self.hadjustment.get_value() / self.canvas.get_scale(), self.vadjustment.get_value() / self.canvas.get_scale()) # finish if we already at destination if dest == cur_pos: break # get next point to destination point = self.get_trace_to(dest) GLib.idle_add(self.scroll_canvas, point) GLib.usleep(20 * self.speed) # finish if we try to goto destination point if point == dest: break self.in_motion = False # shake item after scroll to it self.shake_item(item) def move_to(self, item, destination, animated): """ Move graph to specified position. If 'animated' is True then movement will be animated. It works with 'canvas.scroll_to' in thread. """ # if animated is True than run thread with animation # else - just scroll_to immediately if animated and self.show_animation: self.thread = Thread(target=self.animation, args=[item, destination]) self.thread.start() else: # correct destination to screen centre h_offset = self.hadjustment.get_page_size() / 2 v_offset = self.vadjustment.get_page_size() / 3 # apply the scaling factor so the offset is adjusted to the scale h_offset = h_offset / self.canvas.get_scale() v_offset = v_offset / self.canvas.get_scale() destination = (destination[0] - h_offset, destination[1] - v_offset) self.scroll_canvas(destination) # shake item after scroll to it self.shake_item(item) #------------------------------------------------------------------------- # # Popup menu widget # #------------------------------------------------------------------------- class PopupMenu(Gtk.Menu): """ Produce popup widget for right-click menu. """ def __init__(self, graph_widget, kind=None, handle=None): """ graph_widget: GraphWidget kind: 'person', 'family', 'background' handle: person or family handle """ Gtk.Menu.__init__(self) self.set_reserve_toggle_size(False) self.graph_widget = graph_widget self.view = graph_widget.view self.dbstate = graph_widget.dbstate self.actions = graph_widget.actions if kind == 'background': self.background_menu() elif kind == 'person' and handle is not None: self.person_menu(handle) elif kind == 'family' and handle is not None: self.family_menu(handle) def show_menu(self, event=None): """ Show popup menu. """ if (Gtk.MAJOR_VERSION >= 3) and (Gtk.MINOR_VERSION >= 22): # new from gtk 3.22: self.popup_at_pointer(event) else: if event: self.popup(None, None, None, None, event.get_button()[1], event.time) else: self.popup(None, None, None, None, 0, Gtk.get_current_event_time()) #self.popup(None, None, None, None, 0, 0) def background_menu(self): """ Popup menu on background. """ menu_item = Gtk.CheckMenuItem(_('Show images')) menu_item.set_active( self.view._config.get('interface.graphview-show-images')) menu_item.connect("activate", self.graph_widget.update_setting, 'interface.graphview-show-images') menu_item.show() self.append(menu_item) menu_item = Gtk.CheckMenuItem(_('Highlight the home person')) menu_item.set_active( self.view._config.get('interface.graphview-highlight-home-person')) menu_item.connect("activate", self.graph_widget.update_setting, 'interface.graphview-highlight-home-person') menu_item.show() self.append(menu_item) menu_item = Gtk.CheckMenuItem(_('Show full dates')) menu_item.set_active( self.view._config.get('interface.graphview-show-full-dates')) menu_item.connect("activate", self.graph_widget.update_setting, 'interface.graphview-show-full-dates') menu_item.show() self.append(menu_item) menu_item = Gtk.CheckMenuItem(_('Show places')) menu_item.set_active( self.view._config.get('interface.graphview-show-places')) menu_item.connect("activate", self.graph_widget.update_setting, 'interface.graphview-show-places') menu_item.show() self.append(menu_item) menu_item = Gtk.CheckMenuItem(_('Show tags')) menu_item.set_active( self.view._config.get('interface.graphview-show-tags')) menu_item.connect("activate", self.graph_widget.update_setting, 'interface.graphview-show-tags') menu_item.show() self.append(menu_item) self.add_separator() menu_item = Gtk.CheckMenuItem(_('Show animation')) menu_item.set_active( self.view._config.get('interface.graphview-show-animation')) menu_item.connect("activate", self.graph_widget.update_setting, 'interface.graphview-show-animation') menu_item.show() self.append(menu_item) menu_item = Gtk.CheckMenuItem( _('Show people with the person tag "ProfileTag"')) menu_item.set_active( self.view._config.get('interface.graphview-filter-family-tag')) menu_item.connect("activate", self.graph_widget.update_setting, 'interface.graphview-filter-family-tag') menu_item.show() self.append(menu_item) # add sub menu for line type setting menu_item, sub_menu = self.add_submenu(label=_('Lines type')) spline = self.view._config.get('interface.graphview-show-lines') entry = Gtk.RadioMenuItem(label=_('Direct')) entry.connect("activate", self.graph_widget.update_lines_type, 0, 'interface.graphview-show-lines') if spline == 0: entry.set_active(True) entry.show() sub_menu.append(entry) entry = Gtk.RadioMenuItem(label=_('Curves')) entry.connect("activate", self.graph_widget.update_lines_type, 1, 'interface.graphview-show-lines') if spline == 1: entry.set_active(True) entry.show() sub_menu.append(entry) entry = Gtk.RadioMenuItem(label=_('Ortho')) entry.connect("activate", self.graph_widget.update_lines_type, 2, 'interface.graphview-show-lines') if spline == 2: entry.set_active(True) entry.show() sub_menu.append(entry) # add profile export/import shortcuts self.add_separator() _item, profile_menu = self.add_submenu(label=_('Profiles')) profiles_enabled = self.view.profile_function_is_enabled() try: _item.set_sensitive(profiles_enabled) except Exception: pass has_current_view_profile = bool( self.view.current_view_profile_filename) # Group the popup actions under short, non-clickable # headings. The profile type no longer needs to be repeated in every # action label. view_heading = Gtk.MenuItem() view_heading_label = Gtk.Label(xalign=0) view_heading_label.set_markup( '%s' % escape(_('View profile:'))) view_heading.add(view_heading_label) view_heading.set_sensitive(False) view_heading.show_all() profile_menu.append(view_heading) # Do not offer "Save current View profile" here. # That action follows the selections in the Profiles page, and those # selections are intentionally not updated by changes made elsewhere. # The popup offers a complete new View-profile snapshot. # The popup menu is normally used while the Profiles # page and its checkboxes are not visible. Save the complete current # view as a new View profile, exactly like the matching Configure # button, without the empty-checkbox warning. save_view_as_item = self.add_menuitem( profile_menu, _('Save as new...'), self.view.save_current_view_as_new_view_profile) save_view_as_item.set_sensitive(profiles_enabled) load_view_item = self.add_menuitem( profile_menu, _('Load...'), self.view.load_graphview_profile) load_view_item.set_sensitive(profiles_enabled) delete_view_item = self.add_menuitem( profile_menu, _('Delete'), self.view.delete_current_view_profile) delete_view_item.set_sensitive( profiles_enabled and has_current_view_profile) sep = Gtk.SeparatorMenuItem() sep.show() profile_menu.append(sep) standard_heading = Gtk.MenuItem() standard_heading_label = Gtk.Label(xalign=0) standard_heading_label.set_markup( '%s' % escape(_('Standard profile:'))) standard_heading.add(standard_heading_label) standard_heading.set_sensitive(False) standard_heading.show_all() profile_menu.append(standard_heading) # The popup menu is normally used while the Profiles # page and its checkboxes are not visible. Save the complete current # view as a Standard profile, exactly like the matching Configure # button. Only the shared Active/Home safety check applies. save_standard_item = self.add_menuitem( profile_menu, _('Save as'), self.view.save_current_view_as_standard_profile) save_standard_item.set_sensitive(profiles_enabled) load_standard_item = self.add_menuitem( profile_menu, _('Load'), self.view.load_standard_graphview_profile) # Do not offer a load action when this family tree has # no saved Standard profile. The loader repeats the file check in # case the file disappears while this menu is open. load_standard_item.set_sensitive( profiles_enabled and self.view.standard_graphview_profile_exists()) delete_standard_item = self.add_menuitem( profile_menu, _('Delete'), self.view.delete_standard_graphview_profile) delete_standard_item.set_sensitive( profiles_enabled and self.view.standard_graphview_profile_exists()) # add help menu self.add_separator() self.append_help_menu_entry() def add_tags_to_menu(self, obj, otype, tag_menu): """ Add tags to the Popup menu for person or family node. """ idx = 0 tags_list = obj.get_tag_list() handle = obj.get_handle() for tag_handle in self.dbstate.db.get_tag_handles(): idx += 1 tag = self.dbstate.db.get_tag_from_handle(tag_handle) # prepare the tag if tag: rgba = Gdk.RGBA() rgba.parse(tag.get_color()) rgba2 = Gdk.RGBA() # Calculate the brightness of the background. # depending on this value, the text is shown # either in white, either in black. brightness = (int(rgba.red * 255) * 0.299 + int(rgba.green * 255) * 0.587 + int(rgba.blue * 255) * 0.114) foreground = "#000" if brightness > 100 else "#fff" rgba2.parse(foreground) # We can't use add_menuitem here tag_name = tag.get_name() item = Gtk.RadioMenuItem(label=tag_name) if tag_handle in tags_list: item.set_active(True) else: item.set_active(False) item.override_background_color(Gtk.StateFlags.NORMAL, rgba) item.override_color(Gtk.StateFlags.NORMAL, rgba2) item.connect("activate", self.actions.add_tag_to_object, [handle, otype, tag_handle]) item.show() style = tag_menu.get_style_context() color = style.get_property("background-color", Gtk.StateFlags.PRELIGHT) color.alpha = 0.2 item.override_background_color(Gtk.StateFlags.PRELIGHT, color) tag_menu.append(item) def person_menu(self, handle): """ Popup menu for person node. """ person = self.dbstate.db.get_person_from_handle(handle) if person: add_menuitem(self, _('Edit'), handle, self.actions.edit_person) add_menuitem(self, _('Copy'), handle, self.actions.copy_person_to_clipboard) add_menuitem(self, _('Delete'), person, self.actions.remove_person) self.add_separator() # build events submenu if len(person.get_event_ref_list()) != 0: iteme, evt_menu = self.add_submenu(label=_("Events")) else: iteme = self.add_menuitem(self, _("No Events for this person"), self.menu_no_action) nbe = 0 for event_ref in person.get_event_ref_list(): if not event_ref: continue nbe += 1 event = self.dbstate.db.get_event_from_handle(event_ref.ref) role = _(event_ref.get_role().xml_str()) etype = _(event.get_type().xml_str()) # text = displayer.display(person) if event_ref.get_role() != EventRoleType.PRIMARY: etype += " (" + role + ") " self.add_menuitem(evt_menu, etype, self.actions.edit_person_event, person.get_gramps_id(), event, event_ref) # build tag submenu item, tag_menu = self.add_submenu(label=_("Tags")) add_menuitem(tag_menu, _('Select tags for person'), [handle, 'person'], self.actions.edit_tag_list) add_menuitem(tag_menu, _('Organize Tags...'), [handle, 'person'], self.actions.organize_tags) self.add_tags_to_menu(person, 'person', tag_menu) # go over spouses and build their menu item, sp_menu = self.add_submenu(label=_("Spouses")) add_menuitem(sp_menu, _('Add new family'), handle, self.actions.add_spouse) self.add_separator(sp_menu) fam_list = person.get_family_handle_list() for fam_id in fam_list: family = self.dbstate.db.get_family_from_handle(fam_id) if family.get_father_handle() == person.get_handle(): sp_id = family.get_mother_handle() else: sp_id = family.get_father_handle() if not sp_id: continue spouse = self.dbstate.db.get_person_from_handle(sp_id) if not spouse: continue self.add_menuitem(sp_menu, displayer.display(spouse), self.graph_widget.move_to_person, sp_id, True) # go over siblings and build their menu item, sib_menu = self.add_submenu(label=_("Siblings")) pfam_list = person.get_parent_family_handle_list() siblings = [] step_siblings = [] for f_h in pfam_list: fam = self.dbstate.db.get_family_from_handle(f_h) sib_list = fam.get_child_ref_list() for sib_ref in sib_list: sib_id = sib_ref.ref if sib_id == person.get_handle(): continue siblings.append(sib_id) # collect a list of per-step-family step-siblings for parent_h in [fam.get_father_handle(), fam.get_mother_handle()]: if not parent_h: continue parent = self.dbstate.db.get_person_from_handle( parent_h) other_families = [ self.dbstate.db.get_family_from_handle(fam_id) for fam_id in parent.get_family_handle_list() if fam_id not in pfam_list] for step_fam in other_families: fam_stepsiblings = [ sib_ref.ref for sib_ref in step_fam.get_child_ref_list() if not sib_ref.ref == person.get_handle()] if fam_stepsiblings: step_siblings.append(fam_stepsiblings) # add siblings sub-menu with a bar between each siblings group if siblings or step_siblings: sibs = [siblings] + step_siblings for sib_group in sibs: for sib_id in sib_group: sib = self.dbstate.db.get_person_from_handle( sib_id) if not sib: continue if find_children(self.dbstate.db, sib): label = Gtk.Label( label='%s' % escape(displayer.display(sib))) else: label = Gtk.Label( label=escape(displayer.display(sib))) sib_item = Gtk.MenuItem() label.set_use_markup(True) label.show() label.set_alignment(0, 0) sib_item.add(label) sib_item.connect("activate", self.graph_widget.move_to_person, sib_id, True) sib_item.show() sib_menu.append(sib_item) if sibs.index(sib_group) < len(sibs) - 1: self.add_separator(sib_menu) else: item.set_sensitive(0) self.add_children_submenu(person=person) # Go over parents and build their menu item, par_menu = self.add_submenu(label=_("Parents")) no_parents = True par_list = find_parents(self.dbstate.db, person) for par_id in par_list: if not par_id: continue par = self.dbstate.db.get_person_from_handle(par_id) if not par: continue if no_parents: no_parents = False if find_parents(self.dbstate.db, par): label = Gtk.Label(label='%s' % escape(displayer.display(par))) else: label = Gtk.Label(label=escape(displayer.display(par))) par_item = Gtk.MenuItem() label.set_use_markup(True) label.show() label.set_halign(Gtk.Align.START) par_item.add(label) par_item.connect("activate", self.graph_widget.move_to_person, par_id, True) par_item.show() par_menu.append(par_item) if no_parents: # add button to add parents add_menuitem(par_menu, _('Add parents'), handle, self.actions.add_parents_to_person) # go over related persons and build their menu item, per_menu = self.add_submenu(label=_("Related")) no_related = True for p_id in find_witnessed_people(self.dbstate.db, person): per = self.dbstate.db.get_person_from_handle(p_id) if not per: continue if no_related: no_related = False self.add_menuitem(per_menu, displayer.display(per), self.graph_widget.move_to_person, p_id, True) if no_related: item.set_sensitive(0) self.add_separator() add_menuitem(self, _('Set as home person'), handle, self.actions.set_home_person) add_menuitem(self, _('Show path to home person'), handle, self.actions.path_to_home_person) # check if we have person in bookmarks marks = self.graph_widget.view.bookmarks.get_bookmarks().bookmarks if handle in marks: add_menuitem(self, _('Remove from bookmarks'), handle, self.actions.remove_from_bookmarks) else: add_menuitem(self, _('Add to bookmarks'), [handle, person], self.actions.add_to_bookmarks) # QuickReports and WebConnect section self.add_separator() q_exists = self.add_quickreport_submenu(CATEGORY_QR_PERSON, handle) w_exists = self.add_web_connect_submenu(handle) if q_exists or w_exists: self.add_separator() self.append_help_menu_entry() def add_quickreport_submenu(self, category, handle): """ Adds Quick Reports menu. """ def make_quick_report_callback(pdata, category, dbstate, uistate, handle, track=[]): return lambda x: run_report(dbstate, uistate, category, handle, pdata, track=track) # select the reports to show showlst = [] pmgr = GuiPluginManager.get_instance() for pdata in pmgr.get_reg_quick_reports(): if pdata.supported and pdata.category == category: showlst.append(pdata) showlst.sort(key=lambda x: x.name) if showlst: menu_item, quick_menu = self.add_submenu(_("Quick View")) for pdata in showlst: callback = make_quick_report_callback( pdata, category, self.view.dbstate, self.view.uistate, handle) self.add_menuitem(quick_menu, pdata.name, callback) return True return False def add_web_connect_submenu(self, handle): """ Adds Web Connect menu if some installed. """ def flatten(L): """ Flattens a possibly nested list. Removes None results, too. """ retval = [] if isinstance(L, (list, tuple)): for item in L: fitem = flatten(item) if fitem is not None: retval.extend(fitem) elif L is not None: retval.append(L) return retval # select the web connects to show pmgr = GuiPluginManager.get_instance() plugins = pmgr.process_plugin_data('WebConnect') nav_group = self.view.navigation_type() try: connections = [plug(nav_group) if isinstance(plug, abc.Callable) else plug for plug in plugins] except BaseException: import traceback traceback.print_exc() connections = [] connections = flatten(connections) connections.sort(key=lambda plug: plug.name) if connections: menu_item, web_menu = self.add_submenu(_("Web Connection")) for connect in connections: callback = connect(self.view.dbstate, self.view.uistate, nav_group, handle) self.add_menuitem(web_menu, connect.name, callback) return True return False def family_menu(self, handle): """ Popup menu for family node. """ family = self.dbstate.db.get_family_from_handle(handle) if family: add_menuitem(self, _('Edit'), handle, self.actions.edit_family) add_menuitem(self, _('Delete'), family, self.actions.remove_family) self.add_separator() # build events submenu if len(family.get_event_ref_list()) != 0: iteme, evt_menu = self.add_submenu(label=_("Events")) else: iteme = self.add_menuitem(self, _("No Events for this family"), self.menu_no_action) nbe = 0 for event_ref in family.get_event_ref_list(): if not event_ref: continue nbe += 1 event = self.dbstate.db.get_event_from_handle(event_ref.ref) role = _(event_ref.get_role().xml_str()) etype = _(event.get_type().xml_str()) # text = displayer.display(person) if event_ref.get_role() != EventRoleType.FAMILY: etype += " (" + role + ") " self.add_menuitem(evt_menu, etype, self.actions.edit_family_event, family.get_gramps_id(), event, event_ref) # build tag submenu _item, tag_menu = self.add_submenu(label=_("Tags")) add_menuitem(tag_menu, _('Select tags for family'), [handle, 'family'], self.actions.edit_tag_list) add_menuitem(tag_menu, _('Organize Tags...'), [handle, 'family'], self.actions.organize_tags) self.add_tags_to_menu(family, 'family', tag_menu) # build spouses menu _item, sp_menu = self.add_submenu(label=_("Spouses")) f_handle = family.get_father_handle() m_handle = family.get_mother_handle() if f_handle: spouse = self.dbstate.db.get_person_from_handle(f_handle) self.add_menuitem(sp_menu, displayer.display(spouse), self.graph_widget.move_to_person, f_handle, True) else: add_menuitem(sp_menu, _('Add father'), [family, 'father'], self.actions.add_spouse_to_family) if m_handle: spouse = self.dbstate.db.get_person_from_handle(m_handle) self.add_menuitem(sp_menu, displayer.display(spouse), self.graph_widget.move_to_person, m_handle, True) else: add_menuitem(sp_menu, _('Add mother'), [family, 'mother'], self.actions.add_spouse_to_family) self.add_children_submenu(family=family) # QuickReports section self.add_separator() q_exists = self.add_quickreport_submenu(CATEGORY_QR_FAMILY, handle) if q_exists: self.add_separator() self.append_help_menu_entry() def add_children_submenu(self, person=None, family=None): """ Go over children and build their menu. """ item, child_menu = self.add_submenu(_("Children")) no_child = True childlist = [] if family: for child_ref in family.get_child_ref_list(): childlist.append(child_ref.ref) # allow to add a child to this family add_menuitem(child_menu, _('Add child to family'), family.get_handle(), self.actions.add_child_to_family) self.add_separator(child_menu) no_child = False elif person: childlist = find_children(self.dbstate.db, person) for child_handle in childlist: child = self.dbstate.db.get_person_from_handle(child_handle) if not child: continue if no_child: no_child = False if find_children(self.dbstate.db, child): label = Gtk.Label(label='%s' % escape(displayer.display(child))) else: label = Gtk.Label(label=escape(displayer.display(child))) child_item = Gtk.MenuItem() label.set_use_markup(True) label.show() label.set_halign(Gtk.Align.START) child_item.add(label) child_item.connect("activate", self.graph_widget.move_to_person, child_handle, True) child_item.show() child_menu.append(child_item) if no_child: item.set_sensitive(0) def add_menuitem(self, menu, label, func, *args): """ Adds menu item. """ item = Gtk.MenuItem(label=label) item.connect("activate", func, *args) item.show() menu.append(item) return item def add_submenu(self, label): """ Adds submenu. """ item = Gtk.MenuItem(label=label) item.set_submenu(Gtk.Menu()) item.show() self.append(item) submenu = item.get_submenu() submenu.set_reserve_toggle_size(False) return item, submenu def menu_no_action(self, *args): """ Do nothing """ return True def add_separator(self, menu=None): """ Adds separator to menu. """ if menu is None: menu = self menu_item = Gtk.SeparatorMenuItem() menu_item.show() menu.append(menu_item) def append_help_menu_entry(self): """ Adds help (about) menu entry. """ item = Gtk.MenuItem(label=_("About Graph View")) item.connect("activate", self.actions.on_help_clicked) item.show() self.append(item) class Actions(Callback): """ Define actions. """ __signals__ = { 'focus-person-changed' : (str, ), 'active-changed' : (str, ), 'rebuild-graph' : None, 'path-to-home-person' : (str, bool), } def __init__(self, dbstate, uistate, bookmarks): """ bookmarks - person bookmarks from GraphView(NavigationView). """ Callback.__init__(self) self.dbstate = dbstate self.uistate = uistate self.bookmarks = bookmarks def on_help_clicked(self, widget): """ Display the relevant portion of Gramps manual. """ display_url(WIKI_PAGE) def add_spouse(self, obj): """ Add spouse to person (create new family to person). See: gramps/plugins/view/relview.py (add_spouse) """ handle = obj.get_data() family = Family() person = self.dbstate.db.get_person_from_handle(handle) if not person: return if person.gender == Person.MALE: family.set_father_handle(person.handle) else: family.set_mother_handle(person.handle) try: EditFamily(self.dbstate, self.uistate, [], family) except WindowActiveError: pass # set edited person to scroll on it after rebuilding graph self.emit('focus-person-changed', (handle, )) def add_spouse_to_family(self, obj): """ Adds spouse to existing family. See: editfamily.py """ family, kind = obj.get_data() try: dialog = EditFamily(self.dbstate, self.uistate, [], family) if kind == 'mother': dialog.add_mother_clicked(None) if kind == 'father': dialog.add_father_clicked(None) except WindowActiveError: pass def edit_person(self, obj, person_handle=None): """ Start a person editor for the selected person. """ if not (obj or person_handle): return False if person_handle: handle = person_handle else: handle = obj.get_data() person = self.dbstate.db.get_person_from_handle(handle) try: EditPerson(self.dbstate, self.uistate, [], person) except WindowActiveError: pass # set edited person to scroll on it after rebuilding graph self.emit('focus-person-changed', (handle, )) def set_home_person(self, obj): """ Set the home person for database and make it active. """ handle = obj.get_data() person = self.dbstate.db.get_person_from_handle(handle) if person: self.dbstate.db.set_default_person_handle(handle) self.emit('active-changed', (handle, )) def path_to_home_person(self, obj): """ Draw the relationship between the active and home people """ handle = obj.get_data() self.emit('path-to-home-person', (handle, True)) def edit_family(self, obj, family_handle=None): """ Start a family editor for the selected family. """ if not (obj or family_handle): return False if family_handle: handle = family_handle else: handle = obj.get_data() family = self.dbstate.db.get_family_from_handle(handle) try: EditFamily(self.dbstate, self.uistate, [], family) except WindowActiveError: pass # set edited family person to scroll on it after rebuilding graph f_handle = family.get_father_handle() if f_handle: self.emit('focus-person-changed', (f_handle, )) else: m_handle = family.get_mother_handle() if m_handle: self.emit('focus-person-changed', (m_handle, )) def edit_family_event(self, obj, family_id, event, evt_ref): """ Edit the events for this family """ try: EditEventRef(self.dbstate, self.uistate, [], event, evt_ref, self.obj_added) except WindowActiveError: pass def edit_person_event(self, obj, person_id, event, evt_ref): """ Edit the events for this person """ try: EditEventRef(self.dbstate, self.uistate, [], event, evt_ref, self.obj_added) except WindowActiveError: pass def obj_added(self, reference, primary): reference.ref = primary.handle def copy_person_to_clipboard(self, obj): """ Renders the person data into some lines of text and puts that into the clipboard. """ person_handle = obj.get_data() person = self.dbstate.db.get_person_from_handle(person_handle) if person: _cb = Gtk.Clipboard.get_for_display(Gdk.Display.get_default(), Gdk.SELECTION_CLIPBOARD) format_helper = FormattingHelper(self.dbstate) _cb.set_text(format_helper.format_person(person, 11), -1) return True return False def edit_tag_list(self, obj): """ Edit tag list for person or family. """ handle, otype = obj.get_data() if otype == 'person': target = self.dbstate.db.get_person_from_handle(handle) self.emit('focus-person-changed', (handle, )) elif otype == 'family': target = self.dbstate.db.get_family_from_handle(handle) f_handle = target.get_father_handle() if f_handle: self.emit('focus-person-changed', (f_handle, )) else: m_handle = target.get_mother_handle() if m_handle: self.emit('focus-person-changed', (m_handle, )) else: return False if target: tag_list = [] for tag_handle in target.get_tag_list(): tag = self.dbstate.db.get_tag_from_handle(tag_handle) if tag: tag_list.append((tag_handle, tag.get_name())) all_tags = [] for tag_handle in self.dbstate.db.get_tag_handles( sort_handles=True): tag = self.dbstate.db.get_tag_from_handle(tag_handle) all_tags.append((tag.get_handle(), tag.get_name())) try: editor = EditTagList(tag_list, all_tags, self.uistate, []) if editor.return_list is not None: tag_list = editor.return_list # Save tags to target object. # Make the dialog modal so that the user can't start # another database transaction while the one setting # tags is still running. pmon = progressdlg.ProgressMonitor( progressdlg.GtkProgressDialog, ("", self.uistate.window, Gtk.DialogFlags.MODAL), popup_time=2) status = progressdlg.LongOpStatus(msg=_("Adding Tags"), total_steps=1, interval=1 // 20) pmon.add_op(status) target.set_tag_list([item[0] for item in tag_list]) if otype == 'person': msg = _('Adding Tags to person (%s)') % handle with DbTxn(msg, self.dbstate.db) as trans: self.dbstate.db.commit_person(target, trans) status.heartbeat() else: msg = _('Adding Tags to family (%s)') % handle with DbTxn(msg, self.dbstate.db) as trans: self.dbstate.db.commit_family(target, trans) status.heartbeat() status.end() except WindowActiveError: pass def organize_tags(self, obj): """ Display the Organize Tags dialog. see: .gramps.gui.view.tags """ handle, otype = obj.get_data() if otype == 'person': target = self.dbstate.db.get_person_from_handle(handle) self.emit('focus-person-changed', (handle, )) elif otype == 'family': target = self.dbstate.db.get_family_from_handle(handle) f_handle = target.get_father_handle() if f_handle: self.emit('focus-person-changed', (f_handle, )) else: m_handle = target.get_mother_handle() if m_handle: self.emit('focus-person-changed', (m_handle, )) OrganizeTagsDialog(self.dbstate.db, self.uistate, []) self.emit('rebuild-graph') def add_tag_to_object(self, obj, data): handle, otype, tag_hdle = data if otype == 'person': target = self.dbstate.db.get_person_from_handle(handle) old_tags = target.get_tag_list() if tag_hdle in old_tags: old_tags.remove(tag_hdle) else: old_tags.append(tag_hdle) target.set_tag_list(old_tags) self.emit('focus-person-changed', (handle, )) msg = _('Adding Tags to person (%s)') % handle with DbTxn(msg, self.dbstate.db) as trans: self.dbstate.db.commit_person(target, trans) if otype == 'family': target = self.dbstate.db.get_family_from_handle(handle) old_tags = target.get_tag_list() if tag_hdle in old_tags: old_tags.remove(tag_hdle) else: old_tags.append(tag_hdle) target.set_tag_list(old_tags) msg = _('Adding Tags to family (%s)') % handle with DbTxn(msg, self.dbstate.db) as trans: self.dbstate.db.commit_family(target, trans) self.emit('rebuild-graph') def add_parents_to_person(self, obj): """ Open dialog to add parents to person. """ person_handle = obj.get_data() family = Family() childref = ChildRef() childref.set_reference_handle(person_handle) family.add_child_ref(childref) try: EditFamily(self.dbstate, self.uistate, [], family) except WindowActiveError: return # set edited person to scroll on it after rebuilding graph self.emit('focus-person-changed', (person_handle, )) def add_child_to_family(self, obj): """ Open person editor to create and add child to family. """ family_handle = obj.get_data() callback = lambda x: self.__callback_add_child(x, family_handle) person = Person() name = Name() # the editor requires a surname name.add_surname(Surname()) name.set_primary_surname(0) family = self.dbstate.db.get_family_from_handle(family_handle) # try to get father father_handle = family.get_father_handle() if father_handle: father = self.dbstate.db.get_person_from_handle(father_handle) if father: preset_name(father, name) person.set_primary_name(name) try: EditPerson(self.dbstate, self.uistate, [], person, callback=callback) except WindowActiveError: pass def __callback_add_child(self, person, family_handle): """ Write data to db. Callback from self.add_child_to_family(). """ ref = ChildRef() ref.ref = person.get_handle() family = self.dbstate.db.get_family_from_handle(family_handle) family.add_child_ref(ref) with DbTxn(_("Add Child to Family"), self.dbstate.db) as trans: # add parentref to child person.add_parent_family_handle(family_handle) # default relationship is used self.dbstate.db.commit_person(person, trans) # add child to family self.dbstate.db.commit_family(family, trans) def remove_person(self, obj): """ Remove a person from the database. see: libpersonview.py """ person = obj.get_data() msg1 = _('Delete %s?') % displayer.display(person) msg2 = (_('Deleting the person [%s] will remove it ' 'from the database.') % person.gramps_id) dialog = QuestionDialog2(msg1, msg2, _("Yes"), _("No"), self.uistate.window) if dialog.run(): # set the busy cursor, so the user knows that we are working self.uistate.set_busy_cursor(True) # create the transaction with DbTxn('', self.dbstate.db) as trans: # create description to save description = (_("Delete Person (%s)") % displayer.display(person)) # delete the person from the database # Above will emit person-delete signal self.dbstate.db.delete_person_from_database(person, trans) trans.set_description(description) self.uistate.set_busy_cursor(False) def remove_family(self, obj): """ Remove a family from the database. see: familyview.py """ family = obj.get_data() msg1 = _('Delete family [%s]?') % family.gramps_id msg2 = _('Deleting the family will remove it from the database.') dialog = QuestionDialog2(msg1, msg2, _("Yes"), _("No"), self.uistate.window) if dialog.run(): # set the busy cursor, so the user knows that we are working self.uistate.set_busy_cursor(True) # create the transaction with DbTxn('', self.dbstate.db) as trans: # create description to save description = _("Delete Family [%s]") % family.gramps_id # delete the family from the database self.dbstate.db.remove_family_relationships(family.handle, trans) trans.set_description(description) self.uistate.set_busy_cursor(False) def add_to_bookmarks(self, obj): """ Adds bookmark for person. See: navigationview.py and bookmarks.py """ handle, person = obj.get_data() self.bookmarks.add(handle) name = displayer.display(person) self.uistate.push_message(self.dbstate, _("%s has been bookmarked") % name) def remove_from_bookmarks(self, obj): """ Remove person from the list of bookmarked people. See: bookmarks.py """ handle = obj.get_data() self.bookmarks.remove_handles([handle])