From 581a6c1f59a56f159d7b957a40e6f2a2e6b9a2cc Mon Sep 17 00:00:00 2001 From: Daniel Viegas Date: Fri, 28 Nov 2025 21:49:59 +0100 Subject: [PATCH] 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 --- MyTimeline.gpr.py | 47 ++++ MyTimeline.py | 552 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 599 insertions(+) create mode 100644 MyTimeline.gpr.py create mode 100644 MyTimeline.py diff --git a/MyTimeline.gpr.py b/MyTimeline.gpr.py new file mode 100644 index 0000000..ac8e159 --- /dev/null +++ b/MyTimeline.gpr.py @@ -0,0 +1,47 @@ +# encoding: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 . +# +from gramps.gen.plug._pluginreg import register, VIEW, STABLE +from gramps.gen.const import GRAMPS_LOCALE as glocale + +_ = glocale.translation.gettext + +MODULE_VERSION = "6.0" + +# ------------------------------------------------------------------------ +# +# MyTimeline View +# +# ------------------------------------------------------------------------ + +register( + VIEW, + id="mytimelineview", + name=_("MyTimeline"), + description=_("A vertical timeline view showing family events including birth, death, and marriage"), + version="1.0", + gramps_target_version=MODULE_VERSION, + status=STABLE, + fname="MyTimeline.py", + authors=["MyTimeline Plugin"], + authors_email=[""], + category=("Families", _("Families")), + viewclass="MyTimelineView", +) + diff --git a/MyTimeline.py b/MyTimeline.py new file mode 100644 index 0000000..5e66938 --- /dev/null +++ b/MyTimeline.py @@ -0,0 +1,552 @@ +# -*- 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 +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" +