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