Skip to content

Commit cdc7534

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 cdc7534

File tree

2 files changed

+109
-1
lines changed

2 files changed

+109
-1
lines changed

Sources/TSCUtility/ProgressAnimation.swift

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

Tests/TSCUtilityTests/ProgressAnimationTests.swift

Lines changed: 56 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,61 @@ 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+
func testThrottledPercentProgressAnimation() {
77+
do {
78+
let tracking = TrackingProgressAnimation()
79+
var now = ContinuousClock().now
80+
let animation = ThrottledProgressAnimation(
81+
tracking, now: { now }, interval: .milliseconds(100),
82+
clock: ContinuousClock.self
83+
)
84+
85+
// Update the animation 10 times with a 50ms interval.
86+
let total = 10
87+
for i in 0...total {
88+
animation.update(step: i, total: total, text: "")
89+
now += .milliseconds(50)
90+
}
91+
animation.complete(success: true)
92+
XCTAssertEqual(tracking.steps, [0, 2, 4, 6, 8, 10])
93+
}
94+
95+
do {
96+
// Check that the last animation update is sent even if
97+
// the interval has not passed.
98+
let tracking = TrackingProgressAnimation()
99+
var now = ContinuousClock().now
100+
let animation = ThrottledProgressAnimation(
101+
tracking, now: { now }, interval: .milliseconds(100),
102+
clock: ContinuousClock.self
103+
)
104+
105+
// Update the animation 10 times with a 50ms interval.
106+
let total = 10
107+
for i in 0...total-1 {
108+
animation.update(step: i, total: total, text: "")
109+
now += .milliseconds(50)
110+
}
111+
// The next update is at 1000ms, but we are at 950ms,
112+
// so "step 9" is not sent yet.
113+
XCTAssertEqual(tracking.steps, [0, 2, 4, 6, 8])
114+
// After explicit "completion", the last step is flushed out.
115+
animation.complete(success: true)
116+
XCTAssertEqual(tracking.steps, [0, 2, 4, 6, 8, 9])
117+
}
118+
}
119+
65120
#if !os(Windows) // PseudoTerminal is not supported in Windows
66121
func testPercentProgressAnimationTTY() throws {
67122
let output = try readingTTY { tty in

0 commit comments

Comments
 (0)