From 8be124f5e077a29aa22911a6bd335ebc53e5ed9d Mon Sep 17 00:00:00 2001 From: Daniel Viegas Date: Fri, 28 Nov 2025 23:08:04 +0100 Subject: [PATCH] Add text overlap prevention, enhanced tooltips, and person selection with visual connections - Implement detect_label_overlaps() to prevent text label overlaps - Automatically adjust Y positions while maintaining chronological order - Enhanced tooltips to show all events for a person, not just hovered event - Added person selection on event marker click - Implement draw_person_connections() to visually connect selected person's events - Selected person's events highlighted with blue color and connecting lines - Click on marker selects person, click on label expands event details --- MyTimeline.py | 244 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 209 insertions(+), 35 deletions(-) diff --git a/MyTimeline.py b/MyTimeline.py index 00671b7..c074673 100644 --- a/MyTimeline.py +++ b/MyTimeline.py @@ -244,6 +244,7 @@ class MyTimelineView(NavigationView): self.tooltip_timeout_id = None self.tooltip_window = None self.expanded_event_index = None + self.selected_person_handle = None self.mouse_x = 0 self.mouse_y = 0 @@ -512,6 +513,53 @@ class MyTimelineView(NavigationView): if self.drawing_area: self.drawing_area.set_size_request(800, self.timeline_height) + def detect_label_overlaps(self, context, events_with_y_pos, timeline_y_start, timeline_y_end): + """Detect and adjust Y positions to prevent label overlaps.""" + if not events_with_y_pos: + return events_with_y_pos + + # Create a temporary layout to measure text + layout = PangoCairo.create_layout(context) + layout.set_font_description(Pango.font_description_from_string("Sans 11")) + + adjusted_events = [] + min_spacing = 30 # Minimum spacing between labels in pixels + + for i, event_data in enumerate(events_with_y_pos): + date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data + + # Calculate label height + if person: + person_name = name_displayer.display(person) + date_str = get_date(event) + event_type_str = str(event_type) + label_text = f"{date_str} - {event_type_str} - {person_name}" + else: + date_str = get_date(event) + event_type_str = str(event_type) + label_text = f"{date_str} - {event_type_str}" + + layout.set_text(label_text, -1) + text_width, text_height = layout.get_pixel_size() + label_height = text_height + 16 # Add padding + + # Check for overlap with previous events + adjusted_y = y_pos + for prev_data in adjusted_events: + prev_y_pos = prev_data[6] # Get y_pos from previous event + # Check if labels would overlap + if abs(adjusted_y - prev_y_pos) < min_spacing: + # Adjust downward + adjusted_y = prev_y_pos + min_spacing + + # Ensure adjusted position is within bounds + adjusted_y = max(timeline_y_start, min(adjusted_y, timeline_y_end)) + + # Create new event data with adjusted Y position + adjusted_events.append((date_sort, date_obj, event, person, event_type, expanded, adjusted_y)) + + return adjusted_events + def on_zoom_in(self, widget): """Zoom in.""" if self.zoom_level < self.max_zoom: @@ -559,11 +607,33 @@ class MyTimelineView(NavigationView): # 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 + # Check if clicking on marker (for person selection) or label (for expansion) + date_sort, date_obj, clicked_event, clicked_person, event_type, expanded, _ = self.events[clicked_index] + + # Check if click is on marker area (left side) or label area (right side) + timeline_x = TIMELINE_MARGIN_LEFT + marker_area_width = EVENT_MARKER_SIZE * self.zoom_level + 20 + + if event.x < timeline_x + marker_area_width: + # Click on marker - toggle person selection + if clicked_person: + person_handle = clicked_person.get_handle() + if self.selected_person_handle == person_handle: + # Deselect if clicking same person + self.selected_person_handle = None + else: + # Select this person + self.selected_person_handle = person_handle + else: + # No person for this event, deselect + self.selected_person_handle = None else: - self.expanded_event_index = clicked_index + # Click on label - 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 @@ -649,37 +719,69 @@ class MyTimelineView(NavigationView): return None def show_tooltip(self, event_index, x_root, y_root): - """Show tooltip for an event.""" + """Show tooltip for an event, including all events for that person.""" 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 event has a person, show all events for that person if person: + person_handle = person.get_handle() 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}" + + # Find all events for this person + person_events = [] + for evt_data in self.events: + evt_date_sort, evt_date_obj, evt_event, evt_person, evt_event_type, evt_expanded, evt_y_pos = evt_data + if evt_person and evt_person.get_handle() == person_handle: + person_events.append((evt_date_sort, evt_date_obj, evt_event, evt_event_type)) + + # Sort by date + person_events.sort(key=lambda x: x[0]) + + # Build tooltip text with person name as header + tooltip_text = f"{person_name}\n" + tooltip_text += "─" * 30 + "\n" + + # List all events for this person + for evt_date_sort, evt_date_obj, evt_event, evt_event_type in person_events: + evt_date_str = get_date(evt_event) + evt_event_type_str = str(evt_event_type) + tooltip_text += f"{evt_event_type_str} - {evt_date_str}\n" + + # Add place if available + evt_place_handle = evt_event.get_place_handle() + if evt_place_handle: + try: + evt_place = self.dbstate.db.get_place_from_handle(evt_place_handle) + if evt_place: + evt_place_name = evt_place.get_title() + tooltip_text += f" šŸ“ {evt_place_name}\n" + except: + pass + else: + # Family event (no person) - show single event info + date_str = get_date(event) + event_type_str = str(event_type) + + tooltip_text = f"{event_type_str}\n{date_str}" + + # 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) @@ -772,24 +874,35 @@ class MyTimelineView(NavigationView): context.line_to(timeline_x, timeline_y_end) context.stroke() - # Draw events + # Calculate initial Y positions based on dates + events_with_y_pos = [] for i, event_data in enumerate(self.events): - date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data + date_sort, date_obj, event, person, event_type, expanded, _ = 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) - # Update y_pos in event data - self.events[i] = event_data[:6] + (y_pos,) + events_with_y_pos.append((date_sort, date_obj, event, person, event_type, expanded, y_pos)) + + # Detect and fix label overlaps + events_with_y_pos = self.detect_label_overlaps(context, events_with_y_pos, timeline_y_start, timeline_y_end) + + # Draw events + for i, event_data in enumerate(events_with_y_pos): + date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data # Check if this event is hovered or expanded is_hovered = (i == self.hovered_event_index) is_expanded = (i == self.expanded_event_index) + # Check if this event belongs to selected person + is_selected = (self.selected_person_handle is not None and + person and person.get_handle() == self.selected_person_handle) + # Draw event marker with modern styling - self.draw_event_marker(context, timeline_x, y_pos, event_type, is_hovered) + self.draw_event_marker(context, timeline_x, y_pos, event_type, is_hovered, is_selected) # Draw event label label_x = timeline_x + 25 @@ -797,12 +910,16 @@ class MyTimelineView(NavigationView): context, label_x, y_pos, date_obj, event, person, event_type, is_hovered, is_expanded ) + # Draw visual connections for selected person + if self.selected_person_handle is not None: + self.draw_person_connections(context, events_with_y_pos, timeline_x, timeline_y_start, timeline_y_end) + # Draw year markers on the left self.draw_year_markers(context, timeline_x, timeline_y_start, timeline_y_end, min_date, max_date) context.restore() - def draw_event_marker(self, context, x, y, event_type, is_hovered=False): + def draw_event_marker(self, context, x, y, event_type, is_hovered=False, is_selected=False): """Draw a marker for an event with modern styling.""" context.save() @@ -814,9 +931,15 @@ class MyTimelineView(NavigationView): shape = EVENT_SHAPES.get(event_type_value, 'square') marker_size = EVENT_MARKER_SIZE - # Increase size if hovered + # Increase size if hovered or selected if is_hovered: marker_size *= 1.3 + elif is_selected: + marker_size *= 1.2 + + # Use highlight color if selected + if is_selected: + color = (0.2, 0.4, 0.9) # Blue highlight for selected person's events # Draw shadow context.set_source_rgba(0.0, 0.0, 0.0, 0.3) @@ -1064,6 +1187,57 @@ class MyTimelineView(NavigationView): context.restore() + def draw_person_connections(self, context, events_with_y_pos, timeline_x, timeline_y_start, timeline_y_end): + """Draw visual connections between all events of the selected person.""" + if not self.selected_person_handle: + return + + # Find all events for the selected person + person_events = [] + for event_data in events_with_y_pos: + date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data + if person and person.get_handle() == self.selected_person_handle: + person_events.append((y_pos, event_data)) + + if len(person_events) < 1: + return + + # Sort by Y position + person_events.sort(key=lambda x: x[0]) + + context.save() + + # Draw connecting lines + context.set_source_rgba(0.2, 0.4, 0.9, 0.5) # Semi-transparent blue + context.set_line_width(2) + context.set_line_cap(cairo.LINE_CAP_ROUND) + context.set_line_join(cairo.LINE_JOIN_ROUND) + + # Draw lines from timeline axis to each event marker + for y_pos, event_data in person_events: + context.move_to(timeline_x, y_pos) + context.line_to(timeline_x + EVENT_MARKER_SIZE * 1.5, y_pos) + context.stroke() + + # Draw curved path connecting all events (optional - can be commented out if too cluttered) + if len(person_events) > 1: + # Draw a subtle curved path connecting all markers + context.set_source_rgba(0.2, 0.4, 0.9, 0.3) # More transparent + context.set_line_width(1.5) + + # Create a smooth curve through all points + y_positions = [y for y, _ in person_events] + min_y = min(y_positions) + max_y = max(y_positions) + + # Draw a vertical line on the left side connecting the range + if max_y - min_y > EVENT_MARKER_SIZE * 2: + context.move_to(timeline_x - 15, min_y) + context.line_to(timeline_x - 15, max_y) + context.stroke() + + context.restore() + def get_stock(self): """Return the stock icon name.""" return "gramps-family"