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/text_view.py b/uberwriter/text_view.py index 7436372..e70d8e8 100644 --- a/uberwriter/text_view.py +++ b/uberwriter/text_view.py @@ -3,9 +3,9 @@ import gi from uberwriter.inline_preview import InlinePreview from uberwriter.text_view_format_inserter import FormatInserter from uberwriter.text_view_markup_handler import MarkupHandler +from uberwriter.text_view_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') @@ -85,6 +85,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 @@ -105,14 +106,41 @@ class TextView(Gtk.TextView): text_buffer = self.get_buffer() text_buffer.set_text(text) + 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_text_changed(self, *_): self.markup.apply() - GLib.idle_add(self.scroll_to) + self.smooth_scroll_to() def on_size_allocate(self, *_): self.update_vertical_margin() self.markup.update_margins_indents() + def on_parent_set(self, *_): + parent = self.get_parent() + if parent: + self.scroller = TextViewScroller(self, parent) + 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 set_focus_mode(self, focus_mode): """Toggle focus mode. @@ -123,7 +151,7 @@ class TextView(Gtk.TextView): self.gspell_view.set_inline_spell_checking(not focus_mode) self.update_vertical_margin() self.markup.apply() - self.scroll_to() + self.smooth_scroll_to() def update_vertical_margin(self): if self.focus_mode: @@ -134,11 +162,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 +181,13 @@ 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: + if self.scroller is None: return - va = scrolled_window.get_vadjustment() - if va.props.page_size < margin * 2: - return - - 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) - - 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 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 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 + if mark is None: + mark = self.get_buffer().get_insert() + GLib.idle_add(self.scroller.smooth_scroll_to_mark, mark, self.focus_mode) diff --git a/uberwriter/text_view_scroller.py b/uberwriter/text_view_scroller.py new file mode 100644 index 0000000..70f4c95 --- /dev/null +++ b/uberwriter/text_view_scroller.py @@ -0,0 +1,109 @@ +from gi.repository import Gtk + + +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/web_view_scroller.py b/uberwriter/web_view_scroller.py new file mode 100644 index 0000000..2c757c0 --- /dev/null +++ b/uberwriter/web_view_scroller.py @@ -0,0 +1,134 @@ +import gi + +gi.require_version('Gtk', '3.0') +gi.require_version('WebKit2', '4.0') +from gi.repository import WebKit2, GLib + + +class WebViewScroller: + """Provides read/write scrolling functionality to a WebView. + + It does so using JavaScript, by continuously monitoring it while loaded and focused. + The alternative is using a WebExtension and C-bindings (see reference), but that is more + complicated implementation-wise, and build-wise, at least we start building with Meson. + + Reference: https://github.com/aperezdc/webkit2gtk-python-webextension-example + """ + + SETUP_SROLL_SCALE_JS = """ +const e = document.documentElement; +function get_scroll_scale() { + if (document.readyState !== "complete" || e.clientHeight === 0) { + return null; + } else if (e.scrollHeight <= e.clientHeight) { + return 0; + } else { + return e.scrollTop / (e.scrollHeight - e.clientHeight); + } +} +function set_scroll_scale(scale) { + if (document.readyState !== "complete") { + window.addEventListener("load", function() { set_scroll_scale(scale); }); + } else if (e.clientHeight === 0) { + window.addEventListener("resize", function() { set_scroll_scale(scale); }); + } else if (e.scrollHeight > e.clientHeight) { + e.scrollTop = (e.scrollHeight - e.clientHeight) * scale; + } + return get_scroll_scale(); +} +get_scroll_scale(); +""".strip() + + GET_SCROLL_SCALE_JS = "get_scroll_scale();" + + SET_SCROLL_SCALE_JS = "set_scroll_scale({:.16f});" + + def __init__(self, webview): + super().__init__() + + self.webview = webview + + self.webview.connect("focus-in-event", self.on_focus_changed) + self.webview.connect("focus-out-event", self.on_focus_changed) + self.webview.connect("load-changed", self.on_load_changed) + self.webview.connect("destroy", self.on_destroy) + + self.scroll_scale = 0 + + self.state_loaded = False + self.state_setup = False + self.state_focused = True + self.state_dirty = 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.scroll_scale = scale + self.state_dirty = True + self.state_loop() + + def on_focus_changed(self, _webview, event): + self.state_focused = event.in_ + self.state_loop() + + def on_load_changed(self, _webview, event): + self.state_loaded = event == WebKit2.LoadEvent.FINISHED + self.state_loop() + + def on_destroy(self, _widget): + self.state_loaded = False + self.state_focused = False + self.state_loop() + self.webview = None + + def setup_scroll_state(self): + self.state_waiting = True + self.state_setup = True + self.webview.run_javascript( + self.SETUP_SROLL_SCALE_JS, None, self.sync_scroll_scale) + + def read_scroll_scale(self): + self.state_waiting = True + self.webview.run_javascript( + self.GET_SCROLL_SCALE_JS, None, self.sync_scroll_scale) + + def write_scroll_scale(self): + self.state_waiting = True + self.state_dirty = False + self.webview.run_javascript( + self.SET_SCROLL_SCALE_JS.format(self.scroll_scale), None, self.sync_scroll_scale) + + def sync_scroll_scale(self, _webview, result): + self.state_waiting = False + result = self.webview.run_javascript_finish(result) + self.state_loop(result.get_js_value().to_double()) + + def state_loop(self, scroll_scale=None, read_delay=500): + # 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 is not None and not self.state_dirty: + self.scroll_scale = scroll_scale + + # Handle the current state + if self.state_waiting: + return + elif not self.state_loaded: + return + elif not self.state_setup: + self.setup_scroll_state() + elif self.state_dirty: + self.write_scroll_scale() + elif self.state_focused: + if read_delay > 0: + self.timeout_id = GLib.timeout_add(read_delay, self.state_loop, None, 0) + else: + self.read_scroll_scale() + diff --git a/uberwriter/window.py b/uberwriter/window.py index 10cdb97..5ef61de 100644 --- a/uberwriter/window.py +++ b/uberwriter/window.py @@ -27,6 +27,7 @@ import gi from uberwriter.export_dialog import Export from uberwriter.stats_handler import StatsHandler from uberwriter.text_view import TextView +from uberwriter.web_view_scroller import WebViewScroller gi.require_version('Gtk', '3.0') gi.require_version('WebKit2', '4.0') # pylint: disable=wrong-import-position @@ -108,7 +109,8 @@ class Window(Gtk.ApplicationWindow): self.text_view.grab_focus() # Setup preview webview - self.preview_webview = None + self.web_view = None + self.web_view_scroller = None self.scrolled_window = self.builder.get_object('editor_scrolledwindow') self.scrolled_window.get_style_context().add_class('uberwriter-scrolled-window') @@ -503,25 +505,36 @@ class Window(Gtk.ApplicationWindow): """ if state.get_boolean(): - self.show_preview() + self.show_web_view() else: self.show_text_editor() return True def show_text_editor(self): + # Swap web view with text view 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): + # Sync scroll between web view and text view + self.text_view.set_scroll_scale(self.web_view_scroller.get_scroll_scale()) + + # Destroy web view to clean up resources + self.web_view.destroy() + self.web_view = None + self.web_view_scroller = None + + def show_web_view(self, loaded=False): if loaded: + # Sync scroll between text view and web view + self.web_view_scroller.set_scroll_scale(self.text_view.get_scroll_scale()) + + # Swap text view with web view self.scrolled_window.remove(self.scrolled_window.get_child()) - self.scrolled_window.add(self.preview_webview) - self.preview_webview.show() + self.scrolled_window.add(self.web_view) + self.web_view.show() self.queue_draw() else: args = ['--standalone', @@ -531,22 +544,23 @@ class Window(Gtk.ApplicationWindow): '--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) + if self.web_view is None: + self.web_view = WebKit.WebView() + self.web_view.get_settings().set_allow_universal_access_from_file_urls(True) + self.web_view_scroller = WebViewScroller(self.web_view) # Show preview once the load is finished - self.preview_webview.connect("load-changed", self.on_preview_load_change) + self.web_view.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.web_view.connect("decide-policy", self.on_click_link) - self.preview_webview.load_html(output, 'file://localhost/') + self.web_view.load_html(output, 'file://localhost/') def reload_preview(self): - if self.preview_webview: - self.show_preview() + if self.web_view: + self.show_web_view() def load_file(self, filename=None): """Open File from command line or open / open recent etc.""" @@ -726,11 +740,11 @@ class Window(Gtk.ApplicationWindow): base_path = "/" self.settings.set_value("open-file-path", GLib.Variant("s", base_path)) - def on_preview_load_change(self, webview, event): + def on_preview_load_change(self, _web_view, event): """swaps text editor with preview once the load is complete """ if event == WebKit.LoadEvent.FINISHED: - self.show_preview(loaded=True) + self.show_web_view(loaded=True) def on_click_link(self, web_view, decision, _decision_type): """provide ability for self.webview to open links in default browser