forked from Mirrors/apostrophe
Merge pull request #167 from goncalossilva/ft.qol
"Quality of life" improvementsgithub/fork/yochananmarqos/patch-1
commit
d75a14d0c5
|
@ -1,59 +0,0 @@
|
|||
#!/usr/bin/python3
|
||||
# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
|
||||
### BEGIN LICENSE
|
||||
# Copyright (C) 2019, Wolf Vollprecht <w.vollprecht@gmail.com>
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License version 3, as published
|
||||
# by the Free Software Foundation.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranties of
|
||||
# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
|
||||
# PURPOSE. See the GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
### END LICENSE
|
||||
|
||||
### DO NOT EDIT THIS FILE ###
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
import pkg_resources
|
||||
|
||||
import gettext
|
||||
import locale
|
||||
|
||||
# Add project root directory (enable symlink and trunk execution)
|
||||
PROJECT_ROOT_DIRECTORY = os.path.abspath(
|
||||
os.path.dirname(os.path.dirname(os.path.realpath(sys.argv[0]))))
|
||||
|
||||
# Set the path if needed. This allows uberwriter to run without installing it :)
|
||||
python_path = []
|
||||
if os.path.abspath(__file__).startswith('/opt'):
|
||||
gettext.bindtextdomain('uberwriter', '/opt/extras.ubuntu.com/uberwriter/share/locale')
|
||||
syspath = sys.path[:] # copy to avoid infinite loop in pending objects
|
||||
for path in syspath:
|
||||
opt_path = path.replace('/usr', '/opt/extras.ubuntu.com/uberwriter')
|
||||
python_path.insert(0, opt_path)
|
||||
sys.path.insert(0, opt_path)
|
||||
os.putenv("XDG_DATA_DIRS", "%s:%s" % ("/opt/extras.ubuntu.com/uberwriter/share/", os.getenv("XDG_DATA_DIRS", "/usr/local/share/:/usr/share/")))
|
||||
if (os.path.exists(os.path.join(PROJECT_ROOT_DIRECTORY, 'uberwriter'))
|
||||
and PROJECT_ROOT_DIRECTORY not in sys.path):
|
||||
python_path.insert(0, PROJECT_ROOT_DIRECTORY)
|
||||
sys.path.insert(0, PROJECT_ROOT_DIRECTORY)
|
||||
if python_path:
|
||||
os.putenv('PYTHONPATH', "%s:%s" % (os.getenv('PYTHONPATH', ''), ':'.join(python_path))) # for subprocesses
|
||||
|
||||
import uberwriter
|
||||
|
||||
locale_dir = os.path.abspath(os.path.join(os.path.dirname(uberwriter.__file__),'../po/'))
|
||||
|
||||
# L10n
|
||||
locale.textdomain('uberwriter')
|
||||
locale.bindtextdomain('uberwriter', locale_dir)
|
||||
gettext.textdomain('uberwriter')
|
||||
gettext.bindtextdomain('uberwriter', locale_dir)
|
||||
|
||||
uberwriter.main()
|
|
@ -17,7 +17,7 @@
|
|||
bind "<ctl><shift>z" { "redo" () };
|
||||
}
|
||||
|
||||
@define-color math_text_color mix(@theme_fg_color, #00b5ff, 0.15);
|
||||
@define-color code_bg_color mix(@theme_base_color, @theme_fg_color, 0.05);
|
||||
|
||||
/* Main window and text colors */
|
||||
|
||||
|
@ -117,63 +117,37 @@
|
|||
background-color: mix(@theme_base_color, @theme_bg_color, 0.5);
|
||||
}
|
||||
|
||||
#PreviewMenuItem image {
|
||||
border-radius: 2px;
|
||||
color: #666;
|
||||
padding: 3px 5px;
|
||||
border: none;
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.uberwriter-window treeview {
|
||||
padding: 3px 3px 3px 3px;
|
||||
padding: 4px 4px 4px 4px;
|
||||
}
|
||||
|
||||
#LexikonBubble {
|
||||
/*font: serif 10;*/
|
||||
font-family: serif;
|
||||
font-size: 10px;
|
||||
background: @theme_bg_color;
|
||||
border-radius: 4px;
|
||||
border-color: @theme_bg_color;
|
||||
margin: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
/* .quick-preview-popup {
|
||||
padding: 5px;
|
||||
margin: 5px;
|
||||
border: 1px solid #333;
|
||||
background: @ligth_bg;
|
||||
border-radius: 3px;
|
||||
border-color: @theme_bg_color;
|
||||
} */
|
||||
|
||||
#LexikonBubble label {
|
||||
/*padding: 5px;*/
|
||||
}
|
||||
|
||||
#LexikonBubble {
|
||||
background-color: @theme_bg_color;
|
||||
border: 5px solid @theme_bg_color;
|
||||
}
|
||||
|
||||
#LexikonBubble .lexikon-heading {
|
||||
.lexikon {
|
||||
font-family: serif;
|
||||
font-size: 12px;
|
||||
padding-bottom: 5px;
|
||||
padding-top: 5px;
|
||||
font-weight: bold;
|
||||
padding-left: 10px;
|
||||
background: @theme_bg_color;
|
||||
border: 4px solid @theme_bg_color;
|
||||
}
|
||||
|
||||
#LexikonBubble .lexikon-num {
|
||||
padding-right: 5px;
|
||||
padding-left: 20px;
|
||||
.lexikon .header {
|
||||
font-family: serif;
|
||||
font-size: 14px;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.lexikon .header.first {
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.lexikon .number {
|
||||
padding-left: 16px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.quick-preview-popup {
|
||||
background-color: @theme_bg_color;
|
||||
padding: 8px 12px 8px 12px;
|
||||
}
|
||||
|
||||
.quick-preview-popup grid {
|
||||
|
|
|
@ -95,7 +95,7 @@ To give your document some meta-information and a nice title, you can use title
|
|||
|
||||
Emphasizing some text is done by surrounding it with *s:
|
||||
|
||||
This is *emphasized with asterisks*, and this will be a **bold text**. And even more ***krass***. And if you want to erase something: ~~completely gone~~ (sorrounded by ~)
|
||||
This is *emphasized with asterisks*, and this will be a **bold text**. And even more ***krass***. And if you want to erase something: ~~completely gone~~ (surrounded by ~)
|
||||
|
||||
### Horizontal Rules
|
||||
|
||||
|
|
|
@ -21,11 +21,11 @@
|
|||
</section>
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">Save _As</attribute>
|
||||
<attribute name="label" translatable="yes">Save As</attribute>
|
||||
<attribute name="action">app.save_as</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">_Export</attribute>
|
||||
<attribute name="label" translatable="yes">Export</attribute>
|
||||
<attribute name="action">app.export</attribute>
|
||||
</item>
|
||||
<item>
|
||||
|
@ -33,6 +33,12 @@
|
|||
<attribute name="action">app.copy_html</attribute>
|
||||
</item>
|
||||
</section>
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">Find and Replace</attribute>
|
||||
<attribute name="action">app.search_replace</attribute>
|
||||
</item>
|
||||
</section>
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">Preferences</attribute>
|
||||
|
|
|
@ -92,14 +92,21 @@
|
|||
<child>
|
||||
<object class="GtkShortcutsGroup" id="search">
|
||||
<property name="visible">True</property>
|
||||
<property name="title" translatable="yes" context="shortcut window">Search</property>
|
||||
<property name="title" translatable="yes" context="shortcut window">Find</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut" id="s1-10">
|
||||
<property name="visible">True</property>
|
||||
<property name="title" translatable="yes" context="shortcut window">Search</property>
|
||||
<property name="title" translatable="yes" context="shortcut window">Find</property>
|
||||
<property name="accelerator"><Primary>f</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut" id="s1-11">
|
||||
<property name="visible">True</property>
|
||||
<property name="title" translatable="yes" context="shortcut window">Find and replace</property>
|
||||
<property name="accelerator"><Primary>h</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
<object class="GtkImage" id="edit-find-replace">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="xpad">12</property>
|
||||
<property name="icon_name">edit-find-replace-symbolic</property>
|
||||
</object>
|
||||
<object class="GtkImage" id="go-up">
|
||||
|
@ -268,7 +269,7 @@
|
|||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="tooltip_text" translatable="yes">Open Replace</property>
|
||||
<property name="tooltip_text" translatable="yes">Replace</property>
|
||||
<property name="image">edit-find-replace</property>
|
||||
</object>
|
||||
<packing>
|
||||
|
@ -360,7 +361,7 @@
|
|||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="replace_all">
|
||||
<property name="label" translatable="yes">Replace all</property>
|
||||
<property name="label" translatable="yes">Replace All</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
|
|
|
@ -1,121 +0,0 @@
|
|||
{
|
||||
"id": "de.wolfvollprecht.UberWriter.Plugin.WebPhoto",
|
||||
"runtime": "de.wolfvollprecht.UberWriter",
|
||||
"branch": "stable",
|
||||
"sdk": "org.gnome.Sdk//3.26",
|
||||
"build-extension": true,
|
||||
"separate-locales": false,
|
||||
"appstream-compose": false,
|
||||
"finish-args": [
|
||||
],
|
||||
"build-options" : {
|
||||
"prefix": "/app/extensions/WebPhoto",
|
||||
"env": {
|
||||
"PATH": "/app/extensions/TexLive/bin:/app/extensions/TexLive/2018/bin/x86_64-linux:/app/bin:/usr/bin"
|
||||
}
|
||||
},
|
||||
"cleanup": [],
|
||||
"modules": [
|
||||
{
|
||||
"name": "Glib2",
|
||||
"sources": [
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "http://ftp.gnome.org/pub/gnome/sources/glib/2.56/glib-2.56.1.tar.xz",
|
||||
"sha256": "40ef3f44f2c651c7a31aedee44259809b6f03d3d20be44545cd7d177221c0b8d"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "LibIDL",
|
||||
"buildsystem": "autotools",
|
||||
"sources": [
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://github.com/GNOME/libIDL/",
|
||||
"tag": "LIBIDL_0_8_14",
|
||||
"commit": "666fcbf086fb859738b67417c99a9895bb3d8ce5"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ORBit2",
|
||||
"rm-configure": true,
|
||||
"config-opts": ["--prefix=/app/extensions/WebPhoto"],
|
||||
"build-options": {
|
||||
"env":{
|
||||
"PKG_CONFIG_PATH": "/app/extensions/WebPhoto/lib/pkgconfig",
|
||||
"GNOME2_DIR": "/app/extensions/WebPhoto",
|
||||
"LD_LIBRARY_PATH": "/app/extensions/WebPhoto/lib",
|
||||
"PATH": "/app/extensions/WebPhoto/bin:/usr/bin"
|
||||
}
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "http://ftp.gnome.org/pub/gnome/sources/ORBit2/2.14/ORBit2-2.14.19.tar.bz2",
|
||||
"sha256": "55c900a905482992730f575f3eef34d50bda717c197c97c08fa5a6eafd857550"
|
||||
},
|
||||
{
|
||||
"type": "patch",
|
||||
"path": "ORBit2.patch"
|
||||
},
|
||||
{
|
||||
"type": "script",
|
||||
"dest-filename": "autogen.sh",
|
||||
"commands": [
|
||||
"autoreconf -fi"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gconf",
|
||||
"buildsystem": "autotools",
|
||||
"config-opts": ["--prefix=/app/extensions/WebPhoto"],
|
||||
"build-options": {
|
||||
"env":{
|
||||
"PKG_CONFIG_PATH": "/app/extensions/WebPhoto/lib/pkgconfig",
|
||||
"GNOME2_DIR": "/app/extensions/WebPhoto",
|
||||
"LD_LIBRARY_PATH": "/app/extensions/WebPhoto/lib",
|
||||
"PATH": "/app/extensions/WebPhoto/bin:/usr/bin"
|
||||
}
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "http://ftp.gnome.org/pub/GNOME/sources/GConf/3.2/GConf-3.2.6.tar.xz",
|
||||
"sha256": "1912b91803ab09a5eed34d364bf09fe3a2a9c96751fde03a4e0cfa51a04d784c"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gnome-web-photo",
|
||||
"buildsystem": "autotools",
|
||||
"config-opts": [
|
||||
"--with-gtk=3.0",
|
||||
"--prefix=/app/extensions/WebPhoto"
|
||||
],
|
||||
"build-options": {
|
||||
"env":{
|
||||
"LD_LIBRARY_PATH": "/app/extensions/WebPhoto/lib",
|
||||
"PATH": "/app/bin:/app/extensions/WebPhoto/bin:/usr/bin",
|
||||
"ACLOCAL_PATH": "/app/extensions/WebPhoto/share/aclocal"
|
||||
}
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://github.com/GNOME/gnome-web-photo/",
|
||||
"tag": "0.10.6",
|
||||
"commit": "827d6b98c120b4dd8d689a1faf52450685ca6d46"
|
||||
},
|
||||
{
|
||||
"type": "patch",
|
||||
"path": "GnomeWebPhoto.patch"
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
#!/usr/bin/python3
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# Generates color palettes based on the specified background/foreground colors.
|
||||
#
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#!/usr/bin/python
|
||||
#!/usr/bin/env python
|
||||
# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
|
||||
### BEGIN LICENSE
|
||||
# Copyright (C) 2019, Wolf Vollprecht <w.vollprecht@gmail.com>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#!/usr/bin/python
|
||||
#!/usr/bin/env python
|
||||
# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
|
||||
### BEGIN LICENSE
|
||||
# Copyright (C) 2019, Wolf Vollprecht <w.vollprecht@gmail.com>
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
|
||||
### BEGIN LICENSE
|
||||
# Copyright (C) 2019, Wolf Vollprecht <w.vollprecht@gmail.com>
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License version 3, as published
|
||||
# by the Free Software Foundation.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranties of
|
||||
# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
|
||||
# PURPOSE. See the GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
### END LICENSE
|
||||
|
||||
import unittest
|
||||
import re
|
||||
|
||||
from uberwriter import markup_regex
|
||||
|
||||
|
||||
class TestRegex(unittest.TestCase):
|
||||
"""Test cases based on CommonMark's specs and demo:
|
||||
- https://spec.commonmark.org/
|
||||
- https://spec.commonmark.org/dingus/
|
||||
|
||||
CommonMark is the Markdown variant chosen as first-class. It's great and encouraged that
|
||||
others are supported as well, but when in conflict or undecided, CommonMark should be picked.
|
||||
|
||||
TODO: Use decorators. This needs decorators everywhere.
|
||||
"""
|
||||
|
||||
def test_bold(self):
|
||||
test_texts = {
|
||||
"**bold**": "bold",
|
||||
"__bold__": "bold",
|
||||
"This is **bold** text": "bold",
|
||||
"This is __bold__ text": "bold",
|
||||
"before**middle**end": "middle",
|
||||
"before** middle **end": " middle ",
|
||||
"empty * * bold": None
|
||||
}
|
||||
|
||||
for test, result in test_texts.items():
|
||||
with self.subTest(name=test):
|
||||
match = re.search(markup_regex.BOLD, test)
|
||||
if not match:
|
||||
self.assertFalse(result, msg=test)
|
||||
else:
|
||||
self.assertEqual(match.group("text"), result, msg=test)
|
||||
|
||||
def test_header(self):
|
||||
test_texts = {
|
||||
"# Header 1": "Header 1",
|
||||
"## Header 2": "Header 2",
|
||||
"### Header 3": "Header 3",
|
||||
"#### Header 4": "Header 4",
|
||||
"##### Header 5": "Header 5",
|
||||
"###### Header 6": "Header 6",
|
||||
"#": None,
|
||||
"#######": None,
|
||||
"before\n# Header\nafter": "Header"
|
||||
}
|
||||
|
||||
for test, result in test_texts.items():
|
||||
with self.subTest(name=test):
|
||||
match = re.search(markup_regex.HEADER, test)
|
||||
if not match:
|
||||
self.assertFalse(result, msg=test)
|
||||
else:
|
||||
self.assertEqual(match.group("text"), result, msg=test)
|
||||
|
||||
def test_header_under(self):
|
||||
test_texts = {
|
||||
"Header 1\n=": "Header 1",
|
||||
"Header 1##\n=": "Header 1##",
|
||||
"Header 2\n-- \n": "Header 2",
|
||||
"Header 1\n=f": None,
|
||||
"Header 1\n =": "Header 1"
|
||||
}
|
||||
|
||||
for test, result in test_texts.items():
|
||||
with self.subTest(name=test):
|
||||
match = re.search(markup_regex.HEADER_UNDER, test)
|
||||
if not match:
|
||||
self.assertFalse(result, msg=test)
|
||||
else:
|
||||
self.assertEqual(match.group("text"), result, msg=test)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
|
@ -1,4 +1,4 @@
|
|||
#!/usr/bin/python3
|
||||
#!/usr/bin/env python3
|
||||
# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
|
||||
### BEGIN LICENSE
|
||||
# Copyright (C) 2019, Wolf Vollprecht <w.vollprecht@gmail.com>
|
||||
|
|
|
@ -98,6 +98,10 @@ class Application(Gtk.Application):
|
|||
action.connect("activate", self.on_copy_html)
|
||||
self.add_action(action)
|
||||
|
||||
action = Gio.SimpleAction.new("search_replace", None)
|
||||
action.connect("activate", self.on_search_replace)
|
||||
self.add_action(action)
|
||||
|
||||
action = Gio.SimpleAction.new("preferences", None)
|
||||
action.connect("activate", self.on_preferences)
|
||||
self.add_action(action)
|
||||
|
@ -143,6 +147,7 @@ class Application(Gtk.Application):
|
|||
self.set_accels_for_action("app.fullscreen", ["F11"])
|
||||
self.set_accels_for_action("app.preview", ["<Ctl>p"])
|
||||
self.set_accels_for_action("app.search", ["<Ctl>f"])
|
||||
self.set_accels_for_action("app.search_replace", ["<Ctl>h"])
|
||||
self.set_accels_for_action("app.spellcheck", ["F7"])
|
||||
|
||||
self.set_accels_for_action("app.new", ["<Ctl>n"])
|
||||
|
@ -208,7 +213,10 @@ class Application(Gtk.Application):
|
|||
self.window.save_document()
|
||||
|
||||
def on_search(self, _action, _value):
|
||||
self.window.open_search_and_replace()
|
||||
self.window.open_search()
|
||||
|
||||
def on_search_replace(self, _action, _value):
|
||||
self.window.open_search(replace=True)
|
||||
|
||||
def on_focus_mode(self, action, value):
|
||||
action.set_state(value)
|
||||
|
|
|
@ -1,186 +0,0 @@
|
|||
# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
|
||||
# BEGIN LICENSE
|
||||
# Copyright (C) 2019, Wolf Vollprecht <w.vollprecht@gmail.com>
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License version 3, as published
|
||||
# by the Free Software Foundation.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranties of
|
||||
# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
|
||||
# PURPOSE. See the GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
# END LICENSE
|
||||
|
||||
|
||||
from gettext import gettext as _
|
||||
|
||||
|
||||
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
|
||||
|
||||
def rule(self):
|
||||
"""insert ruler at cursor
|
||||
"""
|
||||
|
||||
self.text_buffer.insert_at_cursor("\n\n-------\n")
|
||||
self.text_editor.scroll_mark_onscreen(self.text_buffer.get_insert())
|
||||
|
||||
def bold(self):
|
||||
"""set selected text as bold
|
||||
"""
|
||||
|
||||
self.apply_format("**")
|
||||
|
||||
def italic(self):
|
||||
"""set selected text as italic
|
||||
"""
|
||||
self.apply_format("*")
|
||||
|
||||
def strikeout(self):
|
||||
"""set selected text as stricked out
|
||||
"""
|
||||
self.apply_format("~~")
|
||||
|
||||
def apply_format(self, wrap="*"):
|
||||
"""apply the given wrap to a selected text, or insert a helper text wraped
|
||||
if nothing is selected
|
||||
|
||||
Keyword Arguments:
|
||||
wrap {str} -- [the format mark] (default: {"*"})
|
||||
"""
|
||||
|
||||
if self.text_buffer.get_has_selection():
|
||||
# Find current highlighting
|
||||
(start, end) = self.text_buffer.get_selection_bounds()
|
||||
moved = False
|
||||
if (start.get_offset() >= len(wrap) and
|
||||
end.get_offset() <= self.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 = self.text_buffer.get_text(ext_start, ext_end, True)
|
||||
else:
|
||||
text = self.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
|
||||
self.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)
|
||||
|
||||
self.text_buffer.delete(start, end)
|
||||
move_back = len(wrap)
|
||||
|
||||
self.text_buffer.insert_at_cursor(text)
|
||||
text_length = len(new_text)
|
||||
|
||||
else:
|
||||
helptext = ""
|
||||
if wrap == "*":
|
||||
helptext = _("emphasized text")
|
||||
elif wrap == "**":
|
||||
helptext = _("strong text")
|
||||
elif wrap == "~~":
|
||||
helptext = _("striked out text")
|
||||
|
||||
self.text_buffer.insert_at_cursor(wrap + helptext + wrap)
|
||||
text_length = len(helptext)
|
||||
move_back = len(wrap)
|
||||
|
||||
cursor_mark = self.text_buffer.get_insert()
|
||||
cursor_iter = self.text_buffer.get_iter_at_mark(cursor_mark)
|
||||
cursor_iter.backward_chars(move_back)
|
||||
self.text_buffer.move_mark_by_name('selection_bound', cursor_iter)
|
||||
cursor_iter.backward_chars(text_length)
|
||||
self.text_buffer.move_mark_by_name('insert', cursor_iter)
|
||||
|
||||
def unordered_list_item(self):
|
||||
"""insert unordered list items or mark a selection as
|
||||
an item in an unordered list
|
||||
"""
|
||||
|
||||
helptext = _("List item")
|
||||
text_length = len(helptext)
|
||||
move_back = 0
|
||||
if self.text_buffer.get_has_selection():
|
||||
(start, end) = self.text_buffer.get_selection_bounds()
|
||||
if start.starts_line():
|
||||
text = self.text_buffer.get_text(start, end, False)
|
||||
if text.startswith(("- ", "* ", "+ ")):
|
||||
delete_end = start.forward_chars(2)
|
||||
self.text_buffer.delete(start, delete_end)
|
||||
else:
|
||||
self.text_buffer.insert(start, "- ")
|
||||
else:
|
||||
move_back = 0
|
||||
cursor_mark = self.text_buffer.get_insert()
|
||||
cursor_iter = self.text_buffer.get_iter_at_mark(cursor_mark)
|
||||
|
||||
start_ext = cursor_iter.copy()
|
||||
start_ext.backward_lines(3)
|
||||
text = self.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():
|
||||
self.text_buffer.insert_at_cursor(line[:2] + helptext)
|
||||
else:
|
||||
self.text_buffer.insert_at_cursor(
|
||||
"\n" + line[:2] + helptext)
|
||||
break
|
||||
else:
|
||||
if not lines[-1] and not lines[-2]:
|
||||
self.text_buffer.insert_at_cursor("- " + helptext)
|
||||
elif not lines[-1]:
|
||||
if cursor_iter.starts_line():
|
||||
self.text_buffer.insert_at_cursor("- " + helptext)
|
||||
else:
|
||||
self.text_buffer.insert_at_cursor("\n- " + helptext)
|
||||
else:
|
||||
self.text_buffer.insert_at_cursor("\n\n- " + helptext)
|
||||
break
|
||||
|
||||
self.select_edit(move_back, text_length)
|
||||
|
||||
def ordered_list_item(self):
|
||||
# TODO: implement ordered lists
|
||||
pass
|
||||
|
||||
def select_edit(self, move_back, text_length):
|
||||
cursor_mark = self.text_buffer.get_insert()
|
||||
cursor_iter = self.text_buffer.get_iter_at_mark(cursor_mark)
|
||||
cursor_iter.backward_chars(move_back)
|
||||
self.text_buffer.move_mark_by_name('selection_bound', cursor_iter)
|
||||
cursor_iter.backward_chars(text_length)
|
||||
self.text_buffer.move_mark_by_name('insert', cursor_iter)
|
||||
self.text_editor.scroll_mark_onscreen(self.text_buffer.get_insert())
|
||||
|
||||
def heading(self):
|
||||
"""insert heading at cursor position or set selected text as one
|
||||
"""
|
||||
helptext = _("Heading")
|
||||
if self.text_buffer.get_has_selection():
|
||||
(start, end) = self.text_buffer.get_selection_bounds()
|
||||
text = self.text_buffer.get_text(start, end, False)
|
||||
self.text_buffer.delete(start, end)
|
||||
else:
|
||||
text = helptext
|
||||
|
||||
self.text_buffer.insert_at_cursor("#" + " " + text)
|
||||
self.select_edit(0, len(text))
|
|
@ -166,7 +166,7 @@ def main_buttons(app):
|
|||
btn.open_recent.pack_start(open_button, False, False, 0)
|
||||
btn.open_recent.pack_end(recents_button, False, False, 0)
|
||||
|
||||
btn.search.set_tooltip_text(_("Search and Replace"))
|
||||
btn.search.set_tooltip_text(_("Find"))
|
||||
btn.menu.set_tooltip_text(_("Menu"))
|
||||
btn.menu.set_image(Gtk.Image.new_from_icon_name("open-menu-symbolic",
|
||||
Gtk.IconSize.BUTTON))
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from contextlib import contextmanager
|
||||
|
||||
import gi
|
||||
import pypandoc
|
||||
|
@ -52,6 +53,13 @@ def get_builder(builder_file_name):
|
|||
return builder
|
||||
|
||||
|
||||
@contextmanager
|
||||
def user_action(text_buffer):
|
||||
text_buffer.begin_user_action()
|
||||
yield text_buffer
|
||||
text_buffer.end_user_action()
|
||||
|
||||
|
||||
def path_to_file(path):
|
||||
"""Return a file path (file:///) for the given path"""
|
||||
|
||||
|
@ -148,12 +156,14 @@ def show_uri(parent, link):
|
|||
|
||||
def alias(alternative_function_name):
|
||||
'''see http://www.drdobbs.com/web-development/184406073#l9'''
|
||||
|
||||
def decorator(function):
|
||||
'''attach alternative_function_name(s) to function'''
|
||||
if not hasattr(function, 'aliases'):
|
||||
function.aliases = []
|
||||
function.aliases.append(alternative_function_name)
|
||||
return function
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
|
@ -172,21 +182,21 @@ def exist_executable(command):
|
|||
|
||||
def get_descendant(widget, child_name, level, doPrint=False):
|
||||
if widget is not None:
|
||||
if doPrint: print("-"*level + str(Gtk.Buildable.get_name(widget)) +
|
||||
if doPrint: print("-" * level + str(Gtk.Buildable.get_name(widget)) +
|
||||
" :: " + widget.get_name())
|
||||
else:
|
||||
if doPrint: print("-"*level + "None")
|
||||
if doPrint: print("-" * level + "None")
|
||||
return None
|
||||
#/*** If it is what we are looking for ***/
|
||||
if Gtk.Buildable.get_name(widget) == child_name: # not widget.get_name() !
|
||||
# /*** If it is what we are looking for ***/
|
||||
if Gtk.Buildable.get_name(widget) == child_name: # not widget.get_name() !
|
||||
return widget
|
||||
#/*** If this widget has one child only search its child ***/
|
||||
# /*** If this widget has one child only search its child ***/
|
||||
if (hasattr(widget, 'get_child') and
|
||||
callable(getattr(widget, 'get_child')) and
|
||||
child_name != ""):
|
||||
child = widget.get_child()
|
||||
if child is not None:
|
||||
return get_descendant(child, child_name, level+1,doPrint)
|
||||
return get_descendant(child, child_name, level + 1, doPrint)
|
||||
# /*** Ity might have many children, so search them ***/
|
||||
elif (hasattr(widget, 'get_children') and
|
||||
callable(getattr(widget, 'get_children')) and
|
||||
|
@ -196,7 +206,7 @@ def get_descendant(widget, child_name, level, doPrint=False):
|
|||
found = None
|
||||
for child in children:
|
||||
if child is not None:
|
||||
found = get_descendant(child, child_name, level+1, doPrint) # //search the child
|
||||
found = get_descendant(child, child_name, level + 1, doPrint) # //search the child
|
||||
if found: return found
|
||||
|
||||
|
||||
|
|
|
@ -14,101 +14,72 @@
|
|||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
# END LICENSE
|
||||
|
||||
import logging
|
||||
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
|
||||
|
||||
from uberwriter.text_view_markup_handler import MarkupHandler
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gtk, Gdk, GdkPixbuf
|
||||
from uberwriter import latex_to_PNG
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("WebKit2", "4.0")
|
||||
from gi.repository import Gtk, Gdk, GdkPixbuf, GLib
|
||||
from gi.repository import WebKit2
|
||||
from uberwriter import latex_to_PNG, markup_regex
|
||||
from uberwriter.settings import Settings
|
||||
|
||||
from uberwriter.fix_table import FixTable
|
||||
|
||||
LOGGER = logging.getLogger('uberwriter')
|
||||
class DictAccessor:
|
||||
reEndResponse = re.compile(br"^[2-5][0-58][0-9] .*\r\n$", re.DOTALL + re.MULTILINE)
|
||||
reDefinition = re.compile(br"^151(.*?)^\.", re.DOTALL + re.MULTILINE)
|
||||
|
||||
# TODO:
|
||||
# - Don't insert a span with id, it breaks the text to often
|
||||
# Would be better to search for the nearest title and generate
|
||||
# A jumping URL from that (for preview)
|
||||
# Also, after going to preview, set cursor back to where it was
|
||||
|
||||
|
||||
class DictAccessor():
|
||||
|
||||
def __init__(self, host='pan.alephnull.com', port=2628, timeout=60):
|
||||
def __init__(self, host="pan.alephnull.com", port=2628, timeout=60):
|
||||
self.telnet = telnetlib.Telnet(host, port)
|
||||
self.timeout = timeout
|
||||
self.login_response = self.telnet.expect(
|
||||
[self.reEndResponse], self.timeout)[2]
|
||||
|
||||
def get_online(self, word):
|
||||
process = subprocess.Popen(['dict', '-d', 'wn', word],
|
||||
stdout=subprocess.PIPE)
|
||||
return process.communicate()[0]
|
||||
self.login_response = self.telnet.expect([self.reEndResponse], self.timeout)[2]
|
||||
|
||||
def run_command(self, cmd):
|
||||
self.telnet.write(cmd.encode('utf-8') + b'\r\n')
|
||||
self.telnet.write(cmd.encode("utf-8") + b"\r\n")
|
||||
return self.telnet.expect([self.reEndResponse], self.timeout)[2]
|
||||
|
||||
def get_matches(self, database, strategy, word):
|
||||
if database in ['', 'all']:
|
||||
d = '*'
|
||||
if database in ["", "all"]:
|
||||
d = "*"
|
||||
else:
|
||||
d = database
|
||||
if strategy in ['', 'default']:
|
||||
s = '.'
|
||||
if strategy in ["", "default"]:
|
||||
s = "."
|
||||
else:
|
||||
s = strategy
|
||||
w = word.replace('"', r'\"')
|
||||
tsplit = self.run_command('MATCH %s %s "%s"' % (d, s, w)).splitlines()
|
||||
w = word.replace("\"", r"\\\"")
|
||||
tsplit = self.run_command("MATCH {} {} \"{}\"".format(d, s, w)).splitlines()
|
||||
mlist = list()
|
||||
if tsplit[-1].startswith(b'250 ok') and tsplit[0].startswith(b'1'):
|
||||
if tsplit[-1].startswith(b"250 ok") and tsplit[0].startswith(b"1"):
|
||||
mlines = tsplit[1:-2]
|
||||
for line in mlines:
|
||||
lsplit = line.strip().split()
|
||||
db = lsplit[0]
|
||||
word = unquote(' '.join(lsplit[1:]))
|
||||
word = unquote(" ".join(lsplit[1:]))
|
||||
mlist.append((db, word))
|
||||
return mlist
|
||||
|
||||
reEndResponse = re.compile(
|
||||
br'^[2-5][0-58][0-9] .*\r\n$', re.DOTALL + re.MULTILINE)
|
||||
reDefinition = re.compile(br'^151(.*?)^\.', re.DOTALL + re.MULTILINE)
|
||||
|
||||
def get_definition(self, database, word):
|
||||
if database in ['', 'all']:
|
||||
d = '*'
|
||||
if database in ["", "all"]:
|
||||
d = "*"
|
||||
else:
|
||||
d = database
|
||||
w = word.replace('"', r'\"')
|
||||
dsplit = self.run_command('DEFINE %s "%s"' % (d, w)).splitlines(True)
|
||||
# dsplit = self.getOnline(word).splitlines()
|
||||
w = word.replace("\"", r"\\\"")
|
||||
dsplit = self.run_command("DEFINE {} \"{}\"".format(d, w)).splitlines(True)
|
||||
|
||||
dlist = list()
|
||||
if dsplit[-1].startswith(b'250 ok') and dsplit[0].startswith(b'1'):
|
||||
if dsplit[-1].startswith(b"250 ok") and dsplit[0].startswith(b"1"):
|
||||
dlines = dsplit[1:-1]
|
||||
dtext = b''.join(dlines)
|
||||
dlist = self.reDefinition.findall(dtext)
|
||||
# print(dlist)
|
||||
dtext = b"".join(dlines)
|
||||
dlist = [dtext]
|
||||
# dlist = dsplit # not using the localhost telnet connection
|
||||
return dlist
|
||||
|
||||
def close(self):
|
||||
t = self.run_command('QUIT')
|
||||
t = self.run_command("QUIT")
|
||||
self.telnet.close()
|
||||
return t
|
||||
|
||||
|
@ -118,415 +89,211 @@ class DictAccessor():
|
|||
|
||||
lines = response.splitlines()
|
||||
lines = lines[2:]
|
||||
lines = ' '.join(lines)
|
||||
lines = re.sub(r'\s+', ' ', lines).strip()
|
||||
lines = re.split(r'( adv | adj | n | v |^adv |^adj |^n |^v )', lines)
|
||||
lines = " ".join(lines)
|
||||
lines = re.sub(r"\s+", " ", lines).strip()
|
||||
lines = re.split(r"( adv | adj | n | v |^adv |^adj |^n |^v )", lines)
|
||||
res = []
|
||||
act_res = {'defs': [], 'class': 'none', 'num': 'None'}
|
||||
act_res = {"defs": [], "class": "none", "num": "None"}
|
||||
for l in lines:
|
||||
l = l.strip()
|
||||
if not l:
|
||||
continue
|
||||
if l in ['adv', 'adj', 'n', 'v']:
|
||||
if l in ["adv", "adj", "n", "v"]:
|
||||
if act_res:
|
||||
res.append(act_res.copy())
|
||||
act_res = {}
|
||||
act_res['defs'] = []
|
||||
act_res['class'] = l
|
||||
act_res = {"defs": [], "class": l}
|
||||
else:
|
||||
ll = re.split(r'(?: |^)(\d): ', l)
|
||||
ll = re.split(r"(?: |^)(\d): ", l)
|
||||
act_def = {}
|
||||
for lll in ll:
|
||||
if lll.strip().isdigit() or not lll.strip():
|
||||
if 'description' in act_def and act_def['description']:
|
||||
act_res['defs'].append(act_def.copy())
|
||||
act_def = {'num': lll}
|
||||
if "description" in act_def and act_def["description"]:
|
||||
act_res["defs"].append(act_def.copy())
|
||||
act_def = {"num": lll}
|
||||
continue
|
||||
a = re.findall(r'(\[(syn|ant): (.+?)\] ??)+', lll)
|
||||
a = re.findall(r"(\[(syn|ant): (.+?)\] ??)+", lll)
|
||||
for n in a:
|
||||
if n[1] == 'syn':
|
||||
act_def['syn'] = re.findall(r'\{(.*?)\}.*?', n[2])
|
||||
if n[1] == "syn":
|
||||
act_def["syn"] = re.findall(r"{(.*?)}.*?", n[2])
|
||||
else:
|
||||
act_def['ant'] = re.findall(r'\{(.*?)\}.*?', n[2])
|
||||
tbr = re.search(r'\[.+\]', lll)
|
||||
act_def["ant"] = re.findall(r"{(.*?)}.*?", n[2])
|
||||
tbr = re.search(r"\[.+\]", lll)
|
||||
if tbr:
|
||||
lll = lll[:tbr.start()]
|
||||
lll = lll.split(';')
|
||||
act_def['examples'] = []
|
||||
act_def['description'] = []
|
||||
lll = lll.split(";")
|
||||
act_def["examples"] = []
|
||||
act_def["description"] = []
|
||||
for llll in lll:
|
||||
llll = llll.strip()
|
||||
if llll.strip().startswith('"'):
|
||||
act_def['examples'].append(llll)
|
||||
if llll.strip().startswith("\""):
|
||||
act_def["examples"].append(llll)
|
||||
else:
|
||||
act_def['description'].append(llll)
|
||||
act_def["description"].append(llll)
|
||||
if act_def and "description" in act_def:
|
||||
act_res["defs"].append(act_def.copy())
|
||||
|
||||
if act_def and 'description' in act_def:
|
||||
act_res['defs'].append(act_def.copy())
|
||||
|
||||
# pprint(act_res)
|
||||
res.append(act_res.copy())
|
||||
return res
|
||||
|
||||
|
||||
def check_url(url, item, spinner):
|
||||
LOGGER.debug("thread started, checking url")
|
||||
error = False
|
||||
try:
|
||||
response = urllib.request.urlopen(url)
|
||||
except URLError as e:
|
||||
error = True
|
||||
text = "Error! Reason: %s" % e.reason
|
||||
|
||||
if not error:
|
||||
if (response.code / 100) >= 4:
|
||||
LOGGER.debug("Website not available")
|
||||
text = _("Website is not available")
|
||||
else:
|
||||
text = _("Website is available")
|
||||
LOGGER.debug("Response: %s" % text)
|
||||
spinner.destroy()
|
||||
item.set_label(text)
|
||||
|
||||
|
||||
def get_dictionary(term):
|
||||
da = DictAccessor()
|
||||
output = da.get_definition('wn', term)
|
||||
output = da.get_definition("wn", term)
|
||||
if output:
|
||||
output = output[0]
|
||||
else:
|
||||
return None
|
||||
return da.parse_wordnet(output.decode(encoding='UTF-8'))
|
||||
|
||||
|
||||
def get_web_thumbnail(url, item, spinner):
|
||||
LOGGER.debug("thread started, generating thumb")
|
||||
|
||||
# error = False
|
||||
|
||||
# gnome-web-photo only understands http urls
|
||||
if url.startswith("www"):
|
||||
url = "http://" + url
|
||||
|
||||
filename = tempfile.mktemp(suffix='.png')
|
||||
thumb_size = '256' # size can only be 32, 64, 96, 128 or 256!
|
||||
args = ['gnome-web-photo', '--mode=thumbnail',
|
||||
'-s', thumb_size, url, filename]
|
||||
process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||
_output = process.communicate()[0]
|
||||
|
||||
image = Gtk.Image.new_from_file(filename)
|
||||
image.show()
|
||||
|
||||
# if not error:
|
||||
# if (response.code / 100) >= 4:
|
||||
# logger.debug("Website not available")
|
||||
# text = _("Website is not available")
|
||||
# else:
|
||||
# text = _("Website is available")
|
||||
|
||||
spinner.destroy()
|
||||
item.add(image)
|
||||
item.show()
|
||||
|
||||
|
||||
def fill_lexikon_bubble(vocab, lexikon_dict):
|
||||
grid = Gtk.Grid.new()
|
||||
i = 0
|
||||
grid.set_name('LexikonBubble')
|
||||
grid.set_row_spacing(2)
|
||||
grid.set_column_spacing(4)
|
||||
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.set_halign(Gtk.Align.START)
|
||||
vocab_label.set_justify(Gtk.Justification.LEFT)
|
||||
grid.attach(vocab_label, 0, i, 3, 1)
|
||||
|
||||
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.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.props.wrap = True
|
||||
grid.attach(def_label, 1, i, 1, 1)
|
||||
i = i + 1
|
||||
grid.show_all()
|
||||
return grid
|
||||
return None
|
||||
return da.parse_wordnet(output.decode(encoding="UTF-8"))
|
||||
|
||||
|
||||
class InlinePreview:
|
||||
WIDTH = 400
|
||||
HEIGHT = 300
|
||||
|
||||
def __init__(self, text_view):
|
||||
self.text_view = text_view
|
||||
self.text_view.connect("button-press-event", self.on_button_press_event)
|
||||
self.text_buffer = text_view.get_buffer()
|
||||
self.cursor_mark = self.text_buffer.create_mark(
|
||||
"click", self.text_buffer.get_iter_at_mark(self.text_buffer.get_insert()))
|
||||
|
||||
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)
|
||||
self.click_mark = self.text_buffer.create_mark('click', cursor_iter)
|
||||
# Events for popup menu
|
||||
# self.TextView.connect_after('populate-popup', self.populate_popup)
|
||||
# self.TextView.connect_after('popup-menu', self.move_popup)
|
||||
self.text_view.connect('button-press-event', self.click_move_button)
|
||||
self.popover = None
|
||||
self.settings = Settings.new()
|
||||
self.characters_per_line = Settings.new().get_int("characters-per-line")
|
||||
|
||||
def open_popover_with_widget(self, widget):
|
||||
a = self.text_buffer.create_child_anchor(
|
||||
self.text_buffer.get_iter_at_mark(self.click_mark))
|
||||
lbl = Gtk.Label('')
|
||||
self.text_view.add_child_at_anchor(lbl, a)
|
||||
lbl.show()
|
||||
# a = Gtk.Window.new(Gtk.WindowType.POPUP)
|
||||
# a.set_transient_for(self.TextView.get_toplevel())
|
||||
# a.grab_focus()
|
||||
# a.set_name("QuickPreviewPopup")
|
||||
# # a.set_attached_to(self.TextView)
|
||||
# a.move(300, 300)
|
||||
# a.set_modal(True)
|
||||
# def close(widget, event, *args):
|
||||
# if(event.keyval == Gdk.KEY_Escape):
|
||||
# widget.destroy()
|
||||
# a.connect('key-press-event', close)
|
||||
alignment = Gtk.Alignment()
|
||||
alignment.props.margin_bottom = 5
|
||||
alignment.props.margin_top = 5
|
||||
alignment.props.margin_left = 4
|
||||
alignment.add(widget)
|
||||
# self.TextView.add_child_in_window(b, Gtk.TextWindowType.WIDGET, 200, 200)
|
||||
# b.attach(Gtk.Label.new("test 123"), 0, 0, 1, 1)
|
||||
# b.show_all()
|
||||
# a.show_all()
|
||||
self.popover = Gtk.Popover.new(lbl)
|
||||
self.popover = Gtk.Popover.new(self.text_view)
|
||||
self.popover.get_style_context().add_class("quick-preview-popup")
|
||||
self.popover.add(alignment)
|
||||
# a.add(alignment)
|
||||
_dismiss, rect = self.popover.get_pointing_to()
|
||||
rect.y = rect.y - 20
|
||||
self.popover.set_pointing_to(rect)
|
||||
# widget = Gtk.Label.new("testasds a;12j3 21 lk3j213")
|
||||
widget.show_all()
|
||||
|
||||
# b.attach(widget, 0, 1, 1, 1)
|
||||
self.popover.set_modal(True)
|
||||
self.popover.show_all()
|
||||
# print(self.popover)
|
||||
self.popover.set_property('width-request', 50)
|
||||
|
||||
def click_move_button(self, _widget, event):
|
||||
self.preview_fns = {
|
||||
markup_regex.MATH: self.get_view_for_math,
|
||||
markup_regex.IMAGE: self.get_view_for_image,
|
||||
markup_regex.LINK: self.get_view_for_link,
|
||||
markup_regex.LINK_ALT: self.get_view_for_link,
|
||||
markup_regex.FOOTNOTE_ID: self.get_view_for_footnote,
|
||||
re.compile(r"(?P<text>\w+)"): self.get_view_for_lexikon
|
||||
}
|
||||
|
||||
def on_button_press_event(self, _text_view, event):
|
||||
if event.button == 1 and event.state & Gdk.ModifierType.CONTROL_MASK:
|
||||
x, y = self.text_view.window_to_buffer_coords(2,
|
||||
int(event.x),
|
||||
int(event.y))
|
||||
self.text_buffer.move_mark(self.click_mark,
|
||||
self.text_view.get_iter_at_location(x, y).iter)
|
||||
self.populate_popup(self.text_view)
|
||||
x, y = self.text_view.window_to_buffer_coords(2, int(event.x), int(event.y))
|
||||
self.text_buffer.move_mark(
|
||||
self.cursor_mark, self.text_view.get_iter_at_location(x, y).iter)
|
||||
self.open_popover(self.text_view)
|
||||
|
||||
def fix_table(self, _widget, _data=None):
|
||||
LOGGER.debug('fixing that table')
|
||||
fix_table = FixTable(self.text_buffer)
|
||||
fix_table.fix_table()
|
||||
def get_view_for_math(self, match):
|
||||
success, result = self.latex_converter.generatepng(match.group("text"))
|
||||
if success:
|
||||
return Gtk.Image.new_from_file(result)
|
||||
else:
|
||||
error = _("Formula looks incorrect:")
|
||||
error += "\n\n“{}”".format(result)
|
||||
return Gtk.Label(label=error)
|
||||
|
||||
def populate_popup(self, _editor, _data=None):
|
||||
# popover = Gtk.Popover.new(editor)
|
||||
# pop_cont = Gtk.Container.new()
|
||||
# popover.add(pop_cont)
|
||||
# popover.show_all()
|
||||
def get_view_for_image(self, match):
|
||||
path = match.group("url")
|
||||
if not path.startswith(("file://", "/")):
|
||||
return self.get_view_for_link(match)
|
||||
elif path.startswith("file://"):
|
||||
path = path[7:]
|
||||
return Gtk.Image.new_from_pixbuf(
|
||||
GdkPixbuf.Pixbuf.new_from_file_at_size(path, self.WIDTH, self.HEIGHT))
|
||||
|
||||
item = Gtk.MenuItem.new()
|
||||
item.set_name("PreviewMenuItem")
|
||||
separator = Gtk.SeparatorMenuItem.new()
|
||||
def get_view_for_link(self, match):
|
||||
url = match.group("url")
|
||||
web_view = WebKit2.WebView(zoom_level=0.3125) # ~1280x960
|
||||
web_view.set_size_request(self.WIDTH, self.HEIGHT)
|
||||
if GLib.uri_parse_scheme(url) is None:
|
||||
url = "http://{}".format(url)
|
||||
web_view.load_uri(url)
|
||||
return web_view
|
||||
|
||||
# table_item = Gtk.MenuItem.new()
|
||||
# table_item.set_label('Fix that table')
|
||||
def get_view_for_footnote(self, match):
|
||||
footnote_id = match.group("id")
|
||||
fn_matches = re.finditer(markup_regex.FOOTNOTE, self.text_buffer.props.text)
|
||||
for fn_match in fn_matches:
|
||||
if fn_match.group("id") == footnote_id:
|
||||
if fn_match:
|
||||
footnote = re.sub("\n[\t ]+", "\n", fn_match.group("text"))
|
||||
else:
|
||||
footnote = _("No matching footnote found")
|
||||
label = Gtk.Label(label=footnote)
|
||||
label.set_max_width_chars(self.characters_per_line)
|
||||
label.set_line_wrap(True)
|
||||
return label
|
||||
return None
|
||||
|
||||
# table_item.connect('activate', self.fix_table)
|
||||
# table_item.show()
|
||||
# menu.prepend(table_item)
|
||||
# menu.show()
|
||||
def get_view_for_lexikon(self, match):
|
||||
term = match.group("text")
|
||||
lexikon_dict = get_dictionary(term)
|
||||
if lexikon_dict:
|
||||
grid = Gtk.Grid.new()
|
||||
grid.get_style_context().add_class("lexikon")
|
||||
grid.set_row_spacing(2)
|
||||
grid.set_column_spacing(4)
|
||||
i = 0
|
||||
for entry in lexikon_dict:
|
||||
if not entry["defs"]:
|
||||
continue
|
||||
elif entry["class"].startswith("n"):
|
||||
word_type = _("noun")
|
||||
elif entry["class"].startswith("v"):
|
||||
word_type = _("verb")
|
||||
elif entry["class"].startswith("adj"):
|
||||
word_type = _("adjective")
|
||||
elif entry["class"].startswith("adv"):
|
||||
word_type = _("adverb")
|
||||
else:
|
||||
continue
|
||||
|
||||
start_iter = self.text_buffer.get_iter_at_mark(self.click_mark)
|
||||
# Line offset of click mark
|
||||
vocab_label = Gtk.Label.new(term + " ~ " + word_type)
|
||||
vocab_label.get_style_context().add_class("header")
|
||||
if i == 0:
|
||||
vocab_label.get_style_context().add_class("first")
|
||||
vocab_label.set_halign(Gtk.Align.START)
|
||||
vocab_label.set_justify(Gtk.Justification.LEFT)
|
||||
grid.attach(vocab_label, 0, i, 3, 1)
|
||||
|
||||
for definition in entry["defs"]:
|
||||
i = i + 1
|
||||
num_label = Gtk.Label.new(definition["num"] + ".")
|
||||
num_label.get_style_context().add_class("number")
|
||||
num_label.set_valign(Gtk.Align.START)
|
||||
grid.attach(num_label, 0, i, 1, 1)
|
||||
|
||||
def_label = Gtk.Label(label=" ".join(definition["description"]))
|
||||
def_label.get_style_context().add_class("description")
|
||||
def_label.set_halign(Gtk.Align.START)
|
||||
def_label.set_max_width_chars(self.characters_per_line)
|
||||
def_label.set_line_wrap(True)
|
||||
def_label.set_justify(Gtk.Justification.FILL)
|
||||
grid.attach(def_label, 1, i, 1, 1)
|
||||
i = i + 1
|
||||
if i > 0:
|
||||
return grid
|
||||
return None
|
||||
|
||||
def open_popover(self, _editor, _data=None):
|
||||
start_iter = self.text_buffer.get_iter_at_mark(self.cursor_mark)
|
||||
line_offset = start_iter.get_line_offset()
|
||||
end_iter = start_iter.copy()
|
||||
start_iter.set_line_offset(0)
|
||||
end_iter.forward_to_line_end()
|
||||
|
||||
text = self.text_buffer.get_text(start_iter, end_iter, False)
|
||||
|
||||
math = MarkupHandler.regex["MATH"]
|
||||
link = MarkupHandler.regex["LINK"]
|
||||
|
||||
footnote = re.compile(r'\[\^([^\s]+?)\]')
|
||||
image = re.compile(r"!\[(.*?)\]\((.+?)\)")
|
||||
|
||||
found_match = False
|
||||
|
||||
matches = re.finditer(math, text)
|
||||
for match in matches:
|
||||
LOGGER.debug(match.group(1))
|
||||
if match.start() < line_offset < match.end():
|
||||
success, result = self.latex_converter.generatepng(
|
||||
match.group(1))
|
||||
if success:
|
||||
image = Gtk.Image.new_from_file(result)
|
||||
image.show()
|
||||
LOGGER.debug("logging image")
|
||||
# item.add(image)
|
||||
self.open_popover_with_widget(image)
|
||||
else:
|
||||
label = Gtk.Label()
|
||||
msg = 'Formula looks incorrect:\n' + result
|
||||
label.set_alignment(0.0, 0.5)
|
||||
label.set_text(msg)
|
||||
label.show()
|
||||
item.add(label)
|
||||
self.open_popover_with_widget(item)
|
||||
item.show()
|
||||
# menu.prepend(separator)
|
||||
# separator.show()
|
||||
# menu.prepend(item)
|
||||
# menu.show()
|
||||
found_match = True
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
# Links
|
||||
matches = re.finditer(link, text)
|
||||
for regex, get_view_fn in self.preview_fns.items():
|
||||
matches = re.finditer(regex, text)
|
||||
for match in matches:
|
||||
if match.start() < line_offset < match.end():
|
||||
text = text[text.find("http://"):-1]
|
||||
|
||||
item.connect("activate", lambda w: webbrowser.open(text))
|
||||
|
||||
LOGGER.debug(text)
|
||||
|
||||
statusitem = Gtk.MenuItem.new()
|
||||
statusitem.show()
|
||||
|
||||
spinner = Gtk.Spinner.new()
|
||||
spinner.start()
|
||||
statusitem.add(spinner)
|
||||
spinner.show()
|
||||
|
||||
thread = threading.Thread(target=check_url,
|
||||
args=(text, statusitem, spinner))
|
||||
thread.start()
|
||||
|
||||
webphoto_item = Gtk.MenuItem.new()
|
||||
webphoto_item.show()
|
||||
spinner_2 = Gtk.Spinner.new()
|
||||
spinner_2.start()
|
||||
webphoto_item.add(spinner_2)
|
||||
spinner_2.show()
|
||||
|
||||
thread_image = threading.Thread(target=get_web_thumbnail,
|
||||
args=(text, webphoto_item, spinner_2))
|
||||
|
||||
thread_image.start()
|
||||
|
||||
item.set_label(_("Open Link in Webbrowser"))
|
||||
item.show()
|
||||
self.open_popover_with_widget(webphoto_item)
|
||||
|
||||
# menu.prepend(separator)
|
||||
# separator.show()
|
||||
|
||||
# menu.prepend(webphoto_item)
|
||||
# menu.prepend(statusitem)
|
||||
# menu.prepend(item)
|
||||
# menu.show()
|
||||
|
||||
found_match = True
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
matches = re.finditer(image, text)
|
||||
for match in matches:
|
||||
if match.start() < line_offset < match.end():
|
||||
path = match.group(2)
|
||||
if path.startswith("file://"):
|
||||
path = path[7:]
|
||||
elif not path.startswith("/"):
|
||||
# then the path is relative
|
||||
base_path = self.settings.get_string("open-file-path")
|
||||
path = base_path + "/" + path
|
||||
|
||||
LOGGER.info(path)
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(path, 400, 300)
|
||||
image = Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
image.show()
|
||||
self.open_popover_with_widget(image)
|
||||
item.set_property('width-request', 50)
|
||||
|
||||
# item.add(image)
|
||||
# item.set_property('width-request', 50)
|
||||
# item.show()
|
||||
# menu.prepend(separator)
|
||||
# separator.show()
|
||||
# menu.prepend(item)
|
||||
# menu.show()
|
||||
found_match = True
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
matches = re.finditer(footnote, text)
|
||||
for match in matches:
|
||||
if match.start() < line_offset < match.end():
|
||||
LOGGER.debug(match.group(1))
|
||||
footnote_match = re.compile(
|
||||
r"\[\^" + match.group(1) + r"\]: (.+(?:\n|\Z)(?:^[\t].+(?:\n|\Z))*)",
|
||||
re.MULTILINE)
|
||||
replace = re.compile(r"^\t", re.MULTILINE)
|
||||
start, end = self.text_buffer.get_bounds()
|
||||
fn_match = re.search(
|
||||
footnote_match, self.text_buffer.get_text(start, end, False))
|
||||
label = Gtk.Label()
|
||||
label.set_alignment(0.0, 0.5)
|
||||
LOGGER.debug(fn_match)
|
||||
if fn_match:
|
||||
result = re.sub(replace, "", fn_match.group(1))
|
||||
if result.endswith("\n"):
|
||||
result = result[:-1]
|
||||
else:
|
||||
result = _("No matching footnote found")
|
||||
label.set_max_width_chars(40)
|
||||
label.set_line_wrap(True)
|
||||
label.set_text(result)
|
||||
label.show()
|
||||
item.add(label)
|
||||
item.show()
|
||||
self.open_popover_with_widget(item)
|
||||
|
||||
# menu.prepend(separator)
|
||||
# separator.show()
|
||||
# menu.prepend(item)
|
||||
# menu.show()
|
||||
found_match = True
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
start_iter = self.text_buffer.get_iter_at_mark(self.click_mark)
|
||||
start_iter.backward_word_start()
|
||||
end_iter = start_iter.copy()
|
||||
end_iter.forward_word_end()
|
||||
word = self.text_buffer.get_text(start_iter, end_iter, False)
|
||||
terms = get_dictionary(word)
|
||||
if terms:
|
||||
scrolled_window = Gtk.ScrolledWindow.new()
|
||||
scrolled_window.add(fill_lexikon_bubble(word, terms))
|
||||
scrolled_window.props.width_request = 500
|
||||
scrolled_window.props.height_request = 400
|
||||
scrolled_window.show_all()
|
||||
self.open_popover_with_widget(scrolled_window)
|
||||
|
||||
def move_popup(self):
|
||||
pass
|
||||
if match.start() <= line_offset <= match.end():
|
||||
prev_view = self.popover.get_child()
|
||||
if prev_view:
|
||||
prev_view.destroy()
|
||||
view = get_view_fn(match)
|
||||
view.show_all()
|
||||
self.popover.add(view)
|
||||
rect = self.text_view.get_iter_location(
|
||||
self.text_buffer.get_iter_at_mark(self.cursor_mark))
|
||||
rect.x, rect.y = self.text_view.buffer_to_window_coords(
|
||||
Gtk.TextWindowType.TEXT, rect.x, rect.y)
|
||||
self.popover.set_pointing_to(rect)
|
||||
GLib.idle_add(self.popover.popup) # TODO: It doesn't popup without idle_add.
|
||||
return
|
||||
|
|
|
@ -3,61 +3,59 @@
|
|||
Based on latex2png.py from Stuart Rackham
|
||||
|
||||
AUTHOR
|
||||
Written by Stuart Rackham, <srackham@gmail.com>
|
||||
The code was inspired by Kjell Magne Fauske's code:
|
||||
http://fauskes.net/nb/htmleqII/
|
||||
Written by Stuart Rackham, <srackham@gmail.com>
|
||||
The code was inspired by Kjell Magne Fauske"s code:
|
||||
http://fauskes.net/nb/htmleqII/
|
||||
|
||||
See also:
|
||||
http://www.amk.ca/python/code/mt-math
|
||||
http://code.google.com/p/latexmath2png/
|
||||
See also:
|
||||
http://www.amk.ca/python/code/mt-math
|
||||
http://code.google.com/p/latexmath2png/
|
||||
|
||||
COPYING
|
||||
Copyright (C) 2010 Stuart Rackham. Free use of this software is
|
||||
granted under the terms of the MIT License.
|
||||
Copyright (C) 2010 Stuart Rackham. Free use of this software is
|
||||
granted under the terms of the MIT License.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
class LatexToPNG():
|
||||
class LatexToPNG:
|
||||
TEX_HEADER = r"""\documentclass{article}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{amsthm}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{bm}
|
||||
\newcommand{\mx}[1]{\mathbf{\bm{#1}}} % Matrix command
|
||||
\newcommand{\vc}[1]{\mathbf{\bm{#1}}} % Vector command
|
||||
\newcommand{\T}{\text{T}} % Transpose
|
||||
\pagestyle{empty}
|
||||
\begin{document}"""
|
||||
|
||||
TEX_HEADER = r'''\documentclass{article}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{amsthm}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{bm}
|
||||
\newcommand{\mx}[1]{\mathbf{\bm{#1}}} % Matrix command
|
||||
\newcommand{\vc}[1]{\mathbf{\bm{#1}}} % Vector command
|
||||
\newcommand{\T}{\text{T}} % Transpose
|
||||
\pagestyle{empty}
|
||||
\begin{document}'''
|
||||
|
||||
TEX_FOOTER = r'''\end{document}'''
|
||||
TEX_FOOTER = r"""\end{document}"""
|
||||
|
||||
def __init__(self):
|
||||
self.temp_result = tempfile.NamedTemporaryFile(suffix='.png')
|
||||
self.temp_result = tempfile.NamedTemporaryFile(suffix=".png")
|
||||
|
||||
def latex2png(self, tex, outfile, dpi, modified):
|
||||
'''Convert LaTeX input file infile to PNG file named outfile.'''
|
||||
"""Convert LaTeX input file infile to PNG file named outfile."""
|
||||
outfile = os.path.abspath(outfile)
|
||||
outdir = os.path.dirname(outfile)
|
||||
texfile = tempfile.mktemp(suffix='.tex', dir=os.path.dirname(outfile))
|
||||
texfile = tempfile.mktemp(suffix=".tex", dir=os.path.dirname(outfile))
|
||||
basefile = os.path.splitext(texfile)[0]
|
||||
dvifile = basefile + '.dvi'
|
||||
temps = [basefile + ext for ext in ('.tex', '.dvi', '.aux', '.log')]
|
||||
dvifile = basefile + ".dvi"
|
||||
temps = [basefile + ext for ext in (".tex", ".dvi", ".aux", ".log")]
|
||||
skip = False
|
||||
|
||||
tex = '%s\n%s\n%s\n' % (self.TEX_HEADER, tex.strip(), self.TEX_FOOTER)
|
||||
tex = "{}\n{}\n{}\n".format(self.TEX_HEADER, tex.strip(), self.TEX_FOOTER)
|
||||
|
||||
open(texfile, 'w').write(tex)
|
||||
open(texfile, "w").write(tex)
|
||||
saved_pwd = os.getcwd()
|
||||
|
||||
os.chdir(outdir)
|
||||
|
||||
args = ['latex', '-halt-on-error', texfile]
|
||||
args = ["latex", "-halt-on-error", texfile]
|
||||
p = subprocess.Popen(args,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdout=subprocess.PIPE)
|
||||
|
@ -66,13 +64,13 @@ class LatexToPNG():
|
|||
output_lines = output.readlines()
|
||||
if os.path.isfile(dvifile): # DVI File exists
|
||||
# Convert DVI file to PNG.
|
||||
args = ['dvipng',
|
||||
'-D', str(dpi),
|
||||
'-T', 'tight',
|
||||
'-x', '1000',
|
||||
'-z', '9',
|
||||
'-bg', 'Transparent',
|
||||
'-o', outfile,
|
||||
args = ["dvipng",
|
||||
"-D", str(dpi),
|
||||
"-T", "tight",
|
||||
"-x", "1000",
|
||||
"-z", "9",
|
||||
"-bg", "Transparent",
|
||||
"-o", outfile,
|
||||
dvifile]
|
||||
|
||||
p = subprocess.Popen(args)
|
||||
|
@ -80,15 +78,15 @@ class LatexToPNG():
|
|||
|
||||
else:
|
||||
self.clean_up(temps)
|
||||
'''
|
||||
Errors in Latex output start with "! "
|
||||
Stripping exclamation marks and superflous newlines
|
||||
and telling the user what he's done wrong.
|
||||
'''
|
||||
"""
|
||||
Errors in Latex output start with "! "
|
||||
Stripping exclamation marks and superflous newlines
|
||||
and telling the user what he"s done wrong.
|
||||
"""
|
||||
i = []
|
||||
error = ""
|
||||
for line in output_lines:
|
||||
line = line.decode('utf-8')
|
||||
line = line.decode("utf-8")
|
||||
if line.startswith("!"):
|
||||
error += line[2:] # removing "! "
|
||||
if error.endswith("\n"):
|
||||
|
@ -97,14 +95,14 @@ class LatexToPNG():
|
|||
|
||||
def generatepng(self, formula):
|
||||
try:
|
||||
self.temp_result = tempfile.NamedTemporaryFile(suffix='.png')
|
||||
self.temp_result = tempfile.NamedTemporaryFile(suffix=".png")
|
||||
formula = "$" + formula + "$"
|
||||
self.latex2png(formula, self.temp_result.name, 300, False)
|
||||
return (True, self.temp_result.name)
|
||||
return True, self.temp_result.name
|
||||
|
||||
except Exception as e:
|
||||
self.temp_result.close()
|
||||
return (False, e.args[0])
|
||||
return False, e.args[0]
|
||||
|
||||
def clean_up(self, files):
|
||||
for f in files:
|
||||
|
|
|
@ -442,19 +442,36 @@ class MainWindow(StyledWindow):
|
|||
if filename:
|
||||
if filename.startswith('file://'):
|
||||
filename = filename[7:]
|
||||
filename = urllib.parse.unquote_plus(filename)
|
||||
self.text_view.clear()
|
||||
try:
|
||||
if os.path.exists(filename):
|
||||
current_file = codecs.open(filename, encoding="utf-8", mode='r')
|
||||
self.text_view.set_text(current_file.read())
|
||||
current_file.close()
|
||||
with codecs.open(filename, encoding="utf-8", mode='r') as current_file:
|
||||
self.text_view.set_text(current_file.read())
|
||||
else:
|
||||
dialog = Gtk.MessageDialog(self,
|
||||
Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
|
||||
Gtk.MessageType.WARNING,
|
||||
Gtk.ButtonsType.CLOSE,
|
||||
_("The file you tried to open doesn't exist.\
|
||||
\nA new file will be created in its place when you save the current one.")
|
||||
)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
self.set_headerbar_title(os.path.basename(filename) + self.title_end)
|
||||
self.set_filename(filename)
|
||||
|
||||
except Exception:
|
||||
LOGGER.warning("Error Reading File: %r" % Exception)
|
||||
except Exception as e:
|
||||
LOGGER.warning(_("Error Reading File: %r") % e)
|
||||
dialog = Gtk.MessageDialog(self,
|
||||
Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
|
||||
Gtk.MessageType.WARNING,
|
||||
Gtk.ButtonsType.CLOSE,
|
||||
_("Error reading file:\
|
||||
\n%r" %e)
|
||||
)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
self.did_change = False
|
||||
else:
|
||||
LOGGER.warning("No File arg")
|
||||
|
@ -467,11 +484,11 @@ class MainWindow(StyledWindow):
|
|||
|
||||
self.load_file(helpers.get_media_file('uberwriter_markdown.md'))
|
||||
|
||||
def open_search_and_replace(self):
|
||||
def open_search(self, replace=False):
|
||||
"""toggle the search box
|
||||
"""
|
||||
|
||||
self.searchreplace.toggle_search()
|
||||
self.searchreplace.toggle_search(replace=replace)
|
||||
|
||||
def open_advanced_export(self, _widget=None, _data=None):
|
||||
"""open the export and advanced export dialog
|
||||
|
@ -482,7 +499,18 @@ class MainWindow(StyledWindow):
|
|||
|
||||
response = self.export.dialog.run()
|
||||
if response == 1:
|
||||
self.export.export(bytes(self.text_view.get_text(), "utf-8"))
|
||||
try:
|
||||
self.export.export(bytes(self.text_view.get_text(), "utf-8"))
|
||||
except Exception as e:
|
||||
dialog = Gtk.MessageDialog(self,
|
||||
Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
|
||||
Gtk.MessageType.ERROR,
|
||||
Gtk.ButtonsType.CLOSE,
|
||||
_("An error happened while trying to export:\n\n{err_msg}")
|
||||
.format(err_msg= str(e).encode().decode("unicode-escape"))
|
||||
)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
self.export.dialog.destroy()
|
||||
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import re
|
||||
|
||||
ITALIC = re.compile(
|
||||
r"(\*|_)(?P<text>.*?\S.*?)\1")
|
||||
BOLD = re.compile(
|
||||
r"(\*\*|__)(?P<text>.*?\S.*?)\1")
|
||||
BOLD_ITALIC = re.compile(
|
||||
r"((\*\*|__)([*_])|([*_])(\*\*|__))(?P<text>.*?\S.*?)(?:\5\4|\3\2)")
|
||||
STRIKETHROUGH = re.compile(
|
||||
r"~~(?P<text>.*?\S.*?)~~")
|
||||
CODE = re.compile(
|
||||
r"`(?P<text>[^`].+?)`")
|
||||
LINK = re.compile(
|
||||
r"\[(?P<text>.*)\]\((?P<url>.+?)(?: \"(?P<title>.+)\")?\)")
|
||||
LINK_ALT = re.compile(
|
||||
r"<(?P<text>(?P<url>[A-Za-z][A-Za-z0-9.+-]{1,31}:[^<>\x00-\x20]*|(?:[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)))>")
|
||||
IMAGE = re.compile(
|
||||
r"!\[(?P<text>.*)\]\((?P<url>.+?)(?: \"(?P<title>.+)\")?\)")
|
||||
HORIZONTAL_RULE = re.compile(
|
||||
r"(?:^|\n)(?P<symbols> {0,3}[*\-_]{3,} *)(?:\n+|$)")
|
||||
LIST = re.compile(
|
||||
r"(?:^|\n)(?P<content>(?P<indent>(?:\t| {4})*)[\-*+]( +)(?P<text>.+(?:\n+ \2.+)*))")
|
||||
ORDERED_LIST = re.compile(
|
||||
r"(?:^|\n)(?P<content>(?P<indent>(?:\t| {4})*)(?P<prefix>(?:\d|[a-z])+[.)]) (?P<text>.+(?:\n+ {2}\2.+)*))")
|
||||
BLOCK_QUOTE = re.compile(
|
||||
r"^ {0,3}(?:> ?)+(?P<text>.+)", re.M)
|
||||
HEADER = re.compile(
|
||||
r"^ {0,3}(?P<level>#{1,6}) (?P<text>[^\n]+)", re.M)
|
||||
HEADER_UNDER = re.compile(
|
||||
r"(?:^\n*|\n\n)(?P<text>[^\s].+)\n {0,3}[=\-]+(?: +?\n|$)")
|
||||
CODE_BLOCK = re.compile(
|
||||
r"(?:^|\n) {0,3}(?P<block>([`~]{3})(?P<text>.+?) {0,3}\2)(?:\s+?\n|$)", re.S)
|
||||
TABLE = re.compile(
|
||||
r"^[\-+]{5,}\n(?P<text>.+?)\n[\-+]{5,}\n", re.S)
|
||||
MATH = re.compile(
|
||||
r"([$]{1,2})(?P<text>[^` ].+?[^`\\ ])\1")
|
||||
FOOTNOTE_ID = re.compile(
|
||||
r"[^\s]+\[\^(?P<id>(?P<text>[^\s]+))\]")
|
||||
FOOTNOTE = re.compile(
|
||||
r"(?:^\n*|\n\n)\[\^(?P<id>[^\s]+)\]: (?P<text>(?:[^\n]+|\n+(?=(?:\t| {4})))+)(?:\n+|$)", re.M)
|
|
@ -12,7 +12,7 @@ gi.require_version('WebKit2', '4.0')
|
|||
from gi.repository import WebKit2, GLib
|
||||
|
||||
from uberwriter.preview_converter import PreviewConverter
|
||||
from uberwriter.web_view import WebView
|
||||
from uberwriter.preview_web_view import PreviewWebView
|
||||
|
||||
|
||||
class Step(IntEnum):
|
||||
|
@ -69,7 +69,7 @@ class PreviewHandler:
|
|||
self.loading = True
|
||||
|
||||
if not self.web_view:
|
||||
self.web_view = WebView()
|
||||
self.web_view = PreviewWebView()
|
||||
self.web_view.get_settings().set_allow_universal_access_from_file_urls(True)
|
||||
|
||||
# Show preview once the load is finished
|
||||
|
@ -150,12 +150,12 @@ class PreviewHandler:
|
|||
self.__show(step=Step.RENDER)
|
||||
|
||||
def on_text_view_scrolled(self, _text_view, scale):
|
||||
if self.shown and not math.isclose(scale, self.web_view.get_scroll_scale(), rel_tol=1e-5):
|
||||
if self.shown and not math.isclose(scale, self.web_view.get_scroll_scale(), rel_tol=1e-4):
|
||||
self.web_view.set_scroll_scale(scale)
|
||||
|
||||
def on_web_view_scrolled(self, _web_view, scale):
|
||||
if self.shown and self.text_view.get_mapped() and \
|
||||
not math.isclose(scale, self.text_view.get_scroll_scale(), rel_tol=1e-5):
|
||||
not math.isclose(scale, self.text_view.get_scroll_scale(), rel_tol=1e-4):
|
||||
self.text_view.set_scroll_scale(scale)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import webbrowser
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version('WebKit2', '4.0')
|
||||
from gi.repository import WebKit2, GLib, GObject
|
||||
|
||||
|
||||
class WebView(WebKit2.WebView):
|
||||
class PreviewWebView(WebKit2.WebView):
|
||||
"""A WebView that provides read/write access to scroll.
|
||||
|
||||
It does so using JavaScript, by continuously monitoring it while loaded.
|
||||
|
@ -31,7 +33,8 @@ e = document.documentElement;
|
|||
canScroll = e.scrollHeight > e.clientHeight;
|
||||
wasRendered = typeof isRendered !== "undefined" && isRendered;
|
||||
isRendered = wasRendered ||
|
||||
hasMathJax && MathJax.Hub.queue.running == 0 && MathJax.Hub.queue.pending == 0;
|
||||
!hasMathJax ||
|
||||
MathJax.Hub.queue.running == 0 && MathJax.Hub.queue.pending == 0;
|
||||
|
||||
// Write the current scroll if instructed or if it was just rendered.
|
||||
if (canScroll && (write || isRendered && !wasRendered)) {{
|
||||
|
@ -53,12 +56,13 @@ if (canScroll && isRendered) {{
|
|||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.connect("size-allocate", self.on_size_allocate)
|
||||
self.connect("decide-policy", self.on_decide_policy)
|
||||
self.connect("load-changed", self.on_load_changed)
|
||||
self.connect("load-failed", self.on_load_failed)
|
||||
self.connect("size-allocate", self.on_size_allocate)
|
||||
self.connect("destroy", self.on_destroy)
|
||||
|
||||
self.scroll_scale = 0.0
|
||||
self.scroll_scale = -1
|
||||
|
||||
self.state_loaded = False
|
||||
self.state_load_failed = False
|
||||
|
@ -68,6 +72,9 @@ if (canScroll && isRendered) {{
|
|||
|
||||
self.timeout_id = None
|
||||
|
||||
def can_scroll(self):
|
||||
return self.scroll_scale != -1
|
||||
|
||||
def get_scroll_scale(self):
|
||||
return self.scroll_scale
|
||||
|
||||
|
@ -76,6 +83,16 @@ if (canScroll && isRendered) {{
|
|||
self.scroll_scale = scale
|
||||
self.state_loop()
|
||||
|
||||
def on_size_allocate(self, *_):
|
||||
self.set_scroll_scale(self.scroll_scale)
|
||||
|
||||
def on_decide_policy(self, _web_view, decision, decision_type):
|
||||
if decision_type == WebKit2.PolicyDecisionType.NAVIGATION_ACTION and \
|
||||
decision.get_navigation_action().is_user_gesture():
|
||||
webbrowser.open(decision.get_request().get_uri())
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_load_changed(self, _web_view, event):
|
||||
self.state_loaded = event >= WebKit2.LoadEvent.COMMITTED and not self.state_load_failed
|
||||
self.state_load_failed = False
|
||||
|
@ -88,9 +105,6 @@ if (canScroll && isRendered) {{
|
|||
self.state_load_failed = True
|
||||
self.state_loop()
|
||||
|
||||
def on_size_allocate(self, *_):
|
||||
self.set_scroll_scale(self.scroll_scale)
|
||||
|
||||
def on_destroy(self, _widget):
|
||||
self.state_loaded = False
|
||||
self.state_loop()
|
||||
|
@ -113,11 +127,11 @@ if (canScroll && isRendered) {{
|
|||
self.timeout_id = None
|
||||
|
||||
# Set scroll scale if specified, and the state is not dirty
|
||||
if not self.state_discard_read and scroll_scale not in (None, -1, self.scroll_scale):
|
||||
if not self.state_discard_read and scroll_scale not in (None, self.scroll_scale):
|
||||
self.scroll_scale = scroll_scale
|
||||
self.emit("scroll-scale-changed", self.scroll_scale)
|
||||
else:
|
||||
self.state_discard_read = False
|
||||
if self.scroll_scale != -1:
|
||||
self.emit("scroll-scale-changed", self.scroll_scale)
|
||||
self.state_discard_read = False
|
||||
|
||||
# Handle the current state
|
||||
if not self.state_loaded or self.state_load_failed or self.state_waiting:
|
|
@ -19,6 +19,8 @@ import re
|
|||
|
||||
import gi
|
||||
|
||||
from uberwriter.helpers import user_action
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gdk
|
||||
|
||||
|
@ -80,11 +82,10 @@ class SearchAndReplace:
|
|||
"""
|
||||
self.replacebox.set_reveal_child(widget.get_active())
|
||||
|
||||
# TODO: refactorize!
|
||||
def key_pressed(self, _widget, event, _data=None):
|
||||
"""hide the search and replace content box when ESC is pressed
|
||||
"""
|
||||
if event.keyval in [Gdk.KEY_Escape]:
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
self.hide()
|
||||
|
||||
def focused_texteditor(self, _widget, _data=None):
|
||||
|
@ -92,15 +93,19 @@ class SearchAndReplace:
|
|||
"""
|
||||
self.hide()
|
||||
|
||||
def toggle_search(self, _widget=None, _data=None):
|
||||
def toggle_search(self, replace=False):
|
||||
"""
|
||||
show search box
|
||||
toggle search box
|
||||
"""
|
||||
if self.textview.get_mapped() and (
|
||||
self.box.get_reveal_child() is False or self.searchbar.get_search_mode() is False):
|
||||
search_hidden = self.textview.get_mapped() and (
|
||||
self.box.get_reveal_child() is False or self.searchbar.get_search_mode() is False)
|
||||
replace_hidden = not self.open_replace_button.get_active()
|
||||
if search_hidden or (replace and replace_hidden):
|
||||
self.searchbar.set_search_mode(True)
|
||||
self.box.set_reveal_child(True)
|
||||
self.searchentry.grab_focus()
|
||||
if replace:
|
||||
self.open_replace_button.set_active(True)
|
||||
else:
|
||||
self.hide()
|
||||
self.open_replace_button.set_active(False)
|
||||
|
@ -162,18 +167,20 @@ class SearchAndReplace:
|
|||
self.replace(self.active)
|
||||
|
||||
def replace_all(self, _widget=None, _data=None):
|
||||
for match in reversed(self.matches):
|
||||
self.do_replace(match)
|
||||
with user_action(self.textbuffer):
|
||||
for match in reversed(self.matches):
|
||||
self.__do_replace(match)
|
||||
self.search(scroll=False)
|
||||
|
||||
def replace(self, searchindex, _inloop=False):
|
||||
self.do_replace(self.matches[searchindex])
|
||||
with user_action(self.textbuffer):
|
||||
self.__do_replace(self.matches[searchindex])
|
||||
active = self.active
|
||||
self.search(scroll=False)
|
||||
self.active = active
|
||||
self.scrollto(self.active)
|
||||
|
||||
def do_replace(self, match):
|
||||
def __do_replace(self, match):
|
||||
start_iter = self.textbuffer.get_iter_at_offset(match[0])
|
||||
end_iter = self.textbuffer.get_iter_at_offset(match[1])
|
||||
self.textbuffer.delete(start_iter, end_iter)
|
||||
|
|
|
@ -1,21 +1,18 @@
|
|||
import math
|
||||
import re
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
from multiprocessing import Process, Pipe
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
from uberwriter import helpers
|
||||
from uberwriter.markup_regex import ITALIC, BOLD_ITALIC, BOLD, STRIKETHROUGH, IMAGE, LINK, LINK_ALT,\
|
||||
HORIZONTAL_RULE, LIST, MATH, TABLE, CODE_BLOCK, HEADER_UNDER, HEADER, BLOCK_QUOTE, ORDERED_LIST, \
|
||||
FOOTNOTE_ID, FOOTNOTE
|
||||
|
||||
|
||||
class StatsCounter:
|
||||
"""Counts characters, words, sentences and read time using a background thread."""
|
||||
"""Counts characters, words, sentences and read time using a worker process."""
|
||||
|
||||
# Regexp that matches characters, with the following exceptions:
|
||||
# * Newlines
|
||||
# * Sequential spaces
|
||||
# * Sequential dashes
|
||||
CHARACTERS = re.compile(r"[^\s-]|(?:[^\S\n](?!\s)|-(?![-\n]))")
|
||||
# Regexp that matches any character, except for newlines and subsequent spaces.
|
||||
CHARACTERS = re.compile(r"[^\s]|(?:[^\S\n](?!\s))")
|
||||
|
||||
# Regexp that matches Asian letters, general symbols and hieroglyphs,
|
||||
# as well as sequences of word characters optionally containing non-word characters in-between.
|
||||
|
@ -25,41 +22,63 @@ class StatsCounter:
|
|||
# exclamation mark, paragraph, and variants.
|
||||
SENTENCES = re.compile(r"[^\n][.。।෴۔።?՞;⸮؟?፧꘏⳺⳻⁇﹖⁈⁉‽!﹗!՜߹႟᥄\n]+")
|
||||
|
||||
# Regexp that matches paragraphs, ie. anything separated by newlines.
|
||||
PARAGRAPHS = re.compile(r".+\n?")
|
||||
# Regexp that matches paragraphs, ie. anything separated by at least 2 newlines.
|
||||
PARAGRAPHS = re.compile(r"[^\n]+(\n{2,}|$)")
|
||||
|
||||
def __init__(self):
|
||||
# List of regexp whose matches should be replaced by their "text" group. Order is important.
|
||||
MARKUP_REGEXP_REPLACE = (
|
||||
BOLD_ITALIC, ITALIC, BOLD, STRIKETHROUGH, IMAGE, LINK, LINK_ALT, LIST, ORDERED_LIST,
|
||||
BLOCK_QUOTE, HEADER, HEADER_UNDER, CODE_BLOCK, TABLE, MATH, FOOTNOTE_ID, FOOTNOTE
|
||||
)
|
||||
|
||||
# List of regexp whose matches should be removed. Order is important.
|
||||
MARKUP_REGEXP_REMOVE = (
|
||||
HORIZONTAL_RULE,
|
||||
)
|
||||
|
||||
def __init__(self, callback):
|
||||
super().__init__()
|
||||
|
||||
self.queue = Queue()
|
||||
worker = Thread(target=self.__do_count, name="stats-counter")
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
# Worker process to handle counting.
|
||||
self.counting = False
|
||||
self.count_pending_text = None
|
||||
self.parent_conn, child_conn = Pipe()
|
||||
Process(target=self.do_count, args=(child_conn,), daemon=True).start()
|
||||
GLib.io_add_watch(
|
||||
self.parent_conn.fileno(), GLib.PRIORITY_LOW, GLib.IO_IN, self.on_counted, callback)
|
||||
|
||||
def count(self, text, callback):
|
||||
"""Count stats for text, calling callback with a result when done.
|
||||
def count(self, text):
|
||||
"""Count stats for text.
|
||||
|
||||
The callback argument contains the result, in the form:
|
||||
In case counting is already running, it will re-count once it finishes. This ensure that
|
||||
the pipe doesn't fill (and block) if multiple requests are made in quick succession."""
|
||||
|
||||
(characters, words, sentences, (hours, minutes, seconds))"""
|
||||
if not self.counting:
|
||||
self.counting = True
|
||||
self.count_pending_text = None
|
||||
self.parent_conn.send(text)
|
||||
else:
|
||||
self.count_pending_text = text
|
||||
|
||||
self.queue.put((text, callback))
|
||||
def do_count(self, child_conn):
|
||||
"""Counts stats in a worker process.
|
||||
|
||||
The result is in the format: (characters, words, sentences, (hours, minutes, seconds))"""
|
||||
|
||||
def stop(self):
|
||||
"""Stops the background worker. StatsCounter shouldn't be used after this."""
|
||||
|
||||
self.queue.put((None, None))
|
||||
|
||||
def __do_count(self):
|
||||
while True:
|
||||
while True:
|
||||
(text, callback) = self.queue.get()
|
||||
if text is None and callback is None:
|
||||
try:
|
||||
text = child_conn.recv()
|
||||
if not child_conn.poll():
|
||||
break
|
||||
except EOFError:
|
||||
child_conn.close()
|
||||
return
|
||||
if self.queue.empty():
|
||||
break
|
||||
|
||||
text = helpers.pandoc_convert(text, to="plain")
|
||||
for regexp in self.MARKUP_REGEXP_REPLACE:
|
||||
text = re.sub(regexp, r"\g<text>", text)
|
||||
for regexp in self.MARKUP_REGEXP_REMOVE:
|
||||
text = re.sub(regexp, "", text)
|
||||
|
||||
character_count = len(re.findall(self.CHARACTERS, text))
|
||||
|
||||
|
@ -73,6 +92,24 @@ class StatsCounter:
|
|||
read_h, read_m = divmod(read_m, 60)
|
||||
read_time = (int(read_h), int(read_m), int(read_s))
|
||||
|
||||
GLib.idle_add(
|
||||
callback,
|
||||
child_conn.send(
|
||||
(character_count, word_count, sentence_count, paragraph_count, read_time))
|
||||
|
||||
def on_counted(self, _source, _condition, callback):
|
||||
"""Reads the counting result from the pipe and triggers any pending count."""
|
||||
|
||||
self.counting = False
|
||||
if self.count_pending_text is not None:
|
||||
self.count(self.count_pending_text) # self.count clears the pending text.
|
||||
|
||||
try:
|
||||
if self.parent_conn.poll():
|
||||
callback(self.parent_conn.recv())
|
||||
return True
|
||||
except EOFError:
|
||||
return False
|
||||
|
||||
def stop(self):
|
||||
"""Stops the worker process. StatsCounter shouldn't be used after this."""
|
||||
|
||||
self.parent_conn.close()
|
||||
|
|
|
@ -36,7 +36,7 @@ class StatsHandler:
|
|||
|
||||
self.settings = Settings.new()
|
||||
|
||||
self.stats_counter = StatsCounter()
|
||||
self.stats_counter = StatsCounter(self.update_stats)
|
||||
|
||||
self.update_default_stat()
|
||||
|
||||
|
@ -60,9 +60,7 @@ class StatsHandler:
|
|||
self.text_view.grab_focus()
|
||||
|
||||
def on_text_changed(self, buf):
|
||||
self.stats_counter.count(
|
||||
buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False),
|
||||
self.update_stats)
|
||||
self.stats_counter.count(buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False))
|
||||
|
||||
def get_text_for_stat(self, stat):
|
||||
if stat == self.CHARACTERS:
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import gi
|
||||
from gi.repository.GObject import SignalMatchType
|
||||
|
||||
from uberwriter.helpers import user_action
|
||||
from uberwriter.inline_preview import InlinePreview
|
||||
from uberwriter.text_view_drag_drop_handler import DragDropHandler, TARGET_URI, TARGET_TEXT
|
||||
from uberwriter.text_view_format_inserter import FormatInserter
|
||||
from uberwriter.text_view_markup_handler import MarkupHandler
|
||||
from uberwriter.text_view_scroller import TextViewScroller
|
||||
from uberwriter.text_view_undo_redo_handler import UndoRedoHandler
|
||||
from uberwriter.text_view_drag_drop_handler import DragDropHandler, TARGET_URI, TARGET_TEXT
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
gi.require_version('Gspell', '1')
|
||||
|
@ -63,6 +63,7 @@ class TextView(Gtk.TextView):
|
|||
# General behavior
|
||||
self.connect('size-allocate', self.on_size_allocate)
|
||||
self.get_buffer().connect('changed', self.on_text_changed)
|
||||
self.get_buffer().connect('paste-done', self.on_paste_done)
|
||||
|
||||
# Spell checking
|
||||
self.spellcheck = True
|
||||
|
@ -71,6 +72,8 @@ class TextView(Gtk.TextView):
|
|||
|
||||
# Undo / redo
|
||||
self.undo_redo = UndoRedoHandler()
|
||||
self.get_buffer().connect('begin-user-action', self.undo_redo.on_begin_user_action)
|
||||
self.get_buffer().connect('end-user-action', self.undo_redo.on_end_user_action)
|
||||
self.get_buffer().connect('insert-text', self.undo_redo.on_insert_text)
|
||||
self.get_buffer().connect('delete-range', self.undo_redo.on_delete_range)
|
||||
self.connect('undo', self.undo_redo.undo)
|
||||
|
@ -88,6 +91,7 @@ class TextView(Gtk.TextView):
|
|||
# Markup
|
||||
self.markup = MarkupHandler(self)
|
||||
self.connect('style-updated', self.markup.on_style_updated)
|
||||
self.connect('destroy', self.markup.stop)
|
||||
|
||||
# Preview popover
|
||||
self.preview_popover = InlinePreview(self)
|
||||
|
@ -121,8 +125,15 @@ class TextView(Gtk.TextView):
|
|||
return text_buffer.get_text(start_iter, end_iter, False)
|
||||
|
||||
def set_text(self, text):
|
||||
"""Set text and clear undo history"""
|
||||
|
||||
text_buffer = self.get_buffer()
|
||||
text_buffer.set_text(text)
|
||||
with user_action(text_buffer):
|
||||
text_buffer.set_text(text)
|
||||
self.undo_redo.clear()
|
||||
|
||||
def can_scroll(self):
|
||||
return self.scroller.can_scroll()
|
||||
|
||||
def get_scroll_scale(self):
|
||||
return self.scroller.get_scroll_scale() if self.scroller else 0
|
||||
|
@ -143,6 +154,8 @@ class TextView(Gtk.TextView):
|
|||
|
||||
def on_text_changed(self, *_):
|
||||
self.markup.apply()
|
||||
|
||||
def on_paste_done(self, *_):
|
||||
self.smooth_scroll_to()
|
||||
|
||||
def on_parent_set(self, *_):
|
||||
|
@ -150,15 +163,16 @@ class TextView(Gtk.TextView):
|
|||
if parent:
|
||||
parent.set_size_request(self.get_min_width(), 500)
|
||||
self.scroller = TextViewScroller(self, parent)
|
||||
parent.get_vadjustment().connect("changed", self.on_scroll_scale_changed)
|
||||
parent.get_vadjustment().connect("value-changed", self.on_scroll_scale_changed)
|
||||
parent.get_vadjustment().connect("changed", self.on_vadjustment_changed)
|
||||
parent.get_vadjustment().connect("value-changed", self.on_vadjustment_changed)
|
||||
else:
|
||||
self.scroller = None
|
||||
|
||||
def on_mark_set(self, _text_buffer, _location, mark, _data=None):
|
||||
if mark.get_name() == 'insert':
|
||||
if mark.get_name() == 'selection_bound':
|
||||
self.markup.apply()
|
||||
self.smooth_scroll_to(mark)
|
||||
if not self.get_buffer().get_has_selection():
|
||||
self.smooth_scroll_to(mark)
|
||||
elif mark.get_name() == 'gtk_drag_target':
|
||||
self.smooth_scroll_to(mark)
|
||||
return True
|
||||
|
@ -168,10 +182,10 @@ class TextView(Gtk.TextView):
|
|||
self.markup.apply()
|
||||
return False
|
||||
|
||||
def on_scroll_scale_changed(self, *_):
|
||||
def on_vadjustment_changed(self, *_):
|
||||
if self.frozen_scroll_scale is not None:
|
||||
self.set_scroll_scale(self.frozen_scroll_scale)
|
||||
else:
|
||||
elif self.can_scroll():
|
||||
self.emit("scroll-scale-changed", self.get_scroll_scale())
|
||||
|
||||
def unfreeze_scroll_scale(self):
|
||||
|
@ -238,8 +252,7 @@ class TextView(Gtk.TextView):
|
|||
def clear(self):
|
||||
"""Clear text and undo history"""
|
||||
|
||||
self.get_buffer().set_text('')
|
||||
self.undo_redo.clear()
|
||||
self.set_text('')
|
||||
|
||||
def smooth_scroll_to(self, mark=None):
|
||||
"""Scrolls if needed to ensure mark is visible.
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import mimetypes
|
||||
import urllib
|
||||
from os.path import basename
|
||||
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
@ -28,17 +29,15 @@ class DragDropHandler:
|
|||
if info == TARGET_URI:
|
||||
uris = data.get_uris()
|
||||
for uri in uris:
|
||||
uri = urllib.parse.unquote_plus(uri)
|
||||
name = basename(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
|
||||
if mime[0] is not None and mime[0].startswith('image/'):
|
||||
text = "![{}]({})".format(name, uri)
|
||||
limit_left = 2
|
||||
limit_right = 23
|
||||
else:
|
||||
text = "[Link description](%s)" % uri
|
||||
text = "[{}]({})".format(name, uri)
|
||||
limit_left = 1
|
||||
limit_right = 22
|
||||
text_buffer.place_cursor(text_buffer.get_iter_at_mark(
|
||||
|
@ -58,5 +57,5 @@ class DragDropHandler:
|
|||
text_buffer.insert_at_cursor(data.get_text())
|
||||
|
||||
Gtk.drag_finish(drag_context, True, True, time)
|
||||
text_view.get_window().present_with_time(time)
|
||||
text_view.get_toplevel().present_with_time(time)
|
||||
return False
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from gettext import gettext as _
|
||||
|
||||
from uberwriter.helpers import user_action
|
||||
|
||||
|
||||
class FormatInserter:
|
||||
"""Manages insertion of formatting.
|
||||
|
@ -25,7 +27,8 @@ class FormatInserter:
|
|||
"""Insert horizontal rule"""
|
||||
|
||||
text_buffer = text_view.get_buffer()
|
||||
text_buffer.insert_at_cursor("\n\n---\n")
|
||||
with user_action(text_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):
|
||||
|
@ -35,12 +38,14 @@ class FormatInserter:
|
|||
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, "- ")
|
||||
with user_action(text_buffer):
|
||||
text = text_buffer.get_text(start, end, False)
|
||||
if text.startswith(("- ", "* ", "+ ")):
|
||||
delete_end = start.copy()
|
||||
delete_end.forward_chars(2)
|
||||
text_buffer.delete(start, delete_end)
|
||||
else:
|
||||
text_buffer.insert(start, "- ")
|
||||
else:
|
||||
helptext = _("Item")
|
||||
text_length = len(helptext)
|
||||
|
@ -53,25 +58,25 @@ class FormatInserter:
|
|||
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]:
|
||||
with user_action(text_buffer):
|
||||
for line in reversed(lines):
|
||||
if line and line.startswith(("- ", "* ", "+ ")):
|
||||
if cursor_iter.starts_line():
|
||||
text_buffer.insert_at_cursor("- " + helptext)
|
||||
text_buffer.insert_at_cursor(line[:2] + helptext)
|
||||
else:
|
||||
text_buffer.insert_at_cursor("\n- " + helptext)
|
||||
text_buffer.insert_at_cursor("\n" + line[:2] + helptext)
|
||||
break
|
||||
else:
|
||||
text_buffer.insert_at_cursor("\n\n- " + helptext)
|
||||
break
|
||||
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)
|
||||
|
||||
|
@ -83,57 +88,60 @@ class FormatInserter:
|
|||
"""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")
|
||||
with user_action(text_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)
|
||||
|
||||
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)
|
||||
with user_action(text_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:
|
||||
if moved and text.startswith(wrap) and text.endswith(wrap):
|
||||
text = text[len(wrap):-len(wrap)]
|
||||
new_text = text.lstrip().rstrip()
|
||||
text = text.replace(new_text, wrap + new_text + 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)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
|
|
@ -15,255 +15,325 @@
|
|||
### END LICENSE
|
||||
|
||||
import re
|
||||
from multiprocessing import Pipe, Process
|
||||
|
||||
import gi
|
||||
from gi.overrides import GLib
|
||||
|
||||
from uberwriter import helpers
|
||||
from uberwriter import helpers, markup_regex
|
||||
from uberwriter.markup_regex import STRIKETHROUGH, BOLD_ITALIC, BOLD, ITALIC, IMAGE, LINK,\
|
||||
LINK_ALT, HORIZONTAL_RULE, LIST, ORDERED_LIST, BLOCK_QUOTE, HEADER, HEADER_UNDER, TABLE, MATH, \
|
||||
CODE
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Gtk, GLib
|
||||
from gi.repository import Pango
|
||||
|
||||
|
||||
class MarkupHandler:
|
||||
# Maximum number of characters for which to markup synchronously.
|
||||
max_char_sync = 100000
|
||||
|
||||
# Regular expressions for various markdown constructs.
|
||||
regex = {
|
||||
"ITALIC": re.compile(r"(\*|_)(.+?)\1"),
|
||||
"BOLD": re.compile(r"(\*\*|__)(.+?)\1"),
|
||||
"BOLDITALIC": re.compile(r"(\*\*\*|___)(.+?)\1"),
|
||||
"STRIKETHROUGH": re.compile(r"~~.+?~~"),
|
||||
"LINK": re.compile(r"(\[).*(\]\(.+?\))"),
|
||||
"HORIZONTALRULE": re.compile(r"\n\n([ ]{0,3}[*\-_]{3,}[ ]*)\n\n", re.MULTILINE),
|
||||
"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}) [^\n]+", re.MULTILINE),
|
||||
"HEADER_UNDER": re.compile(r"^\n[ ]{0,3}\w.+\n[ ]{0,3}[=\-]{3,}", re.MULTILINE),
|
||||
"CODE": re.compile(r"(?:^|\n)[ ]{0,3}(([`~]{3}).+?[ ]{0,3}\2)(?:\n|$)", re.DOTALL),
|
||||
"TABLE": re.compile(r"^[\-+]{5,}\n(.+?)\n[\-+]{5,}\n", re.DOTALL),
|
||||
"MATH": re.compile(r"[$]{1,2}([^` ].+?[^`\\ ])[$]{1,2}"),
|
||||
}
|
||||
TAG_NAME_ITALIC = 'italic'
|
||||
TAG_NAME_BOLD = 'bold'
|
||||
TAG_NAME_BOLD_ITALIC = 'bold_italic'
|
||||
TAG_NAME_STRIKETHROUGH = 'strikethrough'
|
||||
TAG_NAME_CENTER = 'center'
|
||||
TAG_NAME_WRAP_NONE = 'wrap_none'
|
||||
TAG_NAME_PLAIN_TEXT = 'plain_text'
|
||||
TAG_NAME_GRAY_TEXT = 'gray_text'
|
||||
TAG_NAME_CODE_TEXT = 'code_text'
|
||||
TAG_NAME_CODE_BLOCK = 'code_block'
|
||||
TAG_NAME_UNFOCUSED_TEXT = 'unfocused_text'
|
||||
TAG_NAME_MARGIN_INDENT = 'margin_indent'
|
||||
|
||||
def __init__(self, text_view):
|
||||
self.text_view = text_view
|
||||
self.text_buffer = text_view.get_buffer()
|
||||
self.marked_up_text = None
|
||||
|
||||
# Styles
|
||||
# Tags.
|
||||
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,
|
||||
self.tag_italic = buffer.create_tag(self.TAG_NAME_ITALIC,
|
||||
weight=Pango.Weight.NORMAL,
|
||||
style=Pango.Style.ITALIC)
|
||||
|
||||
self.strikethrough = buffer.create_tag('strikethrough', strikethrough=True)
|
||||
|
||||
self.horizontalrule = buffer.create_tag('centertext',
|
||||
justification=Gtk.Justification.CENTER)
|
||||
|
||||
self.plaintext = buffer.create_tag('plaintext',
|
||||
weight=Pango.Weight.NORMAL,
|
||||
style=Pango.Style.NORMAL,
|
||||
strikethrough=False,
|
||||
justification=Gtk.Justification.LEFT)
|
||||
|
||||
self.table = buffer.create_tag('table',
|
||||
wrap_mode=Gtk.WrapMode.NONE,
|
||||
pixels_above_lines=0,
|
||||
pixels_below_lines=0)
|
||||
|
||||
self.mathtext = buffer.create_tag('mathtext')
|
||||
|
||||
self.graytext = buffer.create_tag('graytext',
|
||||
foreground='gray',
|
||||
weight=Pango.Weight.NORMAL,
|
||||
self.tag_bold = buffer.create_tag(self.TAG_NAME_BOLD,
|
||||
weight=Pango.Weight.BOLD,
|
||||
style=Pango.Style.NORMAL)
|
||||
|
||||
# Margin and indents
|
||||
# A baseline margin is set to allow negative offsets for formatting headers, lists, etc
|
||||
self.margins_indents = {}
|
||||
self.tag_bold_italic = buffer.create_tag(self.TAG_NAME_BOLD_ITALIC,
|
||||
weight=Pango.Weight.BOLD,
|
||||
style=Pango.Style.ITALIC)
|
||||
|
||||
self.tag_strikethrough = buffer.create_tag(self.TAG_NAME_STRIKETHROUGH,
|
||||
strikethrough=True)
|
||||
|
||||
self.tag_center = buffer.create_tag(self.TAG_NAME_CENTER,
|
||||
justification=Gtk.Justification.CENTER)
|
||||
|
||||
self.tag_wrap_none = buffer.create_tag(self.TAG_NAME_WRAP_NONE,
|
||||
wrap_mode=Gtk.WrapMode.NONE,
|
||||
pixels_above_lines=0,
|
||||
pixels_below_lines=0)
|
||||
|
||||
self.tag_plain_text = buffer.create_tag(self.TAG_NAME_PLAIN_TEXT,
|
||||
weight=Pango.Weight.NORMAL,
|
||||
style=Pango.Style.NORMAL,
|
||||
strikethrough=False,
|
||||
justification=Gtk.Justification.LEFT)
|
||||
|
||||
self.tag_gray_text = buffer.create_tag(self.TAG_NAME_GRAY_TEXT,
|
||||
foreground='gray',
|
||||
weight=Pango.Weight.NORMAL,
|
||||
style=Pango.Style.NORMAL)
|
||||
|
||||
self.tag_code_text = buffer.create_tag(self.TAG_NAME_CODE_TEXT,
|
||||
weight=Pango.Weight.NORMAL,
|
||||
style=Pango.Style.NORMAL,
|
||||
strikethrough=False)
|
||||
|
||||
self.tag_code_block = buffer.create_tag(self.TAG_NAME_CODE_BLOCK,
|
||||
weight=Pango.Weight.NORMAL,
|
||||
style=Pango.Style.NORMAL,
|
||||
strikethrough=False,
|
||||
indent=self.get_margin_indent(0, 1)[1])
|
||||
|
||||
self.tags_markup = {
|
||||
self.TAG_NAME_ITALIC: lambda args: self.tag_italic,
|
||||
self.TAG_NAME_BOLD: lambda args: self.tag_bold,
|
||||
self.TAG_NAME_BOLD_ITALIC: lambda args: self.tag_bold_italic,
|
||||
self.TAG_NAME_STRIKETHROUGH: lambda args: self.tag_strikethrough,
|
||||
self.TAG_NAME_CENTER: lambda args: self.tag_center,
|
||||
self.TAG_NAME_WRAP_NONE: lambda args: self.tag_wrap_none,
|
||||
self.TAG_NAME_PLAIN_TEXT: lambda args: self.tag_plain_text,
|
||||
self.TAG_NAME_GRAY_TEXT: lambda args: self.tag_gray_text,
|
||||
self.TAG_NAME_CODE_TEXT: lambda args: self.tag_code_text,
|
||||
self.TAG_NAME_CODE_BLOCK: lambda args: self.tag_code_block,
|
||||
self.TAG_NAME_MARGIN_INDENT: lambda args: self.get_margin_indent_tag(*args)
|
||||
}
|
||||
|
||||
# Focus mode.
|
||||
self.tag_unfocused_text = buffer.create_tag(self.TAG_NAME_UNFOCUSED_TEXT,
|
||||
foreground='gray',
|
||||
weight=Pango.Weight.NORMAL,
|
||||
style=Pango.Style.NORMAL)
|
||||
|
||||
# Margin and indents.
|
||||
# A baseline margin is set to allow negative offsets for formatting headers, lists, etc.
|
||||
self.tags_margins_indents = {}
|
||||
self.baseline_margin = 0
|
||||
self.char_width = 0
|
||||
self.update_margins_indents()
|
||||
|
||||
# Style
|
||||
# Style.
|
||||
self.on_style_updated()
|
||||
|
||||
self.version = 0
|
||||
# Worker process to handle parsing.
|
||||
self.parsing = False
|
||||
self.apply_pending = False
|
||||
self.parent_conn, child_conn = Pipe()
|
||||
Process(target=self.parse, args=(child_conn,), daemon=True).start()
|
||||
GLib.io_add_watch(
|
||||
self.parent_conn.fileno(), GLib.PRIORITY_DEFAULT, GLib.IO_IN, self.on_parsed)
|
||||
|
||||
def on_style_updated(self, *_):
|
||||
(found, color) = self.text_view.get_style_context().lookup_color('math_text_color')
|
||||
style_context = self.text_view.get_style_context()
|
||||
(found, color) = style_context.lookup_color('code_bg_color')
|
||||
if not found:
|
||||
(_, color) = self.text_view.get_style_context().lookup_color('foreground_color')
|
||||
self.mathtext.set_property("foreground", color.to_string())
|
||||
(_, color) = style_context.lookup_color('background_color')
|
||||
self.tag_code_text.set_property("background", color.to_string())
|
||||
self.tag_code_block.set_property("paragraph-background", color.to_string())
|
||||
|
||||
def apply(self):
|
||||
self.version = self.version + 1
|
||||
if self.text_buffer.get_char_count() < self.max_char_sync:
|
||||
self.do_apply()
|
||||
else:
|
||||
GLib.idle_add(self.do_apply, self.version)
|
||||
"""Applies markup, parsing it in a worker process if the text has changed.
|
||||
|
||||
def do_apply(self, version=None):
|
||||
if version is not None and version != self.version:
|
||||
return
|
||||
In case parsing is already running, it will re-apply once it finishes. This ensure that
|
||||
the pipe doesn't fill (and block) if multiple requests are made in quick succession."""
|
||||
|
||||
if not self.parsing:
|
||||
self.parsing = True
|
||||
self.apply_pending = False
|
||||
|
||||
text = self.text_buffer.get_slice(
|
||||
self.text_buffer.get_start_iter(), self.text_buffer.get_end_iter(), False)
|
||||
if text != self.marked_up_text:
|
||||
self.parent_conn.send(text)
|
||||
else:
|
||||
self.do_apply(text)
|
||||
else:
|
||||
self.apply_pending = True
|
||||
|
||||
def parse(self, child_conn):
|
||||
"""Parses markup in a worker process."""
|
||||
|
||||
while True:
|
||||
while True:
|
||||
try:
|
||||
text = child_conn.recv()
|
||||
if not child_conn.poll():
|
||||
break
|
||||
except EOFError:
|
||||
child_conn.close()
|
||||
return
|
||||
|
||||
# List of tuples in the form (tag_name, tag_args, tag_start, tag_end).
|
||||
result = []
|
||||
|
||||
# Find:
|
||||
# - "_italic_" (italic)
|
||||
# - "**bold**" (bold)
|
||||
# - "***bolditalic***" (bold/italic)
|
||||
# - "~~strikethrough~~" (strikethrough)
|
||||
# - "`code`" (colorize)
|
||||
# - "$math$" (colorize)
|
||||
# - "---" table (wrap/pixels)
|
||||
regexps = (
|
||||
(ITALIC, self.TAG_NAME_ITALIC),
|
||||
(BOLD, self.TAG_NAME_BOLD),
|
||||
(BOLD_ITALIC, self.TAG_NAME_BOLD_ITALIC),
|
||||
(STRIKETHROUGH, self.TAG_NAME_STRIKETHROUGH),
|
||||
(CODE, self.TAG_NAME_CODE_TEXT),
|
||||
(MATH, self.TAG_NAME_CODE_TEXT),
|
||||
(TABLE, self.TAG_NAME_WRAP_NONE)
|
||||
)
|
||||
for regexp, tag_name in regexps:
|
||||
matches = re.finditer(regexp, text)
|
||||
for match in matches:
|
||||
result.append((tag_name, (), match.start(), match.end()))
|
||||
|
||||
# Find:
|
||||
# - "[description](url)" (gray out)
|
||||
# - "![description](image_url)" (gray out)
|
||||
regexps = (
|
||||
(LINK, self.TAG_NAME_GRAY_TEXT),
|
||||
(IMAGE, self.TAG_NAME_GRAY_TEXT)
|
||||
)
|
||||
for regexp, tag_name in regexps:
|
||||
matches = re.finditer(regexp, text)
|
||||
for match in matches:
|
||||
result.append((tag_name, (), match.start(), match.start("text")))
|
||||
result.append((tag_name, (), match.end("text"), match.end()))
|
||||
|
||||
# Find "<url>" links (gray out).
|
||||
matches = re.finditer(LINK_ALT, text)
|
||||
for match in matches:
|
||||
result.append((
|
||||
self.TAG_NAME_GRAY_TEXT, (), match.start("text"), match.end("text")))
|
||||
|
||||
# Find "---" horizontal rule (center).
|
||||
matches = re.finditer(HORIZONTAL_RULE, text)
|
||||
for match in matches:
|
||||
result.append((
|
||||
self.TAG_NAME_CENTER, (), match.start("symbols"), match.end("symbols")))
|
||||
|
||||
# Find "* list" (offset).
|
||||
matches = re.finditer(LIST, text)
|
||||
for match in matches:
|
||||
# Lists use character+space (eg. "* ").
|
||||
length = 2
|
||||
nest = len(match.group("indent").replace(" ", "\t"))
|
||||
margin = -length - 2 * nest
|
||||
indent = -length - 2 * length * nest
|
||||
result.append((
|
||||
self.TAG_NAME_MARGIN_INDENT,
|
||||
(margin, indent),
|
||||
match.start("content"),
|
||||
match.end("content")
|
||||
))
|
||||
|
||||
# Find "1. ordered list" (offset).
|
||||
matches = re.finditer(ORDERED_LIST, text)
|
||||
for match in matches:
|
||||
# Ordered lists use numbers/letters+dot/parens+space (eg. "123. ").
|
||||
length = len(match.group("prefix")) + 1
|
||||
nest = len(match.group("indent").replace(" ", "\t"))
|
||||
margin = -length - 2 * nest
|
||||
indent = -length - 2 * length * nest
|
||||
result.append((
|
||||
self.TAG_NAME_MARGIN_INDENT,
|
||||
(margin, indent),
|
||||
match.start("content"),
|
||||
match.end("content")
|
||||
))
|
||||
|
||||
# Find "> blockquote" (offset).
|
||||
matches = re.finditer(BLOCK_QUOTE, text)
|
||||
for match in matches:
|
||||
result.append((self.TAG_NAME_MARGIN_INDENT, (2, -2), match.start(), match.end()))
|
||||
|
||||
# Find "# Header" (offset+bold).
|
||||
matches = re.finditer(HEADER, text)
|
||||
for match in matches:
|
||||
margin = -len(match.group("level")) - 1
|
||||
result.append((
|
||||
self.TAG_NAME_MARGIN_INDENT, (margin, 0), match.start(), match.end()))
|
||||
result.append((self.TAG_NAME_BOLD, (), match.start(), match.end()))
|
||||
|
||||
# Find "=======" header underline (bold).
|
||||
matches = re.finditer(HEADER_UNDER, text)
|
||||
for match in matches:
|
||||
result.append((self.TAG_NAME_BOLD, (), match.start(), match.end()))
|
||||
|
||||
# Find "```" code block tag (offset + colorize paragraph).
|
||||
matches = re.finditer(markup_regex.CODE_BLOCK, text)
|
||||
for match in matches:
|
||||
result.append((
|
||||
self.TAG_NAME_CODE_BLOCK, (), match.start("block"), match.end("block")))
|
||||
|
||||
# Send parsed data back.
|
||||
child_conn.send((text, result))
|
||||
|
||||
def on_parsed(self, _source, _condition):
|
||||
"""Reads the parsing result from the pipe and triggers any pending apply."""
|
||||
|
||||
self.parsing = False
|
||||
if self.apply_pending:
|
||||
self.apply() # self.apply clears the apply pending flag.
|
||||
|
||||
try:
|
||||
if self.parent_conn.poll():
|
||||
self.do_apply(*self.parent_conn.recv())
|
||||
return True
|
||||
except EOFError:
|
||||
return False
|
||||
|
||||
def do_apply(self, original_text, result=[]):
|
||||
"""Applies the result of parsing if the current text matches the original text."""
|
||||
|
||||
buffer = self.text_buffer
|
||||
start = buffer.get_start_iter()
|
||||
end = buffer.get_end_iter()
|
||||
offset = 0
|
||||
text = self.text_buffer.get_slice(start, end, False)
|
||||
|
||||
text = buffer.get_slice(start, end, False)
|
||||
# Apply markup tags.
|
||||
if text == original_text and text != self.marked_up_text:
|
||||
buffer.remove_tag(self.tag_italic, start, end)
|
||||
buffer.remove_tag(self.tag_bold, start, end)
|
||||
buffer.remove_tag(self.tag_bold_italic, start, end)
|
||||
buffer.remove_tag(self.tag_strikethrough, start, end)
|
||||
buffer.remove_tag(self.tag_center, start, end)
|
||||
buffer.remove_tag(self.tag_plain_text, start, end)
|
||||
buffer.remove_tag(self.tag_gray_text, start, end)
|
||||
buffer.remove_tag(self.tag_code_text, start, end)
|
||||
buffer.remove_tag(self.tag_code_block, start, end)
|
||||
buffer.remove_tag(self.tag_wrap_none, start, end)
|
||||
for tag in self.tags_margins_indents.values():
|
||||
buffer.remove_tag(tag, start, end)
|
||||
|
||||
# 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.horizontalrule, start, end)
|
||||
buffer.remove_tag(self.plaintext, start, end)
|
||||
buffer.remove_tag(self.table, start, end)
|
||||
buffer.remove_tag(self.mathtext, start, end)
|
||||
for tag in self.margins_indents.values():
|
||||
buffer.remove_tag(tag, start, end)
|
||||
buffer.remove_tag(self.graytext, start, end)
|
||||
buffer.remove_tag(self.graytext, start, end)
|
||||
for tag_name, tag_args, tag_start, tag_end in result:
|
||||
buffer.apply_tag(
|
||||
self.tags_markup[tag_name](tag_args),
|
||||
buffer.get_iter_at_offset(tag_start),
|
||||
buffer.get_iter_at_offset(tag_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)
|
||||
|
||||
matches = re.finditer(self.regex["LINK"], 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.graytext, start_iter, end_iter)
|
||||
start_iter = buffer.get_iter_at_offset(offset + match.start(2))
|
||||
end_iter = buffer.get_iter_at_offset(offset + match.end(2))
|
||||
buffer.apply_tag(self.graytext, 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.horizontalrule, 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. "* ")
|
||||
length = 2
|
||||
nest = len(match.group(1).replace(" ", "\t"))
|
||||
margin = -length - 2 * nest
|
||||
indent = -length - 2 * length * nest
|
||||
buffer.apply_tag(self.get_margin_indent_tag(margin, indent), 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. ")
|
||||
length = len(match.group(2)) + 1
|
||||
nest = len(match.group(1).replace(" ", "\t"))
|
||||
margin = -length - 2 * nest
|
||||
indent = -length - 2 * length * nest
|
||||
buffer.apply_tag(self.get_margin_indent_tag(margin, indent), 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_indent_tag(2, -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())
|
||||
margin = -len(match.group(1)) - 1
|
||||
buffer.apply_tag(self.get_margin_indent_tag(margin, 0), 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 "```" code tag (offset)
|
||||
matches = re.finditer(self.regex["CODE"], 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.get_margin_indent_tag(0, 2), start_iter, end_iter)
|
||||
buffer.apply_tag(self.plaintext, 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)
|
||||
# Apply focus mode tag (grey out before/after current sentence).
|
||||
buffer.remove_tag(self.tag_unfocused_text, start, end)
|
||||
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)
|
||||
buffer.apply_tag(self.tag_unfocused_text, start, start_sentence)
|
||||
buffer.apply_tag(self.tag_unfocused_text, end_sentence, end)
|
||||
|
||||
# Margin and indent are cumulative. They differ in two ways:
|
||||
# * Margin is always in the beginning, which means it effectively only affects the first line
|
||||
|
@ -272,15 +342,15 @@ class MarkupHandler:
|
|||
# Indent is always positive, or 0.
|
||||
def get_margin_indent_tag(self, margin_level, indent_level):
|
||||
level = (margin_level, indent_level)
|
||||
if level not in self.margins_indents:
|
||||
if level not in self.tags_margins_indents:
|
||||
margin, indent = self.get_margin_indent(margin_level, indent_level)
|
||||
tag = self.text_buffer.create_tag(
|
||||
"margin_indent_{}_{}".format(margin_level, indent_level),
|
||||
left_margin=margin, indent=indent)
|
||||
self.margins_indents[level] = tag
|
||||
self.tags_margins_indents[level] = tag
|
||||
return tag
|
||||
else:
|
||||
return self.margins_indents[level]
|
||||
return self.tags_margins_indents[level]
|
||||
|
||||
def get_margin_indent(self, margin_level, indent_level, baseline_margin=None, char_width=None):
|
||||
if baseline_margin is None:
|
||||
|
@ -307,6 +377,9 @@ class MarkupHandler:
|
|||
self.text_view.set_tabs(tab_array)
|
||||
|
||||
# Adjust margins and indents
|
||||
for level, tag in self.margins_indents.items():
|
||||
for level, tag in self.tags_margins_indents.items():
|
||||
margin, indent = self.get_margin_indent(*level, baseline_margin, char_width)
|
||||
tag.set_properties(left_margin=margin, indent=indent)
|
||||
|
||||
def stop(self, *_):
|
||||
self.parent_conn.close()
|
||||
|
|
|
@ -6,6 +6,10 @@ class TextViewScroller:
|
|||
self.scrolled_window = scrolled_window
|
||||
self.smooth_scroller = None
|
||||
|
||||
def can_scroll(self):
|
||||
vap = self.scrolled_window.get_vadjustment().props
|
||||
return vap.upper > vap.page_size
|
||||
|
||||
def get_scroll_scale(self):
|
||||
vap = self.scrolled_window.get_vadjustment().props
|
||||
if vap.upper > vap.page_size:
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
import logging
|
||||
|
||||
LOGGER = logging.getLogger('uberwriter')
|
||||
|
||||
|
||||
class UndoableInsert:
|
||||
"""Something has been inserted into text_buffer"""
|
||||
|
||||
|
@ -7,6 +12,41 @@ class UndoableInsert:
|
|||
self.length = length
|
||||
self.mergeable = not bool(self.length > 1 or self.text in ("\r", "\n", " "))
|
||||
|
||||
def undo(self, text_buffer):
|
||||
offset = self.offset
|
||||
start = text_buffer.get_iter_at_offset(offset)
|
||||
stop = text_buffer.get_iter_at_offset(offset + self.length)
|
||||
text_buffer.place_cursor(start)
|
||||
text_buffer.delete(start, stop)
|
||||
|
||||
def redo(self, text_buffer):
|
||||
start = text_buffer.get_iter_at_offset(self.offset)
|
||||
text_buffer.insert(start, self.text)
|
||||
new_cursor_pos = text_buffer.get_iter_at_offset(self.offset + self.length)
|
||||
text_buffer.place_cursor(new_cursor_pos)
|
||||
|
||||
def merge(self, next_action):
|
||||
"""Merge a following action into this insert, if possible
|
||||
|
||||
can't merge if prev is not another insert
|
||||
can't merge if prev and cur are not mergeable in the first place
|
||||
can't merge when user set the input bar somewhere else
|
||||
can't merge across word boundaries"""
|
||||
|
||||
if not isinstance(next_action, UndoableInsert):
|
||||
return False
|
||||
if not self.mergeable or not next_action.mergeable:
|
||||
return False
|
||||
if self.offset + self.length != next_action.offset:
|
||||
return False
|
||||
whitespace = (' ', '\t')
|
||||
if self.text in whitespace != next_action.text in whitespace:
|
||||
return False
|
||||
|
||||
self.length += next_action.length
|
||||
self.text += next_action.text
|
||||
return True
|
||||
|
||||
|
||||
class UndoableDelete:
|
||||
"""Something has been deleted from text_buffer"""
|
||||
|
@ -20,6 +60,67 @@ class UndoableDelete:
|
|||
self.delete_key_used = bool(insert_iter.get_offset() <= self.start)
|
||||
self.mergeable = not bool(self.end - self.start > 1 or self.text in ("\r", "\n", " "))
|
||||
|
||||
def undo(self, text_buffer):
|
||||
start = text_buffer.get_iter_at_offset(self.start)
|
||||
text_buffer.insert(start, self.text)
|
||||
if self.delete_key_used:
|
||||
text_buffer.place_cursor(start)
|
||||
else:
|
||||
stop = text_buffer.get_iter_at_offset(self.end)
|
||||
text_buffer.place_cursor(stop)
|
||||
|
||||
def redo(self, text_buffer):
|
||||
start = text_buffer.get_iter_at_offset(self.start)
|
||||
stop = text_buffer.get_iter_at_offset(self.end)
|
||||
text_buffer.delete(start, stop)
|
||||
text_buffer.place_cursor(start)
|
||||
|
||||
def merge(self, next_action):
|
||||
"""Check if this delete can be merged with a previous action
|
||||
|
||||
can't merge if prev is not another delete
|
||||
can't merge if prev and cur are not mergeable in the first place
|
||||
can't merge if delete and backspace key were both used
|
||||
can't merge across word boundaries"""
|
||||
|
||||
if not isinstance(next_action, UndoableDelete):
|
||||
return False
|
||||
if not self.mergeable or not next_action.mergeable:
|
||||
return False
|
||||
if self.delete_key_used != next_action.delete_key_used:
|
||||
return False
|
||||
if self.start != next_action.start and self.start != next_action.end:
|
||||
return False
|
||||
whitespace = (' ', '\t')
|
||||
if self.text in whitespace != next_action.text in whitespace:
|
||||
return False
|
||||
|
||||
if self.delete_key_used:
|
||||
self.text += next_action.text
|
||||
self.end += (next_action.end - next_action.start)
|
||||
else:
|
||||
self.text = "%s%s" % (next_action.text, next_action.text)
|
||||
self.start = next_action.start
|
||||
return True
|
||||
|
||||
|
||||
class UndoableGroup(list):
|
||||
"""A list of undoable actions, usually corresponding to a single user action"""
|
||||
|
||||
def undo(self, text_buffer):
|
||||
for undoable in reversed(self):
|
||||
undoable.undo(text_buffer)
|
||||
|
||||
def redo(self, text_buffer):
|
||||
for undoable in self:
|
||||
undoable.redo(text_buffer)
|
||||
|
||||
def merge(self, next_action):
|
||||
if len(self) == 1:
|
||||
return self[0].merge(next_action)
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class UndoRedoHandler:
|
||||
"""Manages undo/redo for a given text_buffer.
|
||||
|
@ -29,7 +130,7 @@ class UndoRedoHandler:
|
|||
def __init__(self):
|
||||
self.undo_stack = []
|
||||
self.redo_stack = []
|
||||
self.not_undoable_action = False
|
||||
self.current_undo_group = None
|
||||
self.undo_in_progress = False
|
||||
|
||||
def undo(self, text_view, _data=None):
|
||||
|
@ -39,28 +140,10 @@ class UndoRedoHandler:
|
|||
|
||||
if not self.undo_stack:
|
||||
return
|
||||
self.__begin_not_undoable_action()
|
||||
self.undo_in_progress = True
|
||||
undo_action = self.undo_stack.pop()
|
||||
self.redo_stack.append(undo_action)
|
||||
text_buffer = text_view.get_buffer()
|
||||
if isinstance(undo_action, UndoableInsert):
|
||||
offset = undo_action.offset
|
||||
start = text_buffer.get_iter_at_offset(offset)
|
||||
stop = text_buffer.get_iter_at_offset(
|
||||
offset + undo_action.length
|
||||
)
|
||||
text_buffer.place_cursor(start)
|
||||
text_buffer.delete(start, stop)
|
||||
else:
|
||||
start = text_buffer.get_iter_at_offset(undo_action.start)
|
||||
text_buffer.insert(start, undo_action.text)
|
||||
if undo_action.delete_key_used:
|
||||
text_buffer.place_cursor(start)
|
||||
else:
|
||||
stop = text_buffer.get_iter_at_offset(undo_action.end)
|
||||
text_buffer.place_cursor(stop)
|
||||
self.__end_not_undoable_action()
|
||||
undo_action.undo(text_view.get_buffer())
|
||||
self.undo_in_progress = False
|
||||
|
||||
def redo(self, text_view, _data=None):
|
||||
|
@ -70,135 +153,71 @@ class UndoRedoHandler:
|
|||
|
||||
if not self.redo_stack:
|
||||
return
|
||||
self.__begin_not_undoable_action()
|
||||
self.undo_in_progress = True
|
||||
redo_action = self.redo_stack.pop()
|
||||
self.undo_stack.append(redo_action)
|
||||
text_buffer = text_view.get_buffer()
|
||||
if isinstance(redo_action, UndoableInsert):
|
||||
start = text_buffer.get_iter_at_offset(redo_action.offset)
|
||||
text_buffer.insert(start, redo_action.text)
|
||||
new_cursor_pos = text_buffer.get_iter_at_offset(
|
||||
redo_action.offset + redo_action.length)
|
||||
text_buffer.place_cursor(new_cursor_pos)
|
||||
else:
|
||||
start = text_buffer.get_iter_at_offset(redo_action.start)
|
||||
stop = text_buffer.get_iter_at_offset(redo_action.end)
|
||||
text_buffer.delete(start, stop)
|
||||
text_buffer.place_cursor(start)
|
||||
self.__end_not_undoable_action()
|
||||
redo_action.redo(text_view.get_buffer())
|
||||
self.undo_in_progress = False
|
||||
|
||||
def clear(self):
|
||||
self.undo_stack = []
|
||||
self.redo_stack = []
|
||||
|
||||
def on_begin_user_action(self, _text_buffer):
|
||||
"""Start of a user action. Refer to TextBuffer's "begin-user-action" signal.
|
||||
|
||||
This method must be registered to TextBuffer's "begin-user-action" signal, or called
|
||||
manually followed by on_end_user_action."""
|
||||
|
||||
self.current_undo_group = UndoableGroup()
|
||||
|
||||
def on_end_user_action(self, _text_buffer):
|
||||
"""End of a user action. Refer to TextBuffer's "end-user-action" signal.
|
||||
|
||||
This method must be registered to TextBuffer's "end-user-action" signal, or called
|
||||
manually preceded by on_start_user_action."""
|
||||
|
||||
if self.current_undo_group:
|
||||
self.undo_stack.append(self.current_undo_group)
|
||||
self.current_undo_group = None
|
||||
|
||||
def on_insert_text(self, _text_buffer, text_iter, text, _length):
|
||||
"""Registers a text insert. Refer to TextBuffer's "insert-text" signal.
|
||||
"""Records a text insert. Refer to TextBuffer's "insert-text" signal.
|
||||
|
||||
This method must be registered to TextBuffer's "insert-text" signal, or called manually."""
|
||||
This method must be registered to TextBuffer's "insert-text" signal, or called manually
|
||||
in between on_begin_user_action and on_end_user_action."""
|
||||
|
||||
def can_be_merged(prev, cur):
|
||||
"""Check if multiple insertions can be merged
|
||||
|
||||
can't merge if prev and cur are not mergeable in the first place
|
||||
can't merge when user set the input bar somewhere else
|
||||
can't merge across word boundaries"""
|
||||
|
||||
whitespace = (' ', '\t')
|
||||
if not cur.mergeable or not prev.mergeable:
|
||||
return False
|
||||
if cur.offset != (prev.offset + prev.length):
|
||||
return False
|
||||
if cur.text in whitespace and prev.text not in whitespace:
|
||||
return False
|
||||
if prev.text in whitespace and cur.text not in whitespace:
|
||||
return False
|
||||
return True
|
||||
|
||||
if not self.undo_in_progress:
|
||||
self.redo_stack = []
|
||||
if self.not_undoable_action:
|
||||
return
|
||||
|
||||
undo_action = UndoableInsert(text_iter, text, len(text))
|
||||
try:
|
||||
prev_insert = self.undo_stack.pop()
|
||||
except IndexError:
|
||||
self.undo_stack.append(undo_action)
|
||||
return
|
||||
if not isinstance(prev_insert, UndoableInsert):
|
||||
self.undo_stack.append(prev_insert)
|
||||
self.undo_stack.append(undo_action)
|
||||
return
|
||||
if can_be_merged(prev_insert, undo_action):
|
||||
prev_insert.length += undo_action.length
|
||||
prev_insert.text += undo_action.text
|
||||
self.undo_stack.append(prev_insert)
|
||||
else:
|
||||
self.undo_stack.append(prev_insert)
|
||||
self.undo_stack.append(undo_action)
|
||||
self.__record_undoable(UndoableInsert(text_iter, text, len(text)))
|
||||
|
||||
def on_delete_range(self, text_buffer, start_iter, end_iter):
|
||||
"""Registers a range deletion. Refer to TextBuffer's "delete-range" signal.
|
||||
"""Records a range deletion. Refer to TextBuffer's "delete-range" signal.
|
||||
|
||||
This method must be registered to TextBuffer's "delete-range" signal, or called manually."""
|
||||
This method must be registered to TextBuffer's "delete-range" signal, or called manually
|
||||
in between on_begin_user_action and on_end_user_action."""
|
||||
|
||||
def can_be_merged(prev, cur):
|
||||
"""Check if multiple deletions can be merged
|
||||
self.__record_undoable(UndoableDelete(text_buffer, start_iter, end_iter))
|
||||
|
||||
can't merge if prev and cur are not mergeable in the first place
|
||||
can't merge if delete and backspace key were both used
|
||||
can't merge across word boundaries"""
|
||||
|
||||
whitespace = (' ', '\t')
|
||||
if not cur.mergeable or not prev.mergeable:
|
||||
return False
|
||||
if prev.delete_key_used != cur.delete_key_used:
|
||||
return False
|
||||
if prev.start != cur.start and prev.start != cur.end:
|
||||
return False
|
||||
if cur.text not in whitespace and \
|
||||
prev.text in whitespace:
|
||||
return False
|
||||
if cur.text in whitespace and \
|
||||
prev.text not in whitespace:
|
||||
return False
|
||||
return True
|
||||
def __record_undoable(self, undoable):
|
||||
"""Records a change, merging it to a previous one if possible."""
|
||||
|
||||
if not self.undo_in_progress:
|
||||
self.redo_stack = []
|
||||
if self.not_undoable_action:
|
||||
return
|
||||
undo_action = UndoableDelete(text_buffer, start_iter, end_iter)
|
||||
try:
|
||||
prev_delete = self.undo_stack.pop()
|
||||
except IndexError:
|
||||
self.undo_stack.append(undo_action)
|
||||
return
|
||||
if not isinstance(prev_delete, UndoableDelete):
|
||||
self.undo_stack.append(prev_delete)
|
||||
self.undo_stack.append(undo_action)
|
||||
return
|
||||
if can_be_merged(prev_delete, undo_action):
|
||||
if prev_delete.start == undo_action.start: # delete key used
|
||||
prev_delete.text += undo_action.text
|
||||
prev_delete.end += (undo_action.end - undo_action.start)
|
||||
else: # Backspace used
|
||||
prev_delete.text = "%s%s" % (undo_action.text,
|
||||
prev_delete.text)
|
||||
prev_delete.start = undo_action.start
|
||||
self.undo_stack.append(prev_delete)
|
||||
else:
|
||||
self.undo_stack.append(prev_delete)
|
||||
self.undo_stack.append(undo_action)
|
||||
return
|
||||
|
||||
def __begin_not_undoable_action(self):
|
||||
"""Toggle to stop recording actions"""
|
||||
prev_group_undoable = self.current_undo_group[-1] if self.current_undo_group else None
|
||||
prev_stack_undoable = self.undo_stack[-1] if self.undo_stack else None
|
||||
|
||||
self.not_undoable_action = True
|
||||
if prev_group_undoable:
|
||||
merged = prev_group_undoable.merge(undoable)
|
||||
elif prev_stack_undoable:
|
||||
merged = prev_stack_undoable.merge(undoable)
|
||||
else:
|
||||
merged = False
|
||||
|
||||
def __end_not_undoable_action(self):
|
||||
"""Toggle to start recording actions"""
|
||||
|
||||
self.not_undoable_action = False
|
||||
if not merged:
|
||||
if self.current_undo_group is None:
|
||||
LOGGER.warning("Recording a change without a user action.")
|
||||
self.undo_stack.append(undoable)
|
||||
else:
|
||||
self.current_undo_group.append(undoable)
|
||||
|
|
|
@ -1,218 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
# webkit2png - makes screenshots of webpages
|
||||
# http://www.paulhammond.org/webkit2png
|
||||
|
||||
# modified and updated version by @somas95 (Manuel Genovés)
|
||||
|
||||
__version__=''
|
||||
# Copyright (c) 2009 Paul Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
#
|
||||
|
||||
import optparse
|
||||
import sys
|
||||
import os
|
||||
|
||||
try:
|
||||
import gi
|
||||
gi.require_version('WebKit2', '4.0')
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import GObject as gobject
|
||||
from gi.repository import Gtk as gtk
|
||||
from gi.repository import Pango as pango
|
||||
from gi.repository import WebKit2 as webkit
|
||||
mode = "pygtk"
|
||||
except ImportError:
|
||||
print("Cannot find python-webkit library files. Are you sure they're installed?")
|
||||
sys.exit()
|
||||
|
||||
class PyGTKBrowser:
|
||||
|
||||
def _save_image(self, webview, res, data):
|
||||
try:
|
||||
original_surface = webview.get_snapshot_finish(res)
|
||||
|
||||
import cairo
|
||||
new_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1024, 768)
|
||||
ctx = cairo.Context(new_surface)
|
||||
ctx.set_source_surface(original_surface, 0, 0)
|
||||
ctx.paint()
|
||||
|
||||
new_surface.write_to_png("test.png")
|
||||
|
||||
self.thumbnail = os.path.abspath("test.png")
|
||||
#return new_surface
|
||||
except Exception as e:
|
||||
print("Could not draw thumbnail for %s: %s" % (self.title, str(e)))
|
||||
|
||||
def _view_load_finished_cb(self, view, event):
|
||||
print(event)
|
||||
if event == webkit.LoadEvent.FINISHED:
|
||||
pixmap = view.get_snapshot(webkit.SnapshotRegion(1),
|
||||
webkit.SnapshotOptions(0),
|
||||
None, self._save_image, None)
|
||||
#size = get_size()
|
||||
|
||||
URL = view.get_main_frame().get_uri()
|
||||
filename = makeFilename(URL, self.options)
|
||||
|
||||
pixbuf = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, False, 8, size[0], size[1])
|
||||
pixbuf.get_from_drawable(pixmap, pixmap.get_colormap(),0,0,0,0,-1,-1)
|
||||
if self.options.fullsize:
|
||||
pixbuf.save(filename + "-full.png", "png")
|
||||
|
||||
if self.options.thumb or self.options.clipped:
|
||||
thumbWidth = int(size[0] * self.options.scale)
|
||||
thumbHeight = int(size[1] * self.options.scale)
|
||||
|
||||
thumbbuf = pixbuf.scale_simple(thumbWidth, thumbHeight, gtk.gdk.INTERP_BILINEAR)
|
||||
if self.options.thumb:
|
||||
thumbbuf.save(filename + "-thumb.png", "png")
|
||||
|
||||
if self.options.clipped:
|
||||
clipbuf = thumbbuf.subpixbuf(0,thumbHeight-int(self.options.clipheight),
|
||||
int(self.options.clipwidth),
|
||||
int(self.options.clipheight))
|
||||
clipbuf.save(filename + "-clip.png", "png")
|
||||
|
||||
gtk.main_quit()
|
||||
|
||||
def __init__(self, options, args):
|
||||
self.options = options
|
||||
|
||||
if options.delay:
|
||||
print("--delay is only supported on Mac OS X (for now). Sorry!")
|
||||
|
||||
window = gtk.Window()
|
||||
window.resize(int(options.initWidth),int(options.initHeight))
|
||||
self.view = webkit.WebView()
|
||||
|
||||
settings = self.view.get_settings()
|
||||
settings.set_property("auto-load-images", not options.noimages)
|
||||
self.view.set_settings(settings)
|
||||
|
||||
self.view.connect("load_changed", self._view_load_finished_cb)
|
||||
|
||||
# window.add(self.view)
|
||||
# window.show_all()
|
||||
self.view.load_uri(args[0])
|
||||
# go go go
|
||||
gtk.main()
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
# parse the command line
|
||||
usage = """%prog [options] [http://example.net/ ...]
|
||||
examples:
|
||||
%prog http://google.com/ # screengrab google
|
||||
%prog -W 1000 -H 1000 http://google.com/ # bigger screengrab of google
|
||||
%prog -T http://google.com/ # just the thumbnail screengrab
|
||||
%prog -TF http://google.com/ # just thumbnail and fullsize grab
|
||||
%prog -o foo http://google.com/ # save images as "foo-thumb.png" etc
|
||||
%prog - # screengrab urls from stdin
|
||||
%prog -h | less # full documentation"""
|
||||
|
||||
cmdparser = optparse.OptionParser(usage,version=("webkit2png "+__version__))
|
||||
# TODO: add quiet/verbose options
|
||||
cmdparser.add_option("-W", "--width",type="float",default=800.0,
|
||||
help="initial (and minimum) width of browser (default: 800)")
|
||||
cmdparser.add_option("-H", "--height",type="float",default=600.0,
|
||||
help="initial (and minimum) height of browser (default: 600)")
|
||||
cmdparser.add_option("--clipwidth",type="float",default=200.0,
|
||||
help="width of clipped thumbnail (default: 200)",
|
||||
metavar="WIDTH")
|
||||
cmdparser.add_option("--clipheight",type="float",default=150.0,
|
||||
help="height of clipped thumbnail (default: 150)",
|
||||
metavar="HEIGHT")
|
||||
cmdparser.add_option("-s", "--scale",type="float",default=0.25,
|
||||
help="scale factor for thumbnails (default: 0.25)")
|
||||
cmdparser.add_option("-m", "--md5", action="store_true",
|
||||
help="use md5 hash for filename (like del.icio.us)")
|
||||
cmdparser.add_option("-o", "--filename", type="string",default="",
|
||||
metavar="NAME", help="save images as NAME-full.png,NAME-thumb.png etc")
|
||||
cmdparser.add_option("-F", "--fullsize", action="store_true",
|
||||
help="only create fullsize screenshot")
|
||||
cmdparser.add_option("-T", "--thumb", action="store_true",
|
||||
help="only create thumbnail sreenshot")
|
||||
cmdparser.add_option("-C", "--clipped", action="store_true",
|
||||
help="only create clipped thumbnail screenshot")
|
||||
cmdparser.add_option("-d", "--datestamp", action="store_true",
|
||||
help="include date in filename")
|
||||
cmdparser.add_option("-D", "--dir",type="string",default="./",
|
||||
help="directory to place images into")
|
||||
cmdparser.add_option("--delay",type="float",default=0,
|
||||
help="delay between page load finishing and screenshot")
|
||||
cmdparser.add_option("--noimages", action="store_true",
|
||||
help="don't load images")
|
||||
cmdparser.add_option("--debug", action="store_true",
|
||||
help=optparse.SUPPRESS_HELP)
|
||||
(options, args) = cmdparser.parse_args()
|
||||
if len(args) == 0:
|
||||
cmdparser.print_usage()
|
||||
return
|
||||
if options.filename:
|
||||
if len(args) != 1 or args[0] == "-":
|
||||
print("--filename option requires exactly one url")
|
||||
return
|
||||
if options.scale == 0:
|
||||
cmdparser.error("scale cannot be zero")
|
||||
# make sure we're outputing something
|
||||
if not (options.fullsize or options.thumb or options.clipped):
|
||||
options.fullsize = True
|
||||
options.thumb = True
|
||||
options.clipped = True
|
||||
# work out the initial size of the browser window
|
||||
# (this might need to be larger so clipped image is right size)
|
||||
options.initWidth = (options.clipwidth / options.scale)
|
||||
options.initHeight = (options.clipheight / options.scale)
|
||||
if options.width>options.initWidth:
|
||||
options.initWidth = options.width
|
||||
if options.height>options.initHeight:
|
||||
options.initHeight = options.height
|
||||
|
||||
PyGTKBrowser(options, args)
|
||||
|
||||
def makeFilename(self, URL, options):
|
||||
# make the filename
|
||||
if options.filename:
|
||||
filename = options.filename
|
||||
elif options.md5:
|
||||
try:
|
||||
import md5
|
||||
except ImportError:
|
||||
print("--md5 requires python md5 library")
|
||||
filename = md5.new(URL).hexdigest()
|
||||
else:
|
||||
import re
|
||||
filename = re.sub('\W','',URL)
|
||||
filename = re.sub('^http','',filename)
|
||||
if options.datestamp:
|
||||
import time
|
||||
now = time.strftime("%Y%m%d")
|
||||
filename = now + "-" + filename
|
||||
import os
|
||||
dir = os.path.abspath(os.path.expanduser(options.dir))
|
||||
return os.path.join(dir,filename)
|
||||
|
||||
|
||||
if __name__ == "__main__": main()
|
Loading…
Reference in New Issue