- Added 12 different event types (Baptism, Education, Occupation, etc.) - Fixed missing event references by storing and reusing original events - Made event generation deterministic with random seed - Updated gen_person to return both XML and tuple format for event reuse - All event references now properly defined and validated - Demo family now includes 240+ additional events for comprehensive testing
1244 lines
47 KiB
Python
1244 lines
47 KiB
Python
# -*- coding: utf-8 -*-
|
|
#
|
|
# Gramps - a GTK+/GNOME based genealogy program
|
|
#
|
|
# Copyright (C) 2024
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 2 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License along
|
|
# with this program; if not, see <https://www.gnu.org/licenses/>.
|
|
#
|
|
|
|
"""
|
|
MyTimeline View - A vertical timeline showing family events
|
|
"""
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# Python modules
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
import cairo
|
|
import math
|
|
from gi.repository import Gtk
|
|
from gi.repository import Gdk
|
|
from gi.repository import GLib
|
|
from gi.repository import Pango
|
|
from gi.repository import PangoCairo
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# Gramps modules
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
from gramps.gen.const import GRAMPS_LOCALE as glocale
|
|
from gramps.gen.lib import EventType, Date
|
|
from gramps.gen.utils.db import (
|
|
get_birth_or_fallback,
|
|
get_death_or_fallback,
|
|
get_marriage_or_fallback,
|
|
)
|
|
from gramps.gen.datehandler import get_date
|
|
from gramps.gen.display.name import displayer as name_displayer
|
|
from gramps.gui.views.navigationview import NavigationView
|
|
from gramps.gui.views.bookmarks import FamilyBookmarks
|
|
from gramps.gen.utils.libformatting import FormattingHelper
|
|
|
|
_ = glocale.translation.sgettext
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# Constants
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
TIMELINE_MARGIN_LEFT = 150
|
|
TIMELINE_MARGIN_RIGHT = 50
|
|
TIMELINE_MARGIN_TOP = 50
|
|
TIMELINE_MARGIN_BOTTOM = 50
|
|
TIMELINE_LINE_WIDTH = 3
|
|
EVENT_MARKER_SIZE = 10
|
|
EVENT_SPACING = 80
|
|
YEAR_LABEL_WIDTH = 100
|
|
EXPANDED_HEIGHT = 120
|
|
TOOLTIP_DELAY = 500 # milliseconds
|
|
|
|
# Event type categories and color mapping
|
|
EVENT_COLORS = {
|
|
# Life Events - Green/Red spectrum
|
|
EventType.BIRTH: (0.2, 0.8, 0.3), # Bright green
|
|
EventType.DEATH: (0.9, 0.2, 0.2), # Bright red
|
|
EventType.BURIAL: (0.7, 0.3, 0.3), # Dark red
|
|
EventType.CREMATION: (0.8, 0.4, 0.4), # Light red
|
|
EventType.ADOPT: (0.3, 0.7, 0.4), # Green
|
|
|
|
# Family Events - Blue/Purple spectrum
|
|
EventType.MARRIAGE: (0.2, 0.4, 0.9), # Blue
|
|
EventType.DIVORCE: (0.6, 0.3, 0.7), # Purple
|
|
EventType.ENGAGEMENT: (0.4, 0.5, 0.9), # Light blue
|
|
EventType.MARR_SETTL: (0.3, 0.4, 0.8), # Dark blue
|
|
EventType.MARR_LIC: (0.4, 0.5, 0.85), # Medium blue
|
|
EventType.MARR_CONTR: (0.35, 0.45, 0.85), # Medium blue
|
|
EventType.MARR_BANNS: (0.45, 0.55, 0.9), # Light blue
|
|
EventType.DIV_FILING: (0.65, 0.35, 0.75), # Light purple
|
|
EventType.ANNULMENT: (0.7, 0.4, 0.8), # Light purple
|
|
EventType.MARR_ALT: (0.5, 0.5, 0.9), # Medium blue
|
|
|
|
# Religious Events - Gold/Yellow spectrum
|
|
EventType.BAPTISM: (0.95, 0.85, 0.2), # Gold
|
|
EventType.ADULT_CHRISTEN: (0.9, 0.8, 0.3), # Light gold
|
|
EventType.CONFIRMATION: (0.95, 0.9, 0.4), # Yellow
|
|
EventType.CHRISTEN: (0.9, 0.85, 0.35), # Gold
|
|
EventType.FIRST_COMMUN: (0.95, 0.88, 0.3), # Gold
|
|
EventType.BLESS: (0.92, 0.87, 0.4), # Light gold
|
|
EventType.BAR_MITZVAH: (0.93, 0.83, 0.3), # Gold
|
|
EventType.BAS_MITZVAH: (0.94, 0.84, 0.3), # Gold
|
|
EventType.RELIGION: (0.9, 0.8, 0.5), # Light gold
|
|
EventType.ORDINATION: (0.88, 0.75, 0.3), # Dark gold
|
|
|
|
# Vocational Events - Orange spectrum
|
|
EventType.OCCUPATION: (0.95, 0.6, 0.2), # Orange
|
|
EventType.RETIREMENT: (0.9, 0.55, 0.25), # Dark orange
|
|
EventType.ELECTED: (0.95, 0.65, 0.3), # Light orange
|
|
EventType.MILITARY_SERV: (0.85, 0.5, 0.2), # Dark orange
|
|
|
|
# Academic Events - Teal spectrum
|
|
EventType.EDUCATION: (0.2, 0.7, 0.7), # Teal
|
|
EventType.GRADUATION: (0.3, 0.8, 0.8), # Bright teal
|
|
EventType.DEGREE: (0.25, 0.75, 0.75), # Medium teal
|
|
|
|
# Travel Events - Cyan spectrum
|
|
EventType.EMIGRATION: (0.2, 0.7, 0.9), # Cyan
|
|
EventType.IMMIGRATION: (0.3, 0.8, 0.95), # Bright cyan
|
|
EventType.NATURALIZATION: (0.25, 0.75, 0.9), # Medium cyan
|
|
|
|
# Legal Events - Brown spectrum
|
|
EventType.PROBATE: (0.6, 0.4, 0.2), # Brown
|
|
EventType.WILL: (0.65, 0.45, 0.25), # Light brown
|
|
|
|
# Residence Events - Indigo spectrum
|
|
EventType.RESIDENCE: (0.4, 0.3, 0.7), # Indigo
|
|
EventType.CENSUS: (0.5, 0.4, 0.8), # Light indigo
|
|
EventType.PROPERTY: (0.45, 0.35, 0.75), # Medium indigo
|
|
|
|
# Other Events - Gray spectrum
|
|
EventType.CAUSE_DEATH: (0.5, 0.5, 0.5), # Gray
|
|
EventType.MED_INFO: (0.6, 0.6, 0.6), # Light gray
|
|
EventType.NOB_TITLE: (0.55, 0.55, 0.55), # Medium gray
|
|
EventType.NUM_MARRIAGES: (0.5, 0.5, 0.5), # Gray
|
|
EventType.UNKNOWN: (0.4, 0.4, 0.4), # Dark gray
|
|
EventType.CUSTOM: (0.45, 0.45, 0.45), # Medium gray
|
|
}
|
|
|
|
# Event shape types: 'triangle', 'circle', 'diamond', 'square', 'star', 'hexagon'
|
|
EVENT_SHAPES = {
|
|
# Life Events
|
|
EventType.BIRTH: 'triangle',
|
|
EventType.DEATH: 'circle',
|
|
EventType.BURIAL: 'circle',
|
|
EventType.CREMATION: 'circle',
|
|
EventType.ADOPT: 'triangle',
|
|
|
|
# Family Events
|
|
EventType.MARRIAGE: 'diamond',
|
|
EventType.DIVORCE: 'square',
|
|
EventType.ENGAGEMENT: 'diamond',
|
|
EventType.MARR_SETTL: 'diamond',
|
|
EventType.MARR_LIC: 'diamond',
|
|
EventType.MARR_CONTR: 'diamond',
|
|
EventType.MARR_BANNS: 'diamond',
|
|
EventType.DIV_FILING: 'square',
|
|
EventType.ANNULMENT: 'square',
|
|
EventType.MARR_ALT: 'diamond',
|
|
|
|
# Religious Events
|
|
EventType.BAPTISM: 'star',
|
|
EventType.ADULT_CHRISTEN: 'star',
|
|
EventType.CONFIRMATION: 'star',
|
|
EventType.CHRISTEN: 'star',
|
|
EventType.FIRST_COMMUN: 'star',
|
|
EventType.BLESS: 'star',
|
|
EventType.BAR_MITZVAH: 'star',
|
|
EventType.BAS_MITZVAH: 'star',
|
|
EventType.RELIGION: 'star',
|
|
EventType.ORDINATION: 'star',
|
|
|
|
# Vocational Events
|
|
EventType.OCCUPATION: 'hexagon',
|
|
EventType.RETIREMENT: 'hexagon',
|
|
EventType.ELECTED: 'hexagon',
|
|
EventType.MILITARY_SERV: 'hexagon',
|
|
|
|
# Academic Events
|
|
EventType.EDUCATION: 'square',
|
|
EventType.GRADUATION: 'square',
|
|
EventType.DEGREE: 'square',
|
|
|
|
# Travel Events
|
|
EventType.EMIGRATION: 'triangle',
|
|
EventType.IMMIGRATION: 'triangle',
|
|
EventType.NATURALIZATION: 'triangle',
|
|
|
|
# Legal Events
|
|
EventType.PROBATE: 'square',
|
|
EventType.WILL: 'square',
|
|
|
|
# Residence Events
|
|
EventType.RESIDENCE: 'square',
|
|
EventType.CENSUS: 'square',
|
|
EventType.PROPERTY: 'square',
|
|
|
|
# Other Events
|
|
EventType.CAUSE_DEATH: 'circle',
|
|
EventType.MED_INFO: 'circle',
|
|
EventType.NOB_TITLE: 'square',
|
|
EventType.NUM_MARRIAGES: 'square',
|
|
EventType.UNKNOWN: 'square',
|
|
EventType.CUSTOM: 'square',
|
|
}
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# MyTimelineView
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
class MyTimelineView(NavigationView):
|
|
"""
|
|
View for displaying a vertical timeline of family events.
|
|
Shows all events for family members with modern design and interactivity.
|
|
"""
|
|
|
|
def __init__(self, pdata, dbstate, uistate, nav_group=0):
|
|
NavigationView.__init__(
|
|
self, _("MyTimeline"), pdata, dbstate, uistate, FamilyBookmarks, nav_group
|
|
)
|
|
|
|
self.dbstate = dbstate
|
|
self.uistate = uistate
|
|
self.format_helper = FormattingHelper(self.dbstate, self.uistate)
|
|
|
|
# Current family handle
|
|
self.active_family_handle = None
|
|
self.events = [] # List of (date_sort, date_obj, event, person, event_type, expanded, y_pos)
|
|
|
|
# UI components
|
|
self.scrolledwindow = None
|
|
self.drawing_area = None
|
|
self.timeline_height = 1000 # Default height, will be recalculated
|
|
self.zoom_level = 1.0 # Zoom level (1.0 = 100%)
|
|
self.min_zoom = 0.5
|
|
self.max_zoom = 3.0
|
|
self.zoom_step = 0.1
|
|
|
|
# Interaction state
|
|
self.hovered_event_index = None
|
|
self.tooltip_timeout_id = None
|
|
self.tooltip_window = None
|
|
self.expanded_event_index = None
|
|
self.selected_person_handle = None
|
|
self.mouse_x = 0
|
|
self.mouse_y = 0
|
|
|
|
# Connect to database changes
|
|
self.dbstate.connect("database-changed", self.change_db)
|
|
# Connect to family updates
|
|
if self.dbstate.is_open():
|
|
self.dbstate.db.connect("family-update", self.family_updated)
|
|
self.dbstate.db.connect("person-update", self.person_updated)
|
|
self.dbstate.db.connect("event-update", self.event_updated)
|
|
|
|
def navigation_type(self):
|
|
"""Return the navigation type for this view."""
|
|
return "Family"
|
|
|
|
def change_page(self):
|
|
"""Called when the page changes."""
|
|
NavigationView.change_page(self)
|
|
active_handle = self.get_active()
|
|
if active_handle:
|
|
self.goto_handle(active_handle)
|
|
|
|
def family_updated(self, handle_list):
|
|
"""Called when a family is updated."""
|
|
if self.active_family_handle in handle_list:
|
|
self.collect_events()
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def person_updated(self, handle_list):
|
|
"""Called when a person is updated."""
|
|
# Check if any updated person is related to current family
|
|
if self.active_family_handle:
|
|
try:
|
|
family = self.dbstate.db.get_family_from_handle(self.active_family_handle)
|
|
if family:
|
|
father_handle = family.get_father_handle()
|
|
mother_handle = family.get_mother_handle()
|
|
child_handles = [ref.ref for ref in family.get_child_ref_list()]
|
|
if (
|
|
(father_handle and father_handle in handle_list)
|
|
or (mother_handle and mother_handle in handle_list)
|
|
or any(h in handle_list for h in child_handles)
|
|
):
|
|
self.collect_events()
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
except:
|
|
pass
|
|
|
|
def event_updated(self, handle_list):
|
|
"""Called when an event is updated."""
|
|
# Re-collect events if we have an active family
|
|
if self.active_family_handle:
|
|
self.collect_events()
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def change_db(self, db):
|
|
"""Called when the database changes."""
|
|
self.active_family_handle = None
|
|
self.events = []
|
|
|
|
# Connect to new database signals
|
|
if db and db.is_open():
|
|
db.connect("family-update", self.family_updated)
|
|
db.connect("person-update", self.person_updated)
|
|
db.connect("event-update", self.event_updated)
|
|
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def build_widget(self):
|
|
"""Build the interface and return the container."""
|
|
# Main container
|
|
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
|
|
|
|
# Toolbar with zoom controls
|
|
toolbar = Gtk.Toolbar()
|
|
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
|
|
|
|
# Zoom out button
|
|
zoom_out_btn = Gtk.ToolButton(icon_name="zoom-out-symbolic")
|
|
zoom_out_btn.set_tooltip_text(_("Zoom Out"))
|
|
zoom_out_btn.connect("clicked", self.on_zoom_out)
|
|
toolbar.insert(zoom_out_btn, 0)
|
|
|
|
# Zoom label
|
|
self.zoom_label = Gtk.Label(label="100%")
|
|
self.zoom_label.set_margin_start(10)
|
|
self.zoom_label.set_margin_end(10)
|
|
zoom_item = Gtk.ToolItem()
|
|
zoom_item.add(self.zoom_label)
|
|
toolbar.insert(zoom_item, 1)
|
|
|
|
# Zoom in button
|
|
zoom_in_btn = Gtk.ToolButton(icon_name="zoom-in-symbolic")
|
|
zoom_in_btn.set_tooltip_text(_("Zoom In"))
|
|
zoom_in_btn.connect("clicked", self.on_zoom_in)
|
|
toolbar.insert(zoom_in_btn, 2)
|
|
|
|
# Reset zoom button
|
|
zoom_reset_btn = Gtk.ToolButton(icon_name="zoom-fit-best-symbolic")
|
|
zoom_reset_btn.set_tooltip_text(_("Reset Zoom"))
|
|
zoom_reset_btn.connect("clicked", self.on_zoom_reset)
|
|
toolbar.insert(zoom_reset_btn, 3)
|
|
|
|
toolbar.insert(Gtk.SeparatorToolItem(), 4)
|
|
|
|
main_box.pack_start(toolbar, False, False, 0)
|
|
|
|
# Scrolled window
|
|
self.scrolledwindow = Gtk.ScrolledWindow()
|
|
self.scrolledwindow.set_policy(
|
|
Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC
|
|
)
|
|
|
|
self.drawing_area = Gtk.DrawingArea()
|
|
self.drawing_area.set_size_request(800, 600)
|
|
self.drawing_area.connect("draw", self.on_draw)
|
|
self.drawing_area.add_events(
|
|
Gdk.EventMask.BUTTON_PRESS_MASK
|
|
| Gdk.EventMask.BUTTON_RELEASE_MASK
|
|
| Gdk.EventMask.POINTER_MOTION_MASK
|
|
| Gdk.EventMask.LEAVE_NOTIFY_MASK
|
|
| Gdk.EventMask.SCROLL_MASK
|
|
)
|
|
|
|
# Connect mouse events
|
|
self.drawing_area.connect("button-press-event", self.on_button_press)
|
|
self.drawing_area.connect("motion-notify-event", self.on_motion_notify)
|
|
self.drawing_area.connect("leave-notify-event", self.on_leave_notify)
|
|
self.drawing_area.connect("scroll-event", self.on_scroll)
|
|
|
|
self.scrolledwindow.add(self.drawing_area)
|
|
main_box.pack_start(self.scrolledwindow, True, True, 0)
|
|
|
|
return main_box
|
|
|
|
def build_tree(self):
|
|
"""Rebuilds the current display. Called when the view becomes visible."""
|
|
active_handle = self.get_active()
|
|
if active_handle:
|
|
self.goto_handle(active_handle)
|
|
|
|
def goto_handle(self, handle):
|
|
"""Called when the active family changes."""
|
|
if handle == self.active_family_handle:
|
|
return
|
|
|
|
self.active_family_handle = handle
|
|
self.expanded_event_index = None
|
|
self.collect_events()
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def collect_events(self):
|
|
"""Collect all events for the active family."""
|
|
self.events = []
|
|
|
|
if not self.active_family_handle:
|
|
return
|
|
|
|
try:
|
|
family = self.dbstate.db.get_family_from_handle(self.active_family_handle)
|
|
except:
|
|
return
|
|
|
|
if not family:
|
|
return
|
|
|
|
# Get family events (marriage, divorce, etc.)
|
|
for event_ref in family.get_event_ref_list():
|
|
try:
|
|
event = self.dbstate.db.get_event_from_handle(event_ref.ref)
|
|
if event and event.get_date_object():
|
|
date_obj = event.get_date_object()
|
|
event_type = event.get_type()
|
|
self.events.append(
|
|
(
|
|
date_obj.get_sort_value(),
|
|
date_obj,
|
|
event,
|
|
None, # No person for family events
|
|
event_type,
|
|
False, # expanded
|
|
0, # y_pos (will be calculated during draw)
|
|
)
|
|
)
|
|
except:
|
|
pass
|
|
|
|
# Helper function to collect all events from a person
|
|
def collect_person_events(person, person_obj):
|
|
"""Collect all events from a person."""
|
|
if not person:
|
|
return
|
|
try:
|
|
# Get all event references from the person
|
|
for event_ref in person.get_event_ref_list():
|
|
try:
|
|
event = self.dbstate.db.get_event_from_handle(event_ref.ref)
|
|
if event and event.get_date_object():
|
|
date_obj = event.get_date_object()
|
|
event_type = event.get_type()
|
|
self.events.append(
|
|
(
|
|
date_obj.get_sort_value(),
|
|
date_obj,
|
|
event,
|
|
person_obj,
|
|
event_type,
|
|
False, # expanded
|
|
0, # y_pos
|
|
)
|
|
)
|
|
except:
|
|
pass
|
|
except:
|
|
pass
|
|
|
|
# Get father's events
|
|
father_handle = family.get_father_handle()
|
|
if father_handle:
|
|
try:
|
|
father = self.dbstate.db.get_person_from_handle(father_handle)
|
|
if father:
|
|
collect_person_events(father, father)
|
|
except:
|
|
pass
|
|
|
|
# Get mother's events
|
|
mother_handle = family.get_mother_handle()
|
|
if mother_handle:
|
|
try:
|
|
mother = self.dbstate.db.get_person_from_handle(mother_handle)
|
|
if mother:
|
|
collect_person_events(mother, mother)
|
|
except:
|
|
pass
|
|
|
|
# Get children's events
|
|
for child_ref in family.get_child_ref_list():
|
|
child_handle = child_ref.ref
|
|
try:
|
|
child = self.dbstate.db.get_person_from_handle(child_handle)
|
|
if child:
|
|
collect_person_events(child, child)
|
|
except:
|
|
pass
|
|
|
|
# Sort events by date
|
|
self.events.sort(key=lambda x: x[0])
|
|
|
|
# Calculate timeline height based on number of events and zoom
|
|
if self.events:
|
|
base_height = (
|
|
TIMELINE_MARGIN_TOP
|
|
+ len(self.events) * EVENT_SPACING
|
|
+ TIMELINE_MARGIN_BOTTOM
|
|
)
|
|
self.timeline_height = int(base_height * self.zoom_level)
|
|
else:
|
|
self.timeline_height = int(600 * self.zoom_level)
|
|
|
|
if self.drawing_area:
|
|
self.drawing_area.set_size_request(800, self.timeline_height)
|
|
|
|
def detect_label_overlaps(self, context, events_with_y_pos, timeline_y_start, timeline_y_end):
|
|
"""Detect and adjust Y positions to prevent label overlaps."""
|
|
if not events_with_y_pos:
|
|
return events_with_y_pos
|
|
|
|
# Create a temporary layout to measure text
|
|
layout = PangoCairo.create_layout(context)
|
|
layout.set_font_description(Pango.font_description_from_string("Sans 11"))
|
|
|
|
adjusted_events = []
|
|
min_spacing = 30 # Minimum spacing between labels in pixels
|
|
|
|
for i, event_data in enumerate(events_with_y_pos):
|
|
date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data
|
|
|
|
# Calculate label height
|
|
if person:
|
|
person_name = name_displayer.display(person)
|
|
date_str = get_date(event)
|
|
event_type_str = str(event_type)
|
|
label_text = f"{date_str} - {event_type_str} - {person_name}"
|
|
else:
|
|
date_str = get_date(event)
|
|
event_type_str = str(event_type)
|
|
label_text = f"{date_str} - {event_type_str}"
|
|
|
|
layout.set_text(label_text, -1)
|
|
text_width, text_height = layout.get_pixel_size()
|
|
label_height = text_height + 16 # Add padding
|
|
|
|
# Check for overlap with previous events
|
|
adjusted_y = y_pos
|
|
for prev_data in adjusted_events:
|
|
prev_y_pos = prev_data[6] # Get y_pos from previous event
|
|
# Check if labels would overlap
|
|
if abs(adjusted_y - prev_y_pos) < min_spacing:
|
|
# Adjust downward
|
|
adjusted_y = prev_y_pos + min_spacing
|
|
|
|
# Ensure adjusted position is within bounds
|
|
adjusted_y = max(timeline_y_start, min(adjusted_y, timeline_y_end))
|
|
|
|
# Create new event data with adjusted Y position
|
|
adjusted_events.append((date_sort, date_obj, event, person, event_type, expanded, adjusted_y))
|
|
|
|
return adjusted_events
|
|
|
|
def on_zoom_in(self, widget):
|
|
"""Zoom in."""
|
|
if self.zoom_level < self.max_zoom:
|
|
self.zoom_level = min(self.zoom_level + self.zoom_step, self.max_zoom)
|
|
self.update_zoom_display()
|
|
self.collect_events() # Recalculate height
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def on_zoom_out(self, widget):
|
|
"""Zoom out."""
|
|
if self.zoom_level > self.min_zoom:
|
|
self.zoom_level = max(self.zoom_level - self.zoom_step, self.min_zoom)
|
|
self.update_zoom_display()
|
|
self.collect_events() # Recalculate height
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def on_zoom_reset(self, widget):
|
|
"""Reset zoom to 100%."""
|
|
self.zoom_level = 1.0
|
|
self.update_zoom_display()
|
|
self.collect_events() # Recalculate height
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def on_scroll(self, widget, event):
|
|
"""Handle scroll events for zooming with Ctrl+scroll."""
|
|
if event.state & Gdk.ModifierType.CONTROL_MASK:
|
|
if event.direction == Gdk.ScrollDirection.UP:
|
|
self.on_zoom_in(widget)
|
|
elif event.direction == Gdk.ScrollDirection.DOWN:
|
|
self.on_zoom_out(widget)
|
|
return True
|
|
return False
|
|
|
|
def update_zoom_display(self):
|
|
"""Update the zoom level display."""
|
|
if hasattr(self, 'zoom_label'):
|
|
self.zoom_label.set_text(f"{int(self.zoom_level * 100)}%")
|
|
|
|
def on_button_press(self, widget, event):
|
|
"""Handle mouse button press events."""
|
|
if event.button == 1: # Left click
|
|
# Find which event was clicked
|
|
clicked_index = self.find_event_at_position(event.x, event.y)
|
|
if clicked_index is not None:
|
|
# Check if clicking on marker (for person selection) or label (for expansion)
|
|
date_sort, date_obj, clicked_event, clicked_person, event_type, expanded, _y_pos = self.events[clicked_index]
|
|
|
|
# Check if click is on marker area (left side) or label area (right side)
|
|
timeline_x = TIMELINE_MARGIN_LEFT
|
|
marker_area_width = EVENT_MARKER_SIZE * self.zoom_level + 20
|
|
|
|
if event.x < timeline_x + marker_area_width:
|
|
# Click on marker - toggle person selection
|
|
if clicked_person:
|
|
person_handle = clicked_person.get_handle()
|
|
if self.selected_person_handle == person_handle:
|
|
# Deselect if clicking same person
|
|
self.selected_person_handle = None
|
|
else:
|
|
# Select this person
|
|
self.selected_person_handle = person_handle
|
|
else:
|
|
# No person for this event, deselect
|
|
self.selected_person_handle = None
|
|
else:
|
|
# Click on label - toggle expansion
|
|
if self.expanded_event_index == clicked_index:
|
|
self.expanded_event_index = None
|
|
else:
|
|
self.expanded_event_index = clicked_index
|
|
|
|
self.drawing_area.queue_draw()
|
|
return False
|
|
|
|
def on_motion_notify(self, widget, event):
|
|
"""Handle mouse motion events for hover detection."""
|
|
self.mouse_x = event.x
|
|
self.mouse_y = event.y
|
|
|
|
# Find which event is under the cursor
|
|
hovered_index = self.find_event_at_position(event.x, event.y)
|
|
|
|
if hovered_index != self.hovered_event_index:
|
|
self.hovered_event_index = hovered_index
|
|
|
|
# Cancel existing tooltip timeout
|
|
if self.tooltip_timeout_id:
|
|
GLib.source_remove(self.tooltip_timeout_id)
|
|
self.tooltip_timeout_id = None
|
|
|
|
# Hide existing tooltip
|
|
if self.tooltip_window:
|
|
self.tooltip_window.destroy()
|
|
self.tooltip_window = None
|
|
|
|
# Schedule new tooltip
|
|
if hovered_index is not None:
|
|
self.tooltip_timeout_id = GLib.timeout_add(
|
|
TOOLTIP_DELAY, self.show_tooltip, hovered_index, event.x_root, event.y_root
|
|
)
|
|
|
|
self.drawing_area.queue_draw()
|
|
|
|
return False
|
|
|
|
def on_leave_notify(self, widget, event):
|
|
"""Handle mouse leave events."""
|
|
self.hovered_event_index = None
|
|
|
|
# Cancel tooltip timeout
|
|
if self.tooltip_timeout_id:
|
|
GLib.source_remove(self.tooltip_timeout_id)
|
|
self.tooltip_timeout_id = None
|
|
|
|
# Hide tooltip
|
|
if self.tooltip_window:
|
|
self.tooltip_window.destroy()
|
|
self.tooltip_window = None
|
|
|
|
self.drawing_area.queue_draw()
|
|
return False
|
|
|
|
def find_event_at_position(self, x, y):
|
|
"""Find which event is at the given position."""
|
|
if not self.events:
|
|
return None
|
|
|
|
# Get widget dimensions
|
|
width = self.drawing_area.get_allocated_width()
|
|
height = self.drawing_area.get_allocated_height()
|
|
|
|
# Calculate date range
|
|
min_date = min(event[0] for event in self.events)
|
|
max_date = max(event[0] for event in self.events)
|
|
date_range = max_date - min_date
|
|
if date_range == 0:
|
|
date_range = 1
|
|
|
|
timeline_x = TIMELINE_MARGIN_LEFT
|
|
|
|
# Check each event
|
|
for i, (date_sort, date_obj, event, person, event_type, expanded, _y_pos) in enumerate(self.events):
|
|
# Calculate Y position
|
|
y_pos = TIMELINE_MARGIN_TOP + (
|
|
(date_sort - min_date) / date_range
|
|
) * (height - TIMELINE_MARGIN_TOP - TIMELINE_MARGIN_BOTTOM)
|
|
|
|
# Check if click is near the marker
|
|
marker_size = EVENT_MARKER_SIZE * self.zoom_level
|
|
if (abs(x - timeline_x) < marker_size + 10 and
|
|
abs(y - y_pos) < marker_size + 20):
|
|
return i
|
|
|
|
return None
|
|
|
|
def show_tooltip(self, event_index, x_root, y_root):
|
|
"""Show tooltip for an event, including all events for that person."""
|
|
if event_index is None or event_index >= len(self.events):
|
|
return False
|
|
|
|
date_sort, date_obj, event, person, event_type, expanded, y_pos = self.events[event_index]
|
|
|
|
# If event has a person, show all events for that person
|
|
if person:
|
|
person_handle = person.get_handle()
|
|
person_name = name_displayer.display(person)
|
|
|
|
# Find all events for this person
|
|
person_events = []
|
|
for evt_data in self.events:
|
|
evt_date_sort, evt_date_obj, evt_event, evt_person, evt_event_type, evt_expanded, evt_y_pos = evt_data
|
|
if evt_person and evt_person.get_handle() == person_handle:
|
|
person_events.append((evt_date_sort, evt_date_obj, evt_event, evt_event_type))
|
|
|
|
# Sort by date
|
|
person_events.sort(key=lambda x: x[0])
|
|
|
|
# Build tooltip text with person name as header
|
|
tooltip_text = f"<b>{person_name}</b>\n"
|
|
tooltip_text += "─" * 30 + "\n"
|
|
|
|
# List all events for this person
|
|
for evt_date_sort, evt_date_obj, evt_event, evt_event_type in person_events:
|
|
evt_date_str = get_date(evt_event)
|
|
evt_event_type_str = str(evt_event_type)
|
|
tooltip_text += f"{evt_event_type_str} - {evt_date_str}\n"
|
|
|
|
# Add place if available
|
|
evt_place_handle = evt_event.get_place_handle()
|
|
if evt_place_handle:
|
|
try:
|
|
evt_place = self.dbstate.db.get_place_from_handle(evt_place_handle)
|
|
if evt_place:
|
|
evt_place_name = evt_place.get_title()
|
|
tooltip_text += f" 📍 {evt_place_name}\n"
|
|
except:
|
|
pass
|
|
else:
|
|
# Family event (no person) - show single event info
|
|
date_str = get_date(event)
|
|
event_type_str = str(event_type)
|
|
|
|
tooltip_text = f"<b>{event_type_str}</b>\n{date_str}"
|
|
|
|
# Get place information
|
|
place_handle = event.get_place_handle()
|
|
if place_handle:
|
|
try:
|
|
place = self.dbstate.db.get_place_from_handle(place_handle)
|
|
if place:
|
|
place_name = place.get_title()
|
|
tooltip_text += f"\n📍 {place_name}"
|
|
except:
|
|
pass
|
|
|
|
# Get description
|
|
description = event.get_description()
|
|
if description:
|
|
tooltip_text += f"\n{description}"
|
|
|
|
# Create tooltip window
|
|
self.tooltip_window = Gtk.Window(type=Gtk.WindowType.POPUP)
|
|
self.tooltip_window.set_border_width(8)
|
|
self.tooltip_window.set_decorated(False)
|
|
|
|
# Get parent window for proper display
|
|
toplevel = self.drawing_area.get_toplevel()
|
|
if isinstance(toplevel, Gtk.Window):
|
|
self.tooltip_window.set_transient_for(toplevel)
|
|
|
|
# Create container with background
|
|
frame = Gtk.Frame()
|
|
frame.set_shadow_type(Gtk.ShadowType.OUT)
|
|
frame.get_style_context().add_class("tooltip")
|
|
|
|
label = Gtk.Label()
|
|
label.set_markup(tooltip_text)
|
|
label.set_line_wrap(True)
|
|
label.set_max_width_chars(40)
|
|
label.set_margin_start(5)
|
|
label.set_margin_end(5)
|
|
label.set_margin_top(5)
|
|
label.set_margin_bottom(5)
|
|
|
|
frame.add(label)
|
|
self.tooltip_window.add(frame)
|
|
self.tooltip_window.show_all()
|
|
|
|
# Position tooltip (offset to avoid cursor)
|
|
self.tooltip_window.move(int(x_root) + 15, int(y_root) + 15)
|
|
|
|
return False
|
|
|
|
def on_draw(self, widget, context):
|
|
"""Draw the timeline."""
|
|
# Apply zoom transformation
|
|
context.save()
|
|
context.scale(self.zoom_level, self.zoom_level)
|
|
|
|
# Get widget dimensions (adjusted for zoom)
|
|
width = widget.get_allocated_width() / self.zoom_level
|
|
height = widget.get_allocated_height() / self.zoom_level
|
|
|
|
# Clear background with modern gradient
|
|
pattern = cairo.LinearGradient(0, 0, 0, height)
|
|
pattern.add_color_stop_rgb(0, 0.98, 0.98, 0.99) # Very light gray-blue
|
|
pattern.add_color_stop_rgb(1, 0.95, 0.96, 0.98) # Slightly darker
|
|
context.set_source(pattern)
|
|
context.paint()
|
|
|
|
if not self.events:
|
|
# Draw "No events" message with modern styling
|
|
context.set_source_rgb(0.6, 0.6, 0.6)
|
|
context.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
|
|
context.set_font_size(24)
|
|
text = _("No events found")
|
|
(x_bearing, y_bearing, text_width, text_height, x_advance, y_advance) = context.text_extents(text)
|
|
context.move_to((width - text_width) / 2, height / 2)
|
|
context.show_text(text)
|
|
context.restore()
|
|
return
|
|
|
|
# Calculate date range
|
|
min_date = min(event[0] for event in self.events)
|
|
max_date = max(event[0] for event in self.events)
|
|
date_range = max_date - min_date
|
|
if date_range == 0:
|
|
date_range = 1
|
|
|
|
# Draw timeline axis with shadow
|
|
timeline_x = TIMELINE_MARGIN_LEFT
|
|
timeline_y_start = TIMELINE_MARGIN_TOP
|
|
timeline_y_end = height - TIMELINE_MARGIN_BOTTOM
|
|
|
|
# Draw shadow
|
|
context.set_source_rgba(0.0, 0.0, 0.0, 0.2)
|
|
context.set_line_width(TIMELINE_LINE_WIDTH)
|
|
context.move_to(timeline_x + 2, timeline_y_start + 2)
|
|
context.line_to(timeline_x + 2, timeline_y_end + 2)
|
|
context.stroke()
|
|
|
|
# Draw main line with gradient
|
|
pattern = cairo.LinearGradient(timeline_x, timeline_y_start, timeline_x, timeline_y_end)
|
|
pattern.add_color_stop_rgb(0, 0.3, 0.3, 0.3)
|
|
pattern.add_color_stop_rgb(1, 0.5, 0.5, 0.5)
|
|
context.set_source(pattern)
|
|
context.set_line_width(TIMELINE_LINE_WIDTH)
|
|
context.move_to(timeline_x, timeline_y_start)
|
|
context.line_to(timeline_x, timeline_y_end)
|
|
context.stroke()
|
|
|
|
# Calculate initial Y positions based on dates
|
|
events_with_y_pos = []
|
|
for i, event_data in enumerate(self.events):
|
|
date_sort, date_obj, event, person, event_type, expanded, _y_pos = event_data
|
|
|
|
# Calculate Y position based on date
|
|
y_pos = TIMELINE_MARGIN_TOP + (
|
|
(date_sort - min_date) / date_range
|
|
) * (timeline_y_end - timeline_y_start)
|
|
|
|
events_with_y_pos.append((date_sort, date_obj, event, person, event_type, expanded, y_pos))
|
|
|
|
# Detect and fix label overlaps
|
|
events_with_y_pos = self.detect_label_overlaps(context, events_with_y_pos, timeline_y_start, timeline_y_end)
|
|
|
|
# Draw events
|
|
for i, event_data in enumerate(events_with_y_pos):
|
|
date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data
|
|
|
|
# Check if this event is hovered or expanded
|
|
is_hovered = (i == self.hovered_event_index)
|
|
is_expanded = (i == self.expanded_event_index)
|
|
|
|
# Check if this event belongs to selected person
|
|
is_selected = (self.selected_person_handle is not None and
|
|
person and person.get_handle() == self.selected_person_handle)
|
|
|
|
# Draw event marker with modern styling
|
|
self.draw_event_marker(context, timeline_x, y_pos, event_type, is_hovered, is_selected)
|
|
|
|
# Draw event label
|
|
label_x = timeline_x + 25
|
|
self.draw_event_label(
|
|
context, label_x, y_pos, date_obj, event, person, event_type, is_hovered, is_expanded
|
|
)
|
|
|
|
# Draw visual connections for selected person
|
|
if self.selected_person_handle is not None:
|
|
self.draw_person_connections(context, events_with_y_pos, timeline_x, timeline_y_start, timeline_y_end)
|
|
|
|
# Draw year markers on the left
|
|
self.draw_year_markers(context, timeline_x, timeline_y_start, timeline_y_end, min_date, max_date)
|
|
|
|
context.restore()
|
|
|
|
def draw_event_marker(self, context, x, y, event_type, is_hovered=False, is_selected=False):
|
|
"""Draw a marker for an event with modern styling."""
|
|
context.save()
|
|
|
|
# Get integer value from EventType object for dictionary lookup
|
|
event_type_value = event_type.value if hasattr(event_type, 'value') else int(event_type)
|
|
|
|
# Get color and shape
|
|
color = EVENT_COLORS.get(event_type_value, (0.5, 0.5, 0.5))
|
|
shape = EVENT_SHAPES.get(event_type_value, 'square')
|
|
marker_size = EVENT_MARKER_SIZE
|
|
|
|
# Increase size if hovered or selected
|
|
if is_hovered:
|
|
marker_size *= 1.3
|
|
elif is_selected:
|
|
marker_size *= 1.2
|
|
|
|
# Use highlight color if selected
|
|
if is_selected:
|
|
color = (0.2, 0.4, 0.9) # Blue highlight for selected person's events
|
|
|
|
# Draw shadow
|
|
context.set_source_rgba(0.0, 0.0, 0.0, 0.3)
|
|
context.translate(1, 1)
|
|
self._draw_shape(context, x, y, marker_size, shape)
|
|
context.fill()
|
|
context.translate(-1, -1)
|
|
|
|
# Draw main shape with gradient
|
|
pattern = cairo.RadialGradient(x - marker_size/2, y - marker_size/2, 0, x, y, marker_size)
|
|
r, g, b = color
|
|
pattern.add_color_stop_rgb(0, min(1.0, r + 0.2), min(1.0, g + 0.2), min(1.0, b + 0.2))
|
|
pattern.add_color_stop_rgb(1, max(0.0, r - 0.1), max(0.0, g - 0.1), max(0.0, b - 0.1))
|
|
context.set_source(pattern)
|
|
|
|
self._draw_shape(context, x, y, marker_size, shape)
|
|
context.fill()
|
|
|
|
# Draw border
|
|
context.set_source_rgba(0.0, 0.0, 0.0, 0.3)
|
|
context.set_line_width(1)
|
|
self._draw_shape(context, x, y, marker_size, shape)
|
|
context.stroke()
|
|
|
|
context.restore()
|
|
|
|
def _draw_shape(self, context, x, y, size, shape):
|
|
"""Draw a shape at the given position."""
|
|
if shape == 'triangle':
|
|
context.move_to(x, y - size)
|
|
context.line_to(x - size, y + size)
|
|
context.line_to(x + size, y + size)
|
|
context.close_path()
|
|
elif shape == 'circle':
|
|
context.arc(x, y, size, 0, 2 * math.pi)
|
|
elif shape == 'diamond':
|
|
context.move_to(x, y - size)
|
|
context.line_to(x + size, y)
|
|
context.line_to(x, y + size)
|
|
context.line_to(x - size, y)
|
|
context.close_path()
|
|
elif shape == 'square':
|
|
context.rectangle(x - size, y - size, size * 2, size * 2)
|
|
elif shape == 'star':
|
|
# Draw 5-pointed star
|
|
points = 5
|
|
outer_radius = size
|
|
inner_radius = size * 0.4
|
|
for i in range(points * 2):
|
|
angle = (i * math.pi) / points - math.pi / 2
|
|
if i % 2 == 0:
|
|
radius = outer_radius
|
|
else:
|
|
radius = inner_radius
|
|
px = x + radius * math.cos(angle)
|
|
py = y + radius * math.sin(angle)
|
|
if i == 0:
|
|
context.move_to(px, py)
|
|
else:
|
|
context.line_to(px, py)
|
|
context.close_path()
|
|
elif shape == 'hexagon':
|
|
# Draw hexagon
|
|
for i in range(6):
|
|
angle = (i * math.pi) / 3
|
|
px = x + size * math.cos(angle)
|
|
py = y + size * math.sin(angle)
|
|
if i == 0:
|
|
context.move_to(px, py)
|
|
else:
|
|
context.line_to(px, py)
|
|
context.close_path()
|
|
|
|
def draw_event_label(self, context, x, y, date_obj, event, person, event_type, is_hovered=False, is_expanded=False):
|
|
"""Draw the label for an event with modern styling."""
|
|
context.save()
|
|
|
|
# Create Pango layout for text
|
|
layout = PangoCairo.create_layout(context)
|
|
|
|
# Use modern font
|
|
font_desc = Pango.font_description_from_string("Sans 11")
|
|
if is_hovered:
|
|
font_desc.set_weight(Pango.Weight.BOLD)
|
|
layout.set_font_description(font_desc)
|
|
|
|
# Build label text
|
|
date_str = get_date(event)
|
|
event_type_str = str(event_type)
|
|
|
|
if person:
|
|
person_name = name_displayer.display(person)
|
|
if is_expanded:
|
|
# Show full details when expanded
|
|
label_text = f"<b>{date_str}</b> - <b>{event_type_str}</b>\n{person_name}"
|
|
|
|
# Add place
|
|
place_handle = event.get_place_handle()
|
|
if place_handle:
|
|
try:
|
|
place = self.dbstate.db.get_place_from_handle(place_handle)
|
|
if place:
|
|
place_name = place.get_title()
|
|
label_text += f"\n📍 {place_name}"
|
|
except:
|
|
pass
|
|
|
|
# Add description
|
|
description = event.get_description()
|
|
if description:
|
|
label_text += f"\n{description}"
|
|
else:
|
|
label_text = f"{date_str} - {event_type_str} - {person_name}"
|
|
else:
|
|
if is_expanded:
|
|
label_text = f"<b>{date_str}</b> - <b>{event_type_str}</b>"
|
|
|
|
# Add place
|
|
place_handle = event.get_place_handle()
|
|
if place_handle:
|
|
try:
|
|
place = self.dbstate.db.get_place_from_handle(place_handle)
|
|
if place:
|
|
place_name = place.get_title()
|
|
label_text += f"\n📍 {place_name}"
|
|
except:
|
|
pass
|
|
|
|
# Add description
|
|
description = event.get_description()
|
|
if description:
|
|
label_text += f"\n{description}"
|
|
else:
|
|
label_text = f"{date_str} - {event_type_str}"
|
|
|
|
layout.set_markup(label_text, -1)
|
|
layout.set_width(-1) # No width limit
|
|
if is_expanded:
|
|
layout.set_width(500 * Pango.SCALE) # Limit width for expanded view
|
|
layout.set_wrap(Pango.WrapMode.WORD)
|
|
|
|
# Draw background for expanded/hovered events
|
|
if is_expanded or is_hovered:
|
|
text_width, text_height = layout.get_pixel_size()
|
|
padding = 8
|
|
|
|
# Draw rounded rectangle background
|
|
rect_x = x - padding
|
|
rect_y = y - text_height / 2 - padding
|
|
rect_width = text_width + padding * 2
|
|
rect_height = text_height + padding * 2
|
|
|
|
# Rounded rectangle
|
|
radius = 5
|
|
context.arc(rect_x + radius, rect_y + radius, radius, math.pi, 3 * math.pi / 2)
|
|
context.arc(rect_x + rect_width - radius, rect_y + radius, radius, 3 * math.pi / 2, 0)
|
|
context.arc(rect_x + rect_width - radius, rect_y + rect_height - radius, radius, 0, math.pi / 2)
|
|
context.arc(rect_x + radius, rect_y + rect_height - radius, radius, math.pi / 2, math.pi)
|
|
context.close_path()
|
|
|
|
# Fill with semi-transparent background
|
|
if is_expanded:
|
|
context.set_source_rgba(1.0, 1.0, 1.0, 0.95)
|
|
else:
|
|
context.set_source_rgba(1.0, 1.0, 1.0, 0.8)
|
|
context.fill()
|
|
|
|
# Draw border
|
|
context.set_source_rgba(0.7, 0.7, 0.7, 0.5)
|
|
context.set_line_width(1)
|
|
context.stroke()
|
|
|
|
# Draw text
|
|
context.set_source_rgb(0.1, 0.1, 0.1) # Dark gray text
|
|
context.move_to(x, y - 10) # Center vertically on marker
|
|
PangoCairo.show_layout(context, layout)
|
|
|
|
context.restore()
|
|
|
|
def draw_year_markers(self, context, timeline_x, y_start, y_end, min_date, max_date):
|
|
"""Draw year markers on the left side of the timeline."""
|
|
context.save()
|
|
|
|
# Find min and max years from events
|
|
min_year = None
|
|
max_year = None
|
|
for date_sort, date_obj, event, person, event_type, expanded, y_pos in self.events:
|
|
try:
|
|
year = date_obj.get_year()
|
|
if year and year != 0:
|
|
if min_year is None or year < min_year:
|
|
min_year = year
|
|
if max_year is None or year > max_year:
|
|
max_year = year
|
|
except:
|
|
pass
|
|
|
|
if min_year is None or max_year is None:
|
|
context.restore()
|
|
return
|
|
|
|
# Draw markers for major years (every 10 years or so)
|
|
year_step = max(1, (max_year - min_year) // 10)
|
|
if year_step == 0:
|
|
year_step = 1
|
|
|
|
context.set_source_rgb(0.5, 0.5, 0.5) # Gray
|
|
context.set_line_width(1)
|
|
|
|
for year in range(min_year, max_year + 1, year_step):
|
|
# Calculate Y position
|
|
year_date = Date()
|
|
year_date.set_yr_mon_day(year, 1, 1)
|
|
year_sort = year_date.get_sort_value()
|
|
|
|
if min_date == max_date:
|
|
y_pos = (y_start + y_end) / 2
|
|
else:
|
|
y_pos = y_start + (
|
|
(year_sort - min_date) / (max_date - min_date)
|
|
) * (y_end - y_start)
|
|
|
|
# Only draw if within visible range
|
|
if y_pos < y_start or y_pos > y_end:
|
|
continue
|
|
|
|
# Draw tick mark
|
|
context.move_to(timeline_x - 10, y_pos)
|
|
context.line_to(timeline_x, y_pos)
|
|
context.stroke()
|
|
|
|
# Draw year label
|
|
layout = PangoCairo.create_layout(context)
|
|
layout.set_font_description(
|
|
Pango.font_description_from_string("Sans 9")
|
|
)
|
|
layout.set_text(str(year), -1)
|
|
|
|
# Get text size in pixels
|
|
text_width, text_height = layout.get_pixel_size()
|
|
|
|
context.set_source_rgb(0.0, 0.0, 0.0) # Black
|
|
context.move_to(timeline_x - 20 - text_width, y_pos - text_height / 2)
|
|
PangoCairo.show_layout(context, layout)
|
|
|
|
context.restore()
|
|
|
|
def draw_person_connections(self, context, events_with_y_pos, timeline_x, timeline_y_start, timeline_y_end):
|
|
"""Draw visual connections between all events of the selected person."""
|
|
if not self.selected_person_handle:
|
|
return
|
|
|
|
# Find all events for the selected person
|
|
person_events = []
|
|
for event_data in events_with_y_pos:
|
|
date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data
|
|
if person and person.get_handle() == self.selected_person_handle:
|
|
person_events.append((y_pos, event_data))
|
|
|
|
if len(person_events) < 1:
|
|
return
|
|
|
|
# Sort by Y position
|
|
person_events.sort(key=lambda x: x[0])
|
|
|
|
context.save()
|
|
|
|
# Draw connecting lines
|
|
context.set_source_rgba(0.2, 0.4, 0.9, 0.5) # Semi-transparent blue
|
|
context.set_line_width(2)
|
|
context.set_line_cap(cairo.LINE_CAP_ROUND)
|
|
context.set_line_join(cairo.LINE_JOIN_ROUND)
|
|
|
|
# Draw lines from timeline axis to each event marker
|
|
for y_pos, event_data in person_events:
|
|
context.move_to(timeline_x, y_pos)
|
|
context.line_to(timeline_x + EVENT_MARKER_SIZE * 1.5, y_pos)
|
|
context.stroke()
|
|
|
|
# Draw curved path connecting all events (optional - can be commented out if too cluttered)
|
|
if len(person_events) > 1:
|
|
# Draw a subtle curved path connecting all markers
|
|
context.set_source_rgba(0.2, 0.4, 0.9, 0.3) # More transparent
|
|
context.set_line_width(1.5)
|
|
|
|
# Create a smooth curve through all points
|
|
y_positions = [y for y, _event_data in person_events]
|
|
min_y = min(y_positions)
|
|
max_y = max(y_positions)
|
|
|
|
# Draw a vertical line on the left side connecting the range
|
|
if max_y - min_y > EVENT_MARKER_SIZE * 2:
|
|
context.move_to(timeline_x - 15, min_y)
|
|
context.line_to(timeline_x - 15, max_y)
|
|
context.stroke()
|
|
|
|
context.restore()
|
|
|
|
def get_stock(self):
|
|
"""Return the stock icon name."""
|
|
return "gramps-family"
|