mygramps/MyTimeline.py
Daniel Viegas 581a6c1f59 Initial commit: MyTimeline plugin for Gramps
- Add MyTimeline.gpr.py plugin registration file
- Add MyTimeline.py view implementation with vertical timeline
- Displays family events (marriage, birth, death) in a vertical timeline
- Supports navigation, bookmarks, and database updates
2025-11-28 21:49:59 +01:00

553 lines
20 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
from gi.repository import Gtk
from gi.repository import Gdk
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 = 8
EVENT_SPACING = 60
YEAR_LABEL_WIDTH = 100
# -------------------------------------------------------------------------
#
# MyTimelineView
#
# -------------------------------------------------------------------------
class MyTimelineView(NavigationView):
"""
View for displaying a vertical timeline of family events.
Shows marriage, birth, and death events for family members.
"""
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_str)
# UI components
self.scrolledwindow = None
self.drawing_area = None
self.timeline_height = 1000 # Default height, will be recalculated
# 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
# (simpler than checking if the event is in our list)
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 = []
# Disconnect old signals if any
# (In practice, the old db object is no longer valid)
# 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."""
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.SCROLL_MASK
)
self.scrolledwindow.add(self.drawing_area)
return self.scrolledwindow
def goto_handle(self, handle):
"""Called when the active family changes."""
if handle == self.active_family_handle:
return
self.active_family_handle = handle
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,
)
)
except:
pass
# Get father's birth and death
father_handle = family.get_father_handle()
if father_handle:
try:
father = self.dbstate.db.get_person_from_handle(father_handle)
if father:
birth = get_birth_or_fallback(self.dbstate.db, father)
if birth and birth.get_date_object():
date_obj = birth.get_date_object()
self.events.append(
(
date_obj.get_sort_value(),
date_obj,
birth,
father,
EventType.BIRTH,
)
)
death = get_death_or_fallback(self.dbstate.db, father)
if death and death.get_date_object():
date_obj = death.get_date_object()
self.events.append(
(
date_obj.get_sort_value(),
date_obj,
death,
father,
EventType.DEATH,
)
)
except:
pass
# Get mother's birth and death
mother_handle = family.get_mother_handle()
if mother_handle:
try:
mother = self.dbstate.db.get_person_from_handle(mother_handle)
if mother:
birth = get_birth_or_fallback(self.dbstate.db, mother)
if birth and birth.get_date_object():
date_obj = birth.get_date_object()
self.events.append(
(
date_obj.get_sort_value(),
date_obj,
birth,
mother,
EventType.BIRTH,
)
)
death = get_death_or_fallback(self.dbstate.db, mother)
if death and death.get_date_object():
date_obj = death.get_date_object()
self.events.append(
(
date_obj.get_sort_value(),
date_obj,
death,
mother,
EventType.DEATH,
)
)
except:
pass
# Get children's birth and death
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:
birth = get_birth_or_fallback(self.dbstate.db, child)
if birth and birth.get_date_object():
date_obj = birth.get_date_object()
self.events.append(
(
date_obj.get_sort_value(),
date_obj,
birth,
child,
EventType.BIRTH,
)
)
death = get_death_or_fallback(self.dbstate.db, child)
if death and death.get_date_object():
date_obj = death.get_date_object()
self.events.append(
(
date_obj.get_sort_value(),
date_obj,
death,
child,
EventType.DEATH,
)
)
except:
pass
# Sort events by date
self.events.sort(key=lambda x: x[0])
# Calculate timeline height based on number of events
if self.events:
self.timeline_height = (
TIMELINE_MARGIN_TOP
+ len(self.events) * EVENT_SPACING
+ TIMELINE_MARGIN_BOTTOM
)
else:
self.timeline_height = 600
if self.drawing_area:
self.drawing_area.set_size_request(800, self.timeline_height)
def on_draw(self, widget, context):
"""Draw the timeline."""
# Get widget dimensions
width = widget.get_allocated_width()
height = widget.get_allocated_height()
# Clear background
context.set_source_rgb(1.0, 1.0, 1.0) # White
context.paint()
if not self.events:
# Draw "No events" message
context.set_source_rgb(0.5, 0.5, 0.5)
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)
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 # Avoid division by zero
# Draw timeline axis
timeline_x = TIMELINE_MARGIN_LEFT
timeline_y_start = TIMELINE_MARGIN_TOP
timeline_y_end = height - TIMELINE_MARGIN_BOTTOM
context.set_source_rgb(0.0, 0.0, 0.0) # Black
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()
# Draw events
for i, (date_sort, date_obj, event, person, event_type) in enumerate(self.events):
# Calculate Y position based on date
y_pos = TIMELINE_MARGIN_TOP + (
(date_sort - min_date) / date_range
) * (timeline_y_end - timeline_y_start)
# Draw event marker
self.draw_event_marker(context, timeline_x, y_pos, event_type)
# Draw event label
label_x = timeline_x + 20
self.draw_event_label(
context, label_x, y_pos, date_obj, event, person, event_type
)
# Draw year markers on the left
self.draw_year_markers(context, timeline_x, timeline_y_start, timeline_y_end, min_date, max_date)
def draw_event_marker(self, context, x, y, event_type):
"""Draw a marker for an event."""
context.save()
# Set color and shape based on event type
if event_type == EventType.BIRTH:
context.set_source_rgb(0.0, 0.8, 0.0) # Green for birth
# Draw upward triangle
context.move_to(x, y - EVENT_MARKER_SIZE)
context.line_to(x - EVENT_MARKER_SIZE, y + EVENT_MARKER_SIZE)
context.line_to(x + EVENT_MARKER_SIZE, y + EVENT_MARKER_SIZE)
context.close_path()
context.fill()
elif event_type == EventType.DEATH:
context.set_source_rgb(0.8, 0.0, 0.0) # Red for death
# Draw filled circle
context.arc(x, y, EVENT_MARKER_SIZE, 0, 2 * 3.14159)
context.fill()
elif event_type == EventType.MARRIAGE:
context.set_source_rgb(0.0, 0.0, 0.8) # Blue for marriage
# Draw diamond
context.move_to(x, y - EVENT_MARKER_SIZE)
context.line_to(x + EVENT_MARKER_SIZE, y)
context.line_to(x, y + EVENT_MARKER_SIZE)
context.line_to(x - EVENT_MARKER_SIZE, y)
context.close_path()
context.fill()
else:
context.set_source_rgb(0.5, 0.5, 0.5) # Gray for other events
# Draw square
context.rectangle(
x - EVENT_MARKER_SIZE,
y - EVENT_MARKER_SIZE,
EVENT_MARKER_SIZE * 2,
EVENT_MARKER_SIZE * 2,
)
context.fill()
context.restore()
def draw_event_label(self, context, x, y, date_obj, event, person, event_type):
"""Draw the label for an event."""
context.save()
# Create Pango layout for text
layout = PangoCairo.create_layout(context)
layout.set_font_description(
Pango.font_description_from_string("Sans 10")
)
# Build label text
date_str = get_date(event)
event_type_str = str(event_type)
if person:
person_name = name_displayer.display(person)
label_text = f"{date_str} - {event_type_str} - {person_name}"
else:
label_text = f"{date_str} - {event_type_str}"
layout.set_text(label_text, -1)
layout.set_width(-1) # No width limit
# Draw text
context.set_source_rgb(0.0, 0.0, 0.0) # Black 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 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 by finding the closest event or interpolating
# Find events around this year
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)
(x_bearing, y_bearing, text_width, text_height, x_advance, y_advance) = layout.get_extents()
text_width = text_width / Pango.SCALE
text_height = text_height / Pango.SCALE
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 get_stock(self):
"""Return the stock icon name."""
return "gramps-family"