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