diff --git a/MyTimeline.py b/MyTimeline.py
index 7d9b929..00671b7 100644
--- a/MyTimeline.py
+++ b/MyTimeline.py
@@ -28,8 +28,10 @@ MyTimeline View - A vertical timeline showing family events
#
# -------------------------------------------------------------------------
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
@@ -63,9 +65,145 @@ TIMELINE_MARGIN_RIGHT = 50
TIMELINE_MARGIN_TOP = 50
TIMELINE_MARGIN_BOTTOM = 50
TIMELINE_LINE_WIDTH = 3
-EVENT_MARKER_SIZE = 8
-EVENT_SPACING = 60
+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',
+}
# -------------------------------------------------------------------------
@@ -76,7 +214,7 @@ YEAR_LABEL_WIDTH = 100
class MyTimelineView(NavigationView):
"""
View for displaying a vertical timeline of family events.
- Shows marriage, birth, and death events for family members.
+ Shows all events for family members with modern design and interactivity.
"""
def __init__(self, pdata, dbstate, uistate, nav_group=0):
@@ -90,12 +228,24 @@ class MyTimelineView(NavigationView):
# Current family handle
self.active_family_handle = None
- self.events = [] # List of (date_sort, date_obj, event, person, event_type_str)
+ 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.mouse_x = 0
+ self.mouse_y = 0
# Connect to database changes
self.dbstate.connect("database-changed", self.change_db)
@@ -147,7 +297,6 @@ class MyTimelineView(NavigationView):
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:
@@ -158,9 +307,6 @@ class MyTimelineView(NavigationView):
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)
@@ -172,6 +318,44 @@ class MyTimelineView(NavigationView):
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
@@ -181,11 +365,23 @@ class MyTimelineView(NavigationView):
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
+ 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)
- return self.scrolledwindow
+ 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."""
@@ -199,6 +395,7 @@ class MyTimelineView(NavigationView):
return
self.active_family_handle = handle
+ self.expanded_event_index = None
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
@@ -232,148 +429,318 @@ class MyTimelineView(NavigationView):
event,
None, # No person for family events
event_type,
+ False, # expanded
+ 0, # y_pos (will be calculated during draw)
)
)
except:
pass
- # Get father's birth and death
+ # 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:
- 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,
- )
- )
+ collect_person_events(father, father)
except:
pass
- # Get mother's birth and death
+ # 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:
- 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,
- )
- )
+ collect_person_events(mother, mother)
except:
pass
- # Get children's birth and death
+ # 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:
- 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,
- )
- )
+ 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
+ # Calculate timeline height based on number of events and zoom
if self.events:
- self.timeline_height = (
+ 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 = 600
+ self.timeline_height = int(600 * self.zoom_level)
if self.drawing_area:
self.drawing_area.set_size_request(800, self.timeline_height)
+ 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:
+ # Toggle expansion
+ 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
+
+ # Get widget dimensions
+ width = self.drawing_area.get_allocated_width()
+ height = self.drawing_area.get_allocated_height()
+
+ # 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
+
+ # Check each event
+ for i, (date_sort, date_obj, event, person, event_type, expanded, _y_pos) in enumerate(self.events):
+ # Calculate Y position
+ y_pos = TIMELINE_MARGIN_TOP + (
+ (date_sort - min_date) / date_range
+ ) * (height - TIMELINE_MARGIN_TOP - TIMELINE_MARGIN_BOTTOM)
+
+ # Check if click is near the marker
+ marker_size = EVENT_MARKER_SIZE * self.zoom_level
+ if (abs(x - timeline_x) < marker_size + 10 and
+ abs(y - y_pos) < marker_size + 20):
+ return i
+
+ return None
+
+ def show_tooltip(self, event_index, x_root, y_root):
+ """Show tooltip for an event."""
+ 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]
+
+ # Build tooltip text
+ date_str = get_date(event)
+ event_type_str = str(event_type)
+
+ tooltip_text = f"{event_type_str}\n{date_str}"
+
+ if person:
+ person_name = name_displayer.display(person)
+ tooltip_text += f"\n{person_name}"
+
+ # 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."""
- # Get widget dimensions
- width = widget.get_allocated_width()
- height = widget.get_allocated_height()
+ # 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
- context.set_source_rgb(1.0, 1.0, 1.0) # White
+ # 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
- context.set_source_rgb(0.5, 0.5, 0.5)
+ # 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
@@ -381,102 +748,249 @@ class MyTimelineView(NavigationView):
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
+ date_range = 1
- # Draw timeline axis
+ # Draw timeline axis with shadow
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
+ # 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()
# Draw events
- for i, (date_sort, date_obj, event, person, event_type) in enumerate(self.events):
+ 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)
-
- # Draw event marker
- self.draw_event_marker(context, timeline_x, y_pos, event_type)
-
+
+ # Update y_pos in event data
+ self.events[i] = event_data[:6] + (y_pos,)
+
+ # Check if this event is hovered or expanded
+ is_hovered = (i == self.hovered_event_index)
+ is_expanded = (i == self.expanded_event_index)
+
+ # Draw event marker with modern styling
+ self.draw_event_marker(context, timeline_x, y_pos, event_type, is_hovered)
+
# Draw event label
- label_x = timeline_x + 20
+ label_x = timeline_x + 25
self.draw_event_label(
- context, label_x, y_pos, date_obj, event, person, event_type
+ context, label_x, y_pos, date_obj, event, person, event_type, is_hovered, is_expanded
)
# 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."""
+ def draw_event_marker(self, context, x, y, event_type, is_hovered=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
+ if is_hovered:
+ marker_size *= 1.3
+
+ # 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)
- layout.set_font_description(
- Pango.font_description_from_string("Sans 10")
- )
+
+ # 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)
- label_text = f"{date_str} - {event_type_str} - {person_name}"
+ 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:
- label_text = f"{date_str} - {event_type_str}"
+ 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_text(label_text, -1)
+ 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.0, 0.0, 0.0) # Black 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)
@@ -489,7 +1003,7 @@ class MyTimelineView(NavigationView):
# 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:
+ 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:
@@ -513,8 +1027,7 @@ class MyTimelineView(NavigationView):
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
+ # Calculate Y position
year_date = Date()
year_date.set_yr_mon_day(year, 1, 1)
year_sort = year_date.get_sort_value()
@@ -554,4 +1067,3 @@ class MyTimelineView(NavigationView):
def get_stock(self):
"""Return the stock icon name."""
return "gramps-family"
-
diff --git a/__pycache__/MyTimeline.cpython-312.pyc b/__pycache__/MyTimeline.cpython-312.pyc
new file mode 100644
index 0000000..fb47714
Binary files /dev/null and b/__pycache__/MyTimeline.cpython-312.pyc differ