# -*- 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, "<PRIMARY><SHIFT>P")
        self._add_action('PRIMARY-J', self.jump, '<PRIMARY>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
        '''
      <placeholder id="CommonGo">
      <section>
        <item>
          <attribute name="action">win.Back</attribute>
          <attribute name="label" translatable="yes">_Back</attribute>
        </item>
        <item>
          <attribute name="action">win.Forward</attribute>
          <attribute name="label" translatable="yes">_Forward</attribute>
        </item>
      </section>
      <section>
        <item>
          <attribute name="action">win.HomePerson</attribute>
          <attribute name="label" translatable="yes">_Home</attribute>
        </item>
      </section>
      </placeholder>
''',
        '''
      <section id='CommonEdit' groups='RW'>
        <item>
          <attribute name="action">win.PrintView</attribute>
          <attribute name="label" translatable="yes">_Print...</attribute>
        </item>
      </section>
''',  # Following are the Toolbar items
        '''
    <placeholder id='CommonNavigation'>
    <child groups='RO'>
      <object class="GtkToolButton">
        <property name="icon-name">go-previous</property>
        <property name="action-name">win.Back</property>
        <property name="tooltip_text" translatable="yes">'''
        '''Go to the previous object in the history</property>
        <property name="label" translatable="yes">_Back</property>
        <property name="use-underline">True</property>
      </object>
      <packing>
        <property name="homogeneous">False</property>
      </packing>
    </child>
    <child groups='RO'>
      <object class="GtkToolButton">
        <property name="icon-name">go-next</property>
        <property name="action-name">win.Forward</property>
        <property name="tooltip_text" translatable="yes">'''
        '''Go to the next object in the history</property>
        <property name="label" translatable="yes">_Forward</property>
        <property name="use-underline">True</property>
      </object>
      <packing>
        <property name="homogeneous">False</property>
      </packing>
    </child>
    <child groups='RO'>
      <object class="GtkToolButton">
        <property name="icon-name">go-home</property>
        <property name="action-name">win.HomePerson</property>
        <property name="tooltip_text" translatable="yes">'''
        '''Go to the default person</property>
        <property name="label" translatable="yes">_Home</property>
        <property name="use-underline">True</property>
      </object>
      <packing>
        <property name="homogeneous">False</property>
      </packing>
    </child>
    </placeholder>
''',
        '''
    <placeholder id='BarCommonEdit'>
    <child groups='RO'>
      <object class="GtkToolButton">
        <property name="icon-name">document-print</property>
        <property name="action-name">win.PrintView</property>
        <property name="tooltip_text" translatable="yes">"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"</property>
        <property name="label" translatable="yes">_Print...</property>
        <property name="use-underline">True</property>
      </object>
      <packing>
        <property name="homogeneous">False</property>
      </packing>
    </child>
    </placeholder>
''']

    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(
            '<b>%s</b>' % 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(
            '<b>%s</b>' % 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('<b>%s</b>' % 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('<b>%s</b>' % 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('<b>%s</b>' % 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('<b>%s</b>' % 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 <b><i>%s</i></b> 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', '<B>%s</B>' % 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),
                '<FONT POINT-SIZE="%(bsize)3.1f"><B>%(text)s</B></FONT>' %
                {'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='<b>' + text + '</b>',
                                      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 <g> tags.
        """
        # The class attribute defines the group type. There should be one
        # graph type <g> 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 </g> tags.
        """
        item = self.item_hier.pop()
        item.title = self.handle

    def start_svg(self, attrs):
        """
        Parse <svg> 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 </svg> tags.
        """
        pass

    def start_title(self, attrs):
        """
        Parse <title> tags.
        """
        pass

    def stop_title(self, tag):
        """
        Parse </title> tags.
        Stripping off underscore prefix added to fool Graphviz.
        """
        self.handle = tag.lstrip("_")

    def start_polygon(self, attrs):
        """
        Parse <polygon> 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 </polygon> tags.
        """
        self.item_hier.pop()

    def start_ellipse(self, attrs):
        """
        Parse <ellipse> 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 </ellipse> tags.
        """
        self.item_hier.pop()

    def start_path(self, attrs):
        """
        Parse <path> 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 </path> tags.
        """
        self.item_hier.pop()

    def start_text(self, attrs):
        """
        Parse <text> tags.
        """
        self.text_attrs = attrs

    def stop_text(self, tag):
        """
        Parse </text> 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 = '<b>%s</b>' % 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 <image> 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 </image> 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 = '<FONT FACE="%s">%s</FONT>' % (self.sym_font, self.bth)
        self.dth = '<FONT FACE="%s">%s</FONT>' % (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 = ('<TABLE BORDER="0" CELLBORDER="0" '
                         'CELLPADDING="5"><TR>')
            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 += '<TD BGCOLOR="%s"></TD>' % value
            tag_table += '</TR></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'),
             '<TABLE '
             'BORDER="0" CELLSPACING="2" CELLPADDING="0" CELLBORDER="0">'
             '<TR><TD>%(img)s</TD></TR>'
             '<TR><TD><FONT POINT-SIZE="%(bsize)3.1f"><B>%(name)s</B>'
             '</FONT></TD></TR>'
             '<TR><TD ALIGN="LEFT">%(birth_str)s</TD></TR>'
             '<TR><TD ALIGN="LEFT">%(death_str)s</TD></TR>'
             '<TR><TD>%(tags)s</TD></TR>'
             '</TABLE>'
             ),
            (1, _('Image on right side'),
             '<TABLE '
             'BORDER="0" CELLSPACING="5" CELLPADDING="0" CELLBORDER="0">'
             '<tr>'
             '<td colspan="2"><FONT POINT-SIZE="%(bsize)3.1f"><B>%(name)s'
             '</B></FONT></td>'
             '</tr>'
             '<tr>'
             '<td ALIGN="LEFT" BALIGN="LEFT" CELLPADDING="5">%(birth_wraped)s'
             '</td>'
             '<td rowspan="2">%(img)s</td>'
             '</tr>'
             '<tr>'
             '<td ALIGN="LEFT" BALIGN="LEFT" CELLPADDING="5">%(death_wraped)s'
             '</td>'
             '</tr>'
             '<tr>'
             '  <td colspan="2">%(tags)s</td>'
             '</tr>'
             '</TABLE>'
             ),
            (2, _('Image on left side'),
             '<TABLE '
             'BORDER="0" CELLSPACING="5" CELLPADDING="0" CELLBORDER="0">'
             '<tr>'
             '<td colspan="2"><FONT POINT-SIZE="%(bsize)3.1f"><B>%(name)s'
             '</B></FONT></td>'
             '</tr>'
             '<tr>'
             '<td rowspan="2">%(img)s</td>'
             '<td ALIGN="LEFT" BALIGN="LEFT" CELLPADDING="5">%(birth_wraped)s'
             '</td>'
             '</tr>'
             '<tr>'
             '<td ALIGN="LEFT" BALIGN="LEFT" CELLPADDING="5">%(death_wraped)s'
             '</td>'
             '</tr>'
             '<tr>'
             '  <td colspan="2">%(tags)s</td>'
             '</tr>'
             '</TABLE>'
             ),
            (3, _('Normal'),
             '<TABLE '
             'BORDER="0" CELLSPACING="2" CELLPADDING="0" CELLBORDER="0">'
             '<TR><TD>%(img)s</TD></TR>'
             '<TR><TD><FONT POINT-SIZE="%(bsize)3.1f"><B>%(name)s'
             '</B></FONT></TD></TR>'
             '<TR><TD ALIGN="LEFT" BALIGN="LEFT">%(birth_wraped)s</TD></TR>'
             '<TR><TD ALIGN="LEFT" BALIGN="LEFT">%(death_wraped)s</TD></TR>'
             '<TR><TD>%(tags)s</TD></TR>'
             '</TABLE>'
             )]

        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 = '<IMG SRC="%s"/>' % 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 += '<BR/>'
                    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 += '<BR/>'
                    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 = ('<TABLE '
                 'BORDER="0" CELLSPACING="2" CELLPADDING="0" CELLBORDER="0">')

        # 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<BR/>%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 += '<TR><TD>%s</TD></TR>' % 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 += '<TR><TD>%s</TD></TR>' % tag_table
                self.add_tags_tooltip(family.handle, tags)

        # close main table
        label += '</TABLE>'

        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 = _('<b>Tags:</b>')
        for tag in tag_list:
            tooltip_str += ('\n<span background="%s">  </span> - %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(
            '<b>%s</b>' % 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(
            '<b>%s</b>' % 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='<b><i>%s</i></b>'
                                % 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='<b><i>%s</i></b>'
                                      % 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='<b><i>%s</i></b>'
                                  % 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])
