diff --git a/.gitignore b/.gitignore
index 564b95d..4ed3eb3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@ flatpak/*
data/ui/shortcut_handlers
*.ui~
.vscode/
+.idea/
*.glade~
dist/uberwriter-2.0b0-py3.7.egg
builddir/*
diff --git a/data/de.wolfvollprecht.UberWriter.gschema.xml b/data/de.wolfvollprecht.UberWriter.gschema.xml
index e7d4bfc..664870c 100644
--- a/data/de.wolfvollprecht.UberWriter.gschema.xml
+++ b/data/de.wolfvollprecht.UberWriter.gschema.xml
@@ -10,6 +10,13 @@
+
+
+
+
+
+
+
@@ -41,6 +48,13 @@
It can cause performance problems to some users.
+
+ true
+ Synchronize editor/preview scrolling
+
+ Keep the editor and preview scroll positions in sync.
+
+
"markdown"
Input format
@@ -69,6 +83,20 @@
Which statistic is shown on the main window.
+
+ 66
+ Characters per line
+
+ Maximum number of characters per line within the editor.
+
+
+
+ "full-width"
+ Preview mode
+
+ How to display the preview.
+
+
diff --git a/data/media/css/gtk/base.css b/data/media/css/gtk/base.css
index ff0736d..f92c69f 100644
--- a/data/media/css/gtk/base.css
+++ b/data/media/css/gtk/base.css
@@ -22,25 +22,11 @@
/* Main window and text colors */
.uberwriter-window {
- /*border-radius: 7px 7px 3px 3px;*/
background: @theme_base_color;
color: @theme_fg_color;
caret-color: @theme_fg_color;
}
-.uberwriter-window .uberwriter-editor {
- font-family: 'Fira Mono', monospace;
- font-size: 16px;
-}
-
-.uberwriter-window.small .uberwriter-editor {
- font-size: 14px;
-}
-
-.uberwriter-window.large .uberwriter-editor {
- font-size: 18px;
-}
-
#titlebar-revealer {
padding: 0;
}
@@ -55,10 +41,32 @@
}
.uberwriter-editor {
+ -gtk-key-bindings: editor-bindings;
border: none;
background-color: transparent;
text-decoration-color: @error_color;
- -gtk-key-bindings: editor-bindings;
+ font-family: 'Fira Mono', monospace;
+ font-size: 16px;
+}
+
+.uberwriter-editor.size14 {
+ font-size: 14px;
+}
+
+.uberwriter-editor.size15 {
+ font-size: 15px;
+}
+
+.uberwriter-editor.size16 {
+ font-size: 16px;
+}
+
+.uberwriter-editor.size17 {
+ font-size: 17px;
+}
+
+.uberwriter-editor.size18 {
+ font-size: 18px;
}
.uberwriter-editor text {
@@ -84,9 +92,8 @@
padding: 0;
}
-.stats-counter {
+.inline-button {
color: alpha(@theme_fg_color, 0.6);
- background-color: @theme_base_color;
text-shadow: inherit;
box-shadow: initial;
background-clip: initial;
@@ -104,8 +111,8 @@
transition: 100ms ease-in;
}
-.stats-counter:hover,
-.stats-counter:checked {
+.inline-button:hover,
+.inline-button:checked {
color: @theme_fg_color;
background-color: mix(@theme_base_color, @theme_bg_color, 0.5);
}
diff --git a/data/media/css/web/adwaita_dark.css b/data/media/css/web/adwaita_dark.css
index df71e44..afedc5c 100644
--- a/data/media/css/web/adwaita_dark.css
+++ b/data/media/css/web/adwaita_dark.css
@@ -1,4 +1,4 @@
-@import url("web/web__base.css");
+@import url("base.css");
:root {
--text-color: #eeeeec;
diff --git a/data/media/css/web/arc.css b/data/media/css/web/arc.css
index 688fb87..7ee62ac 100644
--- a/data/media/css/web/arc.css
+++ b/data/media/css/web/arc.css
@@ -1,4 +1,4 @@
-@import url("web/web__base.css");
+@import url("base.css");
:root {
--text-color: #3b3e45;
diff --git a/data/media/css/web/arc_dark.css b/data/media/css/web/arc_dark.css
index aeba9ac..f421845 100644
--- a/data/media/css/web/arc_dark.css
+++ b/data/media/css/web/arc_dark.css
@@ -1,4 +1,4 @@
-@import url("web/web__base.css");
+@import url("base.css");
:root {
--text-color: #d3dae3;
diff --git a/data/media/css/web/arc_darker.css b/data/media/css/web/arc_darker.css
index 3eeede8..a2b855f 100644
--- a/data/media/css/web/arc_darker.css
+++ b/data/media/css/web/arc_darker.css
@@ -1 +1 @@
-@import url("web/web_arc.css");
\ No newline at end of file
+@import url("arc.css");
diff --git a/data/media/css/web/base.css b/data/media/css/web/base.css
index 65dfbb2..ae24b4a 100644
--- a/data/media/css/web/base.css
+++ b/data/media/css/web/base.css
@@ -45,7 +45,7 @@ html {
}
}
-@media screen and (min-width: 1000px) {
+@media screen and (min-width: 1280px) {
html {
font-size: 18px;
}
@@ -57,9 +57,9 @@ body {
font-family: "Fira Sans", fira-sans, sans-serif, color-emoji;
line-height: 1.5;
word-wrap: break-word;
- max-width: 978px;
+ max-width: 980px;
margin: auto;
- padding: 2em;
+ padding: 4em;
}
a {
diff --git a/data/media/css/web/highcontrast.css b/data/media/css/web/highcontrast.css
index 8e12c4f..3a33d1f 100644
--- a/data/media/css/web/highcontrast.css
+++ b/data/media/css/web/highcontrast.css
@@ -1,4 +1,4 @@
-@import url("web/web__base.css");
+@import url("base.css");
a {
text-decoration: underline;
diff --git a/data/media/css/web/highcontrast_inverse.css b/data/media/css/web/highcontrast_inverse.css
index e4ed265..3e5ac25 100644
--- a/data/media/css/web/highcontrast_inverse.css
+++ b/data/media/css/web/highcontrast_inverse.css
@@ -1,4 +1,4 @@
-@import url("web/web__base.css");
+@import url("base.css");
a {
text-decoration: underline;
diff --git a/data/ui/Preferences.ui b/data/ui/Preferences.ui
index b057517..d02cdd7 100644
--- a/data/ui/Preferences.ui
+++ b/data/ui/Preferences.ui
@@ -138,11 +138,11 @@
-
-
+
True
True
- False
- help
+ end
- 1
+ 2
4
+
+
+ True
+ False
+ start
+ Input format
+ right
+
+
+ 0
+ 5
+
+
True
@@ -172,9 +184,24 @@
2
- 4
+ 5
+
+
+ True
+ True
+ False
+ help
+
+
+ 1
+ 5
+
+
+
+
+
diff --git a/data/ui/Preview.ui b/data/ui/Preview.ui
new file mode 100644
index 0000000..ddc354b
--- /dev/null
+++ b/data/ui/Preview.ui
@@ -0,0 +1,51 @@
+
+
+
+
+
+ True
+ False
+ pan-down-symbolic
+ 2
+
+
+ True
+ False
+ True
+ vertical
+
+
+
+
+
+ True
+ False
+ crossfade
+ 750
+ True
+
+
+ Full-Width
+ True
+ True
+ True
+ Switch Preview Mode
+ end
+ pan-down
+ right
+ True
+
+
+
+
+
+ False
+ True
+ end
+ 1
+
+
+
+
diff --git a/data/ui/Window.ui b/data/ui/Window.ui
index bca48f4..79e1035 100644
--- a/data/ui/Window.ui
+++ b/data/ui/Window.ui
@@ -3,37 +3,32 @@
-
- 100
- 1
- 10
+
+ True
+ False
+ edit-find-replace-symbolic
-
+
True
False
go-up-symbolic
-
+
True
False
go-down-symbolic
-
+
+ True
+ False
+ pan-down-symbolic
+ 2
+
+
True
False
gtk-spell-check
-
-
- text/plain
- text/x-markdown
-
-
-
- True
- False
- edit-find-replace-symbolic
-
FullscreenOverlay
True
@@ -43,10 +38,10 @@
True
False
-
+
True
True
- True
+ False
-
+
True
False
- True
+ True
-
+
True
False
- crossfade
- 750
- True
-
-
- 0 Words
- True
- True
- True
- Show Statistics
- end
-
-
-
-
- 0
- 1
-
-
-
-
- True
- False
- True
- True
- natural
- natural
- none
+ vertical
- 500
True
True
True
True
True
- adjustment1
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ crossfade
+ 750
+ True
+
+
+ 0 Words
+ True
+ True
+ True
+ Show Statistics
+ end
+ pan-down
+ right
+ True
+
+
+
+
+
+ False
+ True
+ 1
+
- 0
- 0
+ False
+ True
+ 0
+
+
+
- False
+ True
False
@@ -191,7 +196,7 @@
True
True
Previous Match
- amunt
+ go-up
False
@@ -205,7 +210,7 @@
True
True
Next Match
- avall
+ go_down
False
@@ -264,7 +269,7 @@
True
True
Open Replace
- reemplaza
+ edit-find-replace
False
@@ -345,7 +350,7 @@
True
True
True
- ortografia1
+ spell-check
False
diff --git a/requirements.txt b/requirements.txt
index 0a7f6c0..10e2313 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,2 @@
regex
-enchant
-pypandoc==1.4
-pyenchant
-pygtkspellcheck
+pypandoc==1.4
\ No newline at end of file
diff --git a/uberwriter/__init__.py b/uberwriter/__init__.py
index 8632281..1bdceb7 100644
--- a/uberwriter/__init__.py
+++ b/uberwriter/__init__.py
@@ -19,7 +19,7 @@ import gi
gi.require_version('Gtk', '3.0')
-from uberwriter import window
+from uberwriter import main_window
from uberwriter import application
from uberwriter.helpers import set_up_logging
from uberwriter.config import get_version
diff --git a/uberwriter/application.py b/uberwriter/application.py
index 4a0f34c..449deda 100644
--- a/uberwriter/application.py
+++ b/uberwriter/application.py
@@ -15,10 +15,12 @@ from gettext import gettext as _
import gi
+from uberwriter.main_window import MainWindow
+
gi.require_version('Gtk', '3.0') # pylint: disable=wrong-import-position
from gi.repository import GLib, Gio, Gtk, GdkPixbuf
-from uberwriter import window
+from uberwriter import main_window
from uberwriter.settings import Settings
from uberwriter.helpers import set_up_logging
from uberwriter.preferences_dialog import PreferencesDialog
@@ -120,10 +122,18 @@ class Application(Gtk.Application):
stat_default = self.settings.get_string("stat-default")
action = Gio.SimpleAction.new_stateful(
- "stat_default", GLib.VariantType.new('s'), GLib.Variant.new_string(stat_default))
+ "stat_default", GLib.VariantType.new("s"), GLib.Variant.new_string(stat_default))
action.connect("activate", self.on_stat_default)
self.add_action(action)
+ # Preview Menu
+
+ preview_mode = self.settings.get_string("preview-mode")
+ action = Gio.SimpleAction.new_stateful(
+ "preview_mode", GLib.VariantType.new("s"), GLib.Variant.new_string(preview_mode))
+ action.connect("activate", self.on_preview_mode)
+ self.add_action(action)
+
# Shortcuts
# TODO: be aware that a couple of shortcuts are defined in base.css
@@ -147,7 +157,7 @@ class Application(Gtk.Application):
# Windows are associated with the application
# when the last one is closed the application shuts down
# self.window = Window(application=self, title="UberWriter")
- self.window = window.Window(self)
+ self.window = MainWindow(self)
if self.args:
self.window.load_file(self.args[0])
@@ -178,8 +188,12 @@ class Application(Gtk.Application):
self.window.toggle_gradient_overlay(settings.get_value(key))
elif key == "input-format":
self.window.reload_preview()
+ elif key == "sync-scroll":
+ self.window.reload_preview(reshow=True)
elif key == "stat-default":
self.window.update_default_stat()
+ elif key == "preview-mode":
+ self.window.update_preview_mode()
def on_new(self, _action, _value):
self.window.new_document()
@@ -250,6 +264,10 @@ class Application(Gtk.Application):
action.set_state(value)
self.settings.set_string("stat-default", value.get_string())
+ def on_preview_mode(self, action, value):
+ action.set_state(value)
+ self.settings.set_string("preview-mode", value.get_string())
+
# ~ if __name__ == "__main__":
# ~ app = Application()
# ~ app.run(sys.argv)
diff --git a/uberwriter/headerbars.py b/uberwriter/headerbars.py
index 110ba52..5b9ad75 100644
--- a/uberwriter/headerbars.py
+++ b/uberwriter/headerbars.py
@@ -48,8 +48,32 @@ class MainHeaderbar: #pylint: disable=too-few-public-methods
self.hb_container.add(self.hb_revealer)
self.hb_container.show()
- self.btns = buttons(app)
- pack_buttons(self.hb, self.btns)
+ self.btns = main_buttons(app)
+ pack_main_buttons(self.hb, self.btns)
+
+ self.hb.show_all()
+
+
+class PreviewHeaderbar:
+ """Sets up the preview headerbar
+ """
+
+ def __init__(self):
+ self.hb = Gtk.HeaderBar().new()
+ self.hb.props.show_close_button = True
+ self.hb.get_style_context().add_class("titlebar")
+
+ self.hb_revealer = Gtk.Revealer(name="titlebar-revealer")
+ self.hb_revealer.add(self.hb)
+ self.hb_revealer.props.transition_duration = 750
+ self.hb_revealer.props.transition_type = Gtk.RevealerTransitionType.CROSSFADE
+ self.hb_revealer.show()
+ self.hb_revealer.set_reveal_child(True)
+
+ self.hb_container = Gtk.Frame(name="titlebar-container")
+ self.hb_container.set_shadow_type(Gtk.ShadowType.NONE)
+ self.hb_container.add(self.hb_revealer)
+ self.hb_container.show()
self.hb.show_all()
@@ -70,14 +94,14 @@ class FullscreenHeaderbar:
self.hb.show()
self.events.hide()
- self.btns = buttons(app)
+ self.btns = main_buttons(app)
fs_btn_exit = Gtk.Button().new_from_icon_name("view-restore-symbolic",
Gtk.IconSize.BUTTON)
fs_btn_exit.set_tooltip_text(_("Exit Fullscreen"))
fs_btn_exit.set_action_name("app.fullscreen")
- pack_buttons(self.hb, self.btns, fs_btn_exit)
+ pack_main_buttons(self.hb, self.btns, fs_btn_exit)
self.hb.show_all()
@@ -101,7 +125,8 @@ class FullscreenHeaderbar:
else:
self.revealer.set_reveal_child(False)
-def buttons(app):
+
+def main_buttons(app):
"""constructor for the headerbar buttons
Returns:
@@ -154,7 +179,7 @@ def buttons(app):
return btn
-def pack_buttons(headerbar, btn, btn_exit=None):
+def pack_main_buttons(headerbar, btn, btn_exit=None):
"""Pack the given buttons in the given headerbar
Arguments:
diff --git a/uberwriter/helpers.py b/uberwriter/helpers.py
index e7f2c03..d7fa007 100644
--- a/uberwriter/helpers.py
+++ b/uberwriter/helpers.py
@@ -55,7 +55,7 @@ def get_builder(builder_file_name):
def path_to_file(path):
"""Return a file path (file:///) for the given path"""
- return "file:///" + path
+ return "file://" + path
def get_media_file(media_file_path):
diff --git a/uberwriter/inline_preview.py b/uberwriter/inline_preview.py
index ae5f9d9..02bf115 100644
--- a/uberwriter/inline_preview.py
+++ b/uberwriter/inline_preview.py
@@ -28,9 +28,11 @@ 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, GObject
-from uberwriter import latex_to_PNG, text_view_markup_handler
+from gi.repository import Gtk, Gdk, GdkPixbuf
+from uberwriter import latex_to_PNG
from uberwriter.settings import Settings
from uberwriter.fix_table import FixTable
@@ -360,8 +362,8 @@ class InlinePreview:
text = self.text_buffer.get_text(start_iter, end_iter, False)
- math = text_view_markup_handler.regex["MATH"]
- link = text_view_markup_handler.regex["LINK"]
+ math = MarkupHandler.regex["MATH"]
+ link = MarkupHandler.regex["LINK"]
footnote = re.compile(r'\[\^([^\s]+?)\]')
image = re.compile(r"!\[(.*?)\]\((.+?)\)")
@@ -455,8 +457,7 @@ class InlinePreview:
path = path[7:]
elif not path.startswith("/"):
# then the path is relative
- base_path = self.settings.get_value(
- "open-file-path").get_string()
+ base_path = self.settings.get_string("open-file-path")
path = base_path + "/" + path
LOGGER.info(path)
diff --git a/uberwriter/window.py b/uberwriter/main_window.py
similarity index 68%
rename from uberwriter/window.py
rename to uberwriter/main_window.py
index 7d91e88..2e7b3ff 100644
--- a/uberwriter/window.py
+++ b/uberwriter/main_window.py
@@ -19,19 +19,18 @@ import locale
import logging
import os
import urllib
-import webbrowser
from gettext import gettext as _
import gi
from uberwriter.export_dialog import Export
+from uberwriter.preview_handler import PreviewHandler
from uberwriter.stats_handler import StatsHandler
+from uberwriter.styled_window import StyledWindow
from uberwriter.text_view import TextView
gi.require_version('Gtk', '3.0')
-gi.require_version('WebKit2', '4.0') # pylint: disable=wrong-import-position
from gi.repository import Gtk, Gdk, GObject, GLib, Gio
-from gi.repository import WebKit2 as WebKit
import cairo
@@ -54,7 +53,7 @@ LOGGER = logging.getLogger('uberwriter')
CONFIG_PATH = os.path.expanduser("~/.config/uberwriter/")
-class Window(Gtk.ApplicationWindow):
+class MainWindow(StyledWindow):
__gsignals__ = {
'save-file': (GObject.SIGNAL_ACTION, None, ()),
'open-file': (GObject.SIGNAL_ACTION, None, ()),
@@ -68,17 +67,17 @@ class Window(Gtk.ApplicationWindow):
def __init__(self, app):
"""Set up the main window"""
- Gtk.ApplicationWindow.__init__(self,
- application=Gio.Application.get_default(),
- title="Uberwriter")
+ super().__init__(application=Gio.Application.get_default(), title="Uberwriter")
+
+ self.get_style_context().add_class('uberwriter-window')
# Set UI
- self.builder = get_builder('Window')
- root = self.builder.get_object("FullscreenOverlay")
- root.connect('style-updated', self.apply_current_theme)
+ builder = get_builder('Window')
+ root = builder.get_object("FullscreenOverlay")
+ self.connect("delete-event", self.on_delete_called)
self.add(root)
- self.set_default_size(900, 500)
+ self.set_default_size(1000, 600)
# Preferences
self.settings = Settings.new()
@@ -86,7 +85,7 @@ class Window(Gtk.ApplicationWindow):
# Headerbars
self.headerbar = headerbars.MainHeaderbar(app)
self.set_titlebar(self.headerbar.hb_container)
- self.fs_headerbar = headerbars.FullscreenHeaderbar(self.builder, app)
+ self.fs_headerbar = headerbars.FullscreenHeaderbar(builder, app)
self.title_end = " – UberWriter"
self.set_headerbar_title("New File" + self.title_end)
@@ -99,28 +98,26 @@ class Window(Gtk.ApplicationWindow):
self.accel_group = Gtk.AccelGroup()
self.add_accel_group(self.accel_group)
+ self.scrolled_window = builder.get_object('editor_scrolledwindow')
+
# Setup text editor
- self.text_view = TextView()
- self.text_view.props.halign = Gtk.Align.CENTER
+ self.text_view = TextView(self.settings.get_int("characters-per-line"))
self.text_view.connect('focus-out-event', self.focus_out)
self.text_view.get_buffer().connect('changed', self.on_text_changed)
self.text_view.show()
self.text_view.grab_focus()
-
- # Setup preview webview
- self.preview_webview = None
-
- self.scrolled_window = self.builder.get_object('editor_scrolledwindow')
- self.scrolled_window.get_style_context().add_class('uberwriter-scrolled-window')
self.scrolled_window.add(self.text_view)
- self.editor_viewport = self.builder.get_object('editor_viewport')
- # Stats counter
- self.stats_counter_revealer = self.builder.get_object('stats_counter_revealer')
- self.stats_button = self.builder.get_object('stats_counter')
- self.stats_button.get_style_context().add_class('stats-counter')
+ # Setup stats counter
+ self.stats_revealer = builder.get_object('editor_stats_revealer')
+ self.stats_button = builder.get_object('editor_stats_button')
self.stats_handler = StatsHandler(self.stats_button, self.text_view)
+ # Setup preview
+ content = builder.get_object('content')
+ editor = builder.get_object('editor')
+ self.preview_handler = PreviewHandler(self, content, editor, self.text_view)
+
# Setup header/stats bar hide after 3 seconds
self.top_bottom_bars_visible = True
self.was_motion = True
@@ -142,8 +139,8 @@ class Window(Gtk.ApplicationWindow):
###
# Sidebar initialization test
###
- self.paned_window = self.builder.get_object("main_pained")
- self.sidebar_box = self.builder.get_object("sidebar_box")
+ self.paned_window = builder.get_object("main_paned")
+ self.sidebar_box = builder.get_object("sidebar_box")
self.sidebar = Sidebar(self)
self.sidebar_box.hide()
@@ -151,40 +148,7 @@ class Window(Gtk.ApplicationWindow):
# Search and replace initialization
# Same interface as Sidebar ;)
###
- self.searchreplace = SearchAndReplace(self, self.text_view)
-
- # Window resize
- self.window_resize(self)
- self.connect("configure-event", self.window_resize)
- self.connect("delete-event", self.on_delete_called)
-
- # Set current theme
- self.apply_current_theme()
- self.get_style_context().add_class('uberwriter-window')
-
- def apply_current_theme(self, *_):
- """Adjusts the window, CSD and preview for the current theme.
- """
- # Get current theme
- theme, changed = Theme.get_current_changed()
- if changed:
- # Set theme variant (dark/light)
- Gtk.Settings.get_default().set_property(
- "gtk-application-prefer-dark-theme",
- GLib.Variant("b", theme.is_dark))
-
- # Set theme css
- style_provider = Gtk.CssProvider()
- style_provider.load_from_path(helpers.get_css_path("gtk/base.css"))
- Gtk.StyleContext.add_provider_for_screen(
- self.get_screen(), style_provider,
- Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
-
- # Reload preview if it exists
- self.reload_preview()
-
- # Redraw contents of window
- self.queue_draw()
+ self.searchreplace = SearchAndReplace(self, self.text_view, builder)
def on_text_changed(self, *_args):
"""called when the text changes, sets the self.did_change to true and
@@ -209,19 +173,16 @@ class Window(Gtk.ApplicationWindow):
if state.get_boolean():
self.fullscreen()
self.fs_headerbar.events.show()
-
else:
self.unfullscreen()
self.fs_headerbar.events.hide()
-
self.text_view.grab_focus()
def set_focus_mode(self, state):
"""toggle focusmode
"""
- focus_mode = state.get_boolean()
- self.text_view.set_focus_mode(focus_mode)
+ self.text_view.set_focus_mode(state.get_boolean())
self.text_view.grab_focus()
def set_hemingway_mode(self, state):
@@ -231,47 +192,21 @@ class Window(Gtk.ApplicationWindow):
self.text_view.set_hemingway_mode(state.get_boolean())
self.text_view.grab_focus()
- def window_resize(self, window, event=None):
- """set paddings dependant of the window size
+ def toggle_preview(self, state):
+ """Toggle the preview mode
+
+ Arguments:
+ state {gtk bool} -- Desired state of the preview mode (enabled/disabled)
"""
- # Ensure the window receiving the event is the one we care about, ie. the main window.
- # On Wayland (bug?), sub-windows such as the recents popover will also trigger this.
- if event and event.window != window.get_window():
- return
-
- # Adjust text editor width depending on window width, so that:
- # - The number of characters per line is adequate (http://webtypography.net/2.1.2)
- # - The number of characters stays constant while resizing the window / font
- # - There is enough text margin for MarkupBuffer to apply indents / negative margins
- #
- # TODO: Avoid hard-coding. Font size is clearer than unclear dimensions, but not ideal.
- w_width = event.width if event else window.get_allocation().width
- if w_width < 900:
- font_size = 14
- self.get_style_context().add_class("small")
- self.get_style_context().remove_class("large")
-
- elif w_width < 1280:
- font_size = 16
- self.get_style_context().remove_class("small")
- self.get_style_context().remove_class("large")
-
+ if state.get_boolean():
+ self.text_view.grab_focus()
+ self.preview_handler.show()
else:
- font_size = 18
- self.get_style_context().remove_class("small")
- self.get_style_context().add_class("large")
+ self.preview_handler.hide()
+ self.text_view.grab_focus()
- font_width = int(font_size * 1/1.6) # Ratio specific to Fira Mono
- width = 67 * font_width - 1 # 66 characters
- horizontal_margin = 8 * font_width # 8 characters
- width_request = width + horizontal_margin * 2
-
- if self.text_view.props.width_request != width_request:
- self.text_view.props.width_request = width_request
- self.text_view.set_left_margin(horizontal_margin)
- self.text_view.set_right_margin(horizontal_margin)
- self.scrolled_window.props.width_request = width_request
+ return True
# TODO: refactorizable
def save_document(self, _widget=None, _data=None):
@@ -467,6 +402,9 @@ class Window(Gtk.ApplicationWindow):
def update_default_stat(self):
self.stats_handler.update_default_stat()
+ def update_preview_mode(self):
+ self.preview_handler.update_preview_mode()
+
def menu_toggle_sidebar(self, _widget=None):
"""WIP
"""
@@ -479,9 +417,7 @@ class Window(Gtk.ApplicationWindow):
status {gtk bool} -- Desired status of the spellchecking
"""
- self.text_view.gspell_view\
- .set_inline_spell_checking(state.get_boolean()
- and not self.text_view.focus_mode)
+ self.text_view.set_spellcheck(state.get_boolean())
def toggle_gradient_overlay(self, state):
"""Toggle the gradient overlay
@@ -495,58 +431,8 @@ class Window(Gtk.ApplicationWindow):
elif self.overlay_id:
self.scrolled_window.disconnect(self.overlay_id)
- def toggle_preview(self, state):
- """Toggle the preview mode
-
- Arguments:
- state {gtk bool} -- Desired state of the preview mode (enabled/disabled)
- """
-
- if state.get_boolean():
- self.show_preview()
- else:
- self.show_text_editor()
-
- return True
-
- def show_text_editor(self):
- self.scrolled_window.remove(self.scrolled_window.get_child())
- self.scrolled_window.add(self.text_view)
- self.text_view.show()
- self.preview_webview.destroy()
- self.preview_webview = None
- self.queue_draw()
-
- def show_preview(self, loaded=False):
- if loaded:
- self.scrolled_window.remove(self.scrolled_window.get_child())
- self.scrolled_window.add(self.preview_webview)
- self.preview_webview.show()
- self.queue_draw()
- else:
- args = ['--standalone',
- '--mathjax',
- '--css=' + Theme.get_current().web_css_path,
- '--lua-filter=' + helpers.get_script_path('relative_to_absolute.lua'),
- '--lua-filter=' + helpers.get_script_path('task-list.lua')]
- output = helpers.pandoc_convert(self.text_view.get_text(), to="html5", args=args)
-
- if self.preview_webview is None:
- self.preview_webview = WebKit.WebView()
- self.preview_webview.get_settings().set_allow_universal_access_from_file_urls(True)
-
- # Show preview once the load is finished
- self.preview_webview.connect("load-changed", self.on_preview_load_change)
-
- # This saying that all links will be opened in default browser, \
- # but local files are opened in appropriate apps:
- self.preview_webview.connect("decide-policy", self.on_click_link)
-
- self.preview_webview.load_html(output, 'file://localhost/')
-
- def reload_preview(self):
- if self.preview_webview:
- self.show_preview()
+ def reload_preview(self, reshow=False):
+ self.preview_handler.reload(reshow=reshow)
def load_file(self, filename=None):
"""Open File from command line or open / open recent etc."""
@@ -617,15 +503,10 @@ class Window(Gtk.ApplicationWindow):
True -- Gtk things
"""
- if (self.was_motion is False
- and self.top_bottom_bars_visible
+ if (not self.was_motion
and self.buffer_modified_for_status_bar
- and self.text_view.props.has_focus): # pylint: disable=no-member
- # self.status_bar.set_state_flags(Gtk.StateFlags.INSENSITIVE, True)
- self.stats_counter_revealer.set_reveal_child(False)
- self.headerbar.hb_revealer.set_reveal_child(False)
- self.top_bottom_bars_visible = False
- self.buffer_modified_for_status_bar = False
+ and self.text_view.props.has_focus):
+ self.reveal_top_bottom_bars(False)
self.was_motion = False
return True
@@ -643,24 +524,22 @@ class Window(Gtk.ApplicationWindow):
return
if now - self.timestamp_last_mouse_motion > 100:
# react on motion by fading in headerbar and statusbar
- if self.top_bottom_bars_visible is False:
- self.stats_counter_revealer.set_reveal_child(True)
- self.headerbar.hb_revealer.set_reveal_child(True)
- self.headerbar.hb.props.opacity = 1
- self.top_bottom_bars_visible = True
- self.buffer_modified_for_status_bar = False
- # self.status_bar.set_state_flags(Gtk.StateFlags.NORMAL, True)
+ self.reveal_top_bottom_bars(True)
self.was_motion = True
def focus_out(self, _widget, _data=None):
"""events called when the window losses focus
"""
- if self.top_bottom_bars_visible is False:
- self.stats_counter_revealer.set_reveal_child(True)
- self.headerbar.hb_revealer.set_reveal_child(True)
- self.headerbar.hb.props.opacity = 1
- self.top_bottom_bars_visible = True
- self.buffer_modified_for_status_bar = False
+ self.reveal_top_bottom_bars(True)
+
+ def reveal_top_bottom_bars(self, reveal):
+ if self.top_bottom_bars_visible != reveal:
+ self.headerbar.hb_revealer.set_reveal_child(reveal)
+ self.stats_revealer.set_reveal_child(reveal)
+ for revealer in self.preview_handler.get_top_bottom_bar_revealers():
+ revealer.set_reveal_child(reveal)
+ self.top_bottom_bars_visible = reveal
+ self.buffer_modified_for_status_bar = reveal
def draw_gradient(self, _widget, cr):
"""draw fading gradient over the top and the bottom of the
@@ -726,18 +605,4 @@ class Window(Gtk.ApplicationWindow):
else:
self.filename = None
base_path = "/"
- self.settings.set_value("open-file-path", GLib.Variant("s", base_path))
-
- def on_preview_load_change(self, webview, event):
- """swaps text editor with preview once the load is complete
- """
- if event == WebKit.LoadEvent.FINISHED:
- self.show_preview(loaded=True)
-
- def on_click_link(self, web_view, decision, _decision_type):
- """provide ability for self.webview to open links in default browser
- """
- if web_view.get_uri().startswith(("http://", "https://", "www.")):
- webbrowser.open(web_view.get_uri())
- decision.ignore()
- return True # Don't let the event "bubble up"
+ self.settings.set_string("open-file-path", base_path)
diff --git a/uberwriter/preferences_dialog.py b/uberwriter/preferences_dialog.py
index fffda4b..921c09d 100644
--- a/uberwriter/preferences_dialog.py
+++ b/uberwriter/preferences_dialog.py
@@ -82,8 +82,12 @@ class PreferencesDialog:
self.gradient_overlay_switch.set_active(self.settings.get_value("gradient-overlay"))
self.gradient_overlay_switch.connect("state-set", self.on_gradient_overlay)
+ self.sync_scroll_switch = self.builder.get_object("sync_scroll_switch")
+ self.sync_scroll_switch.set_active(self.settings.get_value("sync-scroll"))
+ self.sync_scroll_switch.connect("state-set", self.on_sync_scroll)
+
input_format_store = Gtk.ListStore(int, str)
- input_format = self.settings.get_value("input-format").get_string()
+ input_format = self.settings.get_string("input-format")
input_format_active = 0
for i, fmt in enumerate(self.formats):
input_format_store.append([i, fmt["name"]])
@@ -107,28 +111,32 @@ class PreferencesDialog:
preferences_window.show()
def on_dark_mode_auto(self, _, state):
- self.settings.set_value("dark-mode-auto", GLib.Variant.new_boolean(state))
+ self.settings.set_boolean("dark-mode-auto", state)
if state and self.dark_mode_switch.get_active():
self.dark_mode_switch.set_active(GLib.Variant.new_boolean(False))
return False
def on_dark_mode(self, _, state):
- self.settings.set_value("dark-mode", GLib.Variant.new_boolean(state))
+ self.settings.set_boolean("dark-mode", state)
if state and self.dark_mode_auto_switch.get_active():
self.dark_mode_auto_switch.set_active(GLib.Variant.new_boolean(False))
return False
def on_spellcheck(self, _, state):
- self.settings.set_value("spellcheck", GLib.Variant.new_boolean(state))
+ self.settings.set_boolean("spellcheck", state)
return False
def on_gradient_overlay(self, _, state):
- self.settings.set_value("gradient-overlay", GLib.Variant.new_boolean(state))
+ self.settings.set_boolean("gradient-overlay", state)
+ return False
+
+ def on_sync_scroll(self, _, state):
+ self.settings.set_boolean("sync-scroll", state)
return False
def on_input_format(self, combobox):
fmt = self.formats[combobox.get_active()]
- self.settings.set_value("input-format", GLib.Variant.new_string(fmt["format"]))
+ self.settings.set_string("input-format", fmt["format"])
def on_input_format_help(self, _):
fmt = self.formats[self.input_format_combobox.get_active()]
diff --git a/uberwriter/preview_converter.py b/uberwriter/preview_converter.py
new file mode 100644
index 0000000..07fba58
--- /dev/null
+++ b/uberwriter/preview_converter.py
@@ -0,0 +1,49 @@
+from queue import Queue
+from threading import Thread
+
+from gi.repository import GLib
+
+from uberwriter import helpers
+from uberwriter.theme import Theme
+
+
+class PreviewConverter:
+ """Converts markdown to html using a background thread."""
+
+ def __init__(self):
+ super().__init__()
+
+ self.queue = Queue()
+ worker = Thread(target=self.__do_convert, name="preview-converter")
+ worker.daemon = True
+ worker.start()
+
+ def convert(self, text, callback, *user_data):
+ """Converts text to html, calling callback when done.
+
+ The callback argument contains the result."""
+
+ self.queue.put((text, callback, user_data))
+
+ def stop(self):
+ """Stops the background worker. PreviewConverter shouldn't be used after this."""
+
+ self.queue.put((None, None))
+
+ def __do_convert(self):
+ while True:
+ while True:
+ (text, callback, user_data) = self.queue.get()
+ if text is None and callback is None:
+ return
+ if self.queue.empty():
+ break
+
+ args = ['--standalone',
+ '--mathjax',
+ '--css=' + Theme.get_current().web_css_path,
+ '--lua-filter=' + helpers.get_script_path('relative_to_absolute.lua'),
+ '--lua-filter=' + helpers.get_script_path('task-list.lua')]
+ text = helpers.pandoc_convert(text, to="html5", args=args)
+
+ GLib.idle_add(callback, text, *user_data)
diff --git a/uberwriter/preview_handler.py b/uberwriter/preview_handler.py
new file mode 100644
index 0000000..7110e44
--- /dev/null
+++ b/uberwriter/preview_handler.py
@@ -0,0 +1,166 @@
+import math
+import webbrowser
+from enum import auto, IntEnum
+
+import gi
+
+from uberwriter.helpers import get_builder
+from uberwriter.preview_renderer import PreviewRenderer
+from uberwriter.settings import Settings
+
+gi.require_version('WebKit2', '4.0')
+from gi.repository import WebKit2, GLib
+
+from uberwriter.preview_converter import PreviewConverter
+from uberwriter.web_view import WebView
+
+
+class Step(IntEnum):
+ CONVERT_HTML = auto()
+ LOAD_WEBVIEW = auto()
+ RENDER = auto()
+
+
+class PreviewHandler:
+ """Handles showing/hiding the preview, and allows the user to toggle between modes.
+
+ The rendering itself is handled by `PreviewRendered`. This class handles conversion/loading and
+ connects it all together (including synchronization, ie. text changes, scroll)."""
+
+ def __init__(self, window, content, editor, text_view):
+ self.text_view = text_view
+
+ self.web_view = None
+ self.web_view_pending_html = None
+
+ builder = get_builder("Preview")
+ preview = builder.get_object("preview")
+ mode_button = builder.get_object("preview_mode_button")
+ self.mode_revealer = builder.get_object("preview_mode_revealer")
+
+ self.preview_converter = PreviewConverter()
+ self.preview_renderer = PreviewRenderer(
+ window, content, editor, text_view, preview, self.mode_revealer, mode_button)
+
+ window.connect("style-updated", self.reload)
+
+ self.text_changed_handler_id = None
+
+ self.settings = Settings.new()
+ self.web_scroll_handler_id = None
+ self.text_scroll_handler_id = None
+
+ self.loading = False
+ self.shown = False
+
+ def show(self):
+ self.__show()
+
+ def __show(self, html=None, step=Step.CONVERT_HTML):
+ if step == Step.CONVERT_HTML:
+ # First step: convert text to HTML.
+ buf = self.text_view.get_buffer()
+ self.preview_converter.convert(
+ buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False),
+ self.__show, Step.LOAD_WEBVIEW)
+
+ elif step == Step.LOAD_WEBVIEW:
+ # Second step: load HTML.
+ self.loading = True
+
+ if not self.web_view:
+ self.web_view = WebView()
+ self.web_view.get_settings().set_allow_universal_access_from_file_urls(True)
+
+ # Show preview once the load is finished
+ self.web_view.connect("load-changed", self.on_load_changed)
+
+ # All links will be opened in default browser, but local files are opened in apps.
+ self.web_view.connect("decide-policy", self.on_click_link)
+
+ if self.web_view.is_loading():
+ self.web_view_pending_html = html
+ else:
+ self.web_view.load_html(html, "file://localhost/")
+
+ elif step == Step.RENDER:
+ # Last step: show the preview. This is a one-time step.
+ if self.shown:
+ return
+ self.shown = True
+
+ self.text_changed_handler_id = \
+ self.text_view.get_buffer().connect("changed", self.__show)
+
+ GLib.idle_add(self.web_view.set_scroll_scale, self.text_view.get_scroll_scale())
+
+ self.preview_renderer.show(self.web_view)
+
+ if self.settings.get_boolean("sync-scroll"):
+ self.web_scroll_handler_id = \
+ self.web_view.connect("scroll-scale-changed", self.on_web_view_scrolled)
+ self.text_scroll_handler_id = \
+ self.text_view.connect("scroll-scale-changed", self.on_text_view_scrolled)
+
+ def reload(self, *_widget, reshow=False):
+ if self.shown:
+ if reshow:
+ self.hide()
+ self.show()
+
+ def hide(self):
+ if self.shown:
+ self.shown = False
+
+ self.text_view.get_buffer().disconnect(self.text_changed_handler_id)
+
+ GLib.idle_add(self.text_view.set_scroll_scale, self.web_view.get_scroll_scale())
+
+ self.preview_renderer.hide(self.web_view)
+
+ if self.text_scroll_handler_id:
+ self.text_view.disconnect(self.text_scroll_handler_id)
+ self.text_scroll_handler_id = None
+ if self.web_scroll_handler_id:
+ self.web_view.disconnect(self.web_scroll_handler_id)
+ self.web_scroll_handler_id = None
+
+ if self.loading:
+ self.loading = False
+
+ self.web_view.destroy()
+ self.web_view = None
+
+ def update_preview_mode(self):
+ self.preview_renderer.update_mode(self.web_view)
+
+ def get_top_bottom_bar_revealers(self):
+ if self.shown and not self.preview_renderer.window:
+ return [self.mode_revealer]
+ else:
+ return []
+
+ def on_load_changed(self, _web_view, event):
+ if event == WebKit2.LoadEvent.FINISHED:
+ self.loading = False
+ if self.web_view_pending_html:
+ self.__show(html=self.web_view_pending_html, step=Step.LOAD_WEBVIEW)
+ self.web_view_pending_html = None
+ else:
+ 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):
+ 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):
+ self.text_view.set_scroll_scale(scale)
+
+ @staticmethod
+ def on_click_link(web_view, decision, _decision_type):
+ if web_view.get_uri().startswith(("http://", "https://", "www.")):
+ webbrowser.open(web_view.get_uri())
+ decision.ignore()
+ return True
diff --git a/uberwriter/preview_renderer.py b/uberwriter/preview_renderer.py
new file mode 100644
index 0000000..2185486
--- /dev/null
+++ b/uberwriter/preview_renderer.py
@@ -0,0 +1,174 @@
+from gettext import gettext as _
+
+from gi.repository import Gtk, Gio, GLib
+
+from uberwriter import headerbars
+from uberwriter.settings import Settings
+from uberwriter.styled_window import StyledWindow
+
+
+class PreviewRenderer:
+ """Renders the preview according to the user selected mode."""
+
+ # Must match the order/index defined in gschema.xml
+ FULL_WIDTH = 0
+ HALF_WIDTH = 1
+ HALF_HEIGHT = 2
+ WINDOWED = 3
+
+ def __init__(
+ self, main_window, content, editor, text_view, preview, mode_revealer, mode_button):
+ self.main_window = main_window
+ self.main_window.connect("delete-event", self.on_window_closed)
+ self.content = content
+ self.editor = editor
+ self.text_view = text_view
+ self.preview = preview
+ self.mode_revealer = mode_revealer
+ self.mode_button = mode_button
+ self.mode_button.connect("clicked", self.show_mode_popover)
+
+ self.settings = Settings.new()
+ self.popover = None
+ self.window = None
+ self.headerbar = None
+
+ self.mode = self.settings.get_enum("preview-mode")
+ self.update_mode()
+
+ def show(self, web_view):
+ """Show the preview, depending on the currently selected mode."""
+
+ # Windowed preview: create a window and show the preview in it.
+ if self.mode == self.WINDOWED:
+ # Create transient window of the main window.
+ self.window = StyledWindow(application=self.main_window.get_application())
+ self.window.connect("delete-event", self.on_window_closed)
+
+ # Create a custom header bar and move the mode button there.
+ headerbar = headerbars.PreviewHeaderbar()
+ self.headerbar = headerbar.hb
+ self.headerbar.set_title(_("Preview"))
+ self.mode_button.get_style_context().remove_class("inline-button")
+ self.mode_revealer.remove(self.mode_button)
+ self.headerbar.pack_end(self.mode_button)
+ self.window.set_titlebar(headerbar.hb_container)
+
+ # Position it next to the main window.
+ width, height = self.main_window.get_size()
+ self.window.resize(width, height)
+ x, y = self.main_window.get_position()
+ if x is not None and y is not None:
+ self.main_window.move(x, y)
+ self.window.move(x + width + 16, y)
+
+ # Add webview and show.
+ self.window.add(web_view)
+ self.window.show()
+
+ else:
+ self.preview.pack_start(web_view, True, True, 0)
+ self.content.add(self.preview)
+
+ # Full-width preview: swap editor with preview.
+ if self.mode == self.FULL_WIDTH:
+ self.content.remove(self.editor)
+
+ # Half-width preview: set horizontal orientation and add the preview.
+ # Ask for a minimum width that respects the editor's minimum requirements.
+ elif self.mode == self.HALF_WIDTH:
+ self.content.set_orientation(Gtk.Orientation.HORIZONTAL)
+ self.content.set_size_request(self.text_view.get_min_width() * 2, -1)
+
+ # Half-height preview: set vertical orientation and add the preview.
+ # Ask for a minimum height that provides a comfortable experience.
+ elif self.mode == self.HALF_HEIGHT:
+ self.content.set_orientation(Gtk.Orientation.VERTICAL)
+ self.content.set_size_request(-1, 768)
+
+ else:
+ raise ValueError("Unknown preview mode {}".format(self.mode))
+
+ web_view.show()
+
+ def hide(self, web_view):
+ """Hide the preview, depending on the currently selected mode."""
+
+ # Windowed preview: remove preview and destroy window.
+ if self.mode == self.WINDOWED:
+ self.main_window.present()
+ self.headerbar.remove(self.mode_button)
+ self.mode_button.get_style_context().add_class("inline-button")
+ self.mode_revealer.add(self.mode_button)
+ self.headerbar = None
+ self.window.remove(web_view)
+ self.window.destroy()
+ self.window = None
+
+ else:
+ self.preview.remove(web_view)
+ self.content.remove(self.preview)
+
+ # Full-width preview: swap preview with editor.
+ if self.mode == self.FULL_WIDTH:
+ self.content.add(self.editor)
+
+ # Half-width/height previews: remove preview and reset size requirements.
+ elif self.mode == self.HALF_WIDTH or self.mode == self.HALF_HEIGHT:
+ self.content.set_size_request(-1, -1)
+
+ else:
+ raise ValueError("Unknown preview mode {}".format(self.mode))
+
+ def update_mode(self, web_view=None):
+ """Update preview mode, adjusting the mode button and the preview itself."""
+
+ mode = self.settings.get_enum("preview-mode")
+ if web_view and mode != self.mode:
+ self.hide(web_view)
+ self.mode = mode
+ self.show(web_view)
+ else:
+ self.mode = mode
+ if self.mode_button:
+ text = self.get_text_for_preview_mode(self.mode)
+ self.mode_button.set_label(text)
+ if self.popover:
+ self.popover.popdown()
+
+ def show_mode_popover(self, button):
+ """Show preview mode popover."""
+
+ self.mode_button.set_state_flags(Gtk.StateFlags.CHECKED, False)
+
+ menu = Gio.Menu()
+ modes = self.settings.props.settings_schema.get_key("preview-mode").get_range()[1]
+ for i, mode in enumerate(modes):
+ menu_item = Gio.MenuItem.new(self.get_text_for_preview_mode(i), None)
+ menu_item.set_action_and_target_value("app.preview_mode", GLib.Variant.new_string(mode))
+ menu.append_item(menu_item)
+ self.popover = Gtk.Popover.new_from_model(button, menu)
+ self.popover.connect('closed', self.on_popover_closed)
+ self.popover.popup()
+
+ def on_popover_closed(self, _popover):
+ self.mode_button.unset_state_flags(Gtk.StateFlags.CHECKED)
+
+ self.popover = None
+ self.text_view.grab_focus()
+
+ def on_window_closed(self, window, _event):
+ preview_action = window.get_application().lookup_action("preview")
+ preview_action.change_state(GLib.Variant.new_boolean(False))
+
+ def get_text_for_preview_mode(self, mode):
+ if mode == self.FULL_WIDTH:
+ return _("Full-Width")
+ elif mode == self.HALF_WIDTH:
+ return _("Half-Width")
+ elif mode == self.HALF_HEIGHT:
+ return _("Half-Height")
+ elif mode == self.WINDOWED:
+ return _("Windowed")
+ else:
+ raise ValueError("Unknown preview mode {}".format(mode))
diff --git a/uberwriter/scroller.py b/uberwriter/scroller.py
deleted file mode 100644
index 518167f..0000000
--- a/uberwriter/scroller.py
+++ /dev/null
@@ -1,48 +0,0 @@
-class Scroller:
- def __init__(self, scrolled_window, source_pos, target_pos):
- super().__init__()
-
- self.scrolled_window = scrolled_window
- self.source_pos = source_pos
- self.target_pos = target_pos
- self.duration = max(200, (target_pos - source_pos) / 50) * 1000
-
- self.is_started = False
- self.is_setup = False
- self.start_time = 0
- self.end_time = 0
- self.tick_callback_id = 0
-
- def start(self):
- self.is_started = True
- self.tick_callback_id = self.scrolled_window.add_tick_callback(self.on_tick)
-
- def end(self):
- self.scrolled_window.remove_tick_callback(self.tick_callback_id)
- self.is_started = False
-
- def setup(self, time):
- self.start_time = time
- self.end_time = time + self.duration
- self.is_setup = True
-
- def on_tick(self, widget, frame_clock):
- def ease_out_cubic(value):
- return pow(value - 1, 3) + 1
-
- now = frame_clock.get_frame_time()
- if not self.is_setup:
- self.setup(now)
-
- if now < self.end_time:
- time = float(now - self.start_time) / float(self.end_time - self.start_time)
- else:
- time = 1
- self.end()
-
- time = ease_out_cubic(time)
- pos = self.source_pos + (time * (self.target_pos - self.source_pos))
- widget.get_vadjustment().props.value = pos
- return True
-
-
diff --git a/uberwriter/search_and_replace.py b/uberwriter/search_and_replace.py
index 24fb1d9..87920eb 100644
--- a/uberwriter/search_and_replace.py
+++ b/uberwriter/search_and_replace.py
@@ -33,32 +33,32 @@ class SearchAndReplace:
uberwriter
"""
- def __init__(self, parentwindow, textview):
+ def __init__(self, parentwindow, textview, builder):
self.parentwindow = parentwindow
self.textview = textview
self.textbuffer = textview.get_buffer()
- self.box = parentwindow.builder.get_object("searchbar_placeholder")
+ self.box = builder.get_object("searchbar_placeholder")
self.box.set_reveal_child(False)
- self.searchbar = parentwindow.builder.get_object("searchbar")
- self.searchentry = parentwindow.builder.get_object("searchentrybox")
+ self.searchbar = builder.get_object("searchbar")
+ self.searchentry = builder.get_object("searchentrybox")
self.searchentry.connect('changed', self.search)
self.searchentry.connect('activate', self.scrolltonext)
self.searchentry.connect('key-press-event', self.key_pressed)
- self.open_replace_button = parentwindow.builder.get_object("replace")
+ self.open_replace_button = builder.get_object("replace")
self.open_replace_button.connect("toggled", self.toggle_replace)
- self.nextbutton = parentwindow.builder.get_object("next_result")
- self.prevbutton = parentwindow.builder.get_object("previous_result")
- self.regexbutton = parentwindow.builder.get_object("regex")
- self.casesensitivebutton = parentwindow.builder.get_object("case_sensitive")
+ self.nextbutton = builder.get_object("next_result")
+ self.prevbutton = builder.get_object("previous_result")
+ self.regexbutton = builder.get_object("regex")
+ self.casesensitivebutton = builder.get_object("case_sensitive")
- self.replacebox = parentwindow.builder.get_object("replace_placeholder")
+ self.replacebox = builder.get_object("replace_placeholder")
self.replacebox.set_reveal_child(False)
- self.replace_one_button = parentwindow.builder.get_object("replace_one")
- self.replace_all_button = parentwindow.builder.get_object("replace_all")
- self.replaceentry = parentwindow.builder.get_object("replaceentrybox")
+ self.replace_one_button = builder.get_object("replace_one")
+ self.replace_all_button = builder.get_object("replace_all")
+ self.replaceentry = builder.get_object("replaceentrybox")
self.replace_all_button.connect('clicked', self.replace_all)
self.replace_one_button.connect('clicked', self.replace_clicked)
@@ -96,7 +96,8 @@ class SearchAndReplace:
"""
show search box
"""
- if self.box.get_reveal_child() is False or self.searchbar.get_search_mode() is False:
+ if self.textview.get_mapped() and (
+ self.box.get_reveal_child() is False or self.searchbar.get_search_mode() is False):
self.searchbar.set_search_mode(True)
self.box.set_reveal_child(True)
self.searchentry.grab_focus()
diff --git a/uberwriter/stats_counter.py b/uberwriter/stats_counter.py
index fa4b103..adeac08 100644
--- a/uberwriter/stats_counter.py
+++ b/uberwriter/stats_counter.py
@@ -11,8 +11,11 @@ from uberwriter import helpers
class StatsCounter:
"""Counts characters, words, sentences and read time using a background thread."""
- # Regexp that matches any character, except for newlines and subsequent spaces.
- CHARACTERS = re.compile(r"[^\s]|(?:[^\S\n](?!\s))")
+ # 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 Asian letters, general symbols and hieroglyphs,
# as well as sequences of word characters optionally containing non-word characters in-between.
diff --git a/uberwriter/stats_handler.py b/uberwriter/stats_handler.py
index 8fce782..1709366 100644
--- a/uberwriter/stats_handler.py
+++ b/uberwriter/stats_handler.py
@@ -1,13 +1,7 @@
-import math
-import re
from gettext import gettext as _
-from queue import Queue
-from threading import Thread
from gi.repository import GLib, Gio, Gtk
-from uberwriter import helpers
-from uberwriter.helpers import get_builder
from uberwriter.settings import Settings
from uberwriter.stats_counter import StatsCounter
@@ -15,6 +9,13 @@ from uberwriter.stats_counter import StatsCounter
class StatsHandler:
"""Shows a default statistic on the stats button, and allows the user to toggle which one."""
+ # Must match the order/index defined in gschema.xml
+ CHARACTERS = 0
+ WORDS = 1
+ SENTENCES = 2
+ PARAGRAPHS = 3
+ READ_TIME = 4
+
def __init__(self, stats_button, text_view):
super().__init__()
@@ -34,7 +35,6 @@ class StatsHandler:
self.read_time = (0, 0, 0)
self.settings = Settings.new()
- self.default_stat = self.settings.get_enum("stat-default")
self.stats_counter = StatsCounter()
@@ -65,16 +65,16 @@ class StatsHandler:
self.update_stats)
def get_text_for_stat(self, stat):
- if stat == 0:
- return _("{:n} Characters".format(self.characters))
- elif stat == 1:
- return _("{:n} Words".format(self.words))
- elif stat == 2:
- return _("{:n} Sentences".format(self.sentences))
- elif stat == 3:
- return _("{:n} Paragraphs".format(self.paragraphs))
- elif stat == 4:
- return _("{:d}:{:02d}:{:02d} Read Time".format(*self.read_time))
+ if stat == self.CHARACTERS:
+ return _("{:n} Characters").format(self.characters)
+ elif stat == self.WORDS:
+ return _("{:n} Words").format(self.words)
+ elif stat == self.SENTENCES:
+ return _("{:n} Sentences").format(self.sentences)
+ elif stat == self.PARAGRAPHS:
+ return _("{:n} Paragraphs").format(self.paragraphs)
+ elif stat == self.READ_TIME:
+ return _("{:d}:{:02d}:{:02d} Read Time").format(*self.read_time)
else:
raise ValueError("Unknown stat {}".format(stat))
diff --git a/uberwriter/styled_window.py b/uberwriter/styled_window.py
new file mode 100644
index 0000000..e88101e
--- /dev/null
+++ b/uberwriter/styled_window.py
@@ -0,0 +1,37 @@
+import gi
+
+from uberwriter import helpers
+from uberwriter.theme import Theme
+
+gi.require_version('Gtk', '3.0')
+from gi.repository import Gtk, GLib
+
+
+class StyledWindow(Gtk.ApplicationWindow):
+ """A window that will redraw itself upon theme changes."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.connect("style-updated", self.apply_current_theme)
+ self.apply_current_theme()
+
+ def apply_current_theme(self, *_):
+ """Adjusts the window, CSD and preview for the current theme."""
+ # Get current theme
+ theme, changed = Theme.get_current_changed()
+ if changed:
+ # Set theme variant (dark/light)
+ Gtk.Settings.get_default().set_property(
+ "gtk-application-prefer-dark-theme",
+ GLib.Variant("b", theme.is_dark))
+
+ # Set theme css
+ style_provider = Gtk.CssProvider()
+ style_provider.load_from_path(helpers.get_css_path("gtk/base.css"))
+ Gtk.StyleContext.add_provider_for_screen(
+ self.get_screen(), style_provider,
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
+
+ # Redraw contents of window
+ self.queue_draw()
\ No newline at end of file
diff --git a/uberwriter/text_view.py b/uberwriter/text_view.py
index 7436372..ea16989 100644
--- a/uberwriter/text_view.py
+++ b/uberwriter/text_view.py
@@ -1,17 +1,19 @@
import gi
+from gi.repository.GObject import SignalMatchType
from uberwriter.inline_preview import InlinePreview
from uberwriter.text_view_format_inserter import FormatInserter
from uberwriter.text_view_markup_handler import MarkupHandler
+from uberwriter.text_view_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
-from uberwriter.scroller import Scroller
gi.require_version('Gtk', '3.0')
gi.require_version('Gspell', '1')
from gi.repository import Gtk, Gdk, GObject, GLib, Gspell
import logging
+
LOGGER = logging.getLogger('uberwriter')
@@ -36,10 +38,13 @@ class TextView(Gtk.TextView):
'insert-header': (GObject.SignalFlags.ACTION, None, ()),
'insert-strikethrough': (GObject.SignalFlags.ACTION, None, ()),
'undo': (GObject.SignalFlags.ACTION, None, ()),
- 'redo': (GObject.SignalFlags.ACTION, None, ())
+ 'redo': (GObject.SignalFlags.ACTION, None, ()),
+ 'scroll-scale-changed': (GObject.SIGNAL_RUN_LAST, None, (float,)),
}
- def __init__(self):
+ font_sizes = [18, 17, 16, 15, 14] # Must match CSS selectors in gtk/base.css
+
+ def __init__(self, line_chars):
super().__init__()
# Appearance
@@ -49,11 +54,18 @@ class TextView(Gtk.TextView):
self.set_pixels_inside_wrap(8)
self.get_style_context().add_class('uberwriter-editor')
+ # Text sizing
+ self.props.halign = Gtk.Align.FILL
+ self.line_chars = line_chars
+ self.font_size = 16
+ self.get_style_context().add_class('size16')
+
# General behavior
- self.get_buffer().connect('changed', self.on_text_changed)
self.connect('size-allocate', self.on_size_allocate)
+ self.get_buffer().connect('changed', self.on_text_changed)
# Spell checking
+ self.spellcheck = True
self.gspell_view = Gspell.TextView.get_from_gtk_text_view(self)
self.gspell_view.basic_setup()
@@ -85,6 +97,7 @@ class TextView(Gtk.TextView):
# Scrolling
self.scroller = None
+ self.connect('parent-set', self.on_parent_set)
self.get_buffer().connect('mark-set', self.on_mark_set)
# Focus mode
@@ -95,6 +108,12 @@ class TextView(Gtk.TextView):
self.hemingway_mode = False
self.connect('key-press-event', self.on_key_press_event)
+ # While resizing the TextView, there is unwanted scroll upwards if a top margin is present.
+ # When a size allocation is detected, this variable will hold the scroll to re-set until the
+ # UI is idle again.
+ # TODO: Find a better way to handle unwanted scroll.
+ self.frozen_scroll_scale = None
+
def get_text(self):
text_buffer = self.get_buffer()
start_iter = text_buffer.get_start_iter()
@@ -105,13 +124,59 @@ class TextView(Gtk.TextView):
text_buffer = self.get_buffer()
text_buffer.set_text(text)
- def on_text_changed(self, *_):
- self.markup.apply()
- GLib.idle_add(self.scroll_to)
+ def get_scroll_scale(self):
+ return self.scroller.get_scroll_scale() if self.scroller else 0
+
+ def set_scroll_scale(self, scale):
+ if self.scroller:
+ self.scroller.set_scroll_scale(scale)
def on_size_allocate(self, *_):
+ self.update_horizontal_margin()
self.update_vertical_margin()
self.markup.update_margins_indents()
+ self.queue_draw()
+
+ # TODO: Find a better way to handle unwanted scroll on resize.
+ self.frozen_scroll_scale = self.get_scroll_scale()
+ GLib.idle_add(self.unfreeze_scroll_scale)
+
+ def on_text_changed(self, *_):
+ self.markup.apply()
+ self.smooth_scroll_to()
+
+ def on_parent_set(self, *_):
+ parent = self.get_parent()
+ 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)
+ else:
+ self.scroller = None
+
+ def on_mark_set(self, _text_buffer, _location, mark, _data=None):
+ if mark.get_name() == 'insert':
+ self.markup.apply()
+ self.smooth_scroll_to(mark)
+ elif mark.get_name() == 'gtk_drag_target':
+ self.smooth_scroll_to(mark)
+ return True
+
+ def on_button_release_event(self, _widget, _event):
+ if self.focus_mode:
+ self.markup.apply()
+ return False
+
+ def on_scroll_scale_changed(self, *_):
+ if self.frozen_scroll_scale is not None:
+ self.set_scroll_scale(self.frozen_scroll_scale)
+ else:
+ self.emit("scroll-scale-changed", self.get_scroll_scale())
+
+ def unfreeze_scroll_scale(self):
+ self.frozen_scroll_scale = None
+ self.queue_draw()
def set_focus_mode(self, focus_mode):
"""Toggle focus mode.
@@ -120,10 +185,33 @@ class TextView(Gtk.TextView):
and the surrounding text is greyed out."""
self.focus_mode = focus_mode
- self.gspell_view.set_inline_spell_checking(not focus_mode)
self.update_vertical_margin()
self.markup.apply()
- self.scroll_to()
+ self.smooth_scroll_to()
+ self.set_spellcheck(self.spellcheck)
+
+ def set_spellcheck(self, spellcheck):
+ self.spellcheck = spellcheck
+ self.gspell_view.set_inline_spell_checking(self.spellcheck and not self.focus_mode)
+
+ def update_horizontal_margin(self):
+ width = self.get_allocation().width
+
+ # Ensure the appropriate font size is being used
+ for font_size in self.font_sizes:
+ if width >= self.get_min_width(font_size) or font_size == self.font_sizes[-1]:
+ if font_size != self.font_size:
+ self.font_size = font_size
+ for fs in self.font_sizes:
+ self.get_style_context().remove_class("size{}".format(fs))
+ self.get_style_context().add_class("size{}".format(font_size))
+ break
+
+ # Apply margin with the remaining space to allow for markup
+ line_width = (self.line_chars + 1) * int(self.get_char_width(self.font_size)) - 1
+ horizontal_margin = (width - line_width) / 2
+ self.props.left_margin = horizontal_margin
+ self.props.right_margin = horizontal_margin
def update_vertical_margin(self):
if self.focus_mode:
@@ -134,11 +222,6 @@ class TextView(Gtk.TextView):
self.props.top_margin = 80
self.props.bottom_margin = 64
- def on_button_release_event(self, _widget, _event):
- if self.focus_mode:
- self.markup.apply()
- return False
-
def set_hemingway_mode(self, hemingway_mode):
"""Toggle hemingway mode.
@@ -158,48 +241,34 @@ class TextView(Gtk.TextView):
self.get_buffer().set_text('')
self.undo_redo.clear()
- def scroll_to(self, mark=None):
+ def smooth_scroll_to(self, mark=None):
"""Scrolls if needed to ensure mark is visible.
If mark is unspecified, the cursor is used."""
- margin = 32
- scrolled_window = self.get_ancestor(Gtk.ScrolledWindow.__gtype__)
- if not scrolled_window:
- return
- va = scrolled_window.get_vadjustment()
- if va.props.page_size < margin * 2:
+ if self.scroller is None:
return
+ if mark is None:
+ mark = self.get_buffer().get_insert()
+ GLib.idle_add(self.scroller.smooth_scroll_to_mark, mark, self.focus_mode)
- text_buffer = self.get_buffer()
- if mark:
- mark_iter = text_buffer.get_iter_at_mark(mark)
- else:
- mark_iter = text_buffer.get_iter_at_mark(text_buffer.get_insert())
- mark_rect = self.get_iter_location(mark_iter)
+ def get_min_width(self, font_size=None):
+ """Returns the minimum width of this text view."""
- pos_y = mark_rect.y + mark_rect.height + self.props.top_margin
- pos = pos_y - va.props.value
- target_pos = None
- if self.focus_mode:
- if pos != (va.props.page_size * 0.5):
- target_pos = pos_y - (va.props.page_size * 0.5)
- elif pos > va.props.page_size - margin:
- target_pos = pos_y - va.props.page_size + margin
- elif pos < margin:
- target_pos = pos_y - margin
+ if font_size is None:
+ font_size = self.font_sizes[-1]
+ return (self.line_chars + self.get_pad_chars(font_size) + 1) \
+ * self.get_char_width(font_size) - 1
- if self.scroller and self.scroller.is_started:
- self.scroller.end()
- if target_pos:
- self.scroller = Scroller(scrolled_window, va.props.value, target_pos)
- self.scroller.start()
+ def get_pad_chars(self, font_size):
+ """Returns the amount of character padding for font_size.
- def on_mark_set(self, _text_buffer, _location, mark, _data=None):
- if mark.get_name() == 'insert':
- self.markup.apply()
- if self.focus_mode:
- self.scroll_to(mark)
- elif mark.get_name() == 'gtk_drag_target':
- self.scroll_to(mark)
- return True
+ Markup can use up to 7 in normal conditions."""
+
+ return 8 * (1 + font_size - self.font_sizes[-1])
+
+ @staticmethod
+ def get_char_width(font_size):
+ """Returns the font width for a given size. Note: specific to Fira Mono!"""
+
+ return font_size * 1 / 1.6
diff --git a/uberwriter/text_view_markup_handler.py b/uberwriter/text_view_markup_handler.py
index 029871a..40a3da5 100644
--- a/uberwriter/text_view_markup_handler.py
+++ b/uberwriter/text_view_markup_handler.py
@@ -37,13 +37,13 @@ class MarkupHandler:
"BOLDITALIC": re.compile(r"(\*\*\*|___)(.+?)\1"),
"STRIKETHROUGH": re.compile(r"~~.+?~~"),
"LINK": re.compile(r"(\[).*(\]\(.+?\))"),
- "HORIZONTALRULE": re.compile(r"\n\n([ ]{0,3}[*\-_]{3,}[ ]*)\n", re.MULTILINE),
+ "HORIZONTALRULE": re.compile(r"\n\n([ ]{0,3}[*\-_]{3,}[ ]*)\n\n", re.MULTILINE),
"LIST": re.compile(r"^((?:\t|[ ]{4})*)[\-*+] .+", re.MULTILINE),
"NUMERICLIST": re.compile(r"^((\d|[a-z]|#)+[.)]) ", 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"^[ ]{0,3}\w.+\n[ ]{0,3}[=\-]{3,}", 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}"),
@@ -79,10 +79,10 @@ class MarkupHandler:
strikethrough=False,
justification=Gtk.Justification.LEFT)
- self.table = buffer.create_tag('table')
- self.table.set_property('wrap-mode', Gtk.WrapMode.NONE)
- self.table.set_property('pixels-above-lines', 0)
- self.table.set_property('pixels-below-lines', 0)
+ self.table = buffer.create_tag('table',
+ wrap_mode=Gtk.WrapMode.NONE,
+ pixels_above_lines=0,
+ pixels_below_lines=0)
self.mathtext = buffer.create_tag('mathtext')
@@ -94,6 +94,8 @@ class MarkupHandler:
# Margin and indents
# A baseline margin is set to allow negative offsets for formatting headers, lists, etc
self.margins_indents = {}
+ self.baseline_margin = 0
+ self.char_width = 0
self.update_margins_indents()
# Style
@@ -272,11 +274,10 @@ class MarkupHandler:
def get_margin_indent_tag(self, margin_level, indent_level):
level = (margin_level, indent_level)
if level not in self.margins_indents:
- tag = self.text_buffer.create_tag(
- "margin_indent_" + str(margin_level) + "_" + str(indent_level))
margin, indent = self.get_margin_indent(margin_level, indent_level)
- tag.set_property("left-margin", margin)
- tag.set_property("indent", indent)
+ tag = self.text_buffer.create_tag(
+ "margin_indent_{}_{}".format(margin_level, indent_level),
+ left_margin=margin, indent=indent)
self.margins_indents[level] = tag
return tag
else:
@@ -284,7 +285,7 @@ class MarkupHandler:
def get_margin_indent(self, margin_level, indent_level, baseline_margin=None, char_width=None):
if baseline_margin is None:
- baseline_margin = self.text_view.get_left_margin()
+ baseline_margin = self.text_view.props.left_margin
if char_width is None:
char_width = helpers.get_char_width(self.text_view)
margin = max(baseline_margin + char_width * margin_level, 0)
@@ -292,16 +293,21 @@ class MarkupHandler:
return margin, indent
def update_margins_indents(self):
- baseline_margin = self.text_view.get_left_margin()
+ baseline_margin = self.text_view.props.left_margin
char_width = helpers.get_char_width(self.text_view)
- # Adjust tab size, as character width can change
+ # Bail out if neither the baseline margin nor character width change
+ if baseline_margin == self.baseline_margin and char_width == self.char_width:
+ return
+ self.baseline_margin = baseline_margin
+ self.char_width = char_width
+
+ # Adjust tab size
tab_array = Pango.TabArray.new(1, True)
tab_array.set_tab(0, Pango.TabAlign.LEFT, 4 * char_width)
self.text_view.set_tabs(tab_array)
- # Adjust margins and indents, as character width can change
+ # Adjust margins and indents
for level, tag in self.margins_indents.items():
margin, indent = self.get_margin_indent(*level, baseline_margin, char_width)
- tag.set_property("left-margin", margin)
- tag.set_property("indent", indent)
+ tag.set_properties(left_margin=margin, indent=indent)
diff --git a/uberwriter/text_view_scroller.py b/uberwriter/text_view_scroller.py
new file mode 100644
index 0000000..b483359
--- /dev/null
+++ b/uberwriter/text_view_scroller.py
@@ -0,0 +1,106 @@
+class TextViewScroller:
+ def __init__(self, text_view, scrolled_window):
+ super().__init__()
+
+ self.text_view = text_view
+ self.scrolled_window = scrolled_window
+ self.smooth_scroller = None
+
+ def get_scroll_scale(self):
+ vap = self.scrolled_window.get_vadjustment().props
+ if vap.upper > vap.page_size:
+ return vap.value / (vap.upper - vap.page_size)
+ else:
+ return 0
+
+ def set_scroll_scale(self, scale):
+ vap = self.scrolled_window.get_vadjustment().props
+ vap.value = (vap.upper - vap.page_size) * scale
+
+ def scroll_to_mark(self, mark, center):
+ """Scrolls until mark is visible, if needed."""
+
+ target_pos = self.get_target_pos_for_mark(mark, center)
+ if target_pos:
+ self.scrolled_window.get_vadjustment().set_value(target_pos)
+
+ def smooth_scroll_to_mark(self, mark, center):
+ """Smoothly scrolls until mark is visible, if needed."""
+
+ if self.smooth_scroller and self.smooth_scroller.is_started:
+ self.smooth_scroller.end()
+
+ target_pos = self.get_target_pos_for_mark(mark, center)
+ if target_pos:
+ source_pos = self.scrolled_window.get_vadjustment().props.value
+ self.smooth_scroller = SmoothScroller(self.scrolled_window, source_pos, target_pos)
+ self.smooth_scroller.start()
+
+ def get_target_pos_for_mark(self, mark, center):
+ margin = 32
+
+ mark_iter = self.text_view.get_buffer().get_iter_at_mark(mark)
+ mark_rect = self.text_view.get_iter_location(mark_iter)
+
+ vap = self.scrolled_window.get_vadjustment().props
+
+ pos_y = mark_rect.y + mark_rect.height + self.text_view.props.top_margin
+ pos_viewport_y = pos_y - vap.value
+ target_pos = None
+ if center:
+ if pos_viewport_y != vap.page_size / 2:
+ target_pos = pos_y - (vap.page_size / 2)
+ elif pos_viewport_y > vap.page_size - margin:
+ target_pos = pos_y - vap.page_size + margin
+ elif pos_viewport_y < margin:
+ target_pos = pos_y - margin - mark_rect.height
+
+ return target_pos
+
+
+class SmoothScroller:
+ def __init__(self, scrolled_window, source_pos, target_pos):
+ super().__init__()
+
+ self.scrolled_window = scrolled_window
+ self.source_pos = source_pos
+ self.target_pos = target_pos
+ self.duration = max(100, (target_pos - source_pos) / 50) * 1000
+
+ self.is_started = False
+ self.is_setup = False
+ self.start_time = 0
+ self.end_time = 0
+ self.tick_callback_id = 0
+
+ def start(self):
+ self.is_started = True
+ self.tick_callback_id = self.scrolled_window.add_tick_callback(self.on_tick)
+
+ def end(self):
+ self.scrolled_window.remove_tick_callback(self.tick_callback_id)
+ self.is_started = False
+
+ def setup(self, time):
+ self.start_time = time
+ self.end_time = time + self.duration
+ self.is_setup = True
+
+ def on_tick(self, widget, frame_clock):
+ def ease_out_cubic(value):
+ return pow(value - 1, 3) + 1
+
+ now = frame_clock.get_frame_time()
+ if not self.is_setup:
+ self.setup(now)
+
+ if now < self.end_time:
+ time = float(now - self.start_time) / float(self.end_time - self.start_time)
+ else:
+ time = 1
+ self.end()
+
+ time = ease_out_cubic(time)
+ pos = self.source_pos + (time * (self.target_pos - self.source_pos))
+ widget.get_vadjustment().props.value = pos
+ return True
diff --git a/uberwriter/theme.py b/uberwriter/theme.py
index db47156..2c11e2f 100644
--- a/uberwriter/theme.py
+++ b/uberwriter/theme.py
@@ -31,8 +31,8 @@ class Theme:
@classmethod
def get_current_changed(cls):
theme_name = Gtk.Settings.get_default().get_property('gtk-theme-name')
- dark_mode_auto = cls.settings.get_value('dark-mode-auto').get_boolean()
- dark_mode = cls.settings.get_value('dark-mode').get_boolean()
+ dark_mode_auto = cls.settings.get_boolean('dark-mode-auto')
+ dark_mode = cls.settings.get_boolean('dark-mode')
current_theme = cls.get_for_name(theme_name)
if not dark_mode_auto and dark_mode != current_theme.is_dark and current_theme.inverse_name:
current_theme = cls.get_for_name(current_theme.inverse_name, current_theme.name)
diff --git a/uberwriter/web_view.py b/uberwriter/web_view.py
new file mode 100644
index 0000000..d4bc6ae
--- /dev/null
+++ b/uberwriter/web_view.py
@@ -0,0 +1,115 @@
+import gi
+
+gi.require_version('WebKit2', '4.0')
+from gi.repository import WebKit2, GLib, GObject
+
+
+class WebView(WebKit2.WebView):
+ """A WebView that provides read/write access to scroll.
+
+ It does so using JavaScript, by continuously monitoring it while loaded.
+ The alternative is using a WebExtension and C-bindings (see reference), but that is more
+ complicated implementation-wise, as well as build-wise until we start building with Meson.
+
+ Reference: https://github.com/aperezdc/webkit2gtk-python-webextension-example
+ """
+
+ GET_SCROLL_SCALE_JS = """
+e = document.documentElement;
+e.scrollHeight > e.clientHeight ? e.scrollTop / (e.scrollHeight - e.clientHeight) : -1;
+"""
+
+ SET_SCROLL_SCALE_JS = """
+scale = {:.16f};
+e = document.documentElement;
+e.scrollTop = (e.scrollHeight - e.clientHeight) * scale;
+"""
+
+ __gsignals__ = {
+ "scroll-scale-changed": (GObject.SIGNAL_RUN_LAST, None, (float,)),
+ }
+
+ def __init__(self):
+ super().__init__()
+
+ 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.pending_scroll_scale = None
+
+ self.state_loaded = False
+ self.state_load_failed = False
+ self.state_discard_result = False
+ self.state_waiting = False
+
+ self.timeout_id = None
+
+ def get_scroll_scale(self):
+ return self.scroll_scale
+
+ def set_scroll_scale(self, scale):
+ self.pending_scroll_scale = scale
+ self.state_loop()
+
+ 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
+ self.state_discard_result = event == WebKit2.LoadEvent.STARTED and self.state_waiting
+ self.pending_scroll_scale = self.scroll_scale
+ self.state_loop()
+
+ def on_load_failed(self, _web_view, _event):
+ self.state_loaded = False
+ 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()
+
+ def read_scroll_scale(self):
+ self.state_waiting = True
+ self.run_javascript(
+ self.GET_SCROLL_SCALE_JS, None, self.finish_read_scroll_scale)
+
+ def write_scroll_scale(self, scroll_scale):
+ self.run_javascript(
+ self.SET_SCROLL_SCALE_JS.format(scroll_scale), None, None)
+
+ def finish_read_scroll_scale(self, _web_view, result):
+ self.state_waiting = False
+ if not self.state_discard_result:
+ result = self.run_javascript_finish(result)
+ self.state_loop(result.get_js_value().to_double())
+ else:
+ self.state_discard_result = False
+ self.state_loop()
+
+ def state_loop(self, scroll_scale=None, delay=16): # 16ms ~ 60hz
+ # Remove any pending callbacks
+ if self.timeout_id:
+ GLib.source_remove(self.timeout_id)
+ self.timeout_id = None
+
+ # Set scroll scale if specified, and the state is not dirty
+ if scroll_scale not in (None, -1, self.scroll_scale):
+ self.scroll_scale = scroll_scale
+ self.emit("scroll-scale-changed", self.scroll_scale)
+
+ # Handle the current state
+ if not self.state_loaded or self.state_load_failed or self.state_waiting:
+ return
+ if self.pending_scroll_scale:
+ self.write_scroll_scale(self.pending_scroll_scale)
+ self.pending_scroll_scale = None
+ self.read_scroll_scale()
+ elif delay > 0:
+ self.timeout_id = GLib.timeout_add(delay, self.state_loop, None, 0)
+ else:
+ self.read_scroll_scale()