forked from Mirrors/apostrophe
493 lines
16 KiB
Python
493 lines
16 KiB
Python
### BEGIN LICENSE
|
|
# Copyright (C) 2012, Wolf Vollprecht <w.vollprecht@gmail.com>
|
|
# This program is free software: you can redistribute it and/or modify it
|
|
# under the terms of the GNU General Public License version 3, as published
|
|
# by the Free Software Foundation.
|
|
#
|
|
# This program is distributed in the hope that it will be useful, but
|
|
# WITHOUT ANY WARRANTY; without even the implied warranties of
|
|
# MERCHANTABILITY, SATISFACTORY QUALITY, 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, see <http://www.gnu.org/licenses/>.
|
|
### END LICENSE
|
|
"""Module for the TextView widgth wich encapsulates management of TextBuffer
|
|
and TextIter for common functionality, such as cut, copy, paste, undo, redo,
|
|
and highlighting of text.
|
|
|
|
Using
|
|
#create the TextEditor and set the text
|
|
editor = TextEditor()
|
|
editor.text = "Text to add to the editor"
|
|
|
|
#use cut, works the same for copy, paste, undo, and redo
|
|
def __handle_on_cut(self, widget, data=None):
|
|
self.editor.cut()
|
|
|
|
#add string to highlight
|
|
self.editor.add_highlight("Ubuntu")
|
|
self.editor.add_highlight("Quickly")
|
|
|
|
#remove highlights
|
|
self.editor.clear_highlight("Ubuntu")
|
|
self.editor.clear_all_highlight()
|
|
|
|
Configuring
|
|
#Configure as a TextView
|
|
self.editor.set_wrap_mode(Gtk.WRAP_CHAR)
|
|
|
|
#Access the Gtk.TextBuffer if needed
|
|
buffer = self.editor.get_buffer()
|
|
|
|
Extending
|
|
A TextEditor is Gtk.TextView
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
from gi.repository import Gtk
|
|
from gi.repository import Gdk
|
|
from gi.repository import GObject
|
|
from .FormatShortcuts import FormatShortcuts
|
|
|
|
except:
|
|
print("couldn't load depencies")
|
|
|
|
|
|
|
|
import logging
|
|
logger = logging.getLogger('uberwriter')
|
|
|
|
|
|
class UndoableInsert(object):
|
|
"""something that has been inserted into our textbuffer"""
|
|
def __init__(self, text_iter, text, length):
|
|
self.offset = text_iter.get_offset()
|
|
self.text = 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):
|
|
self.text = text_buffer.get_text(start_iter, end_iter, False)
|
|
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 TextEditor(Gtk.TextView):
|
|
"""TextEditor encapsulates management of TextBuffer and TextIter for
|
|
common functionality, such as cut, copy, paste, undo, redo, and
|
|
highlighting of text.
|
|
|
|
"""
|
|
|
|
__gsignals__ = {
|
|
'insert-italic': (GObject.SIGNAL_ACTION, None, ()),
|
|
'insert-bold': (GObject.SIGNAL_ACTION, None, ()),
|
|
'insert-hrule': (GObject.SIGNAL_ACTION, None, ()),
|
|
'insert-ulistitem': (GObject.SIGNAL_ACTION, None, ()),
|
|
'insert-heading': (GObject.SIGNAL_ACTION, None, ()),
|
|
'insert-strikeout': (GObject.SIGNAL_ACTION, None, ()),
|
|
'undo': (GObject.SIGNAL_ACTION, None, ()),
|
|
'redo': (GObject.SIGNAL_ACTION, None, ())
|
|
}
|
|
|
|
def scroll_to_iter(self, iter, *args):
|
|
self.get_buffer().place_cursor(iter)
|
|
|
|
def __init__(self):
|
|
"""
|
|
Create a TextEditor
|
|
"""
|
|
|
|
Gtk.TextView.__init__(self)
|
|
self.undo_max = None
|
|
|
|
self.insert_event = self.get_buffer().connect("insert-text", self.on_insert_text)
|
|
self.delete_event = self.get_buffer().connect("delete-range", self.on_delete_range)
|
|
display = self.get_display()
|
|
self.clipboard = Gtk.Clipboard.get_for_display(display, Gdk.SELECTION_CLIPBOARD)
|
|
|
|
self.undo_stack = []
|
|
self.redo_stack = []
|
|
self.not_undoable_action = False
|
|
self.undo_in_progress = False
|
|
|
|
self.FormatShortcuts = FormatShortcuts(self.get_buffer(), self)
|
|
|
|
self.connect('insert-italic', self.set_italic)
|
|
self.connect('insert-bold', self.set_bold)
|
|
self.connect('insert-strikeout', self.set_strikeout)
|
|
self.connect('insert-hrule', self.insert_horizontal_rule)
|
|
self.connect('insert-ulistitem', self.insert_unordered_list_item)
|
|
self.connect('insert-heading', self.insert_heading)
|
|
self.connect('redo', self.redo)
|
|
self.connect('undo', self.undo)
|
|
|
|
self.get_style_context().add_class("uberwriter-editor")
|
|
|
|
@property
|
|
def text(self):
|
|
"""
|
|
text - a string specifying all the text currently
|
|
in the TextEditor's buffer.
|
|
|
|
This property is read/write.
|
|
"""
|
|
start_iter = self.get_buffer().get_iter_at_offset(0)
|
|
end_iter = self.get_buffer().get_iter_at_offset(-1)
|
|
return self.get_buffer().get_text(start_iter, end_iter, False)
|
|
|
|
@property
|
|
def can_undo(self):
|
|
return bool(self.undo_stack)
|
|
|
|
@property
|
|
def can_redo(self):
|
|
return bool(self.redo_stack)
|
|
|
|
|
|
@text.setter
|
|
def text(self, text):
|
|
self.get_buffer().set_text(text)
|
|
|
|
def append(self, text):
|
|
"""append: appends text to the end of the textbuffer.
|
|
|
|
arguments:
|
|
text - a string to add to the buffer. The text will be the
|
|
last text in the buffer. The insertion cursor will not be moved.
|
|
|
|
"""
|
|
|
|
end_iter = self.get_buffer().get_iter_at_offset(-1)
|
|
self.get_buffer().insert(end_iter, text)
|
|
|
|
def prepend(self, text):
|
|
"""prepend: appends text to the start of the textbuffer.
|
|
|
|
arguments:
|
|
text - a string to add to the buffer. The text will be the
|
|
first text in the buffer. The insertion cursor will not be moved.
|
|
|
|
"""
|
|
|
|
start_iter = self.get_buffer().get_iter_at_offset(0)
|
|
self.get_buffer().insert(start_iter, text)
|
|
insert_iter = self.get_buffer().get_iter_at_offset(len(text)-1)
|
|
self.get_buffer().place_cursor(insert_iter)
|
|
|
|
def cursor_to_end(self):
|
|
"""cursor_to_end: moves the insertion curson to the last position
|
|
in the buffer.
|
|
|
|
"""
|
|
|
|
end_iter = self.get_buffer().get_iter_at_offset(-1)
|
|
self.get_buffer().place_cursor(end_iter)
|
|
|
|
def cursor_to_start(self):
|
|
"""cursor_to_start: moves the insertion curson to the first position
|
|
in the buffer.
|
|
|
|
"""
|
|
|
|
start_iter = self.get_buffer().get_iter_at_offset(0)
|
|
self.get_buffer().place_cursor(start_iter)
|
|
|
|
def cut(self, widget=None, data=None):
|
|
"""cut: cut currently selected text and put it on the clipboard.
|
|
This function can be called as a function, or assigned as a signal
|
|
handler.
|
|
|
|
"""
|
|
|
|
self.get_buffer().cut_clipboard(self.clipboard, True)
|
|
|
|
def copy(self, widget=None, data=None):
|
|
"""copy: copy currently selected text to the clipboard.
|
|
This function can be called as a function, or assigned as a signal
|
|
handler.
|
|
|
|
"""
|
|
|
|
self.get_buffer().copy_clipboard(self.clipboard)
|
|
|
|
def paste(self, widget=None, data=None):
|
|
"""paste: Insert any text currently on the clipboard into the
|
|
buffer.
|
|
This function can be called as a function, or assigned as a signal
|
|
handler.
|
|
|
|
"""
|
|
|
|
self.get_buffer().paste_clipboard(self.clipboard,None,True)
|
|
|
|
def undo(self, widget=None, data=None):
|
|
"""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)
|
|
buf = self.get_buffer()
|
|
if isinstance(undo_action, UndoableInsert):
|
|
offset = undo_action.offset
|
|
start = buf.get_iter_at_offset(offset)
|
|
stop = buf.get_iter_at_offset(
|
|
offset + undo_action.length
|
|
)
|
|
buf.place_cursor(start)
|
|
buf.delete(start, stop)
|
|
else:
|
|
start = buf.get_iter_at_offset(undo_action.start)
|
|
buf.insert(start, undo_action.text)
|
|
if undo_action.delete_key_used:
|
|
buf.place_cursor(start)
|
|
else:
|
|
stop = buf.get_iter_at_offset(undo_action.end)
|
|
buf.place_cursor(stop)
|
|
self.end_not_undoable_action()
|
|
self.undo_in_progress = False
|
|
|
|
def redo(self, widget=None, data=None):
|
|
"""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)
|
|
buf = self.get_buffer()
|
|
if isinstance(redo_action, UndoableInsert):
|
|
start = buf.get_iter_at_offset(redo_action.offset)
|
|
buf.insert(start, redo_action.text)
|
|
new_cursor_pos = buf.get_iter_at_offset(
|
|
redo_action.offset + redo_action.length
|
|
)
|
|
buf.place_cursor(new_cursor_pos)
|
|
else:
|
|
start = buf.get_iter_at_offset(redo_action.start)
|
|
stop = buf.get_iter_at_offset(redo_action.end)
|
|
buf.delete(start, stop)
|
|
buf.place_cursor(start)
|
|
self.end_not_undoable_action()
|
|
self.undo_in_progress = False
|
|
|
|
def on_insert_text(self, textbuffer, text_iter, text, length):
|
|
"""
|
|
_on_insert: internal function to handle programatically inserted
|
|
text. Do not call directly.
|
|
"""
|
|
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, 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):
|
|
"""
|
|
On delete
|
|
"""
|
|
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 set_italic(self, widget, data=None):
|
|
"""Ctrl + I"""
|
|
self.FormatShortcuts.italic()
|
|
|
|
def set_bold(self, widget, data=None):
|
|
"""Ctrl + Shift + D"""
|
|
self.FormatShortcuts.bold()
|
|
|
|
def set_strikeout(self, widget, data=None):
|
|
"""Ctrl + B"""
|
|
self.FormatShortcuts.strikeout()
|
|
|
|
def insert_horizontal_rule(self, widget, data=None):
|
|
"""Ctrl + R"""
|
|
self.FormatShortcuts.rule()
|
|
|
|
def insert_unordered_list_item(self, widget, data=None):
|
|
"""Ctrl + U"""
|
|
self.FormatShortcuts.unordered_list_item()
|
|
|
|
def insert_ordered_list(self, widget, data=None):
|
|
"""CTRL + O"""
|
|
self.FormatShortcuts.ordered_list_item()
|
|
|
|
def insert_heading(self, widget, data=None):
|
|
"""CTRL + H"""
|
|
self.FormatShortcuts.heading()
|
|
|
|
|
|
class TestWindow(Gtk.Window):
|
|
"""For testing and demonstrating AsycnTaskProgressBox.
|
|
|
|
"""
|
|
def __init__(self):
|
|
#create a window a VBox to hold the controls
|
|
Gtk.Window.__init__(self)
|
|
self.set_title("TextEditor Test Window")
|
|
windowbox = Gtk.VBox(False, 2)
|
|
windowbox.show()
|
|
self.add(windowbox)
|
|
self.editor = TextEditor()
|
|
self.editor.show()
|
|
windowbox.pack_end(self.editor, True, True, 0)
|
|
self.set_size_request(200,200)
|
|
self.show()
|
|
self.maximize()
|
|
|
|
self.connect("destroy", Gtk.main_quit)
|
|
self.editor.text = "this is some inserted text"
|
|
self.editor.append("\nLine 3")
|
|
self.editor.prepend("Line1\n")
|
|
self.editor.cursor_to_end()
|
|
self.editor.cursor_to_start()
|
|
self.editor.undo_max = 100
|
|
cut_button = Gtk.Button("Cut")
|
|
cut_button.connect("clicked",self.editor.cut)
|
|
cut_button.show()
|
|
windowbox.pack_start(cut_button, False, False, 0)
|
|
|
|
copy_button = Gtk.Button("Copy")
|
|
copy_button.connect("clicked",self.editor.copy)
|
|
copy_button.show()
|
|
windowbox.pack_start(copy_button, False, False, 0)
|
|
|
|
paste_button = Gtk.Button("Paste")
|
|
paste_button.connect("clicked",self.editor.paste)
|
|
paste_button.show()
|
|
windowbox.pack_start(paste_button, False, False, 0)
|
|
|
|
undo_button = Gtk.Button("Undo")
|
|
undo_button.connect("clicked",self.editor.undo)
|
|
undo_button.show()
|
|
windowbox.pack_start(undo_button, False, False, 0)
|
|
|
|
redo_button = Gtk.Button("Redo")
|
|
redo_button.connect("clicked",self.editor.redo)
|
|
redo_button.show()
|
|
windowbox.pack_start(redo_button, False, False, 0)
|
|
|
|
|
|
if __name__== "__main__":
|
|
test = TestWindow()
|
|
Gtk.main()
|
|
|