Skip to content

Commit 0ce3f43

Browse files
authored
Merge pull request #4271 from Textualize/eta
improved eta
2 parents 7122baa + 1c7c1c2 commit 0ce3f43

File tree

13 files changed

+597
-417
lines changed

13 files changed

+597
-417
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
4949
- Changed `Tabs`
5050
- Changed `TextArea`
5151
- Changed `Tree`
52+
- Improved ETA calculation for ProgressBar https://github.com/Textualize/textual/pull/4271
5253
- BREAKING: `AppFocus` and `AppBlur` are now posted when the terminal window gains or loses focus, if the terminal supports this https://github.com/Textualize/textual/pull/4265
5354
- When the terminal window loses focus, the currently-focused widget will also lose focus.
5455
- When the terminal window regains focus, the previously-focused widget will regain focus.

docs/examples/widgets/progress_bar_isolated_.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from textual.app import App, ComposeResult
2+
from textual.clock import MockClock
23
from textual.containers import Center, Middle
34
from textual.timer import Timer
45
from textual.widgets import Footer, ProgressBar
@@ -11,13 +12,14 @@ class IndeterminateProgressBar(App[None]):
1112
"""Timer to simulate progress happening."""
1213

1314
def compose(self) -> ComposeResult:
15+
self.clock = MockClock()
1416
with Center():
1517
with Middle():
16-
yield ProgressBar()
18+
yield ProgressBar(clock=self.clock)
1719
yield Footer()
1820

1921
def on_mount(self) -> None:
20-
"""Set up a timer to simulate progess happening."""
22+
"""Set up a timer to simulate progress happening."""
2123
self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)
2224

2325
def make_progress(self) -> None:
@@ -31,14 +33,18 @@ def action_start(self) -> None:
3133

3234
def key_f(self) -> None:
3335
# Freeze time for the indeterminate progress bar.
34-
self.query_one(ProgressBar).query_one("#bar")._get_elapsed_time = lambda: 5
36+
self.clock.set_time(5)
37+
self.refresh()
3538

3639
def key_t(self) -> None:
3740
# Freeze time to show always the same ETA.
38-
self.query_one(ProgressBar).query_one("#eta")._get_elapsed_time = lambda: 3.9
39-
self.query_one(ProgressBar).update(total=100, progress=39)
41+
self.clock.set_time(0)
42+
self.query_one(ProgressBar).update(total=100, progress=0)
43+
self.clock.set_time(3.9)
44+
self.query_one(ProgressBar).update(progress=39)
4045

4146
def key_u(self) -> None:
47+
self.refresh()
4248
self.query_one(ProgressBar).update(total=100, progress=100)
4349

4450

docs/examples/widgets/progress_bar_styled.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def compose(self) -> ComposeResult:
1818
yield Footer()
1919

2020
def on_mount(self) -> None:
21-
"""Set up a timer to simulate progess happening."""
21+
"""Set up a timer to simulate progress happening."""
2222
self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)
2323

2424
def make_progress(self) -> None:

docs/examples/widgets/progress_bar_styled_.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from textual.app import App, ComposeResult
2+
from textual.clock import MockClock
23
from textual.containers import Center, Middle
34
from textual.timer import Timer
45
from textual.widgets import Footer, ProgressBar
@@ -12,13 +13,14 @@ class StyledProgressBar(App[None]):
1213
"""Timer to simulate progress happening."""
1314

1415
def compose(self) -> ComposeResult:
16+
self.clock = MockClock()
1517
with Center():
1618
with Middle():
17-
yield ProgressBar()
19+
yield ProgressBar(clock=self.clock)
1820
yield Footer()
1921

2022
def on_mount(self) -> None:
21-
"""Set up a timer to simulate progess happening."""
23+
"""Set up a timer to simulate progress happening."""
2224
self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)
2325

2426
def make_progress(self) -> None:
@@ -29,15 +31,18 @@ def action_start(self) -> None:
2931
"""Start the progress tracking."""
3032
self.query_one(ProgressBar).update(total=100)
3133
self.progress_timer.resume()
34+
self.query_one(ProgressBar).refresh()
3235

