Skip to content

Commit 60766d2

Browse files
Add ThrottledProgressAnimation to avoid too frequent animation updates
Too frequent updates to the progress animation can cause flickering and can prevent kernel from reflecting the terminal size change on `ioctl`.
1 parent 397343f commit 60766d2

File tree

2 files changed

+113
-1
lines changed

2 files changed

+113
-1
lines changed

Sources/TSCUtility/ProgressAnimation.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,58 @@ public class DynamicProgressAnimation: ProgressAnimationProtocol {
300300
animation.clear()
301301
}
302302
}
303+
304+
#if swift(>=5.7)
305+
/// A progress animation wrapper that throttles updates to a given interval.
306+
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
307+
public class ThrottledProgressAnimation: ProgressAnimationProtocol {
308+
private let animation: ProgressAnimationProtocol
309+
private let shouldUpdate: () -> Bool
310+
private var pendingUpdate: (Int, Int, String)?
311+
312+
public convenience init(_ animation: ProgressAnimationProtocol, interval: ContinuousClock.Duration) {
313+
self.init(animation, clock: ContinuousClock(), interval: interval)
314+
}
315+
316+
public convenience init<C: Clock>(_ animation: ProgressAnimationProtocol, clock: C, interval: C.Duration) {
317+
self.init(animation, now: { clock.now }, interval: interval, clock: C.self)
318+
}
319+
320+
init<C: Clock>(
321+
_ animation: ProgressAnimationProtocol,
322+
now: @escaping () -> C.Instant, interval: C.Duration, clock: C.Type = C.self
323+
) {
324+
self.animation = animation
325+
var lastUpdate: C.Instant?
326+
self.shouldUpdate = {
327+
let now = now()
328+
if let lastUpdate = lastUpdate, now < lastUpdate.advanced(by: interval) {
329+
return false
330+
}
331+
// If we're over the interval or it's the first update, should update.
332+
lastUpdate = now
333+
return true
334+
}
335+
}
336+
337+
public func update(step: Int, total: Int, text: String) {
338+
guard shouldUpdate() else {
339+
pendingUpdate = (step, total, text)
340+
return
341+
}
342+
pendingUpdate = nil
343+
animation.update(step: step, total: total, text: text)
344+
}
345+
346+
public func complete(success: Bool) {
347+
if let (step, total, text) = pendingUpdate {
348+
animation.update(step: step, total: total, text: text)
349+
}
350+
animation.complete(success: success)
351+
}
352+
353+
public func clear() {
354+
animation.clear()
355+
}
356+
}
357+
#endif

Tests/TSCUtilityTests/ProgressAnimationTests.swift

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import XCTest
12-
import TSCUtility
12+
@testable import TSCUtility
1313
import TSCLibc
1414
import TSCTestSupport
1515
import TSCBasic
@@ -62,6 +62,63 @@ final class ProgressAnimationTests: XCTestCase {
6262
XCTAssertEqual(outStream.bytes.validDescription, "")
6363
}
6464

65+
class TrackingProgressAnimation: ProgressAnimationProtocol {
66+
var steps: [Int] = []
67+
68+
func update(step: Int, total: Int, text: String) {
69+
steps.append(step)
70+
}
71+
72+
func complete(success: Bool) {}
73+
func clear() {}
74+
}
75+
76+
#if swift(>=5.7)
77+
func testThrottledPercentProgressAnimation() {
78+
do {
79+
let tracking = TrackingProgressAnimation()
80+
var now = ContinuousClock().now
81+
let animation = ThrottledProgressAnimation(
82+
tracking, now: { now }, interval: .milliseconds(100),
83+
clock: ContinuousClock.self
84+
)
85+
86+
// Update the animation 10 times with a 50ms interval.
87+
let total = 10
88+
for i in 0...total {
89+
animation.update(step: i, total: total, text: "")
90+
now += .milliseconds(50)
91+
}
92+
animation.complete(success: true)
93+
XCTAssertEqual(tracking.steps, [0, 2, 4, 6, 8, 10])
94+
}
95+
96+
do {
97+
// Check that the last animation update is sent even if
98+
// the interval has not passed.
99+
let tracking = TrackingProgressAnimation()
100+
var now = ContinuousClock().now
101+
let animation = ThrottledProgressAnimation(
102+
tracking, now: { now }, interval: .milliseconds(100),
103+
clock: ContinuousClock.self
104+
)
105+
106+
// Update the animation 10 times with a 50ms interval.
107+
let total = 10
108+
for i in 0...total-1 {
109+
animation.update(step: i, total: total, text: "")
110+
now += .milliseconds(50)
111+
}
112+
// The next update is at 1000ms, but we are at 950ms,
113+
// so "step 9" is not sent yet.
114+
XCTAssertEqual(tracking.steps, [0, 2, 4, 6, 8])
115+
// After explicit "completion", the last step is flushed out.
116+
animation.complete(success: true)
117+
XCTAssertEqual(tracking.steps, [0, 2, 4, 6, 8, 9])
118+
}
119+
}
120+
#endif
121+
65122
#if !os(Windows) // PseudoTerminal is not supported in Windows
66123
func testPercentProgressAnimationTTY() throws {
67124
let output = try readingTTY { tty in

0 commit comments

Comments
 (0)