Sync scroll between text view and web view

Scrolling is synced via scroll percentage. This works for most cases,
but breaks down on very large or complex documents. It is consistent
with the approach other editors use (eg. iA Writer), but in the future
we should explore alternatives that don't incur in edge cases.

The syncing itself is done via JavaScript. It could be argued that a
`WebExtension` is the better approach, but it is considerably more
complex for such a simple use case and it would be painful to implement
until UberWriter's build system is updated, since it requires
implementing a C extension.

Fixes #55
github/fork/yochananmarqos/patch-1
Gonçalo Silva 2019-04-21 01:23:44 +01:00
parent 63b20d0f3c
commit 562cc7e200
5 changed files with 310 additions and 113 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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