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)
|
start_time = Reactive(monotonic)
|
||||||
time = Reactive(0.0)
|
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:
|
def on_mount(self) -> None:
|
||||||
"""Event handler called when widget is added to the app."""
|
"""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:
|
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):
|
class Stopwatch(Static):
|
||||||
@@ -41,7 +42,7 @@ class Stopwatch(Static):
|
|||||||
yield Button("Start", id="start", variant="success")
|
yield Button("Start", id="start", variant="success")
|
||||||
yield Button("Stop", id="stop", variant="error")
|
yield Button("Stop", id="stop", variant="error")
|
||||||
yield Button("Reset", id="reset")
|
yield Button("Reset", id="reset")
|
||||||
yield TimeDisplay("00:00:00.00")
|
yield TimeDisplay()
|
||||||
|
|
||||||
|
|
||||||
class StopwatchApp(App):
|
class StopwatchApp(App):
|
||||||
|
|||||||
@@ -9,15 +9,9 @@ from textual.widgets import Button, Header, Footer, Static
|
|||||||
class TimeDisplay(Static):
|
class TimeDisplay(Static):
|
||||||
"""A widget to display elapsed time."""
|
"""A widget to display elapsed time."""
|
||||||
|
|
||||||
total = Reactive(0.0)
|
|
||||||
start_time = Reactive(monotonic)
|
start_time = Reactive(monotonic)
|
||||||
time = Reactive(0.0)
|
time = Reactive(0.0)
|
||||||
|
total = 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}")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Event handler called when widget is added to the app."""
|
"""Event handler called when widget is added to the app."""
|
||||||
@@ -27,6 +21,12 @@ class TimeDisplay(Static):
|
|||||||
"""Method to update time to current."""
|
"""Method to update time to current."""
|
||||||
self.time = self.total + (monotonic() - self.start_time)
|
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:
|
def start(self) -> None:
|
||||||
"""Method to start (or resume) time updating."""
|
"""Method to start (or resume) time updating."""
|
||||||
self.start_time = monotonic()
|
self.start_time = monotonic()
|
||||||
@@ -41,21 +41,21 @@ class TimeDisplay(Static):
|
|||||||
def reset(self):
|
def reset(self):
|
||||||
"""Method to reset the time display to zero."""
|
"""Method to reset the time display to zero."""
|
||||||
self.total = 0
|
self.total = 0
|
||||||
self.time = self.start_time
|
self.time = 0
|
||||||
|
|
||||||
|
|
||||||
class Stopwatch(Static):
|
class Stopwatch(Static):
|
||||||
|
"""A Textual app to manage stopwatches."""
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
"""Event handler called when a button is pressed."""
|
"""Event handler called when a button is pressed."""
|
||||||
time_display = self.query_one(TimeDisplay)
|
time_display = self.query_one(TimeDisplay)
|
||||||
if event.button.id == "start":
|
if event.button.id == "start":
|
||||||
time_display.start()
|
time_display.start()
|
||||||
self.add_class("started")
|
self.add_class("started")
|
||||||
self.query_one("#stop").focus()
|
|
||||||
elif event.button.id == "stop":
|
elif event.button.id == "stop":
|
||||||
time_display.stop()
|
time_display.stop()
|
||||||
self.remove_class("started")
|
self.remove_class("started")
|
||||||
self.query_one("#start").focus()
|
|
||||||
elif event.button.id == "reset":
|
elif event.button.id == "reset":
|
||||||
time_display.reset()
|
time_display.reset()
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ class Stopwatch(Static):
|
|||||||
yield Button("Start", id="start", variant="success")
|
yield Button("Start", id="start", variant="success")
|
||||||
yield Button("Stop", id="stop", variant="error")
|
yield Button("Stop", id="stop", variant="error")
|
||||||
yield Button("Reset", id="reset")
|
yield Button("Reset", id="reset")
|
||||||
yield TimeDisplay("00:00:00.00")
|
yield TimeDisplay()
|
||||||
|
|
||||||
|
|
||||||
class StopwatchApp(App):
|
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"}
|
```{.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:
|
def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str:
|
||||||
"""A superfences formatter to insert a SVG screenshot."""
|
"""A superfences formatter to insert a SVG screenshot."""
|
||||||
|
|
||||||
path = attrs.get("path")
|
path: str = attrs["path"]
|
||||||
_press = attrs.get("press", None)
|
_press = attrs.get("press", None)
|
||||||
press = _press.split(",") if _press else []
|
press = [*_press.split(",")] if _press else ["_"]
|
||||||
title = attrs.get("title")
|
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["COLUMNS"] = attrs.get("columns", "80")
|
||||||
os.environ["LINES"] = attrs.get("lines", "24")
|
os.environ["LINES"] = attrs.get("lines", "24")
|
||||||
|
|
||||||
print(f"screenshotting {path!r}")
|
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"])
|
cwd = os.getcwd()
|
||||||
app.run(press=press or None)
|
examples_path, filename = os.path.split(path)
|
||||||
svg = app._screenshot
|
try:
|
||||||
finally:
|
os.chdir(examples_path)
|
||||||
os.chdir(cwd)
|
with open(filename, "rt") as python_code:
|
||||||
else:
|
source = python_code.read()
|
||||||
app_vars = {}
|
app_vars: dict[str, object] = {}
|
||||||
exec(source, app_vars)
|
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
|
svg = app._screenshot
|
||||||
|
finally:
|
||||||
|
os.chdir(cwd)
|
||||||
|
|
||||||
assert svg is not None
|
assert svg is not None
|
||||||
return svg
|
return svg
|
||||||
|
|||||||
@@ -559,9 +559,12 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
|
|
||||||
def run(
|
def run(
|
||||||
self,
|
self,
|
||||||
|
*,
|
||||||
quit_after: float | None = None,
|
quit_after: float | None = None,
|
||||||
headless: bool = False,
|
headless: bool = False,
|
||||||
press: Iterable[str] | None = None,
|
press: Iterable[str] | None = None,
|
||||||
|
screenshot: bool = False,
|
||||||
|
screenshot_title: str | None = None,
|
||||||
) -> ReturnType | None:
|
) -> ReturnType | None:
|
||||||
"""The main entry point for apps.
|
"""The main entry point for apps.
|
||||||
|
|
||||||
@@ -570,6 +573,8 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
to run forever. Defaults to None.
|
to run forever. Defaults to None.
|
||||||
headless (bool, optional): Run in "headless" mode (don't write to stdout).
|
headless (bool, optional): Run in "headless" mode (don't write to stdout).
|
||||||
press (str, optional): An iterable of keys to simulate being pressed.
|
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:
|
Returns:
|
||||||
ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called.
|
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
|
assert press
|
||||||
driver = app._driver
|
driver = app._driver
|
||||||
assert driver is not None
|
assert driver is not None
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
for key in press:
|
for key in press:
|
||||||
if key == "_":
|
if key == "_":
|
||||||
await asyncio.sleep(0.02)
|
print("(pause)")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
else:
|
else:
|
||||||
print(f"press {key!r}")
|
print(f"press {key!r}")
|
||||||
driver.send_event(events.Key(self, key))
|
driver.send_event(events.Key(self, key))
|
||||||
await asyncio.sleep(0.02)
|
await asyncio.sleep(0.02)
|
||||||
|
if screenshot:
|
||||||
|
self._screenshot = self.export_screenshot(
|
||||||
|
title=screenshot_title
|
||||||
|
)
|
||||||
|
await self.shutdown()
|
||||||
|
|
||||||
async def press_keys_task():
|
async def press_keys_task():
|
||||||
"""Press some keys in the background."""
|
"""Press some keys in the background."""
|
||||||
@@ -866,6 +878,19 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
widget.post_message_no_wait(events.Focus(self))
|
widget.post_message_no_wait(events.Focus(self))
|
||||||
widget.emit_no_wait(events.DescendantFocus(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:
|
async def _set_mouse_over(self, widget: Widget | None) -> None:
|
||||||
"""Called when the mouse is over another widget.
|
"""Called when the mouse is over another widget.
|
||||||
|
|
||||||
@@ -1120,8 +1145,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
Args:
|
Args:
|
||||||
widget (Widget): A Widget to unregister
|
widget (Widget): A Widget to unregister
|
||||||
"""
|
"""
|
||||||
if self.focused is widget:
|
self._reset_focus(widget)
|
||||||
self.focused = None
|
|
||||||
|
|
||||||
if isinstance(widget._parent, Widget):
|
if isinstance(widget._parent, Widget):
|
||||||
widget._parent.children._remove(widget)
|
widget._parent.children._remove(widget)
|
||||||
@@ -1369,8 +1393,9 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
|
|
||||||
async def _on_remove(self, event: events.Remove) -> None:
|
async def _on_remove(self, event: events.Remove) -> None:
|
||||||
widget = event.widget
|
widget = event.widget
|
||||||
if widget.has_parent:
|
parent = widget.parent
|
||||||
widget.parent.refresh(layout=True)
|
if parent is not None:
|
||||||
|
parent.refresh(layout=True)
|
||||||
|
|
||||||
remove_widgets = list(widget.walk_children(Widget, with_self=True))
|
remove_widgets = list(widget.walk_children(Widget, with_self=True))
|
||||||
for child in remove_widgets:
|
for child in remove_widgets:
|
||||||
|
|||||||
@@ -146,6 +146,17 @@ class Widget(DOMNode):
|
|||||||
show_vertical_scrollbar = Reactive(False, layout=True)
|
show_vertical_scrollbar = Reactive(False, layout=True)
|
||||||
show_horizontal_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
|
@property
|
||||||
def allow_vertical_scroll(self) -> bool:
|
def allow_vertical_scroll(self) -> bool:
|
||||||
"""Check if vertical scroll is permitted."""
|
"""Check if vertical scroll is permitted."""
|
||||||
@@ -1285,6 +1296,10 @@ class Widget(DOMNode):
|
|||||||
self.scroll_page_right()
|
self.scroll_page_right()
|
||||||
event.stop()
|
event.stop()
|
||||||
|
|
||||||
|
def _on_hide(self, event: events.Hide) -> None:
|
||||||
|
if self.has_focus:
|
||||||
|
self.app._reset_focus(self)
|
||||||
|
|
||||||
def key_home(self) -> bool:
|
def key_home(self) -> bool:
|
||||||
if self.is_scrollable:
|
if self.is_scrollable:
|
||||||
self.scroll_home()
|
self.scroll_home()
|
||||||
|
|||||||
Reference in New Issue
Block a user