diff --git a/docs/examples/widgets/progress_bar_isolated_.py b/docs/examples/widgets/progress_bar_isolated_.py
index 79907562c..f6c2d4eaa 100644
--- a/docs/examples/widgets/progress_bar_isolated_.py
+++ b/docs/examples/widgets/progress_bar_isolated_.py
@@ -11,13 +11,14 @@ class IndeterminateProgressBar(App[None]):
"""Timer to simulate progress happening."""
def compose(self) -> ComposeResult:
+ self.time = 0
with Center():
with Middle():
- yield ProgressBar()
+ yield ProgressBar(_get_time=lambda: self.time)
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 +32,18 @@ class IndeterminateProgressBar(App[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.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.time = 0
+ self.query_one(ProgressBar).update(total=100, progress=0)
+ self.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 8428f359a..9da9a75f2 100644
--- a/docs/examples/widgets/progress_bar_styled_.py
+++ b/docs/examples/widgets/progress_bar_styled_.py
@@ -12,13 +12,14 @@ class StyledProgressBar(App[None]):
"""Timer to simulate progress happening."""
def compose(self) -> ComposeResult:
+ self.time = 0
with Center():
with Middle():
- yield ProgressBar()
+ yield ProgressBar(_get_time=lambda: self.time)
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:
@@ -32,12 +33,15 @@ class StyledProgressBar(App[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.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.time = 0
+ self.query_one(ProgressBar).update(total=100, progress=0)
+ self.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/eta.py b/src/textual/eta.py
index 5f2215421..f432d1751 100644
--- a/src/textual/eta.py
+++ b/src/textual/eta.py
@@ -2,30 +2,42 @@ import bisect
from math import ceil
from operator import itemgetter
from time import monotonic
+from typing import Callable
+
+import rich.repr
+@rich.repr.auto(angular=True)
class ETA:
"""Calculate speed and estimate time to arrival."""
- def __init__(self, estimation_period: float = 30) -> None:
+ def __init__(
+ self, estimation_period: float = 30, _get_time: Callable[[], float] = monotonic
+ ) -> None:
"""Create an ETA.
Args:
estimation_period: Period in seconds, used to calculate speed. Defaults to 30.
+ _get_time: Optional replacement function to get current time.
"""
self.estimation_period = estimation_period
- self._start_time = monotonic()
+ self._get_time = _get_time
+ self._start_time = _get_time()
self._samples: list[tuple[float, float]] = [(0.0, 0.0)]
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "speed", self.speed
+ yield "eta", self.eta
+
def reset(self) -> None:
"""Start ETA calculations from current time."""
del self._samples[:]
- self._start_time = monotonic()
+ self._start_time = self._get_time()
@property
def _current_time(self) -> float:
"""The time since the ETA was started."""
- return monotonic() - self._start_time
+ return self._get_time() - self._start_time
def add_sample(self, progress: float) -> None:
"""Add a new sample.
@@ -69,7 +81,7 @@ class ETA:
def speed(self) -> float | None:
"""The current speed, or `None` if it couldn't be calculated."""
- if len(self._samples) <= 2:
+ if len(self._samples) < 2:
# Need at less 2 samples to calculate speed
return None
@@ -79,15 +91,13 @@ class ETA:
)
time_delta = recent_sample_time - progress_start_time
distance = progress2 - progress1
- speed = distance / time_delta
+ speed = distance / time_delta if time_delta else 0
return speed
@property
def eta(self) -> int | None:
"""Estimated seconds until completion, or `None` if no estimate can be made."""
current_time = self._current_time
- if not self._samples:
- return None
speed = self.speed
if not speed:
return None
diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py
index 505542b1a..d22a46f9e 100644
--- a/src/textual/widgets/_progress_bar.py
+++ b/src/textual/widgets/_progress_bar.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from math import ceil
from time import monotonic
-from typing import Optional
+from typing import Callable, Optional
from rich.style import Style
@@ -69,8 +69,10 @@ class Bar(Widget, can_focus=False):
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
+ _get_time: Callable[[], float] = monotonic,
):
"""Create a bar for a [`ProgressBar`][textual.widgets.ProgressBar]."""
+ self._get_time = _get_time
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self._start_time = None
self._percentage = None
@@ -138,9 +140,9 @@ class Bar(Widget, can_focus=False):
The time elapsed since the bar started being animated.
"""
if self._start_time is None:
- self._start_time = monotonic()
+ self._start_time = self._get_time()
return 0
- return monotonic() - self._start_time
+ return self._get_time() - self._start_time
class PercentageStatus(Label):
@@ -171,6 +173,7 @@ class ETAStatus(Label):
}
"""
eta: reactive[float | None] = reactive[Optional[float]](None)
+ """Estimated number of seconds till completion."""
def render(self) -> RenderResult:
"""Render the ETA display."""
@@ -234,6 +237,7 @@ class ProgressBar(Widget, can_focus=False):
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
+ _get_time: Callable[[], float] = monotonic,
):
"""Create a Progress Bar widget.
@@ -264,14 +268,18 @@ class ProgressBar(Widget, can_focus=False):
self.show_bar = show_bar
self.show_percentage = show_percentage
self.show_eta = show_eta
- self._eta = ETA()
+ self._get_time = _get_time
+ self._eta = ETA(_get_time=_get_time)
def on_mount(self) -> None:
+ self.update()
self.set_interval(0.5, self.update)
def compose(self) -> ComposeResult:
if self.show_bar:
- yield Bar(id="bar").data_bind(_percentage=ProgressBar.percentage)
+ yield Bar(id="bar", _get_time=self._get_time).data_bind(
+ _percentage=ProgressBar.percentage
+ )
if self.show_percentage:
yield PercentageStatus(id="percentage").data_bind(
_percentage=ProgressBar.percentage
@@ -291,17 +299,13 @@ class ProgressBar(Widget, can_focus=False):
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 min(1.0, self.progress / self.total)
elif self.total == 0:
return 1
return None
@@ -346,14 +350,13 @@ class ProgressBar(Widget, can_focus=False):
self._eta.reset()
self.total = total
- elif not isinstance(progress, UnusedParameter):
+ if not isinstance(progress, UnusedParameter):
self.progress = progress
- if self.progress is not None and self.total is not None:
- self._eta.add_sample(self.progress / self.total)
- elif not isinstance(advance, UnusedParameter):
+ if not isinstance(advance, UnusedParameter):
self.progress += advance
- if self.progress is not None and self.total is not None:
- self._eta.add_sample(self.progress / self.total)
+
+ if self.progress is not None and self.total is not None:
+ self._eta.add_sample(self.progress / self.total)
self._display_eta = self._eta.eta
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 04502cde5..f278f078f 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -29774,135 +29774,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%
+
+
+
+
+
+
+
+
+
+
@@ -40274,134 +40274,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 8275289ee..f0e5909f9 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 bc55542d7..6c9ca8aea 100644
--- a/tests/snapshot_tests/snapshot_apps/tooltips.py
+++ b/tests/snapshot_tests/snapshot_apps/tooltips.py
@@ -4,7 +4,7 @@ from textual.widgets import ProgressBar
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_progress_bar.py b/tests/test_progress_bar.py
index bc7f79919..3cc3a42bf 100644
--- a/tests/test_progress_bar.py
+++ b/tests/test_progress_bar.py
@@ -52,7 +52,6 @@ def test_progress_overflow():
assert pb.percentage == 1
pb.update(total=50)
- assert pb.progress == 50
assert pb.percentage == 1