lots of docstrings

This commit is contained in:
Will McGugan
2022-08-25 09:08:35 +01:00
parent cc4427a4bd
commit b22436933a
27 changed files with 407 additions and 147 deletions

View File

@@ -20,7 +20,7 @@ Textual requires Python 3.7 or later. Textual runs on Linux, MacOS, Windows and
## Installation
You can install Textual via PyPi.
You can install Textual via PyPI.
If you plan on developing Textual apps, then you should install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development.
@@ -34,7 +34,7 @@ If you only plan on _running_ Textual apps, then you can drop the `[dev]` part:
pip install textual
```
## Textual CLI app
## Textual CLI
If you installed the dev dependencies you have have access to the `textual` CLI command. There are a number of sub-commands which will aid you in building Textual apps.

View File

@@ -45,7 +45,7 @@ If you want to try the finished Stopwatch app and following along with the code
gh repo clone Textualize/textual
```
With the repository cloned, navigate to `/docs/examples/introduction` and run `stopwatch.py`.
With the repository cloned, navigate to `docs/examples/introduction` and run `stopwatch.py`.
```bash
cd textual/docs/examples/introduction
@@ -69,7 +69,7 @@ def repeat(text: str, count: int) -> str:
```
- Parameter types follow a colon. So `text: str` indicates that `text` requires a string and `count: int` means that `count` requires an integer.
- Return types follow `->`. So `-> str:` indicates that this method returns a string.
- Return types follow `->`. So `-> str:` indicates this method returns a string.
## The App class
@@ -335,7 +335,7 @@ If you run "stopwatch04.py" now you will be able to toggle between the two state
## 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.
A reoccurring theme in Textual is that you rarely need to explicitly update a widget. It is possible: you can call [`refresh()`][textual.widget.Widget.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.

View File

@@ -0,0 +1,5 @@
A message pump is a class that processes messages.
It is a base class for the App, Screen, and Widgets.
::: textual.message_pump.MessagePump

1
docs/reference/timer.md Normal file
View File

@@ -0,0 +1 @@
::: textual.timer

View File

@@ -67,6 +67,8 @@ nav:
- "reference/dom_node.md"
- "reference/events.md"
- "reference/geometry.md"
- "reference/message_pump.md"
- "reference/timer.md"
- "reference/widget.md"

View File

@@ -10,7 +10,7 @@ from dataclasses import dataclass
from . import _clock
from ._callback import invoke
from ._easing import DEFAULT_EASING, EASING
from ._timer import Timer
from .timer import Timer
from ._types import MessageTarget, CallbackType
if sys.version_info >= (3, 8):

View File

@@ -64,7 +64,7 @@ def arrange(
fraction_unit = Fraction(
size.height if edge in ("top", "bottom") else size.width
)
box_model = dock_widget.get_box_model(size, viewport, fraction_unit)
box_model = dock_widget._get_box_model(size, viewport, fraction_unit)
widget_width_fraction, widget_height_fraction, margin = box_model
widget_width = int(widget_width_fraction) + margin.width
@@ -98,7 +98,7 @@ def arrange(
dock_spacing = Spacing(top, right, bottom, left)
region = size.region.shrink(dock_spacing)
layout_placements, arranged_layout_widgets = widget.layout.arrange(
layout_placements, arranged_layout_widgets = widget._layout.arrange(
widget, layout_widgets, region.size
)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Iterator, overload, TYPE_CHECKING
from typing import TYPE_CHECKING, Iterator, Sequence, overload
import rich.repr
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
@rich.repr.auto(angular=True)
class NodeList:
class NodeList(Sequence):
"""
A container for widgets that forms one level of hierarchy.

View File

@@ -300,7 +300,7 @@ class App(Generic[ReturnType], DOMNode):
result (ReturnType | None, optional): Return value. Defaults to None.
"""
self._return_value = result
self.close_messages_no_wait()
self._close_messages_no_wait()
@property
def focus_chain(self) -> list[Widget]:
@@ -458,6 +458,14 @@ class App(Generic[ReturnType], DOMNode):
@property
def screen(self) -> Screen:
"""Get the current screen.
Raises:
ScreenStackError: If there are no screens on the stack.
Returns:
Screen: The currently active screen.
"""
try:
return self._screen_stack[-1]
except IndexError:
@@ -465,6 +473,11 @@ class App(Generic[ReturnType], DOMNode):
@property
def size(self) -> Size:
"""Get the size of the terminal.
Returns:
Size: SIze of the terminal
"""
return Size(*self.console.size)
def log(
@@ -640,9 +653,9 @@ class App(Generic[ReturnType], DOMNode):
"""Press some keys in the background."""
asyncio.create_task(press_keys())
await self.process_messages(ready_callback=press_keys_task)
await self._process_messages(ready_callback=press_keys_task)
else:
await self.process_messages()
await self._process_messages()
if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
# N.B. This doesn't work with Python<3.10, as we end up with 2 event loops:
@@ -971,7 +984,7 @@ class App(Generic[ReturnType], DOMNode):
]
self._exit_renderables.extend(pre_rendered)
self.close_messages_no_wait()
self._close_messages_no_wait()
def on_exception(self, error: Exception) -> None:
"""Called with an unhandled exception.
@@ -996,14 +1009,14 @@ class App(Generic[ReturnType], DOMNode):
self._exit_renderables.append(
Segments(self.console.render(traceback, self.console.options))
)
self.close_messages_no_wait()
self._close_messages_no_wait()
def _print_error_renderables(self) -> None:
for renderable in self._exit_renderables:
self.error_console.print(renderable)
self._exit_renderables.clear()
async def process_messages(
async def _process_messages(
self, ready_callback: CallbackType | None = None
) -> None:
self._set_active()
@@ -1038,11 +1051,11 @@ class App(Generic[ReturnType], DOMNode):
self.set_interval(0.5, self.css_monitor, name="css monitor")
self.log("[b green]STARTED[/]", self.css_monitor)
process_messages = super().process_messages
process_messages = super()._process_messages
async def run_process_messages():
mount_event = events.Mount(sender=self)
await self.dispatch_message(mount_event)
await self._dispatch_message(mount_event)
self.title = self._title
self.stylesheet.update(self)
@@ -1058,7 +1071,7 @@ class App(Generic[ReturnType], DOMNode):
self._running = True
try:
load_event = events.Load(sender=self)
await self.dispatch_message(load_event)
await self._dispatch_message(load_event)
driver: Driver
driver_class = cast(
@@ -1134,7 +1147,7 @@ class App(Generic[ReturnType], DOMNode):
self._registry.add(child)
child._attach(parent)
child._post_register(self)
child.start_messages()
child._start_messages()
return True
return False
@@ -1190,7 +1203,7 @@ class App(Generic[ReturnType], DOMNode):
widget (Widget): The Widget to start.
"""
widget._attach(parent)
widget.start_messages()
widget._start_messages()
widget.post_message_no_wait(events.Mount(sender=parent))
def is_mounted(self, widget: Widget) -> bool:
@@ -1199,14 +1212,14 @@ class App(Generic[ReturnType], DOMNode):
async def close_all(self) -> None:
while self._registry:
child = self._registry.pop()
await child.close_messages()
await child._close_messages()
async def shutdown(self):
await self._disconnect_devtools()
driver = self._driver
if driver is not None:
driver.disable_input()
await self.close_messages()
await self._close_messages()
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
self.screen.refresh(repaint=repaint, layout=layout)
@@ -1343,9 +1356,9 @@ class App(Generic[ReturnType], DOMNode):
action_target = default_namespace or self
action_name = target
await self.dispatch_action(action_target, action_name, params)
await self._dispatch_action(action_target, action_name, params)
async def dispatch_action(
async def _dispatch_action(
self, namespace: object, action_name: str, params: Any
) -> None:
log(
@@ -1362,7 +1375,7 @@ class App(Generic[ReturnType], DOMNode):
if callable(method):
await invoke(method, *params)
async def broker_event(
async def _broker_event(
self, event_name: str, event: events.Event, default_namespace: object | None
) -> bool:
"""Allow the app an opportunity to dispatch events to action system.
@@ -1411,7 +1424,7 @@ class App(Generic[ReturnType], DOMNode):
async def _on_shutdown_request(self, event: events.ShutdownRequest) -> None:
log("shutdown request")
await self.close_messages()
await self._close_messages()
async def _on_resize(self, event: events.Resize) -> None:
event.stop()
@@ -1428,7 +1441,7 @@ class App(Generic[ReturnType], DOMNode):
for child in remove_widgets:
self._unregister(child)
for child in remove_widgets:
await child.close_messages()
await child._close_messages()
async def action_press(self, key: str) -> None:
await self.press(key)

View File

@@ -464,8 +464,8 @@ if __name__ == "__main__":
app = App()
main_view = View(id="main")
help_view = View(id="help")
app.add_child(main_view)
app.add_child(help_view)
app._add_child(main_view)
app._add_child(help_view)
widget1 = Widget(id="widget1")
widget2 = Widget(id="widget2")
@@ -475,21 +475,21 @@ if __name__ == "__main__":
helpbar = Widget(id="helpbar")
helpbar.add_class("float")
main_view.add_child(widget1)
main_view.add_child(widget2)
main_view.add_child(sidebar)
main_view._add_child(widget1)
main_view._add_child(widget2)
main_view._add_child(sidebar)
sub_view = View(id="sub")
sub_view.add_class("-subview")
main_view.add_child(sub_view)
main_view._add_child(sub_view)
tooltip = Widget(id="tooltip")
tooltip.add_class("float", "transient")
sub_view.add_child(tooltip)
sub_view._add_child(tooltip)
help = Widget(id="markdown")
help_view.add_child(help)
help_view.add_child(helpbar)
help_view._add_child(help)
help_view._add_child(helpbar)
from rich import print

View File

@@ -29,7 +29,7 @@ from .css.parse import parse_declarations
from .css.styles import Styles, RenderStyles
from .css.query import NoMatchingNodesError
from .message_pump import MessagePump
from ._timer import Timer
from .timer import Timer
if TYPE_CHECKING:
from .app import App
@@ -515,7 +515,7 @@ class DOMNode(MessagePump):
node._set_dirty()
node._layout_required = True
def add_child(self, node: Widget) -> None:
def _add_child(self, node: Widget) -> None:
"""Add a new child node.
Args:
@@ -524,7 +524,7 @@ class DOMNode(MessagePump):
self.children._append(node)
node._attach(self)
def add_children(self, *nodes: Widget, **named_nodes: Widget) -> None:
def _add_children(self, *nodes: Widget, **named_nodes: Widget) -> None:
"""Add multiple children to this node.
Args:

View File

@@ -243,6 +243,6 @@ if __name__ == "__main__":
class MyApp(App):
async def on_mount(self, event: events.Mount) -> None:
self.set_timer(5, callback=self.close_messages)
self.set_timer(5, callback=self._close_messages)
MyApp.run()

View File

@@ -13,8 +13,8 @@ from .message import Message
MouseEventT = TypeVar("MouseEventT", bound="MouseEvent")
if TYPE_CHECKING:
from ._timer import Timer as TimerClass
from ._timer import TimerCallback
from .timer import Timer as TimerClass
from .timer import TimerCallback
from .widget import Widget

View File

@@ -24,7 +24,7 @@ class CenterLayout(Layout):
fraction_unit = Fraction(size.width)
for widget in children:
width, height, margin = widget.get_box_model(
width, height, margin = widget._get_box_model(
size, parent_size, fraction_unit
)
margin_width = width + margin.width

View File

@@ -33,7 +33,7 @@ class HorizontalLayout(Layout):
fraction_unit = Fraction(size.width, total_fraction or 1)
box_models = [
widget.get_box_model(size, parent_size, fraction_unit)
widget._get_box_model(size, parent_size, fraction_unit)
for widget in cast("list[Widget]", children)
]

View File

@@ -31,7 +31,7 @@ class VerticalLayout(Layout):
fraction_unit = Fraction(size.height, total_fraction or 1)
box_models = [
widget.get_box_model(size, parent_size, fraction_unit)
widget._get_box_model(size, parent_size, fraction_unit)
for widget in children
]

View File

@@ -1,3 +1,11 @@
"""
A message pump is a class that processes messages.
It is a base class for the App, Screen, and Widgets.
"""
from __future__ import annotations
import asyncio
@@ -10,7 +18,7 @@ from weakref import WeakSet
from . import events, log, messages
from ._callback import invoke
from ._context import NoActiveAppError, active_app
from ._timer import Timer, TimerCallback
from .timer import Timer, TimerCallback
from .case import camel_to_snake
from .events import Event
from .message import Message
@@ -81,6 +89,9 @@ class MessagePump(metaclass=MessagePumpMeta):
"""
Get the current app.
Returns:
App: The current app.
Raises:
NoActiveAppError: if no active app could be found for the current asyncio context
"""
@@ -90,14 +101,17 @@ class MessagePump(metaclass=MessagePumpMeta):
raise NoActiveAppError()
@property
def is_parent_active(self):
return self._parent and not self._parent._closed and not self._parent._closing
def is_parent_active(self) -> bool:
return bool(
self._parent and not self._parent._closed and not self._parent._closing
)
@property
def is_running(self) -> bool:
return self._running
def log(self, *args, **kwargs) -> None:
"""Write to logs or devtools."""
return self.app.log(*args, **kwargs, _textual_calling_frame=inspect.stack()[1])
def _attach(self, parent: MessagePump) -> None:
@@ -123,7 +137,7 @@ class MessagePump(metaclass=MessagePumpMeta):
"""Enable processing of messages types."""
self._disabled_messages.difference_update(messages)
async def get_message(self) -> Message:
async def _get_message(self) -> Message:
"""Get the next event on the queue, or None if queue is closed.
Returns:
@@ -142,7 +156,7 @@ class MessagePump(metaclass=MessagePumpMeta):
raise MessagePumpClosed("The message pump is now closed")
return message
def peek_message(self) -> Message | None:
def _peek_message(self) -> Message | None:
"""Peek the message at the head of the queue (does not remove it from the queue),
or return None if the queue is empty.
@@ -172,6 +186,17 @@ class MessagePump(metaclass=MessagePumpMeta):
name: str | None = None,
pause: bool = False,
) -> Timer:
"""Make a function call after a delay.
Args:
delay (float): Time to wait before invoking callback.
callback (TimerCallback | None, optional): Callback to call after time has expired.. Defaults to None.
name (str | None, optional): Name of the timer (for debug). Defaults to None.
pause (bool, optional): Start timer paused. Defaults to False.
Returns:
Timer: A timer object.
"""
timer = Timer(
self,
delay,
@@ -194,6 +219,18 @@ class MessagePump(metaclass=MessagePumpMeta):
repeat: int = 0,
pause: bool = False,
):
"""Call a function at periodic intervals.
Args:
interval (float): Time between calls.
callback (TimerCallback | None, optional): Function to call. Defaults to None.
name (str | None, optional): Name of the timer object. Defaults to None.
repeat (int, optional): Number of times to repeat the call or 0 for continuous. Defaults to 0.
pause (bool, optional): Start the timer paused. Defaults to False.
Returns:
Timer: A timer object.
"""
timer = Timer(
self,
interval,
@@ -209,7 +246,7 @@ class MessagePump(metaclass=MessagePumpMeta):
def call_later(self, callback: Callable, *args, **kwargs) -> None:
"""Schedule a callback to run after all messages are processed and the screen
has been refreshed.
has been refreshed. Positional and keyword arguments are passed to the callable.
Args:
callback (Callable): A callable.
@@ -219,15 +256,15 @@ class MessagePump(metaclass=MessagePumpMeta):
message = messages.InvokeLater(self, partial(callback, *args, **kwargs))
self.post_message_no_wait(message)
def on_invoke_later(self, message: messages.InvokeLater) -> None:
def _on_invoke_later(self, message: messages.InvokeLater) -> None:
# Forward InvokeLater message to the Screen
self.app.screen._invoke_later(message.callback)
def close_messages_no_wait(self) -> None:
def _close_messages_no_wait(self) -> None:
"""Request the message queue to exit."""
self._message_queue.put_nowait(None)
async def close_messages(self) -> None:
async def _close_messages(self) -> None:
"""Close message queue, and optionally wait for queue to finish processing."""
if self._closed or self._closing:
return
@@ -242,13 +279,14 @@ class MessagePump(metaclass=MessagePumpMeta):
# Ensure everything is closed before returning
await self._task
def start_messages(self) -> None:
self._task = asyncio.create_task(self.process_messages())
def _start_messages(self) -> None:
"""Start messages task."""
self._task = asyncio.create_task(self._process_messages())
async def process_messages(self) -> None:
async def _process_messages(self) -> None:
self._running = True
try:
await self._process_messages()
await self._process_messages_loop()
except CancelledError:
pass
finally:
@@ -256,14 +294,14 @@ class MessagePump(metaclass=MessagePumpMeta):
for timer in list(self._timers):
await timer.stop()
async def _process_messages(self) -> None:
async def _process_messages_loop(self) -> None:
"""Process messages until the queue is closed."""
_rich_traceback_guard = True
await Reactive.initialize_object(self)
while not self._closed:
try:
message = await self.get_message()
message = await self._get_message()
except MessagePumpClosed:
break
except CancelledError:
@@ -274,18 +312,18 @@ class MessagePump(metaclass=MessagePumpMeta):
# Combine any pending messages that may supersede this one
while not (self._closed or self._closing):
try:
pending = self.peek_message()
pending = self._peek_message()
except MessagePumpClosed:
break
if pending is None or not message.can_replace(pending):
break
try:
message = await self.get_message()
message = await self._get_message()
except MessagePumpClosed:
break
try:
await self.dispatch_message(message)
await self._dispatch_message(message)
except CancelledError:
raise
except Exception as error:
@@ -307,7 +345,7 @@ class MessagePump(metaclass=MessagePumpMeta):
log("CLOSED", self)
async def dispatch_message(self, message: Message) -> None:
async def _dispatch_message(self, message: Message) -> None:
"""Dispatch a message received from the message queue.
Args:
@@ -458,6 +496,14 @@ class MessagePump(metaclass=MessagePumpMeta):
return False
async def emit(self, message: Message) -> bool:
"""Send a message to the _parent_.
Args:
message (Message): A message object.
Returns:
bool: _True if the message was posted successfully.
"""
if self._parent:
return await self._parent._post_message_from_child(message)
else:

View File

@@ -9,7 +9,7 @@ from rich.style import Style
from . import errors, events, messages
from ._callback import invoke
from ._compositor import Compositor, MapGeometry
from ._timer import Timer
from .timer import Timer
from ._types import CallbackType
from .geometry import Offset, Region, Size
from .reactive import Reactive

View File

@@ -1,3 +1,10 @@
"""
Timer objects are created by [set_interval][textual.message_pump.MessagePump.set_interval] or
[set_interval][textual.message_pump.MessagePump.set_timer].
"""
from __future__ import annotations
import asyncio
@@ -26,6 +33,19 @@ class EventTargetGone(Exception):
@rich_repr
class Timer:
"""A class to send timer-based events.
Args:
event_target (MessageTarget): The object which will receive the timer events.
interval (float): The time between timer events.
sender (MessageTarget): The sender of the event.
name (str | None, optional): A name to assign the event (for debugging). Defaults to None.
callback (TimerCallback | None, optional): A optional callback to invoke when the event is handled. Defaults to None.
repeat (int | None, optional): The number of times to repeat the timer, or None for no repeat. Defaults to None.
skip (bool, optional): Enable skipping of scheduled events that couldn't be sent in time. Defaults to True.
pause (bool, optional): Start the timer paused. Defaults to False.
"""
_timer_count: int = 1
def __init__(
@@ -40,18 +60,6 @@ class Timer:
skip: bool = True,
pause: bool = False,
) -> None:
"""A class to send timer-based events.
Args:
event_target (MessageTarget): The object which will receive the timer events.
interval (float): The time between timer events.
sender (MessageTarget): The sender of the event.
name (str | None, optional): A name to assign the event (for debugging). Defaults to None.
callback (TimerCallback | None, optional): A optional callback to invoke when the event is handled. Defaults to None.
repeat (int | None, optional): The number of times to repeat the timer, or None for no repeat. Defaults to None.
skip (bool, optional): Enable skipping of scheduled events that couldn't be sent in time. Defaults to True.
pause (bool, optional): Start the timer paused. Defaults to False.
"""
self._target_repr = repr(event_target)
self._target = weakref.ref(event_target)
self._interval = interval
@@ -84,7 +92,7 @@ class Timer:
Returns:
Task: A Task instance for the timer.
"""
self._task = asyncio.create_task(self.run())
self._task = asyncio.create_task(self._run_timer())
return self._task
def stop_no_wait(self) -> None:
@@ -101,14 +109,18 @@ class Timer:
self._task = None
def pause(self) -> None:
"""Pause the timer."""
"""Pause the timer.
A paused timer will not send events until it is resumed.
"""
self._active.clear()
def resume(self) -> None:
"""Result a paused timer."""
"""Resume a paused timer."""
self._active.set()
async def run(self) -> None:
async def _run_timer(self) -> None:
"""Run the timer task."""
try:
await self._run()

View File

@@ -135,7 +135,7 @@ class Widget(DOMNode):
id=id,
classes=self.DEFAULT_CLASSES if classes is None else classes,
)
self.add_children(*children)
self._add_children(*children)
virtual_size = Reactive(Size(0, 0), layout=True)
auto_width = Reactive(True)
@@ -152,7 +152,11 @@ class Widget(DOMNode):
@property
def siblings(self) -> list[Widget]:
"""Get the widget's siblings (self is removed from the return list)."""
"""Get the widget's siblings (self is removed from the return list).
Returns:
list[Widget]: A list of siblings.
"""
parent = self.parent
if parent is not None:
siblings = list(parent.children)
@@ -163,14 +167,35 @@ class Widget(DOMNode):
@property
def allow_vertical_scroll(self) -> bool:
"""Check if vertical scroll is permitted."""
"""Check if vertical scroll is permitted.
May be overridden if you want different logic regarding allowing scrolling.
Returns:
bool: True if the widget may scroll _vertically_.
"""
return self.is_scrollable and self.show_vertical_scrollbar
@property
def allow_horizontal_scroll(self) -> bool:
"""Check if horizontal scroll is permitted."""
"""Check if horizontal scroll is permitted.
May be overridden if you want different logic regarding allowing scrolling.
Returns:
bool: True if the widget may scroll _horizontally_.
"""
return self.is_scrollable and self.show_horizontal_scrollbar
@property
def _allow_scroll(self) -> bool:
"""Check if both axis may be scrolled.
Returns:
bool: True if horizontal and vertical scrolling is enabled.
"""
return self.allow_horizontal_scroll and self.allow_vertical_scroll
def _arrange(self, size: Size) -> DockArrangeResult:
"""Arrange children.
@@ -193,6 +218,7 @@ class Widget(DOMNode):
return self._arrangement
def _clear_arrangement_cache(self) -> None:
"""Clear arrangement cache, forcing a new arrange operation."""
self._arrangement = None
def watch_show_horizontal_scrollbar(self, value: bool) -> None:
@@ -222,15 +248,30 @@ class Widget(DOMNode):
the keys will be set as the Widget's id.
Example:
```python
self.mount(Static("hello"), header=Header())
```
"""
self.app._register(self, *anon_widgets, **widgets)
self.screen.refresh(layout=True)
def compose(self) -> ComposeResult:
"""Yield child widgets for a container."""
"""Called by Textual to create child widgets.
Extend this to build a UI.
Example:
```python
def compose(self) -> ComposeResult:
yield Header()
yield Container(
TreeControl(), Viewer()
)
yield Footer()
```
"""
return
yield
@@ -246,7 +287,7 @@ class Widget(DOMNode):
css, path=path, is_default_css=True, tie_breaker=tie_breaker
)
def get_box_model(
def _get_box_model(
self, container: Size, viewport: Size, fraction_unit: Fraction
) -> BoxModel:
"""Process the box model for this widget.
@@ -254,6 +295,7 @@ class Widget(DOMNode):
Args:
container (Size): The size of the container widget (with a layout)
viewport (Size): The viewport size.
fraction_unit (Fraction): The unit used for `fr` units.
Returns:
BoxModel: The size and margin for this widget.
@@ -279,9 +321,9 @@ class Widget(DOMNode):
int: The optimal width of the content.
"""
if self.is_container:
assert self.layout is not None
assert self._layout is not None
return (
self.layout.get_content_width(self, container, viewport)
self._layout.get_content_width(self, container, viewport)
+ self.scrollbar_size_vertical
)
@@ -313,9 +355,9 @@ class Widget(DOMNode):
"""
if self.is_container:
assert self.layout is not None
assert self._layout is not None
height = (
self.layout.get_content_height(
self._layout.get_content_height(
self,
container,
viewport,
@@ -330,8 +372,8 @@ class Widget(DOMNode):
return self._content_height_cache[1]
renderable = self.render()
options = self.console.options.update_width(width).update(highlight=False)
segments = self.console.render(renderable, options)
options = self._console.options.update_width(width).update(highlight=False)
segments = self._console.render(renderable, options)
# Cheaper than counting the lines returned from render_lines!
height = sum(text.count("\n") for text, _, _ in segments)
self._content_height_cache = (cache_key, height)
@@ -611,7 +653,12 @@ class Widget(DOMNode):
@property
def focusable_children(self) -> list[Widget]:
"""Get the children which may be focused."""
"""Get the children which may be focused.
Returns:
list[Widget]: List of widgets that can receive focus.
"""
focusable = [
child for child in self.children if child.display and child.visible
]
@@ -619,12 +666,18 @@ class Widget(DOMNode):
@property
def _focus_sort_key(self) -> tuple[int, int]:
"""Key function to sort widgets in to tfocus order."""
x, y, _, _ = self.virtual_region
top, _, _, left = self.styles.margin
return y - top, x - left
@property
def scroll_offset(self) -> Offset:
"""Get the current scroll offset.
Returns:
Offset: Offset a container has been scrolled by.
"""
return Offset(int(self.scroll_x), int(self.scroll_y))
@property
@@ -637,7 +690,7 @@ class Widget(DOMNode):
return self.is_scrollable and self.styles.background.is_transparent
@property
def console(self) -> Console:
def _console(self) -> Console:
"""Get the current console.
Returns:
@@ -648,14 +701,29 @@ class Widget(DOMNode):
@property
def animate(self) -> BoundAnimator:
"""Get an animator to animate attributes on this widget.
Example:
```python
self.animate("brightness", 0.5)
```
Returns:
BoundAnimator: An animator bound to this widget.
"""
if self._animate is None:
self._animate = self.app.animator.bind(self)
assert self._animate is not None
return self._animate
@property
def layout(self) -> Layout:
"""Get the layout object if set in styles, or a default layout."""
def _layout(self) -> Layout:
"""Get the layout object if set in styles, or a default layout.
Returns:
Layout: A layout object.
"""
return self.styles.layout or self._default_layout
@property
@@ -745,9 +813,11 @@ class Widget(DOMNode):
"""Scroll to a given (absolute) coordinate, optionally animating.
Args:
x (int | None, optional): X coordinate (column) to scroll to, or ``None`` for no change. Defaults to None.
y (int | None, optional): Y coordinate (row) to scroll to, or ``None`` for no change. Defaults to None.
animate (bool, optional): Animate to new scroll position. Defaults to False.
x (int | None, optional): X coordinate (column) to scroll to, or None for no change. Defaults to None.
y (int | None, optional): Y coordinate (row) to scroll to, or None for no change. Defaults to None.
animate (bool, optional): Animate to new scroll position. Defaults to True.
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
duration (float | None, optional): Duration of animation, if animate is True and speed is False.
Returns:
bool: True if the scroll position changed, otherwise False.
@@ -809,6 +879,8 @@ class Widget(DOMNode):
x (int | None, optional): X distance (columns) to scroll, or ``None`` for no change. Defaults to None.
y (int | None, optional): Y distance (rows) to scroll, or ``None`` for no change. Defaults to None.
animate (bool, optional): Animate to new scroll position. Defaults to False.
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
duration (float | None, optional): Duration of animation, if animate is True and speed is False.
Returns:
bool: True if the scroll position changed, otherwise False.
@@ -822,34 +894,114 @@ class Widget(DOMNode):
)
def scroll_home(self, *, animate: bool = True) -> bool:
"""Scroll to home position.
Args:
animate (bool, optional): Animate scroll. Defaults to True.
Returns:
bool: True if any scrolling was done.
"""
return self.scroll_to(0, 0, animate=animate, duration=1)
def scroll_end(self, *, animate: bool = True) -> bool:
"""Scroll to the end of the container.
Args:
animate (bool, optional): Animate scroll. Defaults to True.
Returns:
bool: True if any scrolling was done.
"""
return self.scroll_to(0, self.max_scroll_y, animate=animate, duration=1)
def scroll_left(self, *, animate: bool = True) -> bool:
"""Scroll one cell left.
Args:
animate (bool, optional): Animate scroll. Defaults to True.
Returns:
bool: True if any scrolling was done.
"""
return self.scroll_to(x=self.scroll_target_x - 1, animate=animate)
def scroll_right(self, *, animate: bool = True) -> bool:
"""Scroll on cell right.
Args:
animate (bool, optional): Animate scroll. Defaults to True.
Returns:
bool: True if any scrolling was done.
"""
return self.scroll_to(x=self.scroll_target_x + 1, animate=animate)
def scroll_up(self, *, animate: bool = True) -> bool:
def scroll_down(self, *, animate: bool = True) -> bool:
"""Scroll one line down.
Args:
animate (bool, optional): Animate scroll. Defaults to True.
Returns:
bool: True if any scrolling was done.
"""
return self.scroll_to(y=self.scroll_target_y + 1, animate=animate)
def scroll_down(self, *, animate: bool = True) -> bool:
def scroll_up(self, *, animate: bool = True) -> bool:
"""Scroll one line up.
Args:
animate (bool, optional): Animate scroll. Defaults to True.
Returns:
bool: True if any scrolling was done.
"""
return self.scroll_to(y=self.scroll_target_y - 1, animate=animate)
def scroll_page_up(self, *, animate: bool = True) -> bool:
"""Scroll one page up.
Args:
animate (bool, optional): Animate scroll. Defaults to True.
Returns:
bool: True if any scrolling was done.
"""
return self.scroll_to(
y=self.scroll_target_y - self.container_size.height, animate=animate
)
def scroll_page_down(self, *, animate: bool = True) -> bool:
"""Scroll one page down.
Args:
animate (bool, optional): Animate scroll. Defaults to True.
Returns:
bool: True if any scrolling was done.
"""
return self.scroll_to(
y=self.scroll_target_y + self.container_size.height, animate=animate
)
def scroll_page_left(self, *, animate: bool = True) -> bool:
"""Scroll one page left.
Args:
animate (bool, optional): Animate scroll. Defaults to True.
Returns:
bool: True if any scrolling was done.
"""
return self.scroll_to(
x=self.scroll_target_x - self.container_size.width,
animate=animate,
@@ -857,6 +1009,15 @@ class Widget(DOMNode):
)
def scroll_page_right(self, *, animate: bool = True) -> bool:
"""Scroll one page right.
Args:
animate (bool, optional): Animate scroll. Defaults to True.
Returns:
bool: True if any scrolling was done.
"""
return self.scroll_to(
x=self.scroll_target_x + self.container_size.width,
animate=animate,
@@ -1031,7 +1192,12 @@ class Widget(DOMNode):
yield self.horizontal_scrollbar, scrollbar_region
def get_pseudo_classes(self) -> Iterable[str]:
"""Pseudo classes for a widget"""
"""Pseudo classes for a widget.
Returns:
Iterable[str]: Names of the pseudo classes.
"""
if self.mouse_over:
yield "hover"
if self.has_focus:
@@ -1039,9 +1205,6 @@ class Widget(DOMNode):
if self.descendant_has_focus:
yield "focus-within"
def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None:
watch(self, attribute_name, callback)
def post_render(self, renderable: RenderableType) -> RenderableType:
"""Applies style attributes to the default renderable.
@@ -1098,11 +1261,11 @@ class Widget(DOMNode):
width, height = self.size
renderable = self.render()
renderable = self.post_render(renderable)
options = self.console.options.update_dimensions(width, height).update(
options = self._console.options.update_dimensions(width, height).update(
highlight=False
)
segments = self.console.render(renderable, options)
segments = self._console.render(renderable, options)
lines = list(
islice(
Segment.split_and_crop_lines(
@@ -1155,6 +1318,15 @@ class Widget(DOMNode):
return lines
def get_style_at(self, x: int, y: int) -> Style:
"""Get the Rich style at a given screen offset.
Args:
x (int): X coordinate relative to the screen.
y (int): Y coordinate relative to the screen.
Returns:
Style: A rich Style object.
"""
offset_x, offset_y = self.screen.get_offset(self)
return self.screen.get_style_at(x + offset_x, y + offset_y)
@@ -1170,7 +1342,16 @@ class Widget(DOMNode):
This method sets an internal flag to perform a refresh, which will be done on the
next idle event. Only one refresh will be done even if this method is called multiple times.
By default this method will cause the content of the widget to refresh, but not change its size. You can also
set `layout=True` to perform a layout.
!!! warning
It is rarely necessary to call this method explicitly. Updating styles or reactive attributes will
do this automatically.
Args:
*regions (Region, optional): Additional screen regions to mark as dirty.
repaint (bool, optional): Repaint the widget (will call render() again). Defaults to True.
layout (bool, optional): Also layout widgets in the view. Defaults to False.
"""
@@ -1257,7 +1438,7 @@ class Widget(DOMNode):
self.app.capture_mouse(None)
async def broker_event(self, event_name: str, event: events.Event) -> bool:
return await self.app.broker_event(event_name, event, default_namespace=self)
return await self.app._broker_event(event_name, event, default_namespace=self)
async def _on_mouse_down(self, event: events.MouseUp) -> None:
await self.broker_event("mouse.down", event)
@@ -1322,27 +1503,27 @@ class Widget(DOMNode):
event.stop()
def _on_scroll_to(self, message: ScrollTo) -> None:
if self.is_scrollable:
if self._allow_scroll:
self.scroll_to(message.x, message.y, animate=message.animate, duration=0.1)
message.stop()
def _on_scroll_up(self, event: ScrollUp) -> None:
if self.is_scrollable:
if self.allow_vertical_scroll:
self.scroll_page_up()
event.stop()
def _on_scroll_down(self, event: ScrollDown) -> None:
if self.is_scrollable:
if self.allow_vertical_scroll:
self.scroll_page_down()
event.stop()
def _on_scroll_left(self, event: ScrollLeft) -> None:
if self.is_scrollable:
if self.allow_horizontal_scroll:
self.scroll_page_left()
event.stop()
def _on_scroll_right(self, event: ScrollRight) -> None:
if self.is_scrollable:
if self.allow_horizontal_scroll:
self.scroll_page_right()
event.stop()
@@ -1351,49 +1532,49 @@ class Widget(DOMNode):
self.app._reset_focus(self)
def key_home(self) -> bool:
if self.is_scrollable:
if self._allow_scroll:
self.scroll_home()
return True
return False
def key_end(self) -> bool:
if self.is_scrollable:
if self._allow_scroll:
self.scroll_end()
return True
return False
def key_left(self) -> bool:
if self.is_scrollable:
if self.allow_horizontal_scroll:
self.scroll_left()
return True
return False
def key_right(self) -> bool:
if self.is_scrollable:
if self.allow_horizontal_scroll:
self.scroll_right()
return True
return False
def key_down(self) -> bool:
if self.is_scrollable:
self.scroll_up()
return True
return False
def key_up(self) -> bool:
if self.is_scrollable:
if self.allow_vertical_scroll:
self.scroll_down()
return True
return False
def key_up(self) -> bool:
if self.allow_vertical_scroll:
self.scroll_up()
return True
return False
def key_pagedown(self) -> bool:
if self.is_scrollable:
if self.allow_vertical_scroll:
self.scroll_page_down()
return True
return False
def key_pageup(self) -> bool:
if self.is_scrollable:
if self.allow_vertical_scroll:
self.scroll_page_up()
return True
return False

View File

@@ -9,7 +9,7 @@ from rich.text import Text
from textual import events, _clock
from textual._text_backend import TextEditorBackend
from textual._timer import Timer
from textual.timer import Timer
from textual._types import MessageTarget
from textual.app import ComposeResult
from textual.geometry import Size, clamp

View File

@@ -268,7 +268,7 @@ async def test_scrollbar_gutter(
text_widget = TextWidget()
text_widget.styles.height = "auto"
container.add_child(text_widget)
container._add_child(text_widget)
class MyTestApp(AppTest):
def compose(self) -> ComposeResult:

View File

@@ -21,7 +21,7 @@ def test_nodes_take_display_property_into_account_when_they_display_their_childr
screen = Screen()
screen.styles.layout = layout
screen.add_child(widget)
screen._add_child(widget)
displayed_children = screen.displayed_children
assert isinstance(displayed_children, list)

View File

@@ -32,10 +32,10 @@ def parent():
child1 = DOMNode(id="child1")
child2 = DOMNode(id="child2")
grandchild1 = DOMNode(id="grandchild1")
child1.add_child(grandchild1)
child1._add_child(grandchild1)
parent.add_child(child1)
parent.add_child(child2)
parent._add_child(child1)
parent._add_child(child2)
yield parent

View File

@@ -20,7 +20,7 @@ async def test_focus_chain():
# Check empty focus chain
assert not app.focus_chain
app.screen.add_children(
app.screen._add_children(
Focusable(id="foo"),
NonFocusable(id="bar"),
Focusable(Focusable(id="Paul"), id="container1"),
@@ -37,7 +37,7 @@ async def test_focus_next_and_previous():
app = App()
app._set_active()
app.push_screen(Screen())
app.screen.add_children(
app.screen._add_children(
Focusable(id="foo"),
NonFocusable(id="bar"),
Focusable(Focusable(id="Paul"), id="container1"),

View File

@@ -11,8 +11,8 @@ def test_query():
app = App()
main_view = View(id="main")
help_view = View(id="help")
app.add_child(main_view)
app.add_child(help_view)
app._add_child(main_view)
app._add_child(help_view)
widget1 = Widget(id="widget1")
widget2 = Widget(id="widget2")
@@ -22,21 +22,21 @@ def test_query():
helpbar = Widget(id="helpbar")
helpbar.add_class("float")
main_view.add_child(widget1)
main_view.add_child(widget2)
main_view.add_child(sidebar)
main_view._add_child(widget1)
main_view._add_child(widget2)
main_view._add_child(sidebar)
sub_view = View(id="sub")
sub_view.add_class("-subview")
main_view.add_child(sub_view)
main_view._add_child(sub_view)
tooltip = Widget(id="tooltip")
tooltip.add_class("float", "transient")
sub_view.add_child(tooltip)
sub_view._add_child(tooltip)
help = Widget(id="markdown")
help_view.add_child(help)
help_view.add_child(helpbar)
help_view._add_child(help)
help_view._add_child(helpbar)
# repeat tests to account for caching
for repeat in range(3):

View File

@@ -70,7 +70,7 @@ class AppTest(App):
waiting_duration_after_yield: float = 0,
) -> AsyncContextManager[ClockMock]:
async def run_app() -> None:
await self.process_messages()
await self._process_messages()
@contextlib.asynccontextmanager
async def get_running_state_context_manager():