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 False start - Input format + Synchronize editor/preview scrolling right @@ -151,17 +151,29 @@ - + 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 200 @@ -70,65 +65,75 @@ - + 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()