diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f1ea7c205..14aaabb386 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Changed `Tabs`
- Changed `TextArea`
- Changed `Tree`
+- Improved ETA calculation for ProgressBar https://github.com/Textualize/textual/pull/4271
- 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
- When the terminal window loses focus, the currently-focused widget will also lose focus.
- When the terminal window regains focus, the previously-focused widget will regain focus.
diff --git a/docs/examples/widgets/progress_bar_isolated_.py b/docs/examples/widgets/progress_bar_isolated_.py
index 79907562cf..c1df28d5db 100644
--- a/docs/examples/widgets/progress_bar_isolated_.py
+++ b/docs/examples/widgets/progress_bar_isolated_.py
@@ -1,4 +1,5 @@
from textual.app import App, ComposeResult
+from textual.clock import MockClock
from textual.containers import Center, Middle
from textual.timer import Timer
from textual.widgets import Footer, ProgressBar
@@ -11,13 +12,14 @@ class IndeterminateProgressBar(App[None]):
"""Timer to simulate progress happening."""
def compose(self) -> ComposeResult:
+ self.clock = MockClock()
with Center():
with Middle():
- yield ProgressBar()
+ yield ProgressBar(clock=self.clock)
yield Footer()
def on_mount(self) -> None:
- """Set up a timer to simulate progess happening."""
+ """Set up a timer to simulate progress happening."""
self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)
def make_progress(self) -> None:
@@ -31,14 +33,18 @@ def action_start(self) -> None:
def key_f(self) -> None:
# Freeze time for the indeterminate progress bar.
- self.query_one(ProgressBar).query_one("#bar")._get_elapsed_time = lambda: 5
+ self.clock.set_time(5)
+ self.refresh()
def key_t(self) -> None:
# Freeze time to show always the same ETA.
- self.query_one(ProgressBar).query_one("#eta")._get_elapsed_time = lambda: 3.9
- self.query_one(ProgressBar).update(total=100, progress=39)
+ self.clock.set_time(0)
+ self.query_one(ProgressBar).update(total=100, progress=0)
+ self.clock.set_time(3.9)
+ self.query_one(ProgressBar).update(progress=39)
def key_u(self) -> None:
+ self.refresh()
self.query_one(ProgressBar).update(total=100, progress=100)
diff --git a/docs/examples/widgets/progress_bar_styled.py b/docs/examples/widgets/progress_bar_styled.py
index 96c5005bab..f5b95ad055 100644
--- a/docs/examples/widgets/progress_bar_styled.py
+++ b/docs/examples/widgets/progress_bar_styled.py
@@ -18,7 +18,7 @@ def compose(self) -> ComposeResult:
yield Footer()
def on_mount(self) -> None:
- """Set up a timer to simulate progess happening."""
+ """Set up a timer to simulate progress happening."""
self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)
def make_progress(self) -> None:
diff --git a/docs/examples/widgets/progress_bar_styled_.py b/docs/examples/widgets/progress_bar_styled_.py
index 8428f359a1..b195327567 100644
--- a/docs/examples/widgets/progress_bar_styled_.py
+++ b/docs/examples/widgets/progress_bar_styled_.py
@@ -1,4 +1,5 @@
from textual.app import App, ComposeResult
+from textual.clock import MockClock
from textual.containers import Center, Middle
from textual.timer import Timer
from textual.widgets import Footer, ProgressBar
@@ -12,13 +13,14 @@ class StyledProgressBar(App[None]):
"""Timer to simulate progress happening."""
def compose(self) -> ComposeResult:
+ self.clock = MockClock()
with Center():
with Middle():
- yield ProgressBar()
+ yield ProgressBar(clock=self.clock)
yield Footer()
def on_mount(self) -> None:
- """Set up a timer to simulate progess happening."""
+ """Set up a timer to simulate progress happening."""
self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)
def make_progress(self) -> None:
@@ -29,15 +31,18 @@ def action_start(self) -> None:
"""Start the progress tracking."""
self.query_one(ProgressBar).update(total=100)
self.progress_timer.resume()
+ self.query_one(ProgressBar).refresh()
def key_f(self) -> None:
# Freeze time for the indeterminate progress bar.
- self.query_one(ProgressBar).query_one("#bar")._get_elapsed_time = lambda: 5
+ self.clock.set_time(5.0)
def key_t(self) -> None:
# Freeze time to show always the same ETA.
- self.query_one(ProgressBar).query_one("#eta")._get_elapsed_time = lambda: 3.9
- self.query_one(ProgressBar).update(total=100, progress=39)
+ self.clock.set_time(0)
+ self.query_one(ProgressBar).update(total=100, progress=0)
+ self.clock.set_time(3.9)
+ self.query_one(ProgressBar).update(progress=39)
def key_u(self) -> None:
self.query_one(ProgressBar).update(total=100, progress=100)
diff --git a/src/textual/clock.py b/src/textual/clock.py
new file mode 100644
index 0000000000..6df651bcc9
--- /dev/null
+++ b/src/textual/clock.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from time import monotonic
+from typing import Callable
+
+import rich.repr
+
+
+@rich.repr.auto(angular=True)
+class Clock:
+ """An object to get relative time.
+
+ The `time` attribute of clock will return the time in seconds since the
+ Clock was created or reset.
+
+ """
+
+ def __init__(self, *, get_time: Callable[[], float] = monotonic) -> None:
+ """Create a clock.
+
+ Args:
+ get_time: A callable to get time in seconds.
+ start: Start the clock (time is 0 unless clock has been started).
+ """
+ self._get_time = get_time
+ self._start_time = self._get_time()
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield self.time
+
+ def clone(self) -> Clock:
+ """Clone the Clock with an independent time."""
+ return Clock(get_time=self._get_time)
+
+ def reset(self) -> None:
+ """Reset the clock."""
+ self._start_time = self._get_time()
+
+ @property
+ def time(self) -> float:
+ """Time since creation or reset."""
+ return self._get_time() - self._start_time
+
+
+class MockClock(Clock):
+ """A mock clock object where the time may be explicitly set."""
+
+ def __init__(self, time: float = 0.0) -> None:
+ """Construct a mock clock."""
+ self._time = time
+ super().__init__(get_time=lambda: self._time)
+
+ def clone(self) -> MockClock:
+ """Clone the mocked clock (clone will return the same time as original)."""
+ clock = MockClock(self._time)
+ clock._get_time = self._get_time
+ clock._time = self._time
+ return clock
+
+ def reset(self) -> None:
+ """A null-op because it doesn't make sense to reset a mocked clock."""
+
+ def set_time(self, time: float) -> None:
+ """Set the time for the clock.
+
+ Args:
+ time: Time to set.
+ """
+ self._time = time
+
+ @property
+ def time(self) -> float:
+ """Time since creation or reset."""
+ return self._get_time()
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 94980c87b0..2b47607db4 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -474,13 +474,11 @@ def __init_subclass__(
cls._merged_bindings = cls._merge_bindings()
cls._css_type_names = frozenset(css_type_names)
cls._computes = frozenset(
- dict.fromkeys(
- [
- name.lstrip("_")[8:]
- for name in dir(cls)
- if name.startswith(("_compute_", "compute_"))
- ]
- ).keys()
+ [
+ name.lstrip("_")[8:]
+ for name in dir(cls)
+ if name.startswith(("_compute_", "compute_"))
+ ]
)
def get_component_styles(self, name: str) -> RenderStyles:
diff --git a/src/textual/eta.py b/src/textual/eta.py
new file mode 100644
index 0000000000..f6036bddac
--- /dev/null
+++ b/src/textual/eta.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+import bisect
+from math import ceil
+from time import monotonic
+
+import rich.repr
+
+
+@rich.repr.auto(angular=True)
+class ETA:
+ """Calculate speed and estimate time to arrival."""
+
+ def __init__(
+ self, estimation_period: float = 60, extrapolate_period: float = 30
+ ) -> None:
+ """Create an ETA.
+
+ Args:
+ estimation_period: Period in seconds, used to calculate speed.
+ extrapolate_period: Maximum number of seconds used to estimate progress after last sample.
+ """
+ self.estimation_period = estimation_period
+ self.max_extrapolate = extrapolate_period
+ self._samples: list[tuple[float, float]] = [(0.0, 0.0)]
+ self._add_count = 0
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "speed", self.speed
+ yield "eta", self.get_eta(monotonic())
+
+ @property
+ def first_sample(self) -> tuple[float, float]:
+ """First sample."""
+ assert self._samples, "Assumes samples not empty"
+ return self._samples[0]
+
+ @property
+ def last_sample(self) -> tuple[float, float]:
+ """Last sample."""
+ assert self._samples, "Assumes samples not empty"
+ return self._samples[-1]
+
+ def reset(self) -> None:
+ """Start ETA calculations from current time."""
+ del self._samples[:]
+
+ def add_sample(self, time: float, progress: float) -> None:
+ """Add a new sample.
+
+ Args:
+ time: Time when sample occurred.
+ progress: Progress ratio (0 is start, 1 is complete).
+ """
+ if self._samples and self.last_sample[1] > progress:
+ # If progress goes backwards, we need to reset calculations
+ self.reset()
+ self._samples.append((time, progress))
+ self._add_count += 1
+ if self._add_count % 100 == 0:
+ # Prune periodically so we don't accumulate vast amounts of samples
+ self._prune()
+
+ def _prune(self) -> None:
+ """Prune old samples."""
+ if len(self._samples) <= 10:
+ # Keep at least 10 samples
+ return
+ prune_time = self._samples[-1][0] - self.estimation_period
+ index = bisect.bisect_left(self._samples, (prune_time, 0))
+ del self._samples[:index]
+
+ def _get_progress_at(self, time: float) -> tuple[float, float]:
+ """Get the progress at a specific time."""
+
+ index = bisect.bisect_left(self._samples, (time, 0))
+ if index >= len(self._samples):
+ return self.last_sample
+ if index == 0:
+ return self.first_sample
+ # Linearly interpolate progress between two samples
+ time1, progress1 = self._samples[index - 1]
+ time2, progress2 = self._samples[index]
+ factor = (time - time1) / (time2 - time1)
+ intermediate_progress = progress1 + (progress2 - progress1) * factor
+ return time, intermediate_progress
+
+ @property
+ def speed(self) -> float | None:
+ """The current speed, or `None` if it couldn't be calculated."""
+
+ if len(self._samples) < 2:
+ # Need at least 2 samples to calculate speed
+ return None
+
+ recent_sample_time, progress2 = self.last_sample
+ progress_start_time, progress1 = self._get_progress_at(
+ recent_sample_time - self.estimation_period
+ )
+ time_delta = recent_sample_time - progress_start_time
+ distance = progress2 - progress1
+ speed = distance / time_delta if time_delta else 0
+ return speed
+
+ def get_eta(self, time: float) -> int | None:
+ """Estimated seconds until completion, or `None` if no estimate can be made.
+
+ Args:
+ time: Current time.
+ """
+ speed = self.speed
+ if not speed:
+ # Not enough samples to guess
+ return None
+ recent_time, recent_progress = self.last_sample
+ remaining = 1.0 - recent_progress
+ if remaining <= 0:
+ # Complete
+ return 0
+ # The bar is not complete, so we will extrapolate progress
+ # This will give us a countdown, even with no samples
+ time_since_sample = min(self.max_extrapolate, time - recent_time)
+ extrapolate_progress = speed * time_since_sample
+ # We don't want to extrapolate all the way to 0, as that would erroneously suggest it is finished
+ eta = max(1.0, (remaining - extrapolate_progress) / speed)
+ return ceil(eta)
diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py
index fea9c2fba2..b53a31c7c3 100644
--- a/src/textual/widgets/_progress_bar.py
+++ b/src/textual/widgets/_progress_bar.py
@@ -2,18 +2,17 @@
from __future__ import annotations
-from math import ceil
-from time import monotonic
-from typing import Callable, Optional
+from typing import Optional
from rich.style import Style
from .._types import UnusedParameter
from ..app import ComposeResult, RenderResult
+from ..clock import Clock
+from ..eta import ETA
from ..geometry import clamp
from ..reactive import reactive
from ..renderables.bar import Bar as BarRenderable
-from ..timer import Timer
from ..widget import Widget
from ..widgets import Label
@@ -58,10 +57,8 @@ class Bar(Widget, can_focus=False):
}
"""
- _percentage: reactive[float | None] = reactive[Optional[float]](None)
+ percentage: reactive[float | None] = reactive[Optional[float]](None)
"""The percentage of progress that has been completed."""
- _start_time: float | None
- """The time when the widget started tracking progress."""
def __init__(
self,
@@ -69,13 +66,22 @@ def __init__(
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
+ clock: Clock | None = None,
):
"""Create a bar for a [`ProgressBar`][textual.widgets.ProgressBar]."""
+ self._clock = (clock or Clock()).clone()
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
- self._start_time = None
- self._percentage = None
- def watch__percentage(self, percentage: float | None) -> None:
+ def _validate_percentage(self, percentage: float | None) -> float | None:
+ """Avoid updating the bar, if the percentage increase is too small to render."""
+ width = self.size.width * 2
+ return (
+ None
+ if percentage is None
+ else (int(percentage * width) / width if width else percentage)
+ )
+
+ def watch_percentage(self, percentage: float | None) -> None:
"""Manage the timer that enables the indeterminate bar animation."""
if percentage is not None:
self.auto_refresh = None
@@ -84,16 +90,16 @@ def watch__percentage(self, percentage: float | None) -> None:
def render(self) -> RenderResult:
"""Render the bar with the correct portion filled."""
- if self._percentage is None:
+ if self.percentage is None:
return self.render_indeterminate()
else:
bar_style = (
self.get_component_rich_style("bar--bar")
- if self._percentage < 1
+ if self.percentage < 1
else self.get_component_rich_style("bar--complete")
)
return BarRenderable(
- highlight_range=(0, self.size.width * self._percentage),
+ highlight_range=(0, self.size.width * self.percentage),
highlight_style=Style.from_color(bar_style.color),
background_style=Style.from_color(bar_style.bgcolor),
)
@@ -104,14 +110,15 @@ def render_indeterminate(self) -> RenderResult:
highlighted_bar_width = 0.25 * width
# Width used to enable the visual effect of the bar going into the corners.
total_imaginary_width = width + highlighted_bar_width
-
+ start: float
+ end: float
if self.app.animation_level == "none":
start = 0
end = width
else:
speed = 30 # Cells per second.
# Compute the position of the bar.
- start = (speed * self._get_elapsed_time()) % (2 * total_imaginary_width)
+ start = (speed * self._clock.time) % (2 * total_imaginary_width)
if start > total_imaginary_width:
# If the bar is to the right of its width, wrap it back from right to left.
start = 2 * total_imaginary_width - start # = (tiw - (start - tiw))
@@ -125,21 +132,6 @@ def render_indeterminate(self) -> RenderResult:
background_style=Style.from_color(bar_style.bgcolor),
)
- def _get_elapsed_time(self) -> float:
- """Get time for the indeterminate progress animation.
-
- This method ensures that the progress bar animation always starts at the
- beginning and it also makes it easier to test the bar if we monkey patch
- this method.
-
- Returns:
- The time elapsed since the bar started being animated.
- """
- if self._start_time is None:
- self._start_time = monotonic()
- return 0
- return monotonic() - self._start_time
-
class PercentageStatus(Label):
"""A label to display the percentage status of the progress bar."""
@@ -151,32 +143,14 @@ class PercentageStatus(Label):
}
"""
- _label_text: reactive[str] = reactive("", repaint=False)
- """This is used as an auxiliary reactive to only refresh the label when needed."""
- _percentage: reactive[float | None] = reactive[Optional[float]](None)
+ percentage: reactive[int | None] = reactive[Optional[int]](None)
"""The percentage of progress that has been completed."""
- def __init__(
- self,
- name: str | None = None,
- id: str | None = None,
- classes: str | None = None,
- disabled: bool = False,
- ):
- super().__init__(name=name, id=id, classes=classes, disabled=disabled)
- self._percentage = None
- self._label_text = "--%"
-
- def watch__percentage(self, percentage: float | None) -> None:
- """Manage the text that shows the percentage of progress."""
- if percentage is None:
- self._label_text = "--%"
- else:
- self._label_text = f"{int(100 * percentage)}%"
+ def _validate_percentage(self, percentage: float | None) -> int | None:
+ return None if percentage is None else round(percentage * 100)
- def watch__label_text(self, label_text: str) -> None:
- """If the label text changed, update the renderable (which also refreshes)."""
- self.update(label_text)
+ def render(self) -> RenderResult:
+ return "--%" if self.percentage is None else f"{self.percentage}%"
class ETAStatus(Label):
@@ -188,79 +162,23 @@ class ETAStatus(Label):
content-align-horizontal: right;
}
"""
+ eta: reactive[float | None] = reactive[Optional[float]](None)
+ """Estimated number of seconds till completion, or `None` if no estimate is available."""
- _label_text: reactive[str] = reactive("", repaint=False)
- """This is used as an auxiliary reactive to only refresh the label when needed."""
- _percentage: reactive[float | None] = reactive[Optional[float]](None)
- """The percentage of progress that has been completed."""
- _refresh_timer: Timer | None
- """Timer to update ETA status even when progress stalls."""
- _start_time: float | None
- """The time when the widget started tracking progress."""
-
- def __init__(
- self,
- name: str | None = None,
- id: str | None = None,
- classes: str | None = None,
- disabled: bool = False,
- ):
- super().__init__(name=name, id=id, classes=classes, disabled=disabled)
- self._percentage = None
- self._label_text = "--:--:--"
- self._start_time = None
- self._refresh_timer = None
-
- def on_mount(self) -> None:
- """Periodically refresh the countdown so that the ETA is always up to date."""
- self._refresh_timer = self.set_interval(1 / 2, self.update_eta, pause=True)
-
- def watch__percentage(self, percentage: float | None) -> None:
- if percentage is None:
- self._label_text = "--:--:--"
- else:
- if self._refresh_timer is not None:
- self._refresh_timer.reset()
- self.update_eta()
-
- def update_eta(self) -> None:
- """Update the ETA display."""
- percentage = self._percentage
- delta = self._get_elapsed_time()
- # We display --:--:-- if we haven't started, if we are done,
- # or if we don't know when we started keeping track of time.
- if not percentage or percentage >= 1 or not delta:
- self._label_text = "--:--:--"
- # If we are done, we can delete the timer that periodically refreshes
- # the countdown display.
- if percentage is not None and percentage >= 1:
- self.auto_refresh = None
- # Render a countdown timer with hh:mm:ss, unless it's a LONG time.
+ def render(self) -> RenderResult:
+ """Render the ETA display."""
+ eta = self.eta
+ if eta is None:
+ return "--:--:--"
else:
- left = ceil((delta / percentage) * (1 - percentage))
- minutes, seconds = divmod(left, 60)
+ minutes, seconds = divmod(round(eta), 60)
hours, minutes = divmod(minutes, 60)
if hours > 999999:
- self._label_text = "+999999h"
+ return "+999999h"
elif hours > 99:
- self._label_text = f"{hours}h"
+ return f"{hours}h"
else:
- self._label_text = f"{hours:02}:{minutes:02}:{seconds:02}"
-
- def _get_elapsed_time(self) -> float:
- """Get time to estimate time to progress completion.
-
- Returns:
- The time elapsed since the bar started being animated.
- """
- if self._start_time is None:
- self._start_time = monotonic()
- return 0
- return monotonic() - self._start_time
-
- def watch__label_text(self, label_text: str) -> None:
- """If the ETA label changed, update the renderable (which also refreshes)."""
- self.update(label_text)
+ return f"{hours:02}:{minutes:02}:{seconds:02}"
class ProgressBar(Widget, can_focus=False):
@@ -296,6 +214,7 @@ class ProgressBar(Widget, can_focus=False):
print(progress_bar.percentage) # 0.5
```
"""
+ _display_eta: reactive[int | None] = reactive[Optional[int]](None)
def __init__(
self,
@@ -308,6 +227,7 @@ def __init__(
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
+ clock: Clock | None = None,
):
"""Create a Progress Bar widget.
@@ -332,47 +252,28 @@ def key_space(self):
id: The ID of the widget in the DOM.
classes: The CSS classes for the widget.
disabled: Whether the widget is disabled or not.
+ clock: An optional clock object (leave as default unless testing).
"""
+ self._clock = clock or Clock()
+ self._eta = ETA()
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
+ self.total = total
self.show_bar = show_bar
self.show_percentage = show_percentage
self.show_eta = show_eta
- self.total = total
+ def on_mount(self) -> None:
+ self.update()
+ self.set_interval(1, self.update)
+ self._clock.reset()
def compose(self) -> ComposeResult:
- # We create a closure so that we can determine what are the sub-widgets
- # that are present and, therefore, will need to be notified about changes
- # to the percentage.
- def update_percentage(
- widget: Bar | PercentageStatus | ETAStatus,
- ) -> Callable[[float | None], None]:
- """Closure to allow updating the percentage of a given widget."""
-
- def updater(percentage: float | None) -> None:
- """Update the percentage reactive of the enclosed widget."""
- widget._percentage = percentage
-
- return updater
-
if self.show_bar:
- bar = Bar(id="bar")
- self.watch(self, "percentage", update_percentage(bar))
- yield bar
+ yield Bar(id="bar", clock=self._clock).data_bind(ProgressBar.percentage)
if self.show_percentage:
- percentage_status = PercentageStatus(id="percentage")
- self.watch(self, "percentage", update_percentage(percentage_status))
- yield percentage_status
+ yield PercentageStatus(id="percentage").data_bind(ProgressBar.percentage)
if self.show_eta:
- eta_status = ETAStatus(id="eta")
- self.watch(self, "percentage", update_percentage(eta_status))
- yield eta_status
-
- def _validate_progress(self, progress: float) -> float:
- """Clamp the progress between 0 and the maximum total."""
- if self.total is not None:
- return clamp(progress, 0, self.total)
- return progress
+ yield ETAStatus(id="eta").data_bind(eta=ProgressBar._display_eta)
def _validate_total(self, total: float | None) -> float | None:
"""Ensure the total is not negative."""
@@ -380,21 +281,21 @@ def _validate_total(self, total: float | None) -> float | None:
return total
return max(0, total)
- def _watch_total(self) -> None:
- """Re-validate progress."""
- self.progress = self.progress
-
def _compute_percentage(self) -> float | None:
"""Keep the percentage of progress updated automatically.
This will report a percentage of `1` if the total is zero.
"""
if self.total:
- return self.progress / self.total
+ return clamp(self.progress / self.total, 0.0, 1.0)
elif self.total == 0:
- return 1
+ return 1.0
return None
+ def _watch_progress(self, progress: float) -> None:
+ """Perform update when progress is modified."""
+ self.update(progress=progress)
+
def advance(self, advance: float = 1) -> None:
"""Advance the progress of the progress bar by the given amount.
@@ -406,7 +307,7 @@ def advance(self, advance: float = 1) -> None:
Args:
advance: Number of steps to advance progress by.
"""
- self.progress += advance
+ self.update(advance=advance)
def update(
self,
@@ -430,9 +331,25 @@ def update(
progress: Set the progress to the given number of steps.
advance: Advance the progress by this number of steps.
"""
+ current_time = self._clock.time
if not isinstance(total, UnusedParameter):
+ if total is None or total != self.total:
+ self._eta.reset()
self.total = total
+
+ def add_sample() -> None:
+ """Add a new sample."""
+ if self.progress is not None and self.total:
+ self._eta.add_sample(current_time, self.progress / self.total)
+
if not isinstance(progress, UnusedParameter):
self.progress = progress
+ add_sample()
+
if not isinstance(advance, UnusedParameter):
self.progress += advance
+ add_sample()
+
+ self._display_eta = (
+ None if self.total is None else self._eta.get_eta(current_time)
+ )
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index b009be4d1e..60eb0ff640 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -28962,136 +28962,136 @@
font-weight: 700;
}
- .terminal-2764447286-matrix {
+ .terminal-2114723073-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2764447286-title {
+ .terminal-2114723073-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2764447286-r1 { fill: #e1e1e1 }
- .terminal-2764447286-r2 { fill: #c5c8c6 }
- .terminal-2764447286-r3 { fill: #fea62b }
- .terminal-2764447286-r4 { fill: #323232 }
- .terminal-2764447286-r5 { fill: #dde8f3;font-weight: bold }
- .terminal-2764447286-r6 { fill: #ddedf9 }
+ .terminal-2114723073-r1 { fill: #e1e1e1 }
+ .terminal-2114723073-r2 { fill: #c5c8c6 }
+ .terminal-2114723073-r3 { fill: #fea62b }
+ .terminal-2114723073-r4 { fill: #323232 }
+ .terminal-2114723073-r5 { fill: #dde8f3;font-weight: bold }
+ .terminal-2114723073-r6 { fill: #ddedf9 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- IndeterminateProgressBar
+ IndeterminateProgressBar
-
+
-
-
-
-
-
-
-
-
-
-
-
-
- ━━━━━━━━━━━━╸━━━━━━━━━━━━━━━━━━━39%00:00:07
-
-
-
-
-
-
-
-
-
-
-
- S Start
+
+
+
+
+
+
+
+
+
+
+
+
+ ━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━39%00:00:07
+
+
+
+
+
+
+
+
+
+
+
+ S Start
@@ -29121,138 +29121,138 @@
font-weight: 700;
}
- .terminal-3956614203-matrix {
+ .terminal-1351164996-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-3956614203-title {
+ .terminal-1351164996-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-3956614203-r1 { fill: #e1e1e1 }
- .terminal-3956614203-r2 { fill: #c5c8c6 }
- .terminal-3956614203-r3 { fill: #004578 }
- .terminal-3956614203-r4 { fill: #152939 }
- .terminal-3956614203-r5 { fill: #1e1e1e }
- .terminal-3956614203-r6 { fill: #e1e1e1;text-decoration: underline; }
- .terminal-3956614203-r7 { fill: #dde8f3;font-weight: bold }
- .terminal-3956614203-r8 { fill: #ddedf9 }
+ .terminal-1351164996-r1 { fill: #e1e1e1 }
+ .terminal-1351164996-r2 { fill: #c5c8c6 }
+ .terminal-1351164996-r3 { fill: #004578 }
+ .terminal-1351164996-r4 { fill: #152939 }
+ .terminal-1351164996-r5 { fill: #1e1e1e }
+ .terminal-1351164996-r6 { fill: #e1e1e1;text-decoration: underline; }
+ .terminal-1351164996-r7 { fill: #dde8f3;font-weight: bold }
+ .terminal-1351164996-r8 { fill: #ddedf9 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- StyledProgressBar
+ StyledProgressBar
-
+
-
-
-
-
-
-
-
-
-
-
-
-
- ━━━━━━━━━━━━╸━━━━━━━━━━━━━━━━━━━39%00:00:07
-
-
-
-
-
-
-
-
-
-
-
- S Start
+
+
+
+
+
+
+
+
+
+
+
+
+ ━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━39%00:00:07
+
+
+
+
+
+
+
+
+
+
+
+ S Start
@@ -30088,135 +30088,135 @@
font-weight: 700;
}
- .terminal-904522218-matrix {
+ .terminal-1786282230-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-904522218-title {
+ .terminal-1786282230-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-904522218-r1 { fill: #ff0000 }
- .terminal-904522218-r2 { fill: #c5c8c6 }
- .terminal-904522218-r3 { fill: #e1e1e1;font-weight: bold }
- .terminal-904522218-r4 { fill: #e1e1e1 }
- .terminal-904522218-r5 { fill: #fea62b }
- .terminal-904522218-r6 { fill: #323232 }
+ .terminal-1786282230-r1 { fill: #ff0000 }
+ .terminal-1786282230-r2 { fill: #c5c8c6 }
+ .terminal-1786282230-r3 { fill: #e1e1e1;font-weight: bold }
+ .terminal-1786282230-r4 { fill: #e1e1e1 }
+ .terminal-1786282230-r5 { fill: #fea62b }
+ .terminal-1786282230-r6 { fill: #323232 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- RecomposeApp
+ RecomposeApp
-
-
-
- ┌─────────┐┌─────────┐┌──────────┐┌─────────┐┌──────────┐┌─────────┐┌──────────┐
- │ ┓ ┏━┓││ ┓ ┓ ││ ┓ ╺━┓││ ┓ ╺━┓││ ┓ ╻ ╻││ ┓ ┏━╸││ ┓ ┏━╸│
- │ ┃ ┃ ┃││ ┃ ┃ ││ ┃ ┏━┛││ ┃ ━┫││ ┃ ┗━┫││ ┃ ┗━┓││ ┃ ┣━┓│
- │╺┻╸┗━┛││╺┻╸╺┻╸││╺┻╸┗━╸││╺┻╸╺━┛││╺┻╸ ╹││╺┻╸╺━┛││╺┻╸┗━┛│
- └─────────┘└─────────┘└──────────┘└─────────┘└──────────┘└─────────┘└──────────┘
-
-
-
-
-
-
-
- ━━━━━━━━━━━━━━━━╺━━━━━━━━━━━━━━━50%--:--:--
-
-
-
-
-
-
-
-
-
-
+
+
+
+ ┌─────────┐┌─────────┐┌──────────┐┌─────────┐┌──────────┐┌─────────┐┌──────────┐
+ │ ┓ ┏━┓││ ┓ ┓ ││ ┓ ╺━┓││ ┓ ╺━┓││ ┓ ╻ ╻││ ┓ ┏━╸││ ┓ ┏━╸│
+ │ ┃ ┃ ┃││ ┃ ┃ ││ ┃ ┏━┛││ ┃ ━┫││ ┃ ┗━┫││ ┃ ┗━┓││ ┃ ┣━┓│
+ │╺┻╸┗━┛││╺┻╸╺┻╸││╺┻╸┗━╸││╺┻╸╺━┛││╺┻╸ ╹││╺┻╸╺━┛││╺┻╸┗━┛│
+ └─────────┘└─────────┘└──────────┘└─────────┘└──────────┘└─────────┘└──────────┘
+
+
+
+
+
+
+
+ ━━━━━━━━━━━━━━━━╺━━━━━━━━━━━━━━━50%
+
+
+
+
+
+
+
+
+
+
@@ -40751,134 +40751,134 @@
font-weight: 700;
}
- .terminal-3455460968-matrix {
+ .terminal-3216424293-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-3455460968-title {
+ .terminal-3216424293-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-3455460968-r1 { fill: #fea62b }
- .terminal-3455460968-r2 { fill: #323232 }
- .terminal-3455460968-r3 { fill: #c5c8c6 }
- .terminal-3455460968-r4 { fill: #e1e1e1 }
- .terminal-3455460968-r5 { fill: #e2e3e3 }
+ .terminal-3216424293-r1 { fill: #fea62b }
+ .terminal-3216424293-r2 { fill: #323232 }
+ .terminal-3216424293-r3 { fill: #c5c8c6 }
+ .terminal-3216424293-r4 { fill: #e1e1e1 }
+ .terminal-3216424293-r5 { fill: #e2e3e3 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- TooltipApp
+ TooltipApp
-
-
-
- ━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━10%--:--:--
-
- Hello, Tooltip!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ ━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━10%
+
+ Hello, Tooltip!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/snapshot_tests/snapshot_apps/recompose.py b/tests/snapshot_tests/snapshot_apps/recompose.py
index 8275289eeb..f0e5909f95 100644
--- a/tests/snapshot_tests/snapshot_apps/recompose.py
+++ b/tests/snapshot_tests/snapshot_apps/recompose.py
@@ -29,7 +29,7 @@ class Progress(Horizontal):
progress = reactive(0, recompose=True)
def compose(self) -> ComposeResult:
- bar = ProgressBar(100)
+ bar = ProgressBar(100, show_eta=False)
bar.progress = self.progress
yield bar
diff --git a/tests/snapshot_tests/snapshot_apps/tooltips.py b/tests/snapshot_tests/snapshot_apps/tooltips.py
index bc55542d71..6c9ca8aea5 100644
--- a/tests/snapshot_tests/snapshot_apps/tooltips.py
+++ b/tests/snapshot_tests/snapshot_apps/tooltips.py
@@ -4,7 +4,7 @@
class TooltipApp(App[None]):
def compose(self) -> ComposeResult:
- progress_bar = ProgressBar(100)
+ progress_bar = ProgressBar(100, show_eta=False)
progress_bar.advance(10)
progress_bar.tooltip = "Hello, Tooltip!"
yield progress_bar
diff --git a/tests/test_eta.py b/tests/test_eta.py
new file mode 100644
index 0000000000..c91903293b
--- /dev/null
+++ b/tests/test_eta.py
@@ -0,0 +1,56 @@
+from textual.eta import ETA
+
+
+def test_basics() -> None:
+ eta = ETA()
+ eta.add_sample(1.0, 1.0)
+ assert eta.first_sample == (0, 0)
+ assert eta.last_sample == (1.0, 1.0)
+ assert len(eta._samples) == 2
+ repr(eta)
+
+
+def test_speed() -> None:
+ eta = ETA()
+ # One sample is not enough to determine speed
+ assert eta.speed is None
+ eta.add_sample(1.0, 0.5)
+ assert eta.speed == 0.5
+
+ # Check reset
+ eta.reset()
+ assert eta.speed is None
+ eta.add_sample(0.0, 0.0)
+ assert eta.speed is None
+ eta.add_sample(1.0, 0.5)
+ assert eta.speed == 0.5
+
+ # Check backwards
+ eta.add_sample(2.0, 0.0)
+ assert eta.speed is None
+ eta.add_sample(3.0, 1.0)
+ assert eta.speed == 1.0
+
+
+def test_get_progress_at() -> None:
+ eta = ETA()
+ eta.add_sample(1, 2)
+ # Check interpolation works
+ assert eta._get_progress_at(0) == (0, 0)
+ assert eta._get_progress_at(0.1) == (0.1, 0.2)
+ assert eta._get_progress_at(0.5) == (0.5, 1.0)
+
+
+def test_eta():
+ eta = ETA(estimation_period=2, extrapolate_period=5)
+ eta.add_sample(1, 0.1)
+ assert eta.speed == 0.1
+ assert eta.get_eta(1) == 9
+ assert eta.get_eta(2) == 8
+ assert eta.get_eta(3) == 7
+ assert eta.get_eta(4) == 6
+ assert eta.get_eta(5) == 5
+ # After 5 seconds (extrapolate_period), eta won't update
+ assert eta.get_eta(6) == 4
+ assert eta.get_eta(7) == 4
+ assert eta.get_eta(8) == 4
diff --git a/tests/test_progress_bar.py b/tests/test_progress_bar.py
index bc7f799196..0e9663bf30 100644
--- a/tests/test_progress_bar.py
+++ b/tests/test_progress_bar.py
@@ -48,11 +48,9 @@ def test_progress_overflow():
pb = ProgressBar(total=100)
pb.advance(999_999)
- assert pb.progress == 100
assert pb.percentage == 1
pb.update(total=50)
- assert pb.progress == 50
assert pb.percentage == 1
@@ -60,7 +58,6 @@ def test_progress_underflow():
pb = ProgressBar(total=100)
pb.advance(-999_999)
- assert pb.progress == 0
assert pb.percentage == 0