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