1package daemon
2
3import (
4 "time"
5
6 "github.com/bnema/vev/internal/ports"
7)
8
9const (
10 copyScrollFrame = 16 * time.Millisecond
11 copyScrollTail = 120 * time.Millisecond
12 // copyScrollMaxStep bounds each frame drained after the animation was held
13 // on a full output window, so the backlog that built up during the stall
14 // resumes as motion rather than one catch-up jump.
15 copyScrollMaxStep = 9
16 // copyScrollMaxHeldFrames ends an animation held on a full output window
17 // for about one second: the link is effectively gone, and scrolling on its
18 // own once acknowledgements finally return would surprise the user.
19 copyScrollMaxHeldFrames = int(time.Second / copyScrollFrame)
20)
21
22// copyMu owns the animation and its single cancellable overlay-input timer.
23// Easing distributes requested rows over frames; it never invents extra travel.
24type copyScrollAnimation struct {
25 remaining int
26 lastInput time.Time
27 timer pendingByteTimer
28 // heldFrames counts consecutive frames held on a full output window.
29 heldFrames int
30 // recovering caps the steps that drain the backlog left by a hold.
31 recovering bool
32}
33
34func (rt *overlayRuntime) stopCopyScrollLocked() {
35 rt.copyScroll.timer.stop()
36 rt.copyScroll = copyScrollAnimation{}
37}
38
39func (d *Daemon) smoothCopyWheel(sess *session, ac *attachedClient, delta int) {
40 if delta == 0 {
41 return
42 }
43 rt := ac.overlays
44 if d.clock == nil || d.currentCopyConfig().ReduceMotion {
45 rt.copyMu.Lock()
46 rt.stopCopyScrollLocked()
47 rt.copyMu.Unlock()
48 d.copyWheel(sess, ac, delta)
49 return
50 }
51 rt.copyMu.Lock()
52 if rt.copyMode == nil || rt.copyDocument == nil || rt.copyPane == nil {
53 rt.copyMu.Unlock()
54 return
55 }
56 // Selection stays exact and immediate. Reversal cancels the old tail so
57 // the next frame always follows the user's latest direction.
58 if rt.copyMode.Selection().Enabled || rt.copyDocument.Len() <= rt.copyDocument.Height() {
59 rt.stopCopyScrollLocked()
60 rt.copyMu.Unlock()
61 d.copyWheel(sess, ac, delta)
62 return
63 }
64 if rt.copyScroll.remaining != 0 && (rt.copyScroll.remaining < 0) != (delta < 0) {
65 rt.stopCopyScrollLocked()
66 }
67 motion := &rt.copyScroll
68 limit := rt.copyDocument.Len()
69 motion.remaining += min(max(delta, -limit-motion.remaining), limit-motion.remaining)
70 motion.lastInput = d.clock.Now()
71 changed, exit := false, false
72 if motion.timer.timer == nil {
73 changed, exit = d.advanceCopyScrollLocked(sess, ac)
74 }
75 rt.copyMu.Unlock()
76 if changed || exit {
77 d.invalidateRender(sess, ac, exit, "copy-scroll-animation")
78 }
79}
80
81// advanceCopyScrollLocked requires ac.overlays.copyMu, not d.mu.
82func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (bool, bool) {
83 rt := ac.overlays
84 motion := &rt.copyScroll
85 remaining := motion.remaining
86 if remaining == 0 {
87 return false, false
88 }
89 // While the client has a full window of unacknowledged frames, moving the
90 // viewport would only pile rows into one catch-up jump when the window
91 // reopens. Hold the animation and keep polling at frame pace instead.
92 changed, exit := false, false
93 if ac.output.atCapacity() {
94 motion.heldFrames++
95 motion.recovering = true
96 if motion.heldFrames > copyScrollMaxHeldFrames {
97 rt.stopCopyScrollLocked()
98 return false, false
99 }
100 } else {
101 motion.heldFrames = 0
102 magnitude := remaining
103 if magnitude < 0 {
104 magnitude = -magnitude
105 }
106 step := (magnitude + 3) / 4
107 if d.clock.Now().Sub(motion.lastInput) >= copyScrollTail {
108 step = magnitude
109 }
110 if motion.recovering {
111 step = min(step, copyScrollMaxStep)
112 }
113 if remaining < 0 {
114 step = -step
115 }
116 motion.remaining -= step
117 changed, exit = rt.moveCopyWheelLocked(step)
118 if !changed || exit {
119 rt.stopCopyScrollLocked()
120 return changed, exit
121 }
122 if motion.remaining == 0 {
123 // The held backlog is drained: the next scroll eases freely again.
124 motion.recovering = false
125 }
126 }
127 if motion.remaining != 0 {
128 mode := rt.copyMode
129 motion.timer.retain(d.clock, copyScrollFrame, func(timer ports.Timer) {
130 // Check presentation ownership before entering the copy lock. A
131 // callback never locks another overlay or a session under copyMu.
132 visible := ac.currentSession() == sess && !rt.promptActive() && !rt.paletteActive() &&
133 !rt.pickerClientActive() && !rt.noticesActive() && !rt.resizeModeActive()
134 rt.copyMu.Lock()
135 if rt.copyScroll.timer.timer != timer {
136 rt.copyMu.Unlock()
137 return
138 }
139 rt.copyScroll.timer.timer = nil
140 rt.copyScroll.timer.done = nil
141 if !visible || rt.copyMode != mode || rt.copySearch != nil {
142 rt.stopCopyScrollLocked()
143 rt.copyMu.Unlock()
144 return
145 }
146 changed, exit := d.advanceCopyScrollLocked(sess, ac)
147 rt.copyMu.Unlock()
148 if changed || exit {
149 d.invalidateRender(sess, ac, exit, "copy-scroll-animation")
150 }
151 })
152 }
153 return changed, exit
154}