From 6688eb259ec4b847cd72e20994a8df171ab858d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Silva?= Date: Sun, 31 Mar 2019 03:16:18 +0100 Subject: [PATCH] Refactor textview / textbuffer into separate modules Changes include: * Much better encapsulation of textview/textbuffer, with each isolated responsibility living independently on its own class/file. * Less code overall * Various small fixes around the components involved, such as: * Indentation of nested lists (fixes #120) * Unwanted scroll on select all (ctrl+a) * Removal of unused code around the components involved * Fixes for scrollbar location, now at the edge of the window --- data/media/css/_gtk_base.css | 62 ++- data/ui/UberwriterWindow.ui | 19 +- help/stump/help.md | 4 +- help/stump/help_backup.html | 4 +- help/stump/help_backup.md | 4 +- uberwriter/__init__.py | 8 - uberwriter/application.py | 2 - uberwriter/export_dialog.py | 3 +- uberwriter/format_shortcuts.py | 4 - uberwriter/headerbars.py | 4 +- uberwriter/helpers.py | 8 + uberwriter/inline_preview.py | 41 +- uberwriter/markup_buffer.py | 326 --------------- uberwriter/search_and_replace.py | 26 +- uberwriter/text_editor.py | 487 ---------------------- uberwriter/text_view.py | 175 ++++++++ uberwriter/text_view_drag_drop_handler.py | 62 +++ uberwriter/text_view_format_inserter.py | 154 +++++++ uberwriter/text_view_markup_handler.py | 246 +++++++++++ uberwriter/text_view_scroller.py | 84 ++++ uberwriter/text_view_undo_redo_handler.py | 204 +++++++++ uberwriter/window.py | 470 +++------------------ 22 files changed, 1074 insertions(+), 1323 deletions(-) delete mode 100644 uberwriter/markup_buffer.py delete mode 100644 uberwriter/text_editor.py create mode 100644 uberwriter/text_view.py create mode 100644 uberwriter/text_view_drag_drop_handler.py create mode 100644 uberwriter/text_view_format_inserter.py create mode 100644 uberwriter/text_view_markup_handler.py create mode 100644 uberwriter/text_view_scroller.py create mode 100644 uberwriter/text_view_undo_redo_handler.py diff --git a/data/media/css/_gtk_base.css b/data/media/css/_gtk_base.css index fdbf0cf..84261ab 100644 --- a/data/media/css/_gtk_base.css +++ b/data/media/css/_gtk_base.css @@ -8,42 +8,39 @@ bind "i" { "insert-italic" () }; bind "b" { "insert-bold" () }; bind "r" { "insert-hrule" () }; - bind "u" { "insert-ulistitem" () }; - bind "h" { "insert-heading" () }; + bind "u" { "insert-listitem" () }; + bind "h" { "insert-header" () }; bind "z" { "undo" () }; bind "y" { "redo" () }; - bind "d" { "insert-strikeout" () }; + bind "d" { "insert-strikethrough" () }; /*bind "t" { "insert-at-cursor" ('[ ] ') };*/ bind "z" { "redo" () }; } /* Main window and text colors */ -.uberwriter_window { +.uberwriter-window { /*border-radius: 7px 7px 3px 3px;*/ background: @background_color; caret-color: @foreground_color; } -.uberwriter_window.small .uberwriter-editor { +.uberwriter-window .uberwriter-editor { font-family: 'Fira Mono', monospace; - font-size: 12px; -} -.uberwriter_window grid { - background-color: @background_color; + font-size: 16px; + padding-top: 80px; + padding-bottom: 16px; } -#UberwriterWindow.medium .uberwriter-editor { - font-family: 'Fira Mono', monospace; - font-size: 15px; +.uberwriter-window.small .uberwriter-editor { + font-size: 14px; } -#UberwriterWindow.large .uberwriter-editor { - font-family: 'Fira Mono', monospace; +.uberwriter-window.large .uberwriter-editor { font-size: 18px; } -#titlebar_revealer { +#titlebar-revealer { padding: 0; } @@ -52,8 +49,8 @@ background: transparent; } -#titlebar_container { - background: @background_color; +#titlebar-container { + background: @background_color; } .uberwriter-editor { @@ -89,11 +86,11 @@ } -.status_bar_box label { +.status-bar-box label { color: #666; } -.status_bar_box button { +.status-bar-box button { /* finding reset */ background-color: @background_color; text-shadow: inherit; @@ -118,26 +115,26 @@ transition: 100ms ease-in; } -.status_bar_box button:hover, -.status_bar_box button:checked { +.status-bar-box button:hover, +.status-bar-box button:checked { transition: 0s ease-in; color: @background_color; background-color: #666; } -.status_bar_box button:hover label, -.status_bar_box button:checked label { +.status-bar-box button:hover label, +.status-bar-box button:checked label { color: @background_color; } -.status_bar_box button:active { +.status-bar-box button:active { color: #EEE; background-color: #EEE; background-image: none; box-shadow: 0 0 2px rgba(0,0,0,0.4) } -.status_bar_box separator { +.status-bar-box separator { border-color: #999; border-right: none; } @@ -150,7 +147,7 @@ background: #FFF; } -#UberwriterWindow treeview { +.uberwriter-window treeview { padding: 3px 3px 3px 3px; } @@ -165,7 +162,7 @@ padding: 5px; } -/* .QuickPreviewPopup { +/* .quick-preview-popup { padding: 5px; margin: 5px; border: 1px solid #333; @@ -183,8 +180,7 @@ border: 5px solid @background_color; } -#LexikonBubble .lexikon_heading { - /*font: serif 12;*/ +#LexikonBubble .lexikon-heading { font-family: serif; font-size: 12px; padding-bottom: 5px; @@ -193,21 +189,21 @@ padding-left: 10px; } -#LexikonBubble .lexikon_num { +#LexikonBubble .lexikon-num { padding-right: 5px; padding-left: 20px; } -.QuickPreviewPopup { +.quick-preview-popup { background-color: @background_color; } -.QuickPreviewPopup grid { +.quick-preview-popup grid { background-color: @background_color; color: @foreground_color; border-color: @background_color; } -.QuickPreviewPopup label { +.quick-preview-popup label { color: @foreground_color; } \ No newline at end of file diff --git a/data/ui/UberwriterWindow.ui b/data/ui/UberwriterWindow.ui index 162d6f9..b30c922 100644 --- a/data/ui/UberwriterWindow.ui +++ b/data/ui/UberwriterWindow.ui @@ -186,23 +186,16 @@ natural none - + + 500 True - False - center + True + True True True + adjustment1 - - 500 - True - True - True - adjustment1 - - - - + diff --git a/help/stump/help.md b/help/stump/help.md index c63bf5b..9b0dc25 100644 --- a/help/stump/help.md +++ b/help/stump/help.md @@ -619,9 +619,9 @@ Because `_` is sometimes used inside words and identifiers, pandoc does not inte feas*ible*, not feas*able*. -#### Strikeout +#### Strikethrough -To strikeout a section of text with a horizontal line, begin and end it with `~~`. Thus, for example, +To strikethrough a section of text with a horizontal line, begin and end it with `~~`. Thus, for example, This ~~is deleted text.~~ diff --git a/help/stump/help_backup.html b/help/stump/help_backup.html index 6c05d16..dc82ab5 100644 --- a/help/stump/help_backup.html +++ b/help/stump/help_backup.html @@ -47,8 +47,8 @@ is *emphasized with asterisks*.
This is * not emphasized *, and \*neither is this\*.

Because _ is sometimes used inside words and identifiers, pandoc does not interpret a _ surrounded by alphanumeric characters as an emphasis marker. If you want to emphasize just part of a word, use *:

feas*ible*, not feas*able*.
-

Strikeout

-

To strikeout a section of text with a horizontal line, begin and end it with ~~. Thus, for example,

+

Strikethrough

+

To strikethrough a section of text with a horizontal line, begin and end it with ~~. Thus, for example,

This ~~is deleted text.~~

Block quotations

Markdown uses email conventions for quoting blocks of text. A block quotation is one or more paragraphs or other block elements (such as lists or headers), with each line preceded by a > character and a space.

diff --git a/help/stump/help_backup.md b/help/stump/help_backup.md index 6395e5c..17518d1 100644 --- a/help/stump/help_backup.md +++ b/help/stump/help_backup.md @@ -67,9 +67,9 @@ Because `_` is sometimes used inside words and identifiers, pandoc does not inte feas*ible*, not feas*able*. -### Strikeout +### Strikethrough -To strikeout a section of text with a horizontal line, begin and end it with `~~`. Thus, for example, +To strikethrough a section of text with a horizontal line, begin and end it with `~~`. Thus, for example, This ~~is deleted text.~~ diff --git a/uberwriter/__init__.py b/uberwriter/__init__.py index ed4348d..20bd475 100644 --- a/uberwriter/__init__.py +++ b/uberwriter/__init__.py @@ -32,12 +32,4 @@ def main(): # Run the application. app = application.Application() - # ~ if args: - # ~ for arg in args: - # ~ pass - # ~ else: - # ~ pass - # ~ if options.experimental_features: - # ~ window.use_experimental_features(True) - app.run(sys.argv) diff --git a/uberwriter/application.py b/uberwriter/application.py index 7b199e9..3d0c2e6 100644 --- a/uberwriter/application.py +++ b/uberwriter/application.py @@ -173,8 +173,6 @@ class Application(Gtk.Application): self.window = window.Window(self) if self.args: self.window.load_file(self.args[0]) - if self.options.experimental_features: - self.window.use_experimental_features(True) self.window.present() diff --git a/uberwriter/export_dialog.py b/uberwriter/export_dialog.py index d344d0b..812e8d5 100644 --- a/uberwriter/export_dialog.py +++ b/uberwriter/export_dialog.py @@ -35,7 +35,8 @@ from uberwriter.helpers import get_builder LOGGER = logging.getLogger('uberwriter') -class UberwriterExportDialog: + +class Export: """ Manages all the export operations and dialogs """ diff --git a/uberwriter/format_shortcuts.py b/uberwriter/format_shortcuts.py index 0d0b030..19e0a91 100644 --- a/uberwriter/format_shortcuts.py +++ b/uberwriter/format_shortcuts.py @@ -17,18 +17,14 @@ from gettext import gettext as _ -from uberwriter.markup_buffer import MarkupBuffer - class FormatShortcuts(): """Manage the insertion of formatting for insert them using shortcuts """ - def __init__(self, textbuffer, texteditor): self.text_buffer = textbuffer self.text_editor = texteditor - self.regex = MarkupBuffer.regex def rule(self): """insert ruler at cursor diff --git a/uberwriter/headerbars.py b/uberwriter/headerbars.py index 999a4cd..d424ab4 100644 --- a/uberwriter/headerbars.py +++ b/uberwriter/headerbars.py @@ -36,14 +36,14 @@ class MainHeaderbar: #pylint: disable=too-few-public-methods self.hb.props.show_close_button = True self.hb.get_style_context().add_class("titlebar") - self.hb_revealer = Gtk.Revealer(name='titlebar_revealer') + self.hb_revealer = Gtk.Revealer(name='titlebar-revealer') self.hb_revealer.add(self.hb) self.hb_revealer.props.transition_duration = 1000 self.hb_revealer.props.transition_type = Gtk.RevealerTransitionType.CROSSFADE self.hb_revealer.show() self.hb_revealer.set_reveal_child(True) - self.hb_container = Gtk.Frame(name='titlebar_container') + self.hb_container = Gtk.Frame(name='titlebar-container') self.hb_container.set_shadow_type(Gtk.ShadowType.NONE) self.hb_container.add(self.hb_revealer) self.hb_container.show() diff --git a/uberwriter/helpers.py b/uberwriter/helpers.py index efd0ef5..3ae6d9e 100644 --- a/uberwriter/helpers.py +++ b/uberwriter/helpers.py @@ -23,6 +23,8 @@ import shutil import gi +from gi.overrides.Pango import Pango + gi.require_version('Gtk', '3.0') from gi.repository import Gtk # pylint: disable=E0611 @@ -160,6 +162,7 @@ def exist_executable(command): return shutil.which(command) is not None + def get_descendant(widget, child_name, level, doPrint=False): if widget is not None: if doPrint: print("-"*level + str(Gtk.Buildable.get_name(widget)) + @@ -188,3 +191,8 @@ def get_descendant(widget, child_name, level, doPrint=False): if child is not None: found = get_descendant(child, child_name, level+1, doPrint) # //search the child if found: return found + + +def get_char_width(widget): + return Pango.units_to_double( + widget.get_pango_context().get_metrics().get_approximate_char_width()) diff --git a/uberwriter/inline_preview.py b/uberwriter/inline_preview.py index 6396411..86fead3 100644 --- a/uberwriter/inline_preview.py +++ b/uberwriter/inline_preview.py @@ -14,28 +14,27 @@ # with this program. If not, see . # END LICENSE -import re -import urllib -from urllib.error import URLError -import webbrowser -import subprocess -import tempfile import logging -import threading +import re +import subprocess import telnetlib - +import tempfile +import threading +import urllib +import webbrowser from gettext import gettext as _ +from urllib.error import URLError +from urllib.parse import unquote import gi + gi.require_version('Gtk', '3.0') from gi.repository import Gtk, Gdk, GdkPixbuf, GObject -from uberwriter import latex_to_PNG +from uberwriter import latex_to_PNG, text_view_markup_handler from uberwriter.settings import Settings from uberwriter.fix_table import FixTable -from uberwriter.markup_buffer import MarkupBuffer - LOGGER = logging.getLogger('uberwriter') GObject.threads_init() # Still needed? @@ -240,7 +239,7 @@ def fill_lexikon_bubble(vocab, lexikon_dict): if lexikon_dict: for entry in lexikon_dict: vocab_label = Gtk.Label.new(vocab + ' ~ ' + entry['class']) - vocab_label.get_style_context().add_class('lexikon_heading') + vocab_label.get_style_context().add_class('lexikon-heading') vocab_label.set_halign(Gtk.Align.START) vocab_label.set_justify(Gtk.Justification.LEFT) grid.attach(vocab_label, 0, i, 3, 1) @@ -248,14 +247,14 @@ def fill_lexikon_bubble(vocab, lexikon_dict): for definition in entry['defs']: i = i + 1 num_label = Gtk.Label.new(definition['num']) - num_label.get_style_context().add_class('lexikon_num') + num_label.get_style_context().add_class('lexikon-num') num_label.set_justify(Gtk.Justification.RIGHT) grid.attach(num_label, 0, i, 1, 1) def_label = Gtk.Label.new(' '.join(definition['description'])) def_label.set_halign(Gtk.Align.START) def_label.set_justify(Gtk.Justification.LEFT) - def_label.get_style_context().add_class('lexikon_definition') + def_label.get_style_context().add_class('lexikon-definition') def_label.props.wrap = True grid.attach(def_label, 1, i, 1, 1) i = i + 1 @@ -264,11 +263,11 @@ def fill_lexikon_bubble(vocab, lexikon_dict): return None -class InlinePreview(): +class InlinePreview: - def __init__(self, view, text_buffer): - self.text_view = view - self.text_buffer = text_buffer + def __init__(self, text_view): + self.text_view = text_view + self.text_buffer = text_view.get_buffer() self.latex_converter = latex_to_PNG.LatexToPNG() cursor_mark = self.text_buffer.get_insert() cursor_iter = self.text_buffer.get_iter_at_mark(cursor_mark) @@ -307,7 +306,7 @@ class InlinePreview(): # b.show_all() # a.show_all() self.popover = Gtk.Popover.new(lbl) - self.popover.get_style_context().add_class("QuickPreviewPopup") + self.popover.get_style_context().add_class("quick-preview-popup") self.popover.add(alignment) # a.add(alignment) _dismiss, rect = self.popover.get_pointing_to() @@ -363,8 +362,8 @@ class InlinePreview(): text = self.text_buffer.get_text(start_iter, end_iter, False) - math = MarkupBuffer.regex["MATH"] - link = MarkupBuffer.regex["LINK"] + math = text_view_markup_handler.regex["MATH"] + link = text_view_markup_handler.regex["LINK"] footnote = re.compile(r'\[\^([^\s]+?)\]') image = re.compile(r"!\[(.*?)\]\((.+?)\)") diff --git a/uberwriter/markup_buffer.py b/uberwriter/markup_buffer.py deleted file mode 100644 index 7afc8ae..0000000 --- a/uberwriter/markup_buffer.py +++ /dev/null @@ -1,326 +0,0 @@ -# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- -### BEGIN LICENSE -# Copyright (C) 2012, Wolf Vollprecht -# 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 . -### END LICENSE - -import re -import gi - -gi.require_version('Gtk', '3.0') -from gi.repository import Gtk -from gi.repository import Pango - - -class MarkupBuffer(): - regex = { - "ITALIC": re.compile(r"(\*|_)(.*?)\1", re.UNICODE), # *asdasd* // _asdasd asd asd_ - "STRONG": re.compile(r"(\*\*|__)(.*?)\1", re.UNICODE), # **as das** // __asdasd asd ad a__ - "STRONGITALIC": re.compile(r"(\*\*\*|___)(.*?)\1"), - "BLOCKQUOTE": re.compile(r"^([\>]+ )", re.MULTILINE), - "STRIKETHROUGH": re.compile(r"~~[^ `~\n].+?~~"), - "LIST": re.compile(r"^[\-\*\+] ", re.MULTILINE), - "NUMERICLIST": re.compile(r"^((\d|[a-z]|\#)+[\.\)]) ", re.MULTILINE), - "INDENTEDLIST": re.compile(r"^(\t{1,6})((\d|[a-z]|\#)+[\.\)]|[\-\*\+]) ", re.MULTILINE), - "HEADINDICATOR": re.compile(r"^(#{1,6}) ", re.MULTILINE), - "HEADLINE": re.compile(r"^(#{1,6} [^\n]+)", re.MULTILINE), - "HEADLINE_TWO": re.compile(r"^\w.+\n[\=\-]{3,}", re.MULTILINE), - "MATH": re.compile(r"[\$]{1,2}([^` ].+?[^`\\ ])[\$]{1,2}"), - "HORIZONTALRULE": re.compile(r"(\n\n[\*\-]{3,}\n)"), - "TABLE": re.compile(r"^[\-\+]{5,}\n(.+?)\n[\-\+]{5,}\n", re.DOTALL), - "LINK": re.compile(r"\(http(.+?)\)") - } - - def __init__(self, window, text_editor, base_leftmargin): - self.margin_multiplier = 10 - self.parent = window - self.text_editor = text_editor - self.text_buffer = text_editor.get_buffer() - - # Styles - self.italic = self.text_buffer.create_tag("italic", - style=Pango.Style.ITALIC) - - self.emph = self.text_buffer.create_tag("emph", - weight=Pango.Weight.BOLD, - style=Pango.Style.NORMAL) - - self.bolditalic = self.text_buffer.create_tag("bolditalic", - weight=Pango.Weight.BOLD, - style=Pango.Style.ITALIC) - - self.headline_two = self.text_buffer.create_tag("headline_two", - weight=Pango.Weight.BOLD, - style=Pango.Style.NORMAL) - - self.normal_indent = self.text_buffer.create_tag('normal_indent', indent=100) - - self.math_text = self.text_buffer.create_tag('math_text') - - self.unfocused_text = self.text_buffer.create_tag('graytag', - foreground="gray") - - self.underline = self.text_buffer.create_tag("underline", - underline=Pango.Underline.SINGLE) - - self.underline.set_property('weight', Pango.Weight.BOLD) - - self.strikethrough = self.text_buffer.create_tag("strikethrough", - strikethrough=True) - - self.centertext = self.text_buffer.create_tag("centertext", - justification=Gtk.Justification.CENTER) - - self.text_buffer.apply_tag( - self.normal_indent, - self.text_buffer.get_start_iter(), - self.text_buffer.get_end_iter() - ) - - self.rev_leftmargin = [] - for i in range(0, 6): - name = "rev_marg_indent_left" + str(i) - self.rev_leftmargin.append(self.text_buffer.create_tag(name)) - self.rev_leftmargin[i].set_property("left-margin", 90 - 10 * (i + 1)) - self.rev_leftmargin[i].set_property("indent", - 10 * (i + 1) - 10) - #self.leftmargin[i].set_property("background", "gray") - - self.leftmargin = [] - - for i in range(0, 6): - name = "marg_indent_left" + str(i) - self.leftmargin.append(self.text_buffer.create_tag(name)) - self.leftmargin[i].set_property("left-margin", base_leftmargin + 10 + 10 * (i + 1)) - self.leftmargin[i].set_property("indent", - 10 * (i + 1) - 10) - - self.leftindent = [] - - for i in range(0, 15): - name = "indent_left" + str(i) - self.leftindent.append(self.text_buffer.create_tag(name)) - self.leftindent[i].set_property("indent", - 10 * (i + 1) - 20) - - self.table_env = self.text_buffer.create_tag('table_env') - self.table_env.set_property('wrap-mode', Gtk.WrapMode.NONE) - self.table_env.set_property('pixels-above-lines', 0) - self.table_env.set_property('pixels-below-lines', 0) - - # Theme - self.text_editor.connect('style-updated', self.apply_current_theme) - self.apply_current_theme() - - def apply_current_theme(self, *_): - # Math text color - (found, color) = self.text_editor.get_style_context().lookup_color('math_text_color') - if not found: - (_, color) = self.text_editor.get_style_context().lookup_color('foreground_color') - self.math_text.set_property("foreground", color.to_string()) - - # Margin - mets = self.text_editor.get_pango_context().get_metrics() - self.set_multiplier(Pango.units_to_double(mets.get_approximate_char_width()) + 1) - - def markup_buffer(self, mode=0): - buf = self.text_buffer - - # Test for shifting first line - # bbs = buf.get_start_iter() - # bbb = buf.get_iter_at_offset(3) - - # buf.apply_tag(self.ftag, bbs, bbb) - - # Modes: - # 0 -> start to end - # 1 -> around the cursor - # 2 -> n.d. - - if mode == 0: - context_start = buf.get_start_iter() - context_end = buf.get_end_iter() - context_offset = 0 - elif mode == 1: - cursor_mark = buf.get_insert() - context_start = buf.get_iter_at_mark(cursor_mark) - context_start.backward_lines(3) - context_end = buf.get_iter_at_mark(cursor_mark) - context_end.forward_lines(2) - context_offset = context_start.get_offset() - - text = buf.get_slice(context_start, context_end, False) - - self.text_buffer.remove_tag(self.italic, context_start, context_end) - - matches = re.finditer(self.regex["ITALIC"], text) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - self.text_buffer.apply_tag(self.italic, start_iter, end_iter) - - self.text_buffer.remove_tag(self.emph, context_start, context_end) - - matches = re.finditer(self.regex["STRONG"], text) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - self.text_buffer.apply_tag(self.emph, start_iter, end_iter) - - matches = re.finditer(self.regex["STRONGITALIC"], text) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - self.text_buffer.apply_tag(self.bolditalic, start_iter, end_iter) - - self.text_buffer.remove_tag(self.strikethrough, context_start, context_end) - - matches = re.finditer(self.regex["STRIKETHROUGH"], text) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - self.text_buffer.apply_tag(self.strikethrough, start_iter, end_iter) - - self.text_buffer.remove_tag(self.math_text, context_start, context_end) - - matches = re.finditer(self.regex["MATH"], text) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - self.text_buffer.apply_tag(self.math_text, start_iter, end_iter) - - for margin in self.rev_leftmargin: - self.text_buffer.remove_tag(margin, context_start, context_end) - - matches = re.finditer(self.regex["LIST"], text) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - self.text_buffer.apply_tag(self.rev_leftmargin[0], start_iter, end_iter) - - matches = re.finditer(self.regex["NUMERICLIST"], text) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - index = len(match.group(1)) - 1 - if index < len(self.rev_leftmargin): - margin = self.rev_leftmargin[index] - self.text_buffer.apply_tag(margin, start_iter, end_iter) - - matches = re.finditer(self.regex["BLOCKQUOTE"], text) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - index = len(match.group(1)) - 2 - if index < len(self.leftmargin): - self.text_buffer.apply_tag(self.leftmargin[index], start_iter, end_iter) - - for leftindent in self.leftindent: - self.text_buffer.remove_tag(leftindent, context_start, context_end) - - matches = re.finditer(self.regex["INDENTEDLIST"], text) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - index = (len(match.group(1)) - 1) * 2 + len(match.group(2)) - if index < len(self.leftindent): - self.text_buffer.apply_tag(self.leftindent[index], start_iter, end_iter) - - matches = re.finditer(self.regex["HEADINDICATOR"], text) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - index = len(match.group(1)) - 1 - if index < len(self.rev_leftmargin): - margin = self.rev_leftmargin[index] - self.text_buffer.apply_tag(margin, start_iter, end_iter) - - matches = re.finditer(self.regex["HORIZONTALRULE"], text) - rulecontext = context_start.copy() - rulecontext.forward_lines(3) - self.text_buffer.remove_tag(self.centertext, rulecontext, context_end) - - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - start_iter.forward_chars(2) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - self.text_buffer.apply_tag(self.centertext, start_iter, end_iter) - - matches = re.finditer(self.regex["HEADLINE"], text) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - self.text_buffer.apply_tag(self.emph, start_iter, end_iter) - - matches = re.finditer(self.regex["HEADLINE_TWO"], text) - self.text_buffer.remove_tag(self.headline_two, rulecontext, context_end) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - self.text_buffer.apply_tag(self.headline_two, start_iter, end_iter) - - matches = re.finditer(self.regex["TABLE"], text) - for match in matches: - start_iter = buf.get_iter_at_offset(context_offset + match.start()) - end_iter = buf.get_iter_at_offset(context_offset + match.end()) - self.text_buffer.apply_tag(self.table_env, start_iter, end_iter) - - if self.parent.focusmode: - self.focusmode_highlight() - - def focusmode_highlight(self): - start_document = self.text_buffer.get_start_iter() - end_document = self.text_buffer.get_end_iter() - - self.text_buffer.remove_tag( - self.unfocused_text, - start_document, - end_document) - - cursor = self.text_buffer.get_mark("insert") - cursor_iter = self.text_buffer.get_iter_at_mark(cursor) - - end_sentence = cursor_iter.copy() - end_sentence.forward_sentence_end() - - end_line = cursor_iter.copy() - end_line.forward_to_line_end() - - comp = end_line.compare(end_sentence) - # if comp < 0, end_line is BEFORE end_sentence - if comp <= 0: - end_sentence = end_line - - start_sentence = cursor_iter.copy() - start_sentence.backward_sentence_start() - - # grey out everything before - self.text_buffer.apply_tag( - self.unfocused_text, - self.text_buffer.get_start_iter(), start_sentence) - - self.text_buffer.apply_tag( - self.unfocused_text, - end_sentence, self.text_buffer.get_end_iter()) - - def set_multiplier(self, multiplier): - self.margin_multiplier = multiplier - - def recalculate(self, lm): - multiplier = self.margin_multiplier - for i in range(0, 6): - new_margin = (lm - multiplier) - multiplier * (i + 1) - self.rev_leftmargin[i].set_property("left-margin", 0 if new_margin < 0 else new_margin) - self.rev_leftmargin[i].set_property("indent", - multiplier * (i + 1) - multiplier) - - for i in range(0, 6): - new_margin = (lm - multiplier) + multiplier + multiplier * (i + 1) - self.leftmargin[i].set_property("left-margin", 0 if new_margin < 0 else new_margin) - self.leftmargin[i].set_property("indent", - (multiplier - 1) * (i + 1) - multiplier) diff --git a/uberwriter/search_and_replace.py b/uberwriter/search_and_replace.py index 63c40a0..32cbfa5 100644 --- a/uberwriter/search_and_replace.py +++ b/uberwriter/search_and_replace.py @@ -14,21 +14,26 @@ # with this program. If not, see . ### END LICENSE -import re import logging +import re + import gi + gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, Gdk +from gi.repository import Gdk + # from plugins import plugins LOGGER = logging.getLogger('uberwriter') -class SearchAndReplace(): + +class SearchAndReplace: """ Adds (regex) search and replace functionality to uberwriter """ - def __init__(self, parentwindow): + + def __init__(self, parentwindow, textview): self.parentwindow = parentwindow self.box = parentwindow.builder.get_object("searchbar_placeholder") self.box.set_reveal_child(False) @@ -41,8 +46,8 @@ class SearchAndReplace(): self.open_replace_button = parentwindow.builder.get_object("replace") self.open_replace_button.connect("toggled", self.toggle_replace) - self.textbuffer = parentwindow.text_buffer - self.texteditor = parentwindow.text_editor + self.textview = textview + self.textbuffer = textview.get_buffer() self.nextbutton = parentwindow.builder.get_object("next_result") self.prevbutton = parentwindow.builder.get_object("previous_result") @@ -66,7 +71,7 @@ class SearchAndReplace(): self.highlight = self.textbuffer.create_tag('search_highlight', background="yellow") - self.texteditor.connect("focus-in-event", self.focused_texteditor) + self.textview.connect("focus-in-event", self.focused_texteditor) def toggle_replace(self, widget, _data=None): """toggle the replace box @@ -100,7 +105,6 @@ class SearchAndReplace(): self.hide() self.open_replace_button.set_active(False) - def search(self, _widget=None, _data=None, scroll=True): searchtext = self.searchentry.get_text() buf = self.textbuffer @@ -147,7 +151,7 @@ class SearchAndReplace(): self.active = 0 matchiter = self.matchiters[self.active] - self.texteditor.get_buffer().select_range(matchiter[0], matchiter[1]) + self.textview.get_buffer().select_range(matchiter[0], matchiter[1]) # self.texteditor.scroll_to_iter(matchiter[0], 0.0, True, 0.0, 0.5) @@ -157,8 +161,7 @@ class SearchAndReplace(): self.textbuffer.remove_tag(self.highlight, self.textbuffer.get_start_iter(), self.textbuffer.get_end_iter()) - self.texteditor.grab_focus() - + self.textview.grab_focus() def replace_clicked(self, _widget, _data=None): self.replace(self.active) @@ -177,5 +180,4 @@ class SearchAndReplace(): active = self.active self.search(scroll=False) self.active = active - self.parentwindow.MarkupBuffer.markup_buffer() self.scrollto(self.active) diff --git a/uberwriter/text_editor.py b/uberwriter/text_editor.py deleted file mode 100644 index 60919e1..0000000 --- a/uberwriter/text_editor.py +++ /dev/null @@ -1,487 +0,0 @@ -### BEGIN LICENSE -# Copyright (C) 2012, Wolf Vollprecht -# 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 . -### 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 - -""" - -import gi -gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, Gdk, GObject -from uberwriter.format_shortcuts import FormatShortcuts - -import logging -LOGGER = logging.getLogger('uberwriter') - - -class UndoableInsert: - """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: - """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()) - - 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", " ")) - - -class UberwriterTextEditor(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.SignalFlags.ACTION, None, ()), - 'insert-bold': (GObject.SignalFlags.ACTION, None, ()), - 'insert-hrule': (GObject.SignalFlags.ACTION, None, ()), - 'insert-ulistitem': (GObject.SignalFlags.ACTION, None, ()), - 'insert-heading': (GObject.SignalFlags.ACTION, None, ()), - 'insert-strikeout': (GObject.SignalFlags.ACTION, None, ()), - 'undo': (GObject.SignalFlags.ACTION, None, ()), - 'redo': (GObject.SignalFlags.ACTION, None, ()) - } - - def scroll_to_iter(self, iterable, *args): - self.get_buffer().place_cursor(iterable) - - 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.can_delete = True - self.connect('key-press-event', self.on_key_press_event) - - self.format_shortcuts = 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 - if cur.offset != (prev.offset + prev.length): - return False - if cur.text in whitespace and not prev.text in whitespace: - return False - if 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 - 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: - 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 on_key_press_event(self, widget, event): - if widget == self and not self.can_delete: - return event.keyval == Gdk.KEY_BackSpace or event.keyval == Gdk.KEY_Delete - else: - return False - - def set_italic(self, _widget, _data=None): - """Ctrl + I""" - self.format_shortcuts.italic() - - def set_bold(self, _widget, _data=None): - """Ctrl + Shift + D""" - self.format_shortcuts.bold() - - def set_strikeout(self, _widget, _data=None): - """Ctrl + B""" - self.format_shortcuts.strikeout() - - def insert_horizontal_rule(self, _widget, _data=None): - """Ctrl + R""" - self.format_shortcuts.rule() - - def insert_unordered_list_item(self, _widget, _data=None): - """Ctrl + U""" - self.format_shortcuts.unordered_list_item() - - def insert_ordered_list(self, _widget, _data=None): - """CTRL + O""" - self.format_shortcuts.ordered_list_item() - - def insert_heading(self, _widget, _data=None): - """CTRL + H""" - self.format_shortcuts.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(homogeneous=False, spacing=2) - windowbox.show() - self.add(windowbox) - self.editor = UberwriterTextEditor() - 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(label="Cut") - cut_button.connect("clicked", self.editor.cut) - cut_button.show() - windowbox.pack_start(cut_button, False, False, 0) - - copy_button = Gtk.Button(label="Copy") - copy_button.connect("clicked", self.editor.copy) - copy_button.show() - windowbox.pack_start(copy_button, False, False, 0) - - paste_button = Gtk.Button(label="Paste") - paste_button.connect("clicked", self.editor.paste) - paste_button.show() - windowbox.pack_start(paste_button, False, False, 0) - - undo_button = Gtk.Button(label="Undo") - undo_button.connect("clicked", self.editor.undo) - undo_button.show() - windowbox.pack_start(undo_button, False, False, 0) - - redo_button = Gtk.Button(label="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() diff --git a/uberwriter/text_view.py b/uberwriter/text_view.py new file mode 100644 index 0000000..d580b78 --- /dev/null +++ b/uberwriter/text_view.py @@ -0,0 +1,175 @@ +import gi + +from uberwriter.inline_preview import InlinePreview +from uberwriter.text_view_format_inserter import FormatInserter +from uberwriter.text_view_markup_handler import MarkupHandler +from uberwriter.text_view_undo_redo_handler import UndoRedoHandler +from uberwriter.text_view_drag_drop_handler import DragDropHandler, TARGET_URI, \ + TARGET_TEXT +from uberwriter.text_view_scroller import Scroller + +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk, GObject + +import logging +LOGGER = logging.getLogger('uberwriter') + + +class TextView(Gtk.TextView): + """UberwriterTextView encapsulates all the features around the editor. + + It combines the following: + - Undo / redo (via TextBufferUndoRedoHandler) + - Format shortcuts (via TextBufferShortcutInserter) + - Markup (via TextBufferMarkupHandler) + - Preview popover (via TextBufferMarkupHandler) + - Drag and drop (via TextViewDragDropHandler) + - Scrolling (via TextViewScroller) + - The various modes supported by UberWriter (eg. Focus Mode, Hemingway Mode) + """ + + __gsignals__ = { + 'insert-italic': (GObject.SignalFlags.ACTION, None, ()), + 'insert-bold': (GObject.SignalFlags.ACTION, None, ()), + 'insert-hrule': (GObject.SignalFlags.ACTION, None, ()), + 'insert-listitem': (GObject.SignalFlags.ACTION, None, ()), + 'insert-header': (GObject.SignalFlags.ACTION, None, ()), + 'insert-strikethrough': (GObject.SignalFlags.ACTION, None, ()), + 'undo': (GObject.SignalFlags.ACTION, None, ()), + 'redo': (GObject.SignalFlags.ACTION, None, ()) + } + + def __init__(self): + super().__init__() + + # Appearance + self.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self.set_pixels_above_lines(4) + self.set_pixels_below_lines(4) + self.set_pixels_inside_wrap(8) + self.get_style_context().add_class('uberwriter-editor') + + # General behavior + self.get_buffer().connect('changed', self.on_text_changed) + self.connect('size-allocate', self.on_size_allocate) + + # Undo / redo + self.undo_redo = UndoRedoHandler() + 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) + self.connect('redo', self.undo_redo.redo) + + # Format shortcuts + self.shortcut = FormatInserter() + self.connect('insert-italic', self.shortcut.insert_italic) + self.connect('insert-bold', self.shortcut.insert_bold) + self.connect('insert-strikethrough', self.shortcut.insert_strikethrough) + self.connect('insert-hrule', self.shortcut.insert_horizontal_rule) + self.connect('insert-listitem', self.shortcut.insert_list_item) + self.connect('insert-header', self.shortcut.insert_header) + + # Markup + self.markup = MarkupHandler(self) + self.connect('style-updated', self.markup.on_style_updated) + self.get_buffer().connect('paste-done', self.on_paste_done) + + # Preview popover + self.preview_popover = InlinePreview(self) + + # Drag and drop + self.drag_drop = DragDropHandler(self, TARGET_URI, TARGET_TEXT) + + # Scrolling + self.scroller = Scroller() + self.get_buffer().connect('mark-set', self.on_mark_set) + + # Focus mode + self.focus_mode = False + self.original_top_margin = self.props.top_margin + self.original_bottom_margin = self.props.bottom_margin + self.connect('button-release-event', self.on_button_release_event) + + # Hemingway mode + self.hemingway_mode = False + self.connect('key-press-event', self.on_key_press_event) + + def get_text(self): + text_buffer = self.get_buffer() + start_iter = text_buffer.get_start_iter() + end_iter = text_buffer.get_end_iter() + return text_buffer.get_text(start_iter, end_iter, False) + + def set_text(self, text): + text_buffer = self.get_buffer() + text_buffer.set_text(text) + self.markup.apply() # TODO + + def on_text_changed(self, *_): + self.markup.apply(True) + self.scroll_to() + + def on_paste_done(self, *_): + self.markup.apply() + + def on_size_allocate(self, *_): + self.update_vertical_margin() + self.markup.update_margins() + + def set_focus_mode(self, focus_mode): + """Toggle focus mode. + + When in focus mode, the cursor sits in the middle of the text view, + and the surrounding text is greyed out.""" + + self.focus_mode = focus_mode + self.update_vertical_margin() + self.markup.apply() + self.scroll_to() + + def update_vertical_margin(self): + if self.focus_mode: + height = self.get_allocation().height + self.props.top_margin = height / 2 + self.props.bottom_margin = height / 2 + else: + self.props.top_margin = self.original_top_margin + self.props.bottom_margin = self.original_bottom_margin + + def on_button_release_event(self, _widget, _event): + if self.focus_mode: + self.markup.apply(True) + return False + + def set_hemingway_mode(self, hemingway_mode): + """Toggle hemingway mode. + + When in hemingway mode, the backspace and delete keys are ignored.""" + + self.hemingway_mode = hemingway_mode + + def on_key_press_event(self, _widget, event): + if self.hemingway_mode: + return event.keyval == Gdk.KEY_BackSpace or event.keyval == Gdk.KEY_Delete + else: + return False + + def clear(self): + """Clear text and undo history""" + + self.get_buffer().set_text('') + self.undo_redo.clear() + + def scroll_to(self, mark=None): + """Scrolls if needed to ensure mark is visible. + + If mark is unspecified, the cursor is used.""" + + self.scroller.scroll_to(self, mark, self.focus_mode) + + def on_mark_set(self, _text_buffer, _location, mark, _data=None): + if mark.get_name() == 'insert': + self.markup.apply(not self.focus_mode) + elif mark.get_name() == 'gtk_drag_target': + self.scroll_to(mark) + return True diff --git a/uberwriter/text_view_drag_drop_handler.py b/uberwriter/text_view_drag_drop_handler.py new file mode 100644 index 0000000..1b6c593 --- /dev/null +++ b/uberwriter/text_view_drag_drop_handler.py @@ -0,0 +1,62 @@ +import mimetypes +import urllib + +from gi.repository import Gtk + +(TARGET_URI, TARGET_TEXT) = range(2) + + +class DragDropHandler: + TARGET_URI = None + + def __init__(self, text_view, *targets): + super().__init__() + + self.target_list = Gtk.TargetList.new([]) + if TARGET_URI in targets: + self.target_list.add_uri_targets(TARGET_URI) + if TARGET_TEXT in targets: + self.target_list.add_text_targets(TARGET_TEXT) + + text_view.drag_dest_set_target_list(self.target_list) + text_view.connect_after('drag-data-received', self.on_drag_data_received) + + def on_drag_data_received(self, text_view, drag_context, _x, _y, data, info, time): + """Handle drag and drop events""" + + text_buffer = text_view.get_buffer() + if info == TARGET_URI: + uris = data.get_uris() + for uri in uris: + uri = urllib.parse.unquote_plus(uri) + mime = mimetypes.guess_type(uri) + + if mime[0] is not None and mime[0].startswith('image'): + if uri.startswith("file://"): + uri = uri[7:] + text = "![Image caption](%s)" % uri + limit_left = 2 + limit_right = 23 + else: + text = "[Link description](%s)" % uri + limit_left = 1 + limit_right = 22 + text_buffer.place_cursor(text_buffer.get_iter_at_mark( + text_buffer.get_mark('gtk_drag_target'))) + text_buffer.insert_at_cursor(text) + insert_mark = text_buffer.get_insert() + selection_bound = text_buffer.get_selection_bound() + cursor_iter = text_buffer.get_iter_at_mark(insert_mark) + cursor_iter.backward_chars(len(text) - limit_left) + text_buffer.move_mark(insert_mark, cursor_iter) + cursor_iter.forward_chars(limit_right) + text_buffer.move_mark(selection_bound, cursor_iter) + + elif info == TARGET_TEXT: + text_buffer.place_cursor(text_buffer.get_iter_at_mark( + text_buffer.get_mark('gtk_drag_target'))) + text_buffer.insert_at_cursor(data.get_text()) + + Gtk.drag_finish(drag_context, True, True, time) + text_view.get_window().present_with_time(time) + return False diff --git a/uberwriter/text_view_format_inserter.py b/uberwriter/text_view_format_inserter.py new file mode 100644 index 0000000..5cbaf61 --- /dev/null +++ b/uberwriter/text_view_format_inserter.py @@ -0,0 +1,154 @@ +from gettext import gettext as _ + + +class FormatInserter: + """Manages insertion of formatting. + + Methods can be called directly, as well as be used as signal callbacks.""" + + def insert_italic(self, text_view, _data=None): + """Insert italic or mark a selection as bold""" + + self.__wrap(text_view, "_", _("italic text")) + + def insert_bold(self, text_view, _data=None): + """Insert bold or mark a selection as bold""" + + self.__wrap(text_view, "**", _("bold text")) + + def insert_strikethrough(self, text_view, _data=None): + """Insert strikethrough or mark a selection as strikethrough""" + + self.__wrap(text_view, "~~", _("strikethrough text")) + + def insert_horizontal_rule(self, text_view, _data=None): + """Insert horizontal rule""" + + text_buffer = text_view.get_buffer() + text_buffer.insert_at_cursor("\n\n---\n") + text_view.scroll_mark_onscreen(text_buffer.get_insert()) + + def insert_list_item(self, text_view, _data=None): + """Insert list item or mark a selection as list item""" + + text_buffer = text_view.get_buffer() + if text_buffer.get_has_selection(): + (start, end) = text_buffer.get_selection_bounds() + if start.starts_line(): + text = text_buffer.get_text(start, end, False) + if text.startswith(("- ", "* ", "+ ")): + delete_end = start.forward_chars(2) + text_buffer.delete(start, delete_end) + else: + text_buffer.insert(start, "- ") + else: + helptext = _("Item") + text_length = len(helptext) + + cursor_mark = text_buffer.get_insert() + cursor_iter = text_buffer.get_iter_at_mark(cursor_mark) + + start_ext = cursor_iter.copy() + start_ext.backward_lines(3) + text = text_buffer.get_text(cursor_iter, start_ext, False) + lines = text.splitlines() + + for line in reversed(lines): + if line and line.startswith(("- ", "* ", "+ ")): + if cursor_iter.starts_line(): + text_buffer.insert_at_cursor(line[:2] + helptext) + else: + text_buffer.insert_at_cursor( + "\n" + line[:2] + helptext) + break + else: + if not lines[-1] and not lines[-2]: + text_buffer.insert_at_cursor("- " + helptext) + elif not lines[-1]: + if cursor_iter.starts_line(): + text_buffer.insert_at_cursor("- " + helptext) + else: + text_buffer.insert_at_cursor("\n- " + helptext) + else: + text_buffer.insert_at_cursor("\n\n- " + helptext) + break + + self.__select_text(text_view, 0, text_length) + + def insert_ordered_list_item(self, _text_view, _data=None): + # TODO: implement ordered lists + pass + + def insert_header(self, text_view, _data=None): + """Insert header or mark a selection as a list header""" + + text_buffer = text_view.get_buffer() + if text_buffer.get_has_selection(): + (start, end) = text_buffer.get_selection_bounds() + text = text_buffer.get_text(start, end, False) + text_buffer.delete(start, end) + else: + text = _("Header") + + text_buffer.insert_at_cursor("#" + " " + text) + self.__select_text(text_view, 0, len(text)) + + @staticmethod + def __wrap(text_view, wrap, helptext=""): + """Inserts wrap format to the selected text (helper text when nothing selected)""" + text_buffer = text_view.get_buffer() + if text_buffer.get_has_selection(): + # Find current highlighting + (start, end) = text_buffer.get_selection_bounds() + moved = False + if (start.get_offset() >= len(wrap) and + end.get_offset() <= text_buffer.get_char_count() - len(wrap)): + moved = True + ext_start = start.copy() + ext_start.backward_chars(len(wrap)) + ext_end = end.copy() + ext_end.forward_chars(len(wrap)) + text = text_buffer.get_text(ext_start, ext_end, True) + else: + text = text_buffer.get_text(start, end, True) + + if moved and text.startswith(wrap) and text.endswith(wrap): + text = text[len(wrap):-len(wrap)] + new_text = text + text_buffer.delete(ext_start, ext_end) + move_back = 0 + else: + if moved: + text = text[len(wrap):-len(wrap)] + new_text = text.lstrip().rstrip() + text = text.replace(new_text, wrap + new_text + wrap) + + text_buffer.delete(start, end) + move_back = len(wrap) + + text_buffer.insert_at_cursor(text) + text_length = len(new_text) + + else: + text_buffer.insert_at_cursor(wrap + helptext + wrap) + text_length = len(helptext) + move_back = len(wrap) + + cursor_mark = text_buffer.get_insert() + cursor_iter = text_buffer.get_iter_at_mark(cursor_mark) + cursor_iter.backward_chars(move_back) + text_buffer.move_mark_by_name('selection_bound', cursor_iter) + cursor_iter.backward_chars(text_length) + text_buffer.move_mark_by_name('insert', cursor_iter) + + @staticmethod + def __select_text(text_view, offset, length): + """Selects text starting at the current cursor minus offset, length characters.""" + + text_buffer = text_view.get_buffer() + cursor_mark = text_buffer.get_insert() + cursor_iter = text_buffer.get_iter_at_mark(cursor_mark) + cursor_iter.backward_chars(offset) + text_buffer.move_mark_by_name('selection_bound', cursor_iter) + cursor_iter.backward_chars(length) + text_buffer.move_mark_by_name('insert', cursor_iter) diff --git a/uberwriter/text_view_markup_handler.py b/uberwriter/text_view_markup_handler.py new file mode 100644 index 0000000..ed1d9da --- /dev/null +++ b/uberwriter/text_view_markup_handler.py @@ -0,0 +1,246 @@ +# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- +### BEGIN LICENSE +# Copyright (C) 2012, Wolf Vollprecht +# 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 . +### END LICENSE + +import re +import gi + +from uberwriter import helpers + +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from gi.repository import Pango + + +class MarkupHandler: + regex = { + "ITALIC": re.compile(r"(\*|_)(.*?)\1"), + "BOLD": re.compile(r"(\*\*|__)(.*?)\1"), + "BOLDITALIC": re.compile(r"(\*\*\*|___)(.*?)\1"), + "STRIKETHROUGH": re.compile(r"~~[^ `~\n].+?~~"), + "LIST": re.compile(r"^((?:\t|[ ]{4})*)[\-\*\+][ ].+", re.MULTILINE), + "NUMBEREDLIST": re.compile(r"^((?:\t|[ ]{4})*)((?:\d|[a-z])+[\.\)])[ ].+", re.MULTILINE), + "BLOCKQUOTE": re.compile(r"^[ ]{0,3}(?:\>|(?:\> )+).+", re.MULTILINE), + "HEADER": re.compile(r"^[ ]{0,3}(#{1,6}) .+", re.MULTILINE), + "HEADER_UNDER": re.compile(r"^[ ]{0,3}\w.+\n[ ]{0,3}[\=\-]{3,}", re.MULTILINE), + "HORIZONTALRULE": re.compile(r"^\n([ ]{0,3}[\*\-_]{3,}[ ]*)\n", re.MULTILINE), + "TABLE": re.compile(r"^[\-\+]{5,}\n(.+?)\n[\-\+]{5,}\n", re.DOTALL), + "MATH": re.compile(r"[\$]{1,2}([^` ].+?[^`\\ ])[\$]{1,2}"), + } + + def __init__(self, text_view): + self.text_view = text_view + self.text_buffer = text_view.get_buffer() + + # Styles + buffer = self.text_buffer + self.italic = buffer.create_tag('italic', + weight=Pango.Weight.NORMAL, + style=Pango.Style.ITALIC) + + self.bold = buffer.create_tag('bold', + weight=Pango.Weight.BOLD, + style=Pango.Style.NORMAL) + + self.bolditalic = buffer.create_tag('bolditalic', + weight=Pango.Weight.BOLD, + style=Pango.Style.ITALIC) + + self.graytext = buffer.create_tag('graytext', foreground='gray') + + self.strikethrough = buffer.create_tag('strikethrough', strikethrough=True) + + self.centertext = buffer.create_tag('centertext', justification=Gtk.Justification.CENTER) + + self.invisible = buffer.create_tag('invisible', invisible=True) + + self.table = buffer.create_tag('table') + self.table.set_property('wrap-mode', Gtk.WrapMode.NONE) + self.table.set_property('pixels-above-lines', 0) + self.table.set_property('pixels-below-lines', 0) + + self.mathtext = buffer.create_tag('mathtext') + + # Margins + # A default margin is set to allow negative indents for formatting headers, lists, etc + self.baseline_margin = 0 + self.margins = {} + self.update_margins() + + # Style + self.on_style_updated() + + def on_style_updated(self, *_): + (found, color) = self.text_view.get_style_context().lookup_color('math_text_color') + if not found: + (_, color) = self.text_view.get_style_context().lookup_color('foreground_color') + self.mathtext.set_property("foreground", color.to_string()) + + def apply(self, around_cursor=False): + buffer = self.text_buffer + if around_cursor: + cursor_mark = buffer.get_insert() + start = buffer.get_iter_at_mark(cursor_mark) + start.backward_lines(3) + end = buffer.get_iter_at_mark(cursor_mark) + end.forward_lines(2) + offset = start.get_offset() + else: + start = buffer.get_start_iter() + end = buffer.get_end_iter() + offset = 0 + + text = buffer.get_slice(start, end, False) + + # Remove tags + buffer.remove_tag(self.italic, start, end) + buffer.remove_tag(self.bold, start, end) + buffer.remove_tag(self.bolditalic, start, end) + buffer.remove_tag(self.strikethrough, start, end) + buffer.remove_tag(self.mathtext, start, end) + buffer.remove_tag(self.centertext, start, end) + for tag in self.margins.values(): + buffer.remove_tag(tag, start, end) + buffer.remove_tag(self.graytext, start, end) + + # Apply "_italic_" tag (italic) + matches = re.finditer(self.regex["ITALIC"], text) + for match in matches: + start_iter = buffer.get_iter_at_offset(offset + match.start()) + end_iter = buffer.get_iter_at_offset(offset + match.end()) + buffer.apply_tag(self.italic, start_iter, end_iter) + + # Apply "**bold**" tag (bold) + matches = re.finditer(self.regex["BOLD"], text) + for match in matches: + start_iter = buffer.get_iter_at_offset(offset + match.start()) + end_iter = buffer.get_iter_at_offset(offset + match.end()) + buffer.apply_tag(self.bold, start_iter, end_iter) + + # Apply "***bolditalic***" tag (bold/italic) + matches = re.finditer(self.regex["BOLDITALIC"], text) + for match in matches: + start_iter = buffer.get_iter_at_offset(offset + match.start()) + end_iter = buffer.get_iter_at_offset(offset + match.end()) + buffer.apply_tag(self.bolditalic, start_iter, end_iter) + + # Apply "~~strikethrough~~" tag (strikethrough) + matches = re.finditer(self.regex["STRIKETHROUGH"], text) + for match in matches: + start_iter = buffer.get_iter_at_offset(offset + match.start()) + end_iter = buffer.get_iter_at_offset(offset + match.end()) + buffer.apply_tag(self.strikethrough, start_iter, end_iter) + + # Apply "* list" tag (offset) + matches = re.finditer(self.regex["LIST"], text) + for match in matches: + start_iter = buffer.get_iter_at_offset(offset + match.start()) + end_iter = buffer.get_iter_at_offset(offset + match.end()) + # Lists use character+space (eg. "* ") + indent = 2 + nest = len(match.group(1).replace(" ", "\t")) + buffer.apply_tag(self.get_margin(-indent - 2 * nest), start_iter, end_iter) + + # Apply "1. numbered list" tag (offset) + matches = re.finditer(self.regex["NUMBEREDLIST"], text) + for match in matches: + start_iter = buffer.get_iter_at_offset(offset + match.start()) + end_iter = buffer.get_iter_at_offset(offset + match.end()) + # Numeric lists use numbers/letters+dot/parens+space (eg. "123. ") + indent = len(match.group(2)) + 1 + nest = len(match.group(1).replace(" ", "\t")) + buffer.apply_tag(self.get_margin(-indent - 2 * nest), start_iter, end_iter) + + # Apply "> blockquote" tag (offset) + matches = re.finditer(self.regex["BLOCKQUOTE"], text) + for match in matches: + start_iter = buffer.get_iter_at_offset(offset + match.start()) + end_iter = buffer.get_iter_at_offset(offset + match.end()) + buffer.apply_tag(self.get_margin(2), start_iter, end_iter) + + # Apply "#" tag (offset + bold) + matches = re.finditer(self.regex["HEADER"], text) + for match in matches: + start_iter = buffer.get_iter_at_offset(offset + match.start()) + end_iter = buffer.get_iter_at_offset(offset + match.end()) + indent = -len(match.group(1)) - 1 + buffer.apply_tag(self.get_margin(indent), start_iter, end_iter) + buffer.apply_tag(self.bold, start_iter, end_iter) + + # Apply "======" header underline tag (bold) + matches = re.finditer(self.regex["HEADER_UNDER"], text) + for match in matches: + start_iter = buffer.get_iter_at_offset(offset + match.start()) + end_iter = buffer.get_iter_at_offset(offset + match.end()) + buffer.apply_tag(self.bold, start_iter, end_iter) + + # Apply "---" horizontal rule tag (center) + matches = re.finditer(self.regex["HORIZONTALRULE"], text) + for match in matches: + start_iter = buffer.get_iter_at_offset(offset + match.start(1)) + end_iter = buffer.get_iter_at_offset(offset + match.end(1)) + buffer.apply_tag(self.centertext, start_iter, end_iter) + + # Apply "---" table tag (wrap/pixels) + matches = re.finditer(self.regex["TABLE"], text) + for match in matches: + start_iter = buffer.get_iter_at_offset(offset + match.start()) + end_iter = buffer.get_iter_at_offset(offset + match.end()) + buffer.apply_tag(self.table, start_iter, end_iter) + + # Apply "$math$" tag (colorize) + matches = re.finditer(self.regex["MATH"], text) + for match in matches: + start_iter = buffer.get_iter_at_offset(offset + match.start()) + end_iter = buffer.get_iter_at_offset(offset + match.end()) + buffer.apply_tag(self.mathtext, start_iter, end_iter) + + # Apply focus mode tag (grey out before/after current sentence) + if self.text_view.focus_mode: + cursor_iter = buffer.get_iter_at_mark(buffer.get_insert()) + start_sentence = cursor_iter.copy() + start_sentence.backward_sentence_start() + end_sentence = cursor_iter.copy() + end_sentence.forward_sentence_end() + if start.compare(start_sentence) <= 0: + buffer.apply_tag(self.graytext, start, start_sentence) + if end.compare(end_sentence) >= 0: + buffer.apply_tag(self.graytext, end_sentence, end) + + def get_margin(self, level): + if level not in self.margins: + char_width = helpers.get_char_width(self.text_view) + tag = self.text_buffer.create_tag("indent_" + str(level)) + tag.set_property("left-margin", max(self.baseline_margin + char_width * level, 0)) + self.margins[level] = tag + return self.margins[level] + + def update_margins(self): + char_width = helpers.get_char_width(self.text_view) + + # Adjust tab size, as character width can change + tab_array = Pango.TabArray.new(1, True) + tab_array.set_tab(0, Pango.TabAlign.LEFT, 2 * char_width) + self.text_view.set_tabs(tab_array) + + # Adjust baseline margin, as character width can change + # Baseline needs to account for + self.baseline_margin = char_width * 10 + self.text_view.set_left_margin(self.baseline_margin) + self.text_view.set_right_margin(self.baseline_margin) + + # Adjust left margins, as character width can change + for level, tag in self.margins.items(): + tag.set_property("left-margin", max(self.baseline_margin + char_width * level, 0)) diff --git a/uberwriter/text_view_scroller.py b/uberwriter/text_view_scroller.py new file mode 100644 index 0000000..9679d12 --- /dev/null +++ b/uberwriter/text_view_scroller.py @@ -0,0 +1,84 @@ +from gi.repository import Gtk + + +class Scroller: + def __init__(self): + super().__init__() + + self.smooth_scroll_starttime = 0 + self.smooth_scroll_endtime = 0 + self.smooth_scroll_acttarget = 0 + self.smooth_scroll_data = { + 'target_pos': -1, + 'source_pos': -1, + 'duration': 0 + } + self.smooth_scroll_tickid = -1 + + def scroll_to(self, text_view, mark=None, center=False): + """Scrolls if needed to ensure mark is visible. + + If mark is unspecified, the cursor is used.""" + + margin = 80 + scrolled_window = text_view.get_ancestor(Gtk.ScrolledWindow.__gtype__) + va = scrolled_window.get_vadjustment() + if va.props.page_size < margin * 2: + return + + text_buffer = text_view.get_buffer() + if mark: + ins_it = text_buffer.get_iter_at_mark(mark) + else: + ins_it = text_buffer.get_iter_at_mark(text_buffer.get_insert()) + loc_rect = text_view.get_iter_location(ins_it) + + pos_y = loc_rect.y + loc_rect.height + text_view.props.top_margin + pos = pos_y - va.props.value + target_pos = -1 + if center: + if pos != (va.props.page_size * 0.5): + target_pos = pos_y - (va.props.page_size * 0.5) + elif pos > va.props.page_size - margin: + target_pos = pos_y - va.props.page_size + margin + elif pos < margin: + target_pos = pos_y - margin + self.smooth_scroll_data = { + 'target_pos': target_pos, + 'source_pos': va.props.value, + 'duration': 2000 + } + if self.smooth_scroll_tickid == -1: + self.smooth_scroll_tickid = scrolled_window.add_tick_callback(self.on_tick) + + def on_tick(self, widget, frame_clock, _data=None): + if self.smooth_scroll_data['target_pos'] == -1: + return True + + def ease_out_cubic(value): + return pow(value - 1, 3) + 1 + + now = frame_clock.get_frame_time() + if self.smooth_scroll_acttarget != self.smooth_scroll_data['target_pos']: + self.smooth_scroll_starttime = now + self.smooth_scroll_endtime = now + self.smooth_scroll_data['duration'] * 100 + self.smooth_scroll_acttarget = self.smooth_scroll_data['target_pos'] + + if now < self.smooth_scroll_endtime: + time = float(now - self.smooth_scroll_starttime) / float( + self.smooth_scroll_endtime - self.smooth_scroll_starttime) + else: + time = 1 + pos = self.smooth_scroll_data['source_pos'] \ + + (time * (self.smooth_scroll_data['target_pos'] + - self.smooth_scroll_data['source_pos'])) + widget.get_vadjustment().props.value = pos + self.smooth_scroll_data['target_pos'] = -1 + return True + + time = ease_out_cubic(time) + pos = self.smooth_scroll_data['source_pos'] \ + + (time * (self.smooth_scroll_data['target_pos'] + - self.smooth_scroll_data['source_pos'])) + widget.get_vadjustment().props.value = pos + return True diff --git a/uberwriter/text_view_undo_redo_handler.py b/uberwriter/text_view_undo_redo_handler.py new file mode 100644 index 0000000..7ccbd1c --- /dev/null +++ b/uberwriter/text_view_undo_redo_handler.py @@ -0,0 +1,204 @@ +class UndoableInsert: + """Something has been inserted into text_buffer""" + + def __init__(self, text_iter, text, length): + self.offset = text_iter.get_offset() + self.text = text + self.length = length + self.mergeable = not bool(self.length > 1 or self.text in ("\r", "\n", " ")) + + +class UndoableDelete: + """Something has been deleted from text_buffer""" + + 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() + # Find out if backspace or delete were used to not mess up redo + insert_iter = text_buffer.get_iter_at_mark(text_buffer.get_insert()) + 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", " ")) + + +class UndoRedoHandler: + """Manages undo/redo for a given text_buffer. + + Methods can be called directly, as well as be used as signal callbacks.""" + + def __init__(self): + self.undo_stack = [] + self.redo_stack = [] + self.not_undoable_action = False + self.undo_in_progress = False + + def undo(self, text_view, _data=None): + """Undo insertions or deletions. Undone actions are moved to redo stack. + + This method can be registered to a custom undo signal, or used independently.""" + + 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() + self.undo_in_progress = False + + def redo(self, text_view, _data): + """Redo insertions or deletions. Redone actions are moved to undo stack + + This method can be registered to a custom redo signal, or used independently.""" + + 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() + self.undo_in_progress = False + + def clear(self): + self.undo_stack = [] + self.redo_stack = [] + + def on_insert_text(self, _text_buffer, text_iter, text, _length): + """Registers a text insert. Refer to TextBuffer's "insert-text" signal. + + This method must be registered to TextBuffer's "insert-text" signal, or called manually.""" + + 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) + + def on_delete_range(self, text_buffer, start_iter, end_iter): + """Registers a range deletion. Refer to TextBuffer's "delete-range" signal. + + This method must be registered to TextBuffer's "delete-range" signal, or called manually.""" + + def can_be_merged(prev, cur): + """Check if multiple deletions can be merged + + 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 + + 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): + """Toggle to stop recording actions""" + + self.not_undoable_action = True + + def __end_not_undoable_action(self): + """Toggle to start recording actions""" + + self.not_undoable_action = False diff --git a/uberwriter/window.py b/uberwriter/window.py index dcdbcd9..432f13d 100644 --- a/uberwriter/window.py +++ b/uberwriter/window.py @@ -17,7 +17,6 @@ import codecs import locale import logging -import mimetypes import os import re import subprocess @@ -27,14 +26,13 @@ from gettext import gettext as _ import gi -from uberwriter.export_dialog import UberwriterExportDialog -from uberwriter.text_editor import UberwriterTextEditor +from uberwriter.export_dialog import Export +from uberwriter.text_view import TextView gi.require_version('Gtk', '3.0') gi.require_version('WebKit2', '4.0') # pylint: disable=wrong-import-position from gi.repository import Gtk, Gdk, GObject, GLib, Gio from gi.repository import WebKit2 as WebKit -from gi.repository import Pango # pylint: disable=E0611 import cairo @@ -43,15 +41,12 @@ from uberwriter.theme import Theme from uberwriter.helpers import get_builder from uberwriter.gtkspellcheck import SpellChecker -from uberwriter.markup_buffer import MarkupBuffer -from uberwriter.inline_preview import InlinePreview from uberwriter.sidebar import Sidebar from uberwriter.search_and_replace import SearchAndReplace from uberwriter.settings import Settings from . import headerbars - # Some Globals # TODO move them somewhere for better # accesibility from other files @@ -60,8 +55,6 @@ LOGGER = logging.getLogger('uberwriter') CONFIG_PATH = os.path.expanduser("~/.config/uberwriter/") -# See texteditor_lib.Window.py for more details about how this class works - class Window(Gtk.ApplicationWindow): __gsignals__ = { @@ -85,15 +78,15 @@ class Window(Gtk.ApplicationWindow): # Set UI self.builder = get_builder('UberwriterWindow') - self.add(self.builder.get_object("FullscreenOverlay")) + root = self.builder.get_object("FullscreenOverlay") + root.connect('style-updated', self.apply_current_theme) + self.add(root) - self.set_default_size(850, 500) + self.set_default_size(900, 500) - # preferences + # Preferences self.settings = Settings.new() - self.set_name('UberwriterWindow') - # Headerbars self.headerbar = headerbars.MainHeaderbar(app) self.set_titlebar(self.headerbar.hb_container) @@ -102,20 +95,18 @@ class Window(Gtk.ApplicationWindow): self.title_end = " – UberWriter" self.set_headerbar_title("New File" + self.title_end) - self.focusmode = False - self.word_count = self.builder.get_object('word_count') self.char_count = self.builder.get_object('char_count') # Setup status bar hide after 3 seconds - self.status_bar = self.builder.get_object('status_bar_box') self.statusbar_revealer = self.builder.get_object('status_bar_revealer') - self.status_bar.get_style_context().add_class('status_bar_box') + self.status_bar.get_style_context().add_class('status-bar-box') self.status_bar_visible = True self.was_motion = True self.buffer_modified_for_status_bar = False + self.timestamp_last_mouse_motion = 0 if self.settings.get_value("poll-motion"): self.connect("motion-notify-event", self.on_motion_notify) GObject.timeout_add(3000, self.poll_for_motion) @@ -124,43 +115,20 @@ class Window(Gtk.ApplicationWindow): self.add_accel_group(self.accel_group) # Setup text editor - self.text_editor = UberwriterTextEditor() - self.text_editor.set_name('UberwriterEditor') - self.get_style_context().add_class('uberwriter_window') + self.text_view = TextView() + self.text_view.props.halign = Gtk.Align.CENTER + self.text_view.connect('focus-out-event', self.focus_out) + self.text_view.show() + self.text_view.grab_focus() - base_leftmargin = 100 - self.text_editor.set_left_margin(base_leftmargin) - self.text_editor.set_left_margin(40) - self.text_editor.set_top_margin(80) - self.text_editor.props.width_request = 600 - self.text_editor.props.halign = Gtk.Align.CENTER - self.text_editor.set_vadjustment(self.builder.get_object('vadjustment1')) - self.text_editor.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - self.text_editor.connect('focus-out-event', self.focus_out) - self.text_editor.connect('style-updated', self.apply_current_theme) - - self.text_editor.set_top_margin(80) - self.text_editor.set_bottom_margin(16) - - self.text_editor.set_pixels_above_lines(4) - self.text_editor.set_pixels_below_lines(4) - self.text_editor.set_pixels_inside_wrap(8) - - tab_array = Pango.TabArray.new(1, True) - tab_array.set_tab(0, Pango.TabAlign.LEFT, 20) - self.text_editor.set_tabs(tab_array) - - self.text_editor.show() - self.text_editor.grab_focus() + self.text_view.get_buffer().connect('changed', self.on_text_changed) # Setup preview webview self.preview_webview = None - self.editor_alignment = self.builder.get_object('editor_alignment') self.scrolled_window = self.builder.get_object('editor_scrolledwindow') - self.scrolled_window.props.width_request = 600 - self.scrolled_window.add(self.text_editor) - self.alignment_padding = 40 + self.scrolled_window.get_style_context().add_class('uberwriter-scrolled-window') + self.scrolled_window.add(self.text_view) self.editor_viewport = self.builder.get_object('editor_viewport') # some people seems to have performance problems with the overlay. @@ -169,68 +137,9 @@ class Window(Gtk.ApplicationWindow): if self.settings.get_value("gradient-overlay"): self.overlay = self.scrolled_window.connect_after("draw", self.draw_gradient) - self.smooth_scroll_starttime = 0 - self.smooth_scroll_endtime = 0 - self.smooth_scroll_acttarget = 0 - self.smooth_scroll_data = { - 'target_pos': -1, - 'source_pos': -1, - 'duration': 0 - } - self.smooth_scroll_tickid = -1 - - self.text_buffer = self.text_editor.get_buffer() - self.text_buffer.set_text('') - - # Init Window height for top/bottom padding - self.window_height = self.get_size()[1] - - self.text_change_event = self.text_buffer.connect( - 'changed', self.text_changed) - # Init file name with None self.set_filename() - # Markup and Shortcuts for the TextBuffer - self.markup_buffer = MarkupBuffer( - self, self.text_editor, base_leftmargin) - self.markup_buffer.markup_buffer() - - # Scrolling -> Dark or not? - self.textchange = False - self.scroll_count = 0 - self.timestamp_last_mouse_motion = 0 - self.text_buffer.connect_after('mark-set', self.mark_set) - - # Drag and drop - - # self.TextEditor.drag_dest_unset() - # self.TextEditor.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY) - self.target_list = Gtk.TargetList.new([]) - self.target_list.add_uri_targets(1) - self.target_list.add_text_targets(2) - - self.text_editor.drag_dest_set_target_list(self.target_list) - self.text_editor.connect_after( - 'drag-data-received', self.on_drag_data_received) - - def on_drop(_widget, *_args): - print("drop") - self.text_editor.connect('drag-drop', on_drop) - - self.text_buffer.connect('paste-done', self.paste_done) - # self.connect('key-press-event', self.alt_mod) - - # Events for Typewriter mode - - # Setting up inline preview - self.inline_preview = InlinePreview( - self.text_editor, self.text_buffer) - - # Vertical scrolling - self.vadjustment = self.scrolled_window.get_vadjustment() - self.vadjustment.connect('value-changed', self.scrolled) - # Setting up spellcheck self.auto_correct = None self.toggle_spellcheck(self.settings.get_value("spellcheck")) @@ -248,7 +157,7 @@ class Window(Gtk.ApplicationWindow): # Search and replace initialization # Same interface as Sidebar ;) ### - self.searchreplace = SearchAndReplace(self) + self.searchreplace = SearchAndReplace(self, self.text_view) # Window resize self.window_resize(self) @@ -257,6 +166,7 @@ class Window(Gtk.ApplicationWindow): # Set current theme self.apply_current_theme() + self.get_style_context().add_class('uberwriter-window') def apply_current_theme(self, *_): """Adjusts the window, CSD and preview for the current theme. @@ -284,58 +194,14 @@ class Window(Gtk.ApplicationWindow): # Redraw contents of window self.queue_draw() - def scrolled(self, widget): - """if window scrolled + focusmode make font black again""" - # if self.focusmode: - # if self.textchange == False: - # if self.scroll_count >= 4: - # self.TextBuffer.apply_tag( - # self.MarkupBuffer.blackfont, - # self.TextBuffer.get_start_iter(), - # self.TextBuffer.get_end_iter()) - # else: - # self.scroll_count += 1 - # else: - # self.scroll_count = 0 - # self.textchange = False - - def paste_done(self, *_): - self.markup_buffer.markup_buffer(0) - - def init_typewriter(self): - """put the cursor at the center of the screen by setting top and - bottom margins to height/2 - """ - - editor_height = self.text_editor.get_allocation().height - self.text_editor.props.top_margin = editor_height / 2 - self.text_editor.props.bottom_margin = editor_height / 2 - - def remove_typewriter(self): - """set margins to default values - """ - - self.text_editor.props.top_margin = 80 - self.text_editor.props.bottom_margin = 16 - self.text_change_event = self.text_buffer.connect( - 'changed', self.text_changed) - - def get_text(self): - """get text from self.text_buffer - """ - - start_iter = self.text_buffer.get_start_iter() - end_iter = self.text_buffer.get_end_iter() - return self.text_buffer.get_text(start_iter, end_iter, False) - def update_line_and_char_count(self): """it... it updates line and characters count """ if self.status_bar_visible is False: return - self.char_count.set_text(str(self.text_buffer.get_char_count())) - text = self.get_text() + text = self.text_view.get_text() + self.char_count.set_text(str(len(text))) words = re.split(self.WORDCOUNT, text) length = len(words) # Last word a "space" @@ -348,12 +214,7 @@ class Window(Gtk.ApplicationWindow): length = 0 self.word_count.set_text(str(length)) - def mark_set(self, _buffer, _location, mark, _data=None): - if mark.get_name() in ['insert', 'gtk_drag_target']: - self.check_scroll(mark) - return True - - def text_changed(self, *_args): + def on_text_changed(self, *_args): """called when the text changes, sets the self.did_change to true and updates the title and the counters to reflect that """ @@ -363,12 +224,8 @@ class Window(Gtk.ApplicationWindow): title = self.get_title() self.set_headerbar_title("* " + title) - self.markup_buffer.markup_buffer(1) - self.textchange = True - self.buffer_modified_for_status_bar = True self.update_line_and_char_count() - self.check_scroll(self.text_buffer.get_insert()) def set_fullscreen(self, state): """Puts the application in fullscreen mode and show/hides @@ -386,178 +243,52 @@ class Window(Gtk.ApplicationWindow): self.unfullscreen() self.fs_headerbar.events.hide() - self.text_editor.grab_focus() + self.text_view.grab_focus() def set_focus_mode(self, state): """toggle focusmode """ - if state.get_boolean(): - self.init_typewriter() - self.markup_buffer.focusmode_highlight() - self.focusmode = True - self.text_editor.grab_focus() - self.check_scroll(self.text_buffer.get_insert()) - if self.spell_checker: - self.spell_checker._misspelled.set_property('underline', 0) - self.click_event = self.text_editor.connect("button-release-event", - self.on_focusmode_click) - else: - self.remove_typewriter() - self.focusmode = False - self.text_buffer.remove_tag(self.markup_buffer.unfocused_text, - self.text_buffer.get_start_iter(), - self.text_buffer.get_end_iter()) - self.text_buffer.remove_tag(self.markup_buffer.blackfont, - self.text_buffer.get_start_iter(), - self.text_buffer.get_end_iter()) - - self.markup_buffer.markup_buffer(1) - self.text_editor.grab_focus() - self.update_line_and_char_count() - self.check_scroll() - if self.spell_checker: - self.spell_checker._misspelled.set_property('underline', 4) - _click_event = self.text_editor.disconnect(self.click_event) + focus_mode = state.get_boolean() + self.text_view.set_focus_mode(focus_mode) + if self.spell_checker: + self.spell_checker._misspelled.set_property('underline', 0 if focus_mode else 4) + self.text_view.grab_focus() def set_hemingway_mode(self, state): """toggle hemingwaymode """ - self.text_editor.can_delete = not state.get_boolean() - self.text_editor.grab_focus() - def on_focusmode_click(self, *_args): - """call MarkupBuffer to mark as bold the line where the cursor is - """ + self.text_view.set_hemingway_mode(state.get_boolean()) + self.text_view.grab_focus() - self.markup_buffer.markup_buffer(1) - - def scroll_smoothly(self, widget, frame_clock, _data=None): - if self.smooth_scroll_data['target_pos'] == -1: - return True - - def ease_out_cubic(time): - time = time - 1 - return pow(time, 3) + 1 - - now = frame_clock.get_frame_time() - if self.smooth_scroll_acttarget != self.smooth_scroll_data['target_pos']: - self.smooth_scroll_starttime = now - self.smooth_scroll_endtime = now + \ - self.smooth_scroll_data['duration'] * 100 - self.smooth_scroll_acttarget = self.smooth_scroll_data['target_pos'] - - if now < self.smooth_scroll_endtime: - time = float(now - self.smooth_scroll_starttime) / float( - self.smooth_scroll_endtime - self.smooth_scroll_starttime) - else: - time = 1 - pos = self.smooth_scroll_data['source_pos'] \ - + (time * (self.smooth_scroll_data['target_pos'] - - self.smooth_scroll_data['source_pos'])) - widget.get_vadjustment().props.value = pos - self.smooth_scroll_data['target_pos'] = -1 - return True - - time = ease_out_cubic(time) - pos = self.smooth_scroll_data['source_pos'] \ - + (time * (self.smooth_scroll_data['target_pos'] - - self.smooth_scroll_data['source_pos'])) - widget.get_vadjustment().props.value = pos - return True # continue ticking - - def check_scroll(self, mark=None): - gradient_offset = 80 - buf = self.text_editor.get_buffer() - if mark: - ins_it = buf.get_iter_at_mark(mark) - else: - ins_it = buf.get_iter_at_mark(buf.get_insert()) - loc_rect = self.text_editor.get_iter_location(ins_it) - - # alignment offset added from top - pos_y = loc_rect.y + loc_rect.height + self.text_editor.props.top_margin # pylint: disable=no-member - - ha = self.scrolled_window.get_vadjustment() - if ha.props.page_size < gradient_offset: - return - pos = pos_y - ha.props.value - # print("pos: %i, pos_y %i, page_sz: %i, val: %i" % (pos, pos_y, ha.props.page_size - # - gradient_offset, ha.props.value)) - # global t, amount, initvadjustment - target_pos = -1 - if self.focusmode: - # print("pos: %i > %i" % (pos, ha.props.page_size * 0.5)) - if pos != (ha.props.page_size * 0.5): - target_pos = pos_y - (ha.props.page_size * 0.5) - elif pos > ha.props.page_size - gradient_offset - 60: - target_pos = pos_y - ha.props.page_size + gradient_offset + 40 - elif pos < gradient_offset: - target_pos = pos_y - gradient_offset - self.smooth_scroll_data = { - 'target_pos': target_pos, - 'source_pos': ha.props.value, - 'duration': 2000 - } - if self.smooth_scroll_tickid == -1: - self.smooth_scroll_tickid = self.scrolled_window.add_tick_callback( - self.scroll_smoothly) - - def window_resize(self, widget, _data=None): + def window_resize(self, window, _data=None): """set paddings dependant of the window size """ - # To calc padding top / bottom - self.window_height = widget.get_allocation().height - w_width = widget.get_allocation().width - # Calculate left / right margin + # Adjust text editor width depending on window width, so that: + # - The number of characters per line is adequate (http://webtypography.net/2.1.2) + # - The number of characters stays constant while resizing the window / font + # - There is enough text margin for MarkupBuffer to apply indents / negative margins + w_width = window.get_allocation().width if w_width < 900: - width_request = 600 - self.markup_buffer.set_multiplier(8) - self.current_font_size = 12 - self.alignment_padding = 30 - lm = 7 * 8 - self.get_style_context().remove_class("medium") - self.get_style_context().remove_class("large") + width_request = 700 # ~66 characters self.get_style_context().add_class("small") + self.get_style_context().remove_class("large") - elif w_width < 1400: - width_request = 800 - self.markup_buffer.set_multiplier(10) - self.current_font_size = 15 - self.alignment_padding = 40 - lm = 7 * 10 + elif w_width < 1200: + width_request = 870 # ~66 characters self.get_style_context().remove_class("small") self.get_style_context().remove_class("large") - self.get_style_context().add_class("medium") else: - width_request = 1000 - self.markup_buffer.set_multiplier(13) - self.current_font_size = 17 - self.alignment_padding = 60 - lm = 7 * 13 - self.get_style_context().remove_class("medium") + width_request = 830 # ~66 characters self.get_style_context().remove_class("small") self.get_style_context().add_class("large") - self.editor_alignment.props.margin_bottom = 0 - self.editor_alignment.props.margin_top = 0 - self.text_editor.set_left_margin(lm) - self.text_editor.set_right_margin(lm) - - self.markup_buffer.recalculate(lm) - - if self.focusmode: - self.remove_typewriter() - self.init_typewriter() - - if self.text_editor.props.width_request != width_request: # pylint: disable=no-member - self.text_editor.props.width_request = width_request + if self.text_view.props.width_request != width_request: + self.text_view.props.width_request = width_request self.scrolled_window.props.width_request = width_request - alloc = self.text_editor.get_allocation() - alloc.width = width_request - self.text_editor.size_allocate(alloc) # TODO: refactorizable def save_document(self, _widget=None, _data=None): @@ -569,7 +300,7 @@ class Window(Gtk.ApplicationWindow): LOGGER.info("saving") filename = self.filename file_to_save = codecs.open(filename, encoding="utf-8", mode='w') - file_to_save.write(self.get_text()) + file_to_save.write(self.text_view.get_text()) file_to_save.close() if self.did_change: self.did_change = False @@ -603,7 +334,7 @@ class Window(Gtk.ApplicationWindow): pass file_to_save = codecs.open(filename, encoding="utf-8", mode='w') - file_to_save.write(self.get_text()) + file_to_save.write(self.text_view.get_text()) file_to_save.close() self.set_filename(filename) @@ -644,7 +375,7 @@ class Window(Gtk.ApplicationWindow): pass file_to_save = codecs.open(filename, encoding="utf-8", mode='w') - file_to_save.write(self.get_text()) + file_to_save.write(self.text_view.get_text()) file_to_save.close() self.set_filename(filename) @@ -671,7 +402,7 @@ class Window(Gtk.ApplicationWindow): proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE) - text = bytes(self.get_text(), "utf-8") + text = bytes(self.text_view.get_text(), "utf-8") output = proc.communicate(text)[0] clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) @@ -711,14 +442,14 @@ class Window(Gtk.ApplicationWindow): """Show dialog to prevent loss of unsaved changes """ - if self.did_change and self.get_text(): + if self.did_change and self.text_view.get_text(): dialog = Gtk.MessageDialog(self, Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT, Gtk.MessageType.WARNING, Gtk.ButtonsType.NONE, _("You have not saved your changes.") ) - dialog.add_button(_("Close without Saving"), Gtk.ResponseType.NO) + dialog.add_button(_("Close without saving"), Gtk.ResponseType.NO) dialog.add_button(_("Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(_("Save now"), Gtk.ResponseType.YES) # dialog.set_default_size(200, 60) @@ -745,9 +476,7 @@ class Window(Gtk.ApplicationWindow): if self.check_change() == Gtk.ResponseType.CANCEL: return - self.text_buffer.set_text('') - self.text_editor.undos = [] - self.text_editor.redos = [] + self.text_view.clear() self.did_change = False self.set_filename() @@ -771,7 +500,7 @@ class Window(Gtk.ApplicationWindow): except: try: self.spell_checker = SpellChecker( - self.text_editor, locale.getdefaultlocale()[0], + self.text_view, locale.getdefaultlocale()[0], collapse=False) if self.auto_correct: self.auto_correct.set_language(self.spell_checker.language) @@ -787,7 +516,7 @@ class Window(Gtk.ApplicationWindow): _("You can not enable the Spell Checker.") ) dialog.format_secondary_text( - _("Please install 'hunspell' or 'aspell' dictionarys" + _("Please install 'hunspell' or 'aspell' dictionaries" + " for your language from the software center.")) _response = dialog.run() return @@ -799,46 +528,6 @@ class Window(Gtk.ApplicationWindow): pass return - def on_drag_data_received(self, _widget, drag_context, _x, _y, - data, info, time): - """Handle drag and drop events""" - if info == 1: - # uri target - uris = data.get_uris() - for uri in uris: - uri = urllib.parse.unquote_plus(uri) - mime = mimetypes.guess_type(uri) - - if mime[0] is not None and mime[0].startswith('image'): - if uri.startswith("file://"): - uri = uri[7:] - text = "![Insert image title here](%s)" % uri - limit_left = 2 - limit_right = 23 - else: - text = "[Insert link title here](%s)" % uri - limit_left = 1 - limit_right = 22 - self.text_buffer.place_cursor(self.text_buffer.get_iter_at_mark( - self.text_buffer.get_mark('gtk_drag_target'))) - self.text_buffer.insert_at_cursor(text) - insert_mark = self.text_buffer.get_insert() - selection_bound = self.text_buffer.get_selection_bound() - cursor_iter = self.text_buffer.get_iter_at_mark(insert_mark) - cursor_iter.backward_chars(len(text) - limit_left) - self.text_buffer.move_mark(insert_mark, cursor_iter) - cursor_iter.forward_chars(limit_right) - self.text_buffer.move_mark(selection_bound, cursor_iter) - - elif info == 2: - # Text target - self.text_buffer.place_cursor(self.text_buffer.get_iter_at_mark( - self.text_buffer.get_mark('gtk_drag_target'))) - self.text_buffer.insert_at_cursor(data.get_text()) - Gtk.drag_finish(drag_context, True, True, time) - self.present() - return False - def toggle_preview(self, state): """Toggle the preview mode @@ -855,8 +544,8 @@ class Window(Gtk.ApplicationWindow): def show_text_editor(self): self.scrolled_window.remove(self.scrolled_window.get_child()) - self.scrolled_window.add(self.text_editor) - self.text_editor.show() + self.scrolled_window.add(self.text_view) + self.text_view.show() self.preview_webview.destroy() self.preview_webview = None self.queue_draw() @@ -868,12 +557,6 @@ class Window(Gtk.ApplicationWindow): self.preview_webview.show() self.queue_draw() else: - # Insert a tag with ID to scroll to - # self.TextBuffer.insert_at_cursor('') - # TODO - # Find a way to find the next header, scroll to the next header. - # TODO: provide a local version of mathjax - # We need to convert relative routes to absolute ones # For that first we need to know if the file is saved: if self.filename: @@ -899,19 +582,13 @@ class Window(Gtk.ApplicationWindow): proc = subprocess.Popen( args, stdin=subprocess.PIPE, stdout=subprocess.PIPE) - text = bytes(self.get_text(), "utf-8") + text = bytes(self.text_view.get_text(), "utf-8") output = proc.communicate(text)[0] if self.preview_webview is None: self.preview_webview = WebKit.WebView() self.preview_webview.get_settings().set_allow_universal_access_from_file_urls(True) - # Delete the cursor-scroll mark again - # cursor_iter = self.TextBuffer.get_iter_at_mark(self.TextBuffer.get_insert()) - # begin_del = cursor_iter.copy() - # begin_del.backward_chars(30) - # self.TextBuffer.delete(begin_del, cursor_iter) - # Show preview once the load is finished self.preview_webview.connect("load-changed", self.on_preview_load_change) @@ -930,19 +607,14 @@ class Window(Gtk.ApplicationWindow): if filename.startswith('file://'): filename = filename[7:] filename = urllib.parse.unquote_plus(filename) + self.text_view.clear() try: - if not os.path.exists(filename): - self.text_buffer.set_text("") - else: + if os.path.exists(filename): current_file = codecs.open(filename, encoding="utf-8", mode='r') - self.text_buffer.set_text(current_file.read()) + self.text_view.set_text(current_file.read()) current_file.close() - self.markup_buffer.markup_buffer(0) - self.set_headerbar_title( - os.path.basename(filename) + self.title_end) - self.text_editor.undo_stack = [] - self.text_editor.redo_stack = [] + self.set_headerbar_title(os.path.basename(filename) + self.title_end) self.set_filename(filename) except Exception: @@ -969,12 +641,12 @@ class Window(Gtk.ApplicationWindow): """open the export and advanced export dialog """ - self.export = UberwriterExportDialog(self.filename) + self.export = Export(self.filename) self.export.dialog.set_transient_for(self) response = self.export.dialog.run() if response == 1: - self.export.export(bytes(self.get_text(), "utf-8")) + self.export.export(bytes(self.text_view.get_text(), "utf-8")) self.export.dialog.destroy() @@ -998,7 +670,7 @@ class Window(Gtk.ApplicationWindow): if (self.was_motion is False and self.status_bar_visible and self.buffer_modified_for_status_bar - and self.text_editor.props.has_focus): # pylint: disable=no-member + and self.text_view.props.has_focus): # pylint: disable=no-member # self.status_bar.set_state_flags(Gtk.StateFlags.INSENSITIVE, True) self.statusbar_revealer.set_reveal_child(False) self.headerbar.hb_revealer.set_reveal_child(False) @@ -1071,24 +743,6 @@ class Window(Gtk.ApplicationWindow): cr.set_source(lg_btm) cr.fill() - def use_experimental_features(self, _val): - """use experimental features - """ - pass - # try: - # self.auto_correct = AutoCorrect( - # self.text_editor, self.text_buffer) - # except: - # LOGGER.debug("Couldn't install autocorrect.") - - # self.plugins = [BibTex(self)] - - # def alt_mod(self, _widget, event, _data=None): - # # TODO: Click and open when alt is pressed - # if event.state & Gdk.ModifierType.MOD2_MASK: - # LOGGER.info("Alt pressed") - # return - def on_delete_called(self, _widget, _data=None): """Called when the TexteditorWindow is closed. """