3336
def key_f(self) -> None:
3437
# Freeze time for the indeterminate progress bar.
35-
self.query_one(ProgressBar).query_one("#bar")._get_elapsed_time = lambda: 5
38+
self.clock.set_time(5.0)
3639

3740
def key_t(self) -> None:
3841
# Freeze time to show always the same ETA.
39-
self.query_one(ProgressBar).query_one("#eta")._get_elapsed_time = lambda: 3.9
40-
self.query_one(ProgressBar).update(total=100, progress=39)
42+
self.clock.set_time(0)
43+
self.query_one(ProgressBar).update(total=100, progress=0)
44+
self.clock.set_time(3.9)
45+
self.query_one(ProgressBar).update(progress=39)
4146

4247
def key_u(self) -> None:
4348
self.query_one(ProgressBar).update(total=100, progress=100)

src/textual/clock.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
from time import monotonic
4+
from typing import Callable
5+
6+
import rich.repr
7+
8+
9+
@rich.repr.auto(angular=True)
10+
class Clock:
11+
"""An object to get relative time.
12+
13+
The `time` attribute of clock will return the time in seconds since the
14+
Clock was created or reset.
15+
16+
"""
17+
18+
def __init__(self, *, get_time: Callable[[], float] = monotonic) -> None:
19+
"""Create a clock.
20+
21+
Args:
22+
get_time: A callable to get time in seconds.
23+
start: Start the clock (time is 0 unless clock has been started).
24+
"""
25+
self._get_time = get_time
26+
self._start_time = self._get_time()
27+
28+
def __rich_repr__(self) -> rich.repr.Result:
29+
yield self.time
30+
31+
def clone(self) -> Clock:
32+
"""Clone the Clock with an independent time."""
33+
return Clock(get_time=self._get_time)
34+
35+
def reset(self) -> None:
36+
"""Reset the clock."""
37+
self._start_time = self._get_time()
38+
39+
@property
40+
def time(self) -> float:
41+
"""Time since creation or reset."""
42+
return self._get_time() - self._start_time
43+
44+
45+
class MockClock(Clock):
46+
"""A mock clock object where the time may be explicitly set."""
47+
48+
def __init__(self, time: float = 0.0) -> None:
49+
"""Construct a mock clock."""
50+
self._time = time
51+
super().__init__(get_time=lambda: self._time)
52+
53+
def clone(self) -> MockClock:
54+
"""Clone the mocked clock (clone will return the same time as original)."""
55+
clock = MockClock(self._time)
56+
clock._get_time = self._get_time
57+
clock._time = self._time
58+
return clock
59+
60+
def reset(self) -> None:
61+
"""A null-op because it doesn't make sense to reset a mocked clock."""
62+
63+
def set_time(self, time: float) -> None:
64+
"""Set the time for the clock.
65+
66+
Args:
67+
time: Time to set.
68+
"""
69+
self._time = time
70+
71+
@property
72+
def time(self) -> float:
73+
"""Time since creation or reset."""
74+
return self._get_time()

