Undo support for begin-user-action and end-user-action

Allows keeping track of multiple changes within a single action together.
github/fork/yochananmarqos/patch-1
Gonçalo Silva 2019-06-22 03:38:39 +01:00
parent d9014b12e7
commit e3b99e823b
2 changed files with 149 additions and 128 deletions

View File

@ -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)

View File

@ -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)