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
This commit is contained in:
commit
581a6c1f59
47
MyTimeline.gpr.py
Normal file
47
MyTimeline.gpr.py
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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",
|
||||
)
|
||||
|
||||
552
MyTimeline.py
Normal file
552
MyTimeline.py
Normal file
@ -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 <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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user