src/textual/dom.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -474,13 +474,11 @@ def __init_subclass__(
474474
cls._merged_bindings = cls._merge_bindings()
475475
cls._css_type_names = frozenset(css_type_names)
476476
cls._computes = frozenset(
477-
dict.fromkeys(
478-
[
479-
name.lstrip("_")[8:]
480-
for name in dir(cls)
481-
if name.startswith(("_compute_", "compute_"))
482-
]
483-
).keys()
477+
[
478+
name.lstrip("_")[8:]
479+
for name in dir(cls)
480+
if name.startswith(("_compute_", "compute_"))
481+
]
484482
)
485483

486484
def get_component_styles(self, name: str) -> RenderStyles:

src/textual/eta.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
from __future__ import annotations
2+
3+
import bisect
4+
from math import ceil
5+
from time import monotonic
6+
7+
import rich.repr
8+
9+
10+
@rich.repr.auto(angular=True)
11+
class ETA:
12+
"""Calculate speed and estimate time to arrival."""
13+
14+
def __init__(
15+
self, estimation_period: float = 60, extrapolate_period: float = 30
16+
) -> None:
17+
"""Create an ETA.
18+
19+
Args:
20+
estimation_period: Period in seconds, used to calculate speed.
21+
extrapolate_period: Maximum number of seconds used to estimate progress after last sample.
22+
"""
23+
self.estimation_period = estimation_period
24+
self.max_extrapolate = extrapolate_period
25+
self._samples: list[tuple[float, float]] = [(0.0, 0.0)]
26+
self._add_count = 0
27+
28+
def __rich_repr__(self) -> rich.repr.Result:
29+
yield "speed", self.speed
30+
yield "eta", self.get_eta(monotonic())
31+
32+
@property
33+
def first_sample(self) -> tuple[float, float]:
34+
"""First sample."""
35+
assert self._samples, "Assumes samples not empty"
36+
return self._samples[0]
37+
38+
@property
39+
def last_sample(self) -> tuple[float, float]:
40+
"""Last sample."""
41+
assert self._samples, "Assumes samples not empty"
42+
return self._samples[-1]
43+
44+
def reset(self) -> None:
45+
"""Start ETA calculations from current time."""
46+
del self._samples[:]
47+
48+
def add_sample(self, time: float, progress: float) -> None:
49+
"""Add a new sample.
50+
51+
Args:
52+
time: Time when sample occurred.
53+
progress: Progress ratio (0 is start, 1 is complete).
54+
"""
55+
if self._samples and self.last_sample[1] > progress:
56+
# If progress goes backwards, we need to reset calculations
57+
self.reset()
58+
self._samples.append((time, progress))
59+
self._add_count += 1
60+
if self._add_count % 100 == 0:
61+
# Prune periodically so we don't accumulate vast amounts of samples
62+
self._prune()
63+
64+
def _prune(self) -> None:
65+
"""Prune old samples."""
66+
if len(self._samples) <= 10:
67+
# Keep at least 10 samples
68+
return
69+
prune_time = self._samples[-1][0] - self.estimation_period
70+
index = bisect.bisect_left(self._samples, (prune_time, 0))
71+
del self._samples[:index]
72+
73+
def _get_progress_at(self, time: float) -> tuple[float, float]:
74+
"""Get the progress at a specific time."""
75+
76+
index = bisect.bisect_left(self._samples, (time, 0))
77+
if index >= len(self._samples):
78+
return self.last_sample
79+
if index == 0:
80+
return self.first_sample
81+
# Linearly interpolate progress between two samples
82+
time1, progress1 = self._samples[index - 1]
83+
time2, progress2 = self._samples[index]
84+
factor = (time - time1) / (time2 - time1)
85+
intermediate_progress = progress1 + (progress2 - progress1) * factor
86+
return time, intermediate_progress
87+
88+
@property
89+
def speed(self) -> float | None:
90+
"""The current speed, or `None` if it couldn't be calculated."""
91+
92+
if len(self._samples) < 2:
93+
# Need at least 2 samples to calculate speed
94+
return None
95+
96+
recent_sample_time, progress2 = self.last_sample
97+
progress_start_time, progress1 = self._get_progress_at(
98+
recent_sample_time - self.estimation_period
99+
)
100+
time_delta = recent_sample_time - progress_start_time
101+
distance = progress2 - progress1
102+
speed = distance / time_delta if time_delta else 0
103+
return speed
104+
105+
def get_eta(self, time: float) -> int | None:
106+
"""Estimated seconds until completion, or `None` if no estimate can be made.
107+
108+
Args:
109+
time: Current time.
110+
"""
111+
speed = self.speed
112+
if not speed:
113+
# Not enough samples to guess
114+
return None
115+
recent_time, recent_progress = self.last_sample
116+
remaining = 1.0 - recent_progress
117+
if remaining <= 0:
118+
# Complete
119+
return 0
120+
# The bar is not complete, so we will extrapolate progress
121+
# This will give us a countdown, even with no samples
122+
time_since_sample = min(self.max_extrapolate, time - recent_time)
123+
extrapolate_progress = speed * time_since_sample
124+
# We don't want to extrapolate all the way to 0, as that would erroneously suggest it is finished
125+
eta = max(1.0, (remaining - extrapolate_progress) / speed)
126+
return ceil(eta)

0 commit comments

Comments
 (0)