screenshor plugin fix

This commit is contained in:
Will McGugan
2022-08-22 16:10:49 +01:00
parent 18f96d483c
commit 4dd4d18d2b
6 changed files with 161 additions and 51 deletions

View File

@@ -12,18 +12,19 @@ class TimeDisplay(Static):
start_time = Reactive(monotonic)
time = Reactive(0.0)
def watch_time(self, time: float) -> None:
"""Called when the time attribute changes."""
minutes, seconds = divmod(time - self.start_time, 60)
hours, minutes = divmod(minutes, 60)
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
def on_mount(self) -> None:
"""Event handler called when widget is added to the app."""
self.set_interval(1 / 30, self.update_time)
self.set_interval(1 / 60, self.update_time)
def update_time(self) -> None:
self.time = monotonic()
"""Method to update the time to the current time."""
self.time = monotonic() - self.start_time
def watch_time(self, time: float) -> None:
"""Called when the time attribute changes."""
minutes, seconds = divmod(time, 60)
hours, minutes = divmod(minutes, 60)
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
class Stopwatch(Static):
@@ -41,7 +42,7 @@ class Stopwatch(Static):
yield Button("Start", id="start", variant="success")
yield Button("Stop", id="stop", variant="error")
yield Button("Reset", id="reset")
yield TimeDisplay("00:00:00.00")
yield TimeDisplay()
class StopwatchApp(App):

View File

