diff --git a/uberwriter/text_view.py b/uberwriter/text_view.py index 503fc99..7f975e8 100644 --- a/uberwriter/text_view.py +++ b/uberwriter/text_view.py @@ -70,6 +70,8 @@ class TextView(Gtk.TextView): # Undo / redo self.undo_redo = UndoRedoHandler() + self.get_buffer().connect('begin-user-action', self.undo_redo.on_begin_user_action) + self.get_buffer().connect('end-user-action', self.undo_redo.on_end_user_action) self.get_buffer().connect('insert-text', self.undo_redo.on_insert_text) self.get_buffer().connect('delete-range', self.undo_redo.on_delete_range) self.connect('undo', self.undo_redo.undo) diff --git a/uberwriter/text_view_undo_redo_handler.py b/uberwriter/text_view_undo_redo_handler.py index f91903c..ac15bc2 100644 --- a/uberwriter/text_view_undo_redo_handler.py +++ b/uberwriter/text_view_undo_redo_handler.py @@ -1,3 +1,8 @@ +import logging + +LOGGER = logging.getLogger('uberwriter') + + class UndoableInsert: """Something has been inserted into text_buffer""" @@ -7,6 +12,41 @@ class UndoableInsert: self.length = length self.mergeable = not bool(self.length > 1 or self.text in ("\r", "\n", " ")) + def undo(self, text_buffer): + offset = self.offset + start = text_buffer.get_iter_at_offset(offset) + stop = text_buffer.get_iter_at_offset(offset + self.length) + text_buffer.place_cursor(start) + text_buffer.delete(start, stop) + + def redo(self, text_buffer): + start = text_buffer.get_iter_at_offset(self.offset) + text_buffer.insert(start, self.text) + new_cursor_pos = text_buffer.get_iter_at_offset(self.offset + self.length) + text_buffer.place_cursor(new_cursor_pos) + + def merge(self, next_action): + """Merge a following action into this insert, if possible + + can't merge if prev is not another insert + can't merge if prev and cur are not mergeable in the first place + can't merge when user set the input bar somewhere else + can't merge across word boundaries""" + + if not isinstance(next_action, UndoableInsert): + return False + if not self.mergeable or not next_action.mergeable: + return False + if self.offset + self.length != next_action.offset: + return False + whitespace = (' ', '\t') + if self.text in whitespace != next_action.text in whitespace: + return False + + self.length += next_action.length + self.text += next_action.text + return True + class UndoableDelete: """Something has been deleted from text_buffer""" @@ -20,6 +60,67 @@ class UndoableDelete: self.delete_key_used = bool(insert_iter.get_offset() <= self.start) self.mergeable = not bool(self.end - self.start > 1 or self.text in ("\r", "\n", " ")) + def undo(self, text_buffer): + start = text_buffer.get_iter_at_offset(self.start) + text_buffer.insert(start, self.text) + if self.delete_key_used: + text_buffer.place_cursor(start) + else: + stop = text_buffer.get_iter_at_offset(self.end) + text_buffer.place_cursor(stop) + + def redo(self, text_buffer): + start = text_buffer.get_iter_at_offset(self.start) + stop = text_buffer.get_iter_at_offset(self.end) + text_buffer.delete(start, stop) + text_buffer.place_cursor(start) + + def merge(self, next_action): + """Check if this delete can be merged with a previous action + + can't merge if prev is not another delete + can't merge if prev and cur are not mergeable in the first place + can't merge if delete and backspace key were both used + can't merge across word boundaries""" + + if not isinstance(next_action, UndoableDelete): + return False + if not self.mergeable or not next_action.mergeable: + return False + if self.delete_key_used != next_action.delete_key_used: + return False + if self.start != next_action.start and self.start != next_action.end: + return False + whitespace = (' ', '\t') + if self.text in whitespace != next_action.text in whitespace: + return False + + if self.delete_key_used: + self.text += next_action.text + self.end += (next_action.end - next_action.start) + else: + self.text = "%s%s" % (next_action.text, next_action.text) + self.start = next_action.start + return True + + +class UndoableGroup(list): + """A list of undoable actions, usually corresponding to a single user action""" + + def undo(self, text_buffer): + for undoable in reversed(self): + undoable.undo(text_buffer) + + def redo(self, text_buffer): + for undoable in self: + undoable.redo(text_buffer) + + def merge(self, next_action): + if len(self) == 1: + return self[0].merge(next_action) + else: + return False + class UndoRedoHandler: """Manages undo/redo for a given text_buffer. @@ -29,7 +130,7 @@ class UndoRedoHandler: def __init__(self): self.undo_stack = [] self.redo_stack = [] - self.not_undoable_action = False + self.current_undo_group = None self.undo_in_progress = False def undo(self, text_view, _data=None): @@ -39,28 +140,10 @@ class UndoRedoHandler: if not self.undo_stack: return - self.__begin_not_undoable_action() self.undo_in_progress = True undo_action = self.undo_stack.pop() self.redo_stack.append(undo_action) - text_buffer = text_view.get_buffer() - if isinstance(undo_action, UndoableInsert): - offset = undo_action.offset - start = text_buffer.get_iter_at_offset(offset) - stop = text_buffer.get_iter_at_offset( - offset + undo_action.length - ) - text_buffer.place_cursor(start) - text_buffer.delete(start, stop) - else: - start = text_buffer.get_iter_at_offset(undo_action.start) - text_buffer.insert(start, undo_action.text) - if undo_action.delete_key_used: - text_buffer.place_cursor(start) - else: - stop = text_buffer.get_iter_at_offset(undo_action.end) - text_buffer.place_cursor(stop) - self.__end_not_undoable_action() + undo_action.undo(text_view.get_buffer()) self.undo_in_progress = False def redo(self, text_view, _data=None): @@ -70,135 +153,71 @@ class UndoRedoHandler: if not self.redo_stack: return - self.__begin_not_undoable_action() self.undo_in_progress = True redo_action = self.redo_stack.pop() self.undo_stack.append(redo_action) - text_buffer = text_view.get_buffer() - if isinstance(redo_action, UndoableInsert): - start = text_buffer.get_iter_at_offset(redo_action.offset) - text_buffer.insert(start, redo_action.text) - new_cursor_pos = text_buffer.get_iter_at_offset( - redo_action.offset + redo_action.length) - text_buffer.place_cursor(new_cursor_pos) - else: - start = text_buffer.get_iter_at_offset(redo_action.start) - stop = text_buffer.get_iter_at_offset(redo_action.end) - text_buffer.delete(start, stop) - text_buffer.place_cursor(start) - self.__end_not_undoable_action() + redo_action.redo(text_view.get_buffer()) self.undo_in_progress = False def clear(self): self.undo_stack = [] self.redo_stack = [] + def on_begin_user_action(self, _text_buffer): + """Start of a user action. Refer to TextBuffer's "begin-user-action" signal. + + This method must be registered to TextBuffer's "begin-user-action" signal, or called + manually followed by on_end_user_action.""" + + self.current_undo_group = UndoableGroup() + + def on_end_user_action(self, _text_buffer): + """End of a user action. Refer to TextBuffer's "end-user-action" signal. + + This method must be registered to TextBuffer's "end-user-action" signal, or called + manually preceded by on_start_user_action.""" + + if self.current_undo_group: + self.undo_stack.append(self.current_undo_group) + self.current_undo_group = None + def on_insert_text(self, _text_buffer, text_iter, text, _length): - """Registers a text insert. Refer to TextBuffer's "insert-text" signal. + """Records a text insert. Refer to TextBuffer's "insert-text" signal. - This method must be registered to TextBuffer's "insert-text" signal, or called manually.""" + This method must be registered to TextBuffer's "insert-text" signal, or called manually + in between on_begin_user_action and on_end_user_action.""" - def can_be_merged(prev, cur): - """Check if multiple insertions can be merged - - can't merge if prev and cur are not mergeable in the first place - can't merge when user set the input bar somewhere else - can't merge across word boundaries""" - - whitespace = (' ', '\t') - if not cur.mergeable or not prev.mergeable: - return False - if cur.offset != (prev.offset + prev.length): - return False - if cur.text in whitespace and prev.text not in whitespace: - return False - if prev.text in whitespace and cur.text not in whitespace: - return False - return True - - if not self.undo_in_progress: - self.redo_stack = [] - if self.not_undoable_action: - return - - undo_action = UndoableInsert(text_iter, text, len(text)) - try: - prev_insert = self.undo_stack.pop() - except IndexError: - self.undo_stack.append(undo_action) - return - if not isinstance(prev_insert, UndoableInsert): - self.undo_stack.append(prev_insert) - self.undo_stack.append(undo_action) - return - if can_be_merged(prev_insert, undo_action): - prev_insert.length += undo_action.length - prev_insert.text += undo_action.text - self.undo_stack.append(prev_insert) - else: - self.undo_stack.append(prev_insert) - self.undo_stack.append(undo_action) + self.__record_undoable(UndoableInsert(text_iter, text, len(text))) def on_delete_range(self, text_buffer, start_iter, end_iter): - """Registers a range deletion. Refer to TextBuffer's "delete-range" signal. + """Records a range deletion. Refer to TextBuffer's "delete-range" signal. - This method must be registered to TextBuffer's "delete-range" signal, or called manually.""" + This method must be registered to TextBuffer's "delete-range" signal, or called manually + in between on_begin_user_action and on_end_user_action.""" - def can_be_merged(prev, cur): - """Check if multiple deletions can be merged + self.__record_undoable(UndoableDelete(text_buffer, start_iter, end_iter)) - can't merge if prev and cur are not mergeable in the first place - can't merge if delete and backspace key were both used - can't merge across word boundaries""" - - whitespace = (' ', '\t') - if not cur.mergeable or not prev.mergeable: - return False - if prev.delete_key_used != cur.delete_key_used: - return False - if prev.start != cur.start and prev.start != cur.end: - return False - if cur.text not in whitespace and \ - prev.text in whitespace: - return False - if cur.text in whitespace and \ - prev.text not in whitespace: - return False - return True + def __record_undoable(self, undoable): + """Records a change, merging it to a previous one if possible.""" if not self.undo_in_progress: self.redo_stack = [] - if self.not_undoable_action: - return - undo_action = UndoableDelete(text_buffer, start_iter, end_iter) - try: - prev_delete = self.undo_stack.pop() - except IndexError: - self.undo_stack.append(undo_action) - return - if not isinstance(prev_delete, UndoableDelete): - self.undo_stack.append(prev_delete) - self.undo_stack.append(undo_action) - return - if can_be_merged(prev_delete, undo_action): - if prev_delete.start == undo_action.start: # delete key used - prev_delete.text += undo_action.text - prev_delete.end += (undo_action.end - undo_action.start) - else: # Backspace used - prev_delete.text = "%s%s" % (undo_action.text, - prev_delete.text) - prev_delete.start = undo_action.start - self.undo_stack.append(prev_delete) else: - self.undo_stack.append(prev_delete) - self.undo_stack.append(undo_action) + return - def __begin_not_undoable_action(self): - """Toggle to stop recording actions""" + prev_group_undoable = self.current_undo_group[-1] if self.current_undo_group else None + prev_stack_undoable = self.undo_stack[-1] if self.undo_stack else None - self.not_undoable_action = True + if prev_group_undoable: + merged = prev_group_undoable.merge(undoable) + elif prev_stack_undoable: + merged = prev_stack_undoable.merge(undoable) + else: + merged = False - def __end_not_undoable_action(self): - """Toggle to start recording actions""" - - self.not_undoable_action = False + if not merged: + if self.current_undo_group is None: + LOGGER.warning("Recording a change without a user action.") + self.undo_stack.append(undoable) + else: + self.current_undo_group.append(undoable)