class Feature(SavedObject): syntactic_object = True editable = {} addable = {} def __init__(self, ftype=None, value=None): super().__init__() self.ftype = ftype self.value = value def __repr__(self): if self.ftype == 'cat': return self.value elif self.ftype == 'sel': return '=' + self.value elif self.ftype == 'neg': return '-' + self.value elif self.ftype == 'pos': return '+' + self.value def __eq__(self, other): return self.ftype == other.ftype and self.value == other.value def __hash__(self): return hash(str(self)) # ############## # # # # Save support # # # # ############## # name = SavedField("name") value = SavedField("value")
class DerivationStep(SavedObject): """ Packed state of syntactic objects for stepwise animation of trees growth. """ def __init__(self, synobjs=None, numeration=None, other=None, msg=None, gloss=None, transferred=None, mover=None, uid=None): super().__init__(uid=uid) self.synobjs = synobjs or [] self.numeration = numeration self.other = other self.msg = msg self.gloss = gloss self.transferred = transferred self.mover = mover def __str__(self): return "DS(" + str(self.synobjs) + ", " + str(self.numeration) + ", " + str(self.other) \ + ", '" + str(self.msg) + "')" # ############## # # # # Save support # # # # ############## # synobjs = SavedField("synobjs") numeration = SavedField("numeration") other = SavedField("other") msg = SavedField("msg") gloss = SavedField("gloss") transferred = SavedField("transferred") mover = SavedField("mover")
class HiConstituent(BaseConstituent): """ HiConstituent is a slight modification from BaseConstituent. Everything that is not explicitly defined here is inherited from parent class.""" def __init__(self, *args, **kwargs): """ Constructor for new HiConstituents """ super().__init__(*args, **kwargs) if 'hi' in kwargs: self.hi = kwargs['hi'] else: self.hi = 'hello' def __repr__(self): """ Readable representation for debugging. (E.g. how item will look as a list member, when the list is printed out.) You can also define __str__ for even more readable output, otherwise __str__ will use __repr__. :return: """ if self.is_leaf(): return 'HiConstituent(id=%s)' % self.label else: return "[ %s ]" % (' '.join((x.__repr__() for x in self.parts))) def compose_html_for_viewing(self, node): """ This method builds the html to display in label. For convenience, syntactic objects can override this (going against the containment logic) by having their own 'compose_html_for_viewing' -method. This is so that it is easier to create custom implementations for constituents without requiring custom constituentnodes. Note that synobj's compose_html_for_viewing receives the node object as parameter, so you can call the parent to do its part and then add your own to it. :return: """ html, lower_html = node.compose_html_for_viewing( peek_into_synobj=False) html += ', hi: ' + self.hi return html, lower_html def copy(self): """ Make a deep copy of constituent. Useful for picking constituents from Lexicon. :return: HiConstituent """ nc = super().copy() nc.hi = self.hi return nc # Announce save support for given fields (+ those that are inherited from parent class): hi = SavedField("hi")
class HiConstituent(BaseConstituent): """ HiConstituent is a slight modification from BaseConstituent. Everything that is not explicitly defined here is inherited from parent class.""" def __init__(self, **kw): """ """ super().__init__(**kw) self.hi = 'hi' def __repr__(self): if self.is_leaf(): return 'HiConstituent(id=%s)' % self.label else: return "[ %s ]" % (' '.join((x.__repr__() for x in self.parts))) def compose_html_for_viewing(self, node): """ This method builds the html to display in label. For convenience, syntactic objects can override this (going against the containment logic) by having their own 'compose_html_for_viewing' -method. This is so that it is easier to create custom implementations for constituents without requiring custom constituentnodes. Note that synobj's compose_html_for_viewing receives the node object as parameter, so you can call the parent to do its part and then add your own to it. :return: """ html, lower_html = node.compose_html_for_viewing( peek_into_synobj=False) html += ', hi: ' + self.hi return html, lower_html def copy(self): """ Make a deep copy of constituent. Useful for picking constituents from Lexicon. :return: BaseConstituent """ nc = super().copy() nc.hi = self.hi return nc # ############## # # # # Save support # # # # ############## # hi = SavedField("hi")
class Feature(MyBaseClass): role = "Feature" def __init__(self, namestring='', counter=0, name='', value='', unvalued=False, ifeature=False, valued_by=None, inactive=False): if in_kataja: super().__init__(name=name, value=value) if namestring: if namestring.startswith('u'): namestring = namestring[1:] self.unvalued = True self.ifeature = False elif namestring.startswith('i'): namestring = namestring[1:] self.unvalued = False self.ifeature = True else: self.unvalued = False self.ifeature = False digits = '' digit_count = 0 for char in reversed(namestring): if char.isdigit(): digits = char + digits digit_count += 1 else: break if digit_count: self.counter = int(digits) namestring = namestring[:-digit_count] else: self.counter = 0 self.name, part, self.value = namestring.partition(':') self.valued_by = None self.inactive = inactive else: self.name = name self.counter = counter self.value = value self.unvalued = unvalued self.ifeature = ifeature self.valued_by = None self.inactive = inactive def __str__(self): return repr(self) def __repr__(self): parts = [] if self.ifeature: parts.append('i') elif self.unvalued: parts.append('u') parts.append(self.name) if self.value: parts.append(':' + self.value) if self.counter: parts.append(str(self.counter)) if self.valued_by: parts.append('(%s)' % self.valued_by) #return "'"+''.join(parts)+"'" return ''.join(parts) def get_parts(self): """ This is what Kataja uses to find if there is inner structure/links to other nodes to display. Use it here for valued_by -relation. :return: """ return self.valued_by or [] def __eq__(self, o): if isinstance(o, Feature): return self.counter == o.counter and self.name == o.name and \ self.value == o.value and self.unvalued == o.unvalued and \ self.ifeature == o.ifeature and self.valued_by == o.valued_by elif isinstance(o, str): return str(self) == o else: return False def __lt__(self, other): return repr(self) < repr(other) def __gt__(self, other): return repr(self) > repr(other) def __hash__(self): return hash(str(self)) def __contains__(self, item): """ This doesn't look into linked features, you should be looking into them first (use expanded_features -function in Constituent to get all of them) :param item: :return: """ if isinstance(item, Feature): if item.name != self.name: return False if item.ifeature and not self.ifeature: return False if item.value and self.value != item.value: return False if item.unvalued and not self.unvalued: return False return True else: return item in str(self) def unvalued_and_alone(self): return self.unvalued and not self.valued_by def value_with(self, other): assert other is not self if self.valued_by: self.valued_by.append(other) else: self.valued_by = [other] def expand_linked_features(self): if self.valued_by: flist = [] for f in self.valued_by: flist += f.expand_linked_features() return flist else: return [self] def match(self, name=None, value=None, u=None, i=None, phi=None): if isinstance(name, list): if self.name not in name: return None elif name is not None and self.name != name: return None elif value is not None and self.value != value: return None elif u is not None and self.unvalued != u: return None elif i is not None and self.ifeature != i: return None elif phi and self.name not in ("Person", "Number", "Gender"): return None return True def copy(self): return Feature(counter=self.counter, name=self.name, value=self.value, unvalued=self.unvalued, ifeature=self.ifeature, valued_by=valued_by) if in_kataja: unvalued = SavedField("unvalued") ifeature = SavedField("ifeature") counter = SavedField("counter") valued_by = SavedField("valued_by") inactive = SavedField("inactive")
class BaseFeature(SavedObject): """ BaseFeatures are the simplest feature implementation. BaseFeatures have name, which is used to identify and discriminate between features. BaseFeatures can have simple comparable items as values, generally assumed to be booleans or strings. Distinguishing between assigned and unassigned features is such a common property in minimalist grammars that it is supported by BaseFeature. 'assigned' is by default True, 'unassigned' features have False. Family property can be used e.g. to mark phi-features or LF features. """ syntactic_object = True role = "Feature" editable = {} addable = {} def __init__(self, name='Feature', value=None, family=''): super().__init__() self.name = name self.value = value self.family = family self.checks = None # this has no syntactic effect but storing which feature this # feature has checked helps presentation def has_value(self, prop): return self.value == prop @property def unassigned(self): return self.unvalued() @property def assigned(self): return not self.unvalued() def unvalued(self): return self.value == 'u' or self.value == '=' def satisfies(self, feature): return feature.unvalued( ) and feature.name == self.name and not self.unvalued() def __eq__(self, other): if other: return self.value == other.value and self.name == other.name and self.family == \ other.family return False def copy(self): return self.__class__(name=self.name, value=self.value, family=self.family) def checked(self): return self.value.startswith('✓') def __str__(self): s = [] signs = ('+', '-', '=', 'u', '✓') if self.value and (len(self.value) == 1 and self.value in signs or \ len(self.value) == 2 and self.value[1] in signs): s.append(self.value + str(self.name)) elif self.value or self.family: s.append(str(self.name)) s.append(str(self.value)) if self.family: s.append(str(self.family)) else: s.append(str(self.name)) return ":".join(s) def __repr__(self): s = [str(self.name)] if self.value or self.family: s.append(str(self.value)) if self.family: s.append(str(self.family)) return ":".join(s) def __hash__(self): return id(self) # ############## # # # # Save support # # # # ############## # name = SavedField("name") value = SavedField("value") family = SavedField("family") checks = SavedField("checks") @staticmethod def from_string(s): if not s: return if s[0] in '-=+': value = s[0] name = s[1:] else: value = '' name = s name, foo, family = name.partition( ':') # 'case:acc' -> name = 'case', subtype = 'acc' return BaseFeature(name, value, family)
class Forest(SavedObject): """ Forest is a group of trees that together form one view. Often there needs to be more than one trees visible at same time, so that they can be compared or to show states of construction where some edges are not yet linked to the main root. Forest is the container for these. Forest also takes care of the operations manipulating, creating and removing trees. """ def __init__(self, gloss_text='', comments=None, syntax=None): """ Create an empty forest. Gloss_text and comments are metadata about trees that doesn't belong to syntax implementation, so its kept here. Syntax implementations may still use it. By default, a new Forest doesn't create its nodes -- it doesn't do the derivation yet. This is to save speed and memory with large structures. If the is_parsed -flag is False when created, but once Forest is displayed, the derivation has to run and after that is_parsed is True. """ super().__init__() self.nodes_from_synobs = {} self.main = ctrl.main self.main.forest = self # assign self to be the active forest while # creating the managers. self.in_display = False self.visualization = None self.gloss = None self.is_parsed = False self.syntax = syntax or classes.get('SyntaxConnection')() self.parser = INodeToKatajaConstituent(self) self.undo_manager = UndoManager(self) self.chain_manager = ChainManager(self) self.tree_manager = TreeManager(self) self.free_drawing = FreeDrawing(self) self.projection_manager = ProjectionManager(self) self.derivation_steps = DerivationStepManager(self) self.old_label_mode = 0 self.trees = [] self.nodes = {} self.edges = {} self.groups = {} self.others = {} self.vis_data = {} self.width_map = {} self.traces_to_draw = {} self.comments = [] self.gloss_text = '' self.ongoing_animations = set() self.halt_drawing = False self.gloss_text = gloss_text self.comments = comments # Update request flags self._do_edge_visibility_check = False #self.change_view_mode(ctrl.settings.get('syntactic_mode')) def after_model_update(self, updated_fields, transition_type): """ Compute derived effects of updated values in sensible order. :param updated_fields: field keys of updates :param transition_type: 0:edit, 1:CREATED, -1:DELETED :return: None """ if 'nodes' in updated_fields: # rebuild from-syntactic_object-to-node -dict self.nodes_from_synobs = {} for node in self.nodes.values(): if node.syntactic_object: self.nodes_from_synobs[node.syntactic_object.uid] = node for tree in self.trees: tree.update_items() if 'vis_data' in updated_fields: self.restore_visualization() def after_init(self): """ After_init is called in 2nd step in process of creating objects: 1st wave creates the objects and calls __init__, and then iterates through and sets the values. 2nd wave calls after_inits for all created objects. Now they can properly refer to each other and know their values. :return: None """ # print('created a forest %s , its traces should be visible: %s ' % ( # self, self.traces_are_visible())) pass # for node in self.nodes.values(): # if node.syntactic_object: # self.nodes_by_uid[node.syntactic_object.uid] = node @property def scene(self): """ Return the graphics scene where objects are stored and drawn. :return: GraphScene instance """ return self.main.graph_scene def prepare_for_drawing(self): """ Prepares the forest instance to be displayed in graph scene -- called when switching forests :return: None """ self.in_display = True ctrl.disable_undo() if not self.is_parsed: self.syntax.create_derivation(self) self.after_model_update('nodes', 0) self.is_parsed = True self.forest_edited() ctrl.add_watcher(self, 'palette_changed') ctrl.main.update_colors() self.add_all_to_scene() self.update_visualization() self.scene.keep_updating_visible_area = True self.scene.manual_zoom = False self.draw() # do draw once to avoid having the first draw in undo stack. ctrl.graph_scene.fit_to_window() ctrl.resume_undo() ctrl.graph_view.setFocus() def retire_from_drawing(self): """ Announce that this forest should not try to work with scene anymore -- some other forest is occupying the scene now. :return: """ for item in self.get_all_objects(): self.remove_from_scene(item, fade_out=False) ctrl.remove_from_watch(self) self.in_display = False def clear(self): if self.in_display: for item in self.get_all_objects(): self.remove_from_scene(item, fade_out=False) self.nodes_from_synobs = {} self.gloss = None self.parser = INodeToKatajaConstituent(self) self.undo_manager = UndoManager(self) self.chain_manager = ChainManager(self) self.tree_manager = TreeManager(self) self.free_drawing = FreeDrawing(self) self.projection_manager = ProjectionManager(self) self.derivation_steps = DerivationStepManager(self) self.trees = [] self.nodes = {} self.edges = {} self.groups = {} self.others = {} self.width_map = {} self.traces_to_draw = {} self.comments = [] self.gloss_text = '' @time_me def forest_edited(self): """ Called after forest editing/free drawing actions that have changed the node graph. Analyse the node graph and update/rebuild syntactic objects according to graph. :return: """ self.chain_manager.update() self.tree_manager.update_trees() self.projection_manager.update_projections() if ctrl.free_drawing_mode: print('doing nodes to synobjs in forest_edited') self.syntax.nodes_to_synobjs(self, [x.top for x in self.trees]) @staticmethod def list_nodes(first): """ Do left-first iteration through all nodes. Can become quite large if there is lots of multidomination. :param first: Node, can be started from a certain point in structure :return: iterator through nodes """ def _iterate(node): yield node for child in node.get_children(similar=False, visible=False): _iterate(child) return _iterate(first) @staticmethod def list_visible_nodes_once(first): """ Do left-first iteration through all nodes and return an iterator where only first instance of each node is present. :param first: Node, can be started from a certain point in structure :return: iterator through nodes """ result = [] def _iterate(node): if node not in result: result.append(node) for child in node.get_children(visible=True, similar=True): _iterate(child) _iterate(first) return result @staticmethod def list_nodes_once(first): """ Do left-first iteration through all nodes and return a list where only first instance of each node is present. :param first: Node, start from a certain point in structure :return: iterator through nodes """ result = [] def _iterate(node): if node not in result: result.append(node) for child in node.get_children(similar=False, visible=False): _iterate(child) _iterate(first) return result def visible_nodes(self): """ Any node that is visible. Ignore the type. :return: """ return (x for x in self.nodes.values() if x.is_visible()) def get_nodes_by_index(self, index) -> (Node, set): head = None traces = set() for node in self.nodes.values(): if node.node_type == g.CONSTITUENT_NODE: if node.index == index: if node.is_trace: traces.add(node) else: head = node return head, traces def get_numeration(self): for tree in self.trees: if tree.numeration: return tree tree = Tree(numeration=True) self.add_to_scene(tree) self.trees.append(tree) #tree.show() return tree def set_visualization(self, name): """ Switches the active visualization to visualization with given key :param name: string """ if self.visualization and self.visualization.say_my_name() == name: self.visualization.reselect() else: vs = self.main.visualizations self.visualization = vs.get(name, vs.get(ctrl.settings.get('visualization'), None)) self.vis_data = {'name': self.visualization.say_my_name()} self.visualization.prepare(self) ctrl.settings.set('hide_edges_if_nodes_overlap', self.visualization.hide_edges_if_nodes_overlap, level=g.FOREST) self.scene.keep_updating_visible_area = True self.main.graph_scene.manual_zoom = False def restore_visualization(self): name = self.vis_data.get('name', ctrl.settings.get('visualization')) if (not self.visualization) or name != self.visualization.say_my_name(): v = self.main.visualizations.get(name, None) if v: self.visualization = v v.prepare(self, reset=False) self.main.graph_scene.manual_zoom = False def update_visualization(self): """ Verify that the active visualization is the same as defined in the vis_data (saved visualization state) :return: None """ name = self.vis_data.get('name', ctrl.settings.get('visualization')) if (not self.visualization) or name != self.visualization.say_my_name(): self.set_visualization(name) # ### Maintenance and support methods # ################################################ def __iter__(self): return self.trees.__iter__() def textual_form(self, tree=None, node=None): """ return (unicode) version of linearizations of all trees with traces removed -- as close to original sentences as possible. If trees or node is given, return linearization of only that. :param tree: Tree instance :param node: Node instance """ def _tree_as_text(tree, node, gap): """ Cheapo linearization algorithm for Node structures.""" l = [] if node in tree.sorted_constituents: i = tree.sorted_constituents.index(node) for n in tree.sorted_constituents[i:]: l.append(str(n.syntactic_object)) return gap.join(l) if tree: return _tree_as_text(tree, tree.top, ' ') elif node: return _tree_as_text(node.tree[0], node, ' ') else: trees = [] for tree in self.trees: new_line = _tree_as_text(tree, tree.top, ' ') if new_line: trees.append(new_line) return '/ '.join(trees) def syntax_trees_as_string(self): """ :return: """ s = [] for tree in self.trees: if tree.top and tree.top.is_constituent: s.append(tree.top.syntactic_object.print_tree()) return '\n'.join(s) # Scene and storage --------------------------------------------------------------- def store(self, item): """ Confirm that item is stored in some dictionary or other storage in forest :param item: """ # if isinstance(item, ConstituentNode): # self.nodes[item.key] = item # elif isinstance(item, FeatureNode): # self.features[item.key] = item if isinstance(item, Node): self.poke('nodes') self.nodes[item.uid] = item self.free_drawing.node_types.add(item.node_type) if item.syntactic_object: # remember to rebuild nodes_by_uid in undo/redo, as it is not # stored in model self.nodes_from_synobs[item.syntactic_object.uid] = item elif isinstance(item, Edge): self.poke('edges') self.edges[item.uid] = item self.free_drawing.edge_types.add(item.edge_type) else: key = getattr(item, 'uid', '') or getattr(item, 'key', '') if key and key not in self.others: self.poke('others') self.others[key] = item else: print('F trying to store broken type:', item.__class__.__name__) def add_all_to_scene(self): """ Put items belonging to this forest to scene """ if self.in_display: for item in self.get_all_objects(): sc = item.scene() if not sc: self.scene.addItem(item) # if not item.parentItem(): # print('adding to scene: ', item) # self.scene.addItem(item) def add_to_scene(self, item): """ Put items belonging to this forest to scene :param item: """ if self.in_display: if isinstance(item, QtWidgets.QGraphicsItem): sc = item.scene() if not sc: # print('..adding to scene ', item.uid ) self.scene.addItem(item) elif sc != self.scene: # print('..adding to scene ', item.uid ) self.scene.addItem(item) def remove_from_scene(self, item, fade_out=True): """ Remove item from this scene :param item: :param fade_out: fade instead of immediate disappear :return: """ if fade_out and hasattr(item, 'fade_out_and_delete'): item.fade_out_and_delete() elif isinstance(item, QtWidgets.QGraphicsItem): sc = item.scene() if sc == self.scene: #print('..removing from scene ', item.uid) sc.removeItem(item) elif sc: print('unknown scene for item %s : %s ' % (item, sc)) sc.removeItem(item) print(' - removing anyways') else: print(type(item)) # Getting objects ------------------------------------------------------ def get_all_objects(self): """ Just return all objects governed by Forest -- not all scene objects :return: iterator through objects """ for n in self.trees: yield n for n in self.nodes.values(): yield n for n in self.edges.values(): yield n for n in self.others.values(): yield n for n in self.projection_manager.projections.values(): if n.visual: yield n.visual for n in self.groups.values(): yield n if self.gloss: yield self.gloss def get_node(self, constituent): """ Returns a node corresponding to a constituent :rtype : kataja.BaseConstituentNode :param constituent: syntax.BaseConstituent :return: kataja.ConstituentNode """ if not constituent: return None return self.nodes_from_synobs.get(constituent.uid, None) def get_constituent_edges(self): """ Return generator of constituent edges :return: generator """ return (x for x in self.edges.values() if x.edge_type == g.CONSTITUENT_EDGE and x.is_visible()) def get_constituent_nodes(self): """ Return generator of constituent nodes :return: generator """ return (x for x in self.nodes.values() if isinstance(x, ConstituentNode) and x.is_visible()) def get_feature_nodes(self): """ Return generator of feature nodes :return: generator """ return (x for x in self.nodes.values() if isinstance(x, FeatureNode)) def get_attribute_nodes(self): """ Return generator of attribute nodes :return: generator """ return (x for x in self.nodes.values() if isinstance(x, AttributeNode)) # Drawing and updating -------------------------------------------- def animation_started(self, key): """ Announce animation that should be waited before redrawing :param key: :return: """ self.ongoing_animations.add(key) def animation_finished(self, key): """ Check out animation that was waited for, when all are checked out, redraw forest :param key: :return: """ if key in self.ongoing_animations: self.ongoing_animations.remove(key) # fixme: put this back on when triangle animations work again #if not self.ongoing_animations: # self.draw() def flush_and_rebuild_temporary_items(self): """ Clean up temporary stuff that may be invalidated by changes made by undo/redo. Notice that draw() does some of this, don't have to do those here. :return: """ # Selection and related UI legits = list(self.get_all_objects()) ctrl.multiselection_start() for item in ctrl.selected: if item not in legits: ctrl.remove_from_selection(item) ctrl.multiselection_end() def draw(self): """ Update all trees in the forest according to current visualization """ if self.halt_drawing: return if not self.in_display: print("Why are we drawing a forest which shouldn't be in scene") assert self.is_parsed sc = ctrl.graph_scene sc.stop_animations() #self.tree_manager.update_trees() #for tree in self.trees: # if tree.top: # tree.top.update_visibility() # fixme, delete trees with no visible tops #self.projection_manager.update_projections() self.update_forest_gloss() if self.visualization: self.visualization.prepare_draw() x = 0 first = True for tree in self.trees: if tree.top: #self.visualization.prepare_to_normalise(tree) self.visualization.draw_tree(tree) self.visualization.normalise_to_origo(tree) #self.visualization.normalise_movers_to_top(tree) br = tree.boundingRect() if not first: x -= br.left() tree.move_to(x, 0) x += br.right() tree.start_moving() first = False #if not sc.manual_zoom: # sc.fit_to_window() sc.start_animations() ctrl.graph_view.repaint() def redraw_edges(self, edge_type=None): if edge_type: for edge in self.edges.values(): if edge.edge_type == edge_type: edge.update_shape() else: for edge in self.edges.values(): edge.update_shape() def simple_parse(self, text): return self.parser.simple_parse(text) def create_node_from_string(self, text): """ :param text: """ return self.parser.string_into_forest(text) def order_edge_visibility_check(self): """ Make sure that all edges are checked to update their visibility. This can be called multiple times, but the visibility check is done only once. """ self._do_edge_visibility_check = True def edge_visibility_check(self): """ Perform check for each edge: hide them if their start/end is hidden, show them if necessary. """ if not self._do_edge_visibility_check: return for edge in set(self.edges.values()): changed = edge.update_visibility() if changed: if edge.is_visible(): if ctrl.is_selected(edge): ctrl.ui.add_control_points(edge) else: ctrl.ui.remove_ui_for(edge) self._do_edge_visibility_check = False def update_label_shape(self): shape = ctrl.settings.get('label_shape') ctrl.release_editor_focus() for node in self.nodes.values(): if node.node_type == g.CONSTITUENT_NODE: node.label_object.label_shape = shape node.update_label() if node.is_triangle_host(): ctrl.free_drawing.add_or_update_triangle_for(node) parents = [] for node in self.nodes.values(): node.update_relations(parents) for parent in parents: parent.gather_children() self.prepare_width_map() def update_forest_gloss(self): """ Draw the gloss text on screen, if it exists. """ strat = ctrl.settings.get('gloss_strategy') if strat: if strat == 'linearisation': gts = [] for tree in self.trees: gt = ctrl.syntax.linearize(tree.top) if gt: gts.append(gt) self.gloss_text = ' '.join(gts) elif strat == 'message': pass elif strat == 'manual': pass elif strat == 'no': self.gloss_text = '' else: self.gloss_text = '' if self.gloss_text and not ctrl.settings.get('syntactic_mode'): if not self.gloss: self.gloss = self.free_drawing.create_node(node_type=g.GLOSS_NODE) self.gloss.label = self.gloss_text elif self.gloss.text != self.gloss_text: self.gloss.label = self.gloss_text self.gloss.update_label() self.gloss.physics_x = False self.gloss.physics_y = False self.gloss.put_to_top_of_trees() #self.gloss.show() elif self.gloss: self.remove_from_scene(self.gloss) self.gloss = None def compute_traces_to_draw(self, rotator) -> int: """ This is complicated, but returns a dictionary that tells for each index key (used by chains) in which position at trees to draw the node. Positions are identified by key of their immediate parent: {'i': ConstituentNode394293, ...} """ # highest row = index at trees # x = cannot be skipped, last instance of that trace # i/j/k = index key # rows = rotation # * = use this node # 2 3 7 9 13 15 16 # i j i i k j k # x x x # * * * # * * * # * * * # * * * # * * * # make an index-keyless version of this. trace_dict = {} sorted_parents = [] required_keys = set() for tree in self: sortable_parents = [] ltree = tree.sorted_nodes for node in ltree: if not hasattr(node, 'index'): continue parents = node.get_parents(visible=True, similar=True) if len(parents) > 1: node_key = node.uid required_keys.add(node_key) my_parents = [] for parent in parents: if parent in ltree: i = ltree.index(parent) my_parents.append((i, node_key, parent, True)) if my_parents: my_parents.sort() a, b, c, d = my_parents[-1] # @UnusedVariable my_parents[-1] = a, b, c, False sortable_parents += my_parents sortable_parents.sort() sorted_parents += sortable_parents if rotator < 0: rotator = len(sorted_parents) - len(required_keys) skips = 0 for i, node_key, parent, can_be_skipped in sorted_parents: if node_key in required_keys: if skips == rotator or not can_be_skipped: trace_dict[node_key] = parent.uid required_keys.remove(node_key) else: skips += 1 self.traces_to_draw = trace_dict return rotator def should_we_draw(self, node, parent) -> bool: """ With multidominated nodes the child will eventually be drawn under one of its parents. Under which one is stored in traces_to_draw -dict. This checks if the node should be drawn under given parent. :param node: :param parent: :return: """ if not parent: return True elif not self.traces_to_draw: return True elif hasattr(node, 'index') and len(node.get_parents(similar=True, visible=True)) > 1: key = node.uid if key in self.traces_to_draw: if parent.uid != self.traces_to_draw[key]: return False return True def prepare_width_map(self): """ A map of how much horizontal space each node would need -- it is better to do this once than recursively compute these when updating labels. :return: """ def recursive_width(node): if node.is_leaf(only_similar=True, only_visible=True): if node.is_visible(): w = node.label_object.width else: w = 0 else: w = node.label_object.left_bracket_width() + node.label_object.right_bracket_width() for n in node.get_children(similar=True, visible=True): if self.should_we_draw(n, node): w += recursive_width(n) self.width_map[node.uid] = w node.update_label() return w self.width_map = {} for tree in self: recursive_width(tree.top) return self.width_map # ### Minor updates for forest elements # ####################################################################### def reform_constituent_node_from_string(self, text, node): """ :param text: :param node: """ new_nodes = self.parser.string_into_forest(text) if new_nodes: self.free_drawing.replace_node(node, new_nodes[0]) # View mode @time_me def change_view_mode(self, syntactic_mode): t = time.time() ctrl.settings.set('syntactic_mode', syntactic_mode, level=g.FOREST) label_text_mode = ctrl.settings.get('label_text_mode') if syntactic_mode: self.old_label_mode = label_text_mode if label_text_mode == g.NODE_LABELS: ctrl.settings.set('label_text_mode', g.SYN_LABELS, level=g.FOREST) elif label_text_mode == g.NODE_LABELS_FOR_LEAVES: ctrl.settings.set('label_text_mode', g.SYN_LABELS_FOR_LEAVES, level=g.FOREST) else: if self.old_label_mode == g.NODE_LABELS or \ self.old_label_mode == g.NODE_LABELS_FOR_LEAVES: ctrl.settings.set('label_text_mode', self.old_label_mode, level=g.FOREST) nodes = list(self.nodes.values()) for node in nodes: node.update_label() node.update_visibility(skip_label=True) ctrl.call_watchers(self, 'view_mode_changed', value=syntactic_mode) if syntactic_mode: if ctrl.main.color_manager.paper().value() < 100: ctrl.settings.set('temp_color_theme', 'dk_gray', level=g.FOREST) else: ctrl.settings.set('temp_color_theme', 'gray', level=g.FOREST) else: ctrl.settings.set('temp_color_theme', '', level=g.FOREST) ctrl.main.update_colors() ### Watcher ######################### def watch_alerted(self, obj, signal, field_name, value): """ Receives alerts from signals that this object has chosen to listen. These signals are declared in 'self.watchlist'. This method will try to sort out the received signals and act accordingly. :param obj: the object causing the alarm :param signal: identifier for type of the alarm :param field_name: name of the field of the object causing the alarm :param value: value given to the field :return: """ if signal == 'palette_changed': for other in self.others.values(): other.update_colors() # ######## Utility functions ############################### # def parse_features(self, string, node): # """ # # :param string: # :param node: # :return: # """ # return self.parser.parse_definition(string, node) # ############## # # # # Save support # # # # ############## # trees = SavedField("trees") # the current line of trees nodes = SavedField("nodes") edges = SavedField("edges") #, if_changed=reserve_update_for_trees) groups = SavedField("groups") others = SavedField("others") vis_data = SavedField("vis_data", watcher="visualization") derivation_steps = SavedField("derivation_steps") comments = SavedField("comments") gloss_text = SavedField("gloss_text") syntax = SavedField("syntax") is_parsed = SavedField("is_parsed") gloss = SavedField("gloss")
class Constituent(MyBaseClass): # collections.UserList): role = "Constituent" def __init__(self, label='', part1=None, part2=None, features=None): if in_kataja: if features is not None: features = list(features) else: features = [] if part1 and part2: super().__init__(label=label, parts=[part1, part2], features=features) else: super().__init__(label=label, features=features) self.label = label self.part1 = part1 self.part2 = part2 self.features = [] self.stacked = False self.transfered = False if features: for f in features: if isinstance(f, str): self.features.append(Feature(f)) elif isinstance(f, Feature): self.features.append(f) #.copy()) def __repr__(self): parts = [self.label] if self.part1: parts.append(self.part1) if self.part2: parts.append(self.part2) if self.features: parts.append(sorted(list(self.features))) return repr(parts) def featureless_str(self): """ Used to replicate Model10 output :return: """ if self.part1 and self.part2: return '[%s %s %s]' % (self.label, self.part1.featureless_str(), self.part2.featureless_str()) else: return '[%s ]' % self.label def model10_string(self): parts = [self.label] if self.part1: parts.append(self.part1.model10_string()) if self.part2: parts.append(self.part2.model10_string()) if self.features: fs = [] for item in list(expanded_features(self.features)): fs.append(repr(item)) parts.append(fs) return parts def __str__(self): """ Override BaseConstituent's __str__ :return: """ return repr(self) def __hash__(self): return id(self) def labelstring(self): if self.part1 and self.part2: return '[%s %s %s]' % (self.label, self.part1.labelstring(), self.part2.labelstring()) else: return self.label def is_leaf(self): return not (self.part1 and self.part2) def shared_features(self, other): my_feats = self.get_head_features() other_feats = other.get_head_features() return find_shared_features(my_feats, other_feats) def add_feature(self, feat): self.poke('features') if not isinstance(feat, Feature): feat = Feature(feat) self.features.append(feat) def remove_feature(self, feat): self.poke('features') self.features.remove(feat) def replace_feature(self, old, new): if old in self.features: self.poke('features') self.features.remove(old) self.features.append(new) def has_feature(self, key): """ Check the existence of feature within this constituent :param key: string for identifying feature type or Feature instance :return: bool """ if isinstance(key, Feature): return key in self.features return key in self.features def replace_feature_by_name(self, old_name, new): found = None for item in self.features: if item.name == old_name: found = item break if found is not None: self.features.remove(found) self.features.append(new) else: print('couldnt find ', old_name, self) def ordered_parts(self): if self.part1 and self.part2: return [self.part1, self.part2] elif self.part1: return [self.part1] elif self.part2: return [self.part2] else: return [] def ordered_features(self): return list(self.features) def is_unlabeled(self): return self.label == '_' def is_labeled(self): return self.label != '_' def copy(self): if self.part1 and self.part2: return Constituent(label=self.label, part1=self.part1.copy(), part2=self.part2.copy()) elif self.features: return Constituent(label=self.label, features=self.features) else: raise TypeError def demote_to_copy(self): """ Turn this into a SO with "Copy" feature and return a new fresh version without the copy-feature. :return: """ feature_giver = self.get_feature_giver() copy_feature = Feature("Copy") if copy_feature in feature_giver.features: feature_giver.features.remove(copy_feature) fresh = self.copy() feature_giver.features.append(copy_feature) return fresh def __contains__(self, item): if isinstance(item, Constituent): if self.part1 == item: return True elif self.part2 == item: return True if self.part1: if item in self.part1: return True if self.part2: if item in self.part2: return True elif isinstance(item, Feature): return item in self.features return False def tex_tree(self, stack): stacked = '' if self.stacked: stacked = '(S?)' for i, item in enumerate(reversed(stack)): if item is self: stacked = '(S%s)' % i break label = self.label.replace('_', '-') if self.part1 and self.part2: return '[.%s%s %s %s ]' % (label, stacked, self.part1.tex_tree(stack), self.part2.tex_tree(stack)) else: return label + stacked def get_head(self, no_sharing=True): if self.is_leaf(): return self if self.is_unlabeled(): return self.part1.get_head() elif self.part2.is_unlabeled(): f = self.part1.get_head() if f: return f if self.part1.label == self.label: return self.part1.get_head() elif self.part2.label == self.label: return self.part2.get_head() elif self.label in SHARED_FEAT_LABELS: if no_sharing: return self.part1.get_head(no_sharing=True) else: head1 = self.part1.get_head() head2 = self.part2.get_head() result = [] if isinstance(head1, list): result += head1 else: result.append(head1) if isinstance(head2, list): result += head2 else: result.append(head2) return result if "Phi" in self.label: return self.part1.get_head() assert False def get_head_features(self, no_sharing=False, expanded=False): if no_sharing: head = self.get_head(no_sharing=True) if expanded: return expanded_features(head.features) else: return head.features else: head = self.get_head(no_sharing=False) if isinstance(head, list): shared_feats = find_shared_features(head[0].features, head[1].features) if shared_feats: feats_list = shared_feats else: head = self.get_head(no_sharing=True) feats_list = head.features else: feats_list = head.features if expanded: return expanded_features(feats_list) else: return feats_list def get_feature_giver(self): return self.get_head(no_sharing=True) def replace_within(self, old_chunk, new_chunk, label=False): if self == old_chunk: self.label = new_chunk.label self.features = new_chunk.features self.part1 = new_chunk.part1 self.part2 = new_chunk.part2 return if label: self.recursive_replace_label(old_chunk, new_chunk) elif isinstance(old_chunk, Constituent): self.recursive_replace_constituent(old_chunk, new_chunk) elif isinstance(old_chunk, list): found = self.recursive_replace_feature_set(old_chunk, new_chunk) elif isinstance(old_chunk, Feature): found = self.recursive_replace_feature(old_chunk, new_chunk) else: raise TypeError def recursive_replace_label(self, old_label, new_label): if self.part1: self.part1.recursive_replace_label(old_label, new_label) if self.part2: self.part2.recursive_replace_label(old_label, new_label) if self.label == old_label: self.label = new_label def recursive_replace_feature_set(self, old, new, found=0): # print(type(old),old, type(new), new) for item in new: if not isinstance(item, Feature): print(new) raise TypeError if self.part1: found = self.part1.recursive_replace_feature_set(old, new, found) if self.part2: found = self.part2.recursive_replace_feature_set(old, new, found) if old == self.features: self.features = list(new) found += 1 return found def recursive_replace_feature(self, old, new, found=0): if new and not isinstance(new, Feature): print(new) raise TypeError if self.part1: found = self.part1.recursive_replace_feature(old, new, found) if self.part2: found = self.part2.recursive_replace_feature(old, new, found) if old in self.features: found += 1 self.features.remove(old) if new: self.features.append(new) return found def recursive_replace_constituent(self, old, new): assert(self != old) if self.part1: if self.part1 == old: self.part1 = new else: self.part1.recursive_replace_constituent(old, new) if self.part2: if self.part2 == old: self.part2 = new else: self.part2.recursive_replace_constituent(old, new) if in_kataja: part1 = SavedField("part1") part2 = SavedField("part2")
class Group(SavedObject, QtWidgets.QGraphicsObject): __qt_type_id__ = next_available_type_id() permanent_ui = False unique = False can_fade = False scene_item = True is_widget = False def __init__(self, selection=None, persistent=True): SavedObject.__init__(self) QtWidgets.QGraphicsObject.__init__(self) # -- Fake as UIItem to make selection groups part of UI: self.ui_key = next_available_ui_key() self.ui_type = self.__class__.__name__ self.ui_manager = ctrl.ui self.role = None self.host = None self.watchlist = [] self.is_fading_in = False self.is_fading_out = False # -- end faking as UIItem self.selection = [] self.selection_with_children = [] self.persistent = persistent self._skip_this = not persistent self._selected = False self.points = [] self.center_point = None self.outline = False self.fill = True self.color_key = '' self.color = None self.color_tr_tr = None self.purpose = None self.path = None self.label_item = None self.label_data = {} self.include_children = False self.allow_overlap = True self._br = None #self.setFlag(QtWidgets.QGraphicsObject.ItemIsMovable) self.setFlag(QtWidgets.QGraphicsObject.ItemIsSelectable) if selection: self.update_selection(selection) self.update_shape() self.update_colors() def __contains__(self, item): return item in self.selection_with_children def type(self): """ Qt's type identifier, custom QGraphicsItems should have different type ids if events need to differentiate between them. These are set when the program starts. :return: """ return self.__qt_type_id__ def after_init(self): self.update_selection(self.selection) self.update_shape() self.update_colors() def after_model_update(self, updated_fields, transition_type): """ Compute derived effects of updated values in sensible order. :param updated_fields: field keys of updates :param transition_type: 0:edit, 1:CREATED, -1:DELETED :return: None """ if transition_type == g.CREATED: ctrl.forest.store(self) ctrl.forest.add_to_scene(self) elif transition_type == g.DELETED: ctrl.forest.remove_from_scene(self, fade_out=False) return if updated_fields: self.update_selection(self.selection) self.update_shape() self.update_colors() def copy_from(self, source): """ Helper method to easily make a similar selection with different identity :param source: :return: """ self.selection = source.selection self.selection_with_children = source.selection_with_children self.points = list(source.points) self.center_point = source.center_point self.outline = source.outline self.fill = source.fill self.color_key = source.color_key self.color = source.color self.color_tr_tr = source.color_tr_tr self.path = source.path text = source.get_label_text() if text: self.set_label_text(text) self.include_children = source.include_children self.allow_overlap = source.allow_overlap def get_label_text(self): """ Label text is actually stored in model.label_data, but this is a shortcut for it. :return: """ return self.label_data.get('text', '') def set_label_text(self, value): if self.label_item: old = self.get_label_text() if old != value: self.poke('label_data') self.label_data['text'] = value self.label_item.update_text(value) else: self.label_item = GroupLabel(value, parent=self) self.poke('label_data') self.label_data['text'] = value if self.label_item.automatic_position: self.label_item.position_at_bottom() else: self.label_item.update_position() def if_changed_color_id(self, value): """ Set group color, uses palette id strings as values. :param value: string """ if self.label_item: self.label_item.setDefaultTextColor(ctrl.cm.get(value)) def remove_node(self, node): """ Manual removal of single node, should be called e.g. when node is deleted. :param node: :return: """ if node in self.selection: self.poke('selection') self.selection.remove(node) if node in self.selection_with_children: self.selection_with_children.remove(node) if self.selection: self.update_shape() else: if self.persistent: ctrl.free_drawing.remove_group(self) else: ctrl.ui.remove_ui_for(self) def remove_nodes(self, nodes): """ Remove multiple nodes, just to avoid repeated calls to expensive updates :param nodes: :return: """ self.poke('selection') for node in nodes: if node in self.selection: self.selection.remove(node) if node in self.selection_with_children: self.selection_with_children.remove(node) if self.selection: self.update_shape() else: if self.persistent: ctrl.free_drawing.remove_group(self) else: ctrl.ui.remove_ui_for(self) def clear(self, remove=True): self.selection = set() self.selection_with_children = set() self.update_shape() if remove: if self.persistent: ctrl.free_drawing.remove_group(self) else: ctrl.ui.remove_ui_for(self) def add_node(self, node): """ Manual addition of single node :param node: :return: """ if node not in self.selection: self.poke('selection') self.selection.append(node) self.update_selection(self.selection) self.update_shape() def update_selection(self, selection): swc = [] other_selections = set() if not self.allow_overlap: for group in ctrl.forest.groups.values(): other_selections = other_selections | set( group.selection_with_children) def recursive_add_children(i): if isinstance(i, Node) and i not in swc and \ (i in selection or i not in other_selections): swc.append(i) for child in i.get_children(similar=False, visible=False): recursive_add_children(child) if selection: self.selection = [ item for item in selection if isinstance(item, Node) and item.can_be_in_groups ] if self.include_children: for item in self.selection: recursive_add_children(item) self.selection_with_children = swc else: self.selection_with_children = self.selection else: self.selection = [] self.selection_with_children = [] def update_shape(self): def embellished_corners(item): x1, y1, x2, y2 = item.sceneBoundingRect().getCoords() corners = [(x1 - 5, y1 - 5), (x2 + 5, y1 - 5), (x2 + 5, y2 + 5), (x1 - 5, y2 + 5)] return x1, y1, x2, y2, corners sel = [x for x in self.selection_with_children if x.isVisible()] if len(sel) == 0: self._br = QtCore.QRectF() self.path = None self.center_point = 0, 0 return elif len(sel) == 1: x1, y1, x2, y2, route = embellished_corners(sel[0]) self._br = QtCore.QRectF(x1 - 5, y1 - 5, x2 - x1 + 10, y2 - y1 + 10) self.path = QtGui.QPainterPath( QtCore.QPointF(route[0][0], route[0][1])) for x, y in route[1:]: self.path.lineTo(x, y) self.path.closeSubpath() center = self._br.center() self.center_point = center.x(), center.y() cx, cy = self.center_point else: corners = [] c = 0 x_sum = 0 y_sum = 0 min_x = 50000 max_x = -50000 min_y = 50000 max_y = -50000 for item in sel: c += 2 x1, y1, x2, y2, icorners = embellished_corners(item) x_sum += x1 x_sum += x2 y_sum += y1 y_sum += y2 if x1 < min_x: min_x = x1 if x2 > max_x: max_x = x2 if y1 < min_y: min_y = y1 if y2 > max_y: max_y = y2 corners += icorners self.prepareGeometryChange() self._br = QtCore.QRectF(min_x, min_y, max_x - min_x, max_y - min_y) cx = (min_x + max_x) / 2 cy = (min_y + max_y) / 2 self.center_point = cx, cy r = max(max_x - min_x, max_y - min_y) * 1.1 dots = 32 step = 2 * math.pi / dots deg = 0 route = [] for n in range(0, dots): cpx = math.cos(deg) * r + cx cpy = math.sin(deg) * r + cy deg += step closest = None closest_d = 2000000 for px, py in corners: d = (px - cpx)**2 + (py - cpy)**2 if d < closest_d: closest = px, py closest_d = d if closest: if route: last = route[-1] if last == closest: continue route.append(closest) if self.label_item: if self.label_item.automatic_position: self.label_item.position_at_bottom() else: self.label_item.update_position() curved_path = Group.interpolate_point_with_bezier_curves(route) sx, sy = route[0] self.path = QtGui.QPainterPath(QtCore.QPointF(sx, sy)) for fx, fy, sx, sy, ex, ey in curved_path: self.path.cubicTo(fx, fy, sx, sy, ex, ey) # This is costly if True: for item in self.collidingItems(): if isinstance(item, Node) and item.node_type == g.CONSTITUENT_NODE and item not in \ self.selection_with_children: x, y = item.current_scene_position subshape = item.shape().translated(x, y) subshape_points = [] for i in range(0, subshape.elementCount()): element = subshape.elementAt(i) subshape_points.append((element.x, element.y)) curved_path = Group.interpolate_point_with_bezier_curves( subshape_points) sx, sy = subshape_points[0] subshape = QtGui.QPainterPath(QtCore.QPointF(sx, sy)) for fx, fy, sx, sy, ex, ey in curved_path: subshape.cubicTo(fx, fy, sx, sy, ex, ey) self.path = self.path.subtracted(subshape) def shape(self): if self.path: return self.path else: return QtGui.QPainterPath() def update_position(self): self.update_shape() def clockwise_path_points(self, margin=2): """ Return points along the path circling the group. A margin can be provided to make the points be n points away from the path. Points start from the topmost, rightmost point. :param margin: :return: """ if not self.path: return QtCore.QPointF( 0, 0) # hope this group will be removed immediately max_x = -30000 min_y = 30000 start_i = 0 ppoints = [] cx, cy = self.center_point better_path = [ ] # lets have not only corners, but also points along the edges last_element = self.path.elementAt(self.path.elementCount() - 1) last_x = last_element.x last_y = last_element.y for i in range(0, self.path.elementCount()): element = self.path.elementAt(i) x = element.x y = element.y better_path.append(((last_x + x) / 2, (last_y + y) / 2)) better_path.append((x, y)) last_x = x last_y = y for i, (x, y) in enumerate(better_path): if margin != 0: dx = x - cx dy = y - cy d = math.hypot(dx, dy) if d == 0: change = 0 else: change = (d + margin) / d # should return values like 1.08 x = cx + (dx * change) y = cy + (dy * change) ppoints.append((x, y)) if y < min_y or (y == min_y and x > max_x): min_y = y max_x = x start_i = i return ppoints[start_i:] + ppoints[:start_i] def boundingRect(self): if not self._br: self.update_shape() return self._br def get_color_id(self): return self.color_key def update_colors(self, color_key=''): if not self.color_key: self.color_key = color_key or "accent1" elif color_key: self.color_key = color_key self.color = ctrl.cm.get(self.color_key) self.color_tr_tr = QtGui.QColor(self.color) self.color_tr_tr.setAlphaF(0.2) if self.label_item: self.label_item.update_color() def mousePressEvent(self, event): ctrl.press(self) super().mousePressEvent(event) def mouseReleaseEvent(self, event): if ctrl.pressed is self: ctrl.release(self) if ctrl.dragged_set: ctrl.graph_scene.kill_dragging() ctrl.ui.update_selections( ) # drag operation may have changed visible affordances else: # This is regular click on 'pressed' object self.select(event) self.update() return None # this mouseRelease is now consumed super().mouseReleaseEvent(event) def select(self, event=None, multi=False): """ Scene has decided that this node has been clicked :param event: :param multi: assume multiple selection (append, don't replace) """ if not self.persistent: return ctrl.multiselection_start() if (event and event.modifiers() == QtCore.Qt.ShiftModifier) or multi: # multiple selection if ctrl.is_selected(self): ctrl.remove_from_selection(self) else: ctrl.add_to_selection(self) for item in self.selection: ctrl.add_to_selection(item) elif ctrl.is_selected(self): ctrl.deselect_objects() else: ctrl.deselect_objects() ctrl.add_to_selection(self) for item in self.selection: ctrl.add_to_selection(item) ctrl.multiselection_end() def update_selection_status(self, value): """ :param value: :return: """ self._selected = value def paint(self, painter, style, QWidget_widget=None): if self.selection and self.path: if self.fill: painter.fillPath(self.path, self.color_tr_tr) if self._selected: painter.setPen(ctrl.cm.selection()) painter.drawPath(self.path) elif self.outline: painter.setPen(self.color) painter.drawPath(self.path) @staticmethod def interpolate_point_with_bezier_curves(points): """ Curved path algorithm based on example by Raul Otaño Hurtado, from http://www.codeproject.com/Articles/769055/Interpolate-D-points-usign-Bezier-curves-in-WPF :param points: :return: """ if len(points) < 3: return None res = [] # if is close curve then add the first point at the end if points[-1] != points[0]: points.append(points[0]) for i, (x1, y1) in enumerate(points[:-1]): if i == 0: x0, y0 = points[-2] else: x0, y0 = points[i - 1] x2, y2 = points[i + 1] if i == len(points) - 2: x3, y3 = points[1] else: x3, y3 = points[i + 2] xc1 = (x0 + x1) / 2.0 yc1 = (y0 + y1) / 2.0 xc2 = (x1 + x2) / 2.0 yc2 = (y1 + y2) / 2.0 xc3 = (x2 + x3) / 2.0 yc3 = (y2 + y3) / 2.0 len1 = math.hypot(x1 - x0, y1 - y0) len2 = math.hypot(x2 - x1, y2 - y1) len3 = math.hypot(x3 - x2, y3 - y2) k1 = len1 / (len1 + len2) k2 = len2 / (len2 + len3) xm1 = xc1 + (xc2 - xc1) * k1 ym1 = yc1 + (yc2 - yc1) * k1 xm2 = xc2 + (xc3 - xc2) * k2 ym2 = yc2 + (yc3 - yc2) * k2 smooth = 0.8 ctrl1_x = xm1 + (xc2 - xm1) * smooth + x1 - xm1 ctrl1_y = ym1 + (yc2 - ym1) * smooth + y1 - ym1 ctrl2_x = xm2 + (xc2 - xm2) * smooth + x2 - xm2 ctrl2_y = ym2 + (yc2 - ym2) * smooth + y2 - ym2 res.append((ctrl1_x, ctrl1_y, ctrl2_x, ctrl2_y, x2, y2)) return res selection = SavedField("selection") color_key = SavedField("color_key") label_data = SavedField("label_data") purpose = SavedField("purpose") include_children = SavedField("include_children") allow_overlap = SavedField("allow_overlap") fill = SavedField("fill") outline = SavedField("outline") persistent = SavedField("persistent") forest = SavedField("forest")
class Movable(SavedObject, QtWidgets.QGraphicsObject): """ Movable items ------------- Movable items are items on canvas that can be affected by visualisation algorithms. There are three types of movement: set_position(p3): item is immediately put to given position. move_to(p3): item slides to given position use_physics(True|False) physics_x = True|False: physics_y = True|False: after move, the item can wander around according to physics, in set dimensions. use_physics sets all xy to True|False. Physics is handled by visualization algorithm, Movable only announces if it is responsive for physics. Movements using move_to -are affected by adjustment. Adjustment is a vector that displaces the item given amount from the move_to -command. Movement is triggered manually with move_to. Element may have wandered away from its target position: target position should not be used after the movement if node uses physics. Adjustment needs to be taken into account always when using target_position Nodes that use physics disable adjustment after the move_to has ended. When nodes that use physics are dragged around, they are locked into position. 'Locked' state overrides physics: the node stays in those coordinates. When nodes that don't use physics are dragged, the adjustment. """ def __init__(self): SavedObject.__init__(self) QtWidgets.QGraphicsObject.__init__(self) # Common movement-related elements self._current_position = (random.random() * 150) - 75, (random.random() * 150) - 75 self._dragged = False self.trees = set( ) # each Movable belongs to some trees, either formed by Movable # alone or set of Movables. Tree has abstract position adjustment information. # MOVE_TO -elements self.target_position = 0, 0 self.adjustment = 0, 0 self._start_position = 0, 0 self._move_frames = 0 self._move_counter = 0 self._use_easing = True self._distance = None self.unmoved = True # flag to distinguish newly created nodes self.after_move_function = None self.use_adjustment = False self._high_priority_move = False self.locked_to_node = None # PHYSICS -elements self.locked = False self.physics_x = False self.physics_y = False self.repulsion = 0.2 # Other self._visible_by_logic = True self._fade_anim = None self.is_fading_in = False self.is_fading_out = False self._hovering = False def type(self): """ Qt's type identifier, custom QGraphicsItems should have different type ids if events need to differentiate between them. These are set when the program starts. :return: """ return self.__qt_type_id__ def late_init(self): pass def after_model_update(self, updated_fields, transition_type): """ Compute derived effects of updated values in sensible order. :param updated_fields: field keys of updates :param transition_type: 0:edit, 1:CREATED, -1:DELETED :return: None """ #print('movable after_model_update, ', transition_type, revert_transition) if transition_type == CREATED: ctrl.forest.store(self) ctrl.forest.add_to_scene(self) elif transition_type == DELETED: ctrl.forest.remove_from_scene(self, fade_out=False) return self.update_position() def use_physics(self): return (self.physics_x or self.physics_y) and not self.locked_to_node def reset(self): """ Remove mode information, eg. hovering :return: None """ self._hovering = False @property def current_position(self): return self._current_position @current_position.setter def current_position(self, value): self._current_position = value self.setPos(*value) @property def current_scene_position(self): """ Return current position in scene coordinates and turned to xy -tuple. :return: """ xy = self.scenePos() return int(xy.x()), int(xy.y()) # ## Movement ############################################################## def move_to(self, x, y, after_move_function=None, valign=MIDDLE, align=NO_ALIGN, can_adjust=True): """ Start movement to given position :param x: :param y: :param after_move_function: Function to call when the movement is finished :param valign: What position inside the moved item should correspond to given coordinates. By default align is in center, but often you may want to move items so that e.g. their top rows are aligned. Values are TOP(0), TOP_ROW(1), MIDDLE(2), BOTTOM_ROW(3) and BOTTOM(4)_ :param align: NO_ALIGN, LEFT_ALIGN, CENTER_ALIGN, RIGHT_ALIGN :param can_adjust: can use movable's adjustment to adjust the target position :return: """ if self.use_adjustment and can_adjust: ax, ay = self.adjustment x += ax y += ay if valign == MIDDLE: pass elif valign == TOP: y -= self.boundingRect().top() elif valign == TOP_ROW: y -= self.get_top_y() elif valign == BOTTOM_ROW: y -= self.get_lower_part_y() elif valign == BOTTOM: y -= self.boundingRect().bottom() if align == NO_ALIGN: pass elif align == CENTER_ALIGN: br = self.boundingRect() x -= br.width() / 2 + br.x() elif align == LEFT_ALIGN: x -= self.boundingRect().x() if (x, y) == self.target_position and self._move_counter: # already moving there return self.target_position = x, y if after_move_function: self.after_move_function = after_move_function self.start_moving() def get_lower_part_y(self): """ Implement this if the movable has content where differentiating between bottom row and top row can potentially make sense. :return: """ return 0 def get_top_y(self): """ Implement this if the movable has content where differentiating between bottom row and top row can potentially make sense. :return: """ return 0 def move(self, md: dict) -> (bool, bool): """ Do one frame of movement: either move towards target position or take a step according to algorithm 1. item folding towards position in part of animation to disappear etc. 2. item is being dragged 3. item is locked by user 4. item is tightly attached to another node which is moving (then the move is handled by the other node, it is _not_ parent node, though.) 5. visualisation algorithm setting it specifically (6) or (0) -- places where subclasses can add new movements. :param md: movement data dict, collects sum of all movement to help normalize it :return: """ # _high_priority_move can be used together with _move_counter self.unmoved = False if not self._high_priority_move: # Dragging overrides (almost) everything, don't try to move this anywhere if self._dragged: return True, False # Locked nodes are immune to physics elif self.locked: return False, False #elif self.locked_to_node: # return False, False # MOVE_TO -based movement has priority over physics. This way e.g. triangles work without # additional stipulation if self._move_counter: position = self.current_position # stop even despite the _move_counter, if we are close enough if about_there(position, self.target_position): self.stop_moving() return False, False self._move_counter -= 1 # move a precalculated step if self._use_easing: if self._move_frames != self._move_counter: time_f = 1 - (self._move_counter / self._move_frames) f = qt_prefs.curve.valueForProgress(time_f) else: f = 0 movement = multiply_xy(self._distance, f) self.current_position = add_xy(self._start_position, movement) else: movement = div_xy(sub_xy(self.target_position, position), self._move_counter) self.current_position = add_xy(self.current_position, movement) # if move counter reaches zero, stop and do clean-up. if not self._move_counter: self.stop_moving() if self.locked_to_node: self.locked_to_node.update_bounding_rect() return True, False # Physics move node around only if other movement types have not overridden it elif self.use_physics() and self.is_visible(): movement = ctrl.forest.visualization.calculate_movement(self) md['sum'] = add_xy(movement, md['sum']) md['nodes'].append(self) self.current_position = add_xy(self.current_position, movement) return abs(movement[0]) + abs(movement[1]) > 0.6, True return False, False def distance_to(self, movable): """ Return current x,y distance to another movable :param movable: :return: x, y """ return sub_xy(self.current_position, movable.current_position) def set_original_position(self, pos): """ Sets both current position and computed position to same place, use when first adding items to scene to prevent them wandering from afar :param pos: tuple (x, y) """ if isinstance(pos, (QtCore.QPoint, QtCore.QPointF)): pos = pos.x(), pos.y() self.target_position = tuple(pos) self.use_adjustment = False self.adjustment = (0, 0) self.current_position = tuple(pos) self._dragged = False self.locked = False def start_moving(self): """ Initiate moving animation for object. :return: None """ self._use_easing = True dx, dy = sub_xy(self.target_position, self.current_position) d = math.sqrt(dx * dx + dy * dy) self._distance = dx, dy # this scales nicely: # d = 0 -> p = 0 # d = 50 -> p = 0.5849 # d = 100 -> p = 1 # d = 200 -> p = 1.5849 # d = 500 -> p = 2.5849 # d = 1000 -> p = 3.4594 p = math.log2(d * 0.01 + 1) self._move_frames = int(p * prefs.move_frames) if self._move_frames == 0: self._move_frames = 1 #self._move_frames = prefs.move_frames self._move_counter = self._move_frames self._start_position = self.current_position # self.adjustment affects both elements in the previous subtraction, so it can be ignored ctrl.graph_scene.item_moved() def stop_moving(self): """ Kill moving animation for this object. :return: None """ self._high_priority_move = False self.target_position = self.current_position if self.after_move_function: self.after_move_function() self.after_move_function = None self._move_counter = 0 def _current_position_changed(self, value): self.setPos(value[0], value[1]) def update_position(self): """ Compute new current_position and target_position :return: None """ #if (not self.use_physics()) and (not self._move_counter): if hasattr(self, 'setPos'): self.setPos(*self.current_position) def release(self): """ Remove lock and adjustment""" if self.locked: self.locked = False elif self.use_adjustment: self.adjustment = (0, 0) self.use_adjustment = False self.update_position() def lock(self): """ Item cannot be moved by physics or it is set to use adjustment""" if self.use_physics(): self.locked = True else: self.use_adjustment = True # ## Opacity ############################################################## def fade_in(self, s=300): """ Simple fade effect. The object exists already when fade starts. :return: None """ if self.is_fading_in: return self.is_fading_in = True self.show() if self.is_fading_out: print('interrupting fade out ', self.uid) self._fade_anim.stop() self._fade_anim = QtCore.QPropertyAnimation(self, qbytes_opacity) self._fade_anim.setDuration(s) self._fade_anim.setStartValue(0.0) self._fade_anim.setEndValue(1.0) self._fade_anim.setEasingCurve(QtCore.QEasingCurve.InQuad) self._fade_anim.start(QtCore.QAbstractAnimation.KeepWhenStopped) self._fade_anim.finished.connect(self.fade_in_finished) def fade_in_finished(self): self.is_fading_in = False def fade_out(self, s=300): """ Start fade out. The object exists until fade end. :return: None """ if self.is_fading_out: return if not self.isVisible(): return self.is_fading_out = True if self.is_fading_in: self._fade_anim.stop() self._fade_anim = QtCore.QPropertyAnimation(self, qbytes_opacity) self._fade_anim.setDuration(s) self._fade_anim.setStartValue(1.0) self._fade_anim.setEndValue(0) self._fade_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) self._fade_anim.start(QtCore.QAbstractAnimation.KeepWhenStopped) self._fade_anim.finished.connect(self.fade_out_finished) def fade_out_and_delete(self, s=300): """ Start fade out. The object exists until fade end. :return: None """ if self.is_fading_out: self._fade_anim.finished.disconnect( ) # regular fade_out isn't enough self._fade_anim.finished.connect(self.fade_out_finished_delete) return if not self.isVisible(): self.fade_out_finished_delete() return self.is_fading_out = True if self.is_fading_in: self._fade_anim.stop() self._fade_anim = QtCore.QPropertyAnimation(self, qbytes_opacity) self._fade_anim.setDuration(s) self._fade_anim.setStartValue(1.0) self._fade_anim.setEndValue(0) self._fade_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) self._fade_anim.start(QtCore.QAbstractAnimation.KeepWhenStopped) self._fade_anim.finished.connect(self.fade_out_finished_delete) def fade_out_finished(self): self.is_fading_out = False if self.after_move_function: self.after_move_function() self.after_move_function = None self.hide() def fade_out_finished_delete(self): self.is_fading_out = False ctrl.forest.remove_from_scene(self, fade_out=False) def is_visible(self): """ Our own tracking of object visibility, not based on Qt's scene visibility. :return: bool """ return self._visible_by_logic # ## Selection ############################################################ def is_selected(self): """Return the selection status of this object. :return: boolean """ return ctrl.is_selected(self) # ## Dragging ############################################################ def dragged_to(self, scene_pos): """ Dragged focus is in scene_pos. Move there. :param scene_pos: current drag focus :return: """ if self.parentItem(): p = self.parentItem().mapFromScene(scene_pos[0], scene_pos[1]) new_pos = p.x(), p.y() else: new_pos = scene_pos[0], scene_pos[1] if self.use_physics(): self.locked = True self.current_position = new_pos else: self.use_adjustment = True diff = sub_xy(new_pos, self.current_position) self.adjustment = add_xy(self.adjustment, diff) self.target_position = new_pos self.current_position = new_pos def dragged_over_by(self, dragged): """ When dragging other items on top of this item, should this item react, e.g. show somehow that item can be dropped on this. :param dragged: """ if ctrl.drag_hovering_on is self: return True elif self.accepts_drops(dragged): ctrl.set_drag_hovering(self) return True else: return False def drop_to(self, x, y, recipient=None): """ This item is dropped to screen coordinates. Evaluate if there are sensitive objects (TouchAreas) there and if there are, call their 'drop'-method with self as argument. :param x: int or float :param y: int or float :param recipient: Movable? """ print('movable drop to %s,%s , recipient=%s' % (x, y, recipient)) # closest_ma = None # for ma in ctrl.main.ui_manager.touch_areas: # @UndefinedVariable # if ma.sceneBoundingRect().contains(x, y): # closest_ma = ma # break # if closest_ma: # closest_ma.drop(self) # print('dropped to:', closest_ma) # # ctrl.scene.fit_to_window() def accepts_drops(self, dragged): """ Reimplement this to evaluate if this Movable should accept drops from dragged. Default returns False. :param dragged: Item that is being dragged. You may want to look into what kind of object this is and decide from that. :return: """ return False # ## Existence ############################################################ def show(self): if not self.isVisible(): super().show() def update_visibility(self, fade_in=True, fade_out=True) -> bool: """ Subclasses should set _visible_by_logic based on their many factors. In this level the actual hide/show/fade -operations are made. This is called logical visibility and can be checked with is_visible(). Qt's isVisible() checks for scene visibility. Items that are e.g. fading away have False for logical visibility but True for scene visibility and items that are part of graph in a forest that is not currently drawn may have True for logical visibility but false for scene visibility. :return: True if visibility has changed. Use this information to notify related parties """ if self.scene(): if self._visible_by_logic: if self.is_fading_out: if fade_in: self.fade_in() return True else: self._fade_anim.stop() self.is_fading_out = False self.show() return True elif not self.isVisible(): if fade_in: self.fade_in() return True else: self.show() return True else: if self.isVisible(): if fade_out: if not self.is_fading_out: self.fade_out() return True else: self.hide() return True return False # ############## # # # # Save support # # # # ############## # #current_position = Saved("current_position", if_changed=_current_position_changed) target_position = SavedField("target_position") adjustment = SavedField("adjustment") use_adjustment = SavedField("use_adjustment") locked = SavedField("locked") physics_x = SavedField("physics_x") physics_y = SavedField("physics_y") trees = SavedField("trees") locked_to_node = SavedField("locked_to_node")
class Constituent(BaseConstituent): """ The main difference between mgtdbp Constituents and Kataja's BaseConstituents is that features are stored as lists instead of dicts. See footnote 1, p.3 in Stabler 2012, 'Two models of minimalist, incremental syntactic analysis'. The order of features is important in parsing (though Constituents are actually used only for displaying results, not in parsing itself) and -- more importantly -- one Constituent can have two counts for the same feature, e.g. =D =D. and we have to be able to present such constituents. """ role = "Constituent" def __init__(self, label='', features=None, parts=None, index_str=None): super().__init__(label=label, parts=parts, features=features) self.label = label or '' self.features = features or [] self.parts = parts or [] self.index_str = index_str self.secondary_label = '' self.touched = True # flag to help remove nodes that don't belong to current parse def __repr__(self): return '[%r:%r, %r]' % (self.label, self.features, self.parts) def get_secondary_label(self): """ Visualisation can switch between showing labels and some other information in label space. If you want to support this, have "support_secondary_labels = True" in SyntaxConnection and provide something from this getter. :return: """ #return self.frozen_features return self.secondary_label @staticmethod def build_from_dnodes(dnode, dnodes, terminals, dtrees, all_features=False): key = dnode.path c = dtrees.get(key, Constituent(index_str=key)) if terminals and terminals[0].path == key: leaf = terminals.pop(0) c.label = ' '.join(leaf.label) c.features = list(reversed(leaf.features)) c.secondary_label = ' '.join([str(f) for f in c.features]) if dnode.features and dnode.features != leaf.features: print('dnode has features: ', dnode.features) print('leaf has features: ', leaf.features) c.parts = [] elif dnodes and dnodes[0].path.startswith(key): parts = [] child_dnode = dnodes.pop(0) child = Constituent.build_from_dnodes(child_dnode, dnodes, terminals, dtrees, all_features=all_features) parts.append(child) if dnodes and dnodes[0].path.startswith(key): child_dnode = dnodes.pop(0) child = Constituent.build_from_dnodes( child_dnode, dnodes, terminals, dtrees, all_features=all_features) parts.append(child) if all_features: c.features = list(reversed(dnode.features)) else: c.features = [] c.secondary_label = ' '.join([str(f) for f in c.features]) if len(parts) > 1: c.label = '•' elif len(parts) == 1: c.label = '◦' else: c.label = '' c.parts = parts c.touched = True dtrees[key] = c return c def as_list_tree(self): if len(self.parts) == 2: return [ self.label, self.parts[0].as_list_tree(), self.parts[1].as_list_tree() ] elif len(self.parts) == 1: return [self.label, self.parts[0].as_list_tree()] elif self.features: if self.label: label = [self.label] else: label = [] return label, [str(f) for f in self.features] @staticmethod def dnodes_to_dtree(dnodes, all_features=False, dtrees=None): if dtrees is None: dtrees = {} nonterms = [] terms = [] for dn in dnodes: if dn.terminal: terms.append(dn) else: nonterms.append(dn) terms.sort() nonterms.sort() root = nonterms.pop(0) for item in dtrees.values(): item.touched = False dtree = Constituent.build_from_dnodes(root, nonterms, terms, dtrees, all_features=all_features) for key, item in list(dtrees.items()): if not item.touched: del dtrees[key] #dtree = Constituent() #dtree.build_from_dnodes(root.path, nonterms, terms, dtrees, all_features=all_features) if terms or nonterms: print('dnodes_to_dtree error: unused derivation steps') print('terms=' + str(terms)) print('nonterms=' + str(nonterms)) return dtree def __hash__(self): return hash(self.index_str) secondary_label = SavedField("secondary_label")
class ConstituentNode(Node): """ ConstituentNode is enriched with few elements that have no syntactic meaning but help with reading the trees aliases, indices and glosses. """ __qt_type_id__ = next_available_type_id() display_name = ('Constituent', 'Constituents') display = True width = 20 height = 20 is_constituent = True node_type = g.CONSTITUENT_NODE wraps = 'constituent' editable = { } # Uses custom ConstituentNodeEmbed instead of template-based NodeEditEmbed default_style = { 'plain': { 'color_id': 'content1', 'font_id': g.MAIN_FONT, 'font-size': 10 }, 'fancy': { 'color_id': 'content1', 'font_id': g.MAIN_FONT, 'font-size': 10 } } default_edge = g.CONSTITUENT_EDGE # Touch areas are UI elements that scale with the trees: they can be # temporary shapes suggesting to drag or click here to create the # suggested shape. # touch_areas_when_dragging and touch_areas_when_selected use the same # format. # 'condition': there are some general conditions implemented in UIManager, # but condition can refer to method defined for node instance. When used # for when-dragging checks, the method will be called with two parameters # 'dragged_type' and 'dragged_host'. # 'place': there are some general places defined in UIManager. The most # important is 'edge_up': in this case touch areas are associated with # edges going up. When left empty, touch area is associated with the node. touch_areas_when_dragging = { g.LEFT_ADD_TOP: { 'condition': ['is_top_node', 'dragging_constituent', 'free_drawing_mode'] }, g.RIGHT_ADD_TOP: { 'condition': ['is_top_node', 'dragging_constituent', 'free_drawing_mode'] }, g.LEFT_ADD_SIBLING: { 'place': 'edge_up', 'condition': ['dragging_constituent', 'free_drawing_mode'] }, g.RIGHT_ADD_SIBLING: { 'place': 'edge_up', 'condition': ['dragging_constituent', 'free_drawing_mode'] }, g.TOUCH_CONNECT_COMMENT: { 'condition': 'dragging_comment' }, g.TOUCH_CONNECT_FEATURE: { 'condition': ['dragging_feature', 'free_drawing_mode'] }, g.TOUCH_CONNECT_GLOSS: { 'condition': 'dragging_gloss' } } touch_areas_when_selected = { g.LEFT_ADD_TOP: { 'condition': ['is_top_node', 'free_drawing_mode'], 'action': 'add_top_left' }, g.RIGHT_ADD_TOP: { 'condition': ['is_top_node', 'free_drawing_mode'], 'action': 'add_top_right' }, g.MERGE_TO_TOP: { 'condition': ['not:is_top_node', 'free_drawing_mode'], 'action': 'merge_to_top' }, g.INNER_ADD_SIBLING_LEFT: { 'condition': ['inner_add_sibling', 'free_drawing_mode'], 'place': 'edge_up', 'action': 'inner_add_sibling_left' }, g.INNER_ADD_SIBLING_RIGHT: { 'condition': ['inner_add_sibling', 'free_drawing_mode'], 'place': 'edge_up', 'action': 'inner_add_sibling_right' }, g.UNARY_ADD_CHILD_LEFT: { 'condition': ['has_one_child', 'free_drawing_mode'], 'action': 'unary_add_child_left' }, g.UNARY_ADD_CHILD_RIGHT: { 'condition': ['has_one_child', 'free_drawing_mode'], 'action': 'unary_add_child_right' }, g.LEAF_ADD_SIBLING_LEFT: { 'condition': ['is_leaf', 'free_drawing_mode'], 'action': 'leaf_add_sibling_left' }, g.LEAF_ADD_SIBLING_RIGHT: { 'condition': ['is_leaf', 'free_drawing_mode'], 'action': 'leaf_add_sibling_right' }, g.ADD_TRIANGLE: { 'condition': 'can_have_triangle', 'action': 'add_triangle' }, g.REMOVE_TRIANGLE: { 'condition': 'is_triangle_host', 'action': 'remove_triangle' } } buttons_when_selected = { g.REMOVE_MERGER: { 'condition': ['is_unnecessary_merger', 'free_drawing_mode'], 'action': 'remove_merger' }, g.NODE_EDITOR_BUTTON: { 'action': 'toggle_node_edit_embed' }, g.REMOVE_NODE: { 'condition': ['not:is_unnecessary_merger', 'free_drawing_mode'], 'action': 'remove_node' }, #g.QUICK_EDIT_LABEL: {}, # 'condition': 'is_quick_editing' } def __init__(self, label=''): """ Most of the initiation is inherited from Node """ Node.__init__(self) self.heads = [] # ### Projection -- see also preferences that govern if these are used self.can_project = True self.projecting_to = set() self.index = '' self.label = label self.autolabel = '' self.gloss = '' self.is_trace = False self.merge_order = 0 self.select_order = 0 self.in_projections = [] # ### Cycle index stores the order when node was originally merged to structure. # going up in trees, cycle index should go up too def after_init(self): """ After_init is called in 2nd step in process of creating objects: 1st wave creates the objects and calls __init__, and then iterates through and sets the values. 2nd wave calls after_inits for all created objects. Now they can properly refer to each other and know their values. :return: None """ self.update_gloss() self.update_label_shape() self.update_label() self.update_visibility() self.update_status_tip() self.announce_creation() if prefs.glow_effect: self.toggle_halo(True) ctrl.forest.store(self) @staticmethod def create_synobj(label, forest): """ ConstituentNodes are wrappers for Constituents. Exact implementation/class of constituent is defined in ctrl. :return: """ if not label: label = forest.get_first_free_constituent_name() c = ctrl.syntax.Constituent(label) c.after_init() return c def load_values_from_parsernode(self, parsernode): """ Update constituentnode with values from parsernode :param parsernode: :return: """ def remove_dot_label(inode, row_n): for i, part in enumerate(list(inode.parts)): if isinstance(part, str): if part.startswith('.'): inode.parts[i] = part[1:] return True elif isinstance(part, ICommandNode) and part.command == 'qroof': self.triangle_stack = [self] continue else: return remove_dot_label(part, row_n) if parsernode.index: self.index = parsernode.index rows = parsernode.label_rows # Remove dotlabel for i, row in enumerate(list(rows)): if isinstance(row, str): if row.startswith('.'): rows[i] = row[1:] break stop = remove_dot_label(row, i) if stop: break # △ self.label = join_lines(rows) # now as rows are in one INode / string, we can extract the triangle part and put it to # end. It is different to qtree's way of handling triangles, but much simpler for us in # long run. triangle_part = extract_triangle(self.label, remove_from_original=True) if triangle_part: assert isinstance(self.label, ITextNode) self.label.parts.append('\n') self.label.parts.append(triangle_part) if self.index: base = as_html(self.label) if base.strip().startswith('t<sub>'): self.is_trace = True def get_syntactic_label(self): if self.syntactic_object: return self.syntactic_object.label # Other properties @property def gloss_node(self): """ :return: """ gs = self.get_children(visible=True, of_type=g.GLOSS_EDGE) if gs: return gs[0] def has_ordered_children(self): if self.syntactic_object: return getattr(self.syntactic_object, 'is_ordered', False) return True def update_label_shape(self): self.label_object.label_shape = ctrl.settings.get('label_shape') def should_show_gloss_in_label(self) -> bool: return ctrl.settings.get('lock_glosses_to_label') == 1 def update_status_tip(self) -> None: """ Hovering status tip """ if self.label: label = f'Label: "{as_text(self.label)}" ' else: label = '' syn_label = self.get_syn_label() if syn_label: syn_label = f' Constituent: "{as_text(syn_label)}" ' else: syn_label = '' if self.index: index = f' Index: "{self.index}"' else: index = '' if self.is_trace: name = "Trace" elif self.is_leaf(): name = "Leaf " # elif self.is_top_node(): # name = "Set %s" % self.set_string() # "Root constituent" else: #name = f"Set {self.set_string()}" name = "Set " if self.use_adjustment: adjustment = f' w. adjustment ({self.adjustment[0]:.1f}, {self.adjustment[1]:.1f})' else: adjustment = '' heads = ', '.join([as_text(x.label) for x in self.heads]) self.status_tip = f"{name} ({label}{syn_label}{index} pos: ({self.current_scene_position[0]:.1f}, " \ f"{self.current_scene_position[1]:.1f}){adjustment} head: {heads})" def short_str(self): label = as_text(self.label) if label: lines = label.splitlines() if len(lines) > 3: label = f'{lines[0]} ...\n{lines[-1]}' syn_label = as_text(self.get_syn_label()) if label and syn_label: return f'{label} ({syn_label})' else: return label or syn_label or "no label" def set_string(self): """ This can be surprisingly expensive to calculate :return: """ if self.syntactic_object and hasattr(self.syntactic_object, 'set_string'): return self.syntactic_object.set_string() else: return self._set_string() def _set_string(self): parts = [] for child in self.get_children(similar=True, visible=False): parts.append(str(child._set_string())) if parts: return '{%s}' % ', '.join(parts) else: return self.label def __str__(self): label = as_text(self.label, single_line=True) syn_label = as_text(self.get_syn_label(), single_line=True) if label and syn_label: return f'CN {label} ({syn_label})' else: return f'CN {label or syn_label or "no label"}' def get_syn_label(self): if self.syntactic_object: return self.syntactic_object.label return '' def compose_html_for_viewing(self, peek_into_synobj=True): """ This method builds the html to display in label. For convenience, syntactic objects can override this (going against the containment logic) by having their own 'compose_html_for_viewing' -method. This is so that it is easier to create custom implementations for constituents without requiring custom constituentnodes. Note that synobj's compose_html_for_viewing receives the node object as parameter, so you can replicate the behavior below and add your own to it. :param peek_into_synobj: allow syntactic object to override this method. If synobj in turn needs the result from this implementation (e.g. to append something to it), you have to turn this off to avoid infinite loop. See example plugins. :return: """ # Allow custom syntactic objects to override this if peek_into_synobj and hasattr(self.syntactic_object, 'compose_html_for_viewing'): return self.syntactic_object.compose_html_for_viewing(self) html = [] label_text_mode = ctrl.settings.get('label_text_mode') l = '' if label_text_mode == g.NODE_LABELS: if self.label: l = self.label elif self.syntactic_object: l = self.syntactic_object.label elif label_text_mode == g.NODE_LABELS_FOR_LEAVES: if self.label: l = self.label elif self.syntactic_object and self.is_leaf(only_similar=True, only_visible=False): l = self.syntactic_object.label elif label_text_mode == g.SYN_LABELS: if self.syntactic_object: l = self.syntactic_object.label elif label_text_mode == g.SYN_LABELS_FOR_LEAVES: if self.syntactic_object and self.is_leaf(only_similar=True, only_visible=False): l = self.syntactic_object.label elif label_text_mode == g.SECONDARY_LABELS: if self.syntactic_object: l = self.syntactic_object.get_secondary_label() elif label_text_mode == g.XBAR_LABELS: l = self.get_autolabel() separate_triangle = bool(self.is_cosmetic_triangle() and self.triangle_stack[-1] is self) l_html = as_html(l, omit_triangle=separate_triangle, include_index=self.index) if l_html: html.append(l_html) if self.gloss and self.should_show_gloss_in_label(): if html: html.append('<br/>') html.append(as_html(self.gloss)) if html and html[-1] == '<br/>': html.pop() # Lower part lower_html = '' if separate_triangle: qroof_content = extract_triangle(l) if qroof_content: lower_html = as_html(qroof_content) return ''.join(html), lower_html def compose_html_for_editing(self): """ This is used to build the html when quickediting a label. It should reduce the label into just one field value that is allowed to be edited, in constituentnode this is either label or synobj's label. This can be overridden in syntactic object by having 'compose_html_for_editing' -method there. The method returns a tuple, (field_name, setter, html). :return: """ # Allow custom syntactic objects to override this if self.syntactic_object and hasattr(self.syntactic_object, 'compose_html_for_editing'): return self.syntactic_object.compose_html_for_editing(self) label_text_mode = ctrl.settings.get('label_text_mode') if label_text_mode == g.NODE_LABELS or label_text_mode == g.NODE_LABELS_FOR_LEAVES: if self.label: if self.triangle_stack: lower_part = extract_triangle(self.label) return 'node label', as_html(self.label, omit_triangle=True) + \ '<br/>' + as_html(lower_part or '') else: return 'node label', as_html(self.label) elif self.syntactic_object: return 'syntactic label', as_html(self.syntactic_object.label) else: return '', '', '' elif label_text_mode == g.SYN_LABELS or label_text_mode == g.SYN_LABELS_FOR_LEAVES: if self.syntactic_object: return 'syntactic label', as_html(self.syntactic_object.label) else: return '', '' def parse_edited_label(self, label_name, value): success = False if self.syntactic_object and hasattr(self.syntactic_object, 'parse_edited_label'): success = self.syntactic_object.parse_edited_label( label_name, value) if not success: if label_name == 'node label': self.poke('label') self.label = value return True elif label_name == 'syntactic label': self.syntactic_object.label = value return True elif label_name == 'index': self.index = value return False def as_bracket_string(self): """ returns a simple bracket string representation """ if self.label: children = list(self.get_children(similar=True, visible=False)) if children: return '[.%s %s ]' % \ (self.label, ' '.join((c.as_bracket_string() for c in children))) else: return str(self.label) else: inside = ' '.join( (x.as_bracket_string() for x in self.get_children(similar=True, visible=False))) if inside: return '[ ' + inside + ' ]' elif self.syntactic_object: return str(self.syntactic_object) else: return '-' def get_attribute_nodes(self, label_key=''): """ :param label_k ey: :return: """ atts = [ x.end for x in self.edges_down if x.edge_type == g.ATTRIBUTE_EDGE ] if label_key: for a in atts: if a.attribute_label == label_key: return a else: return atts def get_autolabel(self): return self.autolabel def is_unnecessary_merger(self): """ This merge can be removed, if it has only one child :return: """ return len(list(self.get_children(similar=True, visible=False))) == 1 # Conditions ########################## # These are called from templates with getattr, and may appear unused for IDE's analysis. # Check their real usage with string search before removing these. def inner_add_sibling(self): """ Node has child and it is not unary child. There are no other reasons preventing adding siblings :return: bool """ return self.get_children(similar=True, visible=False) and not self.is_unary() def has_one_child(self): return len(self.get_children(similar=True, visible=False)) == 1 def can_be_projection_of_another_node(self): """ Node can be projection from other nodes if it has other nodes below it. It may be necessary to move this check to syntactic level at some point. :return: """ if ctrl.settings.get('use_projection'): if self.is_leaf(only_similar=True, only_visible=False): return False else: return True else: return False def set_heads(self, head): """ Set projecting head to be Node, list of Nodes or empty. Notice that this doesn't affect syntactic objects. :param head: :return: """ if isinstance(head, list): self.heads = list(head) elif isinstance(head, Node): self.heads = [head] elif not head: self.heads = [] else: raise ValueError def synobj_to_node(self): """ Update node's values from its synobj. Subclasses implement this. :return: """ self.synheads_to_heads() def synheads_to_heads(self): """ Make sure that node's heads reflect synobjs heads. :return: """ self.heads = [] if self.syntactic_object: synlabel = self.syntactic_object.label parts = self.syntactic_object.parts if len(parts) == 0: self.heads = [self] if len(parts) == 1: if parts[0].label == synlabel: self.heads = [ctrl.forest.get_node(parts[0])] else: self.heads = [self] elif len(parts) == 2: if parts[0].label == synlabel: self.heads = [ctrl.forest.get_node(parts[0])] elif parts[1].label == synlabel: self.heads = [ctrl.forest.get_node(parts[1])] elif synlabel == f"({parts[0].label}, {parts[1].label})": self.heads = [ ctrl.forest.get_node(parts[0]), ctrl.forest.get_node(parts[1]) ] elif synlabel == f"({parts[1].label}, {parts[0].label})": self.heads = [ ctrl.forest.get_node(parts[1]), ctrl.forest.get_node(parts[0]) ] @property def contextual_color(self): """ Drawing color that is sensitive to node's state :return: QColor """ if ctrl.is_selected(self): base = ctrl.cm.selection() elif self.in_projections: base = ctrl.cm.get(self.in_projections[0].color_id) else: base = self.color if self.drag_data: return ctrl.cm.lighter(base) elif ctrl.pressed is self: return ctrl.cm.active(base) elif self._hovering: return ctrl.cm.hovering(base) else: return base # ### Features ######################################### def update_gloss(self, value=None): """ """ if not self.syntactic_object: return syn_gloss = self.gloss gloss_node = self.gloss_node if not ctrl.undo_disabled: if gloss_node and not syn_gloss: ctrl.free_drawing.delete_node(gloss_node) elif syn_gloss and not gloss_node: ctrl.free_drawing.create_gloss_node(host=self) elif syn_gloss and gloss_node: gloss_node.update_label() # ### Labels ############################################# # things to do with traces: # if renamed and index is removed/changed, update chains # if moved, update chains # if copied, make sure that copy isn't in chain # if deleted, update chains # any other operations? def is_empty_node(self): """ Empty nodes can be used as placeholders and deleted or replaced without structural worries """ return (not (self.syntactic_object or self.label or self.index)) and self.is_leaf() # ## Indexes and chains ################################### def is_chain_head(self): """ :return: """ if self.index: return not (self.is_leaf() and self.label == 't') return False ### UI support def dragging_constituent(self): """ Check if the currently dragged item is constituent and can connect with me :return: """ return self.is_dragging_this_type(g.CONSTITUENT_NODE) def dragging_feature(self): """ Check if the currently dragged item is feature and can connect with me :return: """ return self.is_dragging_this_type(g.FEATURE_NODE) def dragging_gloss(self): """ Check if the currently dragged item is gloss and can connect with me :return: """ return self.is_dragging_this_type(g.GLOSS_NODE) def dragging_comment(self): """ Check if the currently dragged item is comment and can connect with me :return: """ return self.is_dragging_this_type(g.COMMENT_NODE) # ### Features ######################################### def get_features(self): """ Returns FeatureNodes """ return self.get_children(visible=True, of_type=g.FEATURE_EDGE) def get_features_as_string(self): """ :return: """ features = [f.syntactic_object for f in self.get_features()] feature_strings = [str(f) for f in features] return ', '.join(feature_strings) # ### Checks for callable actions #### def can_top_merge(self): """ :return: """ top = self.get_top_node() return self is not top and self not in top.get_children(similar=True, visible=False) # ### Dragging ##################################################################### # ## Most of this is implemented in Node def prepare_children_for_dragging(self, scene_pos): """ Implement this if structure is supposed to drag with the node :return: """ children = ctrl.forest.list_nodes_once(self) for tree in self.trees: dragged_index = tree.sorted_constituents.index(self) for i, node in enumerate(tree.sorted_constituents): if node is not self and i > dragged_index and node in children: node.start_dragging_tracking(host=False, scene_pos=scene_pos) ################################# # ### Parents & Children #################################################### def is_projecting_to(self, other): """ :param other: """ pass # # def paint(self, painter, option, widget=None): # """ Painting is sensitive to mouse/selection issues, but usually with # :param painter: # :param option: # :param widget: # nodes it is the label of the node that needs complex painting """ # super().paint(painter, option, widget=widget) # ############## # # # # Save support # # # # ############## # label = SavedField("label") index = SavedField("index") gloss = SavedField("gloss", if_changed=update_gloss) heads = SavedField("heads")
class KatajaDocument(SavedObject): """ Container and loader for Forest objects. Remember to not enable undo for any of the actions in here, as scope of undo should be a single Forest. :param name: Optional readable name for document :param filename: File name for saving the document. Initially empty, will be set on save """ unique = True default_treeset_file = running_environment.resources_path + 'trees.txt' def __init__(self, name=None, filename=None, clear=False): super().__init__() self.name = name or filename or 'New project' self.filename = filename self.forests = [Forest()] self.current_index = 0 self.forest = None self.lexicon = {} self.structures = OrderedDict() self.constituents = OrderedDict() self.features = OrderedDict() def new_forest(self): """ Add a new forest after the current one. :return: tuple (current_index (int), selected forest (Forest) """ ctrl.undo_pile = set() #ctrl.undo_disabled = True if self.forest: self.forest.retire_from_drawing() forest = Forest() self.current_index += 1 self.poke( 'forests') # <-- announce change in watched list-like attribute self.forests.insert(self.current_index, forest) self.forest = forest # <-- at this point the signal is sent to update UI #ctrl.undo_disabled = False return self.current_index, self.forest def next_forest(self): """ Select the next forest in the list of forests. The list loops at end. :return: tuple (current_index (int), selected forest (Forest) """ if not self.forests: return None if self.current_index < len(self.forests) - 1: self.current_index += 1 else: self.current_index = 0 ctrl.undo_pile = set() self.forest = self.forests[self.current_index] return self.current_index, self.forest def prev_forest(self): """ Select the previous forest in the list of forests. The list loops at -1. :return: tuple (current_index (int), selected forest (Forest) """ if not self.forests: return None if self.current_index > 0: self.current_index -= 1 else: self.current_index = len(self.forests) - 1 ctrl.undo_pile = set() self.forest = self.forests[self.current_index] return self.current_index, self.forest def build_lexicon_dict(self): self.constituents = OrderedDict() self.features = OrderedDict() self.structures = OrderedDict() for key, data in sorted(self.lexicon.items()): if data.startswith('['): self.structures[key] = data elif data.startswith('<'): self.features[key] = data elif key != 'lexicon_info': self.constituents[key] = data @staticmethod def load_treelist_from_text_file(filename): """ Pretty dumb fileloader, to create a treelist (list of strings) :param filename: str, does nothing with the path. """ try: f = open(filename, 'r', encoding='UTF-8') treelist = f.readlines() f.close() except FileNotFoundError: treelist = ['[A B]', '[ A [ C B ] ]', ''] return treelist @time_me def create_forests(self, filename=None, clear=False): """ This will read list of strings where each line defines a trees or an element of trees. This can be used to reset the KatajaDocument if no treeset or an empty treeset is given. It is common to override this method in plugins to provide custom commands for e.g. running parsers. Example of tree this can read: [.AspP [.Asp\\Ininom] [.vP [.KP [.K\\ng ] [.DP [.D´ [.D ] [.NP\\lola ]] [.KP [.K\\ng] [.DP [.D´ [.D ] [.NP\\alila ] ] [.KP\\{ni Maria} ]]]]] [.v´ [.v ] [.VP [.V ] [.KP\\{ang tubig}]]]]] Ininom = drank ng = NG ng = NG lola = grandma alila = servant ni Maria = NG Maria ang tubig = ANG water 'Maria's grandmother's servant drank the water' :param filename: (optional) file to load from :param clear: (optional) if True, start with an empty treeset and don't attempt to load examples """ print('************* create forests ****************') if clear: treelist = [] else: treelist = self.load_treelist_from_text_file( self.__class__.default_treeset_file) or [] # Clear this screen before we start creating a mess ctrl.disable_undo() # disable tracking of changes (e.g. undo) if self.forest: self.forest.retire_from_drawing() self.forests = [] # buildstring is the bracket trees or trees. buildstring = [] # definitions includes given definitions for constituents of this trees definitions = {} # gloss_text is the gloss for whole trees gloss_text = '' # comments are internal notes about the trees, displayed as help text or something comments = [] started_forest = False syntax_class = classes.get('SyntaxConnection') for line in treelist: line = line.strip() #line.split('=', 1) parts = line.split('=', 1) # comment line if line.startswith('#'): if started_forest: comments.append(line[1:]) # Definition line elif len(parts) > 1 and not line.startswith('['): started_forest = True word = parts[0].strip() values = parts[1] definitions[word] = values # Gloss text: elif line.startswith("'"): if started_forest: if line.endswith("'"): line = line[:-1] gloss_text = line[1:] # empty line: finalize this forest elif started_forest and not line: syn = syntax_class() syn.sentence = buildstring syn.lexicon = definitions forest = Forest(gloss_text=gloss_text, comments=comments, syntax=syn) self.forests.append(forest) started_forest = False # trees definition starts a new forest elif line and not started_forest: started_forest = True buildstring = line definitions = {} gloss_text = '' comments = [] # another trees definition, append to previous elif line: buildstring += '\n' + line if started_forest: # make sure that the last forest is also added syn = syntax_class() syn.sentence = buildstring syn.lexicon = definitions forest = Forest(gloss_text=gloss_text, comments=comments, syntax=syn) self.forests.append(forest) if not self.forests: syn = syntax_class() forest = Forest(gloss_text='', comments=[], syntax=syn) self.forests.append(forest) self.current_index = 0 self.forest = self.forests[0] # allow change tracking (undo) again ctrl.resume_undo() # ############## # # # # Save support # # # # ############## # forests = SavedField("forests") current_index = SavedField("current_index") forest = SavedField("forest", watcher="forest_changed") lexicon = SavedField("lexicon")
class DerivationStepManager(SavedObject): """ Stores derivation steps for one forest and takes care of related logic """ def __init__(self, forest=None): super().__init__() self.forest = forest self.activated = False self.current = None self.derivation_steps = [] self.derivation_step_index = 0 def save_and_create_derivation_step(self, synobjs, numeration=None, other=None, msg='', gloss='', transferred=None, mover=None): """ Ok, new idea: derivation steps only include syntactic objects. Nodes etc. will be created in the fly. No problems from visualisations misbehaving, chains etc. :param synobjs: list of syntactic objects present in this snapshot :param numeration: optional list of items :param other: optional arbitrary data to store (must be Saveable or primitive data structures!) :param msg: optional message about derivation, to float when switching between derivations :param gloss: optional gloss for derivation :param transferred: items that have been transferred/spelt out :return: """ d_step = DerivationStep(synobjs, numeration, other, msg, gloss, transferred, mover) # Use Kataja's save system to freeze objects into form where they can be stored and restored # without further changes affecting them. savedata = {} open_references = {} d_step.save_object(savedata, open_references) c = 0 max_depth = 100 # constituent trees can be surprisingly deep, and we don't have any # general dict about them. while open_references and c < max_depth: c += 1 for obj in list(open_references.values()): if hasattr(obj, 'uid'): #print('saving obj ', obj.uid) obj.save_object(savedata, open_references) else: print('cannot save open reference object ', obj) assert(c < max_depth) # please raise the max depth if this is reached self.derivation_steps.append((d_step.uid, savedata, msg)) @time_me def restore_derivation_step(self): if self.derivation_steps: uid, frozen_data, msg = self.derivation_steps[self.derivation_step_index] d_step = DerivationStep(uid=uid) d_step.load_objects(frozen_data, ctrl.main) self.activated = True self.current = d_step synobjs_to_nodes(self.forest, d_step.synobjs, d_step.numeration, d_step.other, d_step.msg, d_step.gloss, d_step.transferred, d_step.mover) if msg: log.info(msg) def next_derivation_step(self): """ :return: """ if self.derivation_step_index + 1 >= len(self.derivation_steps): return self.derivation_step_index += 1 self.restore_derivation_step() def previous_derivation_step(self): """ :return: """ if self.derivation_step_index == 0: return self.derivation_step_index -= 1 self.restore_derivation_step() def jump_to_derivation_step(self, i): """ :return: """ self.derivation_step_index = i self.restore_derivation_step() def is_first(self): return self.derivation_step_index == 0 def is_last(self): return self.derivation_step_index == len(self.derivation_steps) - 1 # ############## # # # # Save support # # # # ############## # derivation_steps = SavedField("derivation_steps") derivation_step_index = SavedField("derivation_step_index", watcher="forest_changed") forest = SavedField("forest")
class Feature(MyBaseClass): role = "Feature" def __init__(self, namestring='', counter=0, name='', value='', unvalued=False, ifeature=False): if in_kataja: super().__init__(name=name, value=value) if namestring: if namestring.startswith('u'): namestring = namestring[1:] self.unvalued = True self.ifeature = False elif namestring.startswith('i'): namestring = namestring[1:] self.unvalued = False self.ifeature = True else: self.unvalued = False self.ifeature = False digits = '' digit_count = 0 for char in reversed(namestring): if char.isdigit(): digits = char + digits digit_count += 1 else: break if digit_count: self.counter = int(digits) namestring = namestring[:-digit_count] else: self.counter = 0 self.name, part, self.value = namestring.partition(':') self.valued_by = None else: self.name = name self.counter = counter self.value = value self.unvalued = unvalued self.ifeature = ifeature self.valued_by = None def __str__(self): return repr(self) def __repr__(self): parts = [] if self.ifeature: parts.append('i') elif self.unvalued: parts.append('u') parts.append(self.name) if self.value: parts.append(':' + self.value) if self.counter: parts.append(str(self.counter)) #return "'"+''.join(parts)+"'" return ''.join(parts) def __eq__(self, o): if isinstance(o, Feature): return self.counter == o.counter and self.name == o.name and \ self.value == o.value and self.unvalued == o.unvalued and \ self.ifeature == o.ifeature elif isinstance(o, str): return str(self) == o else: return False def __lt__(self, other): return repr(self) < repr(other) def __gt__(self, other): return repr(self) > repr(other) def __hash__(self): return hash(str(self)) def __contains__(self, item): if isinstance(item, Feature): if item.name != self.name: return False if item.ifeature and not self.ifeature: return False if item.value and self.value != item.value: return False if item.unvalued and not self.unvalued: return False return True else: return item in str(self) def copy(self): return Feature(counter=self.counter, name=self.name, value=self.value, unvalued=self.unvalued, ifeature=self.ifeature) if in_kataja: unvalued = SavedField("unvalued") ifeature = SavedField("ifeature") counter = SavedField("counter") valued_by = SavedField("valued_by")
class SavedObject(object): """ Make the object to have internal .saved -object where saved data should go. Also makes it neater to check if item is Savable. """ uid = SavedField("uid") class_name = SavedField("class_name") settings = SavedField("settings") syntactic_object = False unique = False dont_copy = [] allowed_settings = [] def __init__(self, uid=None, **kw): if self.unique: uid = self.__class__.__name__ elif uid is None: uid = next_available_uid() self._saved = {} self._history = {} self.uid = uid self.settings = {} self.class_name = getattr(self.__class__, 'role', self.__class__.__name__) self._cd = 0 # / CREATED / DELETED self._can_be_deleted_with_undo = True self._skip_this = False # temporary "ghost" objects can use this flag to avoid being stored def __str__(self): return self.class_name + str(self.uid) def copy(self): """ Make a new object of same type and copy its attributes. object class can define dont_copy, list of attribute names that shouldn't be copied ( either because they refer to peers or objects above, or because they are handled manually.). Attributes starting with '_' are always ignored, and the copied object is assigned a new key. :return: """ if self.__class__.unique: print('cannot copy unique object') return None new_obj = self.__class__() new_obj.uid = next_available_uid() dont_copy = self.__class__.dont_copy for key, value in vars(self).items(): if (not key.startswith('_')) and key not in dont_copy: if hasattr(value, 'copy'): new_value = value.copy() else: new_value = copy.copy(value) setattr(new_obj, key, new_value) return new_obj def poke(self, attribute): """ Alert undo system that this (Saved) object is being changed. This is used manually for container-type objects in the model before changing adding or removing objects in them, as these operations would not be catch by setters. This doesn't check if the new value is different from previous, as this is used manually before actions that change the list/dict/set. :param attribute: string, name of the attribute :return: None """ if ctrl.undo_disabled: return if not self._history: ctrl.undo_pile.add(self) self._history[attribute] = copy.copy(self._saved[attribute]) elif attribute not in self._history: self._history[attribute] = copy.copy(self._saved[attribute]) def announce_creation(self): """ Flag object to have been created in this undo cycle. If the object was created here, when moving to _previous_ cycle should launch removal of the object from scene. :return:None """ if ctrl.undo_disabled: return self._cd = CREATED ctrl.undo_pile.add(self) def announce_deletion(self): """ Flag object to have been deleted in this undo cycle. :return:None """ if ctrl.undo_disabled or not self._can_be_deleted_with_undo: return self._cd = DELETED ctrl.undo_pile.add(self) def call_watchers(self, signal, field_name=None, value=None): """ Alert (UI) objects that are watching for changes for given field in this object :param signal: :param field_name: :param value: :return: """ ctrl.call_watchers(self, signal, field_name, value) def transitions(self): """ Create a dict of changes based on modified attributes of the item. result dict has tuples as value, where the first item is value before, and second item is value after the change. :return: (dict of changed attributes, 0=EDITED(default) | 1=CREATED | 2=DELETED) """ transitions = {} #print('item %s history: %s' % (self.uid, self._history)) for key, old_value in self._history.items(): new_value = self._saved[key] if old_value != new_value: if isinstance(new_value, Iterable): transitions[key] = old_value, copy.copy(new_value) else: transitions[key] = old_value, new_value return transitions, self._cd def flush_history(self): """ Call after getting storing a set of transitions. Prepare for next round of transitions. :return: None """ self._history = {} self._cd = 0 # don't know yet what to do with synobjs: # elif attr_name.endswith('_synobj') and getattr(self, # attr_name, False): # transitions[attr_name] = (True, True) def revert_to_earlier(self, transitions, transition_type): """ Restore to earlier version with a given changes -dict :param transitions: dict of changes, values are tuples of (old, new) -pairs :return: None """ #print('--- restore to earlier for ', self, ' ----------') for key, value in transitions.items(): old, new = value if isinstance(old, Iterable): setattr(self, key, copy.copy(old)) else: setattr(self, key, old) transition_type = -transition_type # revert transition self.after_model_update(transitions.keys(), transition_type) def move_to_later(self, transitions, transition_type): """ Move to later version with a given changes -dict :param transitions: dict of changes, values are tuples of (old, new) -pairs :return: None """ # print('--- move to later for ', self, ' ----------') for key, value in transitions.items(): old, new = value if isinstance(new, Iterable): setattr(self, key, copy.copy(new)) else: setattr(self, key, new) self.after_model_update(transitions.keys(), transition_type) def after_model_update(self, changed_fields, transition_type): """ Compute derived effects of updated values in sensible order. :param updated_fields: field keys of updates :param transition_type: 0:edit, 1:CREATED, -1:DELETED :return: None """ def can_have_setting(self, key): return key in self.allowed_settings def after_init(self): """ Override this to do preparations necessary for object creation :return: """ self.announce_creation() def save_object(self, saved_objs, open_refs): """ Flatten the object to saveable dict and recursively save the objects it contains :param saved_objs: dict where saved objects are stored. :param open_refs: set of open references. We cannot go jumping to save each referred object when one is met, as it would soon lead to circular references. Open references are stored and cyclically reduced. :return: None """ def _simplify(data): """ Goes through common iterable datatypes and if common Qt types are found, replaces them with basic python tuples. If object is one of Kataja's own data classes, then save its uid """ if isinstance(data, (int, float, str)): return data elif isinstance(data, ITextNode): r = data.as_latex() if r: return 'INode', r else: return '' elif isinstance(data, dict): result = {} for k, value in data.items(): value = _simplify(value) result[k] = value return result elif isinstance(data, list): result = [] for o in data: result.append(_simplify(o)) return result elif isinstance(data, tuple): result = [] for o in data: result.append(_simplify(o)) result = tuple(result) return result elif isinstance(data, set): result = set() for o in data: result.add(_simplify(o)) return result elif isinstance(data, types.FunctionType): # if functions are stored in the dict, there should be some # original # version of the same dict, where these # are in their original form. raise SaveError('trying to save a function at object ', self) elif data is None: return data elif isinstance(data, QPointF): return 'QPointF', to_tuple(QPointF) elif isinstance(data, QPoint): return 'QPoint', to_tuple(QPoint) elif isinstance(data, QtGui.QColor): return 'QColor', data.red(), data.green(), data.blue(), \ data.alpha() elif isinstance(data, QtGui.QPen): pass elif isinstance(data, QtCore.QRectF): return 'QRectF', data.x(), data.y(), data.width(), data.height( ) elif isinstance(data, QtCore.QRect): return 'QRect', data.x(), data.y(), data.width(), data.height() elif isinstance(data, QtGui.QFont): raise SaveError("We shouldn't save QFonts!: ", data) elif hasattr(data, 'uid'): k = getattr(data, 'uid') if k not in saved_objs and k not in open_refs: # print('in %s adding open reference %s' % ( # self.uid, k)) open_refs[k] = data return '|'.join(('*r*', str(k))) else: raise SaveError("simplifying unknown data type:", data, type(data)) if self.uid in saved_objs: return obj_data = {} for key, item in self._saved.items(): obj_data[key] = _simplify(item) # print('saving obj: ', self.uid, obj_data) saved_objs[self.uid] = obj_data if self.uid in open_refs: del open_refs[self.uid] @time_me def load_objects(self, data, kataja_main): """ Load and restore objects starting from given obj (probably Forest or KatajaMain instance) :param data: :param kataja_main: :param self: """ full_map = {} restored = {} full_data = data # First we need a full index of objects that already exist within the # existing objects. # This is to avoid recreating those objects. We just want to modify them def map_existing(obj): """ Take note of existing objects, as undo? will overwrite these. :param obj: :return: """ if isinstance(obj, dict): for item in obj.values(): map_existing(item) return elif isinstance(obj, (list, tuple, set)): for item in obj: map_existing(item) return # objects that support saving key = getattr(obj, 'uid', '') if key and key not in full_map: full_map[key] = obj for item in obj._saved.values(): map_existing(item) # Restore either takes existing object or creates a new 'stub' object # and then loads it with given data map_existing(self) self.restore(self.uid, full_data, full_map, restored, kataja_main, root=True) def restore(self, obj_key, full_data, full_map, restored, kataja_main, root=False): """ Recursively restore objects inside the scope of current obj. Used for loading kataja files. :param obj_key: :param root: :return: """ if isinstance(obj_key, str): if obj_key.isdigit(): obj_key = int(obj_key) def inflate(data): """ Recursively turn QObject descriptions back into actual objects and object references back into real objects :param data: :return: """ # print('inflating %s in %s' % (str(data), self)) if data is None: return data elif isinstance(data, (int, float)): return data elif isinstance(data, dict): result = {} for k, value in data.items(): result[k] = inflate(value) return result elif isinstance(data, list): result = [] for item in data: result.append(inflate(item)) return result elif isinstance(data, str): if data.startswith('*r*'): r, uid = data.split('|', 1) return self.restore(uid, full_data, full_map, restored, kataja_main) else: return data elif isinstance(data, tuple): if data and isinstance(data[0], str): data_type = data[0] if data_type == 'INode': return ctrl.latex_field_parser.process(data[1]) elif data_type == 'QPointF': return QPointF(data[1], data[2]) elif data_type == 'QPoint': return QPoint(data[1], data[2]) elif data_type == 'QRectF': return QtCore.QRectF(data[1], data[2], data[3], data[4]) elif data_type == 'QRect': return QtCore.QRect(data[1], data[2], data[3], data[4]) elif data_type == 'QColor': return QtGui.QColor(data[1], data[2], data[3], data[4]) elif data_type == 'QFont': f = QtGui.QFont() f.fromString(data[1]) return f elif data_type.startswith('Q'): raise SaveError('unknown QObject: %s' % str(data)) else: result = [] for item in data: result.append(inflate(item)) result = tuple(result) return result else: result = [] for item in data: result.append(inflate(item)) result = tuple(result) return result elif isinstance(data, set): result = set() for item in data: result.add(inflate(item)) return result return data # print('restoring %s , %s ' % (obj_key, class_key)) # Don't restore object several times, even if the object is referred # in several places if obj_key in restored: return restored[obj_key] # If the object already exists (e.g. we are doing undo), the loaded # values overwrite existing values. obj = full_map.get(obj_key, None) # new data that the object should have new_data = full_data.get(obj_key, None) if not (obj or new_data): return None elif obj and not new_data: print(obj, " is not present in save data") class_key = new_data['class_name'] if not obj: obj = classes.create(class_key) # when creating/modifying values inside forests, they may refer back # to ctrl.forest. That has to be the current # forest, or otherwise things go awry if class_key == 'Forest': kataja_main.forest = obj # keep track of which objects have been restored restored[obj_key] = obj for key, old_value in obj._saved.items(): new_value = new_data.get(key, None) if new_value is not None: new_value = inflate(new_value) setattr(obj, key, new_value) # objects need to be finalized after setting values, do this only once per load. if root: for item in restored.values(): if hasattr(item, 'after_init'): #print('restoring item, calling after_init for ', type(item), item) item.after_init() return obj
class KatajaMain(SavedObject, QtWidgets.QMainWindow): """ Qt's main window. When this is closed, application closes. Graphics are inside this, in scene objects with view widgets. This window also manages keypresses and menus. """ unique = True def __init__(self, kataja_app, no_prefs=False, reset_prefs=False): """ KatajaMain initializes all its children and connects itself to be the main window of the given application. Receives launch arguments: :param no_prefs: bool, don't load or save preferences :param reset_prefs: bool, don't attempt to load preferences, use defaults instead """ QtWidgets.QMainWindow.__init__(self) kataja_app.processEvents() SavedObject.__init__(self) self.use_tooltips = True self.available_plugins = {} self.setDockOptions(QtWidgets.QMainWindow.AnimatedDocks) self.setCorner(QtCore.Qt.TopLeftCorner, QtCore.Qt.LeftDockWidgetArea) self.setCorner(QtCore.Qt.TopRightCorner, QtCore.Qt.RightDockWidgetArea) self.setCorner(QtCore.Qt.BottomLeftCorner, QtCore.Qt.LeftDockWidgetArea) self.setCorner(QtCore.Qt.BottomRightCorner, QtCore.Qt.RightDockWidgetArea) x, y, w, h = (50, 50, 1152, 720) self.setMinimumSize(w, h) self.app = kataja_app self.save_prefs = not no_prefs self.forest = None self.fontdb = QtGui.QFontDatabase() self.color_manager = PaletteManager() self.settings_manager = Settings() self.forest_keepers = [] self.forest_keeper = None ctrl.late_init(self) classes.late_init() prefs.import_node_classes(classes) self.syntax = SyntaxConnection() prefs.load_preferences(disable=reset_prefs or no_prefs) qt_prefs.late_init(running_environment, prefs, self.fontdb, log) self.settings_manager.set_prefs(prefs) self.color_manager.update_custom_colors() self.find_plugins(prefs.plugins_path or running_environment.plugins_path) self.setWindowIcon(qt_prefs.kataja_icon) self.graph_scene = GraphScene(main=self, graph_view=None) self.graph_view = GraphView(main=self, graph_scene=self.graph_scene) self.graph_scene.graph_view = self.graph_view self.ui_manager = UIManager(self) self.settings_manager.set_ui_manager(self.ui_manager) self.ui_manager.populate_ui_elements() # make empty forest and forest keeper so initialisations don't fail because of their absence self.visualizations = VISUALIZATIONS self.init_forest_keepers() self.settings_manager.set_document(self.forest_keeper) kataja_app.setPalette(self.color_manager.get_qt_palette()) self.forest = Forest() self.settings_manager.set_forest(self.forest) self.change_color_theme(prefs.color_theme, force=True) self.update_style_sheet() self.graph_scene.late_init() self.setCentralWidget(self.graph_view) self.setGeometry(x, y, w, h) self.setWindowTitle(self.tr("Kataja")) self.print_started = False self.show() self.raise_() kataja_app.processEvents() self.activateWindow() self.status_bar = self.statusBar() self.install_plugins() self.load_initial_treeset() log.info('Welcome to Kataja! (h) for help') #ctrl.call_watchers(self.forest_keeper, 'forest_changed') # toolbar = QtWidgets.QToolBar() # toolbar.setFixedSize(480, 40) # self.addToolBar(toolbar) gestures = [ QtCore.Qt.TapGesture, QtCore.Qt.TapAndHoldGesture, QtCore.Qt.PanGesture, QtCore.Qt.PinchGesture, QtCore.Qt.SwipeGesture, QtCore.Qt.CustomGesture ] #for gesture in gestures: # self.grabGesture(gesture) self.action_finished(undoable=False) self.forest.undo_manager.flush_pile() def update_style_sheet(self): c = ctrl.cm.drawing() ui = ctrl.cm.ui() f = qt_prefs.get_font(g.UI_FONT) self.setStyleSheet( stylesheet % { 'draw': c.name(), 'lighter': c.lighter().name(), 'paper': ctrl.cm.paper().name(), 'ui': ui.name(), 'ui_lighter': ui.lighter().name(), 'ui_font': f.family(), 'ui_font_size': f.pointSize(), 'ui_font_larger': int(f.pointSize() * 1.2), 'ui_darker': ui.darker().name() }) def find_plugins(self, plugins_path): """ Find the plugins dir for the running configuration and read the metadata of plugins. Don't try to load actual python code yet :return: None """ if not plugins_path: return self.available_plugins = {} plugins_path = os.path.normpath(plugins_path) os.makedirs(plugins_path, exist_ok=True) sys.path.append(plugins_path) base_ends = len(plugins_path.split('/')) for root, dirs, files in os.walk(plugins_path): path_parts = root.split('/') if len(path_parts) == base_ends + 1 and not path_parts[base_ends].startswith('__') \ and 'plugin.json' in files: success = False try: plugin_file = open(root + '/plugin.json', 'r') data = json.load(plugin_file) plugin_file.close() success = True except: log.error(sys.exc_info()) print(sys.exc_info()) if success: mod_name = path_parts[base_ends] data['module_name'] = mod_name data['module_path'] = root self.available_plugins[mod_name] = data def enable_plugin(self, plugin_key, reload=False): """ Start one plugin: save data, replace required classes with plugin classes, load data. """ self.active_plugin_setup = self.load_plugin(plugin_key) if not self.active_plugin_setup: return self.clear_all() ctrl.disable_undo() if reload: available = [] for key in sys.modules: if key.startswith(plugin_key): available.append(key) if getattr(self.active_plugin_setup, 'reload_order', None): to_reload = [ x for x in self.active_plugin_setup.reload_order if x in available ] else: to_reload = sorted(available) for mod_name in to_reload: importlib.reload(sys.modules[mod_name]) print('reloaded ', mod_name) log.info('reloaded module %s' % mod_name) if hasattr(self.active_plugin_setup, 'plugin_parts'): for classobj in self.active_plugin_setup.plugin_parts: base_class = classes.find_base_model(classobj) if base_class: classes.add_mapping(base_class, classobj) m = "replacing %s with %s " % (base_class.__name__, classobj.__name__) else: m = "adding %s " % classobj.__name__ log.info(m) print(m) if hasattr(self.active_plugin_setup, 'help_file'): dir_path = os.path.dirname( os.path.realpath(self.active_plugin_setup.__file__)) print(dir_path) self.ui_manager.set_help_source(dir_path, self.active_plugin_setup.help_file) if hasattr(self.active_plugin_setup, 'start_plugin'): self.active_plugin_setup.start_plugin(self, ctrl, prefs) self.init_forest_keepers() ctrl.resume_undo() prefs.active_plugin_name = plugin_key def disable_current_plugin(self): """ Disable the current plugin and load the default trees instead. :param clear: if True, have empty treeset, if False, try to load default kataja treeset.""" if not self.active_plugin_setup: return ctrl.disable_undo() if hasattr(self.active_plugin_setup, 'tear_down_plugin'): self.active_plugin_setup.tear_down_plugin(self, ctrl, prefs) self.clear_all() # print(classes.base_name_to_plugin_class) # if hasattr(self.active_plugin_setup, 'plugin_parts'): # for classobj in self.active_plugin_setup.plugin_parts: # class_name = classobj.__name__ # if class_name: # log.info(f'removing {class_name}') # print(f'removing {class_name}') # classes.remove_class(class_name) classes.restore_default_classes() self.init_forest_keepers() ctrl.resume_undo() prefs.active_plugin_name = '' def load_plugin(self, plugin_module): setup = None importlib.invalidate_caches() if plugin_module in self.available_plugins: retry = True while retry: try: setup = importlib.import_module(plugin_module + ".setup") retry = False except: e = sys.exc_info() error_dialog = ErrorDialog(self) error_dialog.set_error( '%s, line %s\n%s: %s' % (plugin_module + ".setup.py", e[2].tb_lineno, e[0].__name__, e[1])) error_dialog.set_traceback(traceback.format_exc()) retry = error_dialog.exec_() setup = None return setup def install_plugins(self): """ If there are plugins defined in preferences to be used, activate them now. :return: None """ if prefs.active_plugin_name: log.info('Installing plugin %s...' % prefs.active_plugin_name) self.enable_plugin(prefs.active_plugin_name, reload=False) self.ui_manager.update_plugin_menu() def reset_preferences(self): """ :return: """ prefs.restore_default_preferences(qt_prefs, running_environment, classes) ctrl.call_watchers(self, 'color_themes_changed') if self.ui_manager.preferences_dialog: self.ui_manager.preferences_dialog.close() self.ui_manager.preferences_dialog = PreferencesDialog(self) self.ui_manager.preferences_dialog.open() self.ui_manager.preferences_dialog.trigger_all_updates() def init_forest_keepers(self): """ Put empty forest keepers (Kataja documents) in place -- you want to do this after plugins have changed the classes that implement these. :return: """ self.forest_keepers = [classes.get('KatajaDocument')()] self.forest_keeper = self.forest_keepers[0] ctrl.call_watchers(self.forest_keeper, 'document_changed') def load_initial_treeset(self): """ Loads and initializes a new set of trees. Has to be done before the program can do anything sane. """ self.forest_keeper.create_forests(clear=False) self.change_forest() self.ui_manager.update_projects_menu() def create_new_project(self): names = [fk.name for fk in self.forest_keepers] name_base = 'New project' name = 'New project' c = 1 while name in names: name = '%s %s' % (name_base, c) c += 1 self.forest.retire_from_drawing() self.forest_keepers.append(classes.KatajaDocument(name=name)) self.forest_keeper = self.forest_keepers[-1] ctrl.call_watchers(self.forest_keeper, 'document_changed') self.change_forest() self.ui_manager.update_projects_menu() return self.forest_keeper def switch_project(self, i): self.forest.retire_from_drawing() self.forest_keeper = self.forest_keepers[i] ctrl.call_watchers(self.forest_keeper, 'document_changed') self.change_forest() self.ui_manager.update_projects_menu() return self.forest_keeper # ### Visualization # ############################################################# def change_forest(self): """ Tells the scene to remove current trees and related data and change it to a new one. Signal 'forest_changed' is already sent by forest keeper. """ ctrl.disable_undo() if self.forest: self.forest.retire_from_drawing() if not self.forest_keeper.forest: self.forest_keeper.create_forests(clear=True) self.forest = self.forest_keeper.forest self.settings_manager.set_forest(self.forest) if self.forest.is_parsed: if self.forest.derivation_steps: ds = self.forest.derivation_steps if not ds.activated: print('jumping to derivation step: ', ds.derivation_step_index) ds.jump_to_derivation_step(ds.derivation_step_index) else: print('no derivation steps') self.forest.prepare_for_drawing() ctrl.resume_undo() #if self.forest.undo_manager. def redraw(self): """ Call for forest redraw :return: None """ self.forest.draw() def attach_widget_to_log_handler(self, browserwidget): """ This has to be done once: we have a logger set up before there is any output widget, once the widget is created it is connected to logger. :param browserwidget: :return: """ self.app.log_handler.set_widget(browserwidget) # def mousePressEvent(self, event): # """ KatajaMain doesn't do anything with mousePressEvents, it delegates # :param event: # them downwards. This is for debugging. """ # QtWidgets.QMainWindow.mousePressEvent(self, event) # def keyPressEvent(self, event): # # if not self.key_manager.receive_key_press(event): # """ # # :param event: # :return: # """ # return QtWidgets.QMainWindow.keyPressEvent(self, event) # ## Menu management ####################################################### def action_finished(self, m='', undoable=True, error=None): """ Write action to undo stack, report back to user and redraw trees if necessary :param m: message for undo :param undoable: are we supposed to take a snapshot of changes after this action. :param error message """ if error: log.error(error) elif m: log.info(m) if ctrl.action_redraw: ctrl.forest.draw() if undoable and not error: ctrl.forest.undo_manager.take_snapshot(m) ctrl.graph_scene.start_animations() ctrl.ui.update_actions() def trigger_action(self, name, *args, **kwargs): """ Helper for programmatically triggering actions (for tests and plugins) :param name: action name :param kwargs: keyword parameters :return: """ action = self.ui_manager.actions[name] action.action_triggered(*args, **kwargs) def trigger_but_suppress_undo(self, name, *args, **kwargs): """ Helper for programmatically triggering actions (for tests and plugins) :param name: action name :param kwargs: keyword parameters :return: """ action = self.ui_manager.actions[name] action.trigger_but_suppress_undo(*args, **kwargs) def enable_actions(self): """ Restores menus """ for action in self.ui_manager.actions.values(): action.setDisabled(False) def disable_actions(self): """ Actions shouldn't be initiated when there is other multi-phase action going on """ for action in self.ui_manager.actions.values(): action.setDisabled(True) def change_color_theme(self, mode, force=False): """ triggered by color mode selector in colors panel :param mode: """ if mode != ctrl.settings.get('color_theme') or force: if ctrl.settings.document: ctrl.settings.set('color_theme', mode, level=g.DOCUMENT) ctrl.settings.set('color_theme', mode, level=g.PREFS) self.update_colors() def timerEvent(self, event): """ Timer event only for printing, for 'snapshot' effect :param event: """ def find_path(fixed_part, extension, counter=0): """ Generate file names until free one is found :param fixed_part: blah :param extension: blah :param counter: blah """ if not counter: fpath = fixed_part + extension else: fpath = fixed_part + str(counter) + extension if os.path.exists(fpath): fpath = find_path(fixed_part, extension, counter + 1) return fpath if not self.print_started: return else: self.print_started = False self.killTimer(event.timerId()) # Prepare file and path path = prefs.print_file_path or prefs.userspace_path or \ running_environment.default_userspace_path if not path.endswith('/'): path += '/' if not os.path.exists(path): print("bad path for printing (print_file_path in preferences) , " "using '.' instead.") path = './' filename = prefs.print_file_name if filename.endswith(('.pdf', '.png')): filename = filename[:-4] # Prepare image self.graph_scene.removeItem(self.graph_scene.photo_frame) self.graph_scene.photo_frame = None # Prepare printer png = prefs.print_format == 'png' source = self.graph_scene.print_rect() if png: full_path = find_path(path + filename, '.png', 0) scale = 4 target = QtCore.QRectF(QtCore.QPointF(0, 0), source.size() * scale) writer = QtGui.QImage(target.size().toSize(), QtGui.QImage.Format_ARGB32_Premultiplied) writer.fill(QtCore.Qt.transparent) painter = QtGui.QPainter() painter.begin(writer) painter.setRenderHint(QtGui.QPainter.Antialiasing) self.graph_scene.render(painter, target=target, source=source) painter.end() iwriter = QtGui.QImageWriter(full_path) iwriter.write(writer) log.info( "printed to %s as PNG (%spx x %spx, %sx size)." % (full_path, int(target.width()), int(target.height()), scale)) else: dpi = 25.4 full_path = find_path(path + filename, '.pdf', 0) target = QtCore.QRectF(0, 0, source.width() / 2.0, source.height() / 2.0) writer = QtGui.QPdfWriter(full_path) writer.setResolution(dpi) writer.setPageSizeMM(target.size()) writer.setPageMargins(QtCore.QMarginsF(0, 0, 0, 0)) painter = QtGui.QPainter() painter.begin(writer) self.graph_scene.render(painter, target=target, source=source) painter.end() log.info("printed to %s as PDF with %s dpi." % (full_path, dpi)) # Thank you! # Restore image self.graph_scene.setBackgroundBrush(self.color_manager.gradient) # Not called from anywhere yet, but useful def release_selected(self, **kw): """ :param kw: :return: """ for node in ctrl.selected: node.release() self.action_finished() return True def clear_all(self): """ Empty everything - maybe necessary before loading new data. """ if self.forest: self.forest.retire_from_drawing() self.forest_keeper = None # Garbage collection doesn't mix well with animations that are still running #print('garbage stats:', gc.get_count()) #gc.collect() #print('after collection:', gc.get_count()) #if gc.garbage: # print('garbage:', gc.garbage) self.forest_keepers.append(classes.KatajaDocument(clear=True)) self.forest_keeper = self.forest_keepers[-1] self.settings_manager.set_document(self.forest_keeper) self.forest = None # ## Other window events ################################################### def closeEvent(self, event): """ Shut down the program, give some debug info :param event: """ QtWidgets.QMainWindow.closeEvent(self, event) if ctrl.print_garbage: # import objgraph log.debug('garbage stats: ' + str(gc.get_count())) gc.collect() log.debug('after collection: ' + str(gc.get_count())) if gc.garbage: log.debug('garbage: ' + str(gc.garbage)) # objgraph.show_most_common_types(limit =40) if self.save_prefs: prefs.save_preferences() log.info('...done') @time_me def create_save_data(self): """ Make a large dictionary of all objects with all of the complex stuff and circular references stripped out. :return: dict """ savedata = {} open_references = {} savedata['save_scheme_version'] = 0.4 self.save_object(savedata, open_references) max_rounds = 10 c = 0 while open_references and c < max_rounds: c += 1 #print(len(savedata)) #print('---------------------------') for obj in list(open_references.values()): if hasattr(obj, 'uid'): obj.save_object(savedata, open_references) else: print('cannot save open reference object ', obj) assert (c < max_rounds) print('total savedata: %s chars in %s items.' % (len(str(savedata)), len(savedata))) # print(savedata) return savedata def update_colors(self, randomise=False, animate=True): t = time.time() cm = self.color_manager old_gradient_base = cm.paper() cm.update_colors(randomise=randomise) self.app.setPalette(cm.get_qt_palette()) self.update_style_sheet() ctrl.call_watchers(self, 'palette_changed') if cm.gradient: if old_gradient_base != cm.paper() and animate: self.graph_scene.fade_background_gradient( old_gradient_base, cm.paper()) else: self.graph_scene.setBackgroundBrush(cm.gradient) else: self.graph_scene.setBackgroundBrush(qt_prefs.no_brush) self.update() ### Applying specific preferences globally. # These are on_change -methods for various preferences -- these are called if changing a # preference should have immediate consequences. They are hosted here because they need to # have access to prefs, qt_prefs, main etc. def prepare_easing_curve(self): qt_prefs.prepare_easing_curve(prefs.curve, prefs.move_frames) def update_color_theme(self): self.change_color_theme(prefs.color_theme, force=True) def update_visualization(self): ctrl.forest.set_visualization(prefs.visualization) self.main.redraw() def resize_ui_font(self): qt_prefs.toggle_large_ui_font(prefs.large_ui_text, prefs.fonts) self.update_style_sheet() def watch_alerted(self, obj, signal, field_name, value): """ Receives alerts from signals that this object has chosen to listen. These signals are declared in 'self.watchlist'. This method will try to sort out the received signals and act accordingly. :param obj: the object causing the alarm :param signal: identifier for type of the alarm :param field_name: name of the field of the object causing the alarm :param value: value given to the field :return: """ pass # ############## # # # # Save support # # # # ############## # forest_keeper = SavedField("forest_keeper") forest = SavedField("forest")
class FeatureNode(Node): """ Node to express a feature of a constituent """ __qt_type_id__ = next_available_type_id() width = 20 height = 20 node_type = FEATURE_NODE display_name = ('Feature', 'Features') display = True wraps = 'feature' editable = {'name': dict(name='Name', prefill='name', tooltip='Name of the feature, used as identifier', syntactic=True), 'value': dict(name='Value', prefill='value', tooltip='Value given to this feature', syntactic=True), 'family': dict(name='Family', prefill='family', tooltip='Several distinct features can be ' 'grouped under one family (e.g. ' 'phi-features)', syntactic=True) } default_style = {'fancy': {'color_id': 'accent2', 'font_id': g.SMALL_CAPS, 'font-size': 9}, 'plain': {'color_id': 'accent2', 'font_id': g.SMALL_CAPS, 'font-size': 9}} default_edge = g.FEATURE_EDGE def __init__(self, label='', value='', family=''): Node.__init__(self) self.name = label self.value = value self.family = family self.repulsion = 0.25 self._gravity = 2.5 self.z_value = 60 self.setZValue(self.z_value) # implement color() to map one of the d['rainbow_%'] colors here. Or if bw mode is on, then something else. @staticmethod def create_synobj(label, forest): """ FeatureNodes are wrappers for Features. Exact implementation/class of feature is defined in ctrl. :return: """ if not label: label = 'Feature' obj = ctrl.syntax.Feature(name=label) obj.after_init() return obj def compute_start_position(self, host): """ Makes features start at somewhat predictable position, if they are of common kinds of features. If not, then some random noise is added to prevent features sticking together :param host: """ x, y = host.current_position k = self.syntactic_object.key if k in color_map: x += color_map[k] y += color_map[k] else: x += random.uniform(-4, 4) y += random.uniform(-4, 4) self.set_original_position((x, y)) def compose_html_for_viewing(self): """ This method builds the html to display in label. For convenience, syntactic objects can override this (going against the containment logic) by having their own 'compose_html_for_viewing' -method. This is so that it is easier to create custom implementations for constituents without requiring custom constituentnodes. Note that synobj's compose_html_for_viewing receives the node object as parameter, so you can replicate the behavior below and add your own to it. :return: """ # Allow custom syntactic objects to override this if hasattr(self.syntactic_object, 'compose_html_for_viewing'): return self.syntactic_object.compose_html_for_viewing(self) return str(self), '' def compose_html_for_editing(self): """ This is used to build the html when quickediting a label. It should reduce the label into just one field value that is allowed to be edited, in constituentnode this is either label synobj's label. This can be overridden in syntactic object by having 'compose_html_for_editing' -method there. The method returns a tuple, (field_name, html). :return: """ # Allow custom syntactic objects to override this if hasattr(self.syntactic_object, 'compose_html_for_editing'): return self.syntactic_object.compose_html_for_editing(self) return 'name', self.compose_html_for_viewing()[0] def parse_quick_edit(self, text): """ This is an optional method for node to parse quick edit information into multiple fields. Usually nodes do without this: quickediting only changes one field at a time and interpretation is straightforward. E.g. features can have more complex parsing. :param text: :return: """ if hasattr(self.syntactic_object, 'parse_quick_edit'): return self.syntactic_object.parse_quick_edit(self, text) parts = text.split(':') name = '' value = '' family = '' if len(parts) >= 3: name, value, family = parts elif len(parts) == 2: name, value = parts elif len(parts) == 1: name = parts[0] if len(name) > 1 and name.startswith('u') and name[1].isupper(): name = name[1:] self.name = name self.value = value self.family = family def update_relations(self, parents, shape=None, position=None): """ Cluster features according to feature_positioning -setting or release them to be positioned according to visualisation. :param parents: list where we collect parent objects that need to position their children :return: """ if shape is None: shape = ctrl.settings.get('label_shape') if position is None: position = ctrl.settings.get('feature_positioning') if position or shape == g.CARD: for parent in self.get_parents(similar=False, visible=False): if parent.node_type == g.CONSTITUENT_NODE: if parent.is_visible(): self.lock_to_node(parent) parents.append(parent) break else: self.release_from_locked_position() elif parent.node_type == g.FEATURE_NODE: if self.locked_to_node == parent: self.release_from_locked_position() else: self.release_from_locked_position() def paint(self, painter, option, widget=None): """ Painting is sensitive to mouse/selection issues, but usually with :param painter: :param option: :param widget: nodes it is the label of the node that needs complex painting """ if ctrl.pressed == self or self._hovering or ctrl.is_selected(self): painter.setPen(ctrl.cm.get('background1')) painter.setBrush(self.contextual_background()) painter.drawRoundedRect(self.inner_rect, 5, 5) Node.paint(self, painter, option, widget) @property def contextual_color(self): """ Drawing color that is sensitive to node's state """ if ctrl.pressed == self: return ctrl.cm.get('background1') elif self._hovering: return ctrl.cm.get('background1') elif ctrl.is_selected(self): return ctrl.cm.get('background1') # return ctrl.cm.selected(ctrl.cm.selection()) else: if getattr(self.syntactic_object, 'unvalued', False): # fixme: Temporary hack return ctrl.cm.get('accent1') else: return self.color def contextual_background(self): """ Background color that is sensitive to node's state """ if ctrl.pressed == self: return ctrl.cm.active(ctrl.cm.selection()) elif self.drag_data: return ctrl.cm.hovering(ctrl.cm.selection()) elif self._hovering: return ctrl.cm.hovering(ctrl.cm.selection()) elif ctrl.is_selected(self): return ctrl.cm.selection() else: return qt_prefs.no_brush def special_connection_point(self, sx, sy, ex, ey, start=False): f_align = ctrl.settings.get('feature_positioning') br = self.boundingRect() left, top, right, bottom = (int(x * .8) for x in br.getCoords()) if f_align == 0: # direct if start: return (sx, sy), BOTTOM_SIDE else: return (ex, ey), BOTTOM_SIDE elif f_align == 1: # vertical if start: if sx < ex: return (sx + right, sy), RIGHT_SIDE else: return (sx + left, sy), LEFT_SIDE else: if sx < ex: return (ex + left, ey), LEFT_SIDE else: return (ex + right, ey), RIGHT_SIDE elif f_align == 2: # horizontal if start: if sy < ey: return (sx, sy + bottom), BOTTOM_SIDE else: return (sx, sy + top), TOP_SIDE else: if sy <= ey: return (ex, ey + top), TOP_SIDE else: return (ex, ey + bottom), BOTTOM_SIDE elif f_align == 3: # card if start: return (sx + right, sy), RIGHT_SIDE else: return (ex + left, ey), LEFT_SIDE if start: return (sx, sy), 0 else: return (ex, ey), 0 def __str__(self): if self.syntactic_object: return str(self.syntactic_object) s = [] signs = ('+', '-', '=', 'u', '✓') if self.value and (len(self.value) == 1 and self.value in signs or \ len(self.value) == 2 and self.value[1] in signs): s.append(self.value + str(self.name)) elif self.value or self.family: s.append(str(self.name)) s.append(str(self.value)) if self.family: s.append(str(self.family)) else: s.append(str(self.name)) return ":".join(s) # ############## # # # # Save support # # # # ############## # name = SavedField("name") value = SavedField("value") family = SavedField("family")
class CommentNode(Node): """ Node to display comments, annotations etc. syntactically inert information """ __qt_type_id__ = next_available_type_id() width = 20 height = 20 node_type = g.COMMENT_NODE display_name = ('Comment', 'Comments') display = True is_syntactic = False can_be_in_groups = False editable = { 'text': dict(name='', prefill='comment', tooltip='freeform text, invisible for processing', input_type='expandingtext') } default_style = { 'fancy': { 'color_id': 'accent4', 'font_id': g.MAIN_FONT, 'font-size': 14 }, 'plain': { 'color_id': 'accent4', 'font_id': g.MAIN_FONT, 'font-size': 14 } } default_edge = g.COMMENT_EDGE touch_areas_when_dragging = { g.DELETE_ARROW: { 'condition': 'dragging_my_arrow' }, g.TOUCH_CONNECT_COMMENT: { 'condition': 'dragging_comment' }, } touch_areas_when_selected = { g.DELETE_ARROW: { 'condition': 'has_arrow', 'action': 'delete_arrow' }, g.ADD_ARROW: { 'action': 'start_arrow_from_node' } } def __init__(self, label='comment'): self.image_object = None Node.__init__(self) if not label: label = 'comment' self.resizable = True self.label = label self.physics_x = False self.physics_y = False self.image_path = None self.image = None self.pos_relative_to_host = -50, -50 self.preferred_host = None def after_init(self): Node.after_init(self) if self.user_size: w, h = self.user_size self.set_user_size(w, h) @property def hosts(self): """ A comment can be associated with nodes. The association uses the general connect/disconnect mechanism, but 'hosts' is a shortcut to get the nodes. :return: list of Nodes """ return self.get_parents(visible=False, of_type=g.COMMENT_EDGE) @property def text(self): """ The text of the comment. Uses the generic node.label as storage. :return: str or ITextNode """ return self.label @text.setter def text(self, value): """ The text of the comment. Uses the generic node.label as storage. :param value: str or ITextNode """ self.label = value def has_arrow(self): return bool(self.edges_down) def set_image_path(self, pixmap_path): if pixmap_path and (self.image_path != pixmap_path or not self.image): self.image_path = pixmap_path self.image = QtGui.QPixmap() success = self.image.load(pixmap_path) if success: if self.image_object: print('removing old image object') self.image_object.hide() self.image_object.setParentItem(None) self.image_object = QtWidgets.QGraphicsPixmapItem( self.image, self) self.image_object.setPos(self.image.width() / -2, self.image.height() / -2) self.text = '' self.update_label_visibility() self.update_bounding_rect() else: self.image = None if self.image_object: print('removing old image object 2') self.image_object.hide() self.image_object.setParentItem(None) self.image_object = None def set_user_size(self, width, height): if width < 1 or height < 1: return self.user_size = (width, height) if self.image_object: scaled = self.image.scaled(width, height, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) self.image_object.prepareGeometryChange() self.image_object.setPixmap(scaled) self.image_object.setPos(-scaled.width() / 2, -scaled.height() / 2) # Update ui items around the label (or node hosting the label) ctrl.ui.update_position_for(self) elif self.label_object: self.label_object.resize_label() def dragging_my_arrow(self): return True def __str__(self): return 'comment: %s' % self.text def update_bounding_rect(self): """ :return: """ if self.image_object: my_class = self.__class__ if self.user_size is None: user_width, user_height = 0, 0 else: user_width, user_height = self.user_size lbr = self.image_object.boundingRect() lbw = lbr.width() lbh = lbr.height() lbx = self.image_object.x() lby = self.image_object.y() self.label_rect = QtCore.QRectF(lbx, lby, lbw, lbh) self.width = max((lbw, my_class.width, user_width)) self.height = max((lbh, my_class.height, user_height)) y = self.height / -2 x = self.width / -2 self.inner_rect = QtCore.QRectF(x, y, self.width, self.height) w4 = (self.width - 2) / 4.0 w2 = (self.width - 2) / 2.0 h2 = (self.height - 2) / 2.0 self._magnets = [(-w2, -h2), (-w4, -h2), (0, -h2), (w4, -h2), (w2, -h2), (-w2, 0), (w2, 0), (-w2, h2), (-w4, h2), (0, h2), (w4, h2), (w2, h2)] if ctrl.ui.selection_group and self in ctrl.ui.selection_group.selection: ctrl.ui.selection_group.update_shape() return self.inner_rect else: return super().update_bounding_rect() def paint(self, painter, option, widget=None): """ Painting is sensitive to mouse/selection issues, but usually with :param painter: :param option: :param widget: nodes it is the label of the node that needs complex painting """ if self.drag_data: p = QtGui.QPen(self.contextual_color) #b = QtGui.QBrush(ctrl.cm.paper()) #p.setColor(ctrl.cm.hover()) p.setWidth(1) painter.setPen(p) #painter.setBrush(self.drag_data.background) painter.drawRect(self.inner_rect) painter.setBrush(QtCore.Qt.NoBrush) elif self._hovering: p = QtGui.QPen(self.contextual_color) #p.setColor(ctrl.cm.hover()) p.setWidth(1) painter.setPen(p) painter.drawRect(self.inner_rect) elif ctrl.pressed is self or ctrl.is_selected(self): p = QtGui.QPen(self.contextual_color) p.setWidth(1) painter.setPen(p) painter.drawRect(self.inner_rect) elif self.has_empty_label() and self.node_alone(): p = QtGui.QPen(self.contextual_color) p.setStyle(QtCore.Qt.DotLine) p.setWidth(1) painter.setPen(p) painter.drawRect(self.inner_rect) def move(self, md): """ Override usual movement if comment is connected to some node. If so, try to keep the given position relative to that node. :return: """ if self.preferred_host: x, y = self.preferred_host.current_scene_position dx, dy = self.pos_relative_to_host self.current_position = self.scene_position_to_tree_position( (x + dx, y + dy)) return False, False else: return super().move(md) def drop_to(self, x, y, recipient=None): """ :param recipient: :param x: :param y: :return: action finished -message (str) """ message = super().drop_to(x, y, recipient=recipient) if self.preferred_host: x, y = self.preferred_host.current_scene_position mx, my = self.current_scene_position self.pos_relative_to_host = mx - x, my - y message = "Adjusted comment to be at %s, %s relative to '%s'" % ( mx - x, my - y, self.preferred_host) return message def on_connect(self, other): print('on_connect called, hosts:', self.hosts) if other in self.hosts: self.preferred_host = other x, y = other.current_scene_position mx, my = self.current_scene_position self.pos_relative_to_host = mx - x, my - y def on_disconnect(self, other): print('on_disconnect called') if other is self.preferred_host: for item in self.hosts: if item != other: self.preferred_host = item x, y = item.current_scene_position mx, my = self.current_scene_position self.pos_relative_to_host = mx - x, my - y return self.preferred_host = None def can_connect_with(self, other): return other not in self.hosts def dragging_comment(self): """ Check if the currently dragged item is comment and can connect with me :return: """ return self.is_dragging_this_type(g.COMMENT_NODE) # ############## # # # # Save support # # # # ############## # image_path = SavedField("image_path", if_changed=set_image_path)
class AttributeNode(Node): """ AttributeNodes can be used for diagnostics, to e.g. show properties of nodes that are not features and not participating in feature computation, but are otherwise interesting, e.g. weights. """ width = 20 height = 20 node_type = ATTRIBUTE_NODE display_name = ('Attribute', 'Attributes') display = False __qt_type_id__ = next_available_type_id() default_style = { 'fancy': { 'color_id': 'accent4', 'font_id': g.SMALL_CAPS, 'font-size': 10 }, 'plain': { 'color_id': 'accent4', 'font_id': g.SMALL_CAPS, 'font-size': 10 } } default_edge = g.ATTRIBUTE_EDGE def __init__(self, forest=None, host=None, attribute_id=None, label='', show_label=False, restoring=False): """ :param host: :param attribute_id: :param attribute_label: :param show_label: :param forest: :param restoring: :raise: """ self.help_text = "" Node.__init__(self) self.host = host self.attribute_label = label or attribute_id self.attribute_id = attribute_id self._show_label = show_label # if self.attribute_label in color_map: # self.color = colors.feature_palette[color_map[self.attribute_label]] # else: # self.color = colors.feature if not restoring: # compute start position -- similar to FeatureNode, but happens on init # because host is given x, y = self.host.current_position k = self.attribute_label if k in color_map: x += color_map[k] y += color_map[k] else: x += random.uniform(-4, 4) y += random.uniform(-4, 4) self.set_original_position((x, y)) self.update_help_text() self.update_label() self.update_bounding_rect() self.update_visibility() def update_help_text(self): """ """ if self.attribute_id == 'select_order': self.help_text = "'{host}' was Selected {value_ordinal} when constructing the trees." elif self.attribute_id == 'merge_order': self.help_text = "'{host}' was Merged {value_ordinal} when constructing the trees." def set_help_text(self, text): """ :param text: """ self.help_text = text self.update_status_tip() def update_status_tip(self): """ """ if self.help_text: self.status_tip = self.help_text.format(host=self.host, value=self.value, value_ordinal=ordinal( self.value), label=self.attribute_label) else: self.status_tip = "Attribute %s for %s" % ( self.get_html_for_label(), self.host) def get_html_for_label(self): """ This should be overridden if there are alternative displays for label :returns : str """ if self._show_label: return '%s:%s' % (self.attribute_label, self.value) else: return self.value # u'%s:%s' % (self.syntactic_object.key, self.syntactic_object.get_value_string()) @property def value(self): """ :return: """ val = getattr(self.host, self.attribute_id, '') if isinstance(val, collections.Callable): return val() else: return val def __str__(self): """ :returns : str """ return 'AttributeNode %s' % self.attribute_label # ############## # # # # Save support # # # # ############## # host = SavedField("host") attribute_label = SavedField("attribute_label") attribute_id = SavedField("attribute_id")
class Tree(Movable): """ Container for nodes that form a single trees. It allows operations that affect all nodes in one trees, e.g. translation of position. :param top: """ __qt_type_id__ = next_available_type_id() display_name = ('Tree', 'Trees') def __init__(self, top=None, numeration=False): Movable.__init__(self) self.top = top if is_constituent(top): self.sorted_constituents = [top] else: self.sorted_constituents = [] if top: self.sorted_nodes = [top] else: self.sorted_nodes = [] self.numeration = numeration self.current_position = 100, 100 self.drag_data = None self.tree_changed = True self._cached_bounding_rect = None self.setZValue(100) def __repr__(self): if self.numeration: suffix = " (numeration)" else: suffix = '' return "Tree '%s' and %s nodes.%s" % (self.top, len( self.sorted_nodes), suffix) def __contains__(self, item): return item in self.sorted_nodes def after_init(self): self.recalculate_top() self.update_items() self.announce_creation() def after_model_update(self, updated_fields, transition_type): """ Compute derived effects of updated values in sensible order. :param updated_fields: field keys of updates :param transition_type: 0:edit, 1:CREATED, -1:DELETED :return: None """ super().after_model_update(updated_fields, transition_type) if transition_type != g.DELETED: self.update_items() def rebuild(self): self.recalculate_top() self.update_items() def recalculate_top(self): """ Verify that self.top is the topmost element of the trees. Doesn't handle consequences, e.g. it may now be that there are two identical trees at the top and doesn't update the constituent and node lists. :return: new top """ passed = set() def walk_to_top(node): """ Recursive walk upwards :param node: :return: """ passed.add(node) for parent in node.get_parents(similar=False, visible=False): if parent not in passed: return walk_to_top(parent) return node if self.top: # hopefully it is a short walk self.top = walk_to_top(self.top) elif self.sorted_nodes: # take the long way if something strange has happened to top self.top = walk_to_top(self.sorted_nodes[-1]) else: self.top = None # hopefully this trees gets deleted. return self.top def is_empty(self): """ Empty trees should be deleted when found :return: """ return bool(self.top) def is_valid(self): """ If trees top has parents, the trees needs to be recalculated or it is otherwise unusable before fixed. :return: """ return not self.top.get_parents(similar=False, visible=False) def add_node(self, node): """ Add this node to given trees and possibly set it as parent for this graphicsitem. :param node: Node :return: """ node.poke('trees') node.trees.add(self) node.update_graphics_parent() def remove_node(self, node, recursive_down=False): """ Remove node from trees and remove the (graphicsitem) parenthood-relation. :param node: Node :param recursive_down: bool -- do recursively remove child nodes from tree too :return: """ if self in node.trees: node.poke('trees') node.trees.remove(self) node.update_graphics_parent() if recursive_down: for child in node.get_children(similar=False, visible=False): legit = False for parent in child.get_parents(similar=False, visible=False): if self in parent.trees: legit = True if not legit: self.remove_node(child, recursive_down=True) def add_to_numeration(self, node): def add_children(node): if node not in self.sorted_nodes: self.sorted_nodes.append(node) if self not in node.trees: self.add_node(node) for child in node.get_children(similar=False, visible=False): if child: # undoing object creation may cause missing edge ends add_children(child) add_children(node) @time_me def update_items(self): """ Check that all children of top item are included in this trees and create the sorted lists of items. Make sure there is a top item before calling this! :return: """ if self.numeration: to_be_removed = set() for item in self.sorted_nodes: for tree in item.trees: if tree is not self: to_be_removed.add(item) break for item in to_be_removed: self.remove_node(item) return sorted_constituents = [] sorted_nodes = [] used = set() def add_children(node): """ Add node to this trees. :param node: :return: """ if node not in used: used.add(node) if is_constituent(node): sorted_constituents.append(node) sorted_nodes.append(node) if self not in node.trees: self.add_node(node) for child in node.get_children(similar=False, visible=False): if child: # undoing object creation may cause missing edge ends add_children(child) old_nodes = set(self.sorted_nodes) if is_constituent(self.top): add_children(self.top) self.sorted_constituents = sorted_constituents for i, item in enumerate(self.sorted_constituents): item.z_value = 10 + i item.setZValue(item.z_value) self.sorted_nodes = sorted_nodes to_be_removed = old_nodes - set(sorted_nodes) for item in to_be_removed: self.remove_node(item) def is_higher_in_tree(self, node_a, node_b): """ Compare two nodes, if node_a is higher, return True. Return False if not. Return None if nodes are not in the same trees -- cannot compare. (Be careful with the result, handle None and False differently.) :param node_a: :param node_b: :return: """ if node_a in self and node_b in self: return self.sorted_nodes.index(node_a) < self.sorted_nodes.index( node_b) else: return None def start_dragging_tracking(self, host=False, scene_pos=None): """ Add this *Tree* to entourage of dragged nodes. These nodes will maintain their relative position to drag pointer while dragging. :return: None """ self.drag_data = TreeDragData(self, is_host=host, mousedown_scene_pos=scene_pos) def boundingRect(self): if self.tree_changed or not self._cached_bounding_rect: if not self.sorted_nodes: return QtCore.QRectF() min_x, min_y = 10000, 10000 max_x, max_y = -10000, -10000 for node in self.sorted_nodes: if node.is_visible() and not node.locked_to_node: nbr = node.future_children_bounding_rect() if node.physics_x or node.physics_y: x, y = node.x(), node.y() else: x, y = node.target_position x1, y1, x2, y2 = nbr.getCoords() x1 += x y1 += y x2 += x y2 += y if x1 < min_x: min_x = x1 if x2 > max_x: max_x = x2 if y1 < min_y: min_y = y1 if y2 > max_y: max_y = y2 self._cached_bounding_rect = QtCore.QRectF(min_x, min_y, max_x - min_x, max_y - min_y) self.tree_changed = False return self._cached_bounding_rect else: return self._cached_bounding_rect def current_scene_bounding_rect(self): if not self.sorted_nodes: return QtCore.QRectF() min_x, min_y = 10000, 10000 max_x, max_y = -10000, -10000 for node in self.sorted_nodes: if node.is_visible(): nbr = node.sceneBoundingRect() x1, y1, x2, y2 = nbr.getCoords() if x1 < min_x: min_x = x1 if x2 > max_x: max_x = x2 if y1 < min_y: min_y = y1 if y2 > max_y: max_y = y2 return QtCore.QRectF(min_x, min_y, max_x - min_x, max_y - min_y) # def normalize_positions(self): # print('tree normalising positions') # tx, ty = self.top.target_position # for node in self.sorted_constituents: # nx, ny = node.target_position # node.move_to(nx - tx, ny - ty) def paint(self, painter, QStyleOptionGraphicsItem, QWidget_widget=None): if self.numeration: # or True: br = self.boundingRect() painter.drawRect(br) #painter.drawText(br.topLeft() + QtCore.QPointF(2, 10), str(self)) top = SavedField("top")
class BaseConstituent(SavedObject, IConstituent): """ BaseConstituent is a default constituent used in syntax. IConstituent inherited here gives the abstract blueprint of what methods Constituents should implement -- it is an optional aid to validate that your Constituent is about ok. Constituents need to inherit SavedObject and use SavedFields for saving permanent data, otherwise structures won't get saved properly, undo and snapshots won't work. Object inheritance is not used to recognise what are constituent implementations and what are not, instead classes have attribute 'role' that tells what part they play in e.g. plugin. So your implementation of Constituent needs at least to have role = "Constituent", but not necessarily inherit BaseConstituent or IConstituent. However, often it is easisest just to inherit BaseConstituent and make minor modifications to it. """ # info for kataja engine syntactic_object = True role = "Constituent" editable = {} addable = { 'features': { 'condition': 'can_add_feature', 'add': 'add_feature', 'order': 20 } } # 'parts': {'check_before': 'can_add_part', 'add': 'add_part', 'order': 10}, def __init__(self, label='', parts=None, uid='', features=None, **kw): """ BaseConstituent is a default constituent used in syntax. It is Savable, which means that the actual values are stored in separate object that is easily dumped to file. Extending this needs to take account if new elements should also be treated as savable, e.g. put them into. and make necessary property and setter. """ SavedObject.__init__(self, **kw) self.label = label self.features = features or [] self.parts = parts or [] def __str__(self): return str(self.label) def __repr__(self): if self.is_leaf(): return 'Constituent(id=%s)' % self.label else: return "[ %s ]" % (' '.join((x.__repr__() for x in self.parts))) def __contains__(self, c): if self == c: return True for part in self.parts: if c in part: return True else: return False def get_features(self): """ Getter for features, redundant for BaseConstituent (you could use c.features ) but it is better to use this consistently for compatibility with other implementations for constituent. :return: """ return self.features def get_parts(self): """ Getter for parts, redundant for BaseConstituent (you could use c.parts ) but it is better to use this consistently for compatibility with other implementations for constituent. :return: """ return self.parts def print_tree(self): """ Bracket trees representation of the constituent structure. Now it is same as str(self). :return: str """ return self.__repr__() def can_add_part(self, **kw): """ :param kw: :return: """ return True def add_part(self, new_part): """ Add constitutive part to this constituent (append to parts) :param new_part: """ self.poke('parts') self.parts.append(new_part) def insert_part(self, new_part, index=0): """ Insert constitutive part to front of the parts list. Usefulness depends on the linearization method. :param new_part: :param index: :return: """ self.poke('parts') self.parts.insert(index, new_part) def remove_part(self, part): """ Remove constitutive part :param part: """ self.poke('parts') self.parts.remove(part) def replace_part(self, old_part, new_part): """ :param old_part: :param new_part: :return: """ if old_part in self.parts: i = self.parts.index(old_part) self.poke('parts') self.parts[i] = new_part else: raise IndexError @property def is_ordered(self): return False def get_feature(self, key): """ Gets the first local feature (within this constituent, not of its children) with key 'key' :param key: string for identifying feature type :return: feature object """ for f in self.features: if f.name == key: return f def get_secondary_label(self): """ Visualisation can switch between showing labels and some other information in label space. If you want to support this, have "support_secondary_labels = True" in SyntaxConnection and provide something from this getter. :return: """ raise NotImplementedError def has_feature(self, key): """ Check the existence of feature within this constituent :param key: string for identifying feature type or Feature instance :return: bool """ if isinstance(key, BaseFeature): return key in self.features else: return bool(self.get_feature(key)) def add_feature(self, feature): """ Add an existing Feature object to this constituent. :param feature: :return: """ if isinstance(feature, BaseFeature): self.poke('features') self.features.append(feature) else: raise TypeError def remove_feature(self, name): """ Remove feature from a constituent. It's not satisfied, it is just gone. :param fname: str, the name for finding the feature or for convenience, a feature instance to be removed """ if isinstance(name, BaseFeature): if name in self.features: self.poke('features') self.features.remove(name) else: for f in list(self.features): if f.name == name: self.poke('features') self.features.remove(f) def is_leaf(self): """ Check if the constituent is leaf constituent (no children) or inside a trees (has children). :return: bool """ return not self.parts def ordered_parts(self): """ Tries to do linearization between two elements according to theory being used. Easiest, default case is to just store the parts as a list and return the list in its original order. This is difficult to justify theoretically, though. :return: len 2 list of ordered nodes, or empty list if cannot be ordered. """ ordering_method = 1 if ordering_method == 1: return list(self.parts) def copy(self): """ Make a deep copy of constituent. Useful for picking constituents from Lexicon. :return: BaseConstituent """ new_parts = [] for part in self.parts: new = part.copy() new_parts.append(new) new_features = self.features.copy() nc = self.__class__(label=self.label, parts=new_parts, features=new_features) return nc # ############## # # # # Save support # # # # ############## # features = SavedField("features") sourcestring = SavedField("sourcestring") label = SavedField("label") parts = SavedField("parts")