forked from Mirrors/apostrophe
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
parent
d9014b12e7
commit
e3b99e823b
|
@ -70,6 +70,8 @@ class TextView(Gtk.TextView):
|
||||||
|
|
||||||
# Undo / redo
|
# Undo / redo
|
||||||
self.undo_redo = UndoRedoHandler()
|
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('insert-text', self.undo_redo.on_insert_text)
|
||||||
self.get_buffer().connect('delete-range', self.undo_redo.on_delete_range)
|
self.get_buffer().connect('delete-range', self.undo_redo.on_delete_range)
|
||||||
self.connect('undo', self.undo_redo.undo)
|
self.connect('undo', self.undo_redo.undo)
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger('uberwriter')
|
||||||
|
|
||||||
|
|
||||||
class UndoableInsert:
|
class UndoableInsert:
|
||||||
"""Something has been inserted into text_buffer"""
|
"""Something has been inserted into text_buffer"""
|
||||||
|
|
||||||
|
@ -7,6 +12,41 @@ class UndoableInsert:
|
||||||
self.length = length
|
self.length = length
|
||||||
self.mergeable = not bool(self.length > 1 or self.text in ("\r", "\n", " "))
|
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:
|
class UndoableDelete:
|
||||||
"""Something has been deleted from text_buffer"""
|
"""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.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", " "))
|
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:
|
class UndoRedoHandler:
|
||||||
"""Manages undo/redo for a given text_buffer.
|
"""Manages undo/redo for a given text_buffer.
|
||||||
|
@ -29,7 +130,7 @@ class UndoRedoHandler:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.undo_stack = []
|
self.undo_stack = []
|
||||||
self.redo_stack = []
|
self.redo_stack = []
|
||||||
self.not_undoable_action = False
|
self.current_undo_group = None
|
||||||
self.undo_in_progress = False
|
self.undo_in_progress = False
|
||||||
|
|
||||||
def undo(self, text_view, _data=None):
|
def undo(self, text_view, _data=None):
|
||||||
|
@ -39,28 +140,10 @@ class UndoRedoHandler:
|
||||||
|
|
||||||
if not self.undo_stack:
|
if not self.undo_stack:
|
||||||
return
|
return
|
||||||
self.__begin_not_undoable_action()
|
|
||||||
self.undo_in_progress = True
|
self.undo_in_progress = True
|
||||||
undo_action = self.undo_stack.pop()
|
undo_action = self.undo_stack.pop()
|
||||||
self.redo_stack.append(undo_action)
|
self.redo_stack.append(undo_action)
|
||||||
text_buffer = text_view.get_buffer()
|
undo_action.undo(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()
|
|
||||||
self.undo_in_progress = False
|
self.undo_in_progress = False
|
||||||
|
|
||||||
def redo(self, text_view, _data=None):
|
def redo(self, text_view, _data=None):
|
||||||
|
@ -70,135 +153,71 @@ class UndoRedoHandler:
|
||||||
|
|
||||||
if not self.redo_stack:
|
if not self.redo_stack:
|
||||||
return
|
return
|
||||||
self.__begin_not_undoable_action()
|
|
||||||
self.undo_in_progress = True
|
self.undo_in_progress = True
|
||||||
redo_action = self.redo_stack.pop()
|
redo_action = self.redo_stack.pop()
|
||||||
self.undo_stack.append(redo_action)
|
self.undo_stack.append(redo_action)
|
||||||
text_buffer = text_view.get_buffer()
|
redo_action.redo(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()
|
|
||||||
self.undo_in_progress = False
|
self.undo_in_progress = False
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
self.undo_stack = []
|
self.undo_stack = []
|
||||||
self.redo_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):
|
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):
|
self.__record_undoable(UndoableInsert(text_iter, text, len(text)))
|
||||||
"""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)
|
|
||||||
|
|
||||||
def on_delete_range(self, text_buffer, start_iter, end_iter):
|
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):
|
self.__record_undoable(UndoableDelete(text_buffer, start_iter, end_iter))
|
||||||
"""Check if multiple deletions can be merged
|
|
||||||
|
|
||||||
can't merge if prev and cur are not mergeable in the first place
|
def __record_undoable(self, undoable):
|
||||||
can't merge if delete and backspace key were both used
|
"""Records a change, merging it to a previous one if possible."""
|
||||||
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
|
|
||||||
|
|
||||||
if not self.undo_in_progress:
|
if not self.undo_in_progress:
|
||||||
self.redo_stack = []
|
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:
|
else:
|
||||||
self.undo_stack.append(prev_delete)
|
return
|
||||||
self.undo_stack.append(undo_action)
|
|
||||||
|
|
||||||
def __begin_not_undoable_action(self):
|
prev_group_undoable = self.current_undo_group[-1] if self.current_undo_group else None
|
||||||
"""Toggle to stop recording actions"""
|
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):
|
if not merged:
|
||||||
"""Toggle to start recording actions"""
|
if self.current_undo_group is None:
|
||||||
|
LOGGER.warning("Recording a change without a user action.")
|
||||||
self.not_undoable_action = False
|
self.undo_stack.append(undoable)
|
||||||
|
else:
|
||||||
|
self.current_undo_group.append(undoable)
|
||||||
|
|
Loading…
Reference in New Issue