package daemon import ( "time" "github.com/bnema/vev/internal/ports" ) const ( copyScrollFrame = 16 * time.Millisecond copyScrollTail = 120 * time.Millisecond // copyScrollMaxStep bounds each frame drained after the animation was held // on a full output window, so the backlog that built up during the stall // resumes as motion rather than one catch-up jump. copyScrollMaxStep = 9 // copyScrollMaxHeldFrames ends an animation held on a full output window // for about one second: the link is effectively gone, and scrolling on its // own once acknowledgements finally return would surprise the user. copyScrollMaxHeldFrames = int(time.Second / copyScrollFrame) ) // copyMu owns the animation and its single cancellable overlay-input timer. // Easing distributes requested rows over frames; it never invents extra travel. type copyScrollAnimation struct { remaining int lastInput time.Time timer pendingByteTimer // heldFrames counts consecutive frames held on a full output window. heldFrames int // recovering caps the steps that drain the backlog left by a hold. recovering bool } func (rt *overlayRuntime) stopCopyScrollLocked() { rt.copyScroll.timer.stop() rt.copyScroll = copyScrollAnimation{} } func (d *Daemon) smoothCopyWheel(sess *session, ac *attachedClient, delta int) { if delta == 0 { return } rt := ac.overlays if d.clock == nil || d.currentCopyConfig().ReduceMotion { rt.copyMu.Lock() rt.stopCopyScrollLocked() rt.copyMu.Unlock() d.copyWheel(sess, ac, delta) return } rt.copyMu.Lock() if rt.copyMode == nil || rt.copyDocument == nil || rt.copyPane == nil { rt.copyMu.Unlock() return } // Selection stays exact and immediate. Reversal cancels the old tail so // the next frame always follows the user's latest direction. if rt.copyMode.Selection().Enabled || rt.copyDocument.Len() <= rt.copyDocument.Height() { rt.stopCopyScrollLocked() rt.copyMu.Unlock() d.copyWheel(sess, ac, delta) return } if rt.copyScroll.remaining != 0 && (rt.copyScroll.remaining < 0) != (delta < 0) { rt.stopCopyScrollLocked() } motion := &rt.copyScroll limit := rt.copyDocument.Len() motion.remaining += min(max(delta, -limit-motion.remaining), limit-motion.remaining) motion.lastInput = d.clock.Now() changed, exit := false, false if motion.timer.timer == nil { changed, exit = d.advanceCopyScrollLocked(sess, ac) } rt.copyMu.Unlock() if changed || exit { d.invalidateRender(sess, ac, exit, "copy-scroll-animation") } } // advanceCopyScrollLocked requires ac.overlays.copyMu, not d.mu. func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (bool, bool) { rt := ac.overlays motion := &rt.copyScroll remaining := motion.remaining if remaining == 0 { return false, false } // While the client has a full window of unacknowledged frames, moving the // viewport would only pile rows into one catch-up jump when the window // reopens. Hold the animation and keep polling at frame pace instead. changed, exit := false, false if ac.output.atCapacity() { motion.heldFrames++ motion.recovering = true if motion.heldFrames > copyScrollMaxHeldFrames { rt.stopCopyScrollLocked() return false, false } } else { motion.heldFrames = 0 magnitude := remaining if magnitude < 0 { magnitude = -magnitude } step := (magnitude + 3) / 4 if d.clock.Now().Sub(motion.lastInput) >= copyScrollTail { step = magnitude } if motion.recovering { step = min(step, copyScrollMaxStep) } if remaining < 0 { step = -step } motion.remaining -= step changed, exit = rt.moveCopyWheelLocked(step) if !changed || exit { rt.stopCopyScrollLocked() return changed, exit } if motion.remaining == 0 { // The held backlog is drained: the next scroll eases freely again. motion.recovering = false } } if motion.remaining != 0 { mode := rt.copyMode motion.timer.retain(d.clock, copyScrollFrame, func(timer ports.Timer) { // Check presentation ownership before entering the copy lock. A // callback never locks another overlay or a session under copyMu. visible := ac.currentSession() == sess && !rt.promptActive() && !rt.paletteActive() && !rt.pickerClientActive() && !rt.noticesActive() && !rt.resizeModeActive() rt.copyMu.Lock() if rt.copyScroll.timer.timer != timer { rt.copyMu.Unlock() return } rt.copyScroll.timer.timer = nil rt.copyScroll.timer.done = nil if !visible || rt.copyMode != mode || rt.copySearch != nil { rt.stopCopyScrollLocked() rt.copyMu.Unlock() return } changed, exit := d.advanceCopyScrollLocked(sess, ac) rt.copyMu.Unlock() if changed || exit { d.invalidateRender(sess, ac, exit, "copy-scroll-animation") } }) } return changed, exit }