Skip to content

Commit

Permalink
tests
Browse files Browse the repository at this point in the history
  • Loading branch information
willmcgugan committed Mar 10, 2024
1 parent 05ec4ab commit 5cff594
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 170 deletions.
11 changes: 6 additions & 5 deletions docs/examples/widgets/progress_bar_isolated_.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,10 +12,10 @@ class IndeterminateProgressBar(App[None]):
"""Timer to simulate progress happening."""

def compose(self) -> ComposeResult:
self.time = 0
self.clock = MockClock()
with Center():
with Middle():
yield ProgressBar(_get_time=lambda: self.time)
yield ProgressBar(clock=self.clock)
yield Footer()

def on_mount(self) -> None:
Expand All @@ -32,14 +33,14 @@ def action_start(self) -> None:

def key_f(self) -> None:
# Freeze time for the indeterminate progress bar.
self.time = 5
self.clock.set_time(5)
self.refresh()

def key_t(self) -> None:
# Freeze time to show always the same ETA.
self.time = 0
self.clock.set_time(0)
self.query_one(ProgressBar).update(total=100, progress=0)
self.time = 3.9
self.clock.set_time(3.9)
self.query_one(ProgressBar).update(progress=39)

def key_u(self) -> None:
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/widgets/progress_bar_styled.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 7 additions & 6 deletions docs/examples/widgets/progress_bar_styled_.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,10 +13,10 @@ class StyledProgressBar(App[None]):
"""Timer to simulate progress happening."""

def compose(self) -> ComposeResult:
self.time = 0
self.clock = MockClock()
with Center():
with Middle():
yield ProgressBar(_get_time=lambda: self.time)
yield ProgressBar(clock=self.clock)
yield Footer()

def on_mount(self) -> None:
Expand All @@ -30,17 +31,17 @@ 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.time = 5
self.refresh()
self.clock.set_time(5.0)

def key_t(self) -> None:
# Freeze time to show always the same ETA.
self.time = 0
self.clock.set_time(0)
self.query_one(ProgressBar).update(total=100, progress=0)
self.time = 3.9
self.clock.set_time(3.9)
self.query_one(ProgressBar).update(progress=39)

def key_u(self) -> None:
Expand Down
74 changes: 74 additions & 0 deletions src/textual/clock.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 2 additions & 1 deletion src/textual/eta.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import bisect
from math import ceil
from operator import itemgetter
from time import monotonic

Expand Down Expand Up @@ -112,4 +113,4 @@ def get_eta(self, time: float) -> int | None:
time_since_sample = time - recent_time
remaining = 1.0 - (recent_progress + speed * time_since_sample)
eta = max(0, remaining / speed)
return round(eta)
return ceil(eta)
59 changes: 24 additions & 35 deletions src/textual/widgets/_progress_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

from __future__ import annotations

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 ..widget import Widget
Expand Down Expand Up @@ -58,27 +59,27 @@ class Bar(Widget, can_focus=False):

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,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
_get_time: Callable[[], float] = monotonic,
clock: Clock | None = None,
):
"""Create a bar for a [`ProgressBar`][textual.widgets.ProgressBar]."""
self._get_time = _get_time
self._clock = (clock or Clock()).clone()
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self._start_time = None
self.percentage = None

def _validate_percentage(self, percentage: float | None) -> float | None:
"""Avoid updating the bar, if the percentage increase is to small to render."""
"""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
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."""
Expand All @@ -105,11 +106,11 @@ def render(self) -> RenderResult:

def render_indeterminate(self) -> RenderResult:
"""Render a frame of the indeterminate progress bar animation."""
print(self._clock)
width = self.size.width
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":
Expand All @@ -118,7 +119,7 @@ def render_indeterminate(self) -> RenderResult:
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))
Expand All @@ -132,21 +133,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 = self._get_time()
return 0
return self._get_time() - self._start_time


class PercentageStatus(Label):
"""A label to display the percentage status of the progress bar."""
Expand Down Expand Up @@ -242,7 +228,7 @@ def __init__(
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
_get_time: Callable[[], float] = monotonic,
clock: Clock | None = None,
):
"""Create a Progress Bar widget.
Expand All @@ -267,22 +253,24 @@ 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).
"""
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._get_time = _get_time
self._clock = clock or Clock()
self._eta = ETA()

def on_mount(self) -> None:
self.update()
self.set_interval(0.5, self.update)
self._clock.reset()

def compose(self) -> ComposeResult:
if self.show_bar:
yield Bar(id="bar").data_bind(ProgressBar.percentage)
yield Bar(id="bar", clock=self._clock).data_bind(ProgressBar.percentage)
if self.show_percentage:
yield PercentageStatus(id="percentage").data_bind(ProgressBar.percentage)
if self.show_eta:
Expand All @@ -300,9 +288,9 @@ def _compute_percentage(self) -> float | None:
This will report a percentage of `1` if the total is zero.
"""
if self.total:
return min(1.0, 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 advance(self, advance: float = 1) -> None:
Expand Down Expand Up @@ -340,6 +328,7 @@ 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 != self.total:
self._eta.reset()
Expand All @@ -351,7 +340,7 @@ def update(
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._get_time(), self.progress / self.total)
if self.progress is not None and self.total:
self._eta.add_sample(current_time, self.progress / self.total)

self._display_eta = self._eta.get_eta(self._get_time())
self._display_eta = self._eta.get_eta(current_time)
Loading

0 comments on commit 5cff594

Please sign in to comment.