# -*- 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 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.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"