From 6a26f7b87337cefc29f26f3de3517dd71af553de Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 16 Aug 2010 11:44:26 +0000 Subject: [PATCH] Added UndoableBuffer for notes that allows undo (control+z) and redo (control+shift+z) svn: r15751 --- src/gui/widgets/Makefile.am | 1 + src/gui/widgets/styledtextbuffer.py | 11 +- src/gui/widgets/styledtexteditor.py | 10 ++ src/gui/widgets/undoablebuffer.py | 248 ++++++++++++++++++++++++++++ 4 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 src/gui/widgets/undoablebuffer.py diff --git a/src/gui/widgets/Makefile.am b/src/gui/widgets/Makefile.am index c64c85291..9a96ada61 100644 --- a/src/gui/widgets/Makefile.am +++ b/src/gui/widgets/Makefile.am @@ -22,6 +22,7 @@ pkgdata_PYTHON = \ styledtextbuffer.py \ styledtexteditor.py \ toolcomboentry.py \ + undoablebuffer.py \ validatedcomboentry.py \ validatedmaskedentry.py \ valueaction.py \ diff --git a/src/gui/widgets/styledtextbuffer.py b/src/gui/widgets/styledtextbuffer.py index 43704c257..277567fd3 100644 --- a/src/gui/widgets/styledtextbuffer.py +++ b/src/gui/widgets/styledtextbuffer.py @@ -43,6 +43,7 @@ _LOG = logging.getLogger(".widgets.styledtextbuffer") #------------------------------------------------------------------------- import gobject import gtk +from gui.widgets.undoablebuffer import UndoableBuffer from pango import WEIGHT_BOLD, STYLE_ITALIC, UNDERLINE_SINGLE #------------------------------------------------------------------------- @@ -225,7 +226,7 @@ class GtkSpellState(object): # StyledTextBuffer class # #------------------------------------------------------------------------- -class StyledTextBuffer(gtk.TextBuffer): +class StyledTextBuffer(UndoableBuffer): """An extended TextBuffer for handling StyledText strings. StyledTextBuffer is an interface between GRAMPS' L{StyledText} format @@ -256,7 +257,7 @@ class StyledTextBuffer(gtk.TextBuffer): } def __init__(self): - gtk.TextBuffer.__init__(self) + super(StyledTextBuffer, self).__init__() # Create fix tags. # Other tags (e.g. color) have to be created on the fly @@ -327,7 +328,7 @@ class StyledTextBuffer(gtk.TextBuffer): def do_changed(self): """Parse for patterns in the text.""" self.matches = [] - text = unicode(gtk.TextBuffer.get_text(self, + text = unicode(super(StyledTextBuffer, self).get_text( self.get_start_iter(), self.get_end_iter())) for regex, flavor in self.patterns: @@ -547,7 +548,7 @@ class StyledTextBuffer(gtk.TextBuffer): @note: 's_' prefix means StyledText*, while 'g_' prefix means gtk.*. """ - gtk.TextBuffer.set_text(self, str(s_text)) + super(StyledTextBuffer, self).set_text(str(s_text)) s_tags = s_text.get_tags() for s_tag in s_tags: @@ -574,7 +575,7 @@ class StyledTextBuffer(gtk.TextBuffer): if end is None: end = self.get_end_iter() - txt = gtk.TextBuffer.get_text(self, start, end, include_hidden_chars) + txt = super(StyledTextBuffer, self).get_text(start, end, include_hidden_chars) txt = unicode(txt) # extract tags out of the buffer diff --git a/src/gui/widgets/styledtexteditor.py b/src/gui/widgets/styledtexteditor.py index d1b7632b5..ba18e75b8 100644 --- a/src/gui/widgets/styledtexteditor.py +++ b/src/gui/widgets/styledtexteditor.py @@ -412,6 +412,8 @@ class StyledTextEditor(gtk.TextView): _('Bold'), self._on_toggle_action_activate), (str(StyledTextTagType.UNDERLINE), gtk.STOCK_UNDERLINE, None, None, _('Underline'), self._on_toggle_action_activate), + ("Undo", gtk.STOCK_UNDO, None, None, _('Undo'), self.undo), + ("Redo", gtk.STOCK_REDO, None, None, _('Redo'), self.redo), ] self.toggle_actions = [action[0] for action in format_toggle_actions] @@ -461,6 +463,8 @@ class StyledTextEditor(gtk.TextView): 'i': str(StyledTextTagType.ITALIC), 'b': str(StyledTextTagType.BOLD), 'u': str(StyledTextTagType.UNDERLINE), + 'z' : "Undo", + 'z': "Redo", } # create the action group and insert all the actions @@ -731,6 +735,12 @@ class StyledTextEditor(gtk.TextView): """ return self.toolbar + def undo(self, obj): + self.textbuffer.undo() + + def redo(self, obj): + self.textbuffer.redo() + def uri_dialog(self, uri, callback): """ Function to spawn the link editor. diff --git a/src/gui/widgets/undoablebuffer.py b/src/gui/widgets/undoablebuffer.py new file mode 100644 index 000000000..527df1095 --- /dev/null +++ b/src/gui/widgets/undoablebuffer.py @@ -0,0 +1,248 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2009 Florian Heinle +# Copyright (C) 2010 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# + +# $Id: $ + +""" +gtk textbuffer with undo functionality +""" + +# Originally LGLP from: +# http://bitbucket.org/tiax/gtk-textbuffer-with-undo/ +# Please send bugfixes and comments upstream to Florian + +import gtk + +class UndoableInsert(object): + """something that has been inserted into our textbuffer""" + def __init__(self, text_iter, text, length): + self.offset = text_iter.get_offset() + # FIXME: GRAMPS change: force to use string rather than + # StyledText + self.text = str(text) + self.length = length + if self.length > 1 or self.text in ("\r", "\n", " "): + self.mergeable = False + else: + self.mergeable = True + +class UndoableDelete(object): + """something that has ben deleted from our textbuffer""" + def __init__(self, text_buffer, start_iter, end_iter): + # FIXME: GRAMPS change: force to use string rather than + # StyledText + self.text = str(text_buffer.get_text(start_iter, end_iter)) + self.start = start_iter.get_offset() + self.end = end_iter.get_offset() + # need to find out if backspace or delete key has been used + # so we don't mess up during redo + insert_iter = text_buffer.get_iter_at_mark(text_buffer.get_insert()) + if insert_iter.get_offset() <= self.start: + self.delete_key_used = True + else: + self.delete_key_used = False + if self.end - self.start > 1 or self.text in ("\r", "\n", " "): + self.mergeable = False + else: + self.mergeable = True + +class UndoableBuffer(gtk.TextBuffer): + """text buffer with added undo capabilities + + designed as a drop-in replacement for gtksourceview, + at least as far as undo is concerned""" + + def __init__(self): + """ + we'll need empty stacks for undo/redo and some state keeping + """ + gtk.TextBuffer.__init__(self) + self.undo_stack = [] + self.redo_stack = [] + self.not_undoable_action = False + self.undo_in_progress = False + self.connect('insert-text', self.on_insert_text) + self.connect('delete-range', self.on_delete_range) + + @property + def can_undo(self): + return bool(self.undo_stack) + + @property + def can_redo(self): + return bool(self.redo_stack) + + def on_insert_text(self, textbuffer, text_iter, text, length): + def can_be_merged(prev, cur): + """see if we can merge multiple inserts here + + will try to merge words or whitespace + 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 + elif cur.offset != (prev.offset + prev.length): + return False + elif cur.text in WHITESPACE and not prev.text in WHITESPACE: + return False + elif prev.text in WHITESPACE and not cur.text 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, length) + 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 can_be_merged(prev, cur): + """see if we can merge multiple deletions here + + will try to merge words or whitespace + 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 + elif prev.delete_key_used != cur.delete_key_used: + return False + elif prev.start != cur.start and prev.start != cur.end: + return False + elif cur.text not in WHITESPACE and \ + prev.text in WHITESPACE: + return False + elif cur.text in WHITESPACE and \ + prev.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 = 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) + + def begin_not_undoable_action(self): + """don't record the next actions + + toggles self.not_undoable_action""" + self.not_undoable_action = True + + def end_not_undoable_action(self): + """record next actions + + toggles self.not_undoable_action""" + self.not_undoable_action = False + + def undo(self): + """undo inserts or deletions + + undone actions are being moved to redo stack""" + 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) + if isinstance(undo_action, UndoableInsert): + start = self.get_iter_at_offset(undo_action.offset) + stop = self.get_iter_at_offset( + undo_action.offset + undo_action.length + ) + self.delete(start, stop) + self.place_cursor(start) + else: + start = self.get_iter_at_offset(undo_action.start) + self.insert(start, undo_action.text) + stop = self.get_iter_at_offset(undo_action.end) + if undo_action.delete_key_used: + self.place_cursor(start) + else: + self.place_cursor(stop) + self.end_not_undoable_action() + self.undo_in_progress = False + + def redo(self): + """redo inserts or deletions + + redone actions are moved to undo stack""" + 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) + if isinstance(redo_action, UndoableInsert): + start = self.get_iter_at_offset(redo_action.offset) + self.insert(start, redo_action.text) + new_cursor_pos = self.get_iter_at_offset( + redo_action.offset + redo_action.length + ) + self.place_cursor(new_cursor_pos) + else: + start = self.get_iter_at_offset(redo_action.start) + stop = self.get_iter_at_offset(redo_action.end) + self.delete(start, stop) + self.place_cursor(start) + self.end_not_undoable_action() + self.undo_in_progress = False