diff --git a/GraphView/drag_n_drop.py b/GraphView/drag_n_drop.py new file mode 100644 index 000000000..c94d64348 --- /dev/null +++ b/GraphView/drag_n_drop.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 + +import pickle +from gi.repository import Gtk, Gdk + +from gramps.gen.db import DbTxn +from gramps.gen.display.name import displayer +from gramps.gen.utils.libformatting import FormattingHelper +from gramps.gen.lib import ChildRef, PersonRef, Person, Family +from gramps.gui.ddtargets import DdTargets + +from gramps.gui.editors import EditPersonRef, EditFamily +from gramps.gen.errors import WindowActiveError + +from gramps.gen.const import GRAMPS_LOCALE as glocale +try: + _trans = glocale.get_addon_translator(__file__) +except ValueError: + _trans = glocale.translation +_ = _trans.gettext +ngettext = glocale.translation.ngettext + +gtk_version = float("%s.%s" % (Gtk.MAJOR_VERSION, Gtk.MINOR_VERSION)) + + +class DragAndDrop(): + """ + Add Drag-n-Drop feature to GraphView addon. + """ + def __init__(self, canvas, dbstate, uistate, h_adj, v_adj): + self.drag_person = None + self.drag_family = None + + self.h_adj = h_adj + self.v_adj = v_adj + self.dbstate = dbstate + self.uistate = uistate + self.canvas = canvas + self.canvas.connect("drag_data_get", self.drag_data_get) + self.canvas.connect("drag_begin", self.begin) + self.canvas.connect("drag_end", self.stop) + + self.drag_enabled = False + self.enable_dnd(True) + + # add drop support + self.canvas.drag_dest_set( + Gtk.DestDefaults.ALL, + [], + Gdk.DragAction.COPY + ) + tglist = Gtk.TargetList.new([]) + tglist.add(DdTargets.PERSON_LINK.atom_drag_type, + DdTargets.PERSON_LINK.target_flags, + DdTargets.PERSON_LINK.app_id, + ) + # TODO: add other targets. For now only person drop supported. + self.canvas.drag_dest_set_target_list(tglist) + self.canvas.connect("drag-motion", self.drag_motion) + self.canvas.connect("drag-data-received", self.drag_data_receive) + + self.item_cache = {} + + def enable_dnd(self, state): + """ + Enable or disable drag-n-drop for canvas widget. + """ + if self.drag_enabled == state: + return + if state: + self.canvas.drag_source_set( + Gdk.ModifierType.BUTTON1_MASK, + [], + Gdk.DragAction.COPY) + else: + self.canvas.drag_source_unset() + self.drag_enabled = state + + def begin(self, widget, context): + """ + Called when drag is start. + """ + tgs = [x.name() for x in context.list_targets()] + # set icon depending on person or family drag + if DdTargets.PERSON_LINK.drag_type in tgs: + Gtk.drag_set_icon_name(context, 'gramps-person', 0, 0) + if DdTargets.FAMILY_LINK.drag_type in tgs: + Gtk.drag_set_icon_name(context, 'gramps-family', 0, 0) + + self.item_cache.clear() + + def stop(self, *args): + """ + Called when drag is end. + """ + self.drag_person = None + self.drag_family = None + + def set_target(self, node_class, handle): + """ + Set targets for drag-n-drop. + """ + self.stop() + tglist = Gtk.TargetList.new([]) + if node_class == 'node': + self.drag_person = self.dbstate.db.get_person_from_handle(handle) + tglist.add(DdTargets.PERSON_LINK.atom_drag_type, + DdTargets.PERSON_LINK.target_flags, + DdTargets.PERSON_LINK.app_id, + ) + # allow drag to a text document, info on drag_get will be 0 + tglist.add_text_targets(0) + elif node_class == 'familynode': + self.drag_family = self.dbstate.db.get_family_from_handle(handle) + tglist.add(DdTargets.FAMILY_LINK.atom_drag_type, + DdTargets.FAMILY_LINK.target_flags, + DdTargets.FAMILY_LINK.app_id, + ) + # allow drag to a text document, info on drag_get will be 1 + tglist.add_text_targets(1) + + if tglist: + self.canvas.drag_source_set_target_list(tglist) + else: + self.enable_dnd(False) + + def drag_data_get(self, widget, context, sel_data, info, time): + """ + Returned parameters after drag. + Specified for 'person-link' and 'family-link', + also to return text info about person or family. + """ + tgs = [x.name() for x in context.list_targets()] + + if info == DdTargets.PERSON_LINK.app_id: + data = (DdTargets.PERSON_LINK.drag_type, + id(widget), self.drag_person.handle, 0) + sel_data.set(sel_data.get_target(), 8, pickle.dumps(data)) + elif info == DdTargets.FAMILY_LINK.app_id: + data = (DdTargets.FAMILY_LINK.drag_type, + id(widget), self.drag_family.handle, 0) + sel_data.set(sel_data.get_target(), 8, pickle.dumps(data)) + elif ('TEXT' in tgs or 'text/plain' in tgs): + if info == 0: + format_helper = FormattingHelper(self.dbstate) + sel_data.set_text( + format_helper.format_person(self.drag_person, 11), -1) + if info == 1: + f_handle = self.drag_family.get_father_handle() + m_handle = self.drag_family.get_mother_handle() + if f_handle: + father = self.dbstate.db.get_person_from_handle(f_handle) + father = displayer.display(father) + else: + father = '...' + if m_handle: + mother = self.dbstate.db.get_person_from_handle(m_handle) + mother = displayer.display(mother) + else: + mother = '...' + sel_data.set_text( + _('Family of %s and %s') % (father, mother), -1) + + def drag_motion(self, widget, context, x, y, time): + """ + Monitor drag motion. And check if we can receive the data. + Disable drop if we are not at person or family node. + """ + if self.get_item_at_pos(x, y) is None: + # disable drop + Gdk.drag_status(context, 0, time) + + def drag_data_receive(self, widget, context, x, y, data, info, time): + """ + Handle drop event. + """ + receiver = self.get_item_at_pos(x, y) + + # unpickle data and get dropped person's handles + out_data = [] + p_data = pickle.loads(data.get_data()) + if isinstance(p_data[0], bytes): + for d in p_data: + tmp = pickle.loads(d) + if tmp[0] == 'person-link': + out_data.append(tmp[2]) + elif p_data[0] == 'person-link': + out_data.append(p_data[2]) + + action_menu = Popover(self.canvas) + rect = Gdk.Rectangle() + rect.x = x + rect.y = y + rect.height = rect.width = 1 + action_menu.set_pointing_to(rect) + + # =========================== + # Generate actions popup menu + # =========================== + + # if person is dropped to family node then add them to this family + if receiver[0] == 'familynode' and out_data: + title = ngettext("Add as child to family", + "Add as children to family", + len(out_data)) + action_menu.add_action(title, self.add_children_to_family, + receiver[1], out_data) + # add spouse to family + if len(out_data) == 1: + person = self.dbstate.db.get_person_from_handle(out_data[0]) + gender = person.get_gender() + f_handle = receiver[1].get_father_handle() + m_handle = receiver[1].get_mother_handle() + + if not f_handle and gender in (Person.MALE, Person.UNKNOWN): + action_menu.add_action(_('Add spouse as father'), + self.add_spouse, + out_data[0], None, receiver[1]) + if not m_handle and gender in (Person.FEMALE, Person.UNKNOWN): + action_menu.add_action(_('Add spouse as mother'), + self.add_spouse, + None, out_data[0], receiver[1]) + + # if drop to person node + if receiver[0] == 'node' and out_data: + # add relation (reference) + if len(out_data) == 1: + action_menu.add_action(_('Add relation'), self.add_personref, + receiver[1], out_data[0]) + # add as parent + if len(out_data) in (1, 2): + parent_family_list = receiver[1].get_parent_family_handle_list() + # dropped 1 person and 1 family exists + if len(parent_family_list) == 1 and len(out_data) == 1: + person = self.dbstate.db.get_person_from_handle(out_data[0]) + gender = person.get_gender() + family = self.dbstate.db.get_family_from_handle( + parent_family_list[0]) + f_handle = family.get_father_handle() + m_handle = family.get_mother_handle() + + if not f_handle and gender in (Person.MALE, Person.UNKNOWN): + action_menu.add_action(_('Add as father'), + self.add_spouse, + out_data[0], None, family) + elif not m_handle and gender in (Person.FEMALE, + Person.UNKNOWN): + action_menu.add_action(_('Add as mother'), + self.add_spouse, + None, out_data[0], family) + # create family for person + elif not parent_family_list: + father = None + mother = None + for p in out_data: + person = self.dbstate.db.get_person_from_handle(p) + gender = person.get_gender() + if gender == Person.MALE: + father = p if father is None else None + if gender == Person.FEMALE: + mother = p if mother is None else None + if father or mother: + child = receiver[1].get_handle() + action_menu.add_action(_('Add parents'), + self.add_spouse, + father, mother, None, child) + action_menu.popup() + + def get_item_at_pos(self, x, y): + """ + Get GooCanvas item at cursor position. + Return: (node_class, person/family object) or None. + """ + scale_coef = self.canvas.get_scale() + x_pos = (x + self.h_adj.get_value()) / scale_coef + y_pos = (y + self.v_adj.get_value()) / scale_coef + + item = self.canvas.get_item_at(x_pos, y_pos, True) + obj = self.item_cache.get(item) + if obj is not None: + return obj + try: + # data stored in GooCanvasGroup which is parent of the item + parent = item.get_parent() + handle = parent.title + node_class = parent.description + + if node_class == 'node' and handle: + obj = (node_class, + self.dbstate.db.get_person_from_handle(handle)) + elif node_class == 'familynode' and handle: + obj = (node_class, + self.dbstate.db.get_family_from_handle(handle)) + else: + return None + self.item_cache[item] = obj + return obj + except: + pass + return None + + def add_spouse(self, widget, father_handle, mother_handle, + family=None, child=None): + """ + Add spouse to family. + If family is not provided it will be created for specified child. + """ + if family is None: + if child is None: + # we need child to refer to new family + return + family = Family() + childref = ChildRef() + childref.set_reference_handle(child) + family.add_child_ref(childref) + + if father_handle: + family.set_father_handle(father_handle) + if mother_handle: + family.set_mother_handle(mother_handle) + + try: + EditFamily(self.dbstate, self.uistate, [], family) + except WindowActiveError: + pass + + def add_children_to_family(self, widget, family, data): + """ + Add persons to family. + data: list of person handles + """ + for person_handle in data: + person = self.dbstate.db.get_person_from_handle(person_handle) + ref = ChildRef() + ref.ref = person_handle + family.add_child_ref(ref) + person.add_parent_family_handle(family.get_handle()) + + with DbTxn(_("Add Child to Family"), self.dbstate.db) as trans: + # default relationship is used + self.dbstate.db.commit_person(person, trans) + # add child to family + self.dbstate.db.commit_family(family, trans) + + def add_personref(self, widget, source, person_handle): + """ + Open dialog to add reference to person. + """ + ref = PersonRef() + ref.rel = _('Godfather') # default role + ref.source = source + try: + dialog = EditPersonRef(self.dbstate, self.uistate, [], + ref, self.__cb_add_ref) + dialog.update_person( + self.dbstate.db.get_person_from_handle(person_handle)) + except WindowActiveError: + pass + + def __cb_add_ref(self, obj): + """ + Save person reference. + """ + person = obj.source + person.add_person_ref(obj) + with DbTxn(_("Add Reference to Person"), self.dbstate.db) as trans: + self.dbstate.db.commit_person(person, trans) + + +class Popover(Gtk.Popover): + """ + Display available actions on drop event. + """ + def __init__(self, widget): + Gtk.Popover.__init__(self, relative_to=widget) + self.set_modal(True) + + self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + self.cancel_btn = Gtk.Button(label=_('Cancel'), margin_top=5) + self.box.pack_end(self.cancel_btn, True, True, 1) + self.cancel_btn.connect('clicked', self.popdown) + + # set all widgets visible + self.box.show_all() + self.add(self.box) + + def add_action(self, label, callback, *args): + """ + Add button to popover with action. + """ + action = Gtk.Button(label=label) + self.box.pack_start(action, True, True, 1) + if callback: + action.connect('clicked', callback, *args) + action.connect('clicked', self.popdown) + action.show() + + def popup(self): + """ + Different popup depending on gtk version. + """ + if gtk_version >= 3.22: + super(self.__class__, self).popup() + else: + self.show() + + def popdown(self, *args): + """ + Different popdown depending on gtk version. + """ + if gtk_version >= 3.22: + super(self.__class__, self).popdown() + else: + self.hide() diff --git a/GraphView/graphview.py b/GraphView/graphview.py index c4b30e0ea..976f2cc71 100644 --- a/GraphView/graphview.py +++ b/GraphView/graphview.py @@ -135,6 +135,7 @@ sys.path.append(os.path.abspath(os.path.dirname(__file__))) from search_widget import SearchWidget, Popover, ListBoxRow, get_person_tooltip from avatars import Avatars +from drag_n_drop import DragAndDrop #------------------------------------------------------------------------- @@ -1140,6 +1141,7 @@ def __init__(self, view, dbstate, uistate): # for detecting double click self.click_events = [] + self.double_click = False # for timeout on changing settings by spinners self.timeout_event = False @@ -1152,6 +1154,11 @@ def __init__(self, view, dbstate, uistate): self.retest_font = True # flag indicates need to resize font self.bold_size = self.norm_size = 0 # font sizes to send to dot + # setup drag and drop + self.canvas.connect("drag-begin", self.del_click_events) + self.dnd = DragAndDrop(self.canvas, self.dbstate, self.uistate, + self.hadjustment, self.vadjustment) + def add_popover(self, widget, container): """ Add popover for button. @@ -1165,7 +1172,7 @@ def add_popover(self, widget, container): def build_spinner(self, icon, start, end, tooltip, conf_const): """ Build spinner with icon and pack it into box. - Chenges apply to config with delay. + Changes apply to config with delay. """ box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) img = Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.MENU) @@ -1465,6 +1472,7 @@ def populate(self, active_person): """ Populate the graph with widgets derived from Graphviz. """ + self.dnd.enable_dnd(False) # set the busy cursor, so the user knows that we are working self.uistate.set_busy_cursor(True) if self.uistate.window.get_window().is_visible(): @@ -1600,7 +1608,8 @@ def button_press(self, item, _target, event): return False button = event.get_button()[1] - if button == 1 or button == 2: + if button in (1, 2): + self.dnd.enable_dnd(False) window = self.canvas.get_parent().get_window() window.set_cursor(Gdk.Cursor.new(Gdk.CursorType.FLEUR)) self._last_x = event.x_root @@ -1648,6 +1657,7 @@ def motion_notify_event(self, _item, _target, event): (event.y_root - self._last_y) * scale_coef) self.vadjustment.set_value(new_y) return True + self.dnd.enable_dnd(True) return False def set_zoom(self, value): @@ -1658,10 +1668,19 @@ def set_zoom(self, value): self.view._config.set('interface.graphview-scale', value) self.canvas.set_scale(value / self.transform_scale) - def select_node(self, item, target, event): + def del_click_events(self, *args): + """ + Remove all single click events. + """ + for click_item in self.click_events: + if not click_item.is_destroyed(): + GLib.source_remove(click_item.get_id()) + self.click_events.clear() + + def press_node(self, item, target, event): """ - Perform actions when a node is clicked. - If middle mouse was clicked then try to set scroll mode. + Perform actions when a node is clicked (button press). + If middle mouse was pressed then try to set scroll mode. """ self.search_widget.hide_search_popover() self.hide_bkmark_popover() @@ -1674,21 +1693,51 @@ def select_node(self, item, target, event): # perform double click on node by left mouse button if event.type == getattr(Gdk.EventType, "DOUBLE_BUTTON_PRESS"): - # Remove all single click events - for click_item in self.click_events: - if not click_item.is_destroyed(): - GLib.source_remove(click_item.get_id()) - self.click_events.clear() + self.del_click_events() if button == 1 and node_class == 'node': GLib.idle_add(self.actions.edit_person, None, handle) - return True elif button == 1 and node_class == 'familynode': GLib.idle_add(self.actions.edit_family, None, handle) - return True + self.double_click = True + return True if event.type != getattr(Gdk.EventType, "BUTTON_PRESS"): return False + # set targets for drag-n-drop (object type and handle) + if button == 1 and node_class in ('node', 'familynode'): + self.dnd.set_target(node_class, handle) + + elif button == 3 and node_class: # right mouse + if node_class == 'node': + self.menu = PopupMenu(self, 'person', handle) + self.menu.show_menu(event) + elif node_class == 'familynode': + self.menu = PopupMenu(self, 'family', handle) + self.menu.show_menu(event) + + elif button == 2: # middle mouse + # to enter in scroll mode (we should change "item" to root item) + item = self.canvas.get_root_item() + self.button_press(item, target, event) + + return True + + def release_node(self, item, target, event): + """ + Perform actions when a node is clicked (button release). + Set timer to handle single click at node and wait double click. + """ + # don't handle single click if had double click before + # because we came here after DOUBLE_BUTTON_PRESS event + if self.double_click: + self.double_click = False + return True + + handle = item.title + node_class = item.description + button = event.get_button()[1] + if button == 1 and node_class == 'node': # left mouse if handle == self.active_person_handle: # Find a parent of the active person so that they can become @@ -1708,21 +1757,6 @@ def select_node(self, item, target, event): context = GLib.main_context_default() self.click_events.append(context.find_source_by_id(click_event_id)) - elif button == 3 and node_class: # right mouse - if node_class == 'node': - self.menu = PopupMenu(self, 'person', handle) - self.menu.show_menu(event) - elif node_class == 'familynode': - self.menu = PopupMenu(self, 'family', handle) - self.menu.show_menu(event) - - elif button == 2: # middle mouse - # to enter in scroll mode (we should change "item" to root item) - item = self.canvas.get_root_item() - self.button_press(item, target, event) - - return True - def find_a_parent(self, handle): """ Locate a parent from the first family that the selected person is a @@ -1937,7 +1971,8 @@ def start_g(self, attrs): self.widget.motion_notify_event) else: item = GooCanvas.CanvasGroup(parent=self.current_parent()) - item.connect("button-press-event", self.widget.select_node) + item.connect("button-press-event", self.widget.press_node) + item.connect("button-release-event", self.widget.release_node) self.items_list.append(item) item.description = attrs.get('class') diff --git a/GraphView/search_widget.py b/GraphView/search_widget.py index a7138d450..88b874573 100755 --- a/GraphView/search_widget.py +++ b/GraphView/search_widget.py @@ -19,6 +19,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # +import pickle + from gi.repository import Gtk, Gdk, GLib, GObject from threading import Event from queue import Queue, Empty @@ -27,6 +29,7 @@ from gramps.gen.display.name import displayer from gramps.gen.utils.db import (get_birth_or_fallback, get_death_or_fallback, find_parents) +from gramps.gui.ddtargets import DdTargets from gramps.gen.const import GRAMPS_LOCALE as glocale try: @@ -504,7 +507,7 @@ def popdown(self): class ListBoxRow(Gtk.ListBoxRow): """ - Extended Gtk.ListBoxRow. + Extended Gtk.ListBoxRow with person DnD support. """ def __init__(self, person_handle=None, label='', marked=False, db=None): Gtk.ListBoxRow.__init__(self) @@ -516,6 +519,41 @@ def __init__(self, person_handle=None, label='', marked=False, db=None): self.set_has_tooltip(True) self.connect('query-tooltip', self.query_tooltip) + self.setup_dnd() + + def add(self, widget): + """ + Override "container.add" to catch drag events. + Pack content of ListBoxRow to Gtk.EventBox. + """ + ebox = Gtk.EventBox() + ebox.add(widget) + super().add(ebox) + + def setup_dnd(self): + """ + Setup drag-n-drop. + """ + self.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, + [], + Gdk.DragAction.COPY) + tglist = Gtk.TargetList.new([]) + tglist.add(DdTargets.PERSON_LINK.atom_drag_type, + DdTargets.PERSON_LINK.target_flags, + DdTargets.PERSON_LINK.app_id) + self.drag_source_set_target_list(tglist) + + self.connect("drag-data-get", self.drag_data_get) + + self.drag_source_set_icon_name('gramps-person') + + def drag_data_get(self, widget, context, sel_data, info, time): + """ + Returned parameters after drag. + """ + data = (DdTargets.PERSON_LINK.drag_type, + id(widget), self.person_handle, 0) + sel_data.set(sel_data.get_target(), 8, pickle.dumps(data)) def query_tooltip(self, widget, x, y, keyboard_mode, tooltip): """ @@ -529,6 +567,7 @@ def query_tooltip(self, widget, x, y, keyboard_mode, tooltip): else: self.set_has_tooltip(False) + class ScrolledListBox(Gtk.ScrolledWindow): """ Extended Gtk.ScrolledWindow with max_height property.