diff --git a/MyTimeline.py b/MyTimeline.py index b1512d5..9665b5f 100644 --- a/MyTimeline.py +++ b/MyTimeline.py @@ -956,7 +956,7 @@ class MyTimelineView(NavigationView): def _build_person_filter_page(self) -> Gtk.Widget: """ - Build the person filter page with expandable families showing their members. + Build the person filter page with a treeview showing families and their members. Returns: Gtk.Widget: The person filter page. @@ -970,20 +970,661 @@ class MyTimelineView(NavigationView): box.set_margin_top(FILTER_PAGE_MARGIN) box.set_margin_bottom(FILTER_PAGE_MARGIN) - # Person checkboxes container - will be populated when dialog is shown - person_checkboxes = {} - self._filter_widgets['person_checkboxes'] = person_checkboxes - self._filter_widgets['person_container'] = box - # Store expanders by family handle - self._filter_widgets['family_expanders'] = {} - info_label = Gtk.Label(label=_("Select families and their members to include in the timeline.")) info_label.set_line_wrap(True) box.pack_start(info_label, False, False, 0) + # Create TreeStore with columns: + # 0: person_active (bool), 1: person_inconsistent (bool), 2: name (str), + # 3: person_handle (str/None), 4: family_handle (str/None), 5: role (str/None), + # 6: descendants_active (bool), 7: descendants_inconsistent (bool) + tree_store = Gtk.TreeStore(bool, bool, str, str, str, str, bool, bool) + self._filter_widgets['person_tree_store'] = tree_store + + # Create TreeView + tree_view = Gtk.TreeView(model=tree_store) + tree_view.set_headers_visible(False) + self._filter_widgets['person_tree_view'] = tree_view + + # Create person checkbox column with CellRendererToggle + person_toggle_renderer = Gtk.CellRendererToggle() + person_toggle_renderer.set_property('activatable', True) + person_toggle_column = Gtk.TreeViewColumn() + person_toggle_column.pack_start(person_toggle_renderer, False) + person_toggle_column.add_attribute(person_toggle_renderer, 'active', 0) + person_toggle_column.add_attribute(person_toggle_renderer, 'inconsistent', 1) + person_toggle_renderer.connect('toggled', self._on_person_tree_checkbox_toggled) + tree_view.append_column(person_toggle_column) + + # Create descendants checkbox column with CellRendererToggle + descendants_toggle_renderer = Gtk.CellRendererToggle() + descendants_toggle_renderer.set_property('activatable', True) + descendants_toggle_column = Gtk.TreeViewColumn() + descendants_toggle_column.pack_start(descendants_toggle_renderer, False) + descendants_toggle_column.add_attribute(descendants_toggle_renderer, 'active', 6) + descendants_toggle_column.add_attribute(descendants_toggle_renderer, 'inconsistent', 7) + # Use cell data function to set activatable based on whether row has descendants + descendants_toggle_column.set_cell_data_func(descendants_toggle_renderer, self._descendants_checkbox_data_func) + descendants_toggle_renderer.connect('toggled', self._on_descendants_checkbox_toggled) + tree_view.append_column(descendants_toggle_column) + + # Create name column with CellRendererText + text_renderer = Gtk.CellRendererText() + name_column = Gtk.TreeViewColumn() + name_column.pack_start(text_renderer, True) + name_column.add_attribute(text_renderer, 'text', 2) + tree_view.append_column(name_column) + + # Store mapping of person handle to list of TreeIters where they appear + self._filter_widgets['person_to_iters'] = {} + # Store mapping of family handle to TreeIter + self._filter_widgets['family_to_iter'] = {} + # Store updating flags per family + self._filter_widgets['family_updating_flags'] = {} + + box.pack_start(tree_view, True, True, 0) scrolled.add(box) return scrolled + def _descendants_checkbox_data_func(self, column: Gtk.TreeViewColumn, renderer: Gtk.CellRendererToggle, + model: Gtk.TreeModel, iter_obj: Gtk.TreeIter, data: Any) -> None: + """ + Cell data function for descendants checkbox to make it invisible when row has no descendants. + + Args: + column: The tree view column. + renderer: The cell renderer. + model: The tree model. + iter_obj: The tree iter for the row. + data: User data (unused). + """ + # Check if this row has descendants + has_descendants = model.iter_has_child(iter_obj) + # Make checkbox invisible if there are no descendants, visible and activatable if there are + renderer.set_property('visible', has_descendants) + renderer.set_property('activatable', has_descendants) + renderer.set_property('sensitive', has_descendants) + + def _calculate_family_state(self, tree_store: Gtk.TreeStore, family_iter: Gtk.TreeIter) -> str: + """ + Calculate the state of a family based on child person rows in TreeStore. + Recursively checks all descendants. + + Args: + tree_store: The TreeStore containing the data. + family_iter: The TreeIter for the family row. + + Returns: + str: 'all' if all descendants selected, 'none' if none selected, 'some' if partially selected. + """ + def count_descendants(parent_iter: Optional[Gtk.TreeIter]) -> Tuple[int, int]: + """ + Recursively count active and total descendants. + + Returns: + Tuple[int, int]: (active_count, total_count) + """ + child_iter = tree_store.iter_children(parent_iter) if parent_iter else tree_store.iter_children(family_iter) + if child_iter is None: + return (0, 0) + + active_count = 0 + total_count = 0 + + while child_iter is not None: + # Count this row if it's a person row (has person_handle) + person_handle = tree_store.get_value(child_iter, 3) + if person_handle and person_handle != '': + total_count += 1 + if tree_store.get_value(child_iter, 0): # Column 0 is active state + active_count += 1 + + # Recursively count descendants + if tree_store.iter_has_child(child_iter): + child_active, child_total = count_descendants(child_iter) + active_count += child_active + total_count += child_total + + child_iter = tree_store.iter_next(child_iter) + + return (active_count, total_count) + + active_count, total_count = count_descendants(None) + + if total_count == 0: + return 'none' + elif active_count == 0: + return 'none' + elif active_count == total_count: + return 'all' + else: + return 'some' + + def _calculate_person_state(self, tree_store: Gtk.TreeStore, person_iter: Gtk.TreeIter) -> str: + """ + Calculate the state of a person based on descendant rows in TreeStore. + Recursively checks all descendants. + + Args: + tree_store: The TreeStore containing the data. + person_iter: The TreeIter for the person row. + + Returns: + str: 'all' if all descendants selected, 'none' if none selected, 'some' if partially selected. + """ + def count_descendants(parent_iter: Gtk.TreeIter) -> Tuple[int, int]: + """ + Recursively count active and total descendants. + + Returns: + Tuple[int, int]: (active_count, total_count) + """ + child_iter = tree_store.iter_children(parent_iter) + if child_iter is None: + return (0, 0) + + active_count = 0 + total_count = 0 + + while child_iter is not None: + # Count this row if it's a person row (has person_handle) + person_handle = tree_store.get_value(child_iter, 3) + if person_handle and person_handle != '': + total_count += 1 + if tree_store.get_value(child_iter, 0): # Column 0 is active state + active_count += 1 + + # Recursively count descendants + if tree_store.iter_has_child(child_iter): + child_active, child_total = count_descendants(child_iter) + active_count += child_active + total_count += child_total + + child_iter = tree_store.iter_next(child_iter) + + return (active_count, total_count) + + active_count, total_count = count_descendants(person_iter) + + if total_count == 0: + return 'none' + elif active_count == 0: + return 'none' + elif active_count == total_count: + return 'all' + else: + return 'some' + + def _calculate_descendants_state(self, tree_store: Gtk.TreeStore, person_iter: Gtk.TreeIter) -> str: + """ + Calculate the state of descendants checkbox based on descendant person checkboxes. + Recursively checks all descendants' person checkboxes (column 0). + + Args: + tree_store: The TreeStore containing the data. + person_iter: The TreeIter for the person row. + + Returns: + str: 'all' if all descendants' person checkboxes are checked, 'none' if none checked, 'some' if partially checked. + """ + def count_descendants(parent_iter: Gtk.TreeIter) -> Tuple[int, int]: + """ + Recursively count active and total descendants based on their person checkboxes (column 0). + + Returns: + Tuple[int, int]: (active_count, total_count) + """ + child_iter = tree_store.iter_children(parent_iter) + if child_iter is None: + return (0, 0) + + active_count = 0 + total_count = 0 + + while child_iter is not None: + # Count this row if it's a person row (has person_handle) + person_handle = tree_store.get_value(child_iter, 3) + if person_handle and person_handle != '': + total_count += 1 + if tree_store.get_value(child_iter, 0): # Column 0 is person checkbox active state + active_count += 1 + + # Recursively count descendants + if tree_store.iter_has_child(child_iter): + child_active, child_total = count_descendants(child_iter) + active_count += child_active + total_count += child_total + + child_iter = tree_store.iter_next(child_iter) + + return (active_count, total_count) + + active_count, total_count = count_descendants(person_iter) + + if total_count == 0: + return 'none' + elif active_count == 0: + return 'none' + elif active_count == total_count: + return 'all' + else: + return 'some' + + def _update_descendants_checkbox_state(self, tree_store: Gtk.TreeStore, person_iter: Gtk.TreeIter, + person_handle: str) -> None: + """ + Update a person row's descendants checkbox state based on descendant person checkboxes. + + Args: + tree_store: The TreeStore containing the data. + person_iter: The TreeIter for the person row. + person_handle: The handle of the person. + """ + # Check if person has descendants + if not tree_store.iter_has_child(person_iter): + # No descendants, set to False + tree_store.set_value(person_iter, 6, False) # descendants_active = False + tree_store.set_value(person_iter, 7, False) # descendants_inconsistent = False + return + + state = self._calculate_descendants_state(tree_store, person_iter) + + if state == 'all': + tree_store.set_value(person_iter, 6, True) # descendants_active = True + tree_store.set_value(person_iter, 7, False) # descendants_inconsistent = False + elif state == 'none': + tree_store.set_value(person_iter, 6, False) # descendants_active = False + tree_store.set_value(person_iter, 7, False) # descendants_inconsistent = False + else: # 'some' + tree_store.set_value(person_iter, 6, False) # descendants_active = False (inconsistent state) + tree_store.set_value(person_iter, 7, True) # descendants_inconsistent = True + + def _update_person_checkbox_state(self, tree_store: Gtk.TreeStore, person_iter: Gtk.TreeIter, + person_handle: str) -> None: + """ + Update a person row's checkbox state based on descendant rows. + + Args: + tree_store: The TreeStore containing the data. + person_iter: The TreeIter for the person row. + person_handle: The handle of the person. + """ + # Check if person has descendants + if not tree_store.iter_has_child(person_iter): + # No descendants, so no inconsistent state needed + return + + state = self._calculate_person_state(tree_store, person_iter) + + if state == 'all': + tree_store.set_value(person_iter, 0, True) # active = True + tree_store.set_value(person_iter, 1, False) # inconsistent = False + elif state == 'none': + tree_store.set_value(person_iter, 0, False) # active = False + tree_store.set_value(person_iter, 1, False) # inconsistent = False + else: # 'some' + tree_store.set_value(person_iter, 1, True) # inconsistent = True + + def _update_family_checkbox_state(self, tree_store: Gtk.TreeStore, family_iter: Gtk.TreeIter, + family_handle: str, updating_flag: Optional[List[bool]] = None) -> None: + """ + Update a family row's checkbox state based on child person rows. + + Args: + tree_store: The TreeStore containing the data. + family_iter: The TreeIter for the family row. + family_handle: The handle of the family. + updating_flag: Optional list with single boolean to prevent recursion. + """ + # Set updating flag to prevent recursion if provided + if updating_flag is not None: + updating_flag[0] = True + + state = self._calculate_family_state(tree_store, family_iter) + + if state == 'all': + tree_store.set_value(family_iter, 0, True) # active = True + tree_store.set_value(family_iter, 1, False) # inconsistent = False + elif state == 'none': + tree_store.set_value(family_iter, 0, False) # active = False + tree_store.set_value(family_iter, 1, False) # inconsistent = False + else: # 'some' + tree_store.set_value(family_iter, 1, True) # inconsistent = True + + # Clear updating flag after update + if updating_flag is not None: + updating_flag[0] = False + + def _on_person_tree_checkbox_toggled(self, renderer: Gtk.CellRendererToggle, path: str) -> None: + """ + Handle person checkbox toggle in the person treeview. + + Args: + renderer: The CellRendererToggle that was toggled. + path: String path of the row that was toggled. + """ + tree_store = self._filter_widgets.get('person_tree_store') + if not tree_store: + return + + tree_path = Gtk.TreePath.new_from_string(path) + tree_iter = tree_store.get_iter(tree_path) + if not tree_iter: + return + + # Get row data + person_handle = tree_store.get_value(tree_iter, 3) # Column 3: person_handle + family_handle = tree_store.get_value(tree_iter, 4) # Column 4: family_handle + + # All rows should be person rows now (no family rows at top level) + if person_handle and person_handle != '': + # This is a person row + self._on_person_checkbox_toggled(tree_store, tree_iter, person_handle, family_handle) + + def _on_descendants_checkbox_toggled(self, renderer: Gtk.CellRendererToggle, path: str) -> None: + """ + Handle descendants checkbox toggle in the person treeview. + Only toggles descendants, not the person itself. + + Args: + renderer: The CellRendererToggle that was toggled. + path: String path of the row that was toggled. + """ + tree_store = self._filter_widgets.get('person_tree_store') + if not tree_store: + return + + tree_path = Gtk.TreePath.new_from_string(path) + tree_iter = tree_store.get_iter(tree_path) + if not tree_iter: + return + + # Check if row has descendants - if not, do nothing + if not tree_store.iter_has_child(tree_iter): + return + + # Get row data + person_handle = tree_store.get_value(tree_iter, 3) # Column 3: person_handle + family_handle = tree_store.get_value(tree_iter, 4) # Column 4: family_handle + + # Only handle person rows (not family rows) + if person_handle and person_handle != '': + self._on_descendants_checkbox_toggled_for_person(tree_store, tree_iter, person_handle, family_handle) + + def _on_descendants_checkbox_toggled_for_person(self, tree_store: Gtk.TreeStore, person_iter: Gtk.TreeIter, + person_handle: str, family_handle: str) -> None: + """ + Handle descendants checkbox toggle for a person - toggle only descendants, not the person itself. + + Args: + tree_store: The TreeStore containing the data. + person_iter: The TreeIter for the person row. + person_handle: The handle of the person. + family_handle: The handle of the family. + """ + # Get current descendants checkbox state + is_inconsistent = tree_store.get_value(person_iter, 7) # Column 7: descendants_inconsistent + current_active = tree_store.get_value(person_iter, 6) # Column 6: descendants_active + + # If inconsistent, make it consistent by checking all descendants + if is_inconsistent: + new_active = True + else: + new_active = not current_active + + # Recursively update all descendants' person checkboxes (column 0), but NOT the person itself + def update_descendants_only_recursive(parent_iter: Gtk.TreeIter) -> None: + """Recursively update only descendants' person checkboxes.""" + child_iter = tree_store.iter_children(parent_iter) + while child_iter is not None: + child_person_handle = tree_store.get_value(child_iter, 3) + if child_person_handle and child_person_handle != '': + # Update this descendant's person checkbox (column 0) + tree_store.set_value(child_iter, 0, new_active) + tree_store.set_value(child_iter, 1, False) # Clear person inconsistent state + # Also update this person in all other families where they appear + child_person_iters = self._filter_widgets.get('person_to_iters', {}).get(child_person_handle, []) + for other_iter in child_person_iters: + if other_iter != child_iter: + tree_store.set_value(other_iter, 0, new_active) + tree_store.set_value(other_iter, 1, False) # Clear person inconsistent state + + # Recursively update descendants + if tree_store.iter_has_child(child_iter): + update_descendants_only_recursive(child_iter) + + child_iter = tree_store.iter_next(child_iter) + + # Update descendants for all appearances of this person + person_iters = self._filter_widgets.get('person_to_iters', {}).get(person_handle, []) + for iter_obj in person_iters: + if tree_store.iter_has_child(iter_obj): + update_descendants_only_recursive(iter_obj) + + # Update descendants checkbox states for all appearances of this person + for iter_obj in person_iters: + self._update_descendants_checkbox_state(tree_store, iter_obj, person_handle) + + # Update descendants checkbox states for all ancestors + def update_ancestors_descendants(iter_obj: Gtk.TreeIter) -> None: + """Update descendants checkbox states for all ancestors.""" + parent_iter = tree_store.iter_parent(iter_obj) + while parent_iter is not None: + parent_person_handle = tree_store.get_value(parent_iter, 3) + if parent_person_handle and parent_person_handle != '': + # Update this ancestor's descendants checkbox state + self._update_descendants_checkbox_state(tree_store, parent_iter, parent_person_handle) + # Also update in all other appearances + parent_person_iters = self._filter_widgets.get('person_to_iters', {}).get(parent_person_handle, []) + for other_iter in parent_person_iters: + if other_iter != parent_iter: + self._update_descendants_checkbox_state(tree_store, other_iter, parent_person_handle) + parent_iter = tree_store.iter_parent(parent_iter) + + # Update ancestors for all appearances + for iter_obj in person_iters: + update_ancestors_descendants(iter_obj) + + # Update person checkbox states for ancestors (since descendants changed) + for iter_obj in person_iters: + update_ancestors_descendants(iter_obj) + # Also update person checkbox states + parent_iter = tree_store.iter_parent(iter_obj) + while parent_iter is not None: + parent_person_handle = tree_store.get_value(parent_iter, 3) + if parent_person_handle and parent_person_handle != '': + self._update_person_checkbox_state(tree_store, parent_iter, parent_person_handle) + parent_person_iters = self._filter_widgets.get('person_to_iters', {}).get(parent_person_handle, []) + for other_iter in parent_person_iters: + if other_iter != parent_iter: + self._update_person_checkbox_state(tree_store, other_iter, parent_person_handle) + parent_iter = tree_store.iter_parent(parent_iter) + + # Update family checkbox states + families_to_update = set() + for iter_obj in person_iters: + fam_handle = tree_store.get_value(iter_obj, 4) + if fam_handle: + families_to_update.add(fam_handle) + + for fam_handle in families_to_update: + family_iter = self._filter_widgets.get('family_to_iter', {}).get(fam_handle) + if family_iter: + updating_flag = self._filter_widgets.get('family_updating_flags', {}).get(fam_handle) + if updating_flag is None: + updating_flag = [False] + if 'family_updating_flags' not in self._filter_widgets: + self._filter_widgets['family_updating_flags'] = {} + self._filter_widgets['family_updating_flags'][fam_handle] = updating_flag + self._update_family_checkbox_state(tree_store, family_iter, fam_handle, updating_flag) + + def _on_family_checkbox_toggled(self, tree_store: Gtk.TreeStore, family_iter: Gtk.TreeIter, + family_handle: str) -> None: + """ + Handle family checkbox toggle - update all child person checkboxes. + + Args: + tree_store: The TreeStore containing the data. + family_iter: The TreeIter for the family row. + family_handle: The handle of the family. + """ + updating_flag = self._filter_widgets.get('family_updating_flags', {}).get(family_handle) + if updating_flag and updating_flag[0]: + return + + # Get current state + is_inconsistent = tree_store.get_value(family_iter, 1) # Column 1: inconsistent + current_active = tree_store.get_value(family_iter, 0) # Column 0: active + + # If inconsistent, make it consistent by checking all + if is_inconsistent: + new_active = True + else: + new_active = not current_active + + # Set updating flag + if updating_flag is None: + updating_flag = [False] + if 'family_updating_flags' not in self._filter_widgets: + self._filter_widgets['family_updating_flags'] = {} + self._filter_widgets['family_updating_flags'][family_handle] = updating_flag + updating_flag[0] = True + + # Recursively update all descendant person rows in this family + def update_descendants_recursive(parent_iter: Optional[Gtk.TreeIter]) -> None: + """Recursively update all descendants.""" + child_iter = tree_store.iter_children(parent_iter) if parent_iter else tree_store.iter_children(family_iter) + while child_iter is not None: + person_handle = tree_store.get_value(child_iter, 3) + if person_handle and person_handle != '': + tree_store.set_value(child_iter, 0, new_active) + tree_store.set_value(child_iter, 1, False) # Clear inconsistent state + # Also update this person in all other families where they appear + person_iters = self._filter_widgets.get('person_to_iters', {}).get(person_handle, []) + for other_iter in person_iters: + if other_iter != child_iter: + tree_store.set_value(other_iter, 0, new_active) + tree_store.set_value(other_iter, 1, False) # Clear inconsistent state + # Update the family checkbox state for the other family + other_family_handle = tree_store.get_value(other_iter, 4) + if other_family_handle and other_family_handle != family_handle: + other_family_iter = self._filter_widgets.get('family_to_iter', {}).get(other_family_handle) + if other_family_iter: + other_updating_flag = self._filter_widgets.get('family_updating_flags', {}).get(other_family_handle) + self._update_family_checkbox_state(tree_store, other_family_iter, other_family_handle, other_updating_flag) + + # Recursively update descendants + if tree_store.iter_has_child(child_iter): + update_descendants_recursive(child_iter) + + child_iter = tree_store.iter_next(child_iter) + + update_descendants_recursive(None) + + # Update person and descendants checkbox states for all persons in this family (to reflect descendant states) + def update_person_states_recursive(parent_iter: Optional[Gtk.TreeIter]) -> None: + """Recursively update person and descendants checkbox states.""" + child_iter = tree_store.iter_children(parent_iter) if parent_iter else tree_store.iter_children(family_iter) + while child_iter is not None: + person_handle = tree_store.get_value(child_iter, 3) + if person_handle and person_handle != '': + # Update this person's checkbox state + self._update_person_checkbox_state(tree_store, child_iter, person_handle) + # Update this person's descendants checkbox state + self._update_descendants_checkbox_state(tree_store, child_iter, person_handle) + # Also update in all other appearances + person_iters = self._filter_widgets.get('person_to_iters', {}).get(person_handle, []) + for other_iter in person_iters: + if other_iter != child_iter: + self._update_person_checkbox_state(tree_store, other_iter, person_handle) + self._update_descendants_checkbox_state(tree_store, other_iter, person_handle) + + # Recursively update descendants + if tree_store.iter_has_child(child_iter): + update_person_states_recursive(child_iter) + + child_iter = tree_store.iter_next(child_iter) + + update_person_states_recursive(None) + + # Update this family's checkbox state + self._update_family_checkbox_state(tree_store, family_iter, family_handle, updating_flag) + + updating_flag[0] = False + + def _on_person_checkbox_toggled(self, tree_store: Gtk.TreeStore, person_iter: Gtk.TreeIter, + person_handle: str, family_handle: str) -> None: + """ + Handle person checkbox toggle - update this person in all families and update family states. + + Args: + tree_store: The TreeStore containing the data. + person_iter: The TreeIter for the person row. + family_handle: The handle of the family this person belongs to. + person_handle: The handle of the person. + """ + # Get current state + is_inconsistent = tree_store.get_value(person_iter, 1) # Column 1: inconsistent + current_active = tree_store.get_value(person_iter, 0) # Column 0: active + + # If inconsistent, make it consistent by checking all + if is_inconsistent: + new_active = True + else: + new_active = not current_active + + # Update this person in ALL families where they appear (ONLY the person, not descendants) + person_iters = self._filter_widgets.get('person_to_iters', {}).get(person_handle, []) + for iter_obj in person_iters: + tree_store.set_value(iter_obj, 0, new_active) + tree_store.set_value(iter_obj, 1, False) # Clear person inconsistent state + + # Update descendants checkbox states for this person (since person checkbox changed) + for iter_obj in person_iters: + self._update_descendants_checkbox_state(tree_store, iter_obj, person_handle) + + # Update person and descendants checkbox states for all ancestors (parents up the tree) + def update_ancestors(iter_obj: Gtk.TreeIter) -> None: + """Update checkbox states for all ancestors.""" + parent_iter = tree_store.iter_parent(iter_obj) + while parent_iter is not None: + parent_person_handle = tree_store.get_value(parent_iter, 3) + if parent_person_handle and parent_person_handle != '': + # Update this ancestor's person checkbox state + self._update_person_checkbox_state(tree_store, parent_iter, parent_person_handle) + # Update this ancestor's descendants checkbox state + self._update_descendants_checkbox_state(tree_store, parent_iter, parent_person_handle) + # Also update in all other appearances + parent_person_iters = self._filter_widgets.get('person_to_iters', {}).get(parent_person_handle, []) + for other_iter in parent_person_iters: + if other_iter != parent_iter: + self._update_person_checkbox_state(tree_store, other_iter, parent_person_handle) + self._update_descendants_checkbox_state(tree_store, other_iter, parent_person_handle) + parent_iter = tree_store.iter_parent(parent_iter) + + # Update ancestors for all appearances + for iter_obj in person_iters: + update_ancestors(iter_obj) + + # Update all family checkbox states for families this person belongs to + families_to_update = set() + for iter_obj in person_iters: + fam_handle = tree_store.get_value(iter_obj, 4) + if fam_handle: + families_to_update.add(fam_handle) + + # Update each family's checkbox state + for fam_handle in families_to_update: + family_iter = self._filter_widgets.get('family_to_iter', {}).get(fam_handle) + if family_iter: + updating_flag = self._filter_widgets.get('family_updating_flags', {}).get(fam_handle) + if updating_flag is None: + updating_flag = [False] + if 'family_updating_flags' not in self._filter_widgets: + self._filter_widgets['family_updating_flags'] = {} + self._filter_widgets['family_updating_flags'][fam_handle] = updating_flag + self._update_family_checkbox_state(tree_store, family_iter, fam_handle, updating_flag) + def _update_event_type_widgets(self) -> None: """ Update event type filter widgets to reflect current filter state. @@ -1014,114 +1655,230 @@ class MyTimelineView(NavigationView): # Update category checkbox state based on children self._update_group_checkbox_state(category_checkbox, child_checkboxes, updating_flag) + def _is_root_family(self, family: 'Family') -> bool: + """ + Check if a family is a root family (neither parent has a parent family). + + Args: + family: The family to check. + + Returns: + bool: True if this is a root family, False otherwise. + """ + father_handle = family.get_father_handle() + mother_handle = family.get_mother_handle() + + # Check father + if father_handle: + try: + father = self.dbstate.db.get_person_from_handle(father_handle) + if father and father.get_parent_family_handle_list(): + return False # Father has a parent family + except (AttributeError, KeyError): + pass + + # Check mother + if mother_handle: + try: + mother = self.dbstate.db.get_person_from_handle(mother_handle) + if mother and mother.get_parent_family_handle_list(): + return False # Mother has a parent family + except (AttributeError, KeyError): + pass + + # Neither parent has a parent family, or parents don't exist + return True + + def _add_person_with_descendants(self, tree_store: Gtk.TreeStore, parent_iter: Optional[Gtk.TreeIter], + person_handle: str, role_label: str, family_handle: str) -> Optional[Gtk.TreeIter]: + """ + Recursively add a person and all their descendants to the TreeStore. + + Args: + tree_store: The TreeStore to add to. + parent_iter: The parent TreeIter (None for top-level). + person_handle: The handle of the person to add. + role_label: The role label (e.g., 'Father', 'Mother', 'Child'). + family_handle: The handle of the family this person belongs to in this context. + + Returns: + Optional[Gtk.TreeIter]: The TreeIter for the added person row, or None if person not found. + """ + if not person_handle: + return None + + try: + person = self.dbstate.db.get_person_from_handle(person_handle) + if not person: + return None + + person_name = self._get_person_display_name(person_handle) + if not person_name: + return None + + # Determine if person should be checked + is_checked = True if not self.person_filter else person_handle in self.person_filter + + # Add person as a row + person_iter = tree_store.append(parent_iter, [ + is_checked, # person_active (column 0) + False, # person_inconsistent (column 1) - will be updated if person has descendants + f"{role_label}: {person_name}", # name (column 2) + person_handle, # person_handle (column 3) + family_handle, # family_handle (column 4) + role_label, # role (column 5) + False, # descendants_active (column 6) - will be updated after adding descendants + False # descendants_inconsistent (column 7) - will be updated after adding descendants + ]) + + # Track this person's appearance in the tree + if person_handle not in self._filter_widgets['person_to_iters']: + self._filter_widgets['person_to_iters'][person_handle] = [] + self._filter_widgets['person_to_iters'][person_handle].append(person_iter) + + # Find all families where this person is a parent + parent_family_handles = person.get_family_handle_list() + + # Add children from each family where this person is a parent + for parent_family_handle in parent_family_handles: + try: + parent_family = self.dbstate.db.get_family_from_handle(parent_family_handle) + if not parent_family: + continue + + # Get the spouse (other parent) + spouse_handle = None + if person_handle == parent_family.get_father_handle(): + spouse_handle = parent_family.get_mother_handle() + elif person_handle == parent_family.get_mother_handle(): + spouse_handle = parent_family.get_father_handle() + + # Add spouse if exists + if spouse_handle: + spouse_name = self._get_person_display_name(spouse_handle) + if spouse_name: + spouse_role = _('Spouse') + # Add spouse as a row under this person + spouse_checked = True if not self.person_filter else spouse_handle in self.person_filter + spouse_iter = tree_store.append(person_iter, [ + spouse_checked, # person_active (column 0) + False, # person_inconsistent (column 1) + f"{spouse_role}: {spouse_name}", # name (column 2) + spouse_handle, # person_handle (column 3) + parent_family_handle, # family_handle (column 4) + spouse_role, # role (column 5) + False, # descendants_active (column 6) + False # descendants_inconsistent (column 7) + ]) + # Track spouse appearance + if spouse_handle not in self._filter_widgets['person_to_iters']: + self._filter_widgets['person_to_iters'][spouse_handle] = [] + self._filter_widgets['person_to_iters'][spouse_handle].append(spouse_iter) + + # Recursively add children from this family + for child_ref in parent_family.get_child_ref_list(): + child_handle = child_ref.ref + # Recursively add child and their descendants + self._add_person_with_descendants( + tree_store, person_iter, child_handle, _('Child'), parent_family_handle + ) + except (AttributeError, KeyError): + continue + + # Update person checkbox state based on descendants + self._update_person_checkbox_state(tree_store, person_iter, person_handle) + + # Update descendants checkbox state based on descendant person checkboxes + self._update_descendants_checkbox_state(tree_store, person_iter, person_handle) + + return person_iter + except (AttributeError, KeyError): + return None + def _update_person_filter_widgets(self) -> None: """ Update person filter widgets to reflect current filter state. + Populates the TreeStore with families and their members. """ - if 'person_checkboxes' not in self._filter_widgets or 'person_container' not in self._filter_widgets: + tree_store = self._filter_widgets.get('person_tree_store') + if not tree_store: return - # Clear existing person checkboxes and family expanders - container = self._filter_widgets['person_container'] - - # Remove all existing expanders - if 'family_expanders' in self._filter_widgets: - for expander in list(self._filter_widgets['family_expanders'].values()): - container.remove(expander) - expander.destroy() - self._filter_widgets['family_expanders'].clear() - - # Remove all existing checkboxes - for checkbox in list(self._filter_widgets['person_checkboxes'].values()): - container.remove(checkbox) - checkbox.destroy() - self._filter_widgets['person_checkboxes'].clear() - - # Collect all families and create expanders if not self.dbstate.is_open(): return + # Clear existing data + tree_store.clear() + self._filter_widgets['person_to_iters'] = {} + self._filter_widgets['family_to_iter'] = {} + self._filter_widgets['family_updating_flags'] = {} + try: - # Initialize family_expanders if not exists - if 'family_expanders' not in self._filter_widgets: - self._filter_widgets['family_expanders'] = {} + # Find all persons without parents (root persons) + root_persons = set() + processed_persons = set() - # Iterate through all families - for family in self.dbstate.db.iter_families(): - family_handle = family.get_handle() - - # Get family display name - family_name = self._get_family_display_name(family) - - # Create container for family members - members_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=FILTER_CATEGORY_SPACING + 1) - members_box.set_margin_start(FILTER_INDENT_MARGIN) - members_box.set_margin_top(CALENDAR_CONTAINER_MARGIN) - members_box.set_margin_bottom(CALENDAR_CONTAINER_MARGIN) - - # Collect all child checkboxes for this family - child_checkboxes = [] - - # Helper function to add person checkbox - def add_person_checkbox(person_handle: Optional[str], role_label: str) -> None: - """Add a checkbox for a person if handle is valid.""" - if not person_handle: - return - person_name = self._get_person_display_name(person_handle) - if person_name: - label_text = f" {role_label}: {person_name}" - checkbox = Gtk.CheckButton(label=label_text) - checkbox.set_active(True if not self.person_filter else person_handle in self.person_filter) - self._filter_widgets['person_checkboxes'][person_handle] = checkbox - child_checkboxes.append(checkbox) - members_box.pack_start(checkbox, False, False, 0) - - # Add father checkbox - add_person_checkbox(family.get_father_handle(), _('Father')) - - # Add mother checkbox - add_person_checkbox(family.get_mother_handle(), _('Mother')) - - # Add children checkboxes - for child_ref in family.get_child_ref_list(): - add_person_checkbox(child_ref.ref, _('Child')) - - # Only add expander if there are members to show - if members_box.get_children(): - # Create family checkbox with three-state support - family_checkbox = Gtk.CheckButton(label=family_name) - - # Create expander with checkbox as label - expander = Gtk.Expander() - # Set the checkbox as the label widget - expander.set_label_widget(family_checkbox) - expander.set_expanded(False) - - # Store family checkbox - if 'family_checkboxes' not in self._filter_widgets: - self._filter_widgets['family_checkboxes'] = {} - self._filter_widgets['family_checkboxes'][family_handle] = family_checkbox - - # Flag to prevent recursion - updating_family = [False] - - # Connect family checkbox to toggle all members - family_checkbox.connect("toggled", - self._make_group_toggle_handler(child_checkboxes, updating_family)) - - # Connect child checkboxes to update family checkbox - for child_cb in child_checkboxes: - child_cb.connect("toggled", - self._make_child_toggle_handler(family_checkbox, child_checkboxes, updating_family)) - - # Initialize family checkbox state - self._update_group_checkbox_state(family_checkbox, child_checkboxes, updating_family) - - expander.add(members_box) - self._filter_widgets['family_expanders'][family_handle] = expander - container.pack_start(expander, False, False, 0) + # First, collect all persons and identify root persons + for person_handle in self.dbstate.db.get_person_handles(): + try: + person = self.dbstate.db.get_person_from_handle(person_handle) + if person: + # Check if person has no parent families + parent_families = person.get_parent_family_handle_list() + if not parent_families: + root_persons.add(person_handle) + processed_persons.add(person_handle) + except (AttributeError, KeyError): + continue + + # Add root persons as top-level rows with all their descendants + for person_handle in root_persons: + try: + person = self.dbstate.db.get_person_from_handle(person_handle) + if not person: + continue + + # Get the first family where this person is a parent (for family_handle context) + # If person has no families as parent, use None + parent_families = person.get_family_handle_list() + family_handle = parent_families[0] if parent_families else None + + # Determine role label - if no family, just use person name + role_label = _('Person') + + # Add person with all descendants as top-level row + self._add_person_with_descendants( + tree_store, None, person_handle, role_label, family_handle or '' + ) + except (AttributeError, KeyError): + continue + + # Final pass: Update all person and descendants checkbox states (to handle ancestors) + def update_all_person_states(parent_iter: Optional[Gtk.TreeIter] = None) -> None: + """Recursively update all person and descendants checkbox states.""" + child_iter = tree_store.iter_children(parent_iter) if parent_iter else tree_store.get_iter_first() + while child_iter is not None: + person_handle = tree_store.get_value(child_iter, 3) + if person_handle and person_handle != '': + # Update this person's checkbox state + self._update_person_checkbox_state(tree_store, child_iter, person_handle) + # Update this person's descendants checkbox state + self._update_descendants_checkbox_state(tree_store, child_iter, person_handle) + # Also update in all other appearances + person_iters = self._filter_widgets.get('person_to_iters', {}).get(person_handle, []) + for other_iter in person_iters: + if other_iter != child_iter: + self._update_person_checkbox_state(tree_store, other_iter, person_handle) + self._update_descendants_checkbox_state(tree_store, other_iter, person_handle) + + # Recursively process children + if tree_store.iter_has_child(child_iter): + update_all_person_states(child_iter) + + child_iter = tree_store.iter_next(child_iter) + + update_all_person_states() - container.show_all() except (AttributeError, KeyError) as e: logger.warning(f"Error updating person filter widgets in filter dialog: {e}", exc_info=True) @@ -1189,13 +1946,86 @@ class MyTimelineView(NavigationView): all_categories = set(self._filter_widgets['category_checkboxes']) self.category_filter = active_categories if active_categories != all_categories else None - # Update person filter - if 'person_checkboxes' in self._filter_widgets: + # Update person filter from TreeStore + tree_store = self._filter_widgets.get('person_tree_store') + if tree_store: active_persons = set() - for person_handle, checkbox in self._filter_widgets['person_checkboxes'].items(): - if checkbox.get_active(): - active_persons.add(person_handle) - all_persons = set(self._filter_widgets['person_checkboxes']) + all_persons = set() + + # Track which persons are included via descendants checkboxes + persons_included_via_descendants = set() + + # Iterate through all rows in TreeStore + def iterate_tree(parent_iter: Optional[Gtk.TreeIter] = None, parent_descendants_checked: bool = False) -> None: + """ + Recursively iterate through TreeStore to collect person states. + + Args: + parent_iter: The parent iter (None for root). + parent_descendants_checked: Whether parent's descendants checkbox is checked. + """ + child_iter = tree_store.iter_children(parent_iter) if parent_iter else tree_store.get_iter_first() + + while child_iter is not None: + person_handle = tree_store.get_value(child_iter, 3) # Column 3: person_handle + person_active = tree_store.get_value(child_iter, 0) # Column 0: person_active + descendants_active = tree_store.get_value(child_iter, 6) # Column 6: descendants_active + descendants_inconsistent = tree_store.get_value(child_iter, 7) # Column 7: descendants_inconsistent + + # If this is a person row (not a family row) + if person_handle and person_handle != '': + all_persons.add(person_handle) + + # Person is included if: + # 1. Person checkbox is checked, OR + # 2. Parent's descendants checkbox is checked (includes this person), OR + # 3. Any ancestor's descendants checkbox is checked + is_included = person_active or parent_descendants_checked + + if is_included: + active_persons.add(person_handle) + # If descendants checkbox is checked or inconsistent, mark descendants as included + if descendants_active or descendants_inconsistent: + persons_included_via_descendants.add(person_handle) + + # Determine if descendants checkbox is checked for this row + descendants_checked = descendants_active or descendants_inconsistent + # If this person is included via parent's descendants checkbox, also check descendants + if parent_descendants_checked and person_handle and person_handle != '': + descendants_checked = True + + # Recursively process children + if tree_store.iter_has_child(child_iter): + iterate_tree(child_iter, descendants_checked) + + child_iter = tree_store.iter_next(child_iter) + + iterate_tree() + + # Also include all descendants of persons with checked descendants checkboxes + def include_descendants(person_handle: str, visited: Set[str]) -> None: + """Recursively include all descendants of a person.""" + if person_handle in visited: + return + visited.add(person_handle) + + # Find all appearances of this person in the tree + person_iters = self._filter_widgets.get('person_to_iters', {}).get(person_handle, []) + for iter_obj in person_iters: + child_iter = tree_store.iter_children(iter_obj) + while child_iter is not None: + child_person_handle = tree_store.get_value(child_iter, 3) + if child_person_handle and child_person_handle != '': + active_persons.add(child_person_handle) + # Recursively include their descendants + include_descendants(child_person_handle, visited) + child_iter = tree_store.iter_next(child_iter) + + visited = set() + for person_handle in persons_included_via_descendants: + include_descendants(person_handle, visited) + + # Set person_filter: None if all persons are selected, otherwise set of selected persons self.person_filter = active_persons if active_persons != all_persons else None # Enable filter if any filter is active