mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
screenshor plugin fix
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user