@@ -9,15 +9,9 @@ from textual.widgets import Button, Header, Footer, Static
class TimeDisplay(Static):
"""A widget to display elapsed time."""
total = Reactive(0.0)
start_time = Reactive(monotonic)
time = Reactive(0.0)
def watch_time(self, time: float) -> None:
"""Called when the time attribute changes."""
minutes, seconds = divmod(time, 60)
hours, minutes = divmod(minutes, 60)
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
total = Reactive(0.0)
def on_mount(self) -> None:
"""Event handler called when widget is added to the app."""
@@ -27,6 +21,12 @@ class TimeDisplay(Static):
"""Method to update time to current."""
self.time = self.total + (monotonic() - self.start_time)
def watch_time(self, time: float) -> None:
"""Called when the time attribute changes."""
minutes, seconds = divmod(time, 60)
hours, minutes = divmod(minutes, 60)
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
def start(self) -> None:
"""Method to start (or resume) time updating."""
self.start_time = monotonic()
@@ -41,21 +41,21 @@ class TimeDisplay(Static):
def reset(self):
"""Method to reset the time display to zero."""
self.total = 0
self.time = self.start_time
self.time = 0
class Stopwatch(Static):
"""A Textual app to manage stopwatches."""
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Event handler called when a button is pressed."""
time_display = self.query_one(TimeDisplay)
if event.button.id == "start":
time_display.start()
self.add_class("started")
self.query_one("#stop").focus()
elif event.button.id == "stop":
time_display.stop()
self.remove_class("started")
self.query_one("#start").focus()
elif event.button.id == "reset":
time_display.reset()
@@ -64,7 +64,7 @@ class Stopwatch(Static):
yield Button("Start", id="start", variant="success")
yield Button("Stop", id="stop", variant="error")
yield Button("Reset", id="reset")
yield TimeDisplay("00:00:00.00")
yield TimeDisplay()
class StopwatchApp(App):

View File

@@ -315,4 +315,78 @@ If you run "stopwatch04.py" now you will be able to toggle between the two state
```{.textual path="docs/examples/introduction/stopwatch04.py" title="stopwatch04.py" press="tab,tab,tab,enter"}
```
## Reactive attributes
A reoccurring theme in Textual is that you rarely need to explicitly update a widget. It is possible: you can call `refresh()` to display new data. However, Textual prefers to do this automatically via _reactive_ attributes.
You can declare a reactive attribute with `textual.reactive.Reactive`. Let's use this feature to create a timer that displays elapsed time and keeps it updated.
```python title="stopwatch04.py" hl_lines="1 5 12-27"
--8<-- "docs/examples/introduction/stopwatch05.py"
```
Here we have created two reactive attributes: `start_time` and `time`. These attributes will be available on `self` as if you had assigned them in `__init__`. If you write to either of these attributes the widget will update automatically.
!!! info
`Reactive` is an example of a Python _descriptor_, which allows you to dynamically create properties.
The first argument to `Reactive` may be a default value, or a callable that returns the default value. In the example, the default for `start_time` is `monotonic` which is a function that returns the time. When `TimeDisplay` is mounted the `start_time` attribute will automatically be assigned the value returned by `monotonic()`.
The `time` attribute has a simple float as the default value, so `self.time` will be `0` on start.
To update the time automatically we will use the `set_interval` method which tells Textual to call a function at given intervals. The `on_mount` method does this to call `self.update_time` 60 times a second.
In `update_time` we calculate the time elapsed since the widget started and assign it to `self.time`. Which brings us to one of Reactive's super-powers.
If you implement a method that begins with `watch_` followed by the name of a reactive attribute (making it a _watch method_), that method will be called when the attribute is modified.
Because `watch_time` watches the `time` attribute, when we update `self.time` 60 times a second we also implicitly call `watch_time` which converts the elapsed time in to a string and updates the widget with a call to `self.update`.
The end result is that all the `Stopwatch` widgets show the time elapsed since the widget was created:
```{.textual path="docs/examples/introduction/stopwatch05.py" title="stopwatch05.py"}
```
We've seen how we can update widgets with a timer. But we still need to wire buttons to the widget
### Wiring the Stopwatch
To make a useful stopwatch we will need to add a little more code to `TimeDisplay`, to be able to start, stop, and reset the timer.
```python title="stopwatch06.py" hl_lines="14-44 50-60"
--8<-- "docs/examples/introduction/stopwatch06.py"
```
Here's a summary of the changes made to `TimeDisplay`.
- We've added a `total` reactive attribute to store the total time elapsed between clicking Stop and Start.
- The call to `set_interval` has grown a `pause=True` attribute which starts the timer in pause mode. This is because we don't want to update the timer until the user hits the Start button.
- We've stored the result of `set_interval` which returns a timer object. We will use this later to _resume_ the timer when we start the Stopwatch.
- We've added `start()`, `stop()`, and `reset()` methods.
The `on_button_pressed` method on `Stopwatch` has grown some code to manage the time display when the user clicked a button. Let's look at that in detail:
```python
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Event handler called when a button is pressed."""
time_display = self.query_one(TimeDisplay)
if event.button.id == "start":
time_display.start()
self.add_class("started")
elif event.button.id == "stop":
time_display.stop()
self.remove_class("started")
elif event.button.id == "reset":
time_display.reset()
```
This code supplies the missing features and makes our app really useful. If you run it now you can start and stop timers independently.
- The first line calls `query_one` to get a reference to the `TimeDisplay` widget. This method queries for a child widget. You may supply a Widget type or a CSS selector.
- We call the `TimeDisplay` method that matches the button pressed.
- We add the "started" class when the Stopwatch is started, and remove it when it is stopped.
```{.textual path="docs/examples/introduction/stopwatch06.py" title="stopwatch06.py" press="tab,enter,_,_,tab,enter,_,tab"}
```

View File

@@ -11,41 +11,36 @@ if TYPE_CHECKING:
def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str:
"""A superfences formatter to insert a SVG screenshot."""
path = attrs.get("path")
path: str = attrs["path"]
_press = attrs.get("press", None)
press = _press.split(",") if _press else []
press = [*_press.split(",")] if _press else ["_"]
title = attrs.get("title")
os.environ["TEXTUAL"] = "headless"
os.environ["TEXTUAL_SCREENSHOT"] = "0.15"
if title:
os.environ["TEXTUAL_SCREENSHOT_TITLE"] = title
else:
os.environ.pop("TEXTUAL_SCREENSHOT_TITLE", None)
os.environ["COLUMNS"] = attrs.get("columns", "80")
os.environ["LINES"] = attrs.get("lines", "24")
print(f"screenshotting {path!r}")
if path:
cwd = os.getcwd()
examples_path, filename = os.path.split(path)
try:
os.chdir(examples_path)
with open(filename, "rt") as python_code:
source = python_code.read()
app_vars: dict[str, object] = {}
exec(source, app_vars)
app: App = cast("App", app_vars["app"])
app.run(press=press or None)
svg = app._screenshot
finally:
os.chdir(cwd)
else:
app_vars = {}
cwd = os.getcwd()
examples_path, filename = os.path.split(path)
try:
os.chdir(examples_path)
with open(filename, "rt") as python_code:
source = python_code.read()
app_vars: dict[str, object] = {}
exec(source, app_vars)
app = cast(App, app_vars["app"])
app.run(press=press or None)
app: App = cast("App", app_vars["app"])
app.run(
quit_after=5,
press=press or ["ctrl+c"],
headless=True,
screenshot=True,
screenshot_title=title,
)
svg = app._screenshot
finally:
os.chdir(cwd)
assert svg is not None
return svg

View File

@@ -559,9 +559,12 @@ class App(Generic[ReturnType], DOMNode):
def run(
self,
*,
quit_after: float | None = None,
headless: bool = False,
press: Iterable[str] | None = None,
screenshot: bool = False,
screenshot_title: str | None = None,
) -> ReturnType | None:
"""The main entry point for apps.
@@ -570,6 +573,8 @@ class App(Generic[ReturnType], DOMNode):
to run forever. Defaults to None.
headless (bool, optional): Run in "headless" mode (don't write to stdout).
press (str, optional): An iterable of keys to simulate being pressed.
screenshot (str, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False.
screenshot_title (str | None, optional): Title of screenshot, or None to use App title. Defaults to None.
Returns:
ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called.
@@ -591,13 +596,20 @@ class App(Generic[ReturnType], DOMNode):
assert press
driver = app._driver
assert driver is not None
await asyncio.sleep(0.05)
for key in press:
if key == "_":
await asyncio.sleep(0.02)
print("(pause)")
await asyncio.sleep(0.05)
else:
print(f"press {key!r}")
driver.send_event(events.Key(self, key))
await asyncio.sleep(0.02)
if screenshot:
self._screenshot = self.export_screenshot(
title=screenshot_title
)
await self.shutdown()
async def press_keys_task():
"""Press some keys in the background."""
@@ -866,6 +878,19 @@ class App(Generic[ReturnType], DOMNode):
widget.post_message_no_wait(events.Focus(self))
widget.emit_no_wait(events.DescendantFocus(self))
def _reset_focus(self, widget: Widget) -> None:
"""Reset the focus when a widget is removed
Args:
widget (Widget): A widget that is removed.
"""
for sibling in widget.siblings:
if sibling.can_focus:
sibling.focus()
break
else:
self.focused = None
async def _set_mouse_over(self, widget: Widget | None) -> None:
"""Called when the mouse is over another widget.
@@ -1120,8 +1145,7 @@ class App(Generic[ReturnType], DOMNode):
Args:
widget (Widget): A Widget to unregister
"""
if self.focused is widget:
self.focused = None
self._reset_focus(widget)
if isinstance(widget._parent, Widget):
widget._parent.children._remove(widget)
@@ -1369,8 +1393,9 @@ class App(Generic[ReturnType], DOMNode):
async def _on_remove(self, event: events.Remove) -> None:
widget = event.widget
if widget.has_parent:
widget.parent.refresh(layout=True)
parent = widget.parent
if parent is not None:
parent.refresh(layout=True)
remove_widgets = list(widget.walk_children(Widget, with_self=True))
for child in remove_widgets:

View File

@@ -146,6 +146,17 @@ class Widget(DOMNode):
show_vertical_scrollbar = Reactive(False, layout=True)
show_horizontal_scrollbar = Reactive(False, layout=True)
@property
def siblings(self) -> list[Widget]:
"""Get the widget's siblings (self is removed from the return list)."""
parent = self.parent
if parent is not None:
siblings = list(parent.children)
siblings.remove(self)
return siblings
else:
return []
@property
def allow_vertical_scroll(self) -> bool:
"""Check if vertical scroll is permitted."""
@@ -1285,6 +1296,10 @@ class Widget(DOMNode):
self.scroll_page_right()
event.stop()
def _on_hide(self, event: events.Hide) -> None:
if self.has_focus:
self.app._reset_focus(self)
def key_home(self) -> bool:
if self.is_scrollable:
self.scroll_home()