# -*- 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 .
#
"""
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:
# Convert mouse coordinates to drawing coordinates (account for zoom)
scaled_x = event.x / self.zoom_level
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 + 20
# Allow person selection from anywhere on the line
# Clicking anywhere on the event line selects the person
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
# Also toggle expansion if clicking on label area (right side)
if scaled_x > timeline_x + marker_area_width:
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
# Convert mouse coordinates to drawing coordinates (account for zoom)
scaled_x = x / self.zoom_level
scaled_y = y / self.zoom_level
# Get widget dimensions in drawing coordinates
width = self.drawing_area.get_allocated_width() / self.zoom_level
height = self.drawing_area.get_allocated_height() / self.zoom_level
# 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
# Calculate initial Y positions (same as in on_draw)
events_with_y_pos = []
for i, event_data in enumerate(self.events):
date_sort, date_obj, event, person, event_type, expanded, _ = event_data
y_pos = TIMELINE_MARGIN_TOP + (
(date_sort - min_date) / date_range
) * (height - TIMELINE_MARGIN_TOP - TIMELINE_MARGIN_BOTTOM)
events_with_y_pos.append((i, y_pos, event_data))
# Check each event using adjusted positions
# We need to simulate the collision detection, but for simplicity,
# we'll check against the calculated positions and use a wider tolerance
for i, y_pos, event_data in events_with_y_pos:
date_sort, date_obj, event, person, event_type, expanded, _ = event_data
# Calculate clickable area - wider to include label area
marker_size = EVENT_MARKER_SIZE
label_x = timeline_x + 25
# Estimate label width (we'll use a reasonable default)
# For whole-line selection, check if click is in the event's horizontal band
clickable_width = 600 # Reasonable width for label area
clickable_height = max(marker_size * 2, 30) # At least marker size or 30px
# Check if click is in the event's area (marker + label)
if (scaled_x >= timeline_x - marker_size - 10 and
scaled_x <= label_x + clickable_width and
abs(scaled_y - y_pos) < clickable_height / 2):
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"{person_name}\n"
tooltip_text += "ā" * 30 + "\n"
# List all events for this person (date first)
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_date_str} - {evt_event_type_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 first)
date_str = get_date(event)
event_type_str = str(event_type)
tooltip_text = f"{date_str}\n{event_type_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"{date_str} - {event_type_str}\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"{date_str} - {event_type_str}"
# 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 - more visible with brighter color and increased opacity
context.set_source_rgba(0.2, 0.5, 1.0, 0.75) # Brighter, more opaque blue
context.set_line_width(3.5) # Increased from 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 vertical connector line on the left side
if len(person_events) > 1:
y_positions = [y for y, _event_data in person_events]
min_y = min(y_positions)
max_y = max(y_positions)
# Draw a more visible vertical line connecting the range
if max_y - min_y > EVENT_MARKER_SIZE * 2:
context.set_source_rgba(0.2, 0.5, 1.0, 0.6) # Slightly less opaque but still visible
context.set_line_width(2.5) # Thicker than before
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"