1# vim:fileencoding=utf-8
2# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
3from __python__ import bound_methods, hash_literals
4
5# Notes about flow mode scrolling:
6#  All the math in flow mode is based on the block and inline directions.
7#  Inline is "the direction lines of text go."
8#  In horizontal scripts such as English and Hebrew, the inline is horizontal
9#  and the block is vertical.
10#  In vertical languages such as Japanese and Mongolian, the inline is vertical
11#  and block is horizontal.
12#  Regardless of language, flow mode scrolls in the block direction.
13#
14#  In vertical RTL books, the block position scrolls from right to left. |<------|
15#  This means that the viewport positions become negative as you scroll.
16#  This is hidden from flow mode by the viewport, which transparently
17#  negates any inline coordinates sent in to the viewport_to_document* functions
18#  and the various scroll_to/scroll_by functions, as well as the reported X position.
19#
20#  The result of all this is that flow mode's math can safely pretend that
21#  things scroll in the positive block direction.
22
23from dom import set_css
24from range_utils import wrap_range, unwrap
25from read_book.cfi import scroll_to as cfi_scroll_to
26from read_book.globals import current_spine_item, get_boss, rtl_page_progression, ltr_page_progression
27from read_book.settings import opts
28from read_book.viewport import line_height, rem_size, scroll_viewport
29
30
31def flow_to_scroll_fraction(frac, on_initial_load):
32    scroll_viewport.scroll_to_in_block_direction(scroll_viewport.document_block_size() * frac)
33
34
35small_scroll_events = v'[]'
36
37
38def clear_small_scrolls():
39    nonlocal small_scroll_events
40    small_scroll_events = v'[]'
41
42
43def dispatch_small_scrolls():
44    if small_scroll_events.length:
45        now = window.performance.now()
46        if now - small_scroll_events[-1].time <= 2000:
47            window.setTimeout(dispatch_small_scrolls, 100)
48            return
49        amt = 0
50        for x in small_scroll_events:
51            amt += x.amt
52        clear_small_scrolls()
53        get_boss().report_human_scroll(amt / scroll_viewport.document_block_size())
54
55
56def add_small_scroll(amt):
57    small_scroll_events.push({'amt': amt, 'time': window.performance.now()})
58    window.setTimeout(dispatch_small_scrolls, 100)
59
60
61def report_human_scroll(amt):
62    h = scroll_viewport.height()
63    is_large_scroll = (abs(amt) / h) >= 0.5
64    if amt > 0:
65        if is_large_scroll:
66            clear_small_scrolls()
67            get_boss().report_human_scroll(amt / scroll_viewport.document_block_size())
68        else:
69            add_small_scroll(amt)
70    elif amt is 0 or is_large_scroll:
71        clear_small_scrolls()
72
73
74last_change_spine_item_request = {}
75
76
77def _check_for_scroll_end(func, obj, args, report):
78    before = scroll_viewport.block_pos()
79    should_flip_progression_direction = func.apply(obj, args)
80
81    now = window.performance.now()
82    scroll_animator.sync(now)
83
84    if scroll_viewport.block_pos() is before:
85        csi = current_spine_item()
86        if last_change_spine_item_request.name is csi.name and now - last_change_spine_item_request.at < 2000:
87            return False
88        last_change_spine_item_request.name = csi.name
89        last_change_spine_item_request.at = now
90        go_to_previous_page = args[0] < 0
91        if (should_flip_progression_direction):
92            go_to_previous_page = not go_to_previous_page
93        get_boss().send_message('next_spine_item', previous=go_to_previous_page)
94        return False
95    if report:
96        report_human_scroll(scroll_viewport.block_pos() - before)
97    return True
98
99
100def check_for_scroll_end(func):
101    return def ():
102        return _check_for_scroll_end(func, this, arguments, False)
103
104
105def check_for_scroll_end_and_report(func):
106    return def ():
107        return _check_for_scroll_end(func, this, arguments, True)
108
109
110@check_for_scroll_end_and_report
111def scroll_by_and_check_next_page(y):
112    scroll_viewport.scroll_by_in_block_direction(y)
113    # This indicates to check_for_scroll_end_and_report that it should not
114    # flip the page progression direction.
115    return False
116
117def flow_onwheel(evt):
118    di = db = 0
119    WheelEvent = window.WheelEvent
120    # Y deltas always scroll in the previous and next page direction,
121    # regardless of writing direction, since doing otherwise would
122    # make mouse wheels mostly useless for scrolling in books written
123    # vertically.
124    if evt.deltaY:
125        if evt.deltaMode is WheelEvent.DOM_DELTA_PIXEL:
126            db = evt.deltaY
127        elif evt.deltaMode is WheelEvent.DOM_DELTA_LINE:
128            db = line_height() * evt.deltaY
129        if evt.deltaMode is WheelEvent.DOM_DELTA_PAGE:
130            db = (scroll_viewport.block_size() - 30) * evt.deltaY
131    # X deltas scroll horizontally in both horizontal and vertical books.
132    # It's more natural in both cases.
133    if evt.deltaX:
134        if evt.deltaMode is WheelEvent.DOM_DELTA_PIXEL:
135            dx = evt.deltaX
136        elif evt.deltaMode is WheelEvent.DOM_DELTA_LINE:
137            dx = line_height() * evt.deltaX
138        else:
139            dx = (scroll_viewport.block_size() - 30) * evt.deltaX
140
141        if scroll_viewport.horizontal_writing_mode:
142            di = dx
143        else:
144            # Left goes forward, so make sure left is positive and right is negative,
145            # which is the opposite of what the wheel sends.
146            if scroll_viewport.rtl:
147                db = -dx
148            # Right goes forward, so the sign is correct.
149            else:
150                db = dx
151    if Math.abs(di) >= 1:
152        scroll_viewport.scroll_by_in_inline_direction(di)
153    if Math.abs(db) >= 1:
154        scroll_by_and_check_next_page(db)
155
156@check_for_scroll_end
157def goto_boundary(dir):
158    position = 0 if dir is DIRECTION.Up else scroll_viewport.document_block_size()
159    scroll_viewport.scroll_to_in_block_direction(position)
160    get_boss().report_human_scroll()
161    return False
162
163
164@check_for_scroll_end_and_report
165def scroll_by_page(direction, flip_if_rtl_page_progression):
166    b = scroll_viewport.block_size() - 10
167    scroll_viewport.scroll_by_in_block_direction(b * direction)
168
169    # Let check_for_scroll_end_and_report know whether or not it should flip
170    # the progression direction.
171    return flip_if_rtl_page_progression and rtl_page_progression()
172
173
174def scroll_to_extend_annotation(backward, horizontal, by_page):
175    direction = -1 if backward else 1
176    h = line_height()
177    if by_page:
178        h = (window.innerWidth if horizontal else window.innerHeight) - h
179    if horizontal:
180        before = window.pageXOffset
181        window.scrollBy(h * direction, 0)
182        return window.pageXOffset is not before
183    before = window.pageYOffset
184    window.scrollBy(0, h * direction)
185    return window.pageYOffset is not before
186
187def is_auto_scroll_active():
188    return scroll_animator.is_active()
189
190
191def start_autoscroll():
192    scroll_animator.start(DIRECTION.Down, True)
193
194
195def toggle_autoscroll():
196    if is_auto_scroll_active():
197        cancel_scroll()
198        running = False
199    else:
200        start_autoscroll()
201        running = True
202    get_boss().send_message('autoscroll_state_changed', running=running)
203
204
205def handle_shortcut(sc_name, evt):
206    if sc_name is 'down':
207        scroll_animator.start(DIRECTION.Down, False)
208        return True
209    if sc_name is 'up':
210        scroll_animator.start(DIRECTION.Up, False)
211        return True
212    if sc_name is 'start_of_file':
213        goto_boundary(DIRECTION.Up)
214        return True
215    if sc_name is 'end_of_file':
216        goto_boundary(DIRECTION.Down)
217        return True
218    if sc_name is 'left':
219        scroll_by_and_check_next_page(-15 if ltr_page_progression() else 15, 0)
220        return True
221    if sc_name is 'right':
222        scroll_by_and_check_next_page(15 if ltr_page_progression() else -15, 0)
223        return True
224    if sc_name is 'start_of_book':
225        get_boss().send_message('goto_doc_boundary', start=True)
226        return True
227    if sc_name is 'end_of_book':
228        get_boss().send_message('goto_doc_boundary', start=False)
229        return True
230    if sc_name is 'pageup':
231        scroll_by_page(-1, False)
232        return True
233    if sc_name is 'pagedown':
234        scroll_by_page(1, False)
235        return True
236    if sc_name is 'toggle_autoscroll':
237        toggle_autoscroll()
238        return True
239
240    if sc_name.startsWith('scrollspeed_'):
241        scroll_animator.sync()
242
243    return False
244
245
246def layout(is_single_page):
247    add_visibility_listener()
248    line_height(True)
249    rem_size(True)
250    set_css(document.body, margin='0', border_width='0', padding='0')
251    body_style = window.getComputedStyle(document.body)
252    # scroll viewport needs to know if we're in vertical mode,
253    # since that will cause scrolling to happen left and right
254    scroll_viewport.initialize_on_layout(body_style)
255    document.documentElement.style.overflow = 'visible' if scroll_viewport.vertical_writing_mode else 'hidden'
256
257def auto_scroll_resume():
258    scroll_animator.wait = False
259    scroll_animator.sync()
260
261
262def add_visibility_listener():
263    if add_visibility_listener.done:
264        return
265    add_visibility_listener.done = True
266    # Pause auto-scroll while minimized
267    document.addEventListener("visibilitychange", def():
268        if (document.visibilityState is 'visible'):
269            scroll_animator.sync()
270        else:
271            scroll_animator.pause()
272    )
273
274
275def cancel_scroll():
276    scroll_animator.stop()
277
278
279def is_scroll_end(pos):
280    return not (0 <= pos <= scroll_viewport.document_block_size() - scroll_viewport.block_size())
281
282
283DIRECTION = {'Up': -1, 'up': -1, 'Down': 1, 'down': 1, 'UP': -1, 'DOWN': 1}
284
285
286class ScrollAnimator:
287    DURATION = 100 # milliseconds
288
289    def __init__(self):
290        self.animation_id = None
291        self.auto = False
292        self.auto_timer = None
293        self.paused = False
294
295    def is_running(self):
296        return self.animation_id is not None or self.auto_timer is not None
297
298    def is_active(self):
299        return self.is_running() and (self.auto or self.auto_timer is not None)
300
301    def start(self, direction, auto):
302        cancel_drag_scroll()
303        if self.wait:
304            return
305
306        now = window.performance.now()
307        self.end_time = now + self.DURATION
308        self.stop_auto_spine_transition()
309
310        if not self.is_running() or direction is not self.direction or auto is not self.auto:
311            if self.auto and not auto:
312                self.pause()
313            self.stop()
314            self.auto = auto
315            self.direction = direction
316            self.start_time = now
317            self.start_offset = scroll_viewport.block_pos()
318            self.csi_idx = current_spine_item().index
319            self.animation_id = window.requestAnimationFrame(self.auto_scroll if auto else self.smooth_scroll)
320
321    def smooth_scroll(self, ts):
322        duration = self.end_time - self.start_time
323        if duration <= 0:
324            self.animation_id = None
325            return
326        progress = max(0, min(1, (ts - self.start_time) / duration)) # max/min to account for jitter
327        scroll_target = self.start_offset
328        scroll_target += Math.trunc(self.direction * progress * duration * line_height() * opts.lines_per_sec_smooth) / 1000
329
330        scroll_viewport.scroll_to_in_block_direction(scroll_target)
331        amt = scroll_viewport.block_pos() - self.start_offset
332
333        if is_scroll_end(scroll_target) and (not opts.scroll_stop_boundaries or (abs(amt) < 3 and duration is self.DURATION)):
334            # "Turn the page" if stop at boundaries option is false or
335            # this is a new scroll action and we were already at the end
336            self.animation_id = None
337            self.wait = True
338            report_human_scroll(amt)
339            get_boss().send_message('next_spine_item', previous=self.direction is DIRECTION.Up)
340        elif progress < 1:
341            self.animation_id = window.requestAnimationFrame(self.smooth_scroll)
342        elif self.paused:
343            self.resume()
344        else:
345            self.animation_id = None
346            report_human_scroll(amt)
347
348    def auto_scroll(self, ts):
349        elapsed = max(0, ts - self.start_time) # max to account for jitter
350        scroll_target = self.start_offset
351        scroll_target += Math.trunc(self.direction * elapsed * line_height() * opts.lines_per_sec_auto) / 1000
352
353        scroll_viewport.scroll_to_in_block_direction(scroll_target)
354        scroll_finished = is_scroll_end(scroll_target)
355
356        # report every second
357        if elapsed >= 1000:
358            self.sync(ts)
359
360        if scroll_finished:
361            self.pause()
362            if opts.scroll_auto_boundary_delay >= 0:
363                self.auto_timer = setTimeout(self.request_next_spine_item, opts.scroll_auto_boundary_delay * 1000)
364        else:
365            self.animation_id = window.requestAnimationFrame(self.auto_scroll)
366
367    def request_next_spine_item(self):
368        self.auto_timer = None
369        get_boss().send_message('next_spine_item', previous=self.direction is DIRECTION.Up)
370
371    def report(self):
372        amt = scroll_viewport.block_pos() - self.start_offset
373        if abs(amt) > 0 and self.csi_idx is current_spine_item().index:
374            report_human_scroll(amt)
375
376    def sync(self, ts):
377        if self.auto:
378            self.report()
379            self.csi_idx = current_spine_item().index
380            self.start_time = ts or window.performance.now()
381            self.start_offset = scroll_viewport.block_pos()
382        else:
383            self.resume()
384
385    def stop(self):
386        self.auto = False
387        if self.animation_id is not None:
388            window.cancelAnimationFrame(self.animation_id)
389            self.animation_id = None
390            self.report()
391        self.stop_auto_spine_transition()
392
393    def stop_auto_spine_transition(self):
394        if self.auto_timer is not None:
395            clearTimeout(self.auto_timer)
396            self.auto_timer = None
397            self.paused = False
398
399    def pause(self):
400        if self.auto:
401            self.paused = self.direction
402            self.stop()
403        else:
404            self.paused = False
405
406    # Resume auto-scroll
407    def resume(self):
408        if self.paused:
409            self.start(self.paused, True)
410            self.paused = False
411
412scroll_animator = ScrollAnimator()
413
414
415class FlickAnimator:
416
417    SPEED_FACTOR = 0.04
418    DECEL_TIME_CONSTANT = 325  # milliseconds
419    VELOCITY_HISTORY = 300  # milliseconds
420    MIMUMUM_VELOCITY = 100  # pixels/sec
421
422    def __init__(self):
423        self.animation_id = None
424
425    def start(self, gesture):
426        cancel_drag_scroll()
427        self.vertical = gesture.axis is 'vertical'
428        now = window.performance.now()
429        points = times = None
430        for i, t in enumerate(gesture.times):
431            if now - t < self.VELOCITY_HISTORY:
432                points, times = gesture.points[i:], gesture.times[i:]
433                break
434        if times and times.length > 1:
435            elapsed = (times[-1] - times[0]) / 1000
436            if elapsed > 0 and points.length > 1:
437                delta = points[0] - points[-1]
438                velocity = delta / elapsed
439                if abs(velocity) > self.MIMUMUM_VELOCITY:
440                    self.amplitude = self.SPEED_FACTOR * velocity
441                    self.start_time = now
442                    self.animation_id = window.requestAnimationFrame(self.auto_scroll)
443
444    def auto_scroll(self, ts):
445        if self.animation_id is None:
446            return
447        elapsed = window.performance.now() - self.start_time
448        delta = self.amplitude * Math.exp(-elapsed / self.DECEL_TIME_CONSTANT)
449        if abs(delta) >= 1:
450            delta = Math.round(delta)
451            if self.vertical:
452                window.scrollBy(0, delta)
453            else:
454                window.scrollBy(delta, 0)
455            self.animation_id = window.requestAnimationFrame(self.auto_scroll)
456
457    def stop(self):
458        if self.animation_id is not None:
459            window.cancelAnimationFrame(self.animation_id)
460            self.animation_id = None
461
462flick_animator = FlickAnimator()
463
464
465class DragScroller:
466
467    DURATION = 100 # milliseconds
468
469    def __init__(self):
470        self.animation_id = None
471        self.direction = 1
472        self.speed_factor = 1
473        self.start_time = self.end_time = 0
474        self.start_offset = 0
475
476    def is_running(self):
477        return self.animation_id is not None
478
479    def smooth_scroll(self, ts):
480        duration = self.end_time - self.start_time
481        if duration <= 0:
482            self.animation_id = None
483            self.start(self.direction, self.speed_factor)
484            return
485        progress = max(0, min(1, (ts - self.start_time) / duration)) # max/min to account for jitter
486        scroll_target = self.start_offset
487        scroll_target += Math.trunc(self.direction * progress * duration * line_height() * opts.lines_per_sec_smooth * self.speed_factor) / 1000
488        scroll_viewport.scroll_to_in_block_direction(scroll_target)
489
490        if progress < 1:
491            self.animation_id = window.requestAnimationFrame(self.smooth_scroll)
492        else:
493            self.animation_id = None
494            self.start(self.direction, self.speed_factor)
495
496    def start(self, direction, speed_factor):
497        now = window.performance.now()
498        self.end_time = now + self.DURATION
499        if not self.is_running() or direction is not self.direction or speed_factor is not self.speed_factor:
500            self.stop()
501            self.direction = direction
502            self.speed_factor = speed_factor
503            self.start_time = now
504            self.start_offset = scroll_viewport.block_pos()
505            self.animation_id = window.requestAnimationFrame(self.smooth_scroll)
506
507    def stop(self):
508        if self.animation_id is not None:
509            window.cancelAnimationFrame(self.animation_id)
510            self.animation_id = None
511
512
513drag_scroller = DragScroller()
514
515
516def cancel_drag_scroll():
517    drag_scroller.stop()
518
519
520def start_drag_scroll(delta):
521    limit = opts.margin_top if delta < 0 else opts.margin_bottom
522    direction = 1 if delta >= 0 else -1
523    speed_factor = min(abs(delta), limit) / limit
524    speed_factor *= (2 - speed_factor)  # QuadOut Easing curve
525    drag_scroller.start(direction, 2 * speed_factor)
526
527
528def handle_gesture(gesture):
529    flick_animator.stop()
530    if gesture.type is 'swipe':
531        if gesture.points.length > 1 and not gesture.is_held:
532            delta = gesture.points[-2] - gesture.points[-1]
533            if Math.abs(delta) >= 1:
534                if gesture.axis is 'vertical':
535                    # Vertical writing scrolls left and right,
536                    # so doing a vertical flick shouldn't change pages.
537                    if scroll_viewport.vertical_writing_mode:
538                        scroll_viewport.scroll_by(delta, 0)
539                    # However, it might change pages in horizontal writing
540                    else:
541                        scroll_by_and_check_next_page(delta)
542                else:
543                    # A horizontal flick should check for new pages in
544                    # vertical modes, since they flow left and right.
545                    if scroll_viewport.vertical_writing_mode:
546                        scroll_by_and_check_next_page(delta)
547                    # In horizontal modes, just move by the delta.
548                    else:
549                        scroll_viewport.scroll_by(delta, 0)
550        if not gesture.active and not gesture.is_held:
551            flick_animator.start(gesture)
552    elif gesture.type is 'prev-page':
553        # should flip = False - previous is previous whether RTL or LTR.
554        # flipping of this command is handled higher up
555        scroll_by_page(-1, False)
556    elif gesture.type is 'next-page':
557        # should flip = False - next is next whether RTL or LTR.
558        # flipping of this command is handled higher up
559        scroll_by_page(1, False)
560
561
562anchor_funcs = {
563    'pos_for_elem': def pos_for_elem(elem):
564        if not elem:
565            return {'block': 0, 'inline': 0}
566        br = elem.getBoundingClientRect()
567
568        # Start of object in the scrolling direction
569        return {
570            'block': scroll_viewport.viewport_to_document_block(scroll_viewport.rect_block_start(br), elem.ownerDocument),
571            'inline': scroll_viewport.viewport_to_document_inline(scroll_viewport.rect_inline_start(br), elem.ownerDocument)
572        }
573    ,
574    'visibility': def visibility(pos):
575        q = pos.block
576        # Have to negate X if in RTL for the math to be correct,
577        # as the value that the scroll viewport returns is negated
578        if scroll_viewport.vertical_writing_mode and scroll_viewport.rtl:
579            q = -q
580
581        if q + 1 < scroll_viewport.block_pos():
582            # the + 1 is needed for visibility when top aligned, see: https://bugs.launchpad.net/calibre/+bug/1924890
583            return -1
584        if q <= scroll_viewport.block_pos() + scroll_viewport.block_size():
585            return 0
586        return 1
587    ,
588    'cmp': def cmp(a, b):
589        return (a.block - b.block) or (a.inline - b.inline)
590    ,
591}
592
593
594def auto_scroll_action(action):
595    if action is 'toggle':
596        toggle_autoscroll()
597    elif action is 'start':
598        if not is_auto_scroll_active():
599            toggle_autoscroll()
600    elif action is 'stop':
601        if is_auto_scroll_active():
602            toggle_autoscroll()
603    elif action is 'resume':
604        auto_scroll_resume()
605    return is_auto_scroll_active()
606
607
608def closest_preceding_element(p):
609    while p and not p.scrollIntoView:
610        p = p.previousSibling or p.parentNode
611    return p
612
613
614def ensure_selection_visible():
615    s = window.getSelection()
616    p = s.anchorNode
617    if not p:
618        return
619    p = closest_preceding_element(p)
620    if p?.scrollIntoView:
621        p.scrollIntoView()
622    r = s.getRangeAt(0)
623    if not r:
624        return
625    rect = r.getBoundingClientRect()
626    if not rect:
627        return
628    if rect.top < 0 or rect.top >= window.innerHeight or rect.left < 0 or rect.left >= window.innerWidth:
629        wrapper = document.createElement('span')
630        wrap_range(r, wrapper)
631        wrapper.scrollIntoView()
632        unwrap(wrapper)
633
634
635def ensure_selection_boundary_visible(use_end):
636    sel = window.getSelection()
637    try:
638        rr = sel.getRangeAt(0)
639    except:
640        rr = None
641    if rr:
642        r = rr.getBoundingClientRect()
643        if r:
644            node = sel.focusNode if use_end else sel.anchorNode
645            x = scroll_viewport.rect_inline_end(r) if use_end else scroll_viewport.rect_inline_start(r)
646            if x < 0 or x >= window.innerWidth:
647                x = scroll_viewport.viewport_to_document_inline(x, doc=node.ownerDocument)
648                if use_end:
649                    x -= line_height()
650                scroll_viewport.scroll_to_in_inline_direction(x, True)
651            y = scroll_viewport.rect_block_end(r) if use_end else scroll_viewport.rect_block_start(r)
652            if y < 0 or y >= window.innerHeight:
653                y = scroll_viewport.viewport_to_document_block(y, doc=node.ownerDocument)
654                if use_end:
655                    y -= line_height()
656                scroll_viewport.scroll_to_in_block_direction(y, True)
657
658
659def jump_to_cfi(cfi):
660    # Jump to the position indicated by the specified conformal fragment
661    # indicator.
662    cfi_scroll_to(cfi, def(x, y):
663        # block is vertical if text is horizontal
664        if scroll_viewport.horizontal_writing_mode:
665            scroll_viewport.scroll_to_in_block_direction(y)
666        # block is horizontal if text is vertical
667        else:
668            scroll_viewport.scroll_to_in_block_direction(x)
669    